From 2c24c435043b763e5f554ea80cb5e2dfd9db137f Mon Sep 17 00:00:00 2001 From: Kevin Marchais Date: Tue, 12 May 2026 14:12:23 +0200 Subject: [PATCH 1/4] convert lattice and tpms constructors to fluent with_* setters --- docs/Overview.rst | 5 +- docs/quick_start.rst | 20 +- docs/tutorials.rst | 98 +-- .../voronoiGyroid/voronoiGyroid.py | 6 +- examples/Lattices/custom_lattice.py | 11 +- examples/Lattices/preset_lattice.py | 24 +- examples/Mesh/gyroid/gyroid_step_remesh.py | 6 +- examples/Mesh/remesh/remesh.py | 42 +- .../TPMS/coordinate_system/cylindrical.py | 13 +- .../coordinate_system/cylindrical_graded.py | 23 +- examples/TPMS/coordinate_system/rotation.py | 15 +- examples/TPMS/coordinate_system/spherical.py | 11 +- examples/TPMS/grading/cell_size.py | 10 +- examples/TPMS/grading/cell_type.py | 10 +- examples/TPMS/grading/density.py | 10 +- examples/TPMS/grading/distance_to_surface.py | 11 +- examples/TPMS/gyroid/gyroid.py | 6 +- examples/TPMS/infill/gyroid.py | 17 +- examples/TPMS/infill/infill.py | 11 +- examples/TPMS/surface/fischer_koch_s.py | 2 +- examples/TPMS/tpms/tpms.py | 6 +- examples/TPMS/tpmsShell/tpmsShell.py | 10 +- examples/TPMS/tpmsSphere/tpmsSphere.py | 10 +- examples/tpms_infill_gallery.py | 37 +- microgen/cad.py | 29 +- .../shape/strut_lattice/abstract_lattice.py | 471 ++++++++----- .../strut_lattice/body_centered_cubic.py | 2 +- microgen/shape/strut_lattice/cubic.py | 2 +- microgen/shape/strut_lattice/cuboctahedron.py | 2 +- .../shape/strut_lattice/custom_lattice.py | 39 +- microgen/shape/strut_lattice/diamond.py | 2 +- .../strut_lattice/face_centered_cubic.py | 2 +- microgen/shape/strut_lattice/octahedron.py | 2 +- microgen/shape/strut_lattice/octet_truss.py | 2 +- .../strut_lattice/rhombic_cuboctahedron.py | 2 +- .../strut_lattice/rhombic_dodecahedron.py | 2 +- .../shape/strut_lattice/truncated_cube.py | 2 +- .../strut_lattice/truncated_cuboctahedron.py | 2 +- .../strut_lattice/truncated_octahedron.py | 2 +- microgen/shape/surface_functions.py | 128 ++-- microgen/shape/tpms.py | 617 +++++++++--------- pyproject.toml | 12 + tests/shapes/test_lattice.py | 16 +- tests/shapes/test_tpms.py | 337 +++++----- tests/shapes/test_tpms_frep.py | 90 ++- tests/test_cad_optional.py | 2 +- tests/test_remesh.py | 2 +- 47 files changed, 1135 insertions(+), 1046 deletions(-) diff --git a/docs/Overview.rst b/docs/Overview.rst index 799bad8b..a7c70bf8 100644 --- a/docs/Overview.rst +++ b/docs/Overview.rst @@ -65,9 +65,8 @@ Brief examples import microgen geometry = microgen.Tpms( - surface_function=microgen.surface_functions.gyroid, - offset=0.3 - ) + surface_function=microgen.surface_functions.gyroid, + ).with_offset(0.3) shape = geometry.sheet shape.plot(color='white') diff --git a/docs/quick_start.rst b/docs/quick_start.rst index 8836425c..5ad992b0 100644 --- a/docs/quick_start.rst +++ b/docs/quick_start.rst @@ -34,12 +34,12 @@ Let's create a Gyroid TPMS structure, one of the most common triply periodic min .. jupyter-execute:: - gyroid = microgen.Tpms( - surface_function=microgen.surface_functions.gyroid, - offset=0.3, - cell_size=1.0, - repeat_cell=2, - resolution=20 + gyroid = ( + microgen.Tpms(surface_function=microgen.surface_functions.gyroid) + .with_offset(0.3) + .with_cell_size(1.0) + .with_repeat_cell(2) + .with_resolution(20) ) # Get the sheet geometry as a PyVista mesh @@ -54,10 +54,10 @@ Each TPMS can generate different part types: .. jupyter-execute:: - tpms = microgen.Tpms( - surface_function=microgen.surface_functions.gyroid, - offset=0.5, - resolution=20 + tpms = ( + microgen.Tpms(surface_function=microgen.surface_functions.gyroid) + .with_offset(0.5) + .with_resolution(20) ) # Sheet (wall) geometry diff --git a/docs/tutorials.rst b/docs/tutorials.rst index 16522003..e28b40de 100644 --- a/docs/tutorials.rst +++ b/docs/tutorials.rst @@ -171,12 +171,12 @@ Schwarz P: .. jupyter-execute:: - schwarz_p = microgen.Tpms( - surface_function=microgen.surface_functions.schwarz_p, - offset=0.3, - cell_size=1.0, - repeat_cell=2, - resolution=20 + schwarz_p = ( + microgen.Tpms(surface_function=microgen.surface_functions.schwarz_p) + .with_offset(0.3) + .with_cell_size(1.0) + .with_repeat_cell(2) + .with_resolution(20) ) schwarz_p.sheet.plot(color='white') @@ -184,12 +184,12 @@ Schwarz D: .. jupyter-execute:: - schwarz_d = microgen.Tpms( - surface_function=microgen.surface_functions.schwarz_d, - offset=0.3, - cell_size=1.0, - repeat_cell=2, - resolution=20 + schwarz_d = ( + microgen.Tpms(surface_function=microgen.surface_functions.schwarz_d) + .with_offset(0.3) + .with_cell_size(1.0) + .with_repeat_cell(2) + .with_resolution(20) ) schwarz_d.sheet.plot(color='white') @@ -197,12 +197,12 @@ Neovius: .. jupyter-execute:: - neovius = microgen.Tpms( - surface_function=microgen.surface_functions.neovius, - offset=0.3, - cell_size=1.0, - repeat_cell=2, - resolution=20 + neovius = ( + microgen.Tpms(surface_function=microgen.surface_functions.neovius) + .with_offset(0.3) + .with_cell_size(1.0) + .with_repeat_cell(2) + .with_resolution(20) ) neovius.sheet.plot(color='white') @@ -214,12 +214,12 @@ You can specify a target density instead of an offset value: .. jupyter-execute:: - gyroid_50 = microgen.Tpms( - surface_function=microgen.surface_functions.gyroid, - density=0.5, # 50% density - cell_size=1.0, - repeat_cell=2, - resolution=20 + gyroid_50 = ( + microgen.Tpms(surface_function=microgen.surface_functions.gyroid) + .with_density(0.5) # 50% density + .with_cell_size(1.0) + .with_repeat_cell(2) + .with_resolution(20) ) gyroid_50.sheet.plot(color='white') @@ -231,13 +231,15 @@ Create TPMS on a spherical coordinate system: .. jupyter-execute:: - spherical_gyroid = microgen.SphericalTpms( - radius=2.0, - surface_function=microgen.surface_functions.gyroid, - offset=0.3, - cell_size=0.5, - repeat_cell=(3, 0, 0), # 0 = auto-fill to complete sphere - resolution=20 + spherical_gyroid = ( + microgen.SphericalTpms( + radius=2.0, + surface_function=microgen.surface_functions.gyroid, + ) + .with_offset(0.3) + .with_cell_size(0.5) + .with_repeat_cell((3, 0, 0)) # 0 = auto-fill to complete sphere + .with_resolution(20) ) spherical_gyroid.sheet.plot(color='white') @@ -249,13 +251,15 @@ Create TPMS on a cylindrical coordinate system: .. jupyter-execute:: - cylindrical_gyroid = microgen.CylindricalTpms( - radius=1.5, - surface_function=microgen.surface_functions.gyroid, - offset=0.3, - cell_size=0.5, - repeat_cell=(2, 0, 3), # 0 = auto-fill circumference - resolution=20 + cylindrical_gyroid = ( + microgen.CylindricalTpms( + radius=1.5, + surface_function=microgen.surface_functions.gyroid, + ) + .with_offset(0.3) + .with_cell_size(0.5) + .with_repeat_cell((2, 0, 3)) # 0 = auto-fill circumference + .with_resolution(20) ) cylindrical_gyroid.sheet.plot(color='white') @@ -348,10 +352,10 @@ Generate a tetrahedral mesh using Gmsh: import microgen # Create a TPMS geometry - gyroid = microgen.Tpms( - surface_function=microgen.surface_functions.gyroid, - offset=0.3, - resolution=20 + gyroid = ( + microgen.Tpms(surface_function=microgen.surface_functions.gyroid) + .with_offset(0.3) + .with_resolution(20) ) shape = gyroid.generate_cad(type_part='sheet') @@ -375,11 +379,11 @@ Generate a periodic mesh suitable for homogenization: .. code-block:: python # Create geometry - gyroid = microgen.Tpms( - surface_function=microgen.surface_functions.gyroid, - offset=0.3, - cell_size=1.0, - resolution=20 + gyroid = ( + microgen.Tpms(surface_function=microgen.surface_functions.gyroid) + .with_offset(0.3) + .with_cell_size(1.0) + .with_resolution(20) ) shape = gyroid.generate_cad(type_part='sheet') diff --git a/examples/3Doperations/voronoiGyroid/voronoiGyroid.py b/examples/3Doperations/voronoiGyroid/voronoiGyroid.py index 8090b698..5959d245 100644 --- a/examples/3Doperations/voronoiGyroid/voronoiGyroid.py +++ b/examples/3Doperations/voronoiGyroid/voronoiGyroid.py @@ -9,10 +9,8 @@ polyhedra = Neper.voronoi_from_tess_file(tess_file) gyroid = Tpms( - center=(0.5, 0.5, 0.5), - surface_function=surface_functions.gyroid, - offset=0.2, -) + center=(0.5, 0.5, 0.5), surface_function=surface_functions.gyroid +).with_offset(0.2) gyroid = gyroid.generate_cad(type_part="sheet").translate((0.5, 0.5, 0.5)) phases = [] diff --git a/examples/Lattices/custom_lattice.py b/examples/Lattices/custom_lattice.py index 5d6a5f82..98ebde23 100644 --- a/examples/Lattices/custom_lattice.py +++ b/examples/Lattices/custom_lattice.py @@ -84,12 +84,11 @@ [l1_strut_vertex_pairs, l2_strut_vertex_pairs, half_l1_strut_vertex_pairs] ) -auxetic_lattice = CustomLattice( - strut_radius=0.1, - strut_heights=strut_heights, - base_vertices=base_vertices, - strut_vertex_pairs=strut_vertex_pairs, - strut_joints=True, +auxetic_lattice = ( + CustomLattice(base_vertices, strut_vertex_pairs) + .with_strut_radius(0.1) + .with_strut_heights(strut_heights) + .with_strut_joints() ) shape = auxetic_lattice.generate_cad() diff --git a/examples/Lattices/preset_lattice.py b/examples/Lattices/preset_lattice.py index edc6ab4f..56aee595 100644 --- a/examples/Lattices/preset_lattice.py +++ b/examples/Lattices/preset_lattice.py @@ -19,18 +19,18 @@ ) preset_lattice_list = [ - BodyCenteredCubic(strut_radius=0.1), - Cubic(strut_radius=0.1), - Cuboctahedron(strut_radius=0.1), - Diamond(strut_radius=0.1), - FaceCenteredCubic(strut_radius=0.1), - Octahedron(strut_radius=0.1), - OctetTruss(strut_radius=0.1), - RhombicCuboctahedron(strut_radius=0.1), - RhombicDodecahedron(strut_radius=0.1), - TruncatedCube(strut_radius=0.1), - TruncatedCuboctahedron(strut_radius=0.1), - TruncatedOctahedron(strut_radius=0.1), + BodyCenteredCubic(0.1), + Cubic(0.1), + Cuboctahedron(0.1), + Diamond(0.1), + FaceCenteredCubic(0.1), + Octahedron(0.1), + OctetTruss(0.1), + RhombicCuboctahedron(0.1), + RhombicDodecahedron(0.1), + TruncatedCube(0.1), + TruncatedCuboctahedron(0.1), + TruncatedOctahedron(0.1), ] meshes = pv.PolyData() diff --git a/examples/Mesh/gyroid/gyroid_step_remesh.py b/examples/Mesh/gyroid/gyroid_step_remesh.py index 0164829d..21d67d61 100755 --- a/examples/Mesh/gyroid/gyroid_step_remesh.py +++ b/examples/Mesh/gyroid/gyroid_step_remesh.py @@ -27,11 +27,7 @@ import pyvista as pv # 1. Generate a TPMS geometry using the gyroid surface function. -geometry = Tpms( - surface_function=gyroid, - density=0.30, - resolution=30, -) +geometry = Tpms(surface_function=gyroid).with_density(0.30).with_resolution(30) # 2. Wrap the geometry into a microgen Phase object. phases = [] diff --git a/examples/Mesh/remesh/remesh.py b/examples/Mesh/remesh/remesh.py index 50954ea7..5cd15370 100644 --- a/examples/Mesh/remesh/remesh.py +++ b/examples/Mesh/remesh/remesh.py @@ -1,21 +1,21 @@ -"""Remesh a gyroid surface keeping periodicity for FEM simulations.""" - -from pathlib import Path - -from microgen import Tpms -from microgen.remesh import remesh_keeping_boundaries_for_fem -from microgen.shape.surface_functions import gyroid - -data_dir = Path(__file__).parent / "data" -Path.mkdir(data_dir, exist_ok=True) - -tpms = Tpms(surface_function=gyroid, offset=1.0, resolution=50) -initial_gyroid = tpms.grid_sheet -initial_gyroid.save(data_dir / "initial_gyroid_mesh.vtk") - -max_element_edge_length = 0.02 -remeshed_gyroid = remesh_keeping_boundaries_for_fem( - initial_gyroid, - hmax=max_element_edge_length, -) -remeshed_gyroid.save(data_dir / "remeshed_gyroid_mesh.vtk") +"""Remesh a gyroid surface keeping periodicity for FEM simulations.""" + +from pathlib import Path + +from microgen import Tpms +from microgen.remesh import remesh_keeping_boundaries_for_fem +from microgen.shape.surface_functions import gyroid + +data_dir = Path(__file__).parent / "data" +Path.mkdir(data_dir, exist_ok=True) + +tpms = Tpms(surface_function=gyroid).with_offset(1.0).with_resolution(50) +initial_gyroid = tpms.grid_sheet +initial_gyroid.save(data_dir / "initial_gyroid_mesh.vtk") + +max_element_edge_length = 0.02 +remeshed_gyroid = remesh_keeping_boundaries_for_fem( + initial_gyroid, + hmax=max_element_edge_length, +) +remeshed_gyroid.save(data_dir / "remeshed_gyroid_mesh.vtk") diff --git a/examples/TPMS/coordinate_system/cylindrical.py b/examples/TPMS/coordinate_system/cylindrical.py index ad4a1844..3014ac64 100644 --- a/examples/TPMS/coordinate_system/cylindrical.py +++ b/examples/TPMS/coordinate_system/cylindrical.py @@ -6,13 +6,12 @@ def swapped_gyroid(x, y, z): return gyroid(x=z, y=y, z=x) -geometry = CylindricalTpms( - surface_function=swapped_gyroid, - offset=0.5, - cell_size=(1, 1, 1), - repeat_cell=(1, 0, 1), - radius=1, - resolution=20, +geometry = ( + CylindricalTpms(surface_function=swapped_gyroid, radius=1) + .with_offset(0.5) + .with_cell_size((1, 1, 1)) + .with_repeat_cell((1, 0, 1)) + .with_resolution(20) ) sheet = geometry.sheet diff --git a/examples/TPMS/coordinate_system/cylindrical_graded.py b/examples/TPMS/coordinate_system/cylindrical_graded.py index fd9530f8..a9c2d8a4 100644 --- a/examples/TPMS/coordinate_system/cylindrical_graded.py +++ b/examples/TPMS/coordinate_system/cylindrical_graded.py @@ -39,17 +39,18 @@ def grading( density=1.0, ) -geometry = CylindricalTpms( - radius=5, - surface_function=swapped_gyroid, - offset=partial( - grading, - min_offset=0.0, - max_offset=full_density_offset, - ), - cell_size=(1, 1, 1), - repeat_cell=(5, 0, 1), - resolution=20, +geometry = ( + CylindricalTpms(radius=5, surface_function=swapped_gyroid) + .with_offset( + partial( + grading, + min_offset=0.0, + max_offset=full_density_offset, + ) + ) + .with_cell_size((1, 1, 1)) + .with_repeat_cell((5, 0, 1)) + .with_resolution(20) ) sheet = geometry.sheet diff --git a/examples/TPMS/coordinate_system/rotation.py b/examples/TPMS/coordinate_system/rotation.py index 8dcc1933..94ff0ee9 100644 --- a/examples/TPMS/coordinate_system/rotation.py +++ b/examples/TPMS/coordinate_system/rotation.py @@ -19,13 +19,14 @@ def rotated_gyroid( grid_rotation = Rotation.from_euler("z", 45, degrees=True) -geometry = CylindricalTpms( - surface_function=partial(rotated_gyroid, grid_rotation=grid_rotation), - offset=0.5, - cell_size=(1, 1, 1), - repeat_cell=(1, 0, 1), - radius=1, - resolution=20, +geometry = ( + CylindricalTpms( + surface_function=partial(rotated_gyroid, grid_rotation=grid_rotation), radius=1 + ) + .with_offset(0.5) + .with_cell_size((1, 1, 1)) + .with_repeat_cell((1, 0, 1)) + .with_resolution(20) ) sheet = geometry.sheet diff --git a/examples/TPMS/coordinate_system/spherical.py b/examples/TPMS/coordinate_system/spherical.py index c87e800d..b087be64 100644 --- a/examples/TPMS/coordinate_system/spherical.py +++ b/examples/TPMS/coordinate_system/spherical.py @@ -6,12 +6,11 @@ def swapped_gyroid(x, y, z): return gyroid(x=z, y=y, z=x) -geometry = SphericalTpms( - surface_function=swapped_gyroid, - offset=0.5, - repeat_cell=(1, 10, 20), - radius=3, - resolution=50, +geometry = ( + SphericalTpms(surface_function=swapped_gyroid, radius=3) + .with_offset(0.5) + .with_repeat_cell((1, 10, 20)) + .with_resolution(50) ) surface = geometry.surface diff --git a/examples/TPMS/grading/cell_size.py b/examples/TPMS/grading/cell_size.py index 42a523bf..03b5d5b1 100644 --- a/examples/TPMS/grading/cell_size.py +++ b/examples/TPMS/grading/cell_size.py @@ -56,11 +56,11 @@ def graded( ) -geometry = Tpms( - surface_function=graded, - offset=0.3, - repeat_cell=(5, 2, 2), - resolution=30, +geometry = ( + Tpms(surface_function=graded) + .with_offset(0.3) + .with_repeat_cell((5, 2, 2)) + .with_resolution(30) ) sheet = geometry.sheet diff --git a/examples/TPMS/grading/cell_type.py b/examples/TPMS/grading/cell_type.py index fe1132ca..775eb15a 100644 --- a/examples/TPMS/grading/cell_type.py +++ b/examples/TPMS/grading/cell_type.py @@ -92,11 +92,11 @@ def trigraded( ) -geometry = Tpms( - surface_function=trigraded, - offset=0.3, - repeat_cell=REPEAT, - resolution=50, +geometry = ( + Tpms(surface_function=trigraded) + .with_offset(0.3) + .with_repeat_cell(REPEAT) + .with_resolution(50) ) sheet = geometry.sheet diff --git a/examples/TPMS/grading/density.py b/examples/TPMS/grading/density.py index e5729ac3..e68b459f 100644 --- a/examples/TPMS/grading/density.py +++ b/examples/TPMS/grading/density.py @@ -35,11 +35,11 @@ def circular_graded_offset( return (max_offset - min_offset) * (x**2 + y**2) / radius**2 + min_offset -geometry = Tpms( - surface_function=gyroid, - offset=linear_graded_offset, - repeat_cell=(5, 2, 1), - resolution=30, +geometry = ( + Tpms(surface_function=gyroid) + .with_offset(linear_graded_offset) + .with_repeat_cell((5, 2, 1)) + .with_resolution(30) ) sheet = geometry.sheet diff --git a/examples/TPMS/grading/distance_to_surface.py b/examples/TPMS/grading/distance_to_surface.py index d097cd56..54267696 100644 --- a/examples/TPMS/grading/distance_to_surface.py +++ b/examples/TPMS/grading/distance_to_surface.py @@ -13,12 +13,11 @@ boundary_weight=1.0, ) -infill = Infill( - obj=mesh, - surface_function=gyroid, - cell_size=0.015, - offset=offset, - resolution=5, +infill = ( + Infill(obj=mesh, surface_function=gyroid) + .with_cell_size(0.015) + .with_offset(offset) + .with_resolution(5) ) pl = pv.Plotter() diff --git a/examples/TPMS/gyroid/gyroid.py b/examples/TPMS/gyroid/gyroid.py index 8132dcf5..77630406 100755 --- a/examples/TPMS/gyroid/gyroid.py +++ b/examples/TPMS/gyroid/gyroid.py @@ -1,10 +1,6 @@ from microgen import Tpms from microgen.shape.surface_functions import gyroid -geometry = Tpms( - surface_function=gyroid, - density=0.30, - resolution=30, -) +geometry = Tpms(surface_function=gyroid).with_density(0.30).with_resolution(30) shape = geometry.generate_surface_mesh(type_part="sheet") shape.save("gyroid.stl") diff --git a/examples/TPMS/infill/gyroid.py b/examples/TPMS/infill/gyroid.py index 98440e94..689902d1 100644 --- a/examples/TPMS/infill/gyroid.py +++ b/examples/TPMS/infill/gyroid.py @@ -5,18 +5,13 @@ from microgen import Infill, Tpms from microgen.shape.surface_functions import gyroid -tpms = Tpms( - surface_function=gyroid, - offset=1.0, - resolution=30, -) +tpms = Tpms(surface_function=gyroid).with_offset(1.0).with_resolution(30) -infill = Infill( - obj=tpms.sheet, - surface_function=gyroid, - cell_size=0.1, - offset=0.5, - resolution=15, +infill = ( + Infill(obj=tpms.sheet, surface_function=gyroid) + .with_cell_size(0.1) + .with_offset(0.5) + .with_resolution(15) ) pl = pv.Plotter() diff --git a/examples/TPMS/infill/infill.py b/examples/TPMS/infill/infill.py index 3a2d5c0d..4afa1bfc 100644 --- a/examples/TPMS/infill/infill.py +++ b/examples/TPMS/infill/infill.py @@ -8,12 +8,11 @@ bunny = examples.download_bunny() -infill = Infill( - obj=bunny, - surface_function=gyroid, - cell_size=0.015, - offset=0.5, - resolution=10, +infill = ( + Infill(obj=bunny, surface_function=gyroid) + .with_cell_size(0.015) + .with_offset(0.5) + .with_resolution(10) ) pl = pv.Plotter() diff --git a/examples/TPMS/surface/fischer_koch_s.py b/examples/TPMS/surface/fischer_koch_s.py index a54ea648..abefc8ce 100644 --- a/examples/TPMS/surface/fischer_koch_s.py +++ b/examples/TPMS/surface/fischer_koch_s.py @@ -3,7 +3,7 @@ from microgen import Tpms from microgen.shape.surface_functions import fischer_koch_s -geometry = Tpms(surface_function=fischer_koch_s, repeat_cell=5, offset=0) +geometry = Tpms(surface_function=fischer_koch_s).with_repeat_cell(5).with_offset(0) mesh = geometry.generate_surface_mesh(type_part="surface") vtk_file = Path(__file__).parent / "surface.vtk" diff --git a/examples/TPMS/tpms/tpms.py b/examples/TPMS/tpms/tpms.py index 1e26b762..a4d72eee 100644 --- a/examples/TPMS/tpms/tpms.py +++ b/examples/TPMS/tpms/tpms.py @@ -27,11 +27,7 @@ i_x = i % n_col i_y = i // n_col - elem = Tpms( - surface_function=surface, - offset=0.3, - resolution=50, - ) + elem = Tpms(surface_function=surface).with_offset(0.3).with_resolution(50) # center = (1.2 * (i_x - 0.5 * (n_col - 1)), -1.2 * (i_y - 0.5 * (n_row - 1)), 0) mesh = elem.sheet diff --git a/examples/TPMS/tpmsShell/tpmsShell.py b/examples/TPMS/tpmsShell/tpmsShell.py index 560df13e..deb8b6e7 100755 --- a/examples/TPMS/tpmsShell/tpmsShell.py +++ b/examples/TPMS/tpmsShell/tpmsShell.py @@ -11,11 +11,11 @@ inner = make_box((2.8, 2.8, 2.8), (0.0, 0.0, 0.0)) shell = outer.cut(inner) -geometry = Tpms( - surface_function=surface_functions.gyroid, - offset=0.5, - repeat_cell=3, - resolution=15, +geometry = ( + Tpms(surface_function=surface_functions.gyroid) + .with_offset(0.5) + .with_repeat_cell(3) + .with_resolution(15) ) shape = geometry.generate_cad(type_part="sheet", smoothing=0) diff --git a/examples/TPMS/tpmsSphere/tpmsSphere.py b/examples/TPMS/tpmsSphere/tpmsSphere.py index c6a72dca..b56e46f6 100755 --- a/examples/TPMS/tpmsSphere/tpmsSphere.py +++ b/examples/TPMS/tpmsSphere/tpmsSphere.py @@ -4,11 +4,11 @@ from microgen import Tpms, surface_functions -geometry = Tpms( - surface_function=surface_functions.gyroid, - offset=0.3, - repeat_cell=3, - resolution=30, +geometry = ( + Tpms(surface_function=surface_functions.gyroid) + .with_offset(0.3) + .with_repeat_cell(3) + .with_resolution(30) ) shape = geometry.generate_surface_mesh(type_part="sheet") shape = shape.flip_faces() diff --git a/examples/tpms_infill_gallery.py b/examples/tpms_infill_gallery.py index 4104ba95..3ec46a4d 100644 --- a/examples/tpms_infill_gallery.py +++ b/examples/tpms_infill_gallery.py @@ -58,12 +58,11 @@ def report(label: str, mesh: pv.DataSet) -> None: # 1. SphericalTpms — gyroid wrapping a sphere (auto-fill θ + φ via repeat=0) # ----------------------------------------------------------------------------- -sph = SphericalTpms( - radius=SPHERE_RADIUS, - surface_function=gyroid, - offset=OFFSET, - cell_size=CELL_SIZE, - repeat_cell=(2, 0, 0), +sph = ( + SphericalTpms(radius=SPHERE_RADIUS, surface_function=gyroid) + .with_offset(OFFSET) + .with_cell_size(CELL_SIZE) + .with_repeat_cell((2, 0, 0)) ) m_sph = sph.generate_surface_mesh(type_part="sheet") clip_y(m_sph).save(OUT / "sphere_g4_tpms.vtk") @@ -73,12 +72,11 @@ def report(label: str, mesh: pv.DataSet) -> None: # 2. CylindricalTpms — gyroid wrapping a cylinder (auto-fill θ via repeat=0) # ----------------------------------------------------------------------------- -cyl = CylindricalTpms( - radius=CYLINDER_RADIUS, - surface_function=gyroid, - offset=OFFSET, - cell_size=CELL_SIZE, - repeat_cell=(2, 0, int(CYLINDER_HEIGHT / CELL_SIZE)), +cyl = ( + CylindricalTpms(radius=CYLINDER_RADIUS, surface_function=gyroid) + .with_offset(OFFSET) + .with_cell_size(CELL_SIZE) + .with_repeat_cell((2, 0, int(CYLINDER_HEIGHT / CELL_SIZE))) ) m_cyl = cyl.generate_surface_mesh(type_part="sheet") clip_y(m_cyl).save(OUT / "cylinder_g5_tpms.vtk") @@ -95,14 +93,13 @@ def helix(t: float) -> np.ndarray: return np.array([2.0 * np.cos(theta), 2.0 * np.sin(theta), 6.0 * (t - 0.5)]) -sw = Sweep( - curve_points=helix, - surface_function=gyroid, - radial_max=0.6, - offset=OFFSET, - cell_size=CELL_SIZE, - repeat_cell=(8, 1, 6), - n_curve_samples=200, +sw = ( + Sweep( + curve_points=helix, surface_function=gyroid, radial_max=0.6, n_curve_samples=200 + ) + .with_offset(OFFSET) + .with_cell_size(CELL_SIZE) + .with_repeat_cell((8, 1, 6)) ) m_sw = sw.generate_surface_mesh(type_part="sheet") clip_y(m_sw).save(OUT / "sweep_g7_helix.vtk") diff --git a/microgen/cad.py b/microgen/cad.py index e6dd3668..983950f5 100644 --- a/microgen/cad.py +++ b/microgen/cad.py @@ -26,6 +26,7 @@ from __future__ import annotations from collections.abc import Iterable, Sequence +from dataclasses import dataclass from typing import TYPE_CHECKING, Any import numpy as np @@ -78,31 +79,19 @@ def to_tuple(self) -> tuple[float, float, float]: return (self[0], self[1], self[2]) +@dataclass class _BBox: """Axis-aligned bounding box exposing ``xmin`` / ``xmax`` / …. - Returned by :meth:`CadShape.bounding_box`. Also indexable as a 6-tuple - ``(xmin, ymin, zmin, xmax, ymax, zmax)`` matching OCCT's ``Bnd_Box.Get``. + Returned by :meth:`CadShape.BoundingBox`. """ - __slots__ = ("xmax", "xmin", "ymax", "ymin", "zmax", "zmin") - - def __init__( - self, - xmin: float, - ymin: float, - zmin: float, - xmax: float, - ymax: float, - zmax: float, - ) -> None: - """Initialize from the 6 axis-aligned extents.""" - self.xmin = float(xmin) - self.ymin = float(ymin) - self.zmin = float(zmin) - self.xmax = float(xmax) - self.ymax = float(ymax) - self.zmax = float(zmax) + xmin: float + ymin: float + zmin: float + xmax: float + ymax: float + zmax: float @property def diagonal_length(self) -> float: diff --git a/microgen/shape/strut_lattice/abstract_lattice.py b/microgen/shape/strut_lattice/abstract_lattice.py index d0a57658..c57796d3 100644 --- a/microgen/shape/strut_lattice/abstract_lattice.py +++ b/microgen/shape/strut_lattice/abstract_lattice.py @@ -32,10 +32,30 @@ from microgen.shape import KwargsGenerateType, Vector3DType BALL_POINT_RADIUS_TOLERANCE = 1e-5 +_DENSITY_ROOT_RADIUS_MIN = 1e-3 class AbstractLattice(Shape): - """Abstract Class to create strut-based lattice.""" + """Abstract class for strut-based lattices, configured via method chaining. + + Construction starts cheap: ``__init__`` only stashes ``center`` and + ``orientation``. Geometry parameters are set via chained ``with_*`` + setters; heavy work (vertex layout, density root-finding, CAD assembly) + runs lazily on the first call to :meth:`generate_cad` / + :meth:`generate_surface_mesh`. + + Example:: + + lattice = ( + OctetTruss() + .with_strut_radius(0.05) + .with_cell_size(1.0) + .generate_surface_mesh() + ) + + Mutual exclusivity: ``with_strut_radius`` and ``with_density`` cannot both + be set; the validator raises at the first terminal call if both are. + """ _UNIT_CUBE_SIZE = 1.0 _DEFAULT_STRUT_HEIGHTS: float | list[float] | None = None @@ -43,137 +63,256 @@ class AbstractLattice(Shape): def __init__( self, strut_radius: float | None = None, - strut_heights: float | list[float] | None = None, - base_vertices: npt.NDArray[np.float64] | None = None, - strut_vertex_pairs: npt.NDArray[np.int64] | None = None, - cell_size: float = 1.0, - strut_joints: bool = False, + *, density: float | None = None, - **kwargs: Vector3DType | Rotation, + center: Vector3DType = (0, 0, 0), + orientation: Vector3DType | Rotation = (0, 0, 0), ) -> None: - """Abstract Class to create strut-based lattice. - - The lattice will be created in a cube which size can be - modified with 'cell_size'. - - :param strut_radius: radius of the struts - :param strut_height: either the unique height of all struts (float), - or a list of strut heights (list[float]). Enter value for a size 1 rve. - :param base_vertices: array of lattice vertices, considering it is - created in a cubic RVE of size 1 and centered on the origin - :param strut_vertex_pairs: array of strut vertex pairs that define how - vertices are connected by the struts - :param cell_size: size of the cubic rve in which the lattice - cell is enclosed - :param strut_joints: option to add spherical joints at the vertices - to better manage strut junctions + """Initialize with ``strut_radius`` (or ``density``) plus defaults. + + Exactly one of ``strut_radius`` / ``density`` should be set, either + here or later via the chained ``with_strut_radius`` / ``with_density`` + setters. The remaining geometry parameters (``cell_size``, + ``strut_heights``, ``strut_joints``, custom vertices / pairs) are + configured via the ``with_*`` setters. """ - if strut_heights is None: - strut_heights = type(self)._DEFAULT_STRUT_HEIGHTS + super().__init__(center=center, orientation=orientation) + if strut_radius is not None and density is not None: err_msg = ( - "strut radius and density cannot be given at the same time. " - "Give only one." + "strut_radius and density cannot both be set. " + "Pass one positionally and leave the other unset, or use " + ".with_strut_radius() / .with_density() to choose later." ) raise ValueError(err_msg) - - if strut_radius is None and density is None: - err_msg = "strut radius or density must be given. Give one of them." + if density is not None and not 0.0 < density <= 1.0: + err_msg = f"density must be between 0 and 1. Given: {density}" raise ValueError(err_msg) - super().__init__(**kwargs) - - self.strut_radius = strut_radius - self.cell_size = cell_size - self.strut_joints = strut_joints - self._strut_heights = strut_heights - self._base_vertices = base_vertices - self._strut_vertex_pairs = strut_vertex_pairs + # Configuration (mutated by chained setters). + self._strut_radius: float | None = strut_radius + self._strut_heights: float | list[float] | None = type( + self + )._DEFAULT_STRUT_HEIGHTS + self._user_base_vertices: npt.NDArray[np.float64] | None = None + self._user_strut_vertex_pairs: npt.NDArray[np.int64] | None = None + self._cell_size: float = 1.0 + self._strut_joints: bool = False + self._density: float | None = density + + # Lazy caches (invalidated by setters). + self._cad_shape: CadShape | None = None + self._vtk_shape: tuple[tuple[float, int, bool], pv.PolyData] | None = None + self._geometry: dict | None = None + self._rve: Rve | None = None - self.rve = Rve(dim=self.cell_size, center=self.center) + # ------------------------------------------------------------------ + # Chained setters + # ------------------------------------------------------------------ - self.vertices = self._compute_vertices() - self.strut_centers = self._compute_strut_centers() - self.strut_directions_cartesian = self._compute_strut_directions() - self.strut_rotations = self._compute_rotations() + def with_strut_radius(self, radius: float) -> AbstractLattice: + """Set the strut radius. - self._validate_inputs() - self._cad_shape = None - self._vtk_shape: tuple[tuple[float, int, bool], pv.PolyData] | None = None + Clears any previously set density (last-set wins between strut radius + and density). + """ + self._strut_radius = radius + self._density = None + self._invalidate_caches() + return self - if density is not None and not 0.0 < density <= 1.0: + def with_strut_heights( + self, + heights: float | list[float], + ) -> AbstractLattice: + """Set strut heights, as a scalar or per-strut list (unit-cell units).""" + self._strut_heights = heights + self._invalidate_caches() + return self + + def with_cell_size(self, size: float) -> AbstractLattice: + """Set the cubic cell edge length.""" + self._cell_size = size + self._invalidate_caches() + return self + + def with_strut_joints(self, *, enabled: bool = True) -> AbstractLattice: + """Enable (default) or disable spherical joints at vertices.""" + self._strut_joints = enabled + self._invalidate_caches() + return self + + def with_density(self, density: float) -> AbstractLattice: + """Set target density in (0, 1]. + + Clears any previously set strut radius (last-set wins between strut + radius and density). + """ + if not 0.0 < density <= 1.0: err_msg = f"density must be between 0 and 1. Given: {density}" raise ValueError(err_msg) + self._density = density + self._strut_radius = None + self._invalidate_caches() + return self - self.density = density + def with_base_vertices( + self, + vertices: npt.NDArray[np.float64], + ) -> AbstractLattice: + """Override the subclass's default vertex layout (unit-cube units).""" + self._user_base_vertices = vertices + self._invalidate_caches() + return self + + def with_strut_vertex_pairs( + self, + pairs: npt.NDArray[np.int64], + ) -> AbstractLattice: + """Override the subclass's default strut connectivity.""" + self._user_strut_vertex_pairs = pairs + self._invalidate_caches() + return self + + def _invalidate_caches(self) -> None: + self._cad_shape = None + self._vtk_shape = None + self._geometry = None + self._rve = None - if density is not None: - self.strut_radius = self._compute_radius_to_fit_density() - else: - self.strut_radius = strut_radius + # ------------------------------------------------------------------ + # Public read accessors + # ------------------------------------------------------------------ - def _compute_radius_to_fit_density(self) -> float: - """Solve for the strut radius matching the requested density. + @property + def cell_size(self) -> float: + """Cubic cell edge length.""" + return self._cell_size - Each ``root_scalar`` step builds a CAD shape; we stash the final - one on ``self._cad_shape`` so :meth:`generate_cad` doesn't have to - rebuild it afterwards. - """ - RADIUS_MIN = 10e-4 - RADIUS_MAX_MULTIPLIER = 1.0 + @property + def strut_joints(self) -> bool: + """Whether spherical joints are added at vertices.""" + return self._strut_joints - def calc_density(radius: float) -> float: - self.strut_radius = radius - self._cad_shape = self._generate_cad() - return self._cad_shape.volume() / (self.cell_size**3) + @property + def density(self) -> float | None: + """User-requested target density (or ``None`` if radius-driven).""" + return self._density + + @property + def strut_radius(self) -> float | None: + """Effective strut radius. - return root_scalar( - lambda radius: float(calc_density(radius)) - self.density, - bracket=[RADIUS_MIN, RADIUS_MAX_MULTIPLIER * self.cell_size], - ).root + If only :meth:`with_density` was set, this triggers the lazy + density-to-radius root-find on first access. + """ + if self._strut_radius is None and self._density is not None: + self._strut_radius = self._compute_radius_to_fit_density() + return self._strut_radius @property def base_vertices(self) -> npt.NDArray[np.float64]: - """Property: coordinates of the vertices for a structure - centered at the origin and enclosed in a size 1 cubic rve""" - if self._base_vertices is not None: - return self._base_vertices + """Vertex coordinates in a unit cubic RVE centered on the origin.""" + if self._user_base_vertices is not None: + return self._user_base_vertices return self._generate_base_vertices() @property def strut_vertex_pairs(self) -> npt.NDArray[np.int64]: - """Property: pairs of vertex indices forming a strut""" - if self._strut_vertex_pairs is not None: - return self._strut_vertex_pairs + """Pairs of vertex indices defining each strut.""" + if self._user_strut_vertex_pairs is not None: + return self._user_strut_vertex_pairs return self._generate_strut_vertex_pairs() - @abstractmethod - def _generate_base_vertices(self) -> npt.NDArray[np.float64]: - """Abstract method to generate base vertices, ie as if the - lattice was centered at the origin and in a cubic size 1 rve. - """ - pass + @property + def strut_number(self) -> int: + """Number of struts in the lattice.""" + return len(self.strut_vertex_pairs) - @abstractmethod - def _generate_strut_vertex_pairs(self) -> npt.NDArray[np.int64]: - """Abstract method to generate strut vertex pairs.""" - pass + @property + def strut_heights(self) -> list[float]: + """Per-strut height list, scaled by :attr:`cell_size`.""" + if self._strut_heights is None: + err_msg = ( + "strut_heights must be defined by the subclass or set " + "via .with_strut_heights()" + ) + raise NotImplementedError(err_msg) + if isinstance(self._strut_heights, float): + return [self._strut_heights * self._cell_size] * self.strut_number + return [h * self._cell_size for h in self._strut_heights] + + @property + def rve(self) -> Rve: + """RVE that bounds the lattice cell.""" + if self._rve is None: + self._rve = Rve(dim=self._cell_size, center=self.center) + return self._rve + + @property + def vertices(self) -> npt.NDArray[np.float64]: + """Lattice vertex coordinates in world space.""" + return self._geom()["vertices"] + + @property + def strut_centers(self) -> npt.NDArray[np.float64]: + """Midpoints of each strut.""" + return self._geom()["strut_centers"] - def _compute_vertices(self) -> npt.NDArray[np.float64]: - return self.center + self.cell_size * self.base_vertices + @property + def strut_directions_cartesian(self) -> npt.NDArray[np.float64]: + """Unit direction vectors of each strut.""" + return self._geom()["strut_directions"] - def _compute_strut_centers(self) -> npt.NDArray[np.float64]: - return np.mean(self.vertices[self.strut_vertex_pairs], axis=1) + @property + def strut_rotations(self) -> list[Rotation]: + """Rotations bringing the default x-axis cylinder onto each strut.""" + return self._geom()["strut_rotations"] - def _compute_strut_directions(self) -> npt.NDArray[np.float64]: - vectors = np.diff(self.vertices[self.strut_vertex_pairs], axis=1).squeeze() - return vectors / np.linalg.norm(vectors, axis=1, keepdims=True) + # ------------------------------------------------------------------ + # Subclass hooks + # ------------------------------------------------------------------ - def _validate_inputs(self): - """Checks coherence of inputs.""" + @abstractmethod + def _generate_base_vertices(self) -> npt.NDArray[np.float64]: + """Return vertex coordinates in a unit cube centered on the origin.""" + @abstractmethod + def _generate_strut_vertex_pairs(self) -> npt.NDArray[np.int64]: + """Return pairs of vertex indices defining each strut.""" + + # ------------------------------------------------------------------ + # Lazy geometry / validation + # ------------------------------------------------------------------ + + def _geom(self) -> dict: + if self._geometry is not None: + return self._geometry + self._validate() + vertices = self.center + self._cell_size * self.base_vertices + pairs = self.strut_vertex_pairs + strut_centers = np.mean(vertices[pairs], axis=1) + vectors = np.diff(vertices[pairs], axis=1).squeeze() + directions = vectors / np.linalg.norm(vectors, axis=1, keepdims=True) + rotations = self._compute_rotations_from(directions) + self._geometry = { + "vertices": vertices, + "strut_centers": strut_centers, + "strut_directions": directions, + "strut_rotations": rotations, + } + return self._geometry + + def _validate(self) -> None: + if self._strut_radius is None and self._density is None: + err_msg = ( + "strut_radius or density must be set via .with_strut_radius() " + "or .with_density()." + ) + raise ValueError(err_msg) if self._strut_heights is None: - raise NotImplementedError("strut_heights must be defined by the subclass") + err_msg = "strut_heights must be defined by the subclass" + raise NotImplementedError(err_msg) if ( isinstance(self._strut_heights, list) and len(self._strut_heights) != self.strut_number @@ -184,54 +323,62 @@ def _validate_inputs(self): ) raise ValueError(err_msg) - @property - def strut_number(self) -> int: - return len(self.strut_vertex_pairs) - - @property - def strut_heights(self) -> list[float]: - """Return the list of strut lengths. - - If a single value is given, it is converted to a list. - """ - if isinstance(self._strut_heights, float): - return [self._strut_heights * self.cell_size] * self.strut_number - - return self._strut_heights * self.cell_size - - def _compute_rotations(self) -> list[Rotation]: - """Computes rotation from default (1.0, 0.0, 0.0) oriented Cylinder - for all struts in the lattice using Scipy's Rotation object.""" - + @staticmethod + def _compute_rotations_from( + directions: npt.NDArray[np.float64], + ) -> list[Rotation]: default_direction = np.array([1.0, 0.0, 0.0]) - - rotations_list = [] - - for i in range(self.strut_number): - if np.all( - self.strut_directions_cartesian[i] == default_direction - ) or np.all(self.strut_directions_cartesian[i] == -default_direction): - rotation_vector = np.zeros(3) - rotations_list.append(Rotation.from_rotvec(rotation_vector)) + rotations_list: list[Rotation] = [] + for i in range(directions.shape[0]): + d = directions[i] + if np.all(d == default_direction) or np.all(d == -default_direction): + rotations_list.append(Rotation.from_rotvec(np.zeros(3))) else: - rotation, _ = Rotation.align_vectors( - self.strut_directions_cartesian[i], default_direction - ) + rotation, _ = Rotation.align_vectors(d, default_direction) rotations_list.append(rotation) - return rotations_list + def _compute_radius_to_fit_density(self) -> float: + """Root-find the strut radius that matches :attr:`density`.""" + last_cad: list[CadShape | None] = [None] + + def calc_density(radius: float) -> float: + self._strut_radius = radius + cad = self._generate_cad() + last_cad[0] = cad + return cad.Volume() / (self._cell_size**3) + + result = root_scalar( + lambda r: calc_density(r) - self._density, + bracket=[_DENSITY_ROOT_RADIUS_MIN, self._cell_size], + ) + self._cad_shape = last_cad[0] + return float(result.root) + + # ------------------------------------------------------------------ + # Terminal generation + # ------------------------------------------------------------------ + def generate_cad(self, **_: KwargsGenerateType) -> CadShape: + """Generate (or return cached) CAD shape.""" if isinstance(self._cad_shape, CadShape): return self._cad_shape - + # Trigger lazy radius resolution + validation via the property. + _ = self.strut_radius + if self._cad_shape is not None: + return self._cad_shape self._cad_shape = self._generate_cad() return self._cad_shape cad_shape = property(generate_cad) + @property + def volume(self) -> float: + """Volume of the generated CAD shape.""" + return self.cad_shape.volume() + def _generate_cad(self, **_: KwargsGenerateType) -> CadShape: - """Generate a strut-based lattice CAD shape using the given parameters.""" + """Build the CAD lattice from the current configuration.""" list_phases: list[Phase] = [] list_periodic_phases: list[Phase] = [] @@ -240,22 +387,18 @@ def _generate_cad(self, **_: KwargsGenerateType) -> CadShape: center=tuple(self.strut_centers[i]), orientation=self.strut_rotations[i], height=self.strut_heights[i], - radius=self.strut_radius, + radius=self._strut_radius, ) - shape = strut.generate_cad() - list_phases.append(Phase(shape)) - if self.strut_joints: + list_phases.append(Phase(strut.generate_cad())) + if self._strut_joints: for vertex in self.vertices: - joint = Sphere( - center=tuple(vertex), - radius=self.strut_radius, - ) - shape = joint.generate_cad() - list_phases.append(Phase(shape)) + joint = Sphere(center=tuple(vertex), radius=self._strut_radius) + list_phases.append(Phase(joint.generate_cad())) for phase in list_phases: - periodic_phase = periodic_split_and_translate(phase=phase, rve=self.rve) - list_periodic_phases.append(periodic_phase) + list_periodic_phases.append( + periodic_split_and_translate(phase=phase, rve=self.rve), + ) lattice = fuse_shapes( [phase.shape for phase in list_periodic_phases], @@ -264,18 +407,10 @@ def _generate_cad(self, **_: KwargsGenerateType) -> CadShape: bounding_box = Box( center=self.center, - dim=(self.cell_size, self.cell_size, self.cell_size), + dim=(self._cell_size, self._cell_size, self._cell_size), ).generate_cad() - cut_lattice = bounding_box.intersect(lattice) - - return cut_lattice - - @property - def volume(self) -> float: - volume = self.cad_shape.volume() - - return volume + return bounding_box.intersect(lattice) def generate_surface_mesh( self, @@ -283,15 +418,10 @@ def generate_surface_mesh( ) -> pv.PolyData: """Return a surface mesh of the lattice (for visualisation). - Today this delegates to :meth:`mesh_for_fem` with default parameters - (``size=0.02, order=1, periodic=True``), which runs CAD → STEP → - gmsh → pyvista. When the F-rep implicit-lattice work lands, this - method will switch to F-rep marching cubes (no CAD/gmsh required) - and :meth:`mesh_for_fem` will remain as the explicit FEM-meshing - path. - - Users who need to control mesh size / element order / periodicity - should call :meth:`mesh_for_fem` directly. + Delegates to :meth:`mesh_for_fem` with default parameters + (``size=0.02, order=1, periodic=True``). Callers who need to + control mesh size, element order, or periodicity should call + :meth:`mesh_for_fem` directly. """ return self.mesh_for_fem() @@ -304,19 +434,12 @@ def mesh_for_fem( *, periodic: bool = True, ) -> pv.PolyData: - """Build a periodic / non-periodic FEM tet mesh and return its surface. - - Path: ``cad_shape`` → STEP → gmsh (:func:`microgen.mesh_periodic` or - :func:`microgen.mesh`) → ``pv.read`` → :meth:`extract_surface`. - Requires the ``[cad]`` extra and gmsh. - - Cached per ``(size, order, periodic)`` tuple on the instance, so - repeated calls with the same parameters are O(1). + """Generate (or return cached) the lattice FEM tet-mesh surface. - :param size: target element size (gmsh) - :param order: element order (gmsh) - :param periodic: enforce periodicity via :func:`mesh_periodic` - :return: surface ``pv.PolyData`` extracted from the tet mesh + Path: ``cad_shape`` -> STEP -> gmsh (:func:`microgen.mesh_periodic` + or :func:`microgen.mesh`) -> ``pv.read`` -> :meth:`extract_surface`. + Requires the ``[cad]`` extra and gmsh. Cached per + ``(size, order, periodic)`` tuple on the instance. """ params = (size, order, periodic) if self._vtk_shape is not None: @@ -348,8 +471,8 @@ def mesh_for_fem( vtk_lattice = pv.read(mesh_file.name).extract_surface(algorithm=None) # Solve compatibility issues of NamedTemporaryFiles with Windows. - for tmp in (cad_step_file.name, mesh_file.name): - Path(tmp).unlink() + for file in (cad_step_file.name, mesh_file.name): + Path(file).unlink() self._vtk_shape = (params, vtk_lattice) return vtk_lattice diff --git a/microgen/shape/strut_lattice/body_centered_cubic.py b/microgen/shape/strut_lattice/body_centered_cubic.py index 9fa87119..3f306959 100644 --- a/microgen/shape/strut_lattice/body_centered_cubic.py +++ b/microgen/shape/strut_lattice/body_centered_cubic.py @@ -23,7 +23,7 @@ class BodyCenteredCubic(AbstractLattice): import microgen - shape = microgen.BodyCenteredCubic(strut_radius=0.1).generate_surface_mesh() + shape = microgen.BodyCenteredCubic(0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/cubic.py b/microgen/shape/strut_lattice/cubic.py index af98a2f9..72a0f513 100644 --- a/microgen/shape/strut_lattice/cubic.py +++ b/microgen/shape/strut_lattice/cubic.py @@ -24,7 +24,7 @@ class Cubic(AbstractLattice): import microgen - shape = microgen.Cubic(strut_radius=0.1).generate_surface_mesh() + shape = microgen.Cubic(0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/cuboctahedron.py b/microgen/shape/strut_lattice/cuboctahedron.py index 62d23dc2..de7b0bb3 100644 --- a/microgen/shape/strut_lattice/cuboctahedron.py +++ b/microgen/shape/strut_lattice/cuboctahedron.py @@ -24,7 +24,7 @@ class Cuboctahedron(AbstractLattice): import microgen - shape = microgen.Cuboctahedron(strut_radius=0.1).generate_surface_mesh() + shape = microgen.Cuboctahedron(0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/custom_lattice.py b/microgen/shape/strut_lattice/custom_lattice.py index c4f3b666..64b62c57 100644 --- a/microgen/shape/strut_lattice/custom_lattice.py +++ b/microgen/shape/strut_lattice/custom_lattice.py @@ -13,37 +13,36 @@ if TYPE_CHECKING: import numpy as np import numpy.typing as npt + from scipy.spatial.transform import Rotation + + from microgen.shape import Vector3DType class CustomLattice(AbstractLattice): - """Class to create a custom lattice. + """Strut-based lattice from user-defined vertices and connectivity. - Uses user-defined base vertices and strut vertex pairs. + ``base_vertices`` and ``strut_vertex_pairs`` are required positional. + Configure the rest via chained ``with_*`` setters before calling + :meth:`generate_cad` or :meth:`generate_surface_mesh`. """ - def __init__( # noqa: PLR0913 + def __init__( self, base_vertices: npt.NDArray[np.float64], strut_vertex_pairs: npt.NDArray[np.int64], - strut_radius: float | None = None, - strut_heights: float | list[float] | None = None, - cell_size: float = 1.0, - strut_joints: bool = False, - density: float | None = None, + *, + center: Vector3DType = (0, 0, 0), + orientation: Vector3DType | Rotation = (0, 0, 0), ) -> None: - """Initialize the custom lattice.""" - super().__init__( - base_vertices=base_vertices, - strut_vertex_pairs=strut_vertex_pairs, - strut_radius=strut_radius, - strut_heights=strut_heights, - cell_size=cell_size, - strut_joints=strut_joints, - density=density, - ) + """Initialize with the user-defined vertex layout and connectivity.""" + super().__init__(center=center, orientation=orientation) + self._user_base_vertices = base_vertices + self._user_strut_vertex_pairs = strut_vertex_pairs def _generate_base_vertices(self) -> npt.NDArray[np.float64]: - return self.base_vertices + # The base class accessor returns _user_base_vertices when set, so + # this hook should never run for CustomLattice. + return self._user_base_vertices def _generate_strut_vertex_pairs(self) -> npt.NDArray[np.int64]: - return self.strut_vertex_pairs + return self._user_strut_vertex_pairs diff --git a/microgen/shape/strut_lattice/diamond.py b/microgen/shape/strut_lattice/diamond.py index 4f3b447f..2cdd5409 100644 --- a/microgen/shape/strut_lattice/diamond.py +++ b/microgen/shape/strut_lattice/diamond.py @@ -25,7 +25,7 @@ class Diamond(AbstractLattice): import microgen - shape = microgen.Diamond(strut_radius=0.1).generate_surface_mesh() + shape = microgen.Diamond(0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/face_centered_cubic.py b/microgen/shape/strut_lattice/face_centered_cubic.py index 2efd6821..eb459167 100644 --- a/microgen/shape/strut_lattice/face_centered_cubic.py +++ b/microgen/shape/strut_lattice/face_centered_cubic.py @@ -23,7 +23,7 @@ class FaceCenteredCubic(AbstractLattice): import microgen - shape = microgen.FaceCenteredCubic(strut_radius=0.1).generate_surface_mesh() + shape = microgen.FaceCenteredCubic(0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/octahedron.py b/microgen/shape/strut_lattice/octahedron.py index 8e0e8a5a..eaf520ae 100644 --- a/microgen/shape/strut_lattice/octahedron.py +++ b/microgen/shape/strut_lattice/octahedron.py @@ -22,7 +22,7 @@ class Octahedron(AbstractLattice): import microgen - shape = microgen.Octahedron(strut_radius=0.1).generate_surface_mesh() + shape = microgen.Octahedron(0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/octet_truss.py b/microgen/shape/strut_lattice/octet_truss.py index af47c8e4..a714d394 100644 --- a/microgen/shape/strut_lattice/octet_truss.py +++ b/microgen/shape/strut_lattice/octet_truss.py @@ -24,7 +24,7 @@ class OctetTruss(AbstractLattice): import microgen - shape = microgen.OctetTruss(strut_radius=0.1).generate_surface_mesh() + shape = microgen.OctetTruss(0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/rhombic_cuboctahedron.py b/microgen/shape/strut_lattice/rhombic_cuboctahedron.py index 009f5853..c4c60c19 100644 --- a/microgen/shape/strut_lattice/rhombic_cuboctahedron.py +++ b/microgen/shape/strut_lattice/rhombic_cuboctahedron.py @@ -24,7 +24,7 @@ class RhombicCuboctahedron(AbstractLattice): import microgen - shape = microgen.RhombicCuboctahedron(strut_radius=0.1).generate_surface_mesh() + shape = microgen.RhombicCuboctahedron(0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/rhombic_dodecahedron.py b/microgen/shape/strut_lattice/rhombic_dodecahedron.py index f4e742a8..480d3b1e 100644 --- a/microgen/shape/strut_lattice/rhombic_dodecahedron.py +++ b/microgen/shape/strut_lattice/rhombic_dodecahedron.py @@ -24,7 +24,7 @@ class RhombicDodecahedron(AbstractLattice): import microgen - shape = microgen.RhombicDodecahedron(strut_radius=0.1).generate_surface_mesh() + shape = microgen.RhombicDodecahedron(0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/truncated_cube.py b/microgen/shape/strut_lattice/truncated_cube.py index 5b9a51ad..a4fede50 100644 --- a/microgen/shape/strut_lattice/truncated_cube.py +++ b/microgen/shape/strut_lattice/truncated_cube.py @@ -24,7 +24,7 @@ class TruncatedCube(AbstractLattice): import microgen - shape = microgen.TruncatedCube(strut_radius=0.1).generate_surface_mesh() + shape = microgen.TruncatedCube(0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/truncated_cuboctahedron.py b/microgen/shape/strut_lattice/truncated_cuboctahedron.py index 175c1871..c3b1703c 100644 --- a/microgen/shape/strut_lattice/truncated_cuboctahedron.py +++ b/microgen/shape/strut_lattice/truncated_cuboctahedron.py @@ -24,7 +24,7 @@ class TruncatedCuboctahedron(AbstractLattice): import microgen - shape = microgen.TruncatedCuboctahedron(strut_radius=0.1).generate_surface_mesh() + shape = microgen.TruncatedCuboctahedron(0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/truncated_octahedron.py b/microgen/shape/strut_lattice/truncated_octahedron.py index 83f7e31e..c906f3cc 100644 --- a/microgen/shape/strut_lattice/truncated_octahedron.py +++ b/microgen/shape/strut_lattice/truncated_octahedron.py @@ -24,7 +24,7 @@ class TruncatedOctahedron(AbstractLattice): import microgen - shape = microgen.TruncatedOctahedron(strut_radius=0.1).generate_surface_mesh() + shape = microgen.TruncatedOctahedron(0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/surface_functions.py b/microgen/shape/surface_functions.py index f7e48b5c..fe4b1dcd 100644 --- a/microgen/shape/surface_functions.py +++ b/microgen/shape/surface_functions.py @@ -15,10 +15,10 @@ def gyroid(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: import microgen - geometry = microgen.Tpms( - surface_function=microgen.surface_functions.gyroid, - offset=0.3, - resolution=30, + geometry = ( + microgen.Tpms(surface_function=microgen.surface_functions.gyroid) + .with_offset(0.3) + .with_resolution(30) ) shape = geometry.sheet @@ -38,10 +38,10 @@ def schwarz_p(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: import microgen - geometry = microgen.Tpms( - surface_function=microgen.surface_functions.schwarz_p, - offset=0.3, - resolution=30, + geometry = ( + microgen.Tpms(surface_function=microgen.surface_functions.schwarz_p) + .with_offset(0.3) + .with_resolution(30) ) shape = geometry.sheet @@ -62,10 +62,10 @@ def schwarz_d(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: import microgen - geometry = microgen.Tpms( - surface_function=microgen.surface_functions.schwarz_d, - offset=0.3, - resolution=30, + geometry = ( + microgen.Tpms(surface_function=microgen.surface_functions.schwarz_d) + .with_offset(0.3) + .with_resolution(30) ) shape = geometry.sheet @@ -90,10 +90,10 @@ def neovius(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: import microgen - geometry = microgen.Tpms( - surface_function=microgen.surface_functions.neovius, - offset=0.3, - resolution=30, + geometry = ( + microgen.Tpms(surface_function=microgen.surface_functions.neovius) + .with_offset(0.3) + .with_resolution(30) ) shape = geometry.sheet @@ -114,10 +114,10 @@ def schoen_iwp(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: import microgen - geometry = microgen.Tpms( - surface_function=microgen.surface_functions.schoen_iwp, - offset=0.3, - resolution=30, + geometry = ( + microgen.Tpms(surface_function=microgen.surface_functions.schoen_iwp) + .with_offset(0.3) + .with_resolution(30) ) shape = geometry.sheet @@ -140,10 +140,10 @@ def schoen_frd(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: import microgen - geometry = microgen.Tpms( - surface_function=microgen.surface_functions.schoen_frd, - offset=0.3, - resolution=30, + geometry = ( + microgen.Tpms(surface_function=microgen.surface_functions.schoen_frd) + .with_offset(0.3) + .with_resolution(30) ) shape = geometry.sheet @@ -165,10 +165,10 @@ def fischer_koch_s(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: import microgen - geometry = microgen.Tpms( - surface_function=microgen.surface_functions.fischer_koch_s, - offset=0.3, - resolution=30, + geometry = ( + microgen.Tpms(surface_function=microgen.surface_functions.fischer_koch_s) + .with_offset(0.3) + .with_resolution(30) ) shape = geometry.sheet @@ -192,10 +192,10 @@ def pmy(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: import microgen - geometry = microgen.Tpms( - surface_function=microgen.surface_functions.pmy, - offset=0.3, - resolution=30, + geometry = ( + microgen.Tpms(surface_function=microgen.surface_functions.pmy) + .with_offset(0.3) + .with_resolution(30) ) shape = geometry.sheet @@ -220,10 +220,10 @@ def honeycomb(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: import microgen - geometry = microgen.Tpms( - surface_function=microgen.surface_functions.honeycomb, - offset=0.3, - resolution=30, + geometry = ( + microgen.Tpms(surface_function=microgen.surface_functions.honeycomb) + .with_offset(0.3) + .with_resolution(30) ) shape = geometry.sheet @@ -248,10 +248,10 @@ def lidinoid(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: import microgen - geometry = microgen.Tpms( - surface_function=microgen.surface_functions.lidinoid, - offset=0.3, - resolution=30, + geometry = ( + microgen.Tpms(surface_function=microgen.surface_functions.lidinoid) + .with_offset(0.3) + .with_resolution(30) ) shape = geometry.sheet @@ -287,10 +287,10 @@ def split_p(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: import microgen - geometry = microgen.Tpms( - surface_function=microgen.surface_functions.split_p, - offset=0.3, - resolution=30, + geometry = ( + microgen.Tpms(surface_function=microgen.surface_functions.split_p) + .with_offset(0.3) + .with_resolution(30) ) shape = geometry.sheet @@ -320,10 +320,10 @@ def honeycomb_gyroid(x: float, y: float, _: float) -> float: import microgen - geometry = microgen.Tpms( - surface_function=microgen.surface_functions.honeycomb_gyroid, - offset=0.3, - resolution=30, + geometry = ( + microgen.Tpms(surface_function=microgen.surface_functions.honeycomb_gyroid) + .with_offset(0.3) + .with_resolution(30) ) shape = geometry.sheet @@ -343,10 +343,10 @@ def honeycomb_schwarz_p(x: float, y: float, _: float) -> float: import microgen - geometry = microgen.Tpms( - surface_function=microgen.surface_functions.honeycomb_schwarz_p, - offset=0.3, - resolution=30, + geometry = ( + microgen.Tpms(surface_function=microgen.surface_functions.honeycomb_schwarz_p) + .with_offset(0.3) + .with_resolution(30) ) shape = geometry.sheet @@ -366,10 +366,10 @@ def honeycomb_schwarz_d(x: float, y: float, _: float) -> float: import microgen - geometry = microgen.Tpms( - surface_function=microgen.surface_functions.honeycomb_schwarz_d, - offset=0.3, - resolution=30, + geometry = ( + microgen.Tpms(surface_function=microgen.surface_functions.honeycomb_schwarz_d) + .with_offset(0.3) + .with_resolution(30) ) shape = geometry.sheet @@ -389,10 +389,10 @@ def honeycomb_schoen_iwp(x: float, y: float, _: float) -> float: import microgen - geometry = microgen.Tpms( - surface_function=microgen.surface_functions.honeycomb_schoen_iwp, - offset=0.3, - resolution=30, + geometry = ( + microgen.Tpms(surface_function=microgen.surface_functions.honeycomb_schoen_iwp) + .with_offset(0.3) + .with_resolution(30) ) shape = geometry.sheet @@ -413,10 +413,10 @@ def honeycomb_lidinoid(x: float, y: float, _: float) -> float: import microgen - geometry = microgen.Tpms( - surface_function=microgen.surface_functions.honeycomb_lidinoid, - offset=0.3, - resolution=30, + geometry = ( + microgen.Tpms(surface_function=microgen.surface_functions.honeycomb_lidinoid) + .with_offset(0.3) + .with_resolution(30) ) shape = geometry.sheet diff --git a/microgen/shape/tpms.py b/microgen/shape/tpms.py index 66a37723..acfa6b1f 100644 --- a/microgen/shape/tpms.py +++ b/microgen/shape/tpms.py @@ -120,99 +120,135 @@ class Tpms(Shape): def __init__( self: Tpms, surface_function: Field, - offset: float | OffsetGrading | Field | None = None, - phase_shift: Sequence[float] = (0.0, 0.0, 0.0), - cell_size: float | Sequence[float] = 1.0, - repeat_cell: int | Sequence[int] = 1, - resolution: int = 20, - density: float | None = None, - **kwargs: Vector3DType, + *, + center: Vector3DType = (0, 0, 0), + orientation: Vector3DType = (0, 0, 0), ) -> None: - r""" - Class used to generate TPMS geometries (sheet or skeletals parts). - - TPMS are created by default in a cube. - The geometry of the cube can be modified using 'cell_size' parameter. - The number of repetitions in each direction of the created geometry \ - can be modified with the 'repeat_cell' parameter. - - :param surface_function: tpms function or custom function (f(x, y, z) = 0) - :param offset: offset of the isosurface to generate thickness - :param phase_shift: phase shift of the isosurface \ - $f(x + \\phi_x, y + \\phi_y, z + \\phi_z, t) = 0$ - :param cell_size: float or list of float for each dimension to set unit\ - cell dimensions - :param repeat_cell: integer or list of integers to repeat the geometry\ - in each dimension - :param resolution: unit cell resolution of the grid to compute tpms\ - scalar fields - :param density: density percentage of the generated geometry (0 < density < 1) \ - If density is given, the offset is automatically computed to fit the\ - density (performance is slower than when using the offset) - """ - if offset is not None and density is not None: - err_msg = ( - "offset and density cannot be given at the same time. Give only one." + r"""Initialize with the TPMS implicit function and default config. + + ``surface_function`` is the only positional argument. All other + parameters (offset / density, cell size, repeat cell, resolution, + phase shift) are configured via chained ``with_*`` setters before + calling :meth:`generate_surface_mesh` / :meth:`generate_cad`. + + Example:: + + shape = ( + Tpms(surface_function=gyroid) + .with_offset(0.3) + .with_cell_size(1.0) + .with_resolution(30) + .generate_surface_mesh(type_part="sheet") ) - raise ValueError(err_msg) - if offset is None and density is None: - err_msg = "offset or density must be given. Give one of them." - raise ValueError(err_msg) - super().__init__(**kwargs) + :param surface_function: tpms function or custom ``f(x, y, z) -> array`` + :param center: shape center (passed to :class:`Shape`) + :param orientation: shape orientation (passed to :class:`Shape`) + """ + super().__init__(center=center, orientation=orientation) self.surface_function = surface_function - self._offset = offset if offset is not None else 0.0 + self._offset: float | npt.NDArray[np.float64] | None = 0.0 # Stores the offset callable when one is provided, so the F-rep path # can re-evaluate variable thickness on its own marching-cubes grid. - self._offset_func: Field | None = ( - offset - if ( - offset is not None - and callable(offset) - and not isinstance(offset, OffsetGrading) - ) - else None - ) - self.phase_shift = phase_shift + self._offset_func: Field | None = None + self.phase_shift: Sequence[float] = (0.0, 0.0, 0.0) + self.density: float | None = None self.grid: pv.StructuredGrid self._grid_sheet: pv.UnstructuredGrid = None self._grid_upper_skeletal: pv.UnstructuredGrid = None self._grid_lower_skeletal: pv.UnstructuredGrid = None self._surface: pv.PolyData = None + self.offset_updated: bool = True - self._init_cell_parameters(cell_size, repeat_cell) + self._init_cell_parameters(1.0, 1) + self.resolution = 20 - self.resolution = resolution + self._build_grid() + # ------------------------------------------------------------------ + # Internal grid rebuild (called from __init__ and grid-affecting setters) + # ------------------------------------------------------------------ + + def _build_grid(self: Tpms) -> None: + """Compute the TPMS field + F-rep, refresh offset limits, reapply offset.""" self._compute_tpms_field() self._setup_frep_field() - - min_field = np.min(self.grid["surface"]) - max_field = np.max(self.grid["surface"]) + min_field = float(np.min(self.grid["surface"])) + max_field = float(np.max(self.grid["surface"])) self.offset_lim = { - "sheet": ( - 0.0, - 2.0 * max(-min_field, max_field), - ), - "skeletal": ( - 2.0 * min_field, - 2.0 * max_field, - ), + "sheet": (0.0, 2.0 * max(-min_field, max_field)), + "skeletal": (2.0 * min_field, 2.0 * max_field), } + if self._offset_func is not None: + # Re-sample the callable on the (possibly new) grid. + self.offset = self._offset_func + elif self._offset is not None and not isinstance(self._offset, np.ndarray): + # Scalar offset: reapply. + self.offset = self._offset + self._invalidate_part_caches() + + def _invalidate_part_caches(self: Tpms) -> None: + self._grid_sheet = None + self._grid_upper_skeletal = None + self._grid_lower_skeletal = None + self._surface = None + self.offset_updated = True + + # ------------------------------------------------------------------ + # Chained setters + # ------------------------------------------------------------------ + + def with_offset( + self: Tpms, + offset: float | npt.NDArray[np.float64] | OffsetGrading | Field, + ) -> Tpms: + """Set the isosurface offset (controls TPMS thickness). + + Clears any previously set density (last-set wins). + """ + self.density = None + self.offset = offset # uses the property setter to update grid arrays + return self - if density is not None and not 0.0 < density <= 1.0: + def with_phase_shift(self: Tpms, phase_shift: Sequence[float]) -> Tpms: + r"""Set the phase shift :math:`(\phi_x, \phi_y, \phi_z)` of the field.""" + self.phase_shift = phase_shift + self._build_grid() + return self + + def with_cell_size(self: Tpms, cell_size: float | Sequence[float]) -> Tpms: + """Set the unit-cell dimensions (scalar applies to all 3 axes).""" + self._init_cell_parameters(cell_size, self.repeat_cell) + self._build_grid() + return self + + def with_repeat_cell(self: Tpms, repeat_cell: int | Sequence[int]) -> Tpms: + """Set the number of cell repetitions along each axis.""" + self._init_cell_parameters(self.cell_size, repeat_cell) + self._build_grid() + return self + + def with_resolution(self: Tpms, resolution: int) -> Tpms: + """Set the grid resolution per unit cell used to evaluate the TPMS field.""" + self.resolution = resolution + self._build_grid() + return self + + def with_density(self: Tpms, density: float) -> Tpms: + """Set the target density (0, 1]. + + Clears any previously set offset; the offset that yields this density + is computed lazily on terminal generation calls. + """ + if not 0.0 < density <= 1.0: err_msg = f"density must be between 0 and 1. Given: {density}" raise ValueError(err_msg) self.density = density - - self._offset: float | npt.NDArray | None - if density is not None: - self._offset = None - else: - self.offset = offset # call setter - self.offset_updated: bool + self._offset = None + self._invalidate_part_caches() + return self @classmethod def offset_from_density( @@ -232,10 +268,14 @@ def offset_from_density( :return: corresponding offset value """ - return Tpms( - surface_function=surface_function, - density=density, - )._compute_offset_to_fit_density(part_type=part_type, resolution=resolution) + return ( + Tpms(surface_function=surface_function) + .with_density(density) + ._compute_offset_to_fit_density( + part_type=part_type, + resolution=resolution, + ) + ) def _density_envelope_volume(self: Tpms) -> float: """ @@ -1189,49 +1229,45 @@ def __init__( self: CylindricalTpms, radius: float, surface_function: Field, - offset: float | OffsetGrading | Field | None = None, - phase_shift: Sequence[float] = (0.0, 0.0, 0.0), - cell_size: float | Sequence[float] = 1.0, - repeat_cell: int | Sequence[int] = 1, + *, center: Vector3DType = (0, 0, 0), orientation: Vector3DType = (0, 0, 0), - resolution: int = 20, - density: float | None = None, ) -> None: - r""" - Cylindrical TPMS geometry. - - Directions of cell_size and repeat_cell must be taken as the cylindrical \ - coordinate system $\\left(\\rho, \\theta, z\\right)$. - - The $\\theta$ component of cell_size is automatically updated to the \ - closest value that matches the cylindrical periodicity of the TPMS. - If the $\\theta$ component of repeat_cell is 0 or greater than the \ - periodicity of the TPMS, it is automatically set the correct number \ - to make the full cylinder. - - :param radius: radius of the cylinder on which the center of the TPMS is located - :param surface_function: tpms function or custom function (f(x, y, z) = 0) - :param offset: offset of the isosurface to generate thickness - :param phase_shift: phase shift of the tpms function \ - $f(x + \\phi_x, y + \\phi_y, z + \\phi_z) = 0$ - :param cell_size: float or list of float for each dimension to\ - set unit cell dimensions - :param repeat_cell: integer or list of integers to repeat the\ - geometry in each dimension - :param center: center of the geometry - :param orientation: orientation of the geometry - :param resolution: unit cell resolution of the grid to compute\ - tpms scalar fields + r"""Cylindrical TPMS geometry. + + ``radius`` and ``surface_function`` are required positional. Configure + cell size / repeat cell / resolution / offset / density via chained + ``with_*`` setters. + + Directions of ``cell_size`` and ``repeat_cell`` must be taken as the + cylindrical coordinate system :math:`(\rho, \theta, z)`. The + :math:`\theta` component of ``cell_size`` is auto-snapped to the + closest value matching cylindrical periodicity; if ``repeat_cell[1]`` + is 0 or exceeds the periodicity, it is auto-set to wrap the full circle. + + :param radius: radius on which the TPMS center is located + :param surface_function: tpms function or custom ``f(x, y, z) -> array`` + :param center: shape center + :param orientation: shape orientation """ - self._init_cell_parameters(cell_size, repeat_cell) - self.cylinder_radius = radius + super().__init__( + surface_function, + center=center, + orientation=orientation, + ) - unit_theta = self.cell_size[1] / radius + def _init_cell_parameters( + self: CylindricalTpms, + cell_size: float | Sequence[float], + repeat_cell: int | Sequence[int], + ) -> None: + """Initialize cell parameters then auto-snap theta direction to a full circle.""" + Tpms._init_cell_parameters(self, cell_size, repeat_cell) + unit_theta = self.cell_size[1] / self.cylinder_radius n_repeat_to_full_circle = int(2 * np.pi / unit_theta) self.unit_theta = 2 * np.pi / n_repeat_to_full_circle - self.cell_size[1] = self.unit_theta * radius + self.cell_size[1] = self.unit_theta * self.cylinder_radius if self.repeat_cell[1] == 0 or self.repeat_cell[1] > n_repeat_to_full_circle: logging.info( "%d cells repeated in circular direction", @@ -1239,18 +1275,6 @@ def __init__( ) self.repeat_cell[1] = n_repeat_to_full_circle - super().__init__( - surface_function=surface_function, - offset=offset, - phase_shift=phase_shift, - cell_size=self.cell_size, - repeat_cell=self.repeat_cell, - resolution=resolution, - density=density, - center=center, - orientation=orientation, - ) - def _create_grid( self: CylindricalTpms, x: npt.NDArray[np.float64], @@ -1407,74 +1431,59 @@ def __init__( self: SphericalTpms, radius: float, surface_function: Field, - offset: float | OffsetGrading | Field | None = None, - phase_shift: Sequence[float] = (0.0, 0.0, 0.0), - cell_size: float | Sequence[float] = 1.0, - repeat_cell: int | Sequence[int] = 1, + *, center: Vector3DType = (0, 0, 0), orientation: Vector3DType = (0, 0, 0), - resolution: int = 20, - density: float | None = None, ) -> None: - r""" - Spherical TPMS geometry. - - Directions of cell_size and repeat_cell must be taken as the spherical \ - coordinate system $\\left(r, \\theta, \\phi\\right)$. - - The $\\theta$ and $\\phi$ components of cell_size are automatically \ - updated to the closest values that matches the spherical periodicity\ - of the TPMS. - If the $\\theta$ or $\\phi$ components of repeat_cell are 0 or greater \ - than the periodicity of the TPMS, they are automatically set the correct \ - number to make the full sphere. - - :param radius: radius of the sphere on which the center of the TPMS is located - :param surface_function: tpms function or custom function (f(x, y, z) = 0) - :param offset: offset of the isosurface to generate thickness - :param phase_shift: phase shift of the tpms function \ - $f(x + \\phi_x, y + \\phi_y, z + \\phi_z) = 0$ - :param cell_size: float or list of float for each dimension\ - to set unit cell dimensions - :param repeat_cell: integer or list of integers to repeat the\ - geometry in each dimension - :param center: center of the geometry - :param orientation: orientation of the geometry - :param resolution: unit cell resolution of the grid to compute\ - tpms scalar fields + r"""Spherical TPMS geometry. + + ``radius`` and ``surface_function`` are required positional. Configure + cell size / repeat cell / resolution / offset / density via chained + ``with_*`` setters. + + Directions of ``cell_size`` and ``repeat_cell`` map to the spherical + coordinate system :math:`(r, \theta, \phi)`. The :math:`\theta` and + :math:`\phi` components of ``cell_size`` are auto-snapped to the + closest values matching spherical periodicity; if the corresponding + ``repeat_cell`` entries are 0 or exceed the periodicity, they are + auto-set to wrap the full sphere. + + :param radius: radius on which the TPMS center is located + :param surface_function: tpms function or custom ``f(x, y, z) -> array`` + :param center: shape center + :param orientation: shape orientation """ - self._init_cell_parameters(cell_size, repeat_cell) - self.sphere_radius = radius + super().__init__( + surface_function, + center=center, + orientation=orientation, + ) - unit_theta = self.cell_size[1] / radius + def _init_cell_parameters( + self: SphericalTpms, + cell_size: float | Sequence[float], + repeat_cell: int | Sequence[int], + ) -> None: + """Initialize cell parameters then auto-snap theta/phi to full sphere.""" + Tpms._init_cell_parameters(self, cell_size, repeat_cell) + + unit_theta = self.cell_size[1] / self.sphere_radius n_repeat_theta_to_join = int(np.pi / unit_theta) self.unit_theta = np.pi / n_repeat_theta_to_join - self.cell_size[1] = self.unit_theta * radius # true only on theta = pi/2 + self.cell_size[1] = self.unit_theta * self.sphere_radius if self.repeat_cell[1] == 0 or self.repeat_cell[1] > n_repeat_theta_to_join: logging.info("%d cells repeated in theta direction", n_repeat_theta_to_join) self.repeat_cell[1] = n_repeat_theta_to_join - unit_phi = self.cell_size[2] / radius + unit_phi = self.cell_size[2] / self.sphere_radius n_repeat_phi_to_join = int(2 * np.pi / unit_phi) self.unit_phi = 2 * np.pi / n_repeat_phi_to_join - self.cell_size[2] = self.unit_phi * radius + self.cell_size[2] = self.unit_phi * self.sphere_radius if self.repeat_cell[2] == 0 or self.repeat_cell[2] > n_repeat_phi_to_join: logging.info("%d cells repeated in phi direction", n_repeat_phi_to_join) self.repeat_cell[2] = n_repeat_phi_to_join - super().__init__( - surface_function=surface_function, - offset=offset, - phase_shift=phase_shift, - cell_size=self.cell_size, - repeat_cell=self.repeat_cell, - resolution=resolution, - density=density, - center=center, - orientation=orientation, - ) - def _create_grid( self: SphericalTpms, x: npt.NDArray[np.float64], @@ -1655,81 +1664,81 @@ class Sweep(Tpms): _envelope_mesh_at_full_density = Tpms._envelope_mesh_via_cell_box + _DEFAULT_N_CURVE_SAMPLES = 200 + def __init__( self: Sweep, curve_points: npt.NDArray[np.float64] | Callable[[float], npt.NDArray[np.float64]], surface_function: Field, radial_max: float, - offset: float | OffsetGrading | Field | None = None, - cell_size: float | Sequence[float] | npt.NDArray[np.float64] = 1.0, - repeat_cell: int | Sequence[int] | npt.NDArray[np.int8] = 1, - phase_shift: Sequence[float] = (0.0, 0.0, 0.0), - resolution: int = 20, - density: float | None = None, - seed_normal: Sequence[float] | None = None, - n_curve_samples: int = 200, + *, center: Vector3DType = (0, 0, 0), orientation: Vector3DType = (0, 0, 0), ) -> None: - r""" - Build a TPMS swept along a curve. + r"""Build a TPMS swept along a curve. + + ``curve_points``, ``surface_function`` and ``radial_max`` are required + positional. Configure cell size / repeat cell / resolution / offset / + density / phase shift / seed normal / curve sample count via chained + ``with_*`` setters. :param curve_points: either an ``(M, 3)`` array of polyline samples - or a callable ``t \in [0, 1] -> (3,)``. Callables are sampled - at ``n_curve_samples`` points before processing. + or a callable ``t in [0, 1] -> (3,)``. Callables are sampled + at :attr:`_DEFAULT_N_CURVE_SAMPLES` points (override via + :meth:`with_n_curve_samples`). :param surface_function: TPMS function ``f(x, y, z)`` :param radial_max: outer tube radius - :param offset: TPMS sheet thickness - :param cell_size: ``(s, r, θ)`` cell size — third axis is angular - (radians per cell), so a sensible default is to leave it at - ``1.0`` and tune via ``repeat_cell[2]``. - :param repeat_cell: ``(n_s, n_r, n_θ)`` — radial cell count is - usually ``1`` for a thin tube; ``n_θ`` controls how many - angular cells around the curve. - :param phase_shift: TPMS phase shift in (s, r, θ) - :param resolution: per-axis MC grid resolution - :param density: mutex with ``offset``; density relative to the - tube volume - :param seed_normal: initial normal direction for parallel transport; - defaults to a vector perpendicular to the first tangent - :param n_curve_samples: number of samples to use when resampling a - ``Callable`` curve (ignored for polyline input) :param center: center of the geometry :param orientation: orientation of the geometry """ - # Discretise the curve. + self._curve_points_input = curve_points + self._n_curve_samples = self._DEFAULT_N_CURVE_SAMPLES + self._seed_normal: Sequence[float] | None = None + self.radial_max = float(radial_max) + + self._discretise_curve() + # Build the parallel-transport frames + arc-length parametrisation. + self._build_curve_frames(seed_normal=self._seed_normal) + + # Cell parameters now live in (s, r, θ) parametric space. + # ``_setup_frep_field`` uses the local frames. + super().__init__( + surface_function, + center=center, + orientation=orientation, + ) + + def _discretise_curve(self: Sweep) -> None: + """Convert ``self._curve_points_input`` into the polyline ``self.curve``.""" + curve_points = self._curve_points_input if callable(curve_points): - ts = np.linspace(0.0, 1.0, int(n_curve_samples)) + ts = np.linspace(0.0, 1.0, int(self._n_curve_samples)) curve = np.asarray([curve_points(t) for t in ts], dtype=np.float64) else: curve = np.asarray(curve_points, dtype=np.float64) if curve.ndim != 2 or curve.shape[1] != 3 or curve.shape[0] < 2: err_msg = ( - f"curve_points must be an (M, 3) array with M ≥ 2, " + f"curve_points must be an (M, 3) array with M >= 2, " f"got shape {curve.shape}" ) raise ValueError(err_msg) - self.curve = curve - self.radial_max = float(radial_max) - # Build the parallel-transport frames + arc-length parametrisation. - self._build_curve_frames(seed_normal=seed_normal) - - # Initialise like a regular TPMS — cell_size now lives in (s, r, θ) - # parametric space, ``_setup_frep_field`` will use the local frames. - super().__init__( - surface_function=surface_function, - offset=offset, - phase_shift=phase_shift, - cell_size=cell_size, - repeat_cell=repeat_cell, - resolution=resolution, - density=density, - center=center, - orientation=orientation, - ) + def with_seed_normal(self: Sweep, seed_normal: Sequence[float]) -> Sweep: + """Set the initial normal direction for parallel transport along the curve.""" + self._seed_normal = seed_normal + self._build_curve_frames(seed_normal=self._seed_normal) + self._build_grid() + return self + + def with_n_curve_samples(self: Sweep, n_curve_samples: int) -> Sweep: + """Set the number of polyline samples (only used when curve was given as a callable).""" + self._n_curve_samples = int(n_curve_samples) + self._discretise_curve() + self._build_curve_frames(seed_normal=self._seed_normal) + self._build_grid() + return self # -- Curve preprocessing ----------------------------------------------- @@ -2040,32 +2049,22 @@ def __init__( self: Infill, obj: pv.PolyData, surface_function: Field, - offset: float | OffsetGrading | Field | None = None, - cell_size: float | Sequence[float] | npt.NDArray[np.float64] | None = None, - repeat_cell: int | Sequence[int] | npt.NDArray[np.int8] | None = None, - phase_shift: Sequence[float] = (0.0, 0.0, 0.0), - resolution: int = 20, - density: float | None = None, + *, + center: Vector3DType = (0, 0, 0), + orientation: Vector3DType = (0, 0, 0), ) -> None: - r""" - Initialize the Infill object. - - :param obj: object in which the infill is generated. Normals must be oriented\ - towards the outside of the object. Use the `flip_faces` method if\ - needed. - :param surface_function: tpms function or custom function (f(x, y, z) = 0) - :param offset: offset of the isosurface to generate thickness - :param cell_size: float or list of float for each dimension to set\ - unit cell dimensions - :param repeat_cell: integer or list of integers to repeat the geometry\ - in each dimension - :param phase_shift: phase shift of the tpms function \ - $f(x + \\phi_x, y + \\phi_y, z + \\phi_z) = 0$ - :param resolution: unit cell resolution of the grid to compute tpms scalar\ - fields - :param density: density percentage of the generated geometry (0 < density < 1) \ - If density is given, the offset is automatically computed to fit the\ - density (performance is slower than when using the offset) + r"""Initialize the Infill TPMS. + + ``obj`` and ``surface_function`` are required positional. Configure + cell size (or repeat_cell — they auto-derive from each other), offset / + density, resolution, phase shift via chained ``with_*`` setters. + + :param obj: envelope mesh in which the infill is generated. Normals + should point outward; this constructor calls ``compute_normals`` + to auto-orient them. + :param surface_function: tpms function or custom ``f(x, y, z) -> array`` + :param center: shape center + :param orientation: shape orientation """ # Capture the original volume *before* re-orienting normals — for # non-manifold inputs (e.g. pyvista's caps-less Cylinder, the @@ -2080,40 +2079,43 @@ def __init__( point_normals=True, cell_normals=True, ) - bounds = np.array(self.obj.bounds) - - margin_factor = 1.001 # to avoid the object surface that can create issues - obj_dim = margin_factor * (bounds[1::2] - bounds[::2]) # [dim_x, dim_y, dim_z] - - if cell_size is not None and repeat_cell is not None: - err_msg = ( - "cell_size and repeat_cell cannot be given at the same time, " - "one is computed from the other." - ) - raise ValueError(err_msg) - - if cell_size is not None: - repeat_cell = np.round(obj_dim / cell_size).astype(int) - elif repeat_cell is not None: - cell_size = obj_dim / repeat_cell + super().__init__( + surface_function, + center=center, + orientation=orientation, + ) - if np.any(cell_size > obj_dim): + def with_cell_size( + self: Infill, + cell_size: float | Sequence[float], + ) -> Infill: + """Set the unit-cell dimensions; ``repeat_cell`` is derived from the envelope.""" + bounds = np.array(self.obj.bounds) + margin_factor = 1.001 + obj_dim = margin_factor * (bounds[1::2] - bounds[::2]) + if np.any(np.asarray(cell_size) > obj_dim): err_msg = ( "cell_size must be lower than the object dimensions. " f"Given: {cell_size}, Object dimensions: {obj_dim}" ) raise ValueError(err_msg) + repeat_cell = np.round(obj_dim / cell_size).astype(int) + self._init_cell_parameters(cell_size, repeat_cell) + self._build_grid() + return self + def with_repeat_cell( + self: Infill, + repeat_cell: int | Sequence[int], + ) -> Infill: + """Set ``repeat_cell``; ``cell_size`` is derived from the envelope.""" + bounds = np.array(self.obj.bounds) + margin_factor = 1.001 + obj_dim = margin_factor * (bounds[1::2] - bounds[::2]) + cell_size = obj_dim / np.asarray(repeat_cell) self._init_cell_parameters(cell_size, repeat_cell) - super().__init__( - surface_function=surface_function, - offset=offset, - phase_shift=phase_shift, - cell_size=self.cell_size, - repeat_cell=self.repeat_cell, - resolution=resolution, - density=density, - ) + self._build_grid() + return self def _create_grid( self: Infill, @@ -2219,31 +2221,60 @@ def __init__( self: GradedInfill, obj: pv.PolyData, surface_function: Field, + *, + center: Vector3DType = (0, 0, 0), + orientation: Vector3DType = (0, 0, 0), + ) -> None: + """Build a graded TPMS infill. + + ``obj`` and ``surface_function`` are required positional. Configure + the gradation profile via :meth:`with_gradation`, and the cell layout / + resolution / phase shift via the chained ``with_*`` setters inherited + from :class:`Infill` / :class:`Tpms`. + + Defaults: ``offset_skin=0.6``, ``offset_core=0.0``, ``transition=0.5``, + ``smoothness=0.2`` — a graded shell (dense skin, hollow core). + + :param obj: envelope mesh + :param surface_function: TPMS function ``f(x,y,z)`` + :param center: shape center + :param orientation: shape orientation + """ + self._gradation_params: tuple[float, float, float, float] = ( + 0.6, + 0.0, + 0.5, + 0.2, + ) + super().__init__( + obj, + surface_function, + center=center, + orientation=orientation, + ) + # Apply the default gradation profile to the freshly built grid. + self.offset = self._make_graded_offset_callable( + self.obj, + *self._gradation_params, + ) + + def with_gradation( + self: GradedInfill, + *, offset_skin: float = 0.6, offset_core: float = 0.0, transition: float = 0.5, smoothness: float = 0.2, - cell_size: float | Sequence[float] | npt.NDArray[np.float64] | None = None, - repeat_cell: int | Sequence[int] | npt.NDArray[np.int8] | None = None, - phase_shift: Sequence[float] = (0.0, 0.0, 0.0), - resolution: int = 20, - ) -> None: - """ - Build a graded TPMS infill. + ) -> GradedInfill: + """Set the gradation profile and re-apply the graded offset. - :param obj: envelope mesh - :param surface_function: TPMS function ``f(x,y,z)`` :param offset_skin: TPMS thickness at the envelope surface - (``d ≈ 0``). Default ``0.6`` ⇒ thick / dense skin. + (``d ~ 0``). Default ``0.6`` -> thick / dense skin. :param offset_core: TPMS thickness deep in the core (``|d|`` maximal). - Default ``0.0`` ⇒ no material at the centre (graded shell). - :param transition: normalised distance ∈ [0, 1] where the offset + Default ``0.0`` -> no material at the centre (graded shell). + :param transition: normalised distance in [0, 1] where the offset transitions from ``offset_skin`` to ``offset_core`` :param smoothness: width of the tanh transition (smaller = sharper) - :param cell_size: unit cell size; mutex with ``repeat_cell`` - :param repeat_cell: number of cells per axis; mutex with ``cell_size`` - :param phase_shift: TPMS phase shift - :param resolution: per-axis grid resolution """ self._gradation_params = ( float(offset_skin), @@ -2251,19 +2282,11 @@ def __init__( float(transition), float(smoothness), ) - - graded_offset = self._make_graded_offset_callable(obj, *self._gradation_params) - - super().__init__( - obj=obj, - surface_function=surface_function, - offset=graded_offset, - cell_size=cell_size, - repeat_cell=repeat_cell, - phase_shift=phase_shift, - resolution=resolution, - density=None, + self.offset = self._make_graded_offset_callable( + self.obj, + *self._gradation_params, ) + return self @staticmethod def _make_graded_offset_callable( diff --git a/pyproject.toml b/pyproject.toml index 8cf4b097..25d7a3bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,18 @@ ignore_missing_imports = true target-version = "py310" [tool.ruff.lint] +extend-select = [ + # Boolean traps — booleans in public APIs must be keyword-only. The + # fluent ``with_*`` setters express boolean state explicitly, so any + # remaining positional booleans here would be accidents. + "FBT001", + "FBT002", + # Function names must be lowercase (CapCase methods were renamed in #134). + "N802", + # Constructors with many parameters are pushed through chained ``with_*`` + # setters (fluent API) rather than positional arg lists. + "PLR0913", +] exclude = [ "docs/*", "tests/*", diff --git a/tests/shapes/test_lattice.py b/tests/shapes/test_lattice.py index 891fc6e6..6d0b5fdd 100644 --- a/tests/shapes/test_lattice.py +++ b/tests/shapes/test_lattice.py @@ -46,7 +46,7 @@ def test_lattice_vertices_strut_centers_and_directions_must_correspond_to_preset_lattice_data( shape, ) -> None: - lattice = shape(strut_radius=0.05, cell_size=1.0, strut_joints=False) + lattice = shape(0.05).with_cell_size(1.0) assert np.allclose( np.sort(lattice.vertices.flat), @@ -72,7 +72,7 @@ def test_lattice_given_density_and_cell_size_must_match_computed_density( ) -> None: """Test for the density of lattice shapes generated with CadQuery and VTK.""" # Arrange - shape_fit_to_density = shape(density=expected_density, cell_size=cell_size) + shape_fit_to_density = shape(density=expected_density).with_cell_size(cell_size) # Act shape_density = shape_fit_to_density.density @@ -81,10 +81,10 @@ def test_lattice_given_density_and_cell_size_must_match_computed_density( assert np.isclose(expected_density, shape_density, rtol=0.01) -def test_lattice_mesh_for_fem_periodic_must_produce_periodic_mesh() -> None: - """mesh_for_fem(periodic=True) must produce a mesh with periodic node positions.""" - lattice = OctetTruss(strut_radius=0.05, cell_size=1.0) - mesh = lattice.mesh_for_fem(size=0.1, periodic=True) +def test_lattice_generate_vtk_periodic_must_produce_periodic_mesh() -> None: + """generate_vtk(periodic=True) must produce a mesh with periodic node positions.""" + lattice = OctetTruss(0.05).with_cell_size(1.0) + mesh = lattice.generate_surface_mesh(size=0.1, periodic=True) assert is_periodic(mesh.points), ( "Mesh generated with periodic=True must be periodic" @@ -94,8 +94,8 @@ def test_lattice_mesh_for_fem_periodic_must_produce_periodic_mesh() -> None: def test_lattice_mesh_for_fem_must_not_reuse_non_periodic_mesh_for_periodic_request() -> ( None ): - """mesh_for_fem must cache by parameters, not by instance only.""" - lattice = OctetTruss(strut_radius=0.05, cell_size=1.0) + """generate_vtk must cache by parameters, not by instance only.""" + lattice = OctetTruss(0.05).with_cell_size(1.0) non_periodic_mesh = lattice.mesh_for_fem(size=0.1, periodic=False) periodic_mesh = lattice.mesh_for_fem(size=0.1, periodic=True) diff --git a/tests/shapes/test_tpms.py b/tests/shapes/test_tpms.py index af08e02a..682d64cc 100644 --- a/tests/shapes/test_tpms.py +++ b/tests/shapes/test_tpms.py @@ -39,9 +39,8 @@ def test_tpms_given_cadquery_vtk_shapes_volume_must_be_equivalent( ) -> None: """Test for the volume of the TPMS shapes generated with CadQuery and VTK.""" # Arrange - tpms = microgen.Tpms( - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - density=0.3, + tpms = microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION).with_density( + 0.3 ) # Act @@ -59,8 +58,8 @@ def test_tpms_given_cadquery_vtk_zero_offset_skeletals_volume_must_be_equivalent """Test for the volume of the TPMS skeletals generated with CadQuery and VTK.""" # Arrange tpms = microgen.Tpms( - surface_function=microgen.surface_functions.schwarz_p, offset=0 - ) + surface_function=microgen.surface_functions.schwarz_p + ).with_offset(0) # Act shape_cadquery = tpms.generate_cad(type_part=type_part) @@ -74,11 +73,11 @@ def test_tpms_given_non_default_cell_size_and_repeat_cell_must_have_same_volume_ None ): """Test for non-default cell size and repeat cell values.""" - tpms = microgen.Tpms( - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=TEST_DEFAULT_OFFSET, - cell_size=(0.5, 2.0, 1.25), - repeat_cell=(2, 1, 2), + tpms = ( + microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION) + .with_offset(TEST_DEFAULT_OFFSET) + .with_cell_size((0.5, 2.0, 1.25)) + .with_repeat_cell((2, 1, 2)) ) shape_cadquery = tpms.generate_cad(type_part="sheet") @@ -100,11 +99,11 @@ def test_tpms_given_sum_volume_must_be_cube_volume( ) -> None: """Test for the volume of the TPMS shapes generated with CadQuery and VTK.""" # Arrange - tpms = microgen.Tpms( - surface_function=getattr(microgen.surface_functions, surface), - offset=TEST_DEFAULT_OFFSET, - repeat_cell=repeat_cell, - cell_size=cell_size, + tpms = ( + microgen.Tpms(surface_function=getattr(microgen.surface_functions, surface)) + .with_offset(TEST_DEFAULT_OFFSET) + .with_repeat_cell(repeat_cell) + .with_cell_size(cell_size) ) # Act @@ -126,9 +125,8 @@ def test_tpms_given_density_must_match_computed_density( """Test for the density of the TPMS shapes generated with CadQuery and VTK.""" # Arrange tpms = microgen.Tpms( - surface_function=getattr(microgen.surface_functions, surface), - density=density, - ) + surface_function=getattr(microgen.surface_functions, surface) + ).with_density(density) # Act sheet = tpms.generate_surface_mesh(type_part="sheet") @@ -150,9 +148,8 @@ def test_tpms_given_coord_system_tpms_coordinates_field_must_be_in_cartesian_fra tpms = coord_sys_tpms( surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=TEST_DEFAULT_OFFSET, **kwargs, - ) + ).with_offset(TEST_DEFAULT_OFFSET) linspaces: list[npt.NDArray[np.float64]] = [ np.linspace( @@ -184,8 +181,7 @@ def test_tpms_given_coord_system_tpms_volumes_must_be_greater_than_zero_and_lowe tpms = coord_sys_tpms( radius=1.0, surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - density=0.2, - ) + ).with_density(0.2) assert 0 < tpms.sheet.volume < np.abs(tpms.grid.volume) assert 0 < tpms.lower_skeletal.volume < np.abs(tpms.grid.volume) @@ -209,18 +205,22 @@ def test_tpms_given_zero_and_max_repeat_cell_values_volumes_must_correspond( The volume of the sheet, lower skeletal, and upper skeletal must be the same for zero and max repeat cell values and be between 0 and the volume of the grid. """ - tpms_repeat_zero = coord_sys_tpms( - radius=1.0, - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=TEST_DEFAULT_OFFSET, - repeat_cell=repeat_cell_zero, + tpms_repeat_zero = ( + coord_sys_tpms( + radius=1.0, + surface_function=TEST_DEFAULT_SURFACE_FUNCTION, + ) + .with_offset(TEST_DEFAULT_OFFSET) + .with_repeat_cell(repeat_cell_zero) ) - tpms_repeat_max = coord_sys_tpms( - radius=1.0, - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=TEST_DEFAULT_OFFSET, - repeat_cell=repeat_cell_max, + tpms_repeat_max = ( + coord_sys_tpms( + radius=1.0, + surface_function=TEST_DEFAULT_SURFACE_FUNCTION, + ) + .with_offset(TEST_DEFAULT_OFFSET) + .with_repeat_cell(repeat_cell_max) ) assert np.abs(tpms_repeat_zero.grid.volume) == np.abs(tpms_repeat_max.grid.volume) @@ -247,7 +247,7 @@ def test_tpms_given_zero_and_max_repeat_cell_values_volumes_must_correspond( def test_tpms_given_generate_surface_must_not_be_empty() -> None: """Test for the surface of the TPMS shapes generated with CadQuery and VTK.""" - tpms = microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION, offset=0) + tpms = microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION).with_offset(0) surface = tpms.generate_cad(type_part="surface") assert np.any(surface.vertices()) @@ -265,9 +265,8 @@ def variable_offset( ) -> npt.NDArray[np.float64]: return x + 1.5 - tpms = microgen.Tpms( - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=variable_offset, + tpms = microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION).with_offset( + variable_offset ) shape_cadquery = tpms.generate_cad(type_part="sheet", smoothing=0, verbose=True) @@ -290,9 +289,8 @@ def variable_offset( return x + param # x ∈ [-0.5, 0.5] # offset must be in [0, 2 * max(gyroid)] - tpms = microgen.Tpms( - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=variable_offset, + tpms = microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION).with_offset( + variable_offset ) with pytest.raises((ValueError, NotImplementedError)): @@ -301,9 +299,8 @@ def variable_offset( def test_tpms_generate_given_wrong_type_part_parameter_must_raise_error() -> None: """Test for the volume of the TPMS shapes generated with CadQuery and VTK.""" - tpms = microgen.Tpms( - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=TEST_DEFAULT_OFFSET, + tpms = microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION).with_offset( + TEST_DEFAULT_OFFSET ) fake_type_part = "fake" expected_err_msg = re.escape( @@ -325,11 +322,9 @@ def test_tpms_given_wrong_cell_size_parameter_must_raise_error() -> None: f"`cell_size` must have a length of 3 floats. Given: {invalid_cell_size}", ) with pytest.raises(ValueError, match=expected_err_msg): - microgen.Tpms( - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=TEST_DEFAULT_OFFSET, - cell_size=invalid_cell_size, - ) + microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION).with_offset( + TEST_DEFAULT_OFFSET + ).with_cell_size(invalid_cell_size) def test_tpms_given_wrong_repeat_cell_parameter_must_raise_error() -> None: @@ -339,11 +334,9 @@ def test_tpms_given_wrong_repeat_cell_parameter_must_raise_error() -> None: f"`repeat_cell` must have a length of 3 integers. Given: {invalid_repeat_cell}", ) with pytest.raises(ValueError, match=expected_err_msg): - microgen.Tpms( - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=TEST_DEFAULT_OFFSET, - repeat_cell=invalid_repeat_cell, - ) + microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION).with_offset( + TEST_DEFAULT_OFFSET + ).with_repeat_cell(invalid_repeat_cell) def test_tpms_given_wrong_density_parameter_must_raise_error() -> None: @@ -353,9 +346,8 @@ def test_tpms_given_wrong_density_parameter_must_raise_error() -> None: f"density must be between 0 and 1. Given: {invalid_density}", ) with pytest.raises(ValueError, match=expected_err_msg): - microgen.Tpms( - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - density=invalid_density, + microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION).with_density( + invalid_density ) @@ -365,9 +357,8 @@ def test_tpms_given_density_must_generate_tpms_with_correct_volume( ) -> None: """Test for the volume of the TPMS shapes generated with CadQuery and VTK.""" expected_density = 0.2 - tpms = microgen.Tpms( - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - density=expected_density, + tpms = microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION).with_density( + expected_density ) part = tpms.generate_surface_mesh(type_part=type_part) @@ -379,9 +370,8 @@ def test_tpms_given_100_percent_density_must_return_a_cube( type_part: Literal["sheet", "lower skeletal", "upper skeletal"], ) -> None: """Test for the volume of the TPMS shapes generated with CadQuery and VTK.""" - tpms = microgen.Tpms( - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - density=1.0, + tpms = microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION).with_density( + 1.0 ) assert np.isclose( @@ -393,7 +383,7 @@ def test_tpms_given_100_percent_density_must_return_a_cube( def test_tpms_offset_from_density_given_density_must_return_valid_offset() -> None: """Test for the volume of the TPMS shapes generated with CadQuery and VTK.""" - tpms = microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION, offset=0) + tpms = microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION).with_offset(0) offset = microgen.Tpms.offset_from_density( surface_function=TEST_DEFAULT_SURFACE_FUNCTION, @@ -407,9 +397,8 @@ def test_tpms_offset_from_density_given_density_must_return_valid_offset() -> No def test_tpms_given_property_must_return_the_same_value() -> None: """Test for the volume of the TPMS shapes generated with CadQuery and VTK.""" - tpms = microgen.Tpms( - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - density=0.2, + tpms = microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION).with_density( + 0.2 ) skeletals = tpms.skeletals @@ -421,7 +410,7 @@ def test_tpms_given_property_must_return_the_same_value() -> None: def test_tpms_given_surface_must_not_be_empty() -> None: """Test for the volume of the TPMS shapes generated with CadQuery and VTK.""" - tpms = microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION, offset=0) + tpms = microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION).with_offset(0) assert np.any(tpms.surface.points) assert np.any(tpms.surface.faces) @@ -431,9 +420,8 @@ def test_tpms_given_negative_offset_for_skeletal_must_work_with_vtk_and_raise_er None ): """Test for the volume of the TPMS shapes generated with CadQuery and VTK.""" - tpms = microgen.Tpms( - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=-1.0, + tpms = microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION).with_offset( + -1.0 ) with pytest.raises(NotImplementedError): tpms.generate_cad(type_part="lower skeletal") @@ -448,9 +436,8 @@ def including_negative_values( ) -> npt.NDArray[np.float64]: return x - tpms = microgen.Tpms( - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=including_negative_values, + tpms = microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION).with_offset( + including_negative_values ) with pytest.raises(NotImplementedError): tpms.generate_cad(type_part="lower skeletal") @@ -471,9 +458,8 @@ def all_negative( ) -> npt.NDArray[np.float64]: return -1.0 + x - tpms = microgen.Tpms( - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=all_negative, + tpms = microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION).with_offset( + all_negative ) err_msg_pattern = ( @@ -494,9 +480,8 @@ def including_negative_values( ) -> npt.NDArray[np.float64]: return x - tpms = microgen.Tpms( - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=including_negative_values, + tpms = microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION).with_offset( + including_negative_values ) with pytest.raises(NotImplementedError): tpms.generate_cad(type_part="sheet") @@ -512,18 +497,15 @@ def test_tpms_center_and_orientation_must_correspond() -> None: tpms = microgen.Tpms( surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=TEST_DEFAULT_OFFSET, center=center, orientation=orientation, - ) + ).with_offset(TEST_DEFAULT_OFFSET) vtk_sheet = tpms.generate_surface_mesh(type_part="sheet") cad_sheet = tpms.generate_cad(type_part="sheet") no_orientation = microgen.Tpms( - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=TEST_DEFAULT_OFFSET, - center=center, - ) + surface_function=TEST_DEFAULT_SURFACE_FUNCTION, center=center + ).with_offset(TEST_DEFAULT_OFFSET) assert np.allclose(vtk_sheet.center, center) assert np.allclose(cad_sheet.center().to_tuple(), center, rtol=1e-3) @@ -543,9 +525,8 @@ def test_tpms_generate_surface_mesh_check_that_volume_has_changed_when_the_offse part_type: Literal["sheet", "lower skeletal", "upper skeletal"], ) -> None: """Test for the volume of the TPMS shapes generated with CadQuery and VTK.""" - tpms = microgen.Tpms( - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=TEST_DEFAULT_OFFSET, + tpms = microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION).with_offset( + TEST_DEFAULT_OFFSET ) first_part = tpms.generate_surface_mesh(type_part=part_type) @@ -559,9 +540,8 @@ def test_tpms_generate_volume_mesh_check_that_volume_has_changed_when_the_offset part_type: Literal["sheet", "lower skeletal", "upper skeletal"], ) -> None: """Test for the volume of the TPMS shapes generated with CadQuery and VTK.""" - tpms = microgen.Tpms( - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=TEST_DEFAULT_OFFSET, + tpms = microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION).with_offset( + TEST_DEFAULT_OFFSET ) first_part = tpms.generate_volume_mesh(type_part=part_type) @@ -572,11 +552,13 @@ def test_tpms_generate_volume_mesh_check_that_volume_has_changed_when_the_offset def test_infill_given_cell_size_must_use_corresponding_repeat_cell() -> None: """Test if the repeat cell is computed correctly.""" - tpms = microgen.Infill( - obj=microgen.Box(dim=(1.0, 1.0, 1.0)).generate_surface_mesh(), - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=TEST_DEFAULT_OFFSET, - cell_size=(0.5, 1.0, 1.0), + tpms = ( + microgen.Infill( + obj=microgen.Box(dim=(1.0, 1.0, 1.0)).generate_surface_mesh(), + surface_function=TEST_DEFAULT_SURFACE_FUNCTION, + ) + .with_offset(TEST_DEFAULT_OFFSET) + .with_cell_size((0.5, 1.0, 1.0)) ) expected_repeat_cell = (2, 1, 1) assert np.allclose(tpms.repeat_cell, expected_repeat_cell) @@ -584,11 +566,13 @@ def test_infill_given_cell_size_must_use_corresponding_repeat_cell() -> None: def test_infill_given_repeat_cell_must_use_corresponding_cell_size() -> None: """Test if the cell size is computed correctly.""" - tpms = microgen.Infill( - obj=microgen.Box(dim=(1.0, 1.0, 1.0)).generate_surface_mesh(), - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=TEST_DEFAULT_OFFSET, - repeat_cell=(1, 1, 2), + tpms = ( + microgen.Infill( + obj=microgen.Box(dim=(1.0, 1.0, 1.0)).generate_surface_mesh(), + surface_function=TEST_DEFAULT_SURFACE_FUNCTION, + ) + .with_offset(TEST_DEFAULT_OFFSET) + .with_repeat_cell((1, 1, 2)) ) expected_cell_size = (1.0, 1.0, 0.5) @@ -599,12 +583,9 @@ def test_infill_given_repeat_cell_must_use_corresponding_cell_size() -> None: def test_infill_bounds_match_obj_bounds(kwarg: dict[str, int | float]) -> None: """Test if the grid bounds match the object bounds.""" obj = microgen.Ellipsoid(radii=(1.0, 2.0 / 3.0, 0.5)).generate_surface_mesh() - tpms = microgen.Infill( - obj=obj, - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=TEST_DEFAULT_OFFSET, - **kwarg, # type: ignore[arg-type] - ) + name, value = next(iter(kwarg.items())) + base = microgen.Infill(obj=obj, surface_function=TEST_DEFAULT_SURFACE_FUNCTION) + tpms = getattr(base, f"with_{name}")(value).with_offset(TEST_DEFAULT_OFFSET) grid_bounds = np.array(tpms.grid.bounds) grid_dim = grid_bounds[1::2] - grid_bounds[::2] @@ -620,22 +601,23 @@ def test_infill_cylinder_has_expected_volume() -> None: """Test if an infilled cylinder has the expected volume.""" density = 0.5 cylinder = microgen.Cylinder().generate_surface_mesh() - infill = microgen.Infill( - cylinder, - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - density=density, - repeat_cell=2, + infill = ( + microgen.Infill(cylinder, surface_function=TEST_DEFAULT_SURFACE_FUNCTION) + .with_density(density) + .with_repeat_cell(2) ) assert np.isclose(infill.sheet.volume, density * cylinder.volume, rtol=1e-2) def test_infill_cylinder_returns_single_connected_component_mesh() -> None: """Test if the infilled cylinder returns a single connected component mesh.""" - infill = microgen.Infill( - obj=microgen.Cylinder().generate_surface_mesh(), - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=TEST_DEFAULT_OFFSET, - repeat_cell=2, + infill = ( + microgen.Infill( + obj=microgen.Cylinder().generate_surface_mesh(), + surface_function=TEST_DEFAULT_SURFACE_FUNCTION, + ) + .with_offset(TEST_DEFAULT_OFFSET) + .with_repeat_cell(2) ) components = infill.sheet.connectivity().point_data["RegionId"] @@ -644,20 +626,16 @@ def test_infill_cylinder_returns_single_connected_component_mesh() -> None: assert n_unique == 1 -def test_infill_given_repeat_cell_and_cell_size_must_raise_an_error() -> None: - """Test if the cell size is computed correctly.""" - expected_err_msg = ( - "cell_size and repeat_cell cannot be given at the same time, " - "one is computed from the other." +def test_infill_repeat_cell_then_cell_size_last_set_wins() -> None: + """When both repeat_cell and cell_size are set in a chain, last wins.""" + obj = microgen.Box(dim=(1.0, 1.0, 1.0)).generate_surface_mesh() + tpms = ( + microgen.Infill(obj=obj, surface_function=TEST_DEFAULT_SURFACE_FUNCTION) + .with_offset(TEST_DEFAULT_OFFSET) + .with_repeat_cell((2, 1, 2)) + .with_cell_size((0.5, 1.0, 0.5)) ) - with pytest.raises(ValueError, match=expected_err_msg): - microgen.Infill( - obj=microgen.Box(dim=(1.0, 1.0, 1.0)).generate_surface_mesh(), - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=TEST_DEFAULT_OFFSET, - repeat_cell=(2, 1, 2), - cell_size=(0.5, 1.0, 0.5), - ) + assert np.allclose(tpms.cell_size, (0.5, 1.0, 0.5), rtol=1e-2) def test_infill_raises_error_when_cell_size_is_too_large() -> None: @@ -672,29 +650,24 @@ def test_infill_raises_error_when_cell_size_is_too_large() -> None: microgen.Infill( obj=microgen.Box(dim=(1.0, 1.0, 1.0)).generate_surface_mesh(), surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=TEST_DEFAULT_OFFSET, - cell_size=too_large_cell_size, - ) + ).with_offset(TEST_DEFAULT_OFFSET).with_cell_size(too_large_cell_size) -def test_tpms_both_offset_and_density_given_must_raise_error() -> None: - """Test whether providing both offset and density results in an error.""" - expected_err_msg = ( - "offset and density cannot be given at the same time. Give only one." +def test_tpms_offset_then_density_last_set_wins() -> None: + """When both offset and density are set in a chain, last wins.""" + tpms = ( + microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION) + .with_offset(TEST_DEFAULT_OFFSET) + .with_density(0.5) ) - with pytest.raises(ValueError, match=expected_err_msg): - microgen.Tpms( - surface_function=TEST_DEFAULT_SURFACE_FUNCTION, - offset=TEST_DEFAULT_OFFSET, - density=0.5, - ) + assert tpms.density == 0.5 + assert tpms.offset is None -def test_tpms_none_offset_and_density_given_must_raise_error() -> None: - """Test whether omitting both offset and density results in an error.""" - expected_err_msg = "offset or density must be given. Give one of them." - with pytest.raises(ValueError, match=expected_err_msg): - microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION) +def test_tpms_default_offset_is_zero() -> None: + """Bare Tpms construction uses offset=0 as the default.""" + tpms = microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION) + assert tpms.offset == 0.0 # ----------------------------------------------------------------------------- @@ -716,12 +689,13 @@ def test_cylindrical_tpms_full_wrap_volume_matches_shell() -> None: from microgen.shape.tpms import CylindricalTpms radius, delta_r, height = 1.5, 2.0, 6.0 - tpms = CylindricalTpms( - radius=radius, - surface_function=microgen.surface_functions.gyroid, - offset=0.5, - cell_size=1.0, - repeat_cell=(2, 0, int(height)), # 0 → auto-fill full circle + tpms = ( + CylindricalTpms( + radius=radius, surface_function=microgen.surface_functions.gyroid + ) + .with_offset(0.5) + .with_cell_size(1.0) + .with_repeat_cell((2, 0, int(height))) ) sheet = tpms.generate_surface_mesh(type_part="sheet") @@ -741,12 +715,11 @@ def test_spherical_tpms_full_wrap_volume_matches_shell() -> None: from microgen.shape.tpms import SphericalTpms radius, delta_r = 3.0, 2.0 - tpms = SphericalTpms( - radius=radius, - surface_function=microgen.surface_functions.gyroid, - offset=0.5, - cell_size=1.0, - repeat_cell=(2, 0, 0), # auto-fill θ + φ + tpms = ( + SphericalTpms(radius=radius, surface_function=microgen.surface_functions.gyroid) + .with_offset(0.5) + .with_cell_size(1.0) + .with_repeat_cell((2, 0, 0)) ) sheet = tpms.generate_surface_mesh(type_part="sheet") @@ -765,21 +738,21 @@ def test_cylindrical_tpms_partial_wrap_smaller_than_full() -> None: """ from microgen.shape.tpms import CylindricalTpms - full = CylindricalTpms( - radius=1.5, - surface_function=microgen.surface_functions.gyroid, - offset=0.5, - cell_size=1.0, - repeat_cell=(2, 0, 6), - ).generate_surface_mesh(type_part="sheet") - - quarter = CylindricalTpms( - radius=1.5, - surface_function=microgen.surface_functions.gyroid, - offset=0.5, - cell_size=1.0, - repeat_cell=(2, 2, 6), # quarter wrap (~2 of 9 angular cells) - ).generate_surface_mesh(type_part="sheet") + full = ( + CylindricalTpms(radius=1.5, surface_function=microgen.surface_functions.gyroid) + .with_offset(0.5) + .with_cell_size(1.0) + .with_repeat_cell((2, 0, 6)) + .generate_surface_mesh(type_part="sheet") + ) + + quarter = ( + CylindricalTpms(radius=1.5, surface_function=microgen.surface_functions.gyroid) + .with_offset(0.5) + .with_cell_size(1.0) + .with_repeat_cell((2, 2, 6)) + .generate_surface_mesh(type_part="sheet") + ) assert abs(quarter.volume) < abs(full.volume) / 3.0, ( f"quarter wrap vol {abs(quarter.volume):.2f} should be ≲ " @@ -795,13 +768,15 @@ def test_sweep_along_straight_line_is_finite_and_positive() -> None: line = np.linspace([0.0, 0.0, -3.0], [0.0, 0.0, 3.0], 50) radial_max, height = 1.0, 6.0 - tpms = Sweep( - curve_points=line, - surface_function=microgen.surface_functions.gyroid, - radial_max=radial_max, - offset=0.4, - cell_size=1.0, - repeat_cell=(int(height), 1, 6), + tpms = ( + Sweep( + curve_points=line, + surface_function=microgen.surface_functions.gyroid, + radial_max=radial_max, + ) + .with_offset(0.4) + .with_cell_size(1.0) + .with_repeat_cell((int(height), 1, 6)) ) sheet = tpms.generate_surface_mesh(type_part="sheet") diff --git a/tests/shapes/test_tpms_frep.py b/tests/shapes/test_tpms_frep.py index edac7e5f..c26ea182 100644 --- a/tests/shapes/test_tpms_frep.py +++ b/tests/shapes/test_tpms_frep.py @@ -43,10 +43,7 @@ def test_gradient_magnitude_near_one(self): def test_saddle_point_safety(self): """SDF at known saddle points should not produce NaN or Inf.""" - tpms = Tpms( - surface_function=surface_functions.gyroid, - offset=0.3, - ) + tpms = Tpms(surface_function=surface_functions.gyroid).with_offset(0.3) # Gyroid saddle points are at (0, 0, 0) and equivalents x = np.array([0.0, 0.25, 0.5]) y = np.array([0.0, 0.25, 0.5]) @@ -77,15 +74,15 @@ class TestTpmsFrepField: """Test that Tpms has a working F-rep implicit field.""" def test_tpms_has_func(self): - tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3) + tpms = Tpms(surface_function=surface_functions.gyroid).with_offset(0.3) assert tpms.func is not None def test_tpms_has_bounds(self): - tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3) + tpms = Tpms(surface_function=surface_functions.gyroid).with_offset(0.3) assert tpms.bounds is not None def test_tpms_evaluate(self): - tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3) + tpms = Tpms(surface_function=surface_functions.gyroid).with_offset(0.3) x = np.array([0.0, 0.1, 0.2]) y = np.array([0.0, 0.1, 0.2]) z = np.array([0.0, 0.1, 0.2]) @@ -94,7 +91,7 @@ def test_tpms_evaluate(self): assert np.all(np.isfinite(vals)) def test_tpms_raw_field(self): - tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3) + tpms = Tpms(surface_function=surface_functions.gyroid).with_offset(0.3) raw = tpms.raw_field assert callable(raw) val = raw(np.array([0.0]), np.array([0.0]), np.array([0.0])) @@ -110,16 +107,16 @@ class TestTpmsFrepMethods: """Test as_sheet, as_upper_skeletal, as_lower_skeletal.""" def test_as_sheet(self): - tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3) + tpms = Tpms(surface_function=surface_functions.gyroid).with_offset(0.3) sheet = tpms.as_sheet(thickness=0.15) assert isinstance(sheet, Shape) assert sheet.func is not None def test_as_sheet_mesh(self): - tpms = Tpms( - surface_function=surface_functions.gyroid, - offset=0.3, - resolution=20, + tpms = ( + Tpms(surface_function=surface_functions.gyroid) + .with_offset(0.3) + .with_resolution(20) ) sheet = tpms.as_sheet(thickness=0.15) mesh = sheet.generate_surface_mesh(bounds=tpms.bounds, resolution=30) @@ -127,13 +124,13 @@ def test_as_sheet_mesh(self): assert mesh.n_cells > 0 def test_as_upper_skeletal(self): - tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3) + tpms = Tpms(surface_function=surface_functions.gyroid).with_offset(0.3) skel = tpms.as_upper_skeletal() assert isinstance(skel, Shape) assert skel.func is not None def test_as_lower_skeletal(self): - tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3) + tpms = Tpms(surface_function=surface_functions.gyroid).with_offset(0.3) skel = tpms.as_lower_skeletal() assert isinstance(skel, Shape) assert skel.func is not None @@ -148,7 +145,7 @@ class TestTpmsBooleans: """Test boolean composition of TPMS with other shapes.""" def test_tpms_and_sphere(self): - tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3) + tpms = Tpms(surface_function=surface_functions.gyroid).with_offset(0.3) sphere_func = lambda x, y, z: np.sqrt(x**2 + y**2 + z**2) - 0.4 # noqa: E731 sphere = from_field(sphere_func, bounds=(-0.5, 0.5, -0.5, 0.5, -0.5, 0.5)) result = tpms & sphere @@ -160,7 +157,7 @@ def test_tpms_and_sphere(self): assert isinstance(mesh, pv.PolyData) def test_sheet_and_sphere(self): - tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3) + tpms = Tpms(surface_function=surface_functions.gyroid).with_offset(0.3) sheet = tpms.as_sheet(thickness=0.15) sphere_func = lambda x, y, z: np.sqrt(x**2 + y**2 + z**2) - 0.4 # noqa: E731 sphere = from_field(sphere_func, bounds=(-0.5, 0.5, -0.5, 0.5, -0.5, 0.5)) @@ -181,30 +178,27 @@ class TestTpmsGenerateVtk: """Test that generate_surface_mesh(type_part=...) still works.""" def test_sheet(self): - tpms = Tpms( - surface_function=surface_functions.gyroid, - offset=0.3, - resolution=20, + tpms = ( + Tpms(surface_function=surface_functions.gyroid) + .with_offset(0.3) + .with_resolution(20) ) mesh = tpms.generate_surface_mesh(type_part="sheet") assert isinstance(mesh, pv.PolyData) assert mesh.n_cells > 0 def test_surface(self): - tpms = Tpms( - surface_function=surface_functions.gyroid, - offset=0.3, - resolution=20, + tpms = ( + Tpms(surface_function=surface_functions.gyroid) + .with_offset(0.3) + .with_resolution(20) ) mesh = tpms.generate_surface_mesh(type_part="surface") assert isinstance(mesh, pv.PolyData) assert mesh.n_cells > 0 def test_invalid_type_part(self): - tpms = Tpms( - surface_function=surface_functions.gyroid, - offset=0.3, - ) + tpms = Tpms(surface_function=surface_functions.gyroid).with_offset(0.3) with pytest.raises(ValueError, match="type_part"): tpms.generate_surface_mesh(type_part="invalid") @@ -213,7 +207,7 @@ def test_invalid_type_part(self): [surface_functions.gyroid, surface_functions.schwarz_p], ) def test_multiple_surface_functions(self, surface_fn): - tpms = Tpms(surface_function=surface_fn, offset=0.3, resolution=20) + tpms = Tpms(surface_function=surface_fn).with_offset(0.3).with_resolution(20) mesh = tpms.generate_surface_mesh(type_part="sheet") assert mesh.n_cells > 0 @@ -229,11 +223,10 @@ class TestCurvilinearTpms: def test_cylindrical_has_func(self): from microgen.shape.tpms import CylindricalTpms - tpms = CylindricalTpms( - radius=1.0, - surface_function=surface_functions.gyroid, - offset=0.3, - resolution=10, + tpms = ( + CylindricalTpms(radius=1.0, surface_function=surface_functions.gyroid) + .with_offset(0.3) + .with_resolution(10) ) assert tpms.func is not None assert tpms.bounds is not None @@ -241,11 +234,10 @@ def test_cylindrical_has_func(self): def test_cylindrical_evaluate(self): from microgen.shape.tpms import CylindricalTpms - tpms = CylindricalTpms( - radius=1.0, - surface_function=surface_functions.gyroid, - offset=0.3, - resolution=10, + tpms = ( + CylindricalTpms(radius=1.0, surface_function=surface_functions.gyroid) + .with_offset(0.3) + .with_resolution(10) ) x = np.array([1.0, 0.5]) y = np.array([0.0, 0.5]) @@ -256,11 +248,10 @@ def test_cylindrical_evaluate(self): def test_spherical_has_func(self): from microgen.shape.tpms import SphericalTpms - tpms = SphericalTpms( - radius=1.0, - surface_function=surface_functions.gyroid, - offset=0.3, - resolution=10, + tpms = ( + SphericalTpms(radius=1.0, surface_function=surface_functions.gyroid) + .with_offset(0.3) + .with_resolution(10) ) assert tpms.func is not None assert tpms.bounds is not None @@ -268,11 +259,10 @@ def test_spherical_has_func(self): def test_spherical_evaluate(self): from microgen.shape.tpms import SphericalTpms - tpms = SphericalTpms( - radius=1.0, - surface_function=surface_functions.gyroid, - offset=0.3, - resolution=10, + tpms = ( + SphericalTpms(radius=1.0, surface_function=surface_functions.gyroid) + .with_offset(0.3) + .with_resolution(10) ) x = np.array([1.0, 0.5]) y = np.array([0.0, 0.5]) diff --git a/tests/test_cad_optional.py b/tests/test_cad_optional.py index d14d36b3..d9c797e5 100644 --- a/tests/test_cad_optional.py +++ b/tests/test_cad_optional.py @@ -57,7 +57,7 @@ def test_tpms_generate_surface_mesh_without_cad_extra() -> None: from microgen import surface_functions from microgen.shape.tpms import Tpms - tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.5) + tpms = Tpms(surface_function=surface_functions.gyroid).with_offset(0.5) mesh = tpms.generate_surface_mesh(type_part="sheet") assert mesh.n_cells > 0 diff --git a/tests/test_remesh.py b/tests/test_remesh.py index 4e448917..d2f6acb6 100644 --- a/tests/test_remesh.py +++ b/tests/test_remesh.py @@ -93,7 +93,7 @@ def fixture_box_mesh() -> BoxMesh: @pytest.fixture(name="gyroid_mesh") def fixture_gyroid_mesh() -> pv.UnstructuredGrid: """Return a gyroid mesh.""" - return Tpms(surface_function=gyroid, offset=1.0).grid_sheet + return Tpms(surface_function=gyroid).with_offset(1.0).grid_sheet @pytest.fixture(name="non_periodic_mesh") From c75752361a590de43b49da45fe45f267e33c7d90 Mon Sep 17 00:00:00 2001 From: Kevin Marchais Date: Tue, 12 May 2026 14:48:19 +0200 Subject: [PATCH 2/4] warn when offset/density (and strut_radius/density) are switched in chain --- .../shape/strut_lattice/abstract_lattice.py | 31 ++++++++++++++++-- microgen/shape/tpms.py | 32 +++++++++++++++++-- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/microgen/shape/strut_lattice/abstract_lattice.py b/microgen/shape/strut_lattice/abstract_lattice.py index c57796d3..aca8bd5a 100644 --- a/microgen/shape/strut_lattice/abstract_lattice.py +++ b/microgen/shape/strut_lattice/abstract_lattice.py @@ -6,6 +6,7 @@ from __future__ import annotations +import warnings from abc import abstractmethod from pathlib import Path from tempfile import NamedTemporaryFile @@ -100,6 +101,12 @@ def __init__( self._strut_joints: bool = False self._density: float | None = density + # Track which of strut_radius / density was last set by the user, so + # the setters can warn on a mode switch without false-firing after the + # lazy density-fit writes back to ``_strut_radius``. + self._strut_radius_explicit: bool = strut_radius is not None + self._density_explicit: bool = density is not None + # Lazy caches (invalidated by setters). self._cad_shape: CadShape | None = None self._vtk_shape: tuple[tuple[float, int, bool], pv.PolyData] | None = None @@ -114,8 +121,17 @@ def with_strut_radius(self, radius: float) -> AbstractLattice: """Set the strut radius. Clears any previously set density (last-set wins between strut radius - and density). + and density). Emits a :class:`UserWarning` if density was already + explicitly set, so the override is visible to the caller. """ + if self._density_explicit: + warnings.warn( + "Overriding explicit density with strut_radius; the previous " + "density value will be cleared.", + stacklevel=2, + ) + self._density_explicit = False + self._strut_radius_explicit = True self._strut_radius = radius self._density = None self._invalidate_caches() @@ -146,11 +162,20 @@ def with_density(self, density: float) -> AbstractLattice: """Set target density in (0, 1]. Clears any previously set strut radius (last-set wins between strut - radius and density). + radius and density). Emits a :class:`UserWarning` if strut_radius + was already explicitly set, so the override is visible to the caller. """ if not 0.0 < density <= 1.0: err_msg = f"density must be between 0 and 1. Given: {density}" raise ValueError(err_msg) + if self._strut_radius_explicit: + warnings.warn( + "Overriding explicit strut_radius with density; the previous " + "strut_radius value will be cleared.", + stacklevel=2, + ) + self._strut_radius_explicit = False + self._density_explicit = True self._density = density self._strut_radius = None self._invalidate_caches() @@ -346,7 +371,7 @@ def calc_density(radius: float) -> float: self._strut_radius = radius cad = self._generate_cad() last_cad[0] = cad - return cad.Volume() / (self._cell_size**3) + return cad.volume() / (self._cell_size**3) result = root_scalar( lambda r: calc_density(r) - self._density, diff --git a/microgen/shape/tpms.py b/microgen/shape/tpms.py index acfa6b1f..0ace7852 100644 --- a/microgen/shape/tpms.py +++ b/microgen/shape/tpms.py @@ -16,6 +16,7 @@ from __future__ import annotations import logging +import warnings from collections.abc import Callable, Sequence from typing import TYPE_CHECKING, Literal @@ -155,6 +156,12 @@ def __init__( self.phase_shift: Sequence[float] = (0.0, 0.0, 0.0) self.density: float | None = None + # Track which of offset / density was last explicitly set by the user, + # so the setters can warn on a mode switch without false-firing after + # the internal density-fit writes back to ``_offset``. + self._offset_explicit: bool = False + self._density_explicit: bool = False + self.grid: pv.StructuredGrid self._grid_sheet: pv.UnstructuredGrid = None self._grid_upper_skeletal: pv.UnstructuredGrid = None @@ -206,8 +213,18 @@ def with_offset( ) -> Tpms: """Set the isosurface offset (controls TPMS thickness). - Clears any previously set density (last-set wins). + Clears any previously set density (last-set wins). Emits a + :class:`UserWarning` if density was already explicitly set, so the + override is visible to the caller. """ + if self._density_explicit: + warnings.warn( + "Overriding explicit density with offset; the previous density " + "value will be cleared.", + stacklevel=2, + ) + self._density_explicit = False + self._offset_explicit = True self.density = None self.offset = offset # uses the property setter to update grid arrays return self @@ -240,13 +257,24 @@ def with_density(self: Tpms, density: float) -> Tpms: """Set the target density (0, 1]. Clears any previously set offset; the offset that yields this density - is computed lazily on terminal generation calls. + is computed lazily on terminal generation calls. Emits a + :class:`UserWarning` if offset was already explicitly set, so the + override is visible to the caller. """ if not 0.0 < density <= 1.0: err_msg = f"density must be between 0 and 1. Given: {density}" raise ValueError(err_msg) + if self._offset_explicit: + warnings.warn( + "Overriding explicit offset with density; the previous offset " + "value will be cleared.", + stacklevel=2, + ) + self._offset_explicit = False + self._density_explicit = True self.density = density self._offset = None + self._offset_func = None self._invalidate_part_caches() return self From cb3388b0f993907f67ad2d759292e484b625c98f Mon Sep 17 00:00:00 2001 From: Kevin Marchais Date: Tue, 12 May 2026 14:53:39 +0200 Subject: [PATCH 3/4] use keyword form for strut_radius in lattice examples and docstrings --- examples/Lattices/preset_lattice.py | 24 +++++++++---------- .../strut_lattice/body_centered_cubic.py | 2 +- microgen/shape/strut_lattice/cubic.py | 2 +- microgen/shape/strut_lattice/cuboctahedron.py | 2 +- microgen/shape/strut_lattice/diamond.py | 2 +- .../strut_lattice/face_centered_cubic.py | 2 +- microgen/shape/strut_lattice/octahedron.py | 2 +- microgen/shape/strut_lattice/octet_truss.py | 2 +- .../strut_lattice/rhombic_cuboctahedron.py | 2 +- .../strut_lattice/rhombic_dodecahedron.py | 2 +- .../shape/strut_lattice/truncated_cube.py | 2 +- .../strut_lattice/truncated_cuboctahedron.py | 2 +- .../strut_lattice/truncated_octahedron.py | 2 +- 13 files changed, 24 insertions(+), 24 deletions(-) diff --git a/examples/Lattices/preset_lattice.py b/examples/Lattices/preset_lattice.py index 56aee595..edc6ab4f 100644 --- a/examples/Lattices/preset_lattice.py +++ b/examples/Lattices/preset_lattice.py @@ -19,18 +19,18 @@ ) preset_lattice_list = [ - BodyCenteredCubic(0.1), - Cubic(0.1), - Cuboctahedron(0.1), - Diamond(0.1), - FaceCenteredCubic(0.1), - Octahedron(0.1), - OctetTruss(0.1), - RhombicCuboctahedron(0.1), - RhombicDodecahedron(0.1), - TruncatedCube(0.1), - TruncatedCuboctahedron(0.1), - TruncatedOctahedron(0.1), + BodyCenteredCubic(strut_radius=0.1), + Cubic(strut_radius=0.1), + Cuboctahedron(strut_radius=0.1), + Diamond(strut_radius=0.1), + FaceCenteredCubic(strut_radius=0.1), + Octahedron(strut_radius=0.1), + OctetTruss(strut_radius=0.1), + RhombicCuboctahedron(strut_radius=0.1), + RhombicDodecahedron(strut_radius=0.1), + TruncatedCube(strut_radius=0.1), + TruncatedCuboctahedron(strut_radius=0.1), + TruncatedOctahedron(strut_radius=0.1), ] meshes = pv.PolyData() diff --git a/microgen/shape/strut_lattice/body_centered_cubic.py b/microgen/shape/strut_lattice/body_centered_cubic.py index 3f306959..9fa87119 100644 --- a/microgen/shape/strut_lattice/body_centered_cubic.py +++ b/microgen/shape/strut_lattice/body_centered_cubic.py @@ -23,7 +23,7 @@ class BodyCenteredCubic(AbstractLattice): import microgen - shape = microgen.BodyCenteredCubic(0.1).generate_surface_mesh() + shape = microgen.BodyCenteredCubic(strut_radius=0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/cubic.py b/microgen/shape/strut_lattice/cubic.py index 72a0f513..af98a2f9 100644 --- a/microgen/shape/strut_lattice/cubic.py +++ b/microgen/shape/strut_lattice/cubic.py @@ -24,7 +24,7 @@ class Cubic(AbstractLattice): import microgen - shape = microgen.Cubic(0.1).generate_surface_mesh() + shape = microgen.Cubic(strut_radius=0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/cuboctahedron.py b/microgen/shape/strut_lattice/cuboctahedron.py index de7b0bb3..62d23dc2 100644 --- a/microgen/shape/strut_lattice/cuboctahedron.py +++ b/microgen/shape/strut_lattice/cuboctahedron.py @@ -24,7 +24,7 @@ class Cuboctahedron(AbstractLattice): import microgen - shape = microgen.Cuboctahedron(0.1).generate_surface_mesh() + shape = microgen.Cuboctahedron(strut_radius=0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/diamond.py b/microgen/shape/strut_lattice/diamond.py index 2cdd5409..4f3b447f 100644 --- a/microgen/shape/strut_lattice/diamond.py +++ b/microgen/shape/strut_lattice/diamond.py @@ -25,7 +25,7 @@ class Diamond(AbstractLattice): import microgen - shape = microgen.Diamond(0.1).generate_surface_mesh() + shape = microgen.Diamond(strut_radius=0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/face_centered_cubic.py b/microgen/shape/strut_lattice/face_centered_cubic.py index eb459167..2efd6821 100644 --- a/microgen/shape/strut_lattice/face_centered_cubic.py +++ b/microgen/shape/strut_lattice/face_centered_cubic.py @@ -23,7 +23,7 @@ class FaceCenteredCubic(AbstractLattice): import microgen - shape = microgen.FaceCenteredCubic(0.1).generate_surface_mesh() + shape = microgen.FaceCenteredCubic(strut_radius=0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/octahedron.py b/microgen/shape/strut_lattice/octahedron.py index eaf520ae..8e0e8a5a 100644 --- a/microgen/shape/strut_lattice/octahedron.py +++ b/microgen/shape/strut_lattice/octahedron.py @@ -22,7 +22,7 @@ class Octahedron(AbstractLattice): import microgen - shape = microgen.Octahedron(0.1).generate_surface_mesh() + shape = microgen.Octahedron(strut_radius=0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/octet_truss.py b/microgen/shape/strut_lattice/octet_truss.py index a714d394..af47c8e4 100644 --- a/microgen/shape/strut_lattice/octet_truss.py +++ b/microgen/shape/strut_lattice/octet_truss.py @@ -24,7 +24,7 @@ class OctetTruss(AbstractLattice): import microgen - shape = microgen.OctetTruss(0.1).generate_surface_mesh() + shape = microgen.OctetTruss(strut_radius=0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/rhombic_cuboctahedron.py b/microgen/shape/strut_lattice/rhombic_cuboctahedron.py index c4c60c19..009f5853 100644 --- a/microgen/shape/strut_lattice/rhombic_cuboctahedron.py +++ b/microgen/shape/strut_lattice/rhombic_cuboctahedron.py @@ -24,7 +24,7 @@ class RhombicCuboctahedron(AbstractLattice): import microgen - shape = microgen.RhombicCuboctahedron(0.1).generate_surface_mesh() + shape = microgen.RhombicCuboctahedron(strut_radius=0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/rhombic_dodecahedron.py b/microgen/shape/strut_lattice/rhombic_dodecahedron.py index 480d3b1e..f4e742a8 100644 --- a/microgen/shape/strut_lattice/rhombic_dodecahedron.py +++ b/microgen/shape/strut_lattice/rhombic_dodecahedron.py @@ -24,7 +24,7 @@ class RhombicDodecahedron(AbstractLattice): import microgen - shape = microgen.RhombicDodecahedron(0.1).generate_surface_mesh() + shape = microgen.RhombicDodecahedron(strut_radius=0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/truncated_cube.py b/microgen/shape/strut_lattice/truncated_cube.py index a4fede50..5b9a51ad 100644 --- a/microgen/shape/strut_lattice/truncated_cube.py +++ b/microgen/shape/strut_lattice/truncated_cube.py @@ -24,7 +24,7 @@ class TruncatedCube(AbstractLattice): import microgen - shape = microgen.TruncatedCube(0.1).generate_surface_mesh() + shape = microgen.TruncatedCube(strut_radius=0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/truncated_cuboctahedron.py b/microgen/shape/strut_lattice/truncated_cuboctahedron.py index c3b1703c..175c1871 100644 --- a/microgen/shape/strut_lattice/truncated_cuboctahedron.py +++ b/microgen/shape/strut_lattice/truncated_cuboctahedron.py @@ -24,7 +24,7 @@ class TruncatedCuboctahedron(AbstractLattice): import microgen - shape = microgen.TruncatedCuboctahedron(0.1).generate_surface_mesh() + shape = microgen.TruncatedCuboctahedron(strut_radius=0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: diff --git a/microgen/shape/strut_lattice/truncated_octahedron.py b/microgen/shape/strut_lattice/truncated_octahedron.py index c906f3cc..83f7e31e 100644 --- a/microgen/shape/strut_lattice/truncated_octahedron.py +++ b/microgen/shape/strut_lattice/truncated_octahedron.py @@ -24,7 +24,7 @@ class TruncatedOctahedron(AbstractLattice): import microgen - shape = microgen.TruncatedOctahedron(0.1).generate_surface_mesh() + shape = microgen.TruncatedOctahedron(strut_radius=0.1).generate_surface_mesh() .. jupyter-execute:: :hide-code: From af98743310af51f7b497a1b27e5caa9b5d3a6220 Mon Sep 17 00:00:00 2001 From: Kevin Marchais Date: Tue, 12 May 2026 15:58:41 +0200 Subject: [PATCH 4/4] default all lattice/TPMS constructor parameters --- examples/tpms_infill_gallery.py | 5 +- microgen/cad.py | 2 +- .../shape/strut_lattice/abstract_lattice.py | 95 ++++--- .../shape/strut_lattice/custom_lattice.py | 37 ++- microgen/shape/tpms.py | 247 +++++++++++++----- pyproject.toml | 1 + 6 files changed, 276 insertions(+), 111 deletions(-) diff --git a/examples/tpms_infill_gallery.py b/examples/tpms_infill_gallery.py index 3ec46a4d..bc21a7ba 100644 --- a/examples/tpms_infill_gallery.py +++ b/examples/tpms_infill_gallery.py @@ -94,9 +94,8 @@ def helix(t: float) -> np.ndarray: sw = ( - Sweep( - curve_points=helix, surface_function=gyroid, radial_max=0.6, n_curve_samples=200 - ) + Sweep(curve_points=helix, surface_function=gyroid, radial_max=0.6) + .with_n_curve_samples(200) .with_offset(OFFSET) .with_cell_size(CELL_SIZE) .with_repeat_cell((8, 1, 6)) diff --git a/microgen/cad.py b/microgen/cad.py index 983950f5..0f8b23b8 100644 --- a/microgen/cad.py +++ b/microgen/cad.py @@ -83,7 +83,7 @@ def to_tuple(self) -> tuple[float, float, float]: class _BBox: """Axis-aligned bounding box exposing ``xmin`` / ``xmax`` / …. - Returned by :meth:`CadShape.BoundingBox`. + Returned by :meth:`CadShape.bounding_box`. """ xmin: float diff --git a/microgen/shape/strut_lattice/abstract_lattice.py b/microgen/shape/strut_lattice/abstract_lattice.py index aca8bd5a..42576c2c 100644 --- a/microgen/shape/strut_lattice/abstract_lattice.py +++ b/microgen/shape/strut_lattice/abstract_lattice.py @@ -6,6 +6,7 @@ from __future__ import annotations +import sys import warnings from abc import abstractmethod from pathlib import Path @@ -18,6 +19,11 @@ from scipy.optimize import root_scalar from scipy.spatial.transform import Rotation +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + from ...cad import CadShape from ...mesh import mesh, mesh_periodic from ...operations import fuse_shapes @@ -34,28 +40,27 @@ BALL_POINT_RADIUS_TOLERANCE = 1e-5 _DENSITY_ROOT_RADIUS_MIN = 1e-3 +_DEFAULT_STRUT_RADIUS = 0.05 class AbstractLattice(Shape): """Abstract class for strut-based lattices, configured via method chaining. - Construction starts cheap: ``__init__`` only stashes ``center`` and - ``orientation``. Geometry parameters are set via chained ``with_*`` - setters; heavy work (vertex layout, density root-finding, CAD assembly) - runs lazily on the first call to :meth:`generate_cad` / + Every parameter has a default: ``__init__`` is callable with no arguments + and produces a lattice with ``strut_radius=0.05`` and ``cell_size=1.0``. + All parameters (radius / density, cell size, strut heights, joints, + center, orientation, custom vertices / pairs) are also exposed via chained + ``with_*`` setters; heavy work (vertex layout, density root-finding, CAD + assembly) runs lazily on the first call to :meth:`generate_cad` / :meth:`generate_surface_mesh`. Example:: - lattice = ( - OctetTruss() - .with_strut_radius(0.05) - .with_cell_size(1.0) - .generate_surface_mesh() - ) + lattice = OctetTruss().with_cell_size(1.0).generate_surface_mesh() - Mutual exclusivity: ``with_strut_radius`` and ``with_density`` cannot both - be set; the validator raises at the first terminal call if both are. + Mutual exclusivity: ``strut_radius`` and ``density`` cannot both be set + explicitly. Passing both to ``__init__`` raises; the ``with_*`` setters + emit a :class:`UserWarning` and clear the previous value on a mode switch. """ _UNIT_CUBE_SIZE = 1.0 @@ -117,7 +122,7 @@ def __init__( # Chained setters # ------------------------------------------------------------------ - def with_strut_radius(self, radius: float) -> AbstractLattice: + def with_strut_radius(self: Self, radius: float) -> Self: """Set the strut radius. Clears any previously set density (last-set wins between strut radius @@ -138,27 +143,27 @@ def with_strut_radius(self, radius: float) -> AbstractLattice: return self def with_strut_heights( - self, + self: Self, heights: float | list[float], - ) -> AbstractLattice: + ) -> Self: """Set strut heights, as a scalar or per-strut list (unit-cell units).""" self._strut_heights = heights self._invalidate_caches() return self - def with_cell_size(self, size: float) -> AbstractLattice: + def with_cell_size(self: Self, size: float) -> Self: """Set the cubic cell edge length.""" self._cell_size = size self._invalidate_caches() return self - def with_strut_joints(self, *, enabled: bool = True) -> AbstractLattice: + def with_strut_joints(self: Self, *, enabled: bool = True) -> Self: """Enable (default) or disable spherical joints at vertices.""" self._strut_joints = enabled self._invalidate_caches() return self - def with_density(self, density: float) -> AbstractLattice: + def with_density(self: Self, density: float) -> Self: """Set target density in (0, 1]. Clears any previously set strut radius (last-set wins between strut @@ -182,23 +187,39 @@ def with_density(self, density: float) -> AbstractLattice: return self def with_base_vertices( - self, + self: Self, vertices: npt.NDArray[np.float64], - ) -> AbstractLattice: + ) -> Self: """Override the subclass's default vertex layout (unit-cube units).""" self._user_base_vertices = vertices self._invalidate_caches() return self def with_strut_vertex_pairs( - self, + self: Self, pairs: npt.NDArray[np.int64], - ) -> AbstractLattice: + ) -> Self: """Override the subclass's default strut connectivity.""" self._user_strut_vertex_pairs = pairs self._invalidate_caches() return self + def with_center(self: Self, center: Vector3DType) -> Self: + """Set the lattice center; clears caches so the next generate reuses it.""" + self._center = center + self._invalidate_caches() + return self + + def with_orientation(self: Self, orientation: Vector3DType | Rotation) -> Self: + """Set the lattice orientation (Euler ZXZ degrees or :class:`Rotation`).""" + self._orientation = ( + orientation + if isinstance(orientation, Rotation) + else Rotation.from_euler("ZXZ", orientation, degrees=True) + ) + self._invalidate_caches() + return self + def _invalidate_caches(self) -> None: self._cad_shape = None self._vtk_shape = None @@ -225,14 +246,18 @@ def density(self) -> float | None: return self._density @property - def strut_radius(self) -> float | None: + def strut_radius(self) -> float: """Effective strut radius. If only :meth:`with_density` was set, this triggers the lazy - density-to-radius root-find on first access. + density-to-radius root-find on first access. If neither + ``strut_radius`` nor ``density`` was explicitly chosen, falls back to + :data:`_DEFAULT_STRUT_RADIUS`. """ if self._strut_radius is None and self._density is not None: self._strut_radius = self._compute_radius_to_fit_density() + if self._strut_radius is None: + self._strut_radius = _DEFAULT_STRUT_RADIUS return self._strut_radius @property @@ -329,12 +354,6 @@ def _geom(self) -> dict: return self._geometry def _validate(self) -> None: - if self._strut_radius is None and self._density is None: - err_msg = ( - "strut_radius or density must be set via .with_strut_radius() " - "or .with_density()." - ) - raise ValueError(err_msg) if self._strut_heights is None: err_msg = "strut_heights must be defined by the subclass" raise NotImplementedError(err_msg) @@ -364,20 +383,22 @@ def _compute_rotations_from( return rotations_list def _compute_radius_to_fit_density(self) -> float: - """Root-find the strut radius that matches :attr:`density`.""" - last_cad: list[CadShape | None] = [None] + """Root-find the strut radius that matches :attr:`density`. + + Does NOT cache the CAD produced during the search: ``root_scalar``'s + final evaluation is at the bracket midpoint, not exactly at the root, + so the last CAD does not correspond to the returned radius. The + caller (``generate_cad``) rebuilds at ``result.root``. + """ def calc_density(radius: float) -> float: self._strut_radius = radius - cad = self._generate_cad() - last_cad[0] = cad - return cad.volume() / (self._cell_size**3) + return self._generate_cad().volume() / (self._cell_size**3) result = root_scalar( lambda r: calc_density(r) - self._density, bracket=[_DENSITY_ROOT_RADIUS_MIN, self._cell_size], ) - self._cad_shape = last_cad[0] return float(result.root) # ------------------------------------------------------------------ @@ -390,8 +411,6 @@ def generate_cad(self, **_: KwargsGenerateType) -> CadShape: return self._cad_shape # Trigger lazy radius resolution + validation via the property. _ = self.strut_radius - if self._cad_shape is not None: - return self._cad_shape self._cad_shape = self._generate_cad() return self._cad_shape diff --git a/microgen/shape/strut_lattice/custom_lattice.py b/microgen/shape/strut_lattice/custom_lattice.py index 64b62c57..e07302ff 100644 --- a/microgen/shape/strut_lattice/custom_lattice.py +++ b/microgen/shape/strut_lattice/custom_lattice.py @@ -8,36 +8,59 @@ from typing import TYPE_CHECKING +import numpy as np + from .abstract_lattice import AbstractLattice if TYPE_CHECKING: - import numpy as np import numpy.typing as npt from scipy.spatial.transform import Rotation from microgen.shape import Vector3DType +# Default topology: three orthogonal struts crossing at the unit-cube center. +_DEFAULT_BASE_VERTICES = np.array( + [ + [-0.5, 0.0, 0.0], + [0.5, 0.0, 0.0], + [0.0, -0.5, 0.0], + [0.0, 0.5, 0.0], + [0.0, 0.0, -0.5], + [0.0, 0.0, 0.5], + ], +) +_DEFAULT_STRUT_VERTEX_PAIRS = np.array([[0, 1], [2, 3], [4, 5]]) + class CustomLattice(AbstractLattice): """Strut-based lattice from user-defined vertices and connectivity. - ``base_vertices`` and ``strut_vertex_pairs`` are required positional. - Configure the rest via chained ``with_*`` setters before calling + All parameters have defaults: the default topology is three orthogonal + struts crossing at the unit-cube center (axis crosshair). Configure + everything via chained ``with_*`` setters before calling :meth:`generate_cad` or :meth:`generate_surface_mesh`. """ + _DEFAULT_STRUT_HEIGHTS = AbstractLattice._UNIT_CUBE_SIZE + def __init__( self, - base_vertices: npt.NDArray[np.float64], - strut_vertex_pairs: npt.NDArray[np.int64], + base_vertices: npt.NDArray[np.float64] | None = None, + strut_vertex_pairs: npt.NDArray[np.int64] | None = None, *, center: Vector3DType = (0, 0, 0), orientation: Vector3DType | Rotation = (0, 0, 0), ) -> None: """Initialize with the user-defined vertex layout and connectivity.""" super().__init__(center=center, orientation=orientation) - self._user_base_vertices = base_vertices - self._user_strut_vertex_pairs = strut_vertex_pairs + self._user_base_vertices = ( + base_vertices if base_vertices is not None else _DEFAULT_BASE_VERTICES + ) + self._user_strut_vertex_pairs = ( + strut_vertex_pairs + if strut_vertex_pairs is not None + else _DEFAULT_STRUT_VERTEX_PAIRS + ) def _generate_base_vertices(self) -> npt.NDArray[np.float64]: # The base class accessor returns _user_base_vertices when set, so diff --git a/microgen/shape/tpms.py b/microgen/shape/tpms.py index 0ace7852..b3ae5189 100644 --- a/microgen/shape/tpms.py +++ b/microgen/shape/tpms.py @@ -16,18 +16,26 @@ from __future__ import annotations import logging +import sys import warnings from collections.abc import Callable, Sequence from typing import TYPE_CHECKING, Literal +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + import numpy as np import numpy.typing as npt import pyvista as pv from scipy.optimize import root_scalar +from scipy.spatial.transform import Rotation from microgen.operations import fuse_shapes, rotate from .shape import BoundsType, Shape, ShellCreationError +from .surface_functions import gyroid as _default_surface_function if TYPE_CHECKING: from microgen.cad import CadShape @@ -120,22 +128,22 @@ class Tpms(Shape): def __init__( self: Tpms, - surface_function: Field, + surface_function: Field = _default_surface_function, *, center: Vector3DType = (0, 0, 0), orientation: Vector3DType = (0, 0, 0), ) -> None: r"""Initialize with the TPMS implicit function and default config. - ``surface_function`` is the only positional argument. All other - parameters (offset / density, cell size, repeat cell, resolution, - phase shift) are configured via chained ``with_*`` setters before - calling :meth:`generate_surface_mesh` / :meth:`generate_cad`. + All parameters have defaults: ``surface_function`` defaults to + :func:`~microgen.surface_functions.gyroid` (the canonical TPMS). + Every parameter is also exposed via a chained ``with_*`` setter, so + ``Tpms()`` produces a valid, configurable gyroid in one step. Example:: shape = ( - Tpms(surface_function=gyroid) + Tpms() .with_offset(0.3) .with_cell_size(1.0) .with_resolution(30) @@ -143,6 +151,7 @@ def __init__( ) :param surface_function: tpms function or custom ``f(x, y, z) -> array`` + (default: gyroid) :param center: shape center (passed to :class:`Shape`) :param orientation: shape orientation (passed to :class:`Shape`) """ @@ -162,7 +171,8 @@ def __init__( self._offset_explicit: bool = False self._density_explicit: bool = False - self.grid: pv.StructuredGrid + self._grid: pv.StructuredGrid | None = None + self._grid_dirty: bool = True self._grid_sheet: pv.UnstructuredGrid = None self._grid_upper_skeletal: pv.UnstructuredGrid = None self._grid_lower_skeletal: pv.UnstructuredGrid = None @@ -172,18 +182,35 @@ def __init__( self._init_cell_parameters(1.0, 1) self.resolution = 20 - self._build_grid() + # Grid is built lazily on first access (see ``grid`` property). + # Chained setters such as ``with_cell_size(...).with_repeat_cell(...) + # .with_offset(...)`` would otherwise rebuild the grid once per call. # ------------------------------------------------------------------ - # Internal grid rebuild (called from __init__ and grid-affecting setters) + # Lazy grid build # ------------------------------------------------------------------ + @property + def grid(self: Tpms) -> pv.StructuredGrid: + """TPMS structured grid (built on first access; rebuilt if dirty).""" + self._ensure_grid() + return self._grid + + def _ensure_grid(self: Tpms) -> None: + """Build the grid if marked dirty by a setter (or never built).""" + if not self._grid_dirty: + return + # Clear the flag *before* building so reads of ``self.grid`` inside + # the build helpers don't recurse back into ``_ensure_grid``. + self._grid_dirty = False + self._build_grid() + def _build_grid(self: Tpms) -> None: """Compute the TPMS field + F-rep, refresh offset limits, reapply offset.""" self._compute_tpms_field() self._setup_frep_field() - min_field = float(np.min(self.grid["surface"])) - max_field = float(np.max(self.grid["surface"])) + min_field = float(np.min(self._grid["surface"])) + max_field = float(np.max(self._grid["surface"])) self.offset_lim = { "sheet": (0.0, 2.0 * max(-min_field, max_field)), "skeletal": (2.0 * min_field, 2.0 * max_field), @@ -208,9 +235,9 @@ def _invalidate_part_caches(self: Tpms) -> None: # ------------------------------------------------------------------ def with_offset( - self: Tpms, + self: Self, offset: float | npt.NDArray[np.float64] | OffsetGrading | Field, - ) -> Tpms: + ) -> Self: """Set the isosurface offset (controls TPMS thickness). Clears any previously set density (last-set wins). Emits a @@ -229,31 +256,64 @@ def with_offset( self.offset = offset # uses the property setter to update grid arrays return self - def with_phase_shift(self: Tpms, phase_shift: Sequence[float]) -> Tpms: + def with_phase_shift(self: Self, phase_shift: Sequence[float]) -> Self: r"""Set the phase shift :math:`(\phi_x, \phi_y, \phi_z)` of the field.""" self.phase_shift = phase_shift - self._build_grid() + self._mark_grid_dirty() return self - def with_cell_size(self: Tpms, cell_size: float | Sequence[float]) -> Tpms: + def with_cell_size(self: Self, cell_size: float | Sequence[float]) -> Self: """Set the unit-cell dimensions (scalar applies to all 3 axes).""" self._init_cell_parameters(cell_size, self.repeat_cell) - self._build_grid() + self._mark_grid_dirty() return self - def with_repeat_cell(self: Tpms, repeat_cell: int | Sequence[int]) -> Tpms: + def with_repeat_cell(self: Self, repeat_cell: int | Sequence[int]) -> Self: """Set the number of cell repetitions along each axis.""" self._init_cell_parameters(self.cell_size, repeat_cell) - self._build_grid() + self._mark_grid_dirty() return self - def with_resolution(self: Tpms, resolution: int) -> Tpms: + def with_resolution(self: Self, resolution: int) -> Self: """Set the grid resolution per unit cell used to evaluate the TPMS field.""" self.resolution = resolution - self._build_grid() + self._mark_grid_dirty() + return self + + def with_surface_function(self: Self, surface_function: Field) -> Self: + """Set the TPMS implicit function (e.g. ``schwarz_p``, ``neovius``).""" + self.surface_function = surface_function + self._mark_grid_dirty() + return self + + def with_center(self: Self, center: Vector3DType) -> Self: + """Set the shape center. + + Applied at the end of :meth:`generate_cad` / :meth:`generate_surface_mesh`, + so the cached grid does not need to be rebuilt. + """ + self._center = center + return self + + def with_orientation(self: Self, orientation: Vector3DType | Rotation) -> Self: + """Set the shape orientation (Euler ZXZ degrees or :class:`Rotation`). + + Applied at the end of :meth:`generate_cad` / :meth:`generate_surface_mesh`, + so the cached grid does not need to be rebuilt. + """ + self._orientation = ( + orientation + if isinstance(orientation, Rotation) + else Rotation.from_euler("ZXZ", orientation, degrees=True) + ) return self - def with_density(self: Tpms, density: float) -> Tpms: + def _mark_grid_dirty(self: Tpms) -> None: + """Defer the next grid rebuild until a terminal accessor reads it.""" + self._grid_dirty = True + self._invalidate_part_caches() + + def with_density(self: Self, density: float) -> Self: """Set the target density (0, 1]. Clears any previously set offset; the offset that yields this density @@ -340,6 +400,7 @@ def _compute_offset_to_fit_density( err_msg = f"density must be between 0 and 1. Given: {self.density}" raise ValueError(err_msg) + self._ensure_grid() part = "skeletal" if "skeletal" in part_type else part_type if self.density == 1.0: self.offset = ( @@ -531,17 +592,17 @@ def _compute_tpms_field(self: Tpms) -> None: x, y, z = np.meshgrid(*linspaces) - self.grid = self._create_grid(x, y, z) + self._grid = self._create_grid(x, y, z) k_x, k_y, k_z = 2.0 * np.pi / self.cell_size - x, y, z = self.grid["coords"].T + x, y, z = self._grid["coords"].T tpms_field = self.surface_function( k_x * (x + self.phase_shift[0]), k_y * (y + self.phase_shift[1]), k_z * (z + self.phase_shift[2]), ) - self.grid["surface"] = tpms_field.ravel(order="F") + self._grid["surface"] = tpms_field.ravel(order="F") def _finalize_frep( self: Tpms, @@ -595,6 +656,7 @@ def as_sheet(self: Tpms, thickness: float | None = None) -> Shape: from .implicit_ops import shell from .shape import Shape + self._ensure_grid() if thickness is not None: t: float | npt.NDArray | Field = float(thickness) elif self._offset_func is not None: @@ -635,6 +697,7 @@ def as_upper_skeletal(self: Tpms) -> Shape: """ from .implicit_ops import from_field + self._ensure_grid() f = self._func h = self._half_offset_field() if callable(h): @@ -653,6 +716,7 @@ def as_lower_skeletal(self: Tpms) -> Shape: """F-rep Shape for the *lower* skeletal: ``{p : f(p) < -offset/2}``.""" from .implicit_ops import from_field + self._ensure_grid() f = self._func h = self._half_offset_field() if callable(h): @@ -676,12 +740,14 @@ def as_surface(self: Tpms) -> Shape: """ from .implicit_ops import from_field + self._ensure_grid() return from_field(func=self._func, bounds=self._bounds) def _cell_box(self: Tpms) -> Shape: """SDF Shape of this TPMS' cell (cell_size × repeat_cell, centered origin).""" from .implicit_ops import box + self._ensure_grid() dims = tuple(float(d) for d in (self.cell_size * self.repeat_cell)) return box(dims=dims, center=(0.0, 0.0, 0.0)) @@ -1016,6 +1082,7 @@ def generate_cad( ) raise ValueError(err_msg) + self._ensure_grid() if type_part == "surface": if self.offset != 0.0: logging.warning("offset is ignored for 'surface' part") @@ -1155,6 +1222,7 @@ def generate_surface_mesh( ) raise ValueError(err_msg) + self._ensure_grid() if self.density == 1.0: envelope_mesh = self._envelope_mesh_at_full_density() envelope_mesh = rotate( @@ -1230,6 +1298,7 @@ def generate_volume_mesh( ) raise ValueError(err_msg) + self._ensure_grid() if self.density is not None: self._compute_offset_to_fit_density( part_type=type_part, @@ -1255,15 +1324,15 @@ class CylindricalTpms(Tpms): def __init__( self: CylindricalTpms, - radius: float, - surface_function: Field, + radius: float = 1.0, + surface_function: Field = _default_surface_function, *, center: Vector3DType = (0, 0, 0), orientation: Vector3DType = (0, 0, 0), ) -> None: r"""Cylindrical TPMS geometry. - ``radius`` and ``surface_function`` are required positional. Configure + All parameters have defaults (``radius=1.0``, gyroid). Configure cell size / repeat cell / resolution / offset / density via chained ``with_*`` setters. @@ -1457,15 +1526,15 @@ class SphericalTpms(Tpms): def __init__( self: SphericalTpms, - radius: float, - surface_function: Field, + radius: float = 1.0, + surface_function: Field = _default_surface_function, *, center: Vector3DType = (0, 0, 0), orientation: Vector3DType = (0, 0, 0), ) -> None: r"""Spherical TPMS geometry. - ``radius`` and ``surface_function`` are required positional. Configure + All parameters have defaults (``radius=1.0``, gyroid). Configure cell size / repeat cell / resolution / offset / density via chained ``with_*`` setters. @@ -1694,22 +1763,37 @@ class Sweep(Tpms): _DEFAULT_N_CURVE_SAMPLES = 200 + @staticmethod + def _default_helix_curve() -> npt.NDArray[np.float64]: + """Default Sweep curve: a 2-turn helix of radius 0.3 along z in [-0.5, 0.5].""" + n = 50 + t = np.linspace(0.0, 1.0, n) + return np.column_stack( + [ + 0.3 * np.cos(2.0 * np.pi * 2.0 * t), + 0.3 * np.sin(2.0 * np.pi * 2.0 * t), + t - 0.5, + ], + ) + def __init__( self: Sweep, curve_points: npt.NDArray[np.float64] - | Callable[[float], npt.NDArray[np.float64]], - surface_function: Field, - radial_max: float, + | Callable[[float], npt.NDArray[np.float64]] + | None = None, + surface_function: Field = _default_surface_function, + radial_max: float = 0.3, *, center: Vector3DType = (0, 0, 0), orientation: Vector3DType = (0, 0, 0), ) -> None: r"""Build a TPMS swept along a curve. - ``curve_points``, ``surface_function`` and ``radial_max`` are required - positional. Configure cell size / repeat cell / resolution / offset / - density / phase shift / seed normal / curve sample count via chained - ``with_*`` setters. + All parameters have defaults: ``curve_points`` defaults to a 2-turn + helix of radius 0.3, ``surface_function`` to gyroid, ``radial_max`` to + 0.3. Configure everything (curve, tube radius, cell size / repeat + cell / resolution / offset / density / phase shift / seed normal / + curve sample count) via chained ``with_*`` setters. :param curve_points: either an ``(M, 3)`` array of polyline samples or a callable ``t in [0, 1] -> (3,)``. Callables are sampled @@ -1720,13 +1804,18 @@ def __init__( :param center: center of the geometry :param orientation: orientation of the geometry """ - self._curve_points_input = curve_points + self._curve_points_input = ( + curve_points if curve_points is not None else self._default_helix_curve() + ) self._n_curve_samples = self._DEFAULT_N_CURVE_SAMPLES self._seed_normal: Sequence[float] | None = None self.radial_max = float(radial_max) + # Build the parallel-transport frames + arc-length parametrisation + # BEFORE ``super().__init__`` so that any future grid build (lazily + # triggered via ``self.grid``) finds the curve state ready, even if + # a future change to ``Tpms.__init__`` accesses the grid eagerly. self._discretise_curve() - # Build the parallel-transport frames + arc-length parametrisation. self._build_curve_frames(seed_normal=self._seed_normal) # Cell parameters now live in (s, r, θ) parametric space. @@ -1753,19 +1842,38 @@ def _discretise_curve(self: Sweep) -> None: raise ValueError(err_msg) self.curve = curve - def with_seed_normal(self: Sweep, seed_normal: Sequence[float]) -> Sweep: + def with_curve_points( + self: Self, + curve_points: npt.NDArray[np.float64] + | Callable[[float], npt.NDArray[np.float64]], + ) -> Self: + """Set the sweep curve (array of polyline samples or a callable ``t->point``).""" + self._curve_points_input = curve_points + self._discretise_curve() + self._build_curve_frames(seed_normal=self._seed_normal) + self._mark_grid_dirty() + return self + + def with_radial_max(self: Self, radial_max: float) -> Self: + """Set the outer tube radius around the curve.""" + self.radial_max = float(radial_max) + self._build_curve_frames(seed_normal=self._seed_normal) + self._mark_grid_dirty() + return self + + def with_seed_normal(self: Self, seed_normal: Sequence[float]) -> Self: """Set the initial normal direction for parallel transport along the curve.""" self._seed_normal = seed_normal self._build_curve_frames(seed_normal=self._seed_normal) - self._build_grid() + self._mark_grid_dirty() return self - def with_n_curve_samples(self: Sweep, n_curve_samples: int) -> Sweep: + def with_n_curve_samples(self: Self, n_curve_samples: int) -> Self: """Set the number of polyline samples (only used when curve was given as a callable).""" self._n_curve_samples = int(n_curve_samples) self._discretise_curve() self._build_curve_frames(seed_normal=self._seed_normal) - self._build_grid() + self._mark_grid_dirty() return self # -- Curve preprocessing ----------------------------------------------- @@ -2075,16 +2183,17 @@ def lower_skeletal(self: Infill) -> pv.PolyData: def __init__( self: Infill, - obj: pv.PolyData, - surface_function: Field, + obj: pv.PolyData | None = None, + surface_function: Field = _default_surface_function, *, center: Vector3DType = (0, 0, 0), orientation: Vector3DType = (0, 0, 0), ) -> None: r"""Initialize the Infill TPMS. - ``obj`` and ``surface_function`` are required positional. Configure - cell size (or repeat_cell — they auto-derive from each other), offset / + All parameters have defaults: ``obj`` defaults to a :class:`pyvista.Sphere` + envelope, ``surface_function`` to gyroid. Configure cell size (or + repeat_cell — they auto-derive from each other), envelope, offset / density, resolution, phase shift via chained ``with_*`` setters. :param obj: envelope mesh in which the infill is generated. Normals @@ -2094,6 +2203,8 @@ def __init__( :param center: shape center :param orientation: shape orientation """ + if obj is None: + obj = pv.Sphere() # Capture the original volume *before* re-orienting normals — for # non-manifold inputs (e.g. pyvista's caps-less Cylinder, the # Stanford bunny) ``compute_normals(auto_orient_normals=True)`` flips @@ -2113,10 +2224,21 @@ def __init__( orientation=orientation, ) + def with_obj(self: Self, obj: pv.PolyData) -> Self: + """Set the envelope mesh; clears the cached grid so it is rebuilt at next access.""" + self._obj_volume = abs(obj.volume) + self.obj = obj.compute_normals( + auto_orient_normals=True, + point_normals=True, + cell_normals=True, + ) + self._mark_grid_dirty() + return self + def with_cell_size( - self: Infill, + self: Self, cell_size: float | Sequence[float], - ) -> Infill: + ) -> Self: """Set the unit-cell dimensions; ``repeat_cell`` is derived from the envelope.""" bounds = np.array(self.obj.bounds) margin_factor = 1.001 @@ -2129,20 +2251,20 @@ def with_cell_size( raise ValueError(err_msg) repeat_cell = np.round(obj_dim / cell_size).astype(int) self._init_cell_parameters(cell_size, repeat_cell) - self._build_grid() + self._mark_grid_dirty() return self def with_repeat_cell( - self: Infill, + self: Self, repeat_cell: int | Sequence[int], - ) -> Infill: + ) -> Self: """Set ``repeat_cell``; ``cell_size`` is derived from the envelope.""" bounds = np.array(self.obj.bounds) margin_factor = 1.001 obj_dim = margin_factor * (bounds[1::2] - bounds[::2]) cell_size = obj_dim / np.asarray(repeat_cell) self._init_cell_parameters(cell_size, repeat_cell) - self._build_grid() + self._mark_grid_dirty() return self def _create_grid( @@ -2247,18 +2369,19 @@ class GradedInfill(Infill): def __init__( self: GradedInfill, - obj: pv.PolyData, - surface_function: Field, + obj: pv.PolyData | None = None, + surface_function: Field = _default_surface_function, *, center: Vector3DType = (0, 0, 0), orientation: Vector3DType = (0, 0, 0), ) -> None: """Build a graded TPMS infill. - ``obj`` and ``surface_function`` are required positional. Configure - the gradation profile via :meth:`with_gradation`, and the cell layout / - resolution / phase shift via the chained ``with_*`` setters inherited - from :class:`Infill` / :class:`Tpms`. + All parameters have defaults: ``obj`` defaults to :class:`pyvista.Sphere`, + ``surface_function`` to gyroid. Configure the gradation profile via + :meth:`with_gradation`, and the cell layout / resolution / phase shift / + envelope via the chained ``with_*`` setters inherited from :class:`Infill` + / :class:`Tpms`. Defaults: ``offset_skin=0.6``, ``offset_core=0.0``, ``transition=0.5``, ``smoothness=0.2`` — a graded shell (dense skin, hollow core). @@ -2287,13 +2410,13 @@ def __init__( ) def with_gradation( - self: GradedInfill, + self: Self, *, offset_skin: float = 0.6, offset_core: float = 0.0, transition: float = 0.5, smoothness: float = 0.2, - ) -> GradedInfill: + ) -> Self: """Set the gradation profile and re-apply the graded offset. :param offset_skin: TPMS thickness at the envelope surface diff --git a/pyproject.toml b/pyproject.toml index 25d7a3bd..37694721 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "meshio", "scipy", "autograd", + "typing_extensions>=4.0; python_version<'3.11'", ] [project.optional-dependencies]