diff --git a/docs/API_MAP.md b/docs/API_MAP.md index 6b0b7ec..a7360df 100644 --- a/docs/API_MAP.md +++ b/docs/API_MAP.md @@ -15,8 +15,8 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ ## experiments/Convert_VTK_To_USD/convert_vtk_to_usd_using_class.py -- `def create_deformed_mesh(base_mesh_data, time_step, num_steps=10)` (line 276): Create a deformed version of the mesh for animation. -- `def verify_usd_file(usd_path)` (line 391): Verify USD file integrity. +- `def create_deformed_pv_mesh(base, time_step, num_steps=10)` (line 237): Return a sinusoidally scaled copy of base with a synthetic pressure field. +- `def verify_usd_file(usd_path)` (line 315): Verify USD file integrity. ## experiments/DisplacementField_To_USD/displacement_field_to_usd.py @@ -115,12 +115,13 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ ## src/physiomotion4d/convert_vtk_to_usd.py -- **class ConvertVTKToUSD** (line 36): Advanced VTK to USD converter with colormap and anatomical labeling support. - - `def __init__(self, data_basename, input_polydata, mask_ids=None, compute_normals=False, convert_to_surface=True, times_per_second=24.0, log_level=logging.INFO)` (line 66): Initialize converter. - - `def supports_mesh_type(self, mesh)` (line 119): Check if mesh type is supported for conversion. - - `def list_available_arrays(self)` (line 147): List all point data arrays available across all time steps. - - `def set_colormap(self, color_by_array=None, colormap='plasma', intensity_range=None)` (line 193): Configure colormap for visualization. - - `def convert(self, output_usd_file, convert_to_surface=None, compute_normals=None)` (line 227): Convert VTK meshes to USD. +- **class ConvertVTKToUSD** (line 38): Advanced VTK to USD converter with colormap and anatomical labeling support. + - `def __init__(self, data_basename, input_polydata, mask_ids=None, compute_normals=False, convert_to_surface=True, times_per_second=24.0, separate_by='none', solid_color=(0.8, 0.8, 0.8), log_level=logging.INFO)` (line 68): Initialize converter. + - `def from_files(cls, data_basename, vtk_files, *, extract_surface=True, separate_by='none', times_per_second=24.0, solid_color=(0.8, 0.8, 0.8), time_codes=None, static_merge=False, mask_ids=None, log_level=logging.INFO)` (line 139): Create a converter by loading VTK files from disk. + - `def supports_mesh_type(self, mesh)` (line 239): Check if mesh type is supported for conversion. + - `def list_available_arrays(self)` (line 267): List all point data arrays available across all time steps. + - `def set_colormap(self, color_by_array=None, colormap='plasma', intensity_range=None)` (line 313): Configure colormap for visualization. + - `def convert(self, output_usd_file, convert_to_surface=None, compute_normals=None)` (line 347): Convert VTK meshes to USD. ## src/physiomotion4d/image_tools.py @@ -342,18 +343,6 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ - `def list_mesh_paths_under(self, stage_or_path, parent_path='/World/Meshes')` (line 1103): List paths of all mesh prims under a parent path. - `def repair_mesh_primvar_element_sizes(self, stage_or_path, mesh_path, *, time_code=None, save=True)` (line 1130): Repair missing/incorrect primvar elementSize metadata for a mesh. -## src/physiomotion4d/vtk_to_usd/converter.py - -- **class VTKToUSDConverter** (line 21): High-level converter for VTK files to USD. - - `def __init__(self, settings=None)` (line 37): Initialize converter. - - `def convert_file(self, vtk_file, output_usd, mesh_name='Mesh', material=None, extract_surface=True)` (line 48): Convert a single VTK file to USD. - - `def convert_files_static(self, vtk_files, output_usd, mesh_name='Mesh', material=None, extract_surface=True)` (line 114): Convert multiple VTK files into one static USD stage (no time samples). - - `def convert_sequence(self, vtk_files, output_usd, mesh_name='Mesh', time_codes=None, material=None, extract_surface=True)` (line 190): Convert a sequence of VTK files to time-varying USD. - - `def convert_mesh_data(self, mesh_data, output_usd, mesh_name='Mesh', material=None)` (line 316): Convert MeshData directly to USD. - - `def convert_mesh_data_sequence(self, mesh_data_sequence, output_usd, mesh_name='Mesh', time_codes=None, material=None)` (line 378): Convert sequence of MeshData to time-varying USD. -- `def convert_vtk_file(vtk_file, output_usd, settings=None, **kwargs)` (line 555): Convenience function to convert a single VTK file. -- `def convert_vtk_sequence(vtk_files, output_usd, settings=None, **kwargs)` (line 576): Convenience function to convert a sequence of VTK files. - ## src/physiomotion4d/vtk_to_usd/data_structures.py - **class DataType** (line 13): Data type enumeration for generic arrays. @@ -428,10 +417,10 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ ## src/physiomotion4d/workflow_convert_vtk_to_usd.py -- **class WorkflowConvertVTKToUSD** (line 29): Workflow to convert one or more VTK files to USD with configurable - - `def __init__(self, vtk_files, output_usd, *, separate_by_connectivity=True, separate_by_cell_type=False, mesh_name='Mesh', times_per_second=60.0, up_axis='Y', triangulate=True, extract_surface=True, time_series_pattern='\\.t(\\d+)\\.(vtk|vtp|vtu)$', appearance='solid', solid_color=(0.8, 0.8, 0.8), anatomy_type='heart', colormap_primvar=None, colormap_name='viridis', colormap_intensity_range=None, log_level=logging.INFO)` (line 35): Initialize the VTK-to-USD workflow. - - `def discover_time_series(self, paths, pattern='\\.t(\\d+)\\.(vtk|vtp|vtu)$')` (line 105): Discover and sort time-series VTK files by extracted time index. - - `def run(self)` (line 141): Run the full workflow: convert VTK to USD, then apply the chosen appearance. +- **class WorkflowConvertVTKToUSD** (line 23): Workflow to convert one or more VTK files to USD with configurable + - `def __init__(self, vtk_files, output_usd, *, separate_by_connectivity=True, separate_by_cell_type=False, mesh_name='Mesh', times_per_second=60.0, extract_surface=True, time_series_pattern='\\.t(\\d+)\\.(vtk|vtp|vtu)$', appearance='solid', solid_color=(0.8, 0.8, 0.8), anatomy_type='heart', colormap_primvar=None, colormap_name='viridis', colormap_intensity_range=None, log_level=logging.INFO)` (line 29): Initialize the VTK-to-USD workflow. + - `def discover_time_series(self, paths, pattern='\\.t(\\d+)\\.(vtk|vtp|vtu)$')` (line 93): Discover and sort time-series VTK files by extracted time index. + - `def run(self)` (line 129): Run the full workflow: convert VTK to USD, then apply the chosen appearance. ## src/physiomotion4d/workflow_create_statistical_model.py @@ -514,18 +503,24 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ ## tests/test_convert_vtk_to_usd.py -- **class TestConvertVTKToUSD** (line 23): Test suite for VTK to USD PolyMesh conversion. - - `def contour_meshes(self, contour_tools, test_labelmaps, test_directories)` (line 27): Extract or load contour meshes for USD conversion testing. - - `def test_converter_initialization(self)` (line 65): Test that ConvertVTKToUSD initializes correctly. - - `def test_supports_mesh_type(self, contour_meshes)` (line 76): Test that converter correctly identifies supported mesh types. - - `def test_convert_single_time_point(self, contour_meshes, test_directories)` (line 89): Test converting a single time point to USD. - - `def test_convert_multiple_time_points(self, contour_meshes, test_directories)` (line 122): Test converting multiple time points to USD. - - `def test_convert_with_deformation(self, contour_tools, test_labelmaps, test_directories)` (line 157): Test converting meshes with deformation magnitude. - - `def test_convert_with_colormap(self, contour_meshes, test_directories)` (line 198): Test converting meshes with colormap visualization. - - `def test_convert_unstructured_grid_to_surface(self, test_directories)` (line 237): Test converting UnstructuredGrid to surface mesh. - - `def test_usd_file_structure(self, contour_meshes, test_directories)` (line 285): Test the structure of generated USD file. - - `def test_time_varying_topology(self, contour_meshes, test_directories)` (line 317): Test handling of time-varying topology. - - `def test_batch_conversion(self, contour_tools, test_labelmaps, test_directories)` (line 358): Test converting multiple anatomy structures in batch. +- **class TestConvertVTKToUSD** (line 37): Test suite for VTK to USD PolyMesh conversion. + - `def contour_meshes(self, contour_tools, test_labelmaps, test_directories)` (line 41): Extract or load contour meshes for USD conversion testing. + - `def test_converter_initialization(self)` (line 79): Test that ConvertVTKToUSD initializes correctly. + - `def test_supports_mesh_type(self, contour_meshes)` (line 90): Test that converter correctly identifies supported mesh types. + - `def test_convert_single_time_point(self, contour_meshes, test_directories)` (line 103): Test converting a single time point to USD. + - `def test_convert_multiple_time_points(self, contour_meshes, test_directories)` (line 136): Test converting multiple time points to USD. + - `def test_convert_with_deformation(self, contour_tools, test_labelmaps, test_directories)` (line 171): Test converting meshes with deformation magnitude. + - `def test_convert_with_colormap(self, contour_meshes, test_directories)` (line 212): Test converting meshes with colormap visualization. + - `def test_convert_unstructured_grid_to_surface(self, test_directories)` (line 251): Test converting UnstructuredGrid to surface mesh. + - `def test_usd_file_structure(self, contour_meshes, test_directories)` (line 299): Test the structure of generated USD file. + - `def test_time_varying_topology(self, contour_meshes, test_directories)` (line 331): Test handling of time-varying topology. + - `def test_batch_conversion(self, contour_tools, test_labelmaps, test_directories)` (line 372): Test converting multiple anatomy structures in batch. +- **class TestSyntheticConversion** (line 425): Synthetic (no-disk-data) tests for ConvertVTKToUSD. + - `def test_single_frame_prim_has_time_sample(self, tmp_path)` (line 438): Single-frame _convert_unified() must author one time sample, not a static prim. + - `def test_static_merge_prim_names_use_data_basename(self, tmp_path)` (line 454): Static-merge prims must be named {data_basename}_{i}, not Mesh_{i}. + - `def test_mask_ids_basic_produces_per_label_prims(self, tmp_path)` (line 486): mask_ids must produce one USD prim per label; no unified /Mesh prim. + - `def test_mask_ids_missing_label_filters_time_codes(self, tmp_path)` (line 506): Time codes for a label must be filtered to frames where it actually appears. + - `def test_mask_ids_missing_boundary_labels_falls_back(self, tmp_path)` (line 541): Mesh without boundary_labels array falls back to a 'default' prim. ## tests/test_download_heart_data.py @@ -712,32 +707,40 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ ## tests/test_vtk_to_usd_library.py -- `def get_data_dir()` (line 35): Get the data directory path. -- `def check_kcl_heart_data()` (line 42): Check if KCL Heart Model data is available. -- `def check_valve4d_data()` (line 49): Check if CHOP Valve4D data is available. -- `def get_or_create_average_surface(test_directories)` (line 56): Get or create average_surface.vtp from average_mesh.vtk. -- `def kcl_average_surface(test_directories)` (line 102): Fixture providing the KCL average heart surface. -- **class TestGenericArray** (line 118): Test GenericArray data structure validation and reshaping. - - `def test_scalar_1d_array(self)` (line 121): Test that 1D scalar arrays (num_components=1) are kept as-is. - - `def test_flat_multicomponent_array_reshape(self)` (line 134): Test that flat 1D arrays with num_components>1 are reshaped to 2D. - - `def test_2d_array_valid(self)` (line 150): Test that 2D arrays with correct shape are accepted. - - `def test_flat_array_not_divisible_raises_error(self)` (line 163): Test that flat arrays with length not divisible by num_components raise error. - - `def test_2d_array_wrong_shape_raises_error(self)` (line 174): Test that 2D arrays with wrong shape raise error. - - `def test_3d_array_raises_error(self)` (line 185): Test that 3D arrays are rejected. - - `def test_flat_array_large_components(self)` (line 196): Test reshaping with large num_components (e.g., 9 for 3x3 tensors). -- **class TestVTKReader** (line 212): Test VTK file reading capabilities. - - `def test_read_vtp_file(self, kcl_average_surface)` (line 215): Test reading VTP (PolyData) files. - - `def test_read_legacy_vtk_file(self)` (line 236): Test reading legacy VTK files. - - `def test_generic_arrays_preserved(self, kcl_average_surface)` (line 263): Test that generic data arrays are preserved during reading. -- **class TestVTKToUSDConversion** (line 287): Test VTK to USD conversion capabilities. - - `def test_single_file_conversion(self, test_directories, kcl_average_surface)` (line 290): Test converting a single VTK file to USD. - - `def test_conversion_with_material(self, test_directories, kcl_average_surface)` (line 332): Test conversion with custom material. - - `def test_conversion_settings(self, test_directories, kcl_average_surface)` (line 379): Test conversion with custom settings. - - `def test_primvar_preservation(self, test_directories, kcl_average_surface)` (line 414): Test that VTK data arrays are preserved as USD primvars. -- **class TestTimeSeriesConversion** (line 454): Test time-series conversion capabilities. - - `def test_time_series_conversion(self, test_directories, kcl_average_surface)` (line 457): Test converting multiple VTK files as time series. -- **class TestIntegration** (line 507): Integration tests combining multiple features. - - `def test_end_to_end_conversion(self, test_directories, kcl_average_surface)` (line 510): Test complete conversion workflow with all features. +- `def get_data_dir()` (line 34): Get the data directory path. +- `def check_kcl_heart_data()` (line 41): Check if KCL Heart Model data is available. +- `def check_valve4d_data()` (line 48): Check if CHOP Valve4D data is available. +- `def get_or_create_average_surface(test_directories)` (line 55): Get or create average_surface.vtp from average_mesh.vtk. +- `def kcl_average_surface(test_directories)` (line 101): Fixture providing the KCL average heart surface. +- **class TestGenericArray** (line 117): Test GenericArray data structure validation and reshaping. + - `def test_scalar_1d_array(self)` (line 120): Test that 1D scalar arrays (num_components=1) are kept as-is. + - `def test_flat_multicomponent_array_reshape(self)` (line 133): Test that flat 1D arrays with num_components>1 are reshaped to 2D. + - `def test_2d_array_valid(self)` (line 149): Test that 2D arrays with correct shape are accepted. + - `def test_flat_array_not_divisible_raises_error(self)` (line 162): Test that flat arrays with length not divisible by num_components raise error. + - `def test_2d_array_wrong_shape_raises_error(self)` (line 173): Test that 2D arrays with wrong shape raise error. + - `def test_3d_array_raises_error(self)` (line 184): Test that 3D arrays are rejected. + - `def test_flat_array_large_components(self)` (line 195): Test reshaping with large num_components (e.g., 9 for 3x3 tensors). +- **class TestFromFilesValidation** (line 210): Synthetic tests for ConvertVTKToUSD.from_files() — no real data required. + - `def test_time_codes_length_mismatch_raises(self, tmp_path)` (line 222): from_files() must reject time_codes whose length != len(vtk_files). + - `def test_time_codes_non_monotone_raises(self, tmp_path)` (line 232): from_files() must reject time_codes that decrease between frames. + - `def test_time_codes_equal_consecutive_is_valid(self, tmp_path)` (line 242): Equal consecutive time codes are non-decreasing and must not raise. + - `def test_from_files_populates_cached_mesh_data(self, tmp_path)` (line 256): from_files() with >1 frame must populate _cached_mesh_data. + - `def test_from_files_cache_reused_in_convert(self, tmp_path)` (line 269): _convert_unified() must not call _vtk_to_mesh_data() when cache is populated. + - `def test_from_files_single_file_no_cache(self, tmp_path)` (line 287): A single-file converter must not populate _cached_mesh_data. + - `def test_from_files_static_merge_no_cache(self, tmp_path)` (line 295): static_merge=True must not populate _cached_mesh_data. +- **class TestVTKReader** (line 307): Test VTK file reading capabilities. + - `def test_read_vtp_file(self, kcl_average_surface)` (line 310): Test reading VTP (PolyData) files. + - `def test_read_legacy_vtk_file(self)` (line 331): Test reading legacy VTK files. + - `def test_generic_arrays_preserved(self, kcl_average_surface)` (line 358): Test that generic data arrays are preserved during reading. +- **class TestVTKToUSDConversion** (line 382): Test VTK to USD conversion capabilities. + - `def test_single_file_conversion(self, test_directories, kcl_average_surface)` (line 385): Test converting a single VTK file to USD. + - `def test_conversion_with_material(self, test_directories, kcl_average_surface)` (line 417): Test conversion with a custom solid color material. + - `def test_conversion_settings(self, test_directories, kcl_average_surface)` (line 455): Test that ConvertVTKToUSD applies correct default stage metadata. + - `def test_primvar_preservation(self, test_directories, kcl_average_surface)` (line 478): Test that VTK data arrays are preserved as USD primvars. +- **class TestTimeSeriesConversion** (line 514): Test time-series conversion capabilities. + - `def test_time_series_conversion(self, test_directories, kcl_average_surface)` (line 517): Test converting multiple VTK files as a time series. +- **class TestIntegration** (line 557): Integration tests combining multiple features. + - `def test_end_to_end_conversion(self, test_directories, kcl_average_surface)` (line 560): Test complete conversion workflow with all features. ## utils/claude_github_reviews.py diff --git a/experiments/Convert_VTK_To_USD/convert_chop_alterra_valve_to_usd.py b/experiments/Convert_VTK_To_USD/convert_chop_alterra_valve_to_usd.py index cff6948..491ed1e 100644 --- a/experiments/Convert_VTK_To_USD/convert_chop_alterra_valve_to_usd.py +++ b/experiments/Convert_VTK_To_USD/convert_chop_alterra_valve_to_usd.py @@ -37,15 +37,11 @@ # Import USDTools for post-processing colormap from physiomotion4d.usd_tools import USDTools -# Import the vtk_to_usd library -from physiomotion4d.vtk_to_usd import ( - ConversionSettings, - MaterialData, - VTKToUSDConverter, - cell_type_name_for_vertex_count, - read_vtk_file, - validate_time_series_topology, -) +from physiomotion4d import ConvertVTKToUSD + +# cell_type_name_for_vertex_count and read_vtk_file are internal APIs used for diagnostics +from physiomotion4d.vtk_to_usd import cell_type_name_for_vertex_count +from physiomotion4d.vtk_to_usd.vtk_reader import read_vtk_file # %% [markdown] # ## 1. Discover and Organize Time-Series Files @@ -71,25 +67,10 @@ colormap_range_min = 25 colormap_range_max = 200 -conversion_settings = ConversionSettings( - triangulate_meshes=True, - compute_normals=False, # Use existing normals if available - preserve_point_arrays=True, - preserve_cell_arrays=True, - separate_objects_by_cell_type=False, - separate_objects_by_connectivity=True, # Essential for alterra vtk file - up_axis="Y", - times_per_second=60.0, # 60 FPS for smooth animation - use_time_samples=True, -) - -stent_material = MaterialData( - name="Alterra_valve", - diffuse_color=(0.5, 0.5, 0.5), - roughness=0.4, - metallic=0.9, - use_vertex_colors=False, -) +# Conversion parameters +separate_by = "connectivity" # Essential for alterra vtk file +times_per_second = 60.0 +solid_color = (0.5, 0.5, 0.5) # %% output_dir.mkdir(parents=True, exist_ok=True) @@ -155,8 +136,6 @@ # ## 3. Convert TPV25 # %% -converter = VTKToUSDConverter(conversion_settings) - alterra_files = [file_path for _, file_path in alterra_series] alterra_times = [float(time_step) for time_step, _ in alterra_series] @@ -168,39 +147,38 @@ print(f"Number of time steps: {len(alterra_times)}") print("\nThis may take several minutes...\n") -# Read MeshData -mesh_data_sequence = [read_vtk_file(f, extract_surface=True) for f in alterra_files] - -# Validate topology consistency across time series -validation_report = validate_time_series_topology( - mesh_data_sequence, filenames=alterra_files -) -if not validation_report["is_consistent"]: - print( - f"Warning: Found {len(validation_report['warnings'])} topology/primvar issues" - ) - if validation_report["topology_changes"]: - print( - f" Topology changes in {len(validation_report['topology_changes'])} frames" - ) - -# Convert to USD (preserves all primvars from VTK) -stage = converter.convert_mesh_data_sequence( - mesh_data_sequence=mesh_data_sequence, - output_usd=output_usd, - mesh_name="AlterraValve", +# topology validation and conversion happen inside from_files() +stage = ConvertVTKToUSD.from_files( + data_basename="AlterraValve", + vtk_files=alterra_files, + extract_surface=True, + separate_by=separate_by, + times_per_second=times_per_second, + solid_color=solid_color, time_codes=alterra_times, - material=stent_material, -) +).convert(str(output_usd)) # %% usd_tools = USDTools() -if conversion_settings.separate_objects_by_connectivity is True: - vessel_path = "/World/Meshes/AlterraValve_object3" -elif conversion_settings.separate_objects_by_cell_type is True: - vessel_path = "/World/Meshes/AlterraValve_triangle1" +# ConvertVTKToUSD places prims at /World/{basename}/{part_name}. +# Discover the target prim dynamically so the path stays valid regardless +# of how many connected components or cell types the VTK file produces. +if separate_by == "connectivity": + mesh_paths = usd_tools.list_mesh_paths_under( + stage, parent_path="/World/AlterraValve" + ) + candidates = [ + p for p in mesh_paths if p.split("/")[-1].startswith("AlterraValve_object") + ] + vessel_path = candidates[-1] if candidates else "/World/AlterraValve/Mesh" +elif separate_by == "cell_type": + mesh_paths = usd_tools.list_mesh_paths_under( + stage, parent_path="/World/AlterraValve" + ) + triangle_paths = [p for p in mesh_paths if p.split("/")[-1].endswith("_Triangle")] + vessel_path = triangle_paths[0] if triangle_paths else "/World/AlterraValve/Mesh" else: - vessel_path = "/World/Meshes/AlterraValve" + vessel_path = "/World/AlterraValve/Mesh" # Select primvar for coloring primvars = usd_tools.list_mesh_primvars(str(output_usd), vessel_path) diff --git a/experiments/Convert_VTK_To_USD/convert_chop_tpv25_valve_to_usd.py b/experiments/Convert_VTK_To_USD/convert_chop_tpv25_valve_to_usd.py index f24bee6..4ecdb8b 100644 --- a/experiments/Convert_VTK_To_USD/convert_chop_tpv25_valve_to_usd.py +++ b/experiments/Convert_VTK_To_USD/convert_chop_tpv25_valve_to_usd.py @@ -36,15 +36,11 @@ # Import USDTools for post-processing colormap from physiomotion4d.usd_tools import USDTools -# Import the vtk_to_usd library -from physiomotion4d.vtk_to_usd import ( - ConversionSettings, - MaterialData, - VTKToUSDConverter, - cell_type_name_for_vertex_count, - read_vtk_file, - validate_time_series_topology, -) +from physiomotion4d import ConvertVTKToUSD + +# cell_type_name_for_vertex_count and read_vtk_file are internal APIs used for diagnostics +from physiomotion4d.vtk_to_usd import cell_type_name_for_vertex_count +from physiomotion4d.vtk_to_usd.vtk_reader import read_vtk_file # %% [markdown] # ## 1. Discover and Organize Time-Series Files @@ -70,25 +66,10 @@ colormap_range_min = 25 colormap_range_max = 200 -conversion_settings = ConversionSettings( - triangulate_meshes=True, - compute_normals=False, # Use existing normals if available - preserve_point_arrays=True, - preserve_cell_arrays=True, - separate_objects_by_cell_type=False, - separate_objects_by_connectivity=True, # Essential for tpv25 vtk file - up_axis="Y", - times_per_second=60.0, # 60 FPS for smooth animation - use_time_samples=True, -) - -stent_material = MaterialData( - name="tpv25_valve", - diffuse_color=(0.5, 0.5, 0.5), - roughness=0.4, - metallic=0.9, - use_vertex_colors=False, -) +# Conversion parameters +separate_by = "connectivity" # Essential for tpv25 vtk file +times_per_second = 60.0 +solid_color = (0.5, 0.5, 0.5) # %% output_dir.mkdir(parents=True, exist_ok=True) @@ -154,8 +135,6 @@ # ## 3. Convert TPV25 # %% -converter = VTKToUSDConverter(conversion_settings) - tpv25_files = [file_path for _, file_path in tpv25_series] tpv25_times = [float(time_step) for time_step, _ in tpv25_series] @@ -167,39 +146,34 @@ print(f"Number of time steps: {len(tpv25_times)}") print("\nThis may take several minutes...\n") -# Read MeshData -mesh_data_sequence = [read_vtk_file(f, extract_surface=True) for f in tpv25_files] - -# Validate topology consistency across time series -validation_report = validate_time_series_topology( - mesh_data_sequence, filenames=tpv25_files -) -if not validation_report["is_consistent"]: - print( - f"Warning: Found {len(validation_report['warnings'])} topology/primvar issues" - ) - if validation_report["topology_changes"]: - print( - f" Topology changes in {len(validation_report['topology_changes'])} frames" - ) - -# Convert to USD (preserves all primvars from VTK) -stage = converter.convert_mesh_data_sequence( - mesh_data_sequence=mesh_data_sequence, - output_usd=output_usd, - mesh_name="TPV25Valve", +# topology validation and conversion happen inside from_files() +stage = ConvertVTKToUSD.from_files( + data_basename="TPV25Valve", + vtk_files=tpv25_files, + extract_surface=True, + separate_by=separate_by, + times_per_second=times_per_second, + solid_color=solid_color, time_codes=tpv25_times, - material=stent_material, -) +).convert(str(output_usd)) # %% usd_tools = USDTools() -if conversion_settings.separate_objects_by_connectivity is True: - vessel_path = "/World/Meshes/TPV25Valve_object4" -elif conversion_settings.separate_objects_by_cell_type is True: - vessel_path = "/World/Meshes/TPV25Valve_triangle1" +# ConvertVTKToUSD places prims at /World/{basename}/{part_name}. +# Discover the target prim dynamically so the path stays valid regardless +# of how many connected components or cell types the VTK file produces. +if separate_by == "connectivity": + mesh_paths = usd_tools.list_mesh_paths_under(stage, parent_path="/World/TPV25Valve") + candidates = [ + p for p in mesh_paths if p.split("/")[-1].startswith("TPV25Valve_object") + ] + vessel_path = candidates[-1] if candidates else "/World/TPV25Valve/Mesh" +elif separate_by == "cell_type": + mesh_paths = usd_tools.list_mesh_paths_under(stage, parent_path="/World/TPV25Valve") + triangle_paths = [p for p in mesh_paths if p.split("/")[-1].endswith("_Triangle")] + vessel_path = triangle_paths[0] if triangle_paths else "/World/TPV25Valve/Mesh" else: - vessel_path = "/World/Meshes/TPV25Valve" + vessel_path = "/World/TPV25Valve/Mesh" # Select primvar for coloring primvars = usd_tools.list_mesh_primvars(str(output_usd), vessel_path) diff --git a/experiments/Convert_VTK_To_USD/convert_vtk_to_usd_using_class.py b/experiments/Convert_VTK_To_USD/convert_vtk_to_usd_using_class.py index 553adff..c5862b8 100644 --- a/experiments/Convert_VTK_To_USD/convert_vtk_to_usd_using_class.py +++ b/experiments/Convert_VTK_To_USD/convert_vtk_to_usd_using_class.py @@ -22,7 +22,6 @@ # - `average_mesh.vtk`: Volumetric mesh of the heart # %% -import copy import logging import os from pathlib import Path @@ -31,18 +30,10 @@ import pyvista as pv from pxr import Usd, UsdGeom, UsdShade -from physiomotion4d import ContourTools +from physiomotion4d import ContourTools, ConvertVTKToUSD -# Import the new vtk_to_usd library -from physiomotion4d.vtk_to_usd import ( - VTKToUSDConverter, - ConversionSettings, - DataType, - GenericArray, - MaterialData, - convert_vtk_file, - read_vtk_file, -) +# read_vtk_file is internal API used here only for data inspection/diagnostics +from physiomotion4d.vtk_to_usd.vtk_reader import read_vtk_file # Configure logging logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") @@ -76,12 +67,12 @@ print(f" VTP: {vtp_file.exists()} - {vtp_file}") # %% -# Simple conversion using convenience function +# Simple conversion using ConvertVTKToUSD output_usd = output_dir / "heart_surface_simple.usd" -stage = convert_vtk_file( - vtk_file=vtp_file, output_usd=output_usd, mesh_name="HeartSurface" -) +stage = ConvertVTKToUSD.from_files( + data_basename="HeartSurface", vtk_files=[vtp_file] +).convert(str(output_usd)) print(f"\nCreated USD file: {output_usd}") print("Stage info:") @@ -145,35 +136,13 @@ # Now let's use custom settings to control the conversion process. # %% -# Create custom conversion settings -settings = ConversionSettings( - triangulate_meshes=True, # Ensure all faces are triangles - compute_normals=True, # Compute normals if not present - preserve_point_arrays=True, # Keep all point data as primvars - preserve_cell_arrays=True, # Keep all cell data as primvars - meters_per_unit=0.001, # Assume VTK data is in millimeters - up_axis="Y", # Use Y-up (USD standard) -) - -# Create custom material -material = MaterialData( - name="heart_material", - diffuse_color=(0.9, 0.3, 0.3), # Reddish color for heart - roughness=0.4, - metallic=0.0, -) - -# Create converter -converter = VTKToUSDConverter(settings) - -# Convert with custom settings +# Convert with a custom solid color output_usd_custom = output_dir / "heart_surface_custom.usd" -stage_custom = converter.convert_file( - vtk_file=vtp_file, - output_usd=output_usd_custom, - mesh_name="HeartSurface", - material=material, -) +stage_custom = ConvertVTKToUSD.from_files( + data_basename="HeartSurface", + vtk_files=[vtp_file], + solid_color=(0.9, 0.3, 0.3), # Reddish color for heart +).convert(str(output_usd_custom)) print(f"\nCreated custom USD file: {output_usd_custom}") @@ -183,23 +152,14 @@ # Now let's convert the legacy VTK format file. # %% -# Convert VTK file with custom material +# Convert VTK (legacy volumetric mesh) with surface extraction output_usd_vtk = output_dir / "heart_mesh.usd" - -material_mesh = MaterialData( - name="heart_mesh_material", - diffuse_color=(0.8, 0.4, 0.4), - roughness=0.5, - metallic=0.0, -) - -stage_vtk = converter.convert_file( - vtk_file=vtk_file, - output_usd=output_usd_vtk, - mesh_name="HeartMesh", - material=material_mesh, +stage_vtk = ConvertVTKToUSD.from_files( + data_basename="HeartMesh", + vtk_files=[vtk_file], extract_surface=True, # Extract surface from volumetric mesh -) + solid_color=(0.8, 0.4, 0.4), +).convert(str(output_usd_vtk)) print(f"\nCreated VTK USD file: {output_usd_vtk}") @@ -216,8 +176,8 @@ print("USD File Inspection") print("=" * 60) -# Get the mesh prim -mesh_path = "/World/Meshes/HeartSurface" +# Get the mesh prim (ConvertVTKToUSD places meshes at /World/{basename}/Mesh) +mesh_path = "/World/HeartSurface/Mesh" mesh_prim = inspect_stage.GetPrimAtPath(mesh_path) if mesh_prim: @@ -270,77 +230,41 @@ # Demonstrate time-series conversion by creating synthetic deformation of the mesh. # %% -# Create a simple time-series by deforming the mesh - +# Create a simple time-series by deforming the mesh using PyVista +base_mesh = pv.read(str(vtp_file)) -def create_deformed_mesh(base_mesh_data, time_step, num_steps=10): - """Create a deformed version of the mesh for animation.""" - # Clone the mesh data - deformed_mesh = copy.deepcopy(base_mesh_data) - # Apply sinusoidal deformation +def create_deformed_pv_mesh( + base: pv.PolyData, time_step: int, num_steps: int = 10 +) -> pv.PolyData: + """Return a sinusoidally scaled copy of base with a synthetic pressure field.""" t = time_step / num_steps * 2 * np.pi - scale_factor = 1.0 + 0.1 * np.sin(t) # 10% amplitude - - # Scale points radially from centroid - centroid = np.mean(deformed_mesh.points, axis=0) - deformed_mesh.points = centroid + (deformed_mesh.points - centroid) * scale_factor - - # Add a time-varying scalar field (simulated pressure) - num_points = len(deformed_mesh.points) - pressure = np.sin(t + np.linspace(0, 2 * np.pi, num_points)) - pressure_array = GenericArray( - name="pressure", - data=pressure, - num_components=1, - data_type=DataType.FLOAT, - interpolation="vertex", - ) - - # Add to generic arrays if not already present - array_names = [arr.name for arr in deformed_mesh.generic_arrays] - if "pressure" not in array_names: - deformed_mesh.generic_arrays.append(pressure_array) - else: - # Replace existing pressure array - for i, arr in enumerate(deformed_mesh.generic_arrays): - if arr.name == "pressure": - deformed_mesh.generic_arrays[i] = pressure_array - break - - return deformed_mesh - - -# Create sequence of deformed meshes -num_time_steps = 10 -mesh_sequence = [] -time_codes = list(range(num_time_steps)) + scale = 1.0 + 0.1 * np.sin(t) + centroid = np.mean(base.points, axis=0) + deformed = base.copy(deep=True) + deformed.points = centroid + (base.points - centroid) * scale + num_points = len(deformed.points) + deformed.point_data["pressure"] = np.sin( + t + np.linspace(0, 2 * np.pi, num_points) + ).astype(np.float32) + return deformed -for t in range(num_time_steps): - deformed = create_deformed_mesh(mesh_data, t, num_time_steps) - mesh_sequence.append(deformed) - print(f"Created time step {t + 1}/{num_time_steps}") -print(f"\nCreated {len(mesh_sequence)} time steps") +num_time_steps = 10 +pv_sequence = [ + create_deformed_pv_mesh(base_mesh, t, num_time_steps) for t in range(num_time_steps) +] +print(f"\nCreated {len(pv_sequence)} time steps") # %% # Convert time series to USD output_usd_anim = output_dir / "heart_surface_animated.usd" -material_anim = MaterialData( - name="heart_animated_material", - diffuse_color=(0.9, 0.2, 0.2), - roughness=0.3, - metallic=0.0, -) - -stage_anim = converter.convert_mesh_data_sequence( - mesh_data_sequence=mesh_sequence, - output_usd=output_usd_anim, - mesh_name="HeartAnimated", - time_codes=time_codes, - material=material_anim, -) +stage_anim = ConvertVTKToUSD( + data_basename="HeartAnimated", + input_polydata=pv_sequence, + solid_color=(0.9, 0.2, 0.2), +).convert(str(output_usd_anim)) print(f"\nCreated animated USD file: {output_usd_anim}") print(f"Time range: {stage_anim.GetStartTimeCode()} to {stage_anim.GetEndTimeCode()}") diff --git a/src/physiomotion4d/__init__.py b/src/physiomotion4d/__init__.py index 7ce40e1..182b491 100644 --- a/src/physiomotion4d/__init__.py +++ b/src/physiomotion4d/__init__.py @@ -34,9 +34,6 @@ stacklevel=2, ) -# VTK to USD library -# VTK to USD library (new modular implementation) -from . import vtk_to_usd from .contour_tools import ContourTools # Data processing utilities @@ -113,6 +110,4 @@ # Data processing utilities "ConvertNRRD4DTo3D", "ConvertVTKToUSD", - # VTK to USD library - "vtk_to_usd", ] diff --git a/src/physiomotion4d/cli/convert_vtk_to_usd.py b/src/physiomotion4d/cli/convert_vtk_to_usd.py index 7f42e26..1ce6a55 100644 --- a/src/physiomotion4d/cli/convert_vtk_to_usd.py +++ b/src/physiomotion4d/cli/convert_vtk_to_usd.py @@ -95,12 +95,6 @@ def main() -> int: dest="times_per_second", help="Frames per second for time series (default: 60)", ) - parser.add_argument( - "--up-axis", - choices=["Y", "Z"], - default="Y", - help="USD up axis (default: Y)", - ) parser.add_argument( "--no-extract-surface", action="store_false", @@ -214,7 +208,6 @@ def main() -> int: separate_by_cell_type=separate_by_cell_type, mesh_name=args.mesh_name, times_per_second=args.times_per_second, - up_axis=args.up_axis, extract_surface=args.extract_surface, appearance=args.appearance, solid_color=solid_color, diff --git a/src/physiomotion4d/convert_vtk_to_usd.py b/src/physiomotion4d/convert_vtk_to_usd.py index 5c79828..bfe04bc 100644 --- a/src/physiomotion4d/convert_vtk_to_usd.py +++ b/src/physiomotion4d/convert_vtk_to_usd.py @@ -14,7 +14,7 @@ import logging from collections.abc import Sequence from pathlib import Path -from typing import Any, Optional, cast +from typing import Any, Literal, Optional, cast import numpy as np import pyvista as pv @@ -30,6 +30,8 @@ MaterialManager, MeshData, UsdMeshConverter, + split_mesh_data_by_cell_type, + split_mesh_data_by_connectivity, ) @@ -71,6 +73,8 @@ def __init__( compute_normals: bool = False, convert_to_surface: bool = True, times_per_second: float = 24.0, + separate_by: Literal["none", "connectivity", "cell_type"] = "none", + solid_color: tuple[float, float, float] = (0.8, 0.8, 0.8), log_level: int | str = logging.INFO, ) -> None: """ @@ -85,6 +89,10 @@ def __init__( convert_to_surface: If True, extract surface from volumetric meshes times_per_second: Time codes per second (default 24.0). For medical imaging time series where each frame = 1 second, use 1.0. + separate_by: How to split the mesh into sub-prims. + 'none' keeps the mesh as-is, 'connectivity' splits by connected + component, 'cell_type' splits by face vertex count. + solid_color: Default RGB diffuse color in [0, 1] used when no colormap is set. log_level: Logging level """ super().__init__(class_name=self.__class__.__name__, log_level=log_level) @@ -94,12 +102,22 @@ def __init__( self.mask_ids = mask_ids self.compute_normals = compute_normals self.convert_to_surface = convert_to_surface + self.separate_by = separate_by + self.solid_color = solid_color # Colormap settings self.color_by_array: Optional[str] = None self.colormap: str = "plasma" self.intensity_range: Optional[tuple[float, float]] = None + # Set by from_files() for file-based construction + self._is_static_merge: bool = False + self._time_codes: Optional[list[float]] = None + # Pre-converted MeshData for each time step; populated by from_files() so + # _convert_unified() can reuse the topology-validation work instead of + # calling _vtk_to_mesh_data() a second time. + self._cached_mesh_data: Optional[list[MeshData]] = None + # Conversion settings self.settings = ConversionSettings( triangulate_meshes=True, @@ -113,9 +131,111 @@ def __init__( self.logger.info( f"Initialized converter with {len(input_polydata)} time steps, " - f"mask_ids={'enabled' if mask_ids else 'disabled'}" + f"mask_ids={'enabled' if mask_ids else 'disabled'}, " + f"separate_by='{separate_by}'" ) + @classmethod + def from_files( + cls, + data_basename: str, + vtk_files: Sequence[Path | str], + *, + extract_surface: bool = True, + separate_by: Literal["none", "connectivity", "cell_type"] = "none", + times_per_second: float = 24.0, + solid_color: tuple[float, float, float] = (0.8, 0.8, 0.8), + time_codes: Optional[list[float]] = None, + static_merge: bool = False, + mask_ids: Optional[dict[int, str]] = None, + log_level: int | str = logging.INFO, + ) -> "ConvertVTKToUSD": + """Create a converter by loading VTK files from disk. + + Accepts .vtk (legacy), .vtp (PolyData), and .vtu (UnstructuredGrid) files. + For time-series input, pass files ordered by time and supply time_codes. + For a static scene with multiple disconnected meshes, set static_merge=True. + + Args: + data_basename: Base name for USD prim paths. + vtk_files: Paths to VTK files; one file = one time step (or one static mesh). + extract_surface: If True, extract surface from UnstructuredGrid (.vtu) meshes. + separate_by: How to split each mesh into sub-prims. + times_per_second: FPS for time-varying animation. + solid_color: Default RGB diffuse color in [0, 1]. + time_codes: Explicit time codes aligned to vtk_files. If None, uses + sequential integers [0, 1, 2, ...]. + static_merge: If True, treat each file as a separate mesh object in a + single static scene (no time samples). + mask_ids: Optional anatomical label mapping. + log_level: Logging level. + + Returns: + ConvertVTKToUSD instance ready to call .convert(). + """ + file_list = [Path(f) for f in vtk_files] + if not file_list: + raise ValueError("vtk_files must not be empty") + + if time_codes is not None and len(time_codes) != len(file_list): + raise ValueError( + f"time_codes length ({len(time_codes)}) must match " + f"vtk_files length ({len(file_list)})" + ) + if time_codes is not None and len(time_codes) > 1: + if any( + time_codes[i] > time_codes[i + 1] for i in range(len(time_codes) - 1) + ): + raise ValueError( + "time_codes must be in non-decreasing order; " + "got values that decrease between consecutive frames" + ) + + meshes: list[pv.DataSet | vtk.vtkDataSet] = [] + for path in file_list: + mesh = pv.read(str(path)) + if extract_surface and isinstance(mesh, pv.UnstructuredGrid): + mesh = mesh.extract_surface(algorithm="dataset_surface") + meshes.append(mesh) + + resolved_time_codes = ( + time_codes + if time_codes is not None + else [float(i) for i in range(len(meshes))] + ) + + instance = cls( + data_basename=data_basename, + input_polydata=meshes, + mask_ids=mask_ids, + separate_by=separate_by, + convert_to_surface=extract_surface, + times_per_second=times_per_second, + solid_color=solid_color, + log_level=log_level, + ) + instance._is_static_merge = static_merge + instance._time_codes = resolved_time_codes + + # Validate topology consistency for multi-frame time series and cache the + # converted MeshData so _convert_unified() can reuse it without a second + # round of _vtk_to_mesh_data() calls. + if len(meshes) > 1 and not static_merge: + from .vtk_to_usd.vtk_reader import validate_time_series_topology + + mesh_data_seq = [ + instance._vtk_to_mesh_data(m, i) for i, m in enumerate(meshes) + ] + instance._cached_mesh_data = mesh_data_seq + try: + report = validate_time_series_topology(mesh_data_seq) + for w in report.get("warnings", []): + instance.log_warning("%s", w) + except Exception as exc: + instance.log_debug("Topology validation skipped: %s", exc) + + return instance + def supports_mesh_type(self, mesh: pv.DataSet | vtk.vtkDataSet) -> bool: """ Check if mesh type is supported for conversion. @@ -270,10 +390,13 @@ def convert( root_prim = stage.DefinePrim("/World", "Xform") stage.SetDefaultPrim(root_prim) - # Set time range for animation - if len(self.input_polydata) > 1: - stage.SetStartTimeCode(0) - stage.SetEndTimeCode(len(self.input_polydata) - 1) + # Set time range for animation (not for static merge) + if len(self.input_polydata) > 1 and not self._is_static_merge: + time_codes = self._time_codes or [ + float(i) for i in range(len(self.input_polydata)) + ] + stage.SetStartTimeCode(time_codes[0]) + stage.SetEndTimeCode(time_codes[-1]) stage.SetTimeCodesPerSecond(self.settings.times_per_second) # Initialize managers @@ -281,11 +404,13 @@ def convert( mesh_converter = UsdMeshConverter(stage, self.settings, material_mgr) # Process meshes - if self.mask_ids: + if self._is_static_merge: + self._convert_static_merge(stage, root_path, material_mgr, mesh_converter) + elif self.mask_ids: # Split by anatomical regions self._convert_with_labels(stage, root_path, material_mgr, mesh_converter) else: - # Single mesh conversion + # Single mesh (or time series) conversion self._convert_unified(stage, root_path, material_mgr, mesh_converter) # Save stage @@ -301,37 +426,100 @@ def _convert_unified( material_mgr: MaterialManager, mesh_converter: UsdMeshConverter, ) -> None: - """Convert all meshes as a single unified mesh.""" - self.logger.debug("Converting as unified mesh (no label splitting)") - - # Convert meshes to MeshData - mesh_data_sequence = [] - for time_idx, vtk_mesh in enumerate(self.input_polydata): - mesh_data = self._vtk_to_mesh_data(vtk_mesh, time_idx) - mesh_data_sequence.append(mesh_data) + """Convert all meshes as a single mesh (or split by connectivity/cell_type).""" + self.logger.debug("Converting mesh(es), separate_by='%s'", self.separate_by) + + # Reuse pre-converted data built during topology validation in from_files(); + # fall back to computing fresh when called without the file-based factory. + mesh_data_sequence = self._cached_mesh_data or [ + self._vtk_to_mesh_data(m, i) for i, m in enumerate(self.input_polydata) + ] + + time_codes = self._time_codes or [ + float(i) for i in range(len(mesh_data_sequence)) + ] + + if self.separate_by == "none": + # Single prim path for all time steps + parts_per_frame = [[(md, "Mesh")] for md in mesh_data_sequence] + elif self.separate_by == "connectivity": + parts_per_frame = [ + split_mesh_data_by_connectivity(md, self.data_basename) + for md in mesh_data_sequence + ] + else: # cell_type + parts_per_frame = [ + split_mesh_data_by_cell_type(md, self.data_basename) + for md in mesh_data_sequence + ] + + # Collect all part names across frames for stable prim paths + all_part_names: list[str] = [] + for parts in parts_per_frame: + for _, name in parts: + if name not in all_part_names: + all_part_names.append(name) + + for part_name in all_part_names: + material = self._create_material_from_colormap(f"{part_name}_material") + material_mgr.get_or_create_material(material) - # Create material - material = self._create_material_from_colormap("unified_material") + # Collect frames that contain this part + part_sequence = [] + part_time_codes = [] + for frame_idx, parts in enumerate(parts_per_frame): + for md, name in parts: + if name == part_name: + md.material_id = material.name + part_sequence.append(md) + part_time_codes.append(time_codes[frame_idx]) + break + + if not part_sequence: + continue - # Convert to USD - mesh_path = f"{root_path}/Mesh" - if len(mesh_data_sequence) == 1: - # Single frame - mesh_data_sequence[0].material_id = material.name - material_mgr.get_or_create_material(material) - mesh_converter.create_mesh( - mesh_data_sequence[0], mesh_path, bind_material=True - ) - else: - # Time series - time_codes = [float(i) for i in range(len(mesh_data_sequence))] - for md in mesh_data_sequence: - md.material_id = material.name - material_mgr.get_or_create_material(material) + mesh_path = f"{root_path}/{part_name}" + # Always use create_time_varying_mesh so the prim carries explicit time + # samples and is only visible at the frames it was present in, even when + # a part appears in only one frame. mesh_converter.create_time_varying_mesh( - mesh_data_sequence, mesh_path, time_codes, bind_material=True + part_sequence, mesh_path, part_time_codes, bind_material=True ) + def _convert_static_merge( + self, + stage: Usd.Stage, + root_path: str, + material_mgr: MaterialManager, + mesh_converter: UsdMeshConverter, + ) -> None: + """Write each input mesh as a separate prim with no time samples. + + Used when multiple files don't match a time-series pattern. + """ + self.logger.debug( + "Static merge: writing %d mesh(es) as separate prims", + len(self.input_polydata), + ) + for i, vtk_mesh in enumerate(self.input_polydata): + mesh_data = self._vtk_to_mesh_data(vtk_mesh, i) + frame_name = f"{self.data_basename}_{i}" + + if self.separate_by == "none": + parts = [(mesh_data, frame_name)] + elif self.separate_by == "connectivity": + parts = split_mesh_data_by_connectivity(mesh_data, frame_name) + else: # cell_type + parts = split_mesh_data_by_cell_type(mesh_data, frame_name) + + for part_md, part_name in parts: + material = self._create_material_from_colormap(f"{part_name}_material") + material_mgr.get_or_create_material(material) + part_md.material_id = material.name + mesh_converter.create_mesh( + part_md, f"{root_path}/{part_name}", bind_material=True + ) + def _convert_with_labels( self, stage: Usd.Stage, @@ -359,13 +547,16 @@ def _convert_with_labels( for label_name in sorted(all_labels): self.logger.debug(f"Processing label: {label_name}") - # Collect mesh data for this label across time + # Collect mesh data for this label across time, tracking which + # original frame indices contribute so time codes stay aligned. label_mesh_sequence = [] - for labeled_meshes in labeled_meshes_by_time: + label_frame_indices: list[int] = [] + for time_idx, labeled_meshes in enumerate(labeled_meshes_by_time): if label_name in labeled_meshes: label_mesh_sequence.append(labeled_meshes[label_name]) + label_frame_indices.append(time_idx) else: - # Label not present in this time step - use empty mesh or skip + # Label not present in this time step - skip self.logger.warning(f"Label '{label_name}' missing in time step") if not label_mesh_sequence: @@ -383,12 +574,17 @@ def _convert_with_labels( label_mesh_sequence[0], mesh_path, bind_material=True ) else: - time_codes = [float(i) for i in range(len(label_mesh_sequence))] + if self._time_codes is not None: + label_time_codes = [ + self._time_codes[i] for i in label_frame_indices + ] + else: + label_time_codes = [float(i) for i in label_frame_indices] for md in label_mesh_sequence: md.material_id = material.name material_mgr.get_or_create_material(material) mesh_converter.create_time_varying_mesh( - label_mesh_sequence, mesh_path, time_codes, bind_material=True + label_mesh_sequence, mesh_path, label_time_codes, bind_material=True ) def _vtk_to_mesh_data( @@ -554,19 +750,17 @@ def _apply_colormap(self, scalar_data: np.ndarray) -> np.ndarray: def _create_material_from_colormap(self, name: str) -> MaterialData: """Create material based on colormap settings.""" if self.color_by_array: - # Use vertex colors return MaterialData( name=name, - diffuse_color=(0.8, 0.8, 0.8), + diffuse_color=self.solid_color, roughness=0.5, metallic=0.0, use_vertex_colors=True, ) else: - # Use solid color return MaterialData( name=name, - diffuse_color=(0.8, 0.8, 0.8), + diffuse_color=self.solid_color, roughness=0.5, metallic=0.0, use_vertex_colors=False, diff --git a/src/physiomotion4d/vtk_to_usd/__init__.py b/src/physiomotion4d/vtk_to_usd/__init__.py index b6b97bd..a5b7f69 100644 --- a/src/physiomotion4d/vtk_to_usd/__init__.py +++ b/src/physiomotion4d/vtk_to_usd/__init__.py @@ -1,28 +1,16 @@ -"""VTK to USD conversion library. +"""Internal VTK-to-USD plumbing for PhysioMotion4D. -A comprehensive library for converting VTK files (VTK, VTP, VTU) to USD format. -Based on the ParaViewConnector architecture but simplified for file-based conversion. +This subpackage is private. External code and all PhysioMotion4D modules must +use ConvertVTKToUSD from physiomotion4d.convert_vtk_to_usd; they must not +import from this package directly. -Features: -- Supports VTK legacy format (.vtk), XML PolyData (.vtp), and UnstructuredGrid (.vtu) -- Preserves geometry, topology, normals, and colors -- Converts VTK data arrays to USD primvars -- Supports time-series/animated data -- Material system with UsdPreviewSurface -- Coordinate conversion from RAS to USD Y-up - -Example Usage: - >>> from physiomotion4d.vtk_to_usd import convert_vtk_file - >>> stage = convert_vtk_file('mesh.vtp', 'output.usd') - - >>> # Advanced usage with custom settings - >>> from physiomotion4d.vtk_to_usd import VTKToUSDConverter, ConversionSettings - >>> settings = ConversionSettings(triangulate_meshes=True, compute_normals=True) - >>> converter = VTKToUSDConverter(settings) - >>> converter.convert_sequence(['mesh_0.vtp', 'mesh_1.vtp'], 'output.usd') +Provides: +- Data containers: MeshData, ConversionSettings, MaterialData, etc. +- VTK file readers (.vtk, .vtp, .vtu) +- USD primitive writers: UsdMeshConverter, MaterialManager +- Coordinate helpers (RAS → Y-up) and mesh splitting utilities """ -from .converter import VTKToUSDConverter, convert_vtk_file, convert_vtk_sequence from .data_structures import ( ConversionSettings, DataType, @@ -58,10 +46,6 @@ ) __all__ = [ - # Main converter - "VTKToUSDConverter", - "convert_vtk_file", - "convert_vtk_sequence", # Data structures "ConversionSettings", "DataType", diff --git a/src/physiomotion4d/vtk_to_usd/converter.py b/src/physiomotion4d/vtk_to_usd/converter.py deleted file mode 100644 index f262b2e..0000000 --- a/src/physiomotion4d/vtk_to_usd/converter.py +++ /dev/null @@ -1,594 +0,0 @@ -"""Main VTK to USD converter interface. - -Provides high-level API for converting VTK files to USD format. -""" - -import logging -from pathlib import Path -from typing import Any, Optional, Sequence - -from pxr import Usd, UsdGeom - -from .data_structures import ConversionSettings, MaterialData, MeshData -from .material_manager import MaterialManager -from .mesh_utils import split_mesh_data_by_cell_type, split_mesh_data_by_connectivity -from .usd_mesh_converter import UsdMeshConverter -from .vtk_reader import read_vtk_file - -logger = logging.getLogger(__name__) - - -class VTKToUSDConverter: - """High-level converter for VTK files to USD. - - Provides simple API for converting single or multiple VTK files to USD format. - Handles material creation, primvar mapping, and time-series data. - - Example: - >>> converter = VTKToUSDConverter() - >>> converter.convert_file('mesh.vtp', 'output.usd') - - >>> # Time-series conversion - >>> converter = VTKToUSDConverter() - >>> files = ['mesh_0.vtp', 'mesh_1.vtp', 'mesh_2.vtp'] - >>> converter.convert_sequence(files, 'output.usd') - """ - - def __init__(self, settings: Optional[ConversionSettings] = None) -> None: - """Initialize converter. - - Args: - settings: Optional conversion settings. If None, uses defaults. - """ - self.settings = settings or ConversionSettings() - self.stage: Optional[Usd.Stage] = None - self.material_mgr: Optional[MaterialManager] = None - self.mesh_converter: Optional[UsdMeshConverter] = None - - def convert_file( - self, - vtk_file: str | Path, - output_usd: str | Path, - mesh_name: str = "Mesh", - material: Optional[MaterialData] = None, - extract_surface: bool = True, - ) -> Usd.Stage: - """Convert a single VTK file to USD. - - Args: - vtk_file: Path to VTK file (.vtk, .vtp, or .vtu) - output_usd: Path to output USD file - mesh_name: Name for the mesh in USD - material: Optional material data. If None, uses default. - extract_surface: For .vtu files, whether to extract surface - - Returns: - Usd.Stage: Created USD stage - """ - logger.info(f"Converting {vtk_file} to {output_usd}") - - # Read VTK file - mesh_data = read_vtk_file(vtk_file, extract_surface=extract_surface) - - # Set material ID if provided - if material is not None: - mesh_data.material_id = material.name - - # Create USD stage - self._create_stage(output_usd) - stage = self.stage - mesh_converter = self.mesh_converter - material_mgr = self.material_mgr - assert stage is not None - assert mesh_converter is not None - assert material_mgr is not None - - # Create material if provided - if material is not None: - material_mgr.get_or_create_material(material) - - # Create mesh(es) - by connectivity, by cell type, or single - if self.settings.separate_objects_by_connectivity: - parts = split_mesh_data_by_connectivity(mesh_data, mesh_name=mesh_name) - for _idx, (part_data, base_name) in enumerate(parts): - mesh_path = f"/World/Meshes/{base_name}" - self._ensure_parent_path(mesh_path) - mesh_converter.create_mesh(part_data, mesh_path, bind_material=True) - elif self.settings.separate_objects_by_cell_type: - parts = split_mesh_data_by_cell_type(mesh_data, mesh_name=mesh_name) - for idx, (part_data, base_name) in enumerate(parts): - mesh_path = f"/World/Meshes/{base_name}" - self._ensure_parent_path(mesh_path) - mesh_converter.create_mesh(part_data, mesh_path, bind_material=True) - else: - mesh_path = f"/World/Meshes/{mesh_name}" - self._ensure_parent_path(mesh_path) - mesh_converter.create_mesh(mesh_data, mesh_path, bind_material=True) - - # Save stage - stage.Save() - logger.info(f"Saved USD file: {output_usd}") - - return stage - - def convert_files_static( - self, - vtk_files: Sequence[str | Path], - output_usd: str | Path, - mesh_name: str = "Mesh", - material: Optional[MaterialData] = None, - extract_surface: bool = True, - ) -> Usd.Stage: - """Convert multiple VTK files into one static USD stage (no time samples). - - All meshes from all files are added to the scene at default time. Use this - when multiple files are provided but filenames do not match a time-series - pattern, so they should be combined as a single static scene rather than - time steps. - - Args: - vtk_files: List of VTK file paths - output_usd: Path to output USD file - mesh_name: Base name for meshes (each file/part gets a unique name) - material: Optional material data. If None, uses default. - extract_surface: For .vtu files, whether to extract surface - - Returns: - Usd.Stage: Created USD stage - """ - if len(vtk_files) == 0: - raise ValueError("Empty file list") - - logger.info( - "Converting %d files to static USD (no time samples): %s", - len(vtk_files), - output_usd, - ) - - # Create USD stage once (no time range) - self._create_stage(output_usd) - stage = self.stage - mesh_converter = self.mesh_converter - material_mgr = self.material_mgr - assert stage is not None - assert mesh_converter is not None - assert material_mgr is not None - - if material is not None: - material_mgr.get_or_create_material(material) - - for file_idx, vtk_file in enumerate(vtk_files): - mesh_data = read_vtk_file(vtk_file, extract_surface=extract_surface) - if material is not None: - mesh_data.material_id = material.name - - # Unique base per file to avoid prim path collisions - file_base = f"{mesh_name}_{file_idx}" - - if self.settings.separate_objects_by_connectivity: - parts = split_mesh_data_by_connectivity(mesh_data, mesh_name=file_base) - for _idx, (part_data, base_name) in enumerate(parts): - mesh_path = f"/World/Meshes/{base_name}" - self._ensure_parent_path(mesh_path) - mesh_converter.create_mesh(part_data, mesh_path, bind_material=True) - elif self.settings.separate_objects_by_cell_type: - parts = split_mesh_data_by_cell_type(mesh_data, mesh_name=file_base) - for idx, (part_data, base_name) in enumerate(parts): - mesh_path = f"/World/Meshes/{base_name}" - self._ensure_parent_path(mesh_path) - mesh_converter.create_mesh(part_data, mesh_path, bind_material=True) - else: - mesh_path = f"/World/Meshes/{file_base}" - self._ensure_parent_path(mesh_path) - mesh_converter.create_mesh(mesh_data, mesh_path, bind_material=True) - - stage.Save() - logger.info(f"Saved USD file: {output_usd}") - - return stage - - def convert_sequence( - self, - vtk_files: Sequence[str | Path], - output_usd: str | Path, - mesh_name: str = "Mesh", - time_codes: Optional[list[float]] = None, - material: Optional[MaterialData] = None, - extract_surface: bool = True, - ) -> Usd.Stage: - """Convert a sequence of VTK files to time-varying USD. - - Args: - vtk_files: List of VTK file paths (one per time step) - output_usd: Path to output USD file - mesh_name: Name for the mesh in USD - time_codes: Optional list of time codes. If None, uses sequential integers. - material: Optional material data. If None, uses default. - extract_surface: For .vtu files, whether to extract surface - - Returns: - Usd.Stage: Created USD stage - """ - if len(vtk_files) == 0: - raise ValueError("Empty file list") - - logger.info(f"Converting sequence of {len(vtk_files)} files to {output_usd}") - - # Generate time codes if not provided - if time_codes is None: - time_codes = [float(i) for i in range(len(vtk_files))] - elif len(time_codes) != len(vtk_files): - raise ValueError( - f"Number of time codes ({len(time_codes)}) must match " - f"number of files ({len(vtk_files)})" - ) - - # Read all mesh data - mesh_data_sequence = [] - for vtk_file in vtk_files: - mesh_data = read_vtk_file(vtk_file, extract_surface=extract_surface) - if material is not None: - mesh_data.material_id = material.name - mesh_data_sequence.append(mesh_data) - - # Create USD stage - self._create_stage(output_usd) - stage = self.stage - mesh_converter = self.mesh_converter - material_mgr = self.material_mgr - assert stage is not None - assert mesh_converter is not None - assert material_mgr is not None - - # Create material if provided - if material is not None: - material_mgr.get_or_create_material(material) - - # Set time range - stage.SetStartTimeCode(time_codes[0]) - stage.SetEndTimeCode(time_codes[-1]) - stage.SetTimeCodesPerSecond(self.settings.times_per_second) - - # Create time-varying mesh(es) - by connectivity, by cell type, or single - if self.settings.separate_objects_by_connectivity: - parts_sequence = [ - split_mesh_data_by_connectivity(m, mesh_name=mesh_name) - for m in mesh_data_sequence - ] - n_parts = len(parts_sequence[0]) - if not all(len(p) == n_parts for p in parts_sequence): - logger.warning( - "Connectivity split count varies across time steps; " - "outputting single mesh per frame instead of splitting by connectivity." - ) - mesh_path = f"/World/Meshes/{mesh_name}" - self._ensure_parent_path(mesh_path) - mesh_converter.create_time_varying_mesh( - mesh_data_sequence, mesh_path, time_codes, bind_material=True - ) - else: - for part_idx in range(n_parts): - part_sequence = [p[part_idx][0] for p in parts_sequence] - base_name = parts_sequence[0][part_idx][1] - mesh_path = f"/World/Meshes/{base_name}" - self._ensure_parent_path(mesh_path) - mesh_converter.create_time_varying_mesh( - part_sequence, mesh_path, time_codes, bind_material=True - ) - elif self.settings.separate_objects_by_cell_type: - parts_sequence = [ - split_mesh_data_by_cell_type(m, mesh_name=mesh_name) - for m in mesh_data_sequence - ] - n_parts = len(parts_sequence[0]) - if not all(len(p) == n_parts for p in parts_sequence): - logger.warning( - "Cell type split count varies across time steps; " - "outputting single mesh per frame instead of splitting by cell type." - ) - mesh_path = f"/World/Meshes/{mesh_name}" - self._ensure_parent_path(mesh_path) - mesh_converter.create_time_varying_mesh( - mesh_data_sequence, mesh_path, time_codes, bind_material=True - ) - else: - for part_idx in range(n_parts): - part_sequence = [p[part_idx][0] for p in parts_sequence] - base_name = parts_sequence[0][part_idx][1] - mesh_path = f"/World/Meshes/{base_name}" - self._ensure_parent_path(mesh_path) - mesh_converter.create_time_varying_mesh( - part_sequence, mesh_path, time_codes, bind_material=True - ) - else: - mesh_path = f"/World/Meshes/{mesh_name}" - self._ensure_parent_path(mesh_path) - mesh_converter.create_time_varying_mesh( - mesh_data_sequence, mesh_path, time_codes, bind_material=True - ) - - # Save stage - stage.Save() - logger.info(f"Saved USD file: {output_usd}") - - return stage - - def convert_mesh_data( - self, - mesh_data: MeshData, - output_usd: str | Path, - mesh_name: str = "Mesh", - material: Optional[MaterialData] = None, - ) -> Usd.Stage: - """Convert MeshData directly to USD. - - Useful when you already have MeshData from other sources. - - Args: - mesh_data: Mesh data to convert - output_usd: Path to output USD file - mesh_name: Name for the mesh in USD - material: Optional material data - - Returns: - Usd.Stage: Created USD stage - """ - logger.info(f"Converting MeshData to {output_usd}") - - if material is not None: - mesh_data.material_id = material.name - - # Create USD stage - self._create_stage(output_usd) - stage = self.stage - mesh_converter = self.mesh_converter - material_mgr = self.material_mgr - assert stage is not None - assert mesh_converter is not None - assert material_mgr is not None - - # Create material if provided - if material is not None: - material_mgr.get_or_create_material(material) - - # Create mesh(es) - by connectivity, by cell type, or single - if self.settings.separate_objects_by_connectivity: - parts = split_mesh_data_by_connectivity(mesh_data, mesh_name=mesh_name) - for _idx, (part_data, base_name) in enumerate(parts): - mesh_path = f"/World/Meshes/{base_name}" - self._ensure_parent_path(mesh_path) - mesh_converter.create_mesh(part_data, mesh_path, bind_material=True) - elif self.settings.separate_objects_by_cell_type: - parts = split_mesh_data_by_cell_type(mesh_data, mesh_name=mesh_name) - for idx, (part_data, base_name) in enumerate(parts): - mesh_path = f"/World/Meshes/{base_name}" - self._ensure_parent_path(mesh_path) - mesh_converter.create_mesh(part_data, mesh_path, bind_material=True) - else: - mesh_path = f"/World/Meshes/{mesh_name}" - self._ensure_parent_path(mesh_path) - mesh_converter.create_mesh(mesh_data, mesh_path, bind_material=True) - - # Save stage - stage.Save() - logger.info(f"Saved USD file: {output_usd}") - - return stage - - def convert_mesh_data_sequence( - self, - mesh_data_sequence: list[MeshData], - output_usd: str | Path, - mesh_name: str = "Mesh", - time_codes: Optional[list[float]] = None, - material: Optional[MaterialData] = None, - ) -> Usd.Stage: - """Convert sequence of MeshData to time-varying USD. - - Args: - mesh_data_sequence: List of MeshData (one per time step) - output_usd: Path to output USD file - mesh_name: Name for the mesh in USD - time_codes: Optional list of time codes - material: Optional material data - - Returns: - Usd.Stage: Created USD stage - """ - if len(mesh_data_sequence) == 0: - raise ValueError("Empty mesh data sequence") - - logger.info( - f"Converting sequence of {len(mesh_data_sequence)} MeshData to {output_usd}" - ) - - # Generate time codes if not provided - if time_codes is None: - time_codes = [float(i) for i in range(len(mesh_data_sequence))] - elif len(time_codes) != len(mesh_data_sequence): - raise ValueError( - f"Number of time codes ({len(time_codes)}) must match " - f"number of mesh data ({len(mesh_data_sequence)})" - ) - - # Set material for all mesh data - if material is not None: - for mesh_data in mesh_data_sequence: - mesh_data.material_id = material.name - - # Create USD stage - self._create_stage(output_usd) - stage = self.stage - mesh_converter = self.mesh_converter - material_mgr = self.material_mgr - assert stage is not None - assert mesh_converter is not None - assert material_mgr is not None - - # Create material if provided - if material is not None: - material_mgr.get_or_create_material(material) - - # Set time range - stage.SetStartTimeCode(time_codes[0]) - stage.SetEndTimeCode(time_codes[-1]) - stage.SetTimeCodesPerSecond(self.settings.times_per_second) - - # Create time-varying mesh(es) - by connectivity, by cell type, or single - if self.settings.separate_objects_by_connectivity: - parts_sequence = [ - split_mesh_data_by_connectivity(m, mesh_name=mesh_name) - for m in mesh_data_sequence - ] - n_parts = len(parts_sequence[0]) - if not all(len(p) == n_parts for p in parts_sequence): - logger.warning( - "Connectivity split count varies across time steps; " - "outputting single mesh per frame instead of splitting by connectivity." - ) - mesh_path = f"/World/Meshes/{mesh_name}" - self._ensure_parent_path(mesh_path) - mesh_converter.create_time_varying_mesh( - mesh_data_sequence, mesh_path, time_codes, bind_material=True - ) - else: - for part_idx in range(n_parts): - part_sequence = [p[part_idx][0] for p in parts_sequence] - base_name = parts_sequence[0][part_idx][1] - mesh_path = f"/World/Meshes/{base_name}" - self._ensure_parent_path(mesh_path) - mesh_converter.create_time_varying_mesh( - part_sequence, mesh_path, time_codes, bind_material=True - ) - elif self.settings.separate_objects_by_cell_type: - parts_sequence = [ - split_mesh_data_by_cell_type(m, mesh_name=mesh_name) - for m in mesh_data_sequence - ] - n_parts = len(parts_sequence[0]) - if not all(len(p) == n_parts for p in parts_sequence): - logger.warning( - "Cell type split count varies across time steps; " - "outputting single mesh per frame instead of splitting by cell type." - ) - mesh_path = f"/World/Meshes/{mesh_name}" - self._ensure_parent_path(mesh_path) - mesh_converter.create_time_varying_mesh( - mesh_data_sequence, mesh_path, time_codes, bind_material=True - ) - else: - for part_idx in range(n_parts): - part_sequence = [p[part_idx][0] for p in parts_sequence] - base_name = parts_sequence[0][part_idx][1] - mesh_path = f"/World/Meshes/{base_name}" - self._ensure_parent_path(mesh_path) - mesh_converter.create_time_varying_mesh( - part_sequence, mesh_path, time_codes, bind_material=True - ) - else: - mesh_path = f"/World/Meshes/{mesh_name}" - self._ensure_parent_path(mesh_path) - mesh_converter.create_time_varying_mesh( - mesh_data_sequence, mesh_path, time_codes, bind_material=True - ) - - # Save stage - stage.Save() - logger.info(f"Saved USD file: {output_usd}") - - return stage - - def _create_stage(self, output_path: str | Path) -> None: - """Create a new USD stage. - - Args: - output_path: Path for the USD file - """ - output_path = Path(output_path) - - # Remove existing file - if output_path.exists(): - output_path.unlink() - logger.debug(f"Removed existing file: {output_path}") - - # Create stage - stage = Usd.Stage.CreateNew(str(output_path)) - assert stage is not None - self.stage = stage - - # Set stage metadata - UsdGeom.SetStageMetersPerUnit(stage, self.settings.meters_per_unit) - if self.settings.up_axis.upper() == "Y": - UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.y) - else: - UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.z) - - # Create root - root_prim = stage.DefinePrim("/World", "Xform") - stage.SetDefaultPrim(root_prim) - - # Initialize managers - self.material_mgr = MaterialManager(stage) - self.mesh_converter = UsdMeshConverter(stage, self.settings, self.material_mgr) - - logger.debug(f"Created USD stage: {output_path}") - - def _ensure_parent_path(self, path: str) -> None: - """Ensure all parent prims in path exist. - - Args: - path: USD path (e.g., "/World/Meshes/MyMesh") - """ - parts = path.strip("/").split("/") - current_path = "" - stage = self.stage - assert stage is not None - for part in parts[:-1]: # Skip the last part (the actual mesh) - current_path += f"/{part}" - if not stage.GetPrimAtPath(current_path): - stage.DefinePrim(current_path, "Xform") - - -# Convenience functions - - -def convert_vtk_file( - vtk_file: str | Path, - output_usd: str | Path, - settings: Optional[ConversionSettings] = None, - **kwargs: Any, -) -> Usd.Stage: - """Convenience function to convert a single VTK file. - - Args: - vtk_file: Path to VTK file - output_usd: Path to output USD file - settings: Optional conversion settings - **kwargs: Additional arguments passed to convert_file() - - Returns: - Usd.Stage: Created USD stage - """ - converter = VTKToUSDConverter(settings) - return converter.convert_file(vtk_file, output_usd, **kwargs) - - -def convert_vtk_sequence( - vtk_files: list[str | Path], - output_usd: str | Path, - settings: Optional[ConversionSettings] = None, - **kwargs: Any, -) -> Usd.Stage: - """Convenience function to convert a sequence of VTK files. - - Args: - vtk_files: List of VTK file paths - output_usd: Path to output USD file - settings: Optional conversion settings - **kwargs: Additional arguments passed to convert_sequence() - - Returns: - Usd.Stage: Created USD stage - """ - converter = VTKToUSDConverter(settings) - return converter.convert_sequence(vtk_files, output_usd, **kwargs) diff --git a/src/physiomotion4d/workflow_convert_vtk_to_usd.py b/src/physiomotion4d/workflow_convert_vtk_to_usd.py index 56b7600..4eea0f4 100644 --- a/src/physiomotion4d/workflow_convert_vtk_to_usd.py +++ b/src/physiomotion4d/workflow_convert_vtk_to_usd.py @@ -12,16 +12,10 @@ from pathlib import Path from typing import Literal +from physiomotion4d.convert_vtk_to_usd import ConvertVTKToUSD from physiomotion4d.physiomotion4d_base import PhysioMotion4DBase from physiomotion4d.usd_anatomy_tools import USDAnatomyTools from physiomotion4d.usd_tools import USDTools -from physiomotion4d.vtk_to_usd import ( - ConversionSettings, - MaterialData, - VTKToUSDConverter, - read_vtk_file, - validate_time_series_topology, -) AppearanceKind = Literal["solid", "anatomy", "colormap"] @@ -41,8 +35,6 @@ def __init__( separate_by_cell_type: bool = False, mesh_name: str = "Mesh", times_per_second: float = 60.0, - up_axis: str = "Y", - triangulate: bool = True, extract_surface: bool = True, time_series_pattern: str = r"\.t(\d+)\.(vtk|vtp|vtu)$", appearance: AppearanceKind = "solid", @@ -65,8 +57,6 @@ def __init__( Cannot be True when separate_by_connectivity is True. mesh_name: Base name for the mesh (or first mesh when not splitting). times_per_second: FPS for time-varying data. - up_axis: "Y" or "Z". - triangulate: Triangulate meshes. extract_surface: For .vtu, extract surface before conversion. time_series_pattern: Regex to extract time index from filenames (one group). appearance: "solid" | "anatomy" | "colormap". @@ -86,8 +76,6 @@ def __init__( self.separate_by_cell_type = separate_by_cell_type self.mesh_name = mesh_name self.times_per_second = times_per_second - self.up_axis = up_axis - self.triangulate = triangulate self.extract_surface = extract_surface self.time_series_pattern = time_series_pattern self.appearance = appearance @@ -159,7 +147,7 @@ def run(self) -> str: paths_ordered = [p for _, p in time_series] n_frames = len(paths_ordered) - # Multiple files but no pattern match: treat as static scene (all at time 0, no time samples) + # Multiple files but no pattern match: treat as static scene (no time samples) is_static_merge = n_frames > 1 and not pattern_matched self.log_info("Input: %d file(s), time steps: %s", n_frames, time_steps[:5]) @@ -171,73 +159,34 @@ def run(self) -> str: ) self.log_info("Output: %s", self.output_usd) - settings = ConversionSettings( - triangulate_meshes=self.triangulate, - compute_normals=False, - preserve_point_arrays=True, - preserve_cell_arrays=True, - separate_objects_by_connectivity=self.separate_by_connectivity, - separate_objects_by_cell_type=self.separate_by_cell_type, - up_axis=self.up_axis, - times_per_second=self.times_per_second, - use_time_samples=not is_static_merge, + separate_by: Literal["none", "connectivity", "cell_type"] = ( + "connectivity" + if self.separate_by_connectivity + else "cell_type" + if self.separate_by_cell_type + else "none" ) - converter = VTKToUSDConverter(settings) - default_material = MaterialData( - name="default_material", - diffuse_color=self.solid_color, - use_vertex_colors=False, + converter = ConvertVTKToUSD.from_files( + data_basename=self.mesh_name, + vtk_files=paths_ordered, + extract_surface=self.extract_surface, + separate_by=separate_by, + times_per_second=self.times_per_second, + solid_color=self.solid_color, + time_codes=time_codes if not is_static_merge else None, + static_merge=is_static_merge, + log_level=self.log_level, ) + stage = converter.convert(str(self.output_usd)) - if n_frames == 1: - stage = converter.convert_file( - paths_ordered[0], - self.output_usd, - mesh_name=self.mesh_name, - material=default_material, - extract_surface=self.extract_surface, - ) - elif is_static_merge: - stage = converter.convert_files_static( - paths_ordered, - self.output_usd, - mesh_name=self.mesh_name, - material=default_material, - extract_surface=self.extract_surface, - ) - else: - # Load mesh sequence once for both validation and conversion (avoids double I/O) - mesh_sequence = [ - read_vtk_file(p, extract_surface=self.extract_surface) - for p in paths_ordered - ] - # Optional: validate topology consistency across frames - try: - report = validate_time_series_topology(mesh_sequence) - if report.get("topology_changes"): - self.log_warning( - "Topology changes across %d frames", - len(report["topology_changes"]), - ) - except Exception as e: - self.log_debug("Time series validation skipped: %s", e) - - stage = converter.convert_mesh_data_sequence( - mesh_sequence, - self.output_usd, - mesh_name=self.mesh_name, - time_codes=time_codes, - material=default_material, - ) - - # Post-process: apply chosen appearance to all meshes under /World/Meshes + # Post-process: apply chosen appearance to all meshes under /World/{mesh_name} usd_tools = USDTools(log_level=self.log_level) mesh_paths = usd_tools.list_mesh_paths_under( - str(self.output_usd), parent_path="/World/Meshes" + str(self.output_usd), parent_path=f"/World/{self.mesh_name}" ) if not mesh_paths: - self.log_warning("No mesh prims found under /World/Meshes") + self.log_warning("No mesh prims found under /World/%s", self.mesh_name) return str(self.output_usd) # Static merge has no time samples; pass None so only default time is used diff --git a/tests/test_convert_vtk_to_usd.py b/tests/test_convert_vtk_to_usd.py index 0bdc253..4b84df2 100644 --- a/tests/test_convert_vtk_to_usd.py +++ b/tests/test_convert_vtk_to_usd.py @@ -10,6 +10,7 @@ from typing import Any import itk +import numpy as np import pytest import pyvista as pv from pxr import UsdGeom @@ -18,6 +19,19 @@ from physiomotion4d.contour_tools import ContourTools +def _make_poly(label_ids: list[int] | None = None) -> pv.PolyData: + """Return a small synthetic PolyData (9 quad cells, 16 points). + + Args: + label_ids: If given, attached as ``cell_data['boundary_labels']`` (int32). + Must have exactly 9 elements to match the plane's cell count. + """ + mesh = pv.Plane(i_resolution=3, j_resolution=3) + if label_ids is not None: + mesh.cell_data["boundary_labels"] = np.array(label_ids, dtype=np.int32) + return mesh + + @pytest.mark.requires_data @pytest.mark.slow class TestConvertVTKToUSD: @@ -406,3 +420,135 @@ def test_batch_conversion( if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) + + +class TestSyntheticConversion: + """Synthetic (no-disk-data) tests for ConvertVTKToUSD. + + Covers: + - Gap C: single-frame prim carries explicit time sample after create_time_varying_mesh change + - Gap D: mask_ids / _convert_with_labels — per-label prims, time-code filtering + - Gap E: static-merge prim naming uses data_basename + """ + + # ------------------------------------------------------------------ + # Gap C — single-part prim must carry explicit time sample + # ------------------------------------------------------------------ + + def test_single_frame_prim_has_time_sample(self, tmp_path: Path) -> None: + """Single-frame _convert_unified() must author one time sample, not a static prim.""" + mesh = _make_poly() + converter = ConvertVTKToUSD(data_basename="P", input_polydata=[mesh]) + stage = converter.convert(str(tmp_path / "out.usd")) + + prim = stage.GetPrimAtPath("/World/P/Mesh") + assert prim.IsValid(), "Mesh prim not found" + samples = UsdGeom.Mesh(prim).GetPointsAttr().GetTimeSamples() + assert len(samples) == 1, f"Expected 1 time sample, got {len(samples)}" + assert samples[0] == pytest.approx(0.0) + + # ------------------------------------------------------------------ + # Gap E — static-merge prim naming uses data_basename + # ------------------------------------------------------------------ + + def test_static_merge_prim_names_use_data_basename(self, tmp_path: Path) -> None: + """Static-merge prims must be named {data_basename}_{i}, not Mesh_{i}.""" + mesh_a, mesh_b = _make_poly(), _make_poly() + converter = ConvertVTKToUSD( + data_basename="Organ", input_polydata=[mesh_a, mesh_b] + ) + converter._is_static_merge = True + stage = converter.convert(str(tmp_path / "out.usd")) + + assert stage.GetPrimAtPath("/World/Organ/Organ_0").IsValid() + assert stage.GetPrimAtPath("/World/Organ/Organ_1").IsValid() + assert not stage.GetPrimAtPath("/World/Organ/Mesh_0").IsValid(), ( + "Old naming still present" + ) + assert not stage.GetPrimAtPath("/World/Organ/Mesh_1").IsValid(), ( + "Old naming still present" + ) + # Static prims carry no time samples + for prim_path in ("/World/Organ/Organ_0", "/World/Organ/Organ_1"): + samples = ( + UsdGeom.Mesh(stage.GetPrimAtPath(prim_path)) + .GetPointsAttr() + .GetTimeSamples() + ) + assert samples == [], ( + f"{prim_path} should have no time samples but got {samples}" + ) + + # ------------------------------------------------------------------ + # Gap D — mask_ids / _convert_with_labels + # ------------------------------------------------------------------ + + def test_mask_ids_basic_produces_per_label_prims(self, tmp_path: Path) -> None: + """mask_ids must produce one USD prim per label; no unified /Mesh prim.""" + label_ids = [1, 1, 1, 1, 1, 2, 2, 2, 2] + mesh = _make_poly(label_ids=label_ids) + converter = ConvertVTKToUSD( + data_basename="Heart", + input_polydata=[mesh], + mask_ids={1: "ventricle", 2: "atrium"}, + ) + stage = converter.convert(str(tmp_path / "out.usd")) + + ventricle = stage.GetPrimAtPath("/World/Heart/ventricle") + atrium = stage.GetPrimAtPath("/World/Heart/atrium") + assert ventricle.IsValid(), "ventricle prim not found" + assert atrium.IsValid(), "atrium prim not found" + assert ventricle.IsA(UsdGeom.Mesh) + assert atrium.IsA(UsdGeom.Mesh) + # Unified prim must NOT exist when mask_ids is active + assert not stage.GetPrimAtPath("/World/Heart/Mesh").IsValid() + + def test_mask_ids_missing_label_filters_time_codes(self, tmp_path: Path) -> None: + """Time codes for a label must be filtered to frames where it actually appears. + + 3-frame setup: + Frame 0 (t=0.0): labels 1 and 2 both present + Frame 1 (t=1.0): only label 1 + Frame 2 (t=2.0): labels 1 and 2 both present + + Both labels have > 1 frame, so create_time_varying_mesh() is called for each. + The atrium (label 2) must carry time samples [0.0, 2.0], not [0.0, 1.0, 2.0]. + """ + mesh0 = _make_poly(label_ids=[1, 1, 1, 1, 1, 2, 2, 2, 2]) + mesh1 = _make_poly(label_ids=[1, 1, 1, 1, 1, 1, 1, 1, 1]) + mesh2 = _make_poly(label_ids=[1, 1, 1, 1, 1, 2, 2, 2, 2]) + converter = ConvertVTKToUSD( + data_basename="Heart", + input_polydata=[mesh0, mesh1, mesh2], + mask_ids={1: "ventricle", 2: "atrium"}, + ) + converter._time_codes = [0.0, 1.0, 2.0] + stage = converter.convert(str(tmp_path / "out.usd")) + + ventricle = stage.GetPrimAtPath("/World/Heart/ventricle") + atrium = stage.GetPrimAtPath("/World/Heart/atrium") + assert ventricle.IsValid() + assert atrium.IsValid() + + v_samples = UsdGeom.Mesh(ventricle).GetPointsAttr().GetTimeSamples() + a_samples = UsdGeom.Mesh(atrium).GetPointsAttr().GetTimeSamples() + assert list(v_samples) == [0.0, 1.0, 2.0], f"ventricle samples: {v_samples}" + # atrium absent from frame 1 — time code 1.0 must NOT appear + assert list(a_samples) == [0.0, 2.0], ( + f"atrium should only appear at t=0 and t=2, got {a_samples}" + ) + + def test_mask_ids_missing_boundary_labels_falls_back(self, tmp_path: Path) -> None: + """Mesh without boundary_labels array falls back to a 'default' prim.""" + mesh = _make_poly() # no boundary_labels + converter = ConvertVTKToUSD( + data_basename="FB", + input_polydata=[mesh], + mask_ids={1: "ventricle"}, + ) + stage = converter.convert(str(tmp_path / "out.usd")) + + assert stage.GetPrimAtPath("/World/FB/default").IsValid(), ( + "'default' fallback prim missing" + ) + assert not stage.GetPrimAtPath("/World/FB/ventricle").IsValid() diff --git a/tests/test_vtk_to_usd_library.py b/tests/test_vtk_to_usd_library.py index e910e76..7db2e57 100644 --- a/tests/test_vtk_to_usd_library.py +++ b/tests/test_vtk_to_usd_library.py @@ -1,32 +1,31 @@ #!/usr/bin/env python """ -Tests for the vtk_to_usd library module. - -This test suite validates the new modular vtk_to_usd library including: -- VTK file reading (VTP, VTK, VTU formats) -- Data structure conversions -- USD conversion -- Material handling -- Time-series support - -Note: These tests require manually downloaded data: -- KCL-Heart-Model: Must be manually downloaded and placed in data/KCL-Heart-Model/ -- CHOP-Valve4D: Must be manually downloaded and placed in data/CHOP-Valve4D/ +Tests for VTK-to-USD conversion via ConvertVTKToUSD. + +Covers: +- VTK file reading (VTP, VTK, VTU formats) via internal vtk_to_usd helpers +- ConvertVTKToUSD.from_files() — single file, time series, settings +- Material and primvar preservation +- Data structure validation (GenericArray, MeshData, etc.) + +Note: Tests marked requires_data need manually downloaded data: +- KCL-Heart-Model: data/KCL-Heart-Model/ +- CHOP-Valve4D: data/CHOP-Valve4D/ """ from pathlib import Path +from unittest.mock import patch import numpy as np import pytest import pyvista as pv from pxr import UsdGeom, UsdShade +from physiomotion4d import ConvertVTKToUSD from physiomotion4d.vtk_to_usd import ( - ConversionSettings, DataType, GenericArray, - MaterialData, - VTKToUSDConverter, + MeshData, read_vtk_file, ) @@ -208,6 +207,102 @@ def test_flat_array_large_components(self) -> None: np.testing.assert_array_equal(array.data, data.reshape(-1, 9)) +class TestFromFilesValidation: + """Synthetic tests for ConvertVTKToUSD.from_files() — no real data required. + + Covers: + - Gap A: time_codes length and monotonicity validation + - Gap B: _cached_mesh_data population and reuse in _convert_unified() + """ + + # ------------------------------------------------------------------ + # Gap A — time_codes validation + # ------------------------------------------------------------------ + + def test_time_codes_length_mismatch_raises(self, tmp_path: Path) -> None: + """from_files() must reject time_codes whose length != len(vtk_files).""" + sphere = pv.Sphere(theta_resolution=4, phi_resolution=4) + f0 = tmp_path / "f0.vtp" + f1 = tmp_path / "f1.vtp" + sphere.save(str(f0)) + sphere.save(str(f1)) + with pytest.raises(ValueError, match="time_codes length"): + ConvertVTKToUSD.from_files("X", [f0, f1], time_codes=[0.0]) + + def test_time_codes_non_monotone_raises(self, tmp_path: Path) -> None: + """from_files() must reject time_codes that decrease between frames.""" + sphere = pv.Sphere(theta_resolution=4, phi_resolution=4) + f0 = tmp_path / "f0.vtp" + f1 = tmp_path / "f1.vtp" + sphere.save(str(f0)) + sphere.save(str(f1)) + with pytest.raises(ValueError, match="non-decreasing order"): + ConvertVTKToUSD.from_files("X", [f0, f1], time_codes=[2.0, 1.0]) + + def test_time_codes_equal_consecutive_is_valid(self, tmp_path: Path) -> None: + """Equal consecutive time codes are non-decreasing and must not raise.""" + sphere = pv.Sphere(theta_resolution=4, phi_resolution=4) + f0 = tmp_path / "f0.vtp" + f1 = tmp_path / "f1.vtp" + sphere.save(str(f0)) + sphere.save(str(f1)) + converter = ConvertVTKToUSD.from_files("X", [f0, f1], time_codes=[1.0, 1.0]) + assert converter._time_codes == [1.0, 1.0] + + # ------------------------------------------------------------------ + # Gap B — topology cache population and reuse + # ------------------------------------------------------------------ + + def test_from_files_populates_cached_mesh_data(self, tmp_path: Path) -> None: + """from_files() with >1 frame must populate _cached_mesh_data.""" + plane = pv.Plane(i_resolution=2, j_resolution=2) + files = [] + for i in range(3): + p = tmp_path / f"p{i}.vtp" + plane.save(str(p)) + files.append(p) + converter = ConvertVTKToUSD.from_files("Plane", files) + assert converter._cached_mesh_data is not None + assert len(converter._cached_mesh_data) == 3 + assert all(isinstance(m, MeshData) for m in converter._cached_mesh_data) + + def test_from_files_cache_reused_in_convert(self, tmp_path: Path) -> None: + """_convert_unified() must not call _vtk_to_mesh_data() when cache is populated.""" + plane = pv.Plane(i_resolution=2, j_resolution=2) + files = [] + for i in range(3): + p = tmp_path / f"p{i}.vtp" + plane.save(str(p)) + files.append(p) + converter = ConvertVTKToUSD.from_files("Plane", files) + with patch.object( + converter, "_vtk_to_mesh_data", wraps=converter._vtk_to_mesh_data + ) as spy: + stage = converter.convert(str(tmp_path / "out.usd")) + assert spy.call_count == 0, ( + f"_vtk_to_mesh_data called {spy.call_count} time(s); cache should have been used" + ) + assert stage.GetPrimAtPath("/World/Plane/Mesh").IsValid() + + def test_from_files_single_file_no_cache(self, tmp_path: Path) -> None: + """A single-file converter must not populate _cached_mesh_data.""" + plane = pv.Plane(i_resolution=2, j_resolution=2) + f0 = tmp_path / "p0.vtp" + plane.save(str(f0)) + converter = ConvertVTKToUSD.from_files("P", [f0]) + assert converter._cached_mesh_data is None + + def test_from_files_static_merge_no_cache(self, tmp_path: Path) -> None: + """static_merge=True must not populate _cached_mesh_data.""" + plane = pv.Plane(i_resolution=2, j_resolution=2) + f0 = tmp_path / "p0.vtp" + f1 = tmp_path / "p1.vtp" + plane.save(str(f0)) + plane.save(str(f1)) + converter = ConvertVTKToUSD.from_files("P", [f0, f1], static_merge=True) + assert converter._cached_mesh_data is None + + @pytest.mark.requires_data class TestVTKReader: """Test VTK file reading capabilities.""" @@ -294,32 +389,22 @@ def test_single_file_conversion( output_dir = test_directories["output"] / "vtk_to_usd_library" output_dir.mkdir(parents=True, exist_ok=True) - # Get test data vtp_file = kcl_average_surface - - # Single mesh (no split) so path is /World/Meshes/HeartSurface - settings = ConversionSettings( - separate_objects_by_connectivity=False, - separate_objects_by_cell_type=False, - ) output_usd = output_dir / "heart_surface.usd" - converter = VTKToUSDConverter(settings) - stage = converter.convert_file( - vtp_file, - output_usd, - mesh_name="HeartSurface", - ) - # Verify USD file + stage = ConvertVTKToUSD.from_files( + data_basename="HeartSurface", + vtk_files=[vtp_file], + ).convert(str(output_usd)) + assert output_usd.exists() assert stage is not None - # Check mesh exists in stage - mesh_prim = stage.GetPrimAtPath("/World/Meshes/HeartSurface") + # No split: mesh lives at /World/HeartSurface/Mesh + mesh_prim = stage.GetPrimAtPath("/World/HeartSurface/Mesh") assert mesh_prim.IsValid() assert mesh_prim.IsA(UsdGeom.Mesh) - # Check mesh has geometry mesh = UsdGeom.Mesh(mesh_prim) points = mesh.GetPointsAttr().Get() assert len(points) > 0 @@ -332,84 +417,63 @@ def test_single_file_conversion( def test_conversion_with_material( self, test_directories: dict[str, Path], kcl_average_surface: Path ) -> None: - """Test conversion with custom material.""" + """Test conversion with a custom solid color material.""" output_dir = test_directories["output"] / "vtk_to_usd_library" output_dir.mkdir(parents=True, exist_ok=True) vtp_file = kcl_average_surface - - # Create custom material - material = MaterialData( - name="heart_tissue", - diffuse_color=(0.9, 0.3, 0.3), - roughness=0.4, - metallic=0.0, - ) - - # Single mesh so path is /World/Meshes/HeartSurface - settings = ConversionSettings( - separate_objects_by_connectivity=False, - separate_objects_by_cell_type=False, - ) output_usd = output_dir / "heart_with_material.usd" - converter = VTKToUSDConverter(settings) - stage = converter.convert_file( - vtp_file, - output_usd, - mesh_name="HeartSurface", - material=material, - ) - # Verify material exists - material_path = f"/World/Looks/{material.name}" - material_prim = stage.GetPrimAtPath(material_path) + stage = ConvertVTKToUSD.from_files( + data_basename="HeartSurface", + vtk_files=[vtp_file], + solid_color=(0.9, 0.3, 0.3), + ).convert(str(output_usd)) + + # Material for the no-split case is named "Mesh_material" + material_prim = stage.GetPrimAtPath("/World/Looks/Mesh_material") assert material_prim.IsValid() assert material_prim.IsA(UsdShade.Material) - # Verify material is bound to mesh - mesh_prim = stage.GetPrimAtPath("/World/Meshes/HeartSurface") + # Verify material is bound to the mesh prim + mesh_prim = stage.GetPrimAtPath("/World/HeartSurface/Mesh") binding_api = UsdShade.MaterialBindingAPI(mesh_prim) bound_material = binding_api.ComputeBoundMaterial()[0] assert bound_material.GetPrim().IsValid() - print("\nConverted with custom material") - print(f" Material: {material.name}") - print(f" Color: {material.diffuse_color}") + # Verify the shader's diffuseColor input carries the requested solid color + shader_prim = stage.GetPrimAtPath("/World/Looks/Mesh_material/PreviewSurface") + assert shader_prim.IsValid() + shader = UsdShade.Shader(shader_prim) + diffuse_value = shader.GetInput("diffuseColor").Get() + assert diffuse_value is not None + assert tuple(diffuse_value) == pytest.approx((0.9, 0.3, 0.3), abs=1e-5) + + print("\nConverted with custom solid color material") + print(" Material path: /World/Looks/Mesh_material") def test_conversion_settings( self, test_directories: dict[str, Path], kcl_average_surface: Path ) -> None: - """Test conversion with custom settings.""" + """Test that ConvertVTKToUSD applies correct default stage metadata.""" output_dir = test_directories["output"] / "vtk_to_usd_library" output_dir.mkdir(parents=True, exist_ok=True) vtp_file = kcl_average_surface - - # Create custom settings (single mesh for predictable path) - settings = ConversionSettings( - triangulate_meshes=True, - compute_normals=True, - preserve_point_arrays=True, - preserve_cell_arrays=True, - separate_objects_by_connectivity=False, - separate_objects_by_cell_type=False, - meters_per_unit=0.001, # mm to meters - up_axis="Y", - ) - - # Convert with settings output_usd = output_dir / "heart_custom_settings.usd" - converter = VTKToUSDConverter(settings) - stage = converter.convert_file(vtp_file, output_usd, mesh_name="Mesh") - # Verify stage metadata - assert UsdGeom.GetStageMetersPerUnit(stage) == 0.001 + stage = ConvertVTKToUSD.from_files( + data_basename="Mesh", + vtk_files=[vtp_file], + ).convert(str(output_usd)) + + # ConvertVTKToUSD always uses meters_per_unit=1.0 and Y-up + assert UsdGeom.GetStageMetersPerUnit(stage) == 1.0 assert UsdGeom.GetStageUpAxis(stage) == UsdGeom.Tokens.y - print("\nConverted with custom settings") - print(f" Meters per unit: {settings.meters_per_unit}") - print(f" Up axis: {settings.up_axis}") - print(f" Compute normals: {settings.compute_normals}") + print("\nVerified stage metadata defaults") + print(f" Meters per unit: {UsdGeom.GetStageMetersPerUnit(stage)}") + print(f" Up axis: {UsdGeom.GetStageUpAxis(stage)}") def test_primvar_preservation( self, test_directories: dict[str, Path], kcl_average_surface: Path @@ -420,33 +484,29 @@ def test_primvar_preservation( vtp_file = kcl_average_surface - # Read to check arrays + # Read source to count arrays mesh_data = read_vtk_file(vtp_file) array_names = [arr.name for arr in mesh_data.generic_arrays] - # Single mesh so path is /World/Meshes/Mesh - settings = ConversionSettings( - separate_objects_by_connectivity=False, - separate_objects_by_cell_type=False, - ) output_usd = output_dir / "heart_with_primvars.usd" - converter = VTKToUSDConverter(settings) - stage = converter.convert_file(vtp_file, output_usd, mesh_name="Mesh") - # Check primvars exist - mesh_prim = stage.GetPrimAtPath("/World/Meshes/Mesh") + stage = ConvertVTKToUSD.from_files( + data_basename="Mesh", + vtk_files=[vtp_file], + ).convert(str(output_usd)) + + # No split: mesh at /World/Mesh/Mesh + mesh_prim = stage.GetPrimAtPath("/World/Mesh/Mesh") primvars_api = UsdGeom.PrimvarsAPI(mesh_prim) primvars = primvars_api.GetPrimvars() primvar_names = [pv.GetPrimvarName() for pv in primvars] - - # Verify at least some arrays were converted to primvars assert len(primvar_names) > 0 print("\nPrimvars preserved:") print(f" Source arrays: {len(array_names)}") print(f" USD primvars: {len(primvar_names)}") - for name in primvar_names[:5]: # Show first 5 + for name in primvar_names[:5]: print(f" - {name}") @@ -457,41 +517,31 @@ class TestTimeSeriesConversion: def test_time_series_conversion( self, test_directories: dict[str, Path], kcl_average_surface: Path ) -> None: - """Test converting multiple VTK files as time series.""" + """Test converting multiple VTK files as a time series.""" output_dir = test_directories["output"] / "vtk_to_usd_library" output_dir.mkdir(parents=True, exist_ok=True) vtp_file = kcl_average_surface - # Use same file multiple times to simulate time series + # Use the same file three times to simulate a time series vtk_files = [vtp_file] * 3 time_codes = [0.0, 1.0, 2.0] - # Single mesh so path is /World/Meshes/Mesh - settings = ConversionSettings( - separate_objects_by_connectivity=False, - separate_objects_by_cell_type=False, - ) output_usd = output_dir / "heart_time_series.usd" - converter = VTKToUSDConverter(settings) - stage = converter.convert_sequence( + + stage = ConvertVTKToUSD.from_files( + data_basename="Mesh", vtk_files=vtk_files, - output_usd=output_usd, time_codes=time_codes, - mesh_name="Mesh", - ) + ).convert(str(output_usd)) - # Verify time range assert stage.GetStartTimeCode() == 0.0 assert stage.GetEndTimeCode() == 2.0 - # Verify mesh has time samples - mesh_prim = stage.GetPrimAtPath("/World/Meshes/Mesh") + # No split: mesh at /World/Mesh/Mesh + mesh_prim = stage.GetPrimAtPath("/World/Mesh/Mesh") mesh = UsdGeom.Mesh(mesh_prim) - points_attr = mesh.GetPointsAttr() - - # Check time samples exist - time_samples = points_attr.GetTimeSamples() + time_samples = mesh.GetPointsAttr().GetTimeSamples() assert len(time_samples) == 3 assert time_samples == time_codes @@ -515,53 +565,30 @@ def test_end_to_end_conversion( output_dir.mkdir(parents=True, exist_ok=True) vtp_file = kcl_average_surface + output_usd = output_dir / "heart_complete.usd" - # Configure everything (single mesh for predictable path) - settings = ConversionSettings( - triangulate_meshes=True, - compute_normals=True, - preserve_point_arrays=True, - separate_objects_by_connectivity=False, - separate_objects_by_cell_type=False, - meters_per_unit=0.001, + stage = ConvertVTKToUSD.from_files( + data_basename="CardiacModel", + vtk_files=[vtp_file], + solid_color=(0.85, 0.2, 0.2), times_per_second=24.0, - ) - - material = MaterialData( - name="cardiac_muscle", - diffuse_color=(0.85, 0.2, 0.2), - roughness=0.5, - metallic=0.0, - ) - - # Convert - output_usd = output_dir / "heart_complete.usd" - converter = VTKToUSDConverter(settings) - stage = converter.convert_file( - vtp_file, - output_usd, - mesh_name="CardiacModel", - material=material, - ) + ).convert(str(output_usd)) - # Comprehensive verification assert output_usd.exists() assert stage is not None - # Check structure - mesh_prim = stage.GetPrimAtPath("/World/Meshes/CardiacModel") + # No split: mesh at /World/CardiacModel/Mesh + mesh_prim = stage.GetPrimAtPath("/World/CardiacModel/Mesh") assert mesh_prim.IsValid() - # Check geometry mesh = UsdGeom.Mesh(mesh_prim) points = mesh.GetPointsAttr().Get() assert len(points) > 0 - # Check material - material_prim = stage.GetPrimAtPath(f"/World/Looks/{material.name}") + # Material is auto-named "Mesh_material" + material_prim = stage.GetPrimAtPath("/World/Looks/Mesh_material") assert material_prim.IsValid() - # Check primvars primvars_api = UsdGeom.PrimvarsAPI(mesh_prim) primvars = primvars_api.GetPrimvars() assert len(primvars) > 0