From a1df60639f6ff443be137d56acfdf65b7bc576a0 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Wed, 22 Oct 2025 21:10:39 +0200 Subject: [PATCH 01/34] apply changes --- src/fleetmaster/core/engine.py | 136 ++++++++++++++++++------------- src/fleetmaster/core/settings.py | 3 + 2 files changed, 84 insertions(+), 55 deletions(-) diff --git a/src/fleetmaster/core/engine.py b/src/fleetmaster/core/engine.py index 0e4b7f2..4df87a1 100644 --- a/src/fleetmaster/core/engine.py +++ b/src/fleetmaster/core/engine.py @@ -103,43 +103,65 @@ 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_capytaine_body( + stl_file: str, + lid: bool, + grid_symmetry: bool, + add_centre_of_mass: bool = False, + translation_x: float = 0.0, + translation_y: float = 0.0, + translation_z: float = 0.0, +) -> tuple[Any, trimesh.Trimesh]: """ Load an STL file and configure a Capytaine FloatingBody object. """ hull_mesh = cpt.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}") + hull_mesh = hull_mesh.transform(translation_vector=translation_vector) + 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, name=f"{Path(stl_file).stem}_mesh") + cog = None if add_centre_of_mass: full_mesh = trimesh.load_mesh(stl_file) + if translation_x != 0.0 or translation_y != 0.0 or translation_z != 0.0: + transform_matrix = trimesh.transformations.translation_matrix([translation_x, translation_y, translation_z]) + full_mesh.apply_transform(transform_matrix) cog = full_mesh.center_mass logger.debug(f"Adding COG {cog}") - else: - cog = None 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 + # Extract the final mesh that Capytaine will use, after all transformations and immersion. + final_mesh_trimesh = trimesh.Trimesh(vertices=boat.mesh.vertices, faces=boat.mesh.faces) + + return boat, final_mesh_trimesh -def add_mesh_to_database(output_file: Path, stl_file: str, overwrite: bool = False) -> None: + +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,26 +187,26 @@ 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") + # Store the binary content of the final, transformed STL group.create_dataset("stl_content", data=memoryview(new_stl_content)) logger.debug(" - Wrote dataset: stl_content") @@ -198,7 +220,7 @@ def _format_value_for_name(value: float) -> str: return f"{value:.1f}" -def _generate_case_group_name(mesh_name: str, water_depth: float, water_level: float, forward_speed: float) -> str: +def _generate_case_group_name(mesh_name: str, water_depth: float, water_level: float, forward_speed: float) -> str: # noqa: E501 """Generates a descriptive group name for a specific simulation case.""" wd = _format_value_for_name(water_depth) wl = _format_value_for_name(water_level) @@ -206,7 +228,7 @@ 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 +238,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 +253,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 +265,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 +283,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 +303,25 @@ 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 + boat, final_mesh = _prepare_capytaine_body( + stl_file, + lid=lid, + grid_symmetry=grid_symmetry, + add_center_of_mass=add_center_of_mass, + translation_x=translation_x, + translation_y=translation_y, + translation_z=translation_z, ) + # 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 +402,28 @@ 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}") - 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) + # Create a copy of the settings to modify for this specific draft + draft_settings = settings.model_copy(deep=True) - 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" + # Combine the draft with the existing z-translation + # A positive draft means sinking the vessel, so we subtract it. + draft_settings.translation_z -= draft - 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/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 From 22973cdf01f1312f345859e095dc89252a04140c Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Wed, 22 Oct 2025 21:21:32 +0200 Subject: [PATCH 02/34] store hull mesh --- src/fleetmaster/core/engine.py | 4 ++-- tests/test_engine.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/fleetmaster/core/engine.py b/src/fleetmaster/core/engine.py index 4df87a1..89f983d 100644 --- a/src/fleetmaster/core/engine.py +++ b/src/fleetmaster/core/engine.py @@ -107,7 +107,7 @@ def _prepare_capytaine_body( stl_file: str, lid: bool, grid_symmetry: bool, - add_centre_of_mass: bool = False, + add_center_of_mass: bool = False, translation_x: float = 0.0, translation_y: float = 0.0, translation_z: float = 0.0, @@ -130,7 +130,7 @@ def _prepare_capytaine_body( hull_mesh = cpt.ReflectionSymmetricMesh(hull_mesh, plane=cpt.xOz_Plane, name=f"{Path(stl_file).stem}_mesh") cog = None - if add_centre_of_mass: + if add_center_of_mass: full_mesh = trimesh.load_mesh(stl_file) if translation_x != 0.0 or translation_y != 0.0 or translation_z != 0.0: transform_matrix = trimesh.transformations.translation_matrix([translation_x, translation_y, translation_z]) diff --git a/tests/test_engine.py b/tests/test_engine.py index 9422774..6d9a009 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -115,8 +115,8 @@ def test_prepare_capytaine_body(mock_trimesh, mock_cpt): 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) + stl_file = "test.stl" # noqa: S108 + body, _ = _prepare_capytaine_body(stl_file, lid=True, grid_symmetry=True, add_center_of_mass=True) mock_cpt.load_mesh.assert_called_once_with(stl_file) mock_hull_mesh.generate_lid.assert_called_once() From 08b8602404db6e70f32f20b58b5916cf0a4ee305 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Wed, 22 Oct 2025 21:45:37 +0200 Subject: [PATCH 03/34] added list --- src/fleetmaster/cli.py | 10 +- src/fleetmaster/commands/__init__.py | 4 +- src/fleetmaster/commands/list.py | 41 ++++++ src/fleetmaster/commands/view.py | 154 ++++++++++++++++++++++ src/fleetmaster/core/visualize_db_mesh.py | 133 +++++++++++++++++++ 5 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 src/fleetmaster/commands/list.py create mode 100644 src/fleetmaster/commands/view.py create mode 100644 src/fleetmaster/core/visualize_db_mesh.py 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..ec41d5a 100644 --- a/src/fleetmaster/commands/__init__.py +++ b/src/fleetmaster/commands/__init__.py @@ -1,4 +1,6 @@ from .gui import gui from .run import run +from .view import view +from .list import list_command -__all__ = ["gui", "run"] +__all__ = ["gui", "run", "view", "list_command"] diff --git a/src/fleetmaster/commands/list.py b/src/fleetmaster/commands/list.py new file mode 100644 index 0000000..bbacd82 --- /dev/null +++ b/src/fleetmaster/commands/list.py @@ -0,0 +1,41 @@ +"""CLI command for listing meshes from HDF5 databases.""" + +import h5py +from pathlib import Path + +import click + + +def list_meshes_in_db(hdf5_paths: list[str]): + """ + Lists all available meshes in one or more HDF5 database files. + """ + for hdf5_path in hdf5_paths: + db_file = Path(hdf5_path) + if not db_file.exists(): + click.echo(f"❌ Error: Database file '{hdf5_path}' not found.", err=True) + continue + + click.echo(f"\nAvailable meshes in '{hdf5_path}':") + try: + with h5py.File(db_file, "r") as f: + meshes_group = f.get("meshes") + if meshes_group: + available_meshes = list(meshes_group.keys()) + if available_meshes: + for name in available_meshes: + click.echo(f" - {name}") + else: + click.echo(" No meshes found.") + else: + click.echo(" No 'meshes' group found.") + except Exception as e: + click.echo(f"❌ Error reading '{hdf5_path}': {e}", err=True) + + +@click.command(name="list", help="List all meshes available in one or more HDF5 database files.") +@click.option("--file", "-f", "hdf5_files", multiple=True, default=["results.hdf5"], + help="Path to one or more HDF5 database files. Can be specified multiple times.") +def list_command(hdf5_files: tuple[str, ...]): + """CLI command to list meshes.""" + list_meshes_in_db(list(hdf5_files)) \ No newline at end of file diff --git a/src/fleetmaster/commands/view.py b/src/fleetmaster/commands/view.py new file mode 100644 index 0000000..e56fb6d --- /dev/null +++ b/src/fleetmaster/commands/view.py @@ -0,0 +1,154 @@ +"""CLI command for visualizing meshes from the HDF5 database.""" + +import h5py +import io +from pathlib import Path + +import click +import numpy as np +import trimesh + +# 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 + import numpy as np # numpy is needed for vtk conversion +except ImportError: + VTK_AVAILABLE = False + + +def show_with_trimesh(mesh: trimesh.Trimesh): + """Visualizes the mesh using the built-in trimesh viewer.""" + click.echo("🎨 Displaying mesh with trimesh viewer. Close the window to continue.") + mesh.show() + + +def show_with_vtk(mesh: trimesh.Trimesh): + """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("🎨 Displaying mesh with VTK viewer. Close the window to continue.") + + # 1. Convert trimesh data to VTK format + # Get vertices and faces + vertices = mesh.vertices + # VTK requires a specific format for faces: [num_points, p1_idx, p2_idx, p3_idx, ...] + faces = np.hstack((np.full((len(mesh.faces), 1), 3), mesh.faces)).flatten() + + # Create vtkPoints for the vertices + vtk_points = vtk.vtkPoints() + vtk_points.SetData(numpy_to_vtk(vertices, deep=True)) + + # Create vtkCellArray for the faces + vtk_cells = vtk.vtkCellArray() + vtk_cells.SetCells(len(mesh.faces), numpy_to_vtk(faces, deep=True, array_type=vtk.VTK_ID_TYPE)) + + # 2. Create the vtkPolyData (the actual geometry) + poly_data = vtk.vtkPolyData() + poly_data.SetPoints(vtk_points) + poly_data.SetPolys(vtk_cells) + + # 3. Set up the visualization pipeline + # Mapper: Connects the geometry to the graphics hardware + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputData(poly_data) + + # Actor: Represents the object in the scene (position, color, etc.) + actor = vtk.vtkActor() + actor.SetMapper(mapper) + actor.GetProperty().SetColor(0.8, 0.8, 1.0) # Light blue + actor.GetProperty().EdgeVisibilityOn() + actor.GetProperty().SetEdgeColor(0.1, 0.1, 0.2) + + # Renderer: Manages the scene, camera, and lighting + renderer = vtk.vtkRenderer() + renderer.AddActor(actor) + renderer.SetBackground(0.1, 0.2, 0.3) # Dark blue/gray + + # Add an axes actor for context + axes = vtk.vtkAxesActor() + widget = vtk.vtkOrientationMarkerWidget() + 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() + + +def visualize_mesh_from_db(hdf5_paths: list[str], mesh_name: str, use_vtk: bool): + """Loads a specific mesh from the HDF5 database and visualizes it.""" + found_mesh_data = None + found_in_file = None + + for hdf5_path in hdf5_paths: + db_file = Path(hdf5_path) + if not db_file.exists(): + click.echo(f"❌ Warning: Database file '{hdf5_path}' not found. Skipping.", err=True) + continue + + mesh_group_path = f"meshes/{mesh_name}" + try: + with h5py.File(db_file, "r") as f: + if mesh_group_path in f: + click.echo(f"📦 Loading mesh '{mesh_name}' from '{hdf5_path}'...") + found_mesh_data = f[mesh_group_path]["stl_content"][()] + found_in_file = hdf5_path + break # Found the mesh, no need to check other files + except Exception as e: + click.echo(f"❌ Error reading '{hdf5_path}': {e}", err=True) + continue + + if found_mesh_data is None: + click.echo(f"❌ Error: Mesh '{mesh_name}' not found in any of the specified HDF5 files.", err=True) + click.echo("Use 'fleetmaster list --file ' to see available meshes.", err=True) + return + + # If we found the mesh, proceed with visualization + stl_binary_content = found_mesh_data + + stl_file_in_memory = io.BytesIO(stl_binary_content) + mesh = trimesh.load_mesh(stl_file_in_memory, file_type="stl") + + if use_vtk: + show_with_vtk(mesh) + else: + show_with_trimesh(mesh) + + +@click.command(name="view", help="Visualize a specific mesh from one or more HDF5 database files.") +@click.argument("mesh_name") +@click.option("--file", "-f", "hdf5_files", multiple=True, default=["results.hdf5"], + help="Path to one or more HDF5 database files. Can be specified multiple times.") +@click.option("--vtk", is_flag=True, help="Use the VTK viewer instead of the default trimesh viewer.") +def view(mesh_name: str, hdf5_files: tuple[str, ...], vtk: bool): + """ + CLI command to load and visualize a specific mesh from the HDF5 database. + + MESH_NAME: The name of the mesh to visualize (e.g., 'barge_draft_1.0'). + """ + if not mesh_name: + click.echo("❌ Error: Please provide a MESH_NAME to visualize.", err=True) + click.echo("Use 'fleetmaster list --file ' to see available meshes.", err=True) + return + + visualize_mesh_from_db(list(hdf5_files), mesh_name, vtk) \ No newline at end of file diff --git a/src/fleetmaster/core/visualize_db_mesh.py b/src/fleetmaster/core/visualize_db_mesh.py new file mode 100644 index 0000000..a2e202d --- /dev/null +++ b/src/fleetmaster/core/visualize_db_mesh.py @@ -0,0 +1,133 @@ + +import h5py +import trimesh +import io +import sys +import argparse +from pathlib import Path + +# Try to import vtk, but make it an optional dependency +try: + import vtk + from vtk.util.numpy_support import vtk_to_numpy, numpy_to_vtk + VTK_AVAILABLE = True +except ImportError: + VTK_AVAILABLE = False + +def show_with_trimesh(mesh: trimesh.Trimesh): + """Visualizes the mesh using the built-in trimesh viewer.""" + print("🎨 Displaying mesh with trimesh viewer. Close the window to continue.") + mesh.show() + +def show_with_vtk(mesh: trimesh.Trimesh): + """Visualizes the mesh using a VTK pipeline.""" + if not VTK_AVAILABLE: + print("❌ Error: The 'vtk' library is not installed. Please install it with 'pip install vtk'.") + return + + print("🎨 Displaying mesh with VTK viewer. Close the window to continue.") + + # 1. Convert trimesh data to VTK format + # Get vertices and faces + vertices = mesh.vertices + # VTK requires a specific format for faces: [num_points, p1_idx, p2_idx, p3_idx, ...] + faces = np.hstack((np.full((len(mesh.faces), 1), 3), mesh.faces)).flatten() + + # Create vtkPoints for the vertices + vtk_points = vtk.vtkPoints() + vtk_points.SetData(numpy_to_vtk(vertices, deep=True)) + + # Create vtkCellArray for the faces + vtk_cells = vtk.vtkCellArray() + vtk_cells.SetCells(len(mesh.faces), numpy_to_vtk(faces, deep=True, array_type=vtk.VTK_ID_TYPE)) + + # 2. Create the vtkPolyData (the actual geometry) + poly_data = vtk.vtkPolyData() + poly_data.SetPoints(vtk_points) + poly_data.SetPolys(vtk_cells) + + # 3. Set up the visualization pipeline + # Mapper: Connects the geometry to the graphics hardware + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputData(poly_data) + + # Actor: Represents the object in the scene (position, color, etc.) + actor = vtk.vtkActor() + actor.SetMapper(mapper) + actor.GetProperty().SetColor(0.8, 0.8, 1.0) # Light blue + actor.GetProperty().EdgeVisibilityOn() + actor.GetProperty().SetEdgeColor(0.1, 0.1, 0.2) + + # Renderer: Manages the scene, camera, and lighting + renderer = vtk.vtkRenderer() + renderer.AddActor(actor) + renderer.SetBackground(0.1, 0.2, 0.3) # Dark blue/gray + + # Add an axes actor for context + axes = vtk.vtkAxesActor() + widget = vtk.vtkOrientationMarkerWidget() + 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() + + +def visualize_mesh_from_db(hdf5_path: str, mesh_name: str, use_vtk: bool): + """ + Loads a specific mesh from the HDF5 database and visualizes it. + """ + db_file = Path(hdf5_path) + if not db_file.exists(): + print(f"❌ Error: Database file '{hdf5_path}' not found.") + return + + mesh_group_path = f"meshes/{mesh_name}" + + with h5py.File(db_file, 'r') as f: + if mesh_group_path not in f: + print(f"❌ Error: Mesh '{mesh_name}' not found in {hdf5_path}.") + available_meshes = list(f.get('meshes', {}).keys()) + if available_meshes: + print("\nAvailable meshes are:") + for name in available_meshes: + print(f" - {name}") + return + + print(f"📦 Loading mesh '{mesh_name}' from the database...") + stl_binary_content = f[mesh_group_path]['stl_content'][()] + + stl_file_in_memory = io.BytesIO(stl_binary_content) + mesh = trimesh.load_mesh(stl_file_in_memory, file_type='stl') + + if use_vtk: + show_with_vtk(mesh) + else: + show_with_trimesh(mesh) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description="Visualize a mesh from the Fleetmaster HDF5 database.") + parser.add_argument("mesh_name", help="The name of the mesh to visualize (e.g., 'barge_draft_1.0').") + parser.add_argument("--file", default="results.hdf5", help="Path to the HDF5 database file.") + parser.add_argument("--vtk", action="store_true", help="Use the VTK viewer instead of the default trimesh viewer.") + + args = parser.parse_args() + + visualize_mesh_from_db(args.file, args.mesh_name, args.vtk) From fd240c69c50b9a608b74deb925872816e383cc39 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Wed, 22 Oct 2025 21:47:50 +0200 Subject: [PATCH 04/34] list works --- src/fleetmaster/commands/list.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/fleetmaster/commands/list.py b/src/fleetmaster/commands/list.py index bbacd82..b9ba9da 100644 --- a/src/fleetmaster/commands/list.py +++ b/src/fleetmaster/commands/list.py @@ -34,8 +34,18 @@ def list_meshes_in_db(hdf5_paths: list[str]): @click.command(name="list", help="List all meshes available in one or more HDF5 database files.") -@click.option("--file", "-f", "hdf5_files", multiple=True, default=["results.hdf5"], +@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.") -def list_command(hdf5_files: tuple[str, ...]): +def list_command(files: tuple[str, ...], option_files: tuple[str, ...]): """CLI command to list meshes.""" - list_meshes_in_db(list(hdf5_files)) \ No newline at end of file + # Combine positional arguments and optional --file arguments + all_files = set(files) | set(option_files) + + # If no files are provided at all, use the default. + if not all_files: + final_files = ["results.hdf5"] + else: + final_files = list(all_files) + + list_meshes_in_db(final_files) \ No newline at end of file From 7f98e233552507273b993a3ff5690a4478c94ed5 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Wed, 22 Oct 2025 22:02:12 +0200 Subject: [PATCH 05/34] added view with axis --- pyproject.toml | 1 + src/fleetmaster/commands/view.py | 207 +++++++++++++++++++------------ uv.lock | 11 ++ 3 files changed, 138 insertions(+), 81 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6194c9e..cce0b3a 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", diff --git a/src/fleetmaster/commands/view.py b/src/fleetmaster/commands/view.py index e56fb6d..208c301 100644 --- a/src/fleetmaster/commands/view.py +++ b/src/fleetmaster/commands/view.py @@ -14,7 +14,7 @@ from vtk.util.numpy_support import numpy_to_vtk VTK_AVAILABLE = True - import numpy as np # numpy is needed for vtk conversion + # The global import of numpy is sufficient. except ImportError: VTK_AVAILABLE = False @@ -25,53 +25,53 @@ def show_with_trimesh(mesh: trimesh.Trimesh): mesh.show() -def show_with_vtk(mesh: trimesh.Trimesh): +def show_with_vtk(meshes: list[trimesh.Trimesh], mode: str): """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("🎨 Displaying mesh with VTK viewer. Close the window to continue.") - - # 1. Convert trimesh data to VTK format - # Get vertices and faces - vertices = mesh.vertices - # VTK requires a specific format for faces: [num_points, p1_idx, p2_idx, p3_idx, ...] - faces = np.hstack((np.full((len(mesh.faces), 1), 3), mesh.faces)).flatten() - - # Create vtkPoints for the vertices - vtk_points = vtk.vtkPoints() - vtk_points.SetData(numpy_to_vtk(vertices, deep=True)) - - # Create vtkCellArray for the faces - vtk_cells = vtk.vtkCellArray() - vtk_cells.SetCells(len(mesh.faces), numpy_to_vtk(faces, deep=True, array_type=vtk.VTK_ID_TYPE)) - - # 2. Create the vtkPolyData (the actual geometry) - poly_data = vtk.vtkPolyData() - poly_data.SetPoints(vtk_points) - poly_data.SetPolys(vtk_cells) - - # 3. Set up the visualization pipeline - # Mapper: Connects the geometry to the graphics hardware - mapper = vtk.vtkPolyDataMapper() - mapper.SetInputData(poly_data) - - # Actor: Represents the object in the scene (position, color, etc.) - actor = vtk.vtkActor() - actor.SetMapper(mapper) - actor.GetProperty().SetColor(0.8, 0.8, 1.0) # Light blue - actor.GetProperty().EdgeVisibilityOn() - actor.GetProperty().SetEdgeColor(0.1, 0.1, 0.2) - - # Renderer: Manages the scene, camera, and lighting + click.echo(f"🎨 Displaying {len(meshes)} mesh(es) with VTK viewer. Close the window to continue.") renderer = vtk.vtkRenderer() - renderer.AddActor(actor) renderer.SetBackground(0.1, 0.2, 0.3) # Dark blue/gray + # Define a list of colors for multiple meshes + 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 + ] + + # 2. Loop through each mesh, create an actor, and add it to the renderer + for i, mesh in enumerate(meshes): + # Convert trimesh data to VTK format + vertices = mesh.vertices + faces = np.hstack((np.full((len(mesh.faces), 1), 3), mesh.faces)).flatten() + vtk_points = vtk.vtkPoints() + vtk_points.SetData(numpy_to_vtk(vertices, deep=True)) + vtk_cells = vtk.vtkCellArray() + vtk_cells.SetCells(len(mesh.faces), numpy_to_vtk(faces, deep=True, array_type=vtk.VTK_ID_TYPE)) + poly_data = vtk.vtkPolyData() + poly_data.SetPoints(vtk_points) + poly_data.SetPolys(vtk_cells) + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputData(poly_data) + actor = vtk.vtkActor() + actor.SetMapper(mapper) + actor.GetProperty().SetColor(colors[i % len(colors)]) + if mode == "wireframe": + actor.GetProperty().SetRepresentationToWireframe() + renderer.AddActor(actor) + + # 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() + widget = vtk.vtkOrientationMarkerWidget() # This is the small one in the corner widget.SetOutlineColor(0.9300, 0.5700, 0.1300) widget.SetOrientationMarker(axes) @@ -95,60 +95,105 @@ def show_with_vtk(mesh: trimesh.Trimesh): render_window_interactor.Start() -def visualize_mesh_from_db(hdf5_paths: list[str], mesh_name: str, use_vtk: bool): - """Loads a specific mesh from the HDF5 database and visualizes it.""" - found_mesh_data = None - found_in_file = None - - for hdf5_path in hdf5_paths: - db_file = Path(hdf5_path) - if not db_file.exists(): - click.echo(f"❌ Warning: Database file '{hdf5_path}' not found. Skipping.", err=True) - continue - - mesh_group_path = f"meshes/{mesh_name}" - try: - with h5py.File(db_file, "r") as f: - if mesh_group_path in f: - click.echo(f"📦 Loading mesh '{mesh_name}' from '{hdf5_path}'...") - found_mesh_data = f[mesh_group_path]["stl_content"][()] - found_in_file = hdf5_path - break # Found the mesh, no need to check other files - except Exception as e: - click.echo(f"❌ Error reading '{hdf5_path}': {e}", err=True) - continue - - if found_mesh_data is None: - click.echo(f"❌ Error: Mesh '{mesh_name}' not found in any of the specified HDF5 files.", err=True) - click.echo("Use 'fleetmaster list --file ' to see available meshes.", err=True) +def visualize_meshes_from_db(hdf5_paths: list[str], mesh_names_to_show: list[str], use_vtk: bool, mode: str): + """Loads one or more meshes from HDF5 databases and visualizes them in a single scene.""" + loaded_meshes = [] + + for mesh_name in mesh_names_to_show: + found_mesh_data = None + for hdf5_path in hdf5_paths: + db_file = Path(hdf5_path) + if not db_file.exists(): + continue # Skip non-existent files silently, list command can be used for checks + + mesh_group_path = f"meshes/{mesh_name}" + try: + with h5py.File(db_file, "r") as f: + if mesh_group_path in f: + click.echo(f"📦 Loading mesh '{mesh_name}' from '{hdf5_path}'...") + found_mesh_data = f[mesh_group_path]["stl_content"][()] + break # Found the mesh, no need to check other files for this name + except Exception as e: + click.echo(f"❌ Error reading '{hdf5_path}': {e}", err=True) + continue + + if found_mesh_data is not None and found_mesh_data.size > 0: # Check for non-None and non-empty array + stl_file_in_memory = io.BytesIO(found_mesh_data) + mesh = trimesh.load_mesh(stl_file_in_memory, file_type="stl") + loaded_meshes.append(mesh) + else: + click.echo(f"❌ Warning: Mesh '{mesh_name}' not found in any of the specified files.", err=True) + + if not loaded_meshes: + click.echo("No meshes were loaded. Nothing to display.", err=True) return - # If we found the mesh, proceed with visualization - stl_binary_content = found_mesh_data - - stl_file_in_memory = io.BytesIO(stl_binary_content) - mesh = trimesh.load_mesh(stl_file_in_memory, file_type="stl") - if use_vtk: - show_with_vtk(mesh) + show_with_vtk(loaded_meshes, mode) else: - show_with_trimesh(mesh) + click.echo(f"🎨 Displaying {len(loaded_meshes)} mesh(es) with trimesh viewer. Close the window to continue.") + # Create a scene and add an axis marker at the origin + scene = trimesh.Scene() + scene.add_geometry(trimesh.creation.axis(origin_size=0.05)) + + if mode == "wireframe": + # For wireframe, set the visual properties of each mesh + for mesh in loaded_meshes: + mesh.visual = trimesh.visual.ColorVisuals(mesh, edge_colors=[0, 0, 0, 255]) + scene.add_geometry(mesh) + else: + scene.add_geometry(loaded_meshes) + scene.show() -@click.command(name="view", help="Visualize a specific mesh from one or more HDF5 database files.") -@click.argument("mesh_name") + +@click.command(name="view", help="Visualize one or more meshes from HDF5 database files.") +@click.argument("mesh_names", nargs=-1) @click.option("--file", "-f", "hdf5_files", multiple=True, default=["results.hdf5"], help="Path to one or more HDF5 database files. Can be specified multiple times.") @click.option("--vtk", is_flag=True, help="Use the VTK viewer instead of the default trimesh viewer.") -def view(mesh_name: str, hdf5_files: tuple[str, ...], vtk: bool): +@click.option("--show-all", is_flag=True, help="Visualize all meshes found in the specified files.") +@click.option("--mode", type=click.Choice(["solid", "wireframe"]), default="solid", help="Visualization mode.", show_default=True) +def view(mesh_names: tuple[str, ...], hdf5_files: tuple[str, ...], vtk: bool, show_all: bool, mode: str): """ - CLI command to load and visualize a specific mesh from the HDF5 database. + CLI command to load and visualize meshes from HDF5 databases. - MESH_NAME: The name of the mesh to visualize (e.g., 'barge_draft_1.0'). + You can specify mesh names as arguments or use --show-all. """ - if not mesh_name: - click.echo("❌ Error: Please provide a MESH_NAME to visualize.", err=True) - click.echo("Use 'fleetmaster list --file ' to see available meshes.", err=True) + # --- Smartly separate file paths from mesh names --- + all_args = list(mesh_names) + list(hdf5_files) + + files_to_check = {arg for arg in all_args if arg.endswith((".hdf5", ".h5"))} + meshes_to_show = {arg for arg in all_args if not arg.endswith((".hdf5", ".h5"))} + + # If the user provided file paths but also left the default --file value, remove the default. + # This happens if they provide a path as a positional argument without using -f. + if files_to_check and "results.hdf5" in hdf5_files and len(hdf5_files) == 1: + ctx = click.get_current_context() + if ctx.get_parameter_source("hdf5_files") == click.core.ParameterSource.DEFAULT: + # The user didn't explicitly type '--file results.hdf5', so we can ignore it + # if other files were found. + pass # The default is implicitly overridden by the positional file args. + elif not files_to_check: + files_to_check = set(hdf5_files) # Use the default or user-provided --file + + if show_all: + all_found_meshes = set() + for hdf5_path in files_to_check: + db_file = Path(hdf5_path) + if not db_file.exists(): + click.echo(f"❌ Warning: Database file '{hdf5_path}' not found. Skipping.", err=True) + continue + with h5py.File(db_file, "r") as f: + meshes_to_show.update(f.get("meshes", {}).keys()) + + # Remove duplicates + final_meshes_to_show = sorted(list(meshes_to_show)) + final_files_to_check = list(files_to_check) + + if not meshes_to_show: + click.echo("No mesh names provided and no meshes found with --show-all.", err=True) + click.echo("Usage: fleetmaster view [MESH_NAME...] [--file ] or fleetmaster view --show-all", err=True) return - visualize_mesh_from_db(list(hdf5_files), mesh_name, vtk) \ No newline at end of file + visualize_meshes_from_db(final_files_to_check, final_meshes_to_show, vtk, mode) \ No newline at end of file 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" From 704c55d76133910566d11e039c952442afbaab63 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Wed, 22 Oct 2025 22:10:26 +0200 Subject: [PATCH 06/34] added wireframe mode --- src/fleetmaster/commands/view.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/fleetmaster/commands/view.py b/src/fleetmaster/commands/view.py index 208c301..01313b9 100644 --- a/src/fleetmaster/commands/view.py +++ b/src/fleetmaster/commands/view.py @@ -2,12 +2,15 @@ import h5py import io +import logging from pathlib import Path import click import numpy as np import trimesh +logger = logging.getLogger(__name__) + # Try to import vtk, but make it an optional dependency try: import vtk @@ -136,15 +139,17 @@ def visualize_meshes_from_db(hdf5_paths: list[str], mesh_names_to_show: list[str scene = trimesh.Scene() scene.add_geometry(trimesh.creation.axis(origin_size=0.05)) + # Add all meshes to the scene + scene.add_geometry(loaded_meshes) + + # For the default trimesh viewer, we can request wireframe mode before showing. + # The user can still toggle it with the 'w' key. if mode == "wireframe": - # For wireframe, set the visual properties of each mesh - for mesh in loaded_meshes: - mesh.visual = trimesh.visual.ColorVisuals(mesh, edge_colors=[0, 0, 0, 255]) - scene.add_geometry(mesh) + logger.debug("Showing with wireframe mode. Toggle with w/s to go to solid") + scene.show(wireframe=True) else: - scene.add_geometry(loaded_meshes) - - scene.show() + logger.debug("Showing with solid mode. Toggle with w/s to go to wireframe") + scene.show() @click.command(name="view", help="Visualize one or more meshes from HDF5 database files.") From 300ca70e381cd8de20bc91c27f9c963db61b5a59 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Wed, 22 Oct 2025 22:13:37 +0200 Subject: [PATCH 07/34] removed wireframe option --- src/fleetmaster/commands/view.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/fleetmaster/commands/view.py b/src/fleetmaster/commands/view.py index 01313b9..f3ded9b 100644 --- a/src/fleetmaster/commands/view.py +++ b/src/fleetmaster/commands/view.py @@ -28,7 +28,7 @@ def show_with_trimesh(mesh: trimesh.Trimesh): mesh.show() -def show_with_vtk(meshes: list[trimesh.Trimesh], mode: str): +def show_with_vtk(meshes: list[trimesh.Trimesh]): """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'.") @@ -63,8 +63,6 @@ def show_with_vtk(meshes: list[trimesh.Trimesh], mode: str): actor = vtk.vtkActor() actor.SetMapper(mapper) actor.GetProperty().SetColor(colors[i % len(colors)]) - if mode == "wireframe": - actor.GetProperty().SetRepresentationToWireframe() renderer.AddActor(actor) # Add a global axes actor at the origin @@ -98,7 +96,7 @@ def show_with_vtk(meshes: list[trimesh.Trimesh], mode: str): render_window_interactor.Start() -def visualize_meshes_from_db(hdf5_paths: list[str], mesh_names_to_show: list[str], use_vtk: bool, mode: str): +def visualize_meshes_from_db(hdf5_paths: list[str], mesh_names_to_show: list[str], use_vtk: bool): """Loads one or more meshes from HDF5 databases and visualizes them in a single scene.""" loaded_meshes = [] @@ -145,8 +143,13 @@ def visualize_meshes_from_db(hdf5_paths: list[str], mesh_names_to_show: list[str # For the default trimesh viewer, we can request wireframe mode before showing. # The user can still toggle it with the 'w' key. if mode == "wireframe": - logger.debug("Showing with wireframe mode. Toggle with w/s to go to solid") - scene.show(wireframe=True) + logger.debug("Showing with wireframe mode. Note: 'w' key might not toggle back to solid.") + # Replace solid meshes with their wireframe outlines for rendering + wireframe_geometries = [mesh.outline() for mesh in loaded_meshes] + scene.geometry.clear() + scene.add_geometry(trimesh.creation.axis(origin_size=0.05)) + scene.add_geometry(wireframe_geometries) + scene.show() else: logger.debug("Showing with solid mode. Toggle with w/s to go to wireframe") scene.show() @@ -201,4 +204,4 @@ def view(mesh_names: tuple[str, ...], hdf5_files: tuple[str, ...], vtk: bool, sh click.echo("Usage: fleetmaster view [MESH_NAME...] [--file ] or fleetmaster view --show-all", err=True) return - visualize_meshes_from_db(final_files_to_check, final_meshes_to_show, vtk, mode) \ No newline at end of file + visualize_meshes_from_db(final_files_to_check, final_meshes_to_show, vtk) \ No newline at end of file From 9395f749ea1a71ef4ec225d0c50754b3aeeebcf5 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Wed, 22 Oct 2025 22:16:08 +0200 Subject: [PATCH 08/34] removed mode --- src/fleetmaster/commands/view.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/fleetmaster/commands/view.py b/src/fleetmaster/commands/view.py index f3ded9b..70f776f 100644 --- a/src/fleetmaster/commands/view.py +++ b/src/fleetmaster/commands/view.py @@ -130,7 +130,7 @@ def visualize_meshes_from_db(hdf5_paths: list[str], mesh_names_to_show: list[str return if use_vtk: - show_with_vtk(loaded_meshes, mode) + show_with_vtk(loaded_meshes) else: click.echo(f"🎨 Displaying {len(loaded_meshes)} mesh(es) with trimesh viewer. Close the window to continue.") # Create a scene and add an axis marker at the origin @@ -140,19 +140,8 @@ def visualize_meshes_from_db(hdf5_paths: list[str], mesh_names_to_show: list[str # Add all meshes to the scene scene.add_geometry(loaded_meshes) - # For the default trimesh viewer, we can request wireframe mode before showing. - # The user can still toggle it with the 'w' key. - if mode == "wireframe": - logger.debug("Showing with wireframe mode. Note: 'w' key might not toggle back to solid.") - # Replace solid meshes with their wireframe outlines for rendering - wireframe_geometries = [mesh.outline() for mesh in loaded_meshes] - scene.geometry.clear() - scene.add_geometry(trimesh.creation.axis(origin_size=0.05)) - scene.add_geometry(wireframe_geometries) - scene.show() - else: - logger.debug("Showing with solid mode. Toggle with w/s to go to wireframe") - scene.show() + logger.debug("Showing with solid mode. Toggle with w/s to go to wireframe") + scene.show() @click.command(name="view", help="Visualize one or more meshes from HDF5 database files.") @@ -161,8 +150,7 @@ def visualize_meshes_from_db(hdf5_paths: list[str], mesh_names_to_show: list[str help="Path to one or more HDF5 database files. Can be specified multiple times.") @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.") -@click.option("--mode", type=click.Choice(["solid", "wireframe"]), default="solid", help="Visualization mode.", show_default=True) -def view(mesh_names: tuple[str, ...], hdf5_files: tuple[str, ...], vtk: bool, show_all: bool, mode: str): +def view(mesh_names: tuple[str, ...], hdf5_files: tuple[str, ...], vtk: bool, show_all: bool): """ CLI command to load and visualize meshes from HDF5 databases. From 09d47afc2381bd18a7d60a759f7de8e10ca622fc Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Wed, 22 Oct 2025 22:42:59 +0200 Subject: [PATCH 09/34] changed mesh format --- src/fleetmaster/commands/list.py | 102 +++++++++++++++++++++++++------ src/fleetmaster/commands/run.py | 1 + src/fleetmaster/core/engine.py | 6 +- 3 files changed, 91 insertions(+), 18 deletions(-) diff --git a/src/fleetmaster/commands/list.py b/src/fleetmaster/commands/list.py index b9ba9da..a2c1f78 100644 --- a/src/fleetmaster/commands/list.py +++ b/src/fleetmaster/commands/list.py @@ -1,43 +1,111 @@ """CLI command for listing meshes from HDF5 databases.""" import h5py +import io +import logging from pathlib import Path import click +import trimesh +from trimesh import Trimesh -def list_meshes_in_db(hdf5_paths: list[str]): +logger = logging.getLogger(__name__) + +def list_items_in_db(hdf5_paths: list[str], show_cases: bool): """ - Lists all available meshes in one or more HDF5 database files. + Lists available meshes or simulation cases in one or more HDF5 database files. """ for hdf5_path in hdf5_paths: db_file = Path(hdf5_path) if not db_file.exists(): click.echo(f"❌ Error: Database file '{hdf5_path}' not found.", err=True) continue - - click.echo(f"\nAvailable meshes in '{hdf5_path}':") try: with h5py.File(db_file, "r") as f: - meshes_group = f.get("meshes") - if meshes_group: - available_meshes = list(meshes_group.keys()) - if available_meshes: - for name in available_meshes: - click.echo(f" - {name}") - else: - click.echo(" No meshes found.") + if show_cases: + click.echo(f"\nAvailable cases in '{hdf5_path}':") + case_names = [name for name in f.keys() if name != "meshes"] + if not case_names: + click.echo(" No cases found.") + continue + + for case_name in sorted(case_names): + case_group = f[case_name] + click.echo(f"\n- Case: {case_name}") + + mesh_name = case_group.attrs.get("stl_mesh_name") + if not mesh_name: + click.echo(" Mesh: [Unknown]") + continue + + click.echo(f" Mesh: {mesh_name}") + mesh_info_group = f.get(f"meshes/{mesh_name}") + if mesh_info_group: + attrs = mesh_info_group.attrs + vol = attrs.get("volume", "N/A") + cog_x = attrs.get("cog_x", "N/A") + cog_y = attrs.get("cog_y", "N/A") + cog_z = attrs.get("cog_z", "N/A") + lx = attrs.get("bbox_lx", "N/A") + ly = attrs.get("bbox_ly", "N/A") + lz = attrs.get("bbox_lz", "N/A") + + # Load mesh from stored content to get more details + num_faces = "N/A" + bounds = None + stl_content_dataset = mesh_info_group.get("stl_content") + if stl_content_dataset: + try: + # When stored correctly, h5py returns a bytes object directly. + stl_bytes = stl_content_dataset[()] + mesh: Trimesh = trimesh.load_mesh(io.BytesIO(stl_bytes), file_type="stl") # type: ignore + num_faces = len(mesh.faces) + bounds = mesh.bounding_box.bounds + except (ValueError, IOError, TypeError) as e: + 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_x:.3f}, {cog_y:.3f}, {cog_z:.3f})" + if all(isinstance(c, float) for c in [cog_x, cog_y, cog_z]) + else f" COG (x,y,z): ({cog_x}, {cog_y}, {cog_z})" + ) + click.echo( + f" BBox Dims (Lx,Ly,Lz): ({lx:.3f}, {ly:.3f}, {lz:.3f})" + if all(isinstance(d, float) for d in [lx, ly, lz]) + else f" BBox Dims (Lx,Ly,Lz): ({lx}, {ly}, {lz})" + ) + 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})") + else: + click.echo(" Mesh properties not found in database.") else: - click.echo(" No 'meshes' group found.") + click.echo(f"\nAvailable meshes in '{hdf5_path}':") + meshes_group = f.get("meshes") + if meshes_group: + available_meshes = list(meshes_group.keys()) + if available_meshes: + for name in sorted(available_meshes): + click.echo(f" - {name}") + else: + click.echo(" No meshes found.") + else: + click.echo(" No 'meshes' group found.") except Exception as e: click.echo(f"❌ Error reading '{hdf5_path}': {e}", err=True) @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.") -def list_command(files: tuple[str, ...], option_files: tuple[str, ...]): +@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): """CLI command to list meshes.""" # Combine positional arguments and optional --file arguments all_files = set(files) | set(option_files) @@ -48,4 +116,4 @@ def list_command(files: tuple[str, ...], option_files: tuple[str, ...]): else: final_files = list(all_files) - list_meshes_in_db(final_files) \ No newline at end of file + list_items_in_db(final_files, show_cases=cases) \ No newline at end of file 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/core/engine.py b/src/fleetmaster/core/engine.py index 89f983d..c7447a2 100644 --- a/src/fleetmaster/core/engine.py +++ b/src/fleetmaster/core/engine.py @@ -207,7 +207,7 @@ def add_mesh_to_database(output_file: Path, mesh_to_add: trimesh.Trimesh, mesh_n logger.debug(" - Wrote dataset: inertia_tensor") # Store the binary content of the final, transformed STL - group.create_dataset("stl_content", data=memoryview(new_stl_content)) + group.create_dataset("stl_content", data=new_stl_content) logger.debug(" - Wrote dataset: stl_content") @@ -412,6 +412,10 @@ def run_simulation_batch(settings: SimulationSettings) -> None: # A positive draft means sinking the vessel, so we subtract it. draft_settings.translation_z -= draft + # Ensure other translation settings are also passed through + draft_settings.translation_x = settings.translation_x + draft_settings.translation_y = settings.translation_y + # 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}" From b53b9478ab834865a5d452621efc53e39892911d Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Wed, 22 Oct 2025 22:57:59 +0200 Subject: [PATCH 10/34] not working --- src/fleetmaster/commands/list.py | 15 ++++++++++++--- src/fleetmaster/commands/view.py | 33 +++++++++++--------------------- src/fleetmaster/core/engine.py | 29 +++++++++++++++------------- 3 files changed, 39 insertions(+), 38 deletions(-) diff --git a/src/fleetmaster/commands/list.py b/src/fleetmaster/commands/list.py index a2c1f78..db59793 100644 --- a/src/fleetmaster/commands/list.py +++ b/src/fleetmaster/commands/list.py @@ -58,11 +58,20 @@ def list_items_in_db(hdf5_paths: list[str], show_cases: bool): if stl_content_dataset: try: # When stored correctly, h5py returns a bytes object directly. - stl_bytes = stl_content_dataset[()] - mesh: Trimesh = trimesh.load_mesh(io.BytesIO(stl_bytes), file_type="stl") # type: ignore + stl_data = stl_content_dataset[()] + try: + # New, correct way: data is already bytes + stl_bytes = stl_data + except AttributeError: + # Old way: data was a numpy.void object, needs conversion + stl_bytes = stl_data.tobytes() + mesh: Trimesh = trimesh.load_mesh(io.BytesIO(stl_bytes), file_type="stl") + if mesh is None: + raise ValueError("trimesh.load_mesh returned None, failed to parse STL.") + num_faces = len(mesh.faces) bounds = mesh.bounding_box.bounds - except (ValueError, IOError, TypeError) as e: + except (ValueError, IOError, TypeError, AttributeError) as e: logger.debug(f"Failed to parse STL content for mesh '{mesh_name}': {e}") click.echo(f" Could not parse stored STL content: {e}") diff --git a/src/fleetmaster/commands/view.py b/src/fleetmaster/commands/view.py index 70f776f..08c30f2 100644 --- a/src/fleetmaster/commands/view.py +++ b/src/fleetmaster/commands/view.py @@ -118,7 +118,7 @@ def visualize_meshes_from_db(hdf5_paths: list[str], mesh_names_to_show: list[str click.echo(f"❌ Error reading '{hdf5_path}': {e}", err=True) continue - if found_mesh_data is not None and found_mesh_data.size > 0: # Check for non-None and non-empty array + if found_mesh_data is not None and len(found_mesh_data) > 0: # A bytes object has no .size, use len() stl_file_in_memory = io.BytesIO(found_mesh_data) mesh = trimesh.load_mesh(stl_file_in_memory, file_type="stl") loaded_meshes.append(mesh) @@ -156,25 +156,15 @@ def view(mesh_names: tuple[str, ...], hdf5_files: tuple[str, ...], vtk: bool, sh You can specify mesh names as arguments or use --show-all. """ - # --- Smartly separate file paths from mesh names --- - all_args = list(mesh_names) + list(hdf5_files) - - files_to_check = {arg for arg in all_args if arg.endswith((".hdf5", ".h5"))} - meshes_to_show = {arg for arg in all_args if not arg.endswith((".hdf5", ".h5"))} - - # If the user provided file paths but also left the default --file value, remove the default. - # This happens if they provide a path as a positional argument without using -f. - if files_to_check and "results.hdf5" in hdf5_files and len(hdf5_files) == 1: - ctx = click.get_current_context() - if ctx.get_parameter_source("hdf5_files") == click.core.ParameterSource.DEFAULT: - # The user didn't explicitly type '--file results.hdf5', so we can ignore it - # if other files were found. - pass # The default is implicitly overridden by the positional file args. - elif not files_to_check: - files_to_check = set(hdf5_files) # Use the default or user-provided --file + # Simplified and predictable logic: + # Positional arguments are mesh names. + # --file/-f arguments are HDF5 files. + files_to_check = set(hdf5_files) + meshes_to_show = set(mesh_names) if show_all: - all_found_meshes = set() + # If --show-all, we ignore any provided mesh names and find all meshes in the specified files. + meshes_to_show = set() for hdf5_path in files_to_check: db_file = Path(hdf5_path) if not db_file.exists(): @@ -183,13 +173,12 @@ def view(mesh_names: tuple[str, ...], hdf5_files: tuple[str, ...], vtk: bool, sh with h5py.File(db_file, "r") as f: meshes_to_show.update(f.get("meshes", {}).keys()) - # Remove duplicates final_meshes_to_show = sorted(list(meshes_to_show)) final_files_to_check = list(files_to_check) - - if not meshes_to_show: + + if not final_meshes_to_show: click.echo("No mesh names provided and no meshes found with --show-all.", err=True) - click.echo("Usage: fleetmaster view [MESH_NAME...] [--file ] or fleetmaster view --show-all", err=True) + click.echo("Usage: fleetmaster view [MESH_NAME...] --file OR fleetmaster view --show-all --file ", err=True) return visualize_meshes_from_db(final_files_to_check, final_meshes_to_show, vtk) \ No newline at end of file diff --git a/src/fleetmaster/core/engine.py b/src/fleetmaster/core/engine.py index c7447a2..488e0b0 100644 --- a/src/fleetmaster/core/engine.py +++ b/src/fleetmaster/core/engine.py @@ -114,30 +114,33 @@ def _prepare_capytaine_body( ) -> tuple[Any, trimesh.Trimesh]: """ Load an STL file and configure a Capytaine FloatingBody object. + The geometry is first loaded and transformed using trimesh, then passed to Capytaine. """ - hull_mesh = cpt.load_mesh(stl_file) + # 1. Load the mesh with trimesh to perform transformations. + trimesh_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}") - hull_mesh = hull_mesh.transform(translation_vector=translation_vector) - - 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, name=f"{Path(stl_file).stem}_mesh") + transform_matrix = trimesh.transformations.translation_matrix(translation_vector) + trimesh_mesh.apply_transform(transform_matrix) cog = None if add_center_of_mass: - full_mesh = trimesh.load_mesh(stl_file) - if translation_x != 0.0 or translation_y != 0.0 or translation_z != 0.0: - transform_matrix = trimesh.transformations.translation_matrix([translation_x, translation_y, translation_z]) - full_mesh.apply_transform(transform_matrix) - cog = full_mesh.center_mass + # Calculate COG from the (already transformed) trimesh object + cog = trimesh_mesh.center_mass logger.debug(f"Adding COG {cog}") + # 2. Create the Capytaine mesh from the transformed trimesh object. + hull_mesh = cpt.Mesh( + vertices=trimesh_mesh.vertices, faces=trimesh_mesh.faces, name=f"{Path(stl_file).stem}_mesh" + ) + 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() From 95df9f422f6227635a7cf03743758bc2843ad388 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Wed, 22 Oct 2025 23:37:29 +0200 Subject: [PATCH 11/34] added view --- src/fleetmaster/commands/view.py | 68 ++++++++++++-------------- src/fleetmaster/core/engine.py | 82 ++++++++++++++++++++++---------- 2 files changed, 88 insertions(+), 62 deletions(-) diff --git a/src/fleetmaster/commands/view.py b/src/fleetmaster/commands/view.py index 08c30f2..82e6136 100644 --- a/src/fleetmaster/commands/view.py +++ b/src/fleetmaster/commands/view.py @@ -96,30 +96,29 @@ def show_with_vtk(meshes: list[trimesh.Trimesh]): render_window_interactor.Start() -def visualize_meshes_from_db(hdf5_paths: list[str], mesh_names_to_show: list[str], use_vtk: bool): +def visualize_meshes_from_db(hdf5_path: str, mesh_names_to_show: list[str], use_vtk: bool): """Loads one or more meshes from HDF5 databases and visualizes them in a single scene.""" loaded_meshes = [] + db_file = Path(hdf5_path) + if not db_file.exists(): + click.echo(f"❌ Error: Database file '{hdf5_path}' not found.", err=True) + return + for mesh_name in mesh_names_to_show: found_mesh_data = None - for hdf5_path in hdf5_paths: - db_file = Path(hdf5_path) - if not db_file.exists(): - continue # Skip non-existent files silently, list command can be used for checks - - mesh_group_path = f"meshes/{mesh_name}" - try: - with h5py.File(db_file, "r") as f: - if mesh_group_path in f: - click.echo(f"📦 Loading mesh '{mesh_name}' from '{hdf5_path}'...") - found_mesh_data = f[mesh_group_path]["stl_content"][()] - break # Found the mesh, no need to check other files for this name - except Exception as e: - click.echo(f"❌ Error reading '{hdf5_path}': {e}", err=True) - continue + mesh_group_path = f"meshes/{mesh_name}" + try: + with h5py.File(db_file, "r") as f: + if mesh_group_path in f: + click.echo(f"📦 Loading mesh '{mesh_name}' from '{hdf5_path}'...") + found_mesh_data = f[mesh_group_path]["stl_content"][()] + except Exception as e: + click.echo(f"❌ Error reading '{hdf5_path}': {e}", err=True) + continue if found_mesh_data is not None and len(found_mesh_data) > 0: # A bytes object has no .size, use len() - stl_file_in_memory = io.BytesIO(found_mesh_data) + stl_file_in_memory = io.BytesIO(found_mesh_data.tobytes()) mesh = trimesh.load_mesh(stl_file_in_memory, file_type="stl") loaded_meshes.append(mesh) else: @@ -144,41 +143,36 @@ def visualize_meshes_from_db(hdf5_paths: list[str], mesh_names_to_show: list[str scene.show() -@click.command(name="view", help="Visualize one or more meshes from HDF5 database files.") +@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("--file", "-f", "hdf5_files", multiple=True, default=["results.hdf5"], - help="Path to one or more HDF5 database files. Can be specified multiple times.") @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(mesh_names: tuple[str, ...], hdf5_files: tuple[str, ...], vtk: bool, show_all: bool): +def view(hdf5_file: str, mesh_names: tuple[str, ...], vtk: bool, show_all: bool): """ CLI command to load and visualize meshes from HDF5 databases. - You can specify mesh names as arguments or use --show-all. + HDF5_FILE: Path to the HDF5 database file. + [MESH_NAMES]...: Optional names of meshes to visualize. """ - # Simplified and predictable logic: - # Positional arguments are mesh names. - # --file/-f arguments are HDF5 files. - files_to_check = set(hdf5_files) + # The HDF5 file is now a required positional argument. + # Mesh names are optional positional arguments. meshes_to_show = set(mesh_names) if show_all: # If --show-all, we ignore any provided mesh names and find all meshes in the specified files. meshes_to_show = set() - for hdf5_path in files_to_check: - db_file = Path(hdf5_path) - if not db_file.exists(): - click.echo(f"❌ Warning: Database file '{hdf5_path}' not found. Skipping.", err=True) - continue + db_file = Path(hdf5_file) + if db_file.exists(): with h5py.File(db_file, "r") as f: meshes_to_show.update(f.get("meshes", {}).keys()) + else: + click.echo(f"❌ Error: Database file '{hdf5_file}' not found.", err=True) + return - final_meshes_to_show = sorted(list(meshes_to_show)) - final_files_to_check = list(files_to_check) - - if not final_meshes_to_show: + if not meshes_to_show: click.echo("No mesh names provided and no meshes found with --show-all.", err=True) - click.echo("Usage: fleetmaster view [MESH_NAME...] --file OR fleetmaster view --show-all --file ", err=True) + click.echo("Usage: fleetmaster view [MESH_NAME...] OR fleetmaster view --show-all", err=True) return - visualize_meshes_from_db(final_files_to_check, final_meshes_to_show, vtk) \ No newline at end of file + visualize_meshes_from_db(hdf5_file, sorted(list(meshes_to_show)), vtk) \ No newline at end of file diff --git a/src/fleetmaster/core/engine.py b/src/fleetmaster/core/engine.py index 488e0b0..95d4d02 100644 --- a/src/fleetmaster/core/engine.py +++ b/src/fleetmaster/core/engine.py @@ -103,39 +103,65 @@ def _setup_output_file(settings: SimulationSettings) -> Path: return output_file -def _prepare_capytaine_body( +def _prepare_trimesh_geometry( stl_file: str, - lid: bool, - grid_symmetry: bool, - add_center_of_mass: bool = False, translation_x: float = 0.0, translation_y: float = 0.0, translation_z: float = 0.0, -) -> tuple[Any, trimesh.Trimesh]: +) -> trimesh.Trimesh: """ - Load an STL file and configure a Capytaine FloatingBody object. - The geometry is first loaded and transformed using trimesh, then passed to Capytaine. + Loads an STL file and applies specified translations. + + Returns: + A trimesh.Trimesh object representing the transformed geometry. """ - # 1. Load the mesh with trimesh to perform transformations. - trimesh_mesh = trimesh.load_mesh(stl_file) + 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) - trimesh_mesh.apply_transform(transform_matrix) + transformed_mesh.apply_transform(transform_matrix) + + return transformed_mesh - cog = None - if add_center_of_mass: - # Calculate COG from the (already transformed) trimesh object - cog = trimesh_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}") - # 2. Create the Capytaine mesh from the transformed trimesh object. - hull_mesh = cpt.Mesh( - vertices=trimesh_mesh.vertices, faces=trimesh_mesh.faces, name=f"{Path(stl_file).stem}_mesh" - ) + # 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. + # To avoid race conditions on file I/O, we create, write, close, and then + # read the temporary file in distinct steps. + fd, temp_path_str = tempfile.mkstemp(suffix=".stl") + temp_path = Path(temp_path_str) + try: + # Step 1: Write to the temporary file. + with open(fd, "wb") as f: + source_mesh.export(f, file_type="stl") + logger.debug(f"Exported transformed mesh to temporary file: {temp_path}") + + # Step 2: Read from the now-closed temporary file. + hull_mesh = cpt.load_mesh(str(temp_path), name=mesh_name) + + finally: + # Step 3: Ensure the temporary file is always deleted. + 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") @@ -145,7 +171,7 @@ def _prepare_capytaine_body( boat.add_all_rigid_body_dofs() boat.keep_immersed_part() - # Extract the final mesh that Capytaine will use, after all transformations and immersion. + # 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) return boat, final_mesh_trimesh @@ -210,7 +236,9 @@ def add_mesh_to_database(output_file: Path, mesh_to_add: trimesh.Trimesh, mesh_n logger.debug(" - Wrote dataset: inertia_tensor") # Store the binary content of the final, transformed STL - group.create_dataset("stl_content", data=new_stl_content) + # 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") @@ -312,16 +340,20 @@ def process_all_cases_for_one_stl( mesh_name_override: str | None = None, ) -> None: mesh_name = mesh_name_override or Path(stl_file).stem - boat, final_mesh = _prepare_capytaine_body( - stl_file, - lid=lid, - grid_symmetry=grid_symmetry, - add_center_of_mass=add_center_of_mass, + + # 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) From a8df8246c1ece51515390cef2399beec2f5271cf Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Wed, 22 Oct 2025 23:52:07 +0200 Subject: [PATCH 12/34] fixed viewer --- src/fleetmaster/commands/view.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/fleetmaster/commands/view.py b/src/fleetmaster/commands/view.py index 82e6136..7378e00 100644 --- a/src/fleetmaster/commands/view.py +++ b/src/fleetmaster/commands/view.py @@ -109,18 +109,26 @@ def visualize_meshes_from_db(hdf5_path: str, mesh_names_to_show: list[str], use_ found_mesh_data = None mesh_group_path = f"meshes/{mesh_name}" try: + logger.debug(f"Opening database {db_file}") with h5py.File(db_file, "r") as f: if mesh_group_path in f: click.echo(f"📦 Loading mesh '{mesh_name}' from '{hdf5_path}'...") found_mesh_data = f[mesh_group_path]["stl_content"][()] except Exception as e: + logger.exception(f"Error reading mesh {mesh_group_path}' from '{hdf5_path}'") click.echo(f"❌ Error reading '{hdf5_path}': {e}", err=True) continue - if found_mesh_data is not None and len(found_mesh_data) > 0: # A bytes object has no .size, use len() - stl_file_in_memory = io.BytesIO(found_mesh_data.tobytes()) - mesh = trimesh.load_mesh(stl_file_in_memory, file_type="stl") - loaded_meshes.append(mesh) + if found_mesh_data: # A non-empty numpy.void object evaluates to True. + try: + # The data is stored as a numpy.void object, which must be converted to bytes. + stl_bytes = found_mesh_data.tobytes() + mesh = trimesh.load_mesh(io.BytesIO(stl_bytes), file_type="stl") + if mesh: + loaded_meshes.append(mesh) + except Exception as e: + logger.exception(f"Failed to parse mesh '{mesh_name}'") + click.echo(f"❌ Error parsing mesh '{mesh_name}': {e}", err=True) else: click.echo(f"❌ Warning: Mesh '{mesh_name}' not found in any of the specified files.", err=True) @@ -132,13 +140,11 @@ def visualize_meshes_from_db(hdf5_path: str, mesh_names_to_show: list[str], use_ show_with_vtk(loaded_meshes) else: click.echo(f"🎨 Displaying {len(loaded_meshes)} mesh(es) with trimesh viewer. Close the window to continue.") - # Create a scene and add an axis marker at the origin - scene = trimesh.Scene() - scene.add_geometry(trimesh.creation.axis(origin_size=0.05)) - - # Add all meshes to the scene - scene.add_geometry(loaded_meshes) - + # 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] + loaded_meshes) + logger.debug("Showing with solid mode. Toggle with w/s to go to wireframe") scene.show() From 51d99c09d83af21f8063473b188d002d67c3d47e Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 00:09:05 +0200 Subject: [PATCH 13/34] fixed double mesh error --- src/fleetmaster/core/engine.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/fleetmaster/core/engine.py b/src/fleetmaster/core/engine.py index 95d4d02..7da610f 100644 --- a/src/fleetmaster/core/engine.py +++ b/src/fleetmaster/core/engine.py @@ -171,6 +171,11 @@ def _prepare_capytaine_body( boat.add_all_rigid_body_dofs() boat.keep_immersed_part() + # 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) From 5a62fb71006266e4bf0f21b9598c89d7219a690c Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 00:14:55 +0200 Subject: [PATCH 14/34] added case view as well --- src/fleetmaster/commands/view.py | 41 ++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/fleetmaster/commands/view.py b/src/fleetmaster/commands/view.py index 7378e00..847ac81 100644 --- a/src/fleetmaster/commands/view.py +++ b/src/fleetmaster/commands/view.py @@ -158,27 +158,42 @@ def view(hdf5_file: str, mesh_names: tuple[str, ...], vtk: bool, show_all: bool) """ CLI command to load and visualize meshes from HDF5 databases. - HDF5_FILE: Path to the HDF5 database file. - [MESH_NAMES]...: Optional names of meshes to visualize. + 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. - meshes_to_show = set(mesh_names) + 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. - meshes_to_show = set() + names_to_resolve = set() db_file = Path(hdf5_file) - if db_file.exists(): - with h5py.File(db_file, "r") as f: - meshes_to_show.update(f.get("meshes", {}).keys()) - else: - click.echo(f"❌ Error: Database file '{hdf5_file}' not found.", err=True) - return - - if not meshes_to_show: + 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 - visualize_meshes_from_db(hdf5_file, sorted(list(meshes_to_show)), vtk) \ No newline at end of file + visualize_meshes_from_db(hdf5_file, sorted(list(resolved_mesh_names)), vtk) \ No newline at end of file From 441b02001fc815f9d73b40f7ac17d41286ab97bc Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 00:27:16 +0200 Subject: [PATCH 15/34] fixed unit tests --- src/fleetmaster/core/engine.py | 25 ++++---- tests/test_engine.py | 109 ++++++++++++++++++--------------- 2 files changed, 74 insertions(+), 60 deletions(-) diff --git a/src/fleetmaster/core/engine.py b/src/fleetmaster/core/engine.py index 7da610f..36990bc 100644 --- a/src/fleetmaster/core/engine.py +++ b/src/fleetmaster/core/engine.py @@ -1,3 +1,4 @@ +import os import hashlib import logging import tempfile @@ -143,23 +144,23 @@ def _prepare_capytaine_body( # 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. - # To avoid race conditions on file I/O, we create, write, close, and then - # read the temporary file in distinct steps. - fd, temp_path_str = tempfile.mkstemp(suffix=".stl") - temp_path = Path(temp_path_str) + # We use NamedTemporaryFile to handle creation and cleanup automatically. + temp_path = None try: - # Step 1: Write to the temporary file. - with open(fd, "wb") as f: - source_mesh.export(f, file_type="stl") - logger.debug(f"Exported transformed mesh to temporary file: {temp_path}") + 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. + # 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. - logger.debug(f"Deleting temporary file: {temp_path}") - temp_path.unlink() + # 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 diff --git a/tests/test_engine.py b/tests/test_engine.py index 6d9a009..5549eda 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,7 +1,9 @@ import hashlib import logging +import os from pathlib import Path from unittest.mock import ANY, MagicMock, call, patch +import trimesh import h5py import numpy as np @@ -104,24 +106,42 @@ 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" # noqa: S108 - body, _ = _prepare_capytaine_body(stl_file, lid=True, grid_symmetry=True, add_center_of_mass=True) + # To make `isinstance(boat.mesh, cpt.meshes.ReflectionSymmetricMesh)` work, + # we define a dummy class and configure the mock to use it. + 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,18 +153,16 @@ 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) with h5py.File(output_file, "r") as f: group = f["meshes/mesh"] @@ -159,17 +177,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 +196,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 +229,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 @@ -245,46 +262,42 @@ 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), - ]) + mock_process.assert_has_calls( + [ + 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): From d10084abc482c768bb1cf19b7ae618ca891f65a8 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 00:33:34 +0200 Subject: [PATCH 16/34] updated changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90094e2..01b8205 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.1.3] - 2025-10-23 + +- Modified stored mesh: now the mesh actually used by capytaine is stored to the database +- Added list utiltity to show a list of meshes and cases in the database +- Added view utiltity to display the mesh + ## [0.1.1] - 2025-10-21 ### Added From 9778659f17291766b76a213572d4bdee9f9eab7d Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 00:34:31 +0200 Subject: [PATCH 17/34] refformat with just check --- src/fleetmaster/commands/__init__.py | 4 ++-- src/fleetmaster/commands/list.py | 22 ++++++++++++------ src/fleetmaster/commands/view.py | 10 ++++---- src/fleetmaster/core/engine.py | 17 ++++++++++---- src/fleetmaster/core/visualize_db_mesh.py | 28 ++++++++++++----------- tests/test_engine.py | 15 +++++------- 6 files changed, 56 insertions(+), 40 deletions(-) diff --git a/src/fleetmaster/commands/__init__.py b/src/fleetmaster/commands/__init__.py index ec41d5a..5d4b530 100644 --- a/src/fleetmaster/commands/__init__.py +++ b/src/fleetmaster/commands/__init__.py @@ -1,6 +1,6 @@ from .gui import gui +from .list import list_command from .run import run from .view import view -from .list import list_command -__all__ = ["gui", "run", "view", "list_command"] +__all__ = ["gui", "list_command", "run", "view"] diff --git a/src/fleetmaster/commands/list.py b/src/fleetmaster/commands/list.py index db59793..87d4c97 100644 --- a/src/fleetmaster/commands/list.py +++ b/src/fleetmaster/commands/list.py @@ -1,17 +1,17 @@ """CLI command for listing meshes from HDF5 databases.""" -import h5py import io import logging from pathlib import Path import click +import h5py import trimesh from trimesh import Trimesh - logger = logging.getLogger(__name__) + def list_items_in_db(hdf5_paths: list[str], show_cases: bool): """ Lists available meshes or simulation cases in one or more HDF5 database files. @@ -71,7 +71,7 @@ def list_items_in_db(hdf5_paths: list[str], show_cases: bool): num_faces = len(mesh.faces) bounds = mesh.bounding_box.bounds - except (ValueError, IOError, TypeError, AttributeError) as e: + except (OSError, ValueError, TypeError, AttributeError) as e: logger.debug(f"Failed to parse STL content for mesh '{mesh_name}': {e}") click.echo(f" Could not parse stored STL content: {e}") @@ -88,8 +88,12 @@ def list_items_in_db(hdf5_paths: list[str], show_cases: bool): else f" BBox Dims (Lx,Ly,Lz): ({lx}, {ly}, {lz})" ) 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})") + 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})" + ) else: click.echo(" Mesh properties not found in database.") else: @@ -111,7 +115,11 @@ def list_items_in_db(hdf5_paths: list[str], show_cases: bool): @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." + "--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): @@ -125,4 +133,4 @@ def list_command(files: tuple[str, ...], option_files: tuple[str, ...], cases: b else: final_files = list(all_files) - list_items_in_db(final_files, show_cases=cases) \ No newline at end of file + list_items_in_db(final_files, show_cases=cases) diff --git a/src/fleetmaster/commands/view.py b/src/fleetmaster/commands/view.py index 847ac81..a57fccc 100644 --- a/src/fleetmaster/commands/view.py +++ b/src/fleetmaster/commands/view.py @@ -1,11 +1,11 @@ """CLI command for visualizing meshes from the HDF5 database.""" -import h5py import io import logging from pathlib import Path import click +import h5py import numpy as np import trimesh @@ -144,7 +144,7 @@ def visualize_meshes_from_db(hdf5_path: str, mesh_names_to_show: list[str], use_ # 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] + loaded_meshes) - + logger.debug("Showing with solid mode. Toggle with w/s to go to wireframe") scene.show() @@ -193,7 +193,9 @@ def view(hdf5_file: str, mesh_names: tuple[str, ...], vtk: bool, show_all: bool) 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) + click.echo( + "Usage: fleetmaster view [MESH_NAME...] OR fleetmaster view --show-all", err=True + ) return - visualize_meshes_from_db(hdf5_file, sorted(list(resolved_mesh_names)), vtk) \ No newline at end of file + visualize_meshes_from_db(hdf5_file, sorted(list(resolved_mesh_names)), vtk) diff --git a/src/fleetmaster/core/engine.py b/src/fleetmaster/core/engine.py index 36990bc..0389907 100644 --- a/src/fleetmaster/core/engine.py +++ b/src/fleetmaster/core/engine.py @@ -1,4 +1,3 @@ -import os import hashlib import logging import tempfile @@ -183,7 +182,9 @@ def _prepare_capytaine_body( 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: +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. @@ -257,7 +258,7 @@ def _format_value_for_name(value: float) -> str: return f"{value:.1f}" -def _generate_case_group_name(mesh_name: str, water_depth: float, water_level: float, forward_speed: float) -> str: # noqa: E501 +def _generate_case_group_name(mesh_name: str, water_depth: float, water_level: float, forward_speed: float) -> str: """Generates a descriptive group name for a specific simulation case.""" wd = _format_value_for_name(water_depth) wl = _format_value_for_name(water_level) @@ -265,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, mesh_name_override: str | None = None) -> 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. """ @@ -357,7 +360,11 @@ def process_all_cases_for_one_stl( # 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 + 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. diff --git a/src/fleetmaster/core/visualize_db_mesh.py b/src/fleetmaster/core/visualize_db_mesh.py index a2e202d..cb88934 100644 --- a/src/fleetmaster/core/visualize_db_mesh.py +++ b/src/fleetmaster/core/visualize_db_mesh.py @@ -1,24 +1,26 @@ +import argparse +import io +from pathlib import Path import h5py import trimesh -import io -import sys -import argparse -from pathlib import Path # Try to import vtk, but make it an optional dependency try: import vtk - from vtk.util.numpy_support import vtk_to_numpy, numpy_to_vtk + from vtk.util.numpy_support import numpy_to_vtk, vtk_to_numpy + VTK_AVAILABLE = True except ImportError: VTK_AVAILABLE = False + def show_with_trimesh(mesh: trimesh.Trimesh): """Visualizes the mesh using the built-in trimesh viewer.""" print("🎨 Displaying mesh with trimesh viewer. Close the window to continue.") mesh.show() + def show_with_vtk(mesh: trimesh.Trimesh): """Visualizes the mesh using a VTK pipeline.""" if not VTK_AVAILABLE: @@ -68,7 +70,7 @@ def show_with_vtk(mesh: trimesh.Trimesh): widget = vtk.vtkOrientationMarkerWidget() 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) @@ -78,7 +80,7 @@ def show_with_vtk(mesh: trimesh.Trimesh): # 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) @@ -100,10 +102,10 @@ def visualize_mesh_from_db(hdf5_path: str, mesh_name: str, use_vtk: bool): mesh_group_path = f"meshes/{mesh_name}" - with h5py.File(db_file, 'r') as f: + with h5py.File(db_file, "r") as f: if mesh_group_path not in f: print(f"❌ Error: Mesh '{mesh_name}' not found in {hdf5_path}.") - available_meshes = list(f.get('meshes', {}).keys()) + available_meshes = list(f.get("meshes", {}).keys()) if available_meshes: print("\nAvailable meshes are:") for name in available_meshes: @@ -111,10 +113,10 @@ def visualize_mesh_from_db(hdf5_path: str, mesh_name: str, use_vtk: bool): return print(f"📦 Loading mesh '{mesh_name}' from the database...") - stl_binary_content = f[mesh_group_path]['stl_content'][()] + stl_binary_content = f[mesh_group_path]["stl_content"][()] stl_file_in_memory = io.BytesIO(stl_binary_content) - mesh = trimesh.load_mesh(stl_file_in_memory, file_type='stl') + mesh = trimesh.load_mesh(stl_file_in_memory, file_type="stl") if use_vtk: show_with_vtk(mesh) @@ -122,12 +124,12 @@ def visualize_mesh_from_db(hdf5_path: str, mesh_name: str, use_vtk: bool): show_with_trimesh(mesh) -if __name__ == '__main__': +if __name__ == "__main__": parser = argparse.ArgumentParser(description="Visualize a mesh from the Fleetmaster HDF5 database.") parser.add_argument("mesh_name", help="The name of the mesh to visualize (e.g., 'barge_draft_1.0').") parser.add_argument("--file", default="results.hdf5", help="Path to the HDF5 database file.") parser.add_argument("--vtk", action="store_true", help="Use the VTK viewer instead of the default trimesh viewer.") - + args = parser.parse_args() visualize_mesh_from_db(args.file, args.mesh_name, args.vtk) diff --git a/tests/test_engine.py b/tests/test_engine.py index 5549eda..e948929 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,14 +1,13 @@ import hashlib import logging -import os from pathlib import Path from unittest.mock import ANY, MagicMock, call, patch -import trimesh import h5py import numpy as np import pandas as pd import pytest +import trimesh import xarray as xr from fleetmaster.core.engine import ( @@ -131,7 +130,7 @@ def to_mesh(self): 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 @@ -262,12 +261,10 @@ 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, mesh_name_override=None), - call("file2.stl", mock_settings, output_file, mesh_name_override=None), - ] - ) + mock_process.assert_has_calls([ + 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._process_single_stl") From 979da920ebcfee8511fa9a964cfeaa20867df952 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 00:43:38 +0200 Subject: [PATCH 18/34] fixed all ruff issues --- src/fleetmaster/commands/list.py | 204 +++++++++++----------- src/fleetmaster/commands/view.py | 4 +- src/fleetmaster/core/visualize_db_mesh.py | 3 +- tests/test_engine.py | 9 +- 4 files changed, 113 insertions(+), 107 deletions(-) diff --git a/src/fleetmaster/commands/list.py b/src/fleetmaster/commands/list.py index 87d4c97..feb7133 100644 --- a/src/fleetmaster/commands/list.py +++ b/src/fleetmaster/commands/list.py @@ -6,110 +6,105 @@ import click import h5py +import numpy as np import trimesh from trimesh import Trimesh logger = logging.getLogger(__name__) -def list_items_in_db(hdf5_paths: list[str], show_cases: bool): +def _parse_stl_content(stl_content_dataset: h5py.Dataset) -> Trimesh: """ - Lists available meshes or simulation cases in one or more HDF5 database files. + Parses binary STL data from an HDF5 dataset into a trimesh object. + + Handles both legacy (numpy.void) and current storage formats. """ - for hdf5_path in hdf5_paths: - db_file = Path(hdf5_path) - if not db_file.exists(): - click.echo(f"❌ Error: Database file '{hdf5_path}' not found.", err=True) - continue + 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: h5py.Group): + """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: - with h5py.File(db_file, "r") as f: - if show_cases: - click.echo(f"\nAvailable cases in '{hdf5_path}':") - case_names = [name for name in f.keys() if name != "meshes"] - if not case_names: - click.echo(" No cases found.") - continue - - for case_name in sorted(case_names): - case_group = f[case_name] - click.echo(f"\n- Case: {case_name}") - - mesh_name = case_group.attrs.get("stl_mesh_name") - if not mesh_name: - click.echo(" Mesh: [Unknown]") - continue - - click.echo(f" Mesh: {mesh_name}") - mesh_info_group = f.get(f"meshes/{mesh_name}") - if mesh_info_group: - attrs = mesh_info_group.attrs - vol = attrs.get("volume", "N/A") - cog_x = attrs.get("cog_x", "N/A") - cog_y = attrs.get("cog_y", "N/A") - cog_z = attrs.get("cog_z", "N/A") - lx = attrs.get("bbox_lx", "N/A") - ly = attrs.get("bbox_ly", "N/A") - lz = attrs.get("bbox_lz", "N/A") - - # Load mesh from stored content to get more details - num_faces = "N/A" - bounds = None - stl_content_dataset = mesh_info_group.get("stl_content") - if stl_content_dataset: - try: - # When stored correctly, h5py returns a bytes object directly. - stl_data = stl_content_dataset[()] - try: - # New, correct way: data is already bytes - stl_bytes = stl_data - except AttributeError: - # Old way: data was a numpy.void object, needs conversion - stl_bytes = stl_data.tobytes() - mesh: Trimesh = trimesh.load_mesh(io.BytesIO(stl_bytes), file_type="stl") - if mesh is None: - raise ValueError("trimesh.load_mesh returned None, failed to parse STL.") - - num_faces = len(mesh.faces) - bounds = mesh.bounding_box.bounds - except (OSError, ValueError, TypeError, AttributeError) as e: - 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_x:.3f}, {cog_y:.3f}, {cog_z:.3f})" - if all(isinstance(c, float) for c in [cog_x, cog_y, cog_z]) - else f" COG (x,y,z): ({cog_x}, {cog_y}, {cog_z})" - ) - click.echo( - f" BBox Dims (Lx,Ly,Lz): ({lx:.3f}, {ly:.3f}, {lz:.3f})" - if all(isinstance(d, float) for d in [lx, ly, lz]) - else f" BBox Dims (Lx,Ly,Lz): ({lx}, {ly}, {lz})" - ) - 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})" - ) - else: - click.echo(" Mesh properties not found in database.") - else: - click.echo(f"\nAvailable meshes in '{hdf5_path}':") - meshes_group = f.get("meshes") - if meshes_group: - available_meshes = list(meshes_group.keys()) - if available_meshes: - for name in sorted(available_meshes): - click.echo(f" - {name}") - else: - click.echo(" No meshes found.") - else: - click.echo(" No 'meshes' group found.") - except Exception as e: - click.echo(f"❌ Error reading '{hdf5_path}': {e}", err=True) + 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: h5py.File, hdf5_path: str): + """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: h5py.File, hdf5_path: str): + """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.") @@ -128,9 +123,18 @@ def list_command(files: tuple[str, ...], option_files: tuple[str, ...], cases: b all_files = set(files) | set(option_files) # If no files are provided at all, use the default. - if not all_files: - final_files = ["results.hdf5"] - else: - final_files = list(all_files) + final_files = ["results.hdf5"] if not all_files else list(all_files) - list_items_in_db(final_files, show_cases=cases) + 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/view.py b/src/fleetmaster/commands/view.py index a57fccc..b8ea239 100644 --- a/src/fleetmaster/commands/view.py +++ b/src/fleetmaster/commands/view.py @@ -143,7 +143,7 @@ def visualize_meshes_from_db(hdf5_path: str, mesh_names_to_show: list[str], use_ # 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] + loaded_meshes) + scene = trimesh.Scene([axis, *loaded_meshes]) logger.debug("Showing with solid mode. Toggle with w/s to go to wireframe") scene.show() @@ -198,4 +198,4 @@ def view(hdf5_file: str, mesh_names: tuple[str, ...], vtk: bool, show_all: bool) ) return - visualize_meshes_from_db(hdf5_file, sorted(list(resolved_mesh_names)), vtk) + visualize_meshes_from_db(hdf5_file, sorted(resolved_mesh_names), vtk) diff --git a/src/fleetmaster/core/visualize_db_mesh.py b/src/fleetmaster/core/visualize_db_mesh.py index cb88934..b1d22dc 100644 --- a/src/fleetmaster/core/visualize_db_mesh.py +++ b/src/fleetmaster/core/visualize_db_mesh.py @@ -3,12 +3,13 @@ from pathlib import Path import h5py +import numpy as np import trimesh # Try to import vtk, but make it an optional dependency try: import vtk - from vtk.util.numpy_support import numpy_to_vtk, vtk_to_numpy + from vtk.util.numpy_support import numpy_to_vtk VTK_AVAILABLE = True except ImportError: diff --git a/tests/test_engine.py b/tests/test_engine.py index e948929..9aeebd0 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -123,13 +123,14 @@ def test_prepare_capytaine_body(mock_tempfile, mock_cpt, tmp_path: Path): mock_cpt.FloatingBody.return_value = mock_body # To make `isinstance(boat.mesh, cpt.meshes.ReflectionSymmetricMesh)` work, - # we define a dummy class and configure the mock to use it. - class DummySymmetricMesh: + # 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() + mock_cpt.meshes.ReflectionSymmetricMesh = _DummySymmetricMesh + mock_cpt.ReflectionSymmetricMesh.return_value = _DummySymmetricMesh() # Act body, _ = _prepare_capytaine_body( From 7b70ff77d8d8ad993698f1486753edce53f598d2 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 00:44:59 +0200 Subject: [PATCH 19/34] fuxed --- pyproject.toml | 2 +- src/fleetmaster/commands/list.py | 8 ++++---- src/fleetmaster/commands/view.py | 8 ++++---- src/fleetmaster/core/visualize_db_mesh.py | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cce0b3a..0f93f8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -134,7 +134,7 @@ module = [ "PySide6.*", "rich.*", "trimesh.*", - "vtk", + "vtk.*", "yaml", ] ignore_missing_imports = true diff --git a/src/fleetmaster/commands/list.py b/src/fleetmaster/commands/list.py index feb7133..5d0de6f 100644 --- a/src/fleetmaster/commands/list.py +++ b/src/fleetmaster/commands/list.py @@ -34,7 +34,7 @@ def _parse_stl_content(stl_content_dataset: h5py.Dataset) -> Trimesh: return mesh -def _print_mesh_details(mesh_info_group: h5py.Group): +def _print_mesh_details(mesh_info_group: h5py.Group) -> None: """Prints formatted geometric properties of a mesh from its HDF5 group.""" attrs = mesh_info_group.attrs vol = attrs.get("volume", "N/A") @@ -70,7 +70,7 @@ def _print_mesh_details(mesh_info_group: h5py.Group): click.echo(f" BBox Max (x,y,z): ({bounds[1][0]:.3f}, {bounds[1][1]:.3f}, {bounds[1][2]:.3f})") -def _list_cases(stream: h5py.File, hdf5_path: str): +def _list_cases(stream: h5py.File, 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"] @@ -93,7 +93,7 @@ def _list_cases(stream: h5py.File, hdf5_path: str): click.echo(" Mesh properties not found in database.") -def _list_meshes(stream: h5py.File, hdf5_path: str): +def _list_meshes(stream: h5py.File, 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")): @@ -117,7 +117,7 @@ def _list_meshes(stream: h5py.File, hdf5_path: str): 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): +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) diff --git a/src/fleetmaster/commands/view.py b/src/fleetmaster/commands/view.py index b8ea239..5c235b1 100644 --- a/src/fleetmaster/commands/view.py +++ b/src/fleetmaster/commands/view.py @@ -22,13 +22,13 @@ VTK_AVAILABLE = False -def show_with_trimesh(mesh: trimesh.Trimesh): +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 show_with_vtk(meshes: list[trimesh.Trimesh]): +def show_with_vtk(meshes: list[trimesh.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'.") @@ -96,7 +96,7 @@ def show_with_vtk(meshes: list[trimesh.Trimesh]): render_window_interactor.Start() -def visualize_meshes_from_db(hdf5_path: str, mesh_names_to_show: list[str], use_vtk: bool): +def visualize_meshes_from_db(hdf5_path: str, mesh_names_to_show: list[str], use_vtk: bool) -> None: """Loads one or more meshes from HDF5 databases and visualizes them in a single scene.""" loaded_meshes = [] @@ -154,7 +154,7 @@ def visualize_meshes_from_db(hdf5_path: str, mesh_names_to_show: list[str], use_ @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): +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. diff --git a/src/fleetmaster/core/visualize_db_mesh.py b/src/fleetmaster/core/visualize_db_mesh.py index b1d22dc..232b179 100644 --- a/src/fleetmaster/core/visualize_db_mesh.py +++ b/src/fleetmaster/core/visualize_db_mesh.py @@ -16,13 +16,13 @@ VTK_AVAILABLE = False -def show_with_trimesh(mesh: trimesh.Trimesh): +def show_with_trimesh(mesh: trimesh.Trimesh) -> None: """Visualizes the mesh using the built-in trimesh viewer.""" print("🎨 Displaying mesh with trimesh viewer. Close the window to continue.") mesh.show() -def show_with_vtk(mesh: trimesh.Trimesh): +def show_with_vtk(mesh: trimesh.Trimesh) -> None: """Visualizes the mesh using a VTK pipeline.""" if not VTK_AVAILABLE: print("❌ Error: The 'vtk' library is not installed. Please install it with 'pip install vtk'.") @@ -92,7 +92,7 @@ def show_with_vtk(mesh: trimesh.Trimesh): render_window_interactor.Start() -def visualize_mesh_from_db(hdf5_path: str, mesh_name: str, use_vtk: bool): +def visualize_mesh_from_db(hdf5_path: str, mesh_name: str, use_vtk: bool) -> None: """ Loads a specific mesh from the HDF5 database and visualizes it. """ From f783cd696cda9025b0739e70f8330b5ed95572bb Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 00:46:16 +0200 Subject: [PATCH 20/34] added any --- src/fleetmaster/commands/list.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/fleetmaster/commands/list.py b/src/fleetmaster/commands/list.py index 5d0de6f..50ad3c0 100644 --- a/src/fleetmaster/commands/list.py +++ b/src/fleetmaster/commands/list.py @@ -3,6 +3,7 @@ import io import logging from pathlib import Path +from typing import Any import click import h5py @@ -13,7 +14,7 @@ logger = logging.getLogger(__name__) -def _parse_stl_content(stl_content_dataset: h5py.Dataset) -> Trimesh: +def _parse_stl_content(stl_content_dataset: Any) -> Trimesh: """ Parses binary STL data from an HDF5 dataset into a trimesh object. @@ -34,7 +35,7 @@ def _parse_stl_content(stl_content_dataset: h5py.Dataset) -> Trimesh: return mesh -def _print_mesh_details(mesh_info_group: h5py.Group) -> None: +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") @@ -70,7 +71,7 @@ def _print_mesh_details(mesh_info_group: h5py.Group) -> None: click.echo(f" BBox Max (x,y,z): ({bounds[1][0]:.3f}, {bounds[1][1]:.3f}, {bounds[1][2]:.3f})") -def _list_cases(stream: h5py.File, hdf5_path: str) -> None: +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"] @@ -93,7 +94,7 @@ def _list_cases(stream: h5py.File, hdf5_path: str) -> None: click.echo(" Mesh properties not found in database.") -def _list_meshes(stream: h5py.File, hdf5_path: str) -> None: +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")): From 8b8381955be4e5f44e98a3068f277f8aecb2407f Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 00:46:58 +0200 Subject: [PATCH 21/34] removed unused pyglet --- pyproject.toml | 1 - uv.lock | 11 ----------- 2 files changed, 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0f93f8b..390dade 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,6 @@ 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", diff --git a/uv.lock b/uv.lock index db585c5..0a5a871 100644 --- a/uv.lock +++ b/uv.lock @@ -567,7 +567,6 @@ 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" }, @@ -630,7 +629,6 @@ 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" }, @@ -1852,15 +1850,6 @@ 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" From a376faa0bf719bc64b5e83ebac3805f5b58b83d9 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 00:48:25 +0200 Subject: [PATCH 22/34] we really need it --- pyproject.toml | 1 + uv.lock | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 390dade..0f93f8b 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", 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" From 0627993c82d926b2bf4387e96cce4d55468cac60 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 00:52:04 +0200 Subject: [PATCH 23/34] keep --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 0f93f8b..a2b112d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -227,6 +227,7 @@ source = ["src"] "mkdocs", "mkdocs-material", "mkdocstrings", + "pyglet", "types-PyYAML", ] From 16d309c08eab7811f395da9287f38b720c9a8344 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 00:54:48 +0200 Subject: [PATCH 24/34] updated changelog --- CHANGELOG.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01b8205..23660ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,21 @@ All notable changes to this project will be documented in this file. -## [Unreleased] +## [Unreleased] - 2025-10-23 -## [0.1.3] - 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. -- Modified stored mesh: now the mesh actually used by capytaine is stored to the database -- Added list utiltity to show a list of meshes and cases in the database -- Added view utiltity to display the mesh +### 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 From fd92317ad6f1f1fbce0c9b0d1393ac7650f58aaf Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 00:57:53 +0200 Subject: [PATCH 25/34] update changelog for 0.2.0 --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23660ac..b0d0d94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ All notable changes to this project will be documented in this file. -## [Unreleased] - 2025-10-23 +## [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. From d60cbb61f87fe435b46f70bcdef673499c667793 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 01:10:07 +0200 Subject: [PATCH 26/34] Update tests/test_engine.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- tests/test_engine.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_engine.py b/tests/test_engine.py index 9aeebd0..10be172 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -164,8 +164,15 @@ def test_add_mesh_to_database_new(tmp_path): add_mesh_to_database(output_file, mock_mesh, "mesh", overwrite=False) + # Assert that mesh attributes and datasets remain unchanged with h5py.File(output_file, "r") as f: group = f["meshes/mesh"] + # Check that the sha256 attribute is unchanged + assert group.attrs["sha256"] == file_hash + # Check that no new attributes have been added + assert len(group.attrs) == 1 + # Check that no datasets have been added to the group + assert list(group.keys()) == [] assert "sha256" in group.attrs assert group.attrs["volume"] == 1.0 assert group.attrs["cog_x"] == 0.1 From af8099fdcb4085d0e394b866c475533191272728 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 01:10:26 +0200 Subject: [PATCH 27/34] Update src/fleetmaster/commands/list.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- src/fleetmaster/commands/list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fleetmaster/commands/list.py b/src/fleetmaster/commands/list.py index 50ad3c0..66a608f 100644 --- a/src/fleetmaster/commands/list.py +++ b/src/fleetmaster/commands/list.py @@ -124,7 +124,7 @@ def list_command(files: tuple[str, ...], option_files: tuple[str, ...], cases: b all_files = set(files) | set(option_files) # If no files are provided at all, use the default. - final_files = ["results.hdf5"] if not all_files else list(all_files) + final_files = list(all_files) if all_files else ["results.hdf5"] for hdf5_path in final_files: db_file = Path(hdf5_path) From 3b6c8f297618f4d3facf835a6ecf8a82f45b5255 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 01:10:37 +0200 Subject: [PATCH 28/34] Update src/fleetmaster/commands/view.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- src/fleetmaster/commands/view.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/fleetmaster/commands/view.py b/src/fleetmaster/commands/view.py index 5c235b1..b8ebd63 100644 --- a/src/fleetmaster/commands/view.py +++ b/src/fleetmaster/commands/view.py @@ -123,8 +123,9 @@ def visualize_meshes_from_db(hdf5_path: str, mesh_names_to_show: list[str], use_ try: # The data is stored as a numpy.void object, which must be converted to bytes. stl_bytes = found_mesh_data.tobytes() - mesh = trimesh.load_mesh(io.BytesIO(stl_bytes), file_type="stl") - if mesh: + if mesh := trimesh.load_mesh( + io.BytesIO(stl_bytes), file_type="stl" + ): loaded_meshes.append(mesh) except Exception as e: logger.exception(f"Failed to parse mesh '{mesh_name}'") From d4c63f79414fbe5bbdfbd9a0de7d889026d3f012 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 01:10:46 +0200 Subject: [PATCH 29/34] Update src/fleetmaster/core/visualize_db_mesh.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- src/fleetmaster/core/visualize_db_mesh.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/fleetmaster/core/visualize_db_mesh.py b/src/fleetmaster/core/visualize_db_mesh.py index 232b179..75dd7af 100644 --- a/src/fleetmaster/core/visualize_db_mesh.py +++ b/src/fleetmaster/core/visualize_db_mesh.py @@ -106,8 +106,7 @@ def visualize_mesh_from_db(hdf5_path: str, mesh_name: str, use_vtk: bool) -> Non with h5py.File(db_file, "r") as f: if mesh_group_path not in f: print(f"❌ Error: Mesh '{mesh_name}' not found in {hdf5_path}.") - available_meshes = list(f.get("meshes", {}).keys()) - if available_meshes: + if available_meshes := list(f.get("meshes", {}).keys()): print("\nAvailable meshes are:") for name in available_meshes: print(f" - {name}") From a50664291113bb31418c81093409842c20883ca9 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 01:19:40 +0200 Subject: [PATCH 30/34] refactored --- src/fleetmaster/commands/view.py | 139 +++++++++------------- src/fleetmaster/core/io.py | 33 +++++ src/fleetmaster/core/visualize_db_mesh.py | 135 --------------------- 3 files changed, 86 insertions(+), 221 deletions(-) create mode 100644 src/fleetmaster/core/io.py diff --git a/src/fleetmaster/commands/view.py b/src/fleetmaster/commands/view.py index b8ebd63..7060da2 100644 --- a/src/fleetmaster/commands/view.py +++ b/src/fleetmaster/commands/view.py @@ -1,13 +1,14 @@ """CLI command for visualizing meshes from the HDF5 database.""" -import io import logging +from itertools import cycle from pathlib import Path import click import h5py import numpy as np import trimesh +from trimesh import Trimesh logger = logging.getLogger(__name__) @@ -21,6 +22,14 @@ except ImportError: VTK_AVAILABLE = False +from fleetmaster.core.io import load_meshes_from_hdf5 + +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.""" @@ -28,7 +37,29 @@ def show_with_trimesh(mesh: trimesh.Trimesh) -> None: mesh.show() -def show_with_vtk(meshes: list[trimesh.Trimesh]) -> None: +def _vtk_actor_from_trimesh(mesh: trimesh.Trimesh, color: tuple[float, float, float]) -> vtk.vtkActor: + """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'.") @@ -37,33 +68,8 @@ def show_with_vtk(meshes: list[trimesh.Trimesh]) -> None: 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 - - # Define a list of colors for multiple meshes - 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 - ] - - # 2. Loop through each mesh, create an actor, and add it to the renderer - for i, mesh in enumerate(meshes): - # Convert trimesh data to VTK format - vertices = mesh.vertices - faces = np.hstack((np.full((len(mesh.faces), 1), 3), mesh.faces)).flatten() - vtk_points = vtk.vtkPoints() - vtk_points.SetData(numpy_to_vtk(vertices, deep=True)) - vtk_cells = vtk.vtkCellArray() - vtk_cells.SetCells(len(mesh.faces), numpy_to_vtk(faces, deep=True, array_type=vtk.VTK_ID_TYPE)) - poly_data = vtk.vtkPolyData() - poly_data.SetPoints(vtk_points) - poly_data.SetPolys(vtk_cells) - mapper = vtk.vtkPolyDataMapper() - mapper.SetInputData(poly_data) - actor = vtk.vtkActor() - actor.SetMapper(mapper) - actor.GetProperty().SetColor(colors[i % len(colors)]) - renderer.AddActor(actor) + 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() @@ -96,60 +102,6 @@ def show_with_vtk(meshes: list[trimesh.Trimesh]) -> None: render_window_interactor.Start() -def visualize_meshes_from_db(hdf5_path: str, mesh_names_to_show: list[str], use_vtk: bool) -> None: - """Loads one or more meshes from HDF5 databases and visualizes them in a single scene.""" - loaded_meshes = [] - - db_file = Path(hdf5_path) - if not db_file.exists(): - click.echo(f"❌ Error: Database file '{hdf5_path}' not found.", err=True) - return - - for mesh_name in mesh_names_to_show: - found_mesh_data = None - mesh_group_path = f"meshes/{mesh_name}" - try: - logger.debug(f"Opening database {db_file}") - with h5py.File(db_file, "r") as f: - if mesh_group_path in f: - click.echo(f"📦 Loading mesh '{mesh_name}' from '{hdf5_path}'...") - found_mesh_data = f[mesh_group_path]["stl_content"][()] - except Exception as e: - logger.exception(f"Error reading mesh {mesh_group_path}' from '{hdf5_path}'") - click.echo(f"❌ Error reading '{hdf5_path}': {e}", err=True) - continue - - if found_mesh_data: # A non-empty numpy.void object evaluates to True. - try: - # The data is stored as a numpy.void object, which must be converted to bytes. - stl_bytes = found_mesh_data.tobytes() - if mesh := trimesh.load_mesh( - io.BytesIO(stl_bytes), file_type="stl" - ): - loaded_meshes.append(mesh) - except Exception as e: - logger.exception(f"Failed to parse mesh '{mesh_name}'") - click.echo(f"❌ Error parsing mesh '{mesh_name}': {e}", err=True) - else: - click.echo(f"❌ Warning: Mesh '{mesh_name}' not found in any of the specified files.", err=True) - - if not loaded_meshes: - click.echo("No meshes were loaded. Nothing to display.", err=True) - return - - if use_vtk: - show_with_vtk(loaded_meshes) - else: - click.echo(f"🎨 Displaying {len(loaded_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, *loaded_meshes]) - - logger.debug("Showing with solid mode. Toggle with w/s to go to wireframe") - scene.show() - - @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) @@ -159,8 +111,8 @@ def view(hdf5_file: str, mesh_names: tuple[str, ...], vtk: bool, show_all: bool) """ 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. + 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. @@ -199,4 +151,19 @@ def view(hdf5_file: str, mesh_names: tuple[str, ...], vtk: bool, show_all: bool) ) return - visualize_meshes_from_db(hdf5_file, sorted(resolved_mesh_names), vtk) + 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/io.py b/src/fleetmaster/core/io.py new file mode 100644 index 0000000..9042b3a --- /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") + + 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 \ No newline at end of file diff --git a/src/fleetmaster/core/visualize_db_mesh.py b/src/fleetmaster/core/visualize_db_mesh.py index 75dd7af..e69de29 100644 --- a/src/fleetmaster/core/visualize_db_mesh.py +++ b/src/fleetmaster/core/visualize_db_mesh.py @@ -1,135 +0,0 @@ -import argparse -import io -from pathlib import Path - -import h5py -import numpy as np -import trimesh - -# 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 -except ImportError: - VTK_AVAILABLE = False - - -def show_with_trimesh(mesh: trimesh.Trimesh) -> None: - """Visualizes the mesh using the built-in trimesh viewer.""" - print("🎨 Displaying mesh with trimesh viewer. Close the window to continue.") - mesh.show() - - -def show_with_vtk(mesh: trimesh.Trimesh) -> None: - """Visualizes the mesh using a VTK pipeline.""" - if not VTK_AVAILABLE: - print("❌ Error: The 'vtk' library is not installed. Please install it with 'pip install vtk'.") - return - - print("🎨 Displaying mesh with VTK viewer. Close the window to continue.") - - # 1. Convert trimesh data to VTK format - # Get vertices and faces - vertices = mesh.vertices - # VTK requires a specific format for faces: [num_points, p1_idx, p2_idx, p3_idx, ...] - faces = np.hstack((np.full((len(mesh.faces), 1), 3), mesh.faces)).flatten() - - # Create vtkPoints for the vertices - vtk_points = vtk.vtkPoints() - vtk_points.SetData(numpy_to_vtk(vertices, deep=True)) - - # Create vtkCellArray for the faces - vtk_cells = vtk.vtkCellArray() - vtk_cells.SetCells(len(mesh.faces), numpy_to_vtk(faces, deep=True, array_type=vtk.VTK_ID_TYPE)) - - # 2. Create the vtkPolyData (the actual geometry) - poly_data = vtk.vtkPolyData() - poly_data.SetPoints(vtk_points) - poly_data.SetPolys(vtk_cells) - - # 3. Set up the visualization pipeline - # Mapper: Connects the geometry to the graphics hardware - mapper = vtk.vtkPolyDataMapper() - mapper.SetInputData(poly_data) - - # Actor: Represents the object in the scene (position, color, etc.) - actor = vtk.vtkActor() - actor.SetMapper(mapper) - actor.GetProperty().SetColor(0.8, 0.8, 1.0) # Light blue - actor.GetProperty().EdgeVisibilityOn() - actor.GetProperty().SetEdgeColor(0.1, 0.1, 0.2) - - # Renderer: Manages the scene, camera, and lighting - renderer = vtk.vtkRenderer() - renderer.AddActor(actor) - renderer.SetBackground(0.1, 0.2, 0.3) # Dark blue/gray - - # Add an axes actor for context - axes = vtk.vtkAxesActor() - widget = vtk.vtkOrientationMarkerWidget() - 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() - - -def visualize_mesh_from_db(hdf5_path: str, mesh_name: str, use_vtk: bool) -> None: - """ - Loads a specific mesh from the HDF5 database and visualizes it. - """ - db_file = Path(hdf5_path) - if not db_file.exists(): - print(f"❌ Error: Database file '{hdf5_path}' not found.") - return - - mesh_group_path = f"meshes/{mesh_name}" - - with h5py.File(db_file, "r") as f: - if mesh_group_path not in f: - print(f"❌ Error: Mesh '{mesh_name}' not found in {hdf5_path}.") - if available_meshes := list(f.get("meshes", {}).keys()): - print("\nAvailable meshes are:") - for name in available_meshes: - print(f" - {name}") - return - - print(f"📦 Loading mesh '{mesh_name}' from the database...") - stl_binary_content = f[mesh_group_path]["stl_content"][()] - - stl_file_in_memory = io.BytesIO(stl_binary_content) - mesh = trimesh.load_mesh(stl_file_in_memory, file_type="stl") - - if use_vtk: - show_with_vtk(mesh) - else: - show_with_trimesh(mesh) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Visualize a mesh from the Fleetmaster HDF5 database.") - parser.add_argument("mesh_name", help="The name of the mesh to visualize (e.g., 'barge_draft_1.0').") - parser.add_argument("--file", default="results.hdf5", help="Path to the HDF5 database file.") - parser.add_argument("--vtk", action="store_true", help="Use the VTK viewer instead of the default trimesh viewer.") - - args = parser.parse_args() - - visualize_mesh_from_db(args.file, args.mesh_name, args.vtk) From 52aaf9ca8e7665d49b0b9f57ebef534b153da5a9 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 01:19:57 +0200 Subject: [PATCH 31/34] removed --- src/fleetmaster/core/visualize_db_mesh.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/fleetmaster/core/visualize_db_mesh.py diff --git a/src/fleetmaster/core/visualize_db_mesh.py b/src/fleetmaster/core/visualize_db_mesh.py deleted file mode 100644 index e69de29..0000000 From dde922caba61dc11637a07034f264ffc9d5e07fa Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 01:20:31 +0200 Subject: [PATCH 32/34] reformat --- CHANGELOG.md | 3 +++ src/fleetmaster/commands/view.py | 1 + src/fleetmaster/core/io.py | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0d0d94..da170c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +7,18 @@ All notable changes to this project will be documented in this file. ## [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. diff --git a/src/fleetmaster/commands/view.py b/src/fleetmaster/commands/view.py index 7060da2..eb75409 100644 --- a/src/fleetmaster/commands/view.py +++ b/src/fleetmaster/commands/view.py @@ -31,6 +31,7 @@ (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.") diff --git a/src/fleetmaster/core/io.py b/src/fleetmaster/core/io.py index 9042b3a..531f779 100644 --- a/src/fleetmaster/core/io.py +++ b/src/fleetmaster/core/io.py @@ -30,4 +30,4 @@ def load_meshes_from_hdf5( meshes.append(mesh) except Exception: logger.exception("Failed to parse mesh %r", name) - return meshes \ No newline at end of file + return meshes From 1e98020ae4562850dfec9fbd9e0e80352cb20795 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 01:22:47 +0200 Subject: [PATCH 33/34] followd sourcey advises --- src/fleetmaster/commands/view.py | 7 ++++--- src/fleetmaster/core/io.py | 2 +- tests/test_engine.py | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/fleetmaster/commands/view.py b/src/fleetmaster/commands/view.py index eb75409..7af4d05 100644 --- a/src/fleetmaster/commands/view.py +++ b/src/fleetmaster/commands/view.py @@ -3,6 +3,7 @@ import logging from itertools import cycle from pathlib import Path +from typing import Any import click import h5py @@ -10,6 +11,8 @@ 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 @@ -22,8 +25,6 @@ except ImportError: VTK_AVAILABLE = False -from fleetmaster.core.io import load_meshes_from_hdf5 - VTK_COLORS = [ (0.8, 0.8, 1.0), # Light Blue (1.0, 0.8, 0.8), # Light Red @@ -38,7 +39,7 @@ def show_with_trimesh(mesh: trimesh.Trimesh) -> None: mesh.show() -def _vtk_actor_from_trimesh(mesh: trimesh.Trimesh, color: tuple[float, float, float]) -> vtk.vtkActor: +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)) diff --git a/src/fleetmaster/core/io.py b/src/fleetmaster/core/io.py index 531f779..98f2d83 100644 --- a/src/fleetmaster/core/io.py +++ b/src/fleetmaster/core/io.py @@ -15,7 +15,7 @@ def load_meshes_from_hdf5( """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") + raise FileNotFoundError(f"{hdf5_path} not found") # noqa: TRY003 with h5py.File(hdf5_path, "r") as f: for name in mesh_names: diff --git a/tests/test_engine.py b/tests/test_engine.py index 10be172..900833c 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -164,6 +164,7 @@ def test_add_mesh_to_database_new(tmp_path): add_mesh_to_database(output_file, mock_mesh, "mesh", overwrite=False) + file_hash = hashlib.sha256(stl_content).hexdigest() # Assert that mesh attributes and datasets remain unchanged with h5py.File(output_file, "r") as f: group = f["meshes/mesh"] From 4564cdbf858ef073ca36378417f4733b7878db41 Mon Sep 17 00:00:00 2001 From: Eelco van Vliet Date: Thu, 23 Oct 2025 01:23:59 +0200 Subject: [PATCH 34/34] fixed unit tests --- tests/test_engine.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/test_engine.py b/tests/test_engine.py index 900833c..89b8ed1 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -165,18 +165,14 @@ def test_add_mesh_to_database_new(tmp_path): add_mesh_to_database(output_file, mock_mesh, "mesh", overwrite=False) file_hash = hashlib.sha256(stl_content).hexdigest() - # Assert that mesh attributes and datasets remain unchanged with h5py.File(output_file, "r") as f: group = f["meshes/mesh"] - # Check that the sha256 attribute is unchanged + # Check that all attributes and datasets are correctly written assert group.attrs["sha256"] == file_hash - # Check that no new attributes have been added - assert len(group.attrs) == 1 - # Check that no datasets have been added to the group - assert list(group.keys()) == [] - assert "sha256" in group.attrs 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]