From 6288db84405e32cb9545eacdf37be9b76c21147b Mon Sep 17 00:00:00 2001 From: Wojtek Potrzebowski Date: Thu, 11 Dec 2025 20:42:02 +0100 Subject: [PATCH 1/7] Visualization code moved to models --- sasmodels/__init__.py | 3 + sasmodels/modelinfo.py | 3 + sasmodels/models/adsorbed_layer.py | 1 + sasmodels/models/barbell.py | 1 + sasmodels/models/bcc_paracrystal.py | 1 + sasmodels/models/be_polyelectrolyte.py | 1 + sasmodels/models/binary_hard_sphere.py | 1 + sasmodels/models/broad_peak.py | 1 + sasmodels/models/capped_cylinder.py | 1 + sasmodels/models/core_multi_shell.py | 1 + sasmodels/models/core_shell_bicelle.py | 1 + .../models/core_shell_bicelle_elliptical.py | 1 + ...ore_shell_bicelle_elliptical_belt_rough.py | 1 + sasmodels/models/core_shell_cylinder.py | 1 + sasmodels/models/core_shell_ellipsoid.py | 1 + sasmodels/models/core_shell_parallelepiped.py | 1 + sasmodels/models/core_shell_sphere.py | 103 ++++++++++++++++++ sasmodels/models/correlation_length.py | 1 + sasmodels/models/cylinder.py | 75 +++++++++++++ sasmodels/models/dab.py | 1 + sasmodels/models/ellipsoid.py | 1 + sasmodels/models/elliptical_cylinder.py | 1 + sasmodels/models/fcc_paracrystal.py | 1 + sasmodels/models/flexible_cylinder.py | 1 + .../models/flexible_cylinder_elliptical.py | 1 + sasmodels/models/fractal.py | 1 + sasmodels/models/fractal_core_shell.py | 1 + sasmodels/models/fuzzy_sphere.py | 1 + sasmodels/models/gauss_lorentz_gel.py | 1 + sasmodels/models/gaussian_peak.py | 1 + sasmodels/models/gel_fit.py | 1 + sasmodels/models/guinier.py | 1 + sasmodels/models/guinier_porod.py | 1 + sasmodels/models/hardsphere.py | 1 + sasmodels/models/hayter_msa.py | 1 + sasmodels/models/hollow_cylinder.py | 1 + sasmodels/models/hollow_rectangular_prism.py | 1 + .../hollow_rectangular_prism_thin_walls.py | 1 + sasmodels/models/lamellar.py | 1 + sasmodels/models/lamellar_hg.py | 1 + sasmodels/models/lamellar_hg_stack_caille.py | 1 + sasmodels/models/lamellar_stack_caille.py | 1 + .../models/lamellar_stack_paracrystal.py | 1 + sasmodels/models/line.py | 1 + sasmodels/models/linear_pearls.py | 1 + sasmodels/models/lorentz.py | 1 + sasmodels/models/mass_fractal.py | 1 + sasmodels/models/mass_surface_fractal.py | 1 + sasmodels/models/micromagnetic_FF_3D.py | 1 + sasmodels/models/mono_gauss_coil.py | 1 + sasmodels/models/multilayer_vesicle.py | 1 + sasmodels/models/onion.py | 1 + sasmodels/models/parallelepiped.py | 1 + sasmodels/models/peak_lorentz.py | 1 + sasmodels/models/pearl_necklace.py | 1 + sasmodels/models/poly_gauss_coil.py | 1 + sasmodels/models/polymer_excl_volume.py | 1 + sasmodels/models/polymer_micelle.py | 1 + sasmodels/models/porod.py | 1 + sasmodels/models/power_law.py | 1 + sasmodels/models/pringle.py | 1 + sasmodels/models/raspberry.py | 1 + sasmodels/models/rectangular_prism.py | 1 + sasmodels/models/rpa.py | 1 + sasmodels/models/sc_paracrystal.py | 1 + sasmodels/models/sphere.py | 58 ++++++++++ sasmodels/models/spherical_sld.py | 1 + sasmodels/models/spinodal.py | 1 + sasmodels/models/squarewell.py | 1 + sasmodels/models/stacked_disks.py | 1 + sasmodels/models/star_polymer.py | 1 + sasmodels/models/stickyhardsphere.py | 1 + sasmodels/models/superball.py | 1 + sasmodels/models/surface_fractal.py | 1 + sasmodels/models/teubner_strey.py | 1 + sasmodels/models/triaxial_ellipsoid.py | 1 + sasmodels/models/two_lorentzian.py | 1 + sasmodels/models/two_power_law.py | 1 + sasmodels/models/two_yukawa.py | 1 + sasmodels/models/unified_power_Rg.py | 1 + sasmodels/models/vesicle.py | 1 + 81 files changed, 318 insertions(+) diff --git a/sasmodels/__init__.py b/sasmodels/__init__.py index 01a91f73e..475c0b847 100644 --- a/sasmodels/__init__.py +++ b/sasmodels/__init__.py @@ -19,6 +19,9 @@ except ImportError: __version__ = "0.0.0.dev" +# Shape visualization API +from .shape_visualizer import generate_shape_image, SASModelsShapeDetector + def data_files(): """ diff --git a/sasmodels/modelinfo.py b/sasmodels/modelinfo.py index 63ec37e29..07860b132 100644 --- a/sasmodels/modelinfo.py +++ b/sasmodels/modelinfo.py @@ -944,6 +944,7 @@ def make_model_info(kernel_module): # TODO: find Fq by inspection info.radius_effective_modes = getattr(kernel_module, 'radius_effective_modes', None) info.have_Fq = getattr(kernel_module, 'have_Fq', False) + info.has_shape_visualization = getattr(kernel_module, 'has_shape_visualization', False) info.profile_axes = getattr(kernel_module, 'profile_axes', ['x', 'y']) # Note: custom.load_custom_kernel_module assumes the C sources are defined # by this attribute. @@ -1097,6 +1098,8 @@ class ModelInfo: #: True if the model defines an Fq function with signature #: ``void Fq(double q, double *F1, double *F2, ...)`` have_Fq = False + #: True if the model supports 3D shape visualization + has_shape_visualization = False # type: bool #: List of options for computing the effective radius of the shape, #: or None if the model is not usable as a form factor model. radius_effective_modes = None # type: list[str] diff --git a/sasmodels/models/adsorbed_layer.py b/sasmodels/models/adsorbed_layer.py index bf9a3b68e..50df36a54 100644 --- a/sasmodels/models/adsorbed_layer.py +++ b/sasmodels/models/adsorbed_layer.py @@ -95,6 +95,7 @@ def Iq(q, second_moment, adsorbed_amount, density_shell, radius, return inten Iq.vectorized = True # Iq accepts an array of q values +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" # only care about the value of second_moment: diff --git a/sasmodels/models/barbell.py b/sasmodels/models/barbell.py index d7f3d3074..44c5520d2 100644 --- a/sasmodels/models/barbell.py +++ b/sasmodels/models/barbell.py @@ -118,6 +118,7 @@ # pylint: enable=bad-whitespace, line-too-long source = ["lib/polevl.c", "lib/sas_J1.c", "lib/gauss76.c", "barbell.c"] +has_shape_visualization = False valid = "radius_bell >= radius" have_Fq = True radius_effective_modes = [ diff --git a/sasmodels/models/bcc_paracrystal.py b/sasmodels/models/bcc_paracrystal.py index 515eefc55..817c723a0 100644 --- a/sasmodels/models/bcc_paracrystal.py +++ b/sasmodels/models/bcc_paracrystal.py @@ -179,6 +179,7 @@ category = "shape:paracrystal" #note - calculation requires double precision +has_shape_visualization = False single = False # pylint: disable=bad-whitespace, line-too-long diff --git a/sasmodels/models/be_polyelectrolyte.py b/sasmodels/models/be_polyelectrolyte.py index e41e1a0a3..025ed06a6 100644 --- a/sasmodels/models/be_polyelectrolyte.py +++ b/sasmodels/models/be_polyelectrolyte.py @@ -166,6 +166,7 @@ def Iq(q, Iq.vectorized = True # Iq accepts an array of q values +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" # TODO: review random be_polyelectrolyte model generation diff --git a/sasmodels/models/binary_hard_sphere.py b/sasmodels/models/binary_hard_sphere.py index 1b34fa369..6ee4e5c98 100644 --- a/sasmodels/models/binary_hard_sphere.py +++ b/sasmodels/models/binary_hard_sphere.py @@ -77,6 +77,7 @@ from numpy import inf category = "shape:sphere" +has_shape_visualization = False single = False # double precision only! name = "binary_hard_sphere" diff --git a/sasmodels/models/broad_peak.py b/sasmodels/models/broad_peak.py index ec144765d..c9220a01f 100644 --- a/sasmodels/models/broad_peak.py +++ b/sasmodels/models/broad_peak.py @@ -100,6 +100,7 @@ def Iq(q, return inten Iq.vectorized = True # Iq accepts an array of q values +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" pars = dict( diff --git a/sasmodels/models/capped_cylinder.py b/sasmodels/models/capped_cylinder.py index a0a3e4989..ca6a5312e 100644 --- a/sasmodels/models/capped_cylinder.py +++ b/sasmodels/models/capped_cylinder.py @@ -144,6 +144,7 @@ "equivalent cylinder excluded volume", "equivalent volume sphere", "radius", "half length", "half total length", ] +has_shape_visualization = True def random(): """Return a random parameter set for the model.""" diff --git a/sasmodels/models/core_multi_shell.py b/sasmodels/models/core_multi_shell.py index 77d20a0e7..de7c6665c 100644 --- a/sasmodels/models/core_multi_shell.py +++ b/sasmodels/models/core_multi_shell.py @@ -99,6 +99,7 @@ ] source = ["lib/sas_3j1x_x.c", "core_multi_shell.c"] +has_shape_visualization = False have_Fq = True radius_effective_modes = ["outer radius", "core radius"] diff --git a/sasmodels/models/core_shell_bicelle.py b/sasmodels/models/core_shell_bicelle.py index bca302290..860da28e6 100644 --- a/sasmodels/models/core_shell_bicelle.py +++ b/sasmodels/models/core_shell_bicelle.py @@ -154,6 +154,7 @@ source = ["lib/sas_Si.c", "lib/polevl.c", "lib/sas_J1.c", "lib/gauss76.c", "core_shell_bicelle.c"] +has_shape_visualization = False have_Fq = True radius_effective_modes = [ "excluded volume", "equivalent volume sphere", "outer rim radius", diff --git a/sasmodels/models/core_shell_bicelle_elliptical.py b/sasmodels/models/core_shell_bicelle_elliptical.py index ad780d864..f593bd563 100644 --- a/sasmodels/models/core_shell_bicelle_elliptical.py +++ b/sasmodels/models/core_shell_bicelle_elliptical.py @@ -152,6 +152,7 @@ "outer rim average radius", "outer rim min radius", "outer max radius", "half outer thickness", "half diagonal", ] +has_shape_visualization = True def random(): """Return a random parameter set for the model.""" diff --git a/sasmodels/models/core_shell_bicelle_elliptical_belt_rough.py b/sasmodels/models/core_shell_bicelle_elliptical_belt_rough.py index 002e88790..228919a1d 100644 --- a/sasmodels/models/core_shell_bicelle_elliptical_belt_rough.py +++ b/sasmodels/models/core_shell_bicelle_elliptical_belt_rough.py @@ -159,6 +159,7 @@ source = ["lib/sas_Si.c", "lib/polevl.c", "lib/sas_J1.c", "lib/gauss76.c", "core_shell_bicelle_elliptical_belt_rough.c"] +has_shape_visualization = False have_Fq = True radius_effective_modes = [ "equivalent cylinder excluded volume", "equivalent volume sphere", diff --git a/sasmodels/models/core_shell_cylinder.py b/sasmodels/models/core_shell_cylinder.py index 84d08cc62..8c1b31e1f 100644 --- a/sasmodels/models/core_shell_cylinder.py +++ b/sasmodels/models/core_shell_cylinder.py @@ -139,6 +139,7 @@ "excluded volume", "equivalent volume sphere", "outer radius", "half outer length", "half min outer dimension", "half max outer dimension", "half outer diagonal", ] +has_shape_visualization = True def random(): """Return a random parameter set for the model.""" diff --git a/sasmodels/models/core_shell_ellipsoid.py b/sasmodels/models/core_shell_ellipsoid.py index 7f0415a95..75d19fc93 100644 --- a/sasmodels/models/core_shell_ellipsoid.py +++ b/sasmodels/models/core_shell_ellipsoid.py @@ -150,6 +150,7 @@ # pylint: enable=bad-whitespace, line-too-long source = ["lib/sas_3j1x_x.c", "lib/gauss76.c", "core_shell_ellipsoid.c"] +has_shape_visualization = False have_Fq = True radius_effective_modes = [ "average outer curvature", "equivalent volume sphere", diff --git a/sasmodels/models/core_shell_parallelepiped.py b/sasmodels/models/core_shell_parallelepiped.py index 22a59ec76..b6cb0b768 100644 --- a/sasmodels/models/core_shell_parallelepiped.py +++ b/sasmodels/models/core_shell_parallelepiped.py @@ -225,6 +225,7 @@ ] source = ["lib/gauss76.c", "core_shell_parallelepiped.c"] +has_shape_visualization = False have_Fq = True radius_effective_modes = [ "equivalent cylinder excluded volume", diff --git a/sasmodels/models/core_shell_sphere.py b/sasmodels/models/core_shell_sphere.py index 2913bdd8a..5fc23d2eb 100644 --- a/sasmodels/models/core_shell_sphere.py +++ b/sasmodels/models/core_shell_sphere.py @@ -85,6 +85,109 @@ source = ["lib/sas_3j1x_x.c", "lib/core_shell.c", "core_shell_sphere.c"] have_Fq = True radius_effective_modes = ["outer radius", "core radius"] +has_shape_visualization = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for core_shell_sphere visualization.""" + import numpy as np + radius = params.get('radius', 60) + thickness = params.get('thickness', 10) + + phi = np.linspace(0, np.pi, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + # Core + x_core = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y_core = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z_core = radius * np.cos(phi_mesh) + + # Shell + shell_radius = radius + thickness + x_shell = shell_radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y_shell = shell_radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z_shell = shell_radius * np.cos(phi_mesh) + + return { + 'core': (x_core, y_core, z_core), + 'shell': (x_shell, y_shell, z_shell) + } + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the core_shell_sphere.""" + import numpy as np + radius = params.get('radius', 60) + thickness = params.get('thickness', 10) + shell_radius = radius + thickness + + # Create circles for core and shell + theta = np.linspace(0, 2*np.pi, 100) + + # Core circles + core_x = radius * np.cos(theta) + core_y = radius * np.sin(theta) + + # Shell circles + shell_x = shell_radius * np.cos(theta) + shell_y = shell_radius * np.sin(theta) + + # XY plane (top view) + ax_xy.plot(shell_x, shell_y, 'r-', linewidth=2, label='Shell') + ax_xy.fill(shell_x, shell_y, 'lightcoral', alpha=0.3) + ax_xy.plot(core_x, core_y, 'b-', linewidth=2, label='Core') + ax_xy.fill(core_x, core_y, 'lightblue', alpha=0.5) + + ax_xy.set_xlim(-shell_radius*1.2, shell_radius*1.2) + ax_xy.set_ylim(-shell_radius*1.2, shell_radius*1.2) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ax_xy.legend() + + # XZ plane (side view) + ax_xz.plot(shell_x, shell_y, 'r-', linewidth=2, label='Shell') + ax_xz.fill(shell_x, shell_y, 'lightcoral', alpha=0.3) + ax_xz.plot(core_x, core_y, 'b-', linewidth=2, label='Core') + ax_xz.fill(core_x, core_y, 'lightblue', alpha=0.5) + + ax_xz.set_xlim(-shell_radius*1.2, shell_radius*1.2) + ax_xz.set_ylim(-shell_radius*1.2, shell_radius*1.2) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section (Side View)') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + ax_xz.legend() + + # YZ plane (front view) + ax_yz.plot(shell_x, shell_y, 'g-', linewidth=2, label='Shell') + ax_yz.fill(shell_x, shell_y, 'lightgreen', alpha=0.3) + ax_yz.plot(core_x, core_y, 'orange', linewidth=2, label='Core') + ax_yz.fill(core_x, core_y, 'moccasin', alpha=0.5) + + ax_yz.set_xlim(-shell_radius*1.2, shell_radius*1.2) + ax_yz.set_ylim(-shell_radius*1.2, shell_radius*1.2) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section (Front View)') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) + ax_yz.legend() + + # Add dimension annotations + ax_xz.annotate('', xy=(-radius, 0), xytext=(radius, 0), + arrowprops=dict(arrowstyle='<->', color='blue')) + ax_xz.text(0, -radius*0.3, f'Core R = {radius:.0f} Å', ha='center', fontsize=10, color='blue') + + ax_xz.annotate('', xy=(-shell_radius, -radius*0.7), xytext=(shell_radius, -radius*0.7), + arrowprops=dict(arrowstyle='<->', color='red')) + ax_xz.text(0, -radius*0.9, f'Shell R = {shell_radius:.0f} Å', ha='center', fontsize=10, color='red') + + ax_xz.annotate('', xy=(radius*0.7, 0), xytext=(shell_radius*0.7, 0), + arrowprops=dict(arrowstyle='<->', color='black')) + ax_xz.text(radius*0.85, radius*0.2, f't = {thickness:.0f} Å', ha='center', fontsize=10, rotation=90) def random(): """Return a random parameter set for the model.""" diff --git a/sasmodels/models/correlation_length.py b/sasmodels/models/correlation_length.py index ae3e24252..888a3dc7f 100644 --- a/sasmodels/models/correlation_length.py +++ b/sasmodels/models/correlation_length.py @@ -70,6 +70,7 @@ def Iq(q, lorentz_scale, porod_scale, cor_length, porod_exp, lorentz_exp): return inten Iq.vectorized = True +has_shape_visualization = False tests = [[{}, 0.001, 1009.98], [{}, 0.150141, 0.175645], [{}, 0.442528, 0.0213957]] diff --git a/sasmodels/models/cylinder.py b/sasmodels/models/cylinder.py index 37a9d458a..afad6a7e9 100644 --- a/sasmodels/models/cylinder.py +++ b/sasmodels/models/cylinder.py @@ -149,6 +149,81 @@ "excluded volume", "equivalent volume sphere", "radius", "half length", "half min dimension", "half max dimension", "half diagonal", ] +has_shape_visualization = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for cylinder visualization.""" + import numpy as np + radius = params.get('radius', 20) + length = params.get('length', 400) + + # Create cylinder + theta = np.linspace(0, 2*np.pi, resolution) + z = np.linspace(-length/2, length/2, resolution//2) + theta_mesh, z_mesh = np.meshgrid(theta, z) + + x = radius * np.cos(theta_mesh) + y = radius * np.sin(theta_mesh) + + # Create end caps + r_cap = np.linspace(0, radius, resolution//4) + theta_cap = np.linspace(0, 2*np.pi, resolution) + r_cap_mesh, theta_cap_mesh = np.meshgrid(r_cap, theta_cap) + + x_cap = r_cap_mesh * np.cos(theta_cap_mesh) + y_cap = r_cap_mesh * np.sin(theta_cap_mesh) + z_cap_top = np.full_like(x_cap, length/2) + z_cap_bottom = np.full_like(x_cap, -length/2) + + return { + 'cylinder': (x, y, z_mesh), + 'cap_top': (x_cap, y_cap, z_cap_top), + 'cap_bottom': (x_cap, y_cap, z_cap_bottom) + } + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the cylinder.""" + import numpy as np + radius = params.get('radius', 20) + length = params.get('length', 400) + + # XY plane (top view) - circle + theta = np.linspace(0, 2*np.pi, 100) + circle_x = radius * np.cos(theta) + circle_y = radius * np.sin(theta) + + ax_xy.plot(circle_x, circle_y, 'b-', linewidth=2, label='Cylinder') + ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.3) + ax_xy.set_xlim(-radius*1.5, radius*1.5) + ax_xy.set_ylim(-radius*1.5, radius*1.5) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # XZ plane (side view) - rectangle + rect_x = [-length/2, -length/2, length/2, length/2, -length/2] + rect_z = [-radius, radius, radius, -radius, -radius] + + ax_xz.plot(rect_x, rect_z, 'r-', linewidth=2, label='Cylinder') + ax_xz.fill(rect_x, rect_z, 'lightcoral', alpha=0.3) + ax_xz.set_xlim(-length/2*1.2, length/2*1.2) + ax_xz.set_ylim(-radius*1.5, radius*1.5) + ax_xz.set_xlabel('Z (Å)') + ax_xz.set_ylabel('X (Å)') + ax_xz.set_title('XZ Cross-section (Side View)') + ax_xz.grid(True, alpha=0.3) + + # YZ plane (front view) - rectangle + ax_yz.plot(rect_x, rect_z, 'g-', linewidth=2, label='Cylinder') + ax_yz.fill(rect_x, rect_z, 'lightgreen', alpha=0.3) + ax_yz.set_xlim(-length/2*1.2, length/2*1.2) + ax_yz.set_ylim(-radius*1.5, radius*1.5) + ax_yz.set_xlabel('Z (Å)') + ax_yz.set_ylabel('Y (Å)') + ax_yz.set_title('YZ Cross-section (Front View)') + ax_yz.grid(True, alpha=0.3) def random(): """Return a random parameter set for the model.""" diff --git a/sasmodels/models/dab.py b/sasmodels/models/dab.py index 295116bf1..541e42451 100644 --- a/sasmodels/models/dab.py +++ b/sasmodels/models/dab.py @@ -71,6 +71,7 @@ source = ["dab.c"] +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" pars = dict( diff --git a/sasmodels/models/ellipsoid.py b/sasmodels/models/ellipsoid.py index 00969bcd2..07e8cb677 100644 --- a/sasmodels/models/ellipsoid.py +++ b/sasmodels/models/ellipsoid.py @@ -158,6 +158,7 @@ radius_effective_modes = [ "average curvature", "equivalent volume sphere", "min radius", "max radius", ] +has_shape_visualization = True def random(): """Return a random parameter set for the model.""" diff --git a/sasmodels/models/elliptical_cylinder.py b/sasmodels/models/elliptical_cylinder.py index 87ec33bc5..a48357c89 100644 --- a/sasmodels/models/elliptical_cylinder.py +++ b/sasmodels/models/elliptical_cylinder.py @@ -131,6 +131,7 @@ "equivalent circular cross-section", "half length", "half min dimension", "half max dimension", "half diagonal", ] +has_shape_visualization = True def random(): """Return a random parameter set for the model.""" diff --git a/sasmodels/models/fcc_paracrystal.py b/sasmodels/models/fcc_paracrystal.py index 4c616f525..759095fe8 100644 --- a/sasmodels/models/fcc_paracrystal.py +++ b/sasmodels/models/fcc_paracrystal.py @@ -177,6 +177,7 @@ """ category = "shape:paracrystal" +has_shape_visualization = False single = False # pylint: disable=bad-whitespace, line-too-long diff --git a/sasmodels/models/flexible_cylinder.py b/sasmodels/models/flexible_cylinder.py index 9d22274b0..8e9f2c6b4 100644 --- a/sasmodels/models/flexible_cylinder.py +++ b/sasmodels/models/flexible_cylinder.py @@ -98,6 +98,7 @@ """ category = "shape:cylinder" +has_shape_visualization = False single = False # double precision only! # pylint: disable=bad-whitespace, line-too-long diff --git a/sasmodels/models/flexible_cylinder_elliptical.py b/sasmodels/models/flexible_cylinder_elliptical.py index ae72622de..3bdcd30e8 100644 --- a/sasmodels/models/flexible_cylinder_elliptical.py +++ b/sasmodels/models/flexible_cylinder_elliptical.py @@ -119,6 +119,7 @@ source = ["lib/polevl.c", "lib/sas_J1.c", "lib/gauss76.c", "lib/wrc_cyl.c", "flexible_cylinder_elliptical.c"] +has_shape_visualization = True def random(): """Return a random parameter set for the model.""" diff --git a/sasmodels/models/fractal.py b/sasmodels/models/fractal.py index c3dd60d8d..073cf9b41 100644 --- a/sasmodels/models/fractal.py +++ b/sasmodels/models/fractal.py @@ -96,6 +96,7 @@ # pylint: enable=bad-whitespace, line-too-long source = ["lib/sas_3j1x_x.c", "lib/sas_gamma.c", "lib/fractal_sq.c", "fractal.c"] +has_shape_visualization = False valid = "fractal_dim >= 0.0" def random(): diff --git a/sasmodels/models/fractal_core_shell.py b/sasmodels/models/fractal_core_shell.py index 383ce590e..6de7e605e 100644 --- a/sasmodels/models/fractal_core_shell.py +++ b/sasmodels/models/fractal_core_shell.py @@ -96,6 +96,7 @@ source = ["lib/sas_3j1x_x.c", "lib/sas_gamma.c", "lib/core_shell.c", "lib/fractal_sq.c", "fractal_core_shell.c"] +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" outer_radius = 10**np.random.uniform(0.7, 4) diff --git a/sasmodels/models/fuzzy_sphere.py b/sasmodels/models/fuzzy_sphere.py index 85fe7616a..096a536a9 100644 --- a/sasmodels/models/fuzzy_sphere.py +++ b/sasmodels/models/fuzzy_sphere.py @@ -89,6 +89,7 @@ # pylint: enable=bad-whitespace,line-too-long source = ["lib/sas_3j1x_x.c", "fuzzy_sphere.c"] +has_shape_visualization = False have_Fq = True radius_effective_modes = ["radius", "radius + fuzziness"] diff --git a/sasmodels/models/gauss_lorentz_gel.py b/sasmodels/models/gauss_lorentz_gel.py index 9fa16c097..0937fe3f0 100644 --- a/sasmodels/models/gauss_lorentz_gel.py +++ b/sasmodels/models/gauss_lorentz_gel.py @@ -94,6 +94,7 @@ def Iq(q, Iq.vectorized = True # Iq accepts an array of q values +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" gauss_scale = 10**np.random.uniform(1, 3) diff --git a/sasmodels/models/gaussian_peak.py b/sasmodels/models/gaussian_peak.py index 1b51fb233..49d615e04 100644 --- a/sasmodels/models/gaussian_peak.py +++ b/sasmodels/models/gaussian_peak.py @@ -53,6 +53,7 @@ source = ["gaussian_peak.c"] +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" peak_pos = 10**np.random.uniform(-3, -1) diff --git a/sasmodels/models/gel_fit.py b/sasmodels/models/gel_fit.py index d829768ae..bacf97363 100644 --- a/sasmodels/models/gel_fit.py +++ b/sasmodels/models/gel_fit.py @@ -73,6 +73,7 @@ source = ["gel_fit.c"] +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" guinier_scale = 10**np.random.uniform(1, 3) diff --git a/sasmodels/models/guinier.py b/sasmodels/models/guinier.py index 4e49c41fc..e9787bf66 100644 --- a/sasmodels/models/guinier.py +++ b/sasmodels/models/guinier.py @@ -76,6 +76,7 @@ source = ["guinier.c"] +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" scale = 10**np.random.uniform(1, 4) diff --git a/sasmodels/models/guinier_porod.py b/sasmodels/models/guinier_porod.py index 19f83f1c8..3adb5a7eb 100644 --- a/sasmodels/models/guinier_porod.py +++ b/sasmodels/models/guinier_porod.py @@ -121,6 +121,7 @@ def Iq(q, rg, s, porod_exp): Iq.vectorized = True # Iq accepts an array of q values +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" rg = 10**np.random.uniform(1, 5) diff --git a/sasmodels/models/hardsphere.py b/sasmodels/models/hardsphere.py index 44aa7847a..1acdffa11 100644 --- a/sasmodels/models/hardsphere.py +++ b/sasmodels/models/hardsphere.py @@ -77,6 +77,7 @@ volfraction is the volume fraction occupied by the spheres. """ category = "structure-factor" +has_shape_visualization = False structure_factor = True single = False # TODO: check diff --git a/sasmodels/models/hayter_msa.py b/sasmodels/models/hayter_msa.py index dec730031..9fca51764 100644 --- a/sasmodels/models/hayter_msa.py +++ b/sasmodels/models/hayter_msa.py @@ -74,6 +74,7 @@ from numpy import inf category = "structure-factor" +has_shape_visualization = False structure_factor = True single = False # double precision only! diff --git a/sasmodels/models/hollow_cylinder.py b/sasmodels/models/hollow_cylinder.py index 27096f948..bb0d51e6d 100644 --- a/sasmodels/models/hollow_cylinder.py +++ b/sasmodels/models/hollow_cylinder.py @@ -106,6 +106,7 @@ "half outer min dimension", "half outer max dimension", "half outer diagonal", ] +has_shape_visualization = True def random(): """Return a random parameter set for the model.""" diff --git a/sasmodels/models/hollow_rectangular_prism.py b/sasmodels/models/hollow_rectangular_prism.py index 31b65464e..2a5c139de 100644 --- a/sasmodels/models/hollow_rectangular_prism.py +++ b/sasmodels/models/hollow_rectangular_prism.py @@ -147,6 +147,7 @@ ] source = ["lib/gauss76.c", "hollow_rectangular_prism.c"] +has_shape_visualization = False have_Fq = True radius_effective_modes = [ "equivalent cylinder excluded volume", "equivalent outer volume sphere", diff --git a/sasmodels/models/hollow_rectangular_prism_thin_walls.py b/sasmodels/models/hollow_rectangular_prism_thin_walls.py index b26283315..7b8177573 100644 --- a/sasmodels/models/hollow_rectangular_prism_thin_walls.py +++ b/sasmodels/models/hollow_rectangular_prism_thin_walls.py @@ -108,6 +108,7 @@ ] source = ["lib/gauss76.c", "hollow_rectangular_prism_thin_walls.c"] +has_shape_visualization = False have_Fq = True radius_effective_modes = [ "equivalent cylinder excluded volume", "equivalent outer volume sphere", diff --git a/sasmodels/models/lamellar.py b/sasmodels/models/lamellar.py index daf415a77..628bba77e 100644 --- a/sasmodels/models/lamellar.py +++ b/sasmodels/models/lamellar.py @@ -94,6 +94,7 @@ return 4.0e-4*M_PI*sub*sub/qsq * 2.0*sinq2*sinq2 / (thickness*qsq); """ +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" thickness = 10**np.random.uniform(1, 4) diff --git a/sasmodels/models/lamellar_hg.py b/sasmodels/models/lamellar_hg.py index 887673135..46d8880fc 100644 --- a/sasmodels/models/lamellar_hg.py +++ b/sasmodels/models/lamellar_hg.py @@ -88,6 +88,7 @@ source = ["lamellar_hg.c"] +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" thickness = 10**np.random.uniform(1, 4) diff --git a/sasmodels/models/lamellar_hg_stack_caille.py b/sasmodels/models/lamellar_hg_stack_caille.py index 6182bf9b8..2889b689a 100644 --- a/sasmodels/models/lamellar_hg_stack_caille.py +++ b/sasmodels/models/lamellar_hg_stack_caille.py @@ -98,6 +98,7 @@ """ category = "shape:lamellae" +has_shape_visualization = False single = False # TODO: check parameters = [ # [ "name", "units", default, [lower, upper], "type", diff --git a/sasmodels/models/lamellar_stack_caille.py b/sasmodels/models/lamellar_stack_caille.py index 2a14c426a..5361f2fad 100644 --- a/sasmodels/models/lamellar_stack_caille.py +++ b/sasmodels/models/lamellar_stack_caille.py @@ -92,6 +92,7 @@ """ category = "shape:lamellae" +has_shape_visualization = False single = False # TODO: check # pylint: disable=bad-whitespace, line-too-long # ["name", "units", default, [lower, upper], "type","description"], diff --git a/sasmodels/models/lamellar_stack_paracrystal.py b/sasmodels/models/lamellar_stack_paracrystal.py index 2812f67d5..ab184207a 100644 --- a/sasmodels/models/lamellar_stack_paracrystal.py +++ b/sasmodels/models/lamellar_stack_paracrystal.py @@ -113,6 +113,7 @@ """ category = "shape:lamellae" +has_shape_visualization = False single = False # ["name", "units", default, [lower, upper], "type","description"], diff --git a/sasmodels/models/line.py b/sasmodels/models/line.py index 86a74356b..f3fd47e70 100644 --- a/sasmodels/models/line.py +++ b/sasmodels/models/line.py @@ -87,6 +87,7 @@ def Iqxy(qx, qy, intercept, slope): #{ return (m*qx+b)*(m*qy+b); } #""" +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" scale = 10**np.random.uniform(0, 3) diff --git a/sasmodels/models/linear_pearls.py b/sasmodels/models/linear_pearls.py index a8df98b54..fd06741ec 100644 --- a/sasmodels/models/linear_pearls.py +++ b/sasmodels/models/linear_pearls.py @@ -66,6 +66,7 @@ ["sld_solvent", "1e-6/Ang^2", 6.3, [-inf, inf], "sld", "SLD of the solvent"], ] # pylint: enable=bad-whitespace, line-too-long +has_shape_visualization = False single = False source = ["lib/sas_3j1x_x.c", "linear_pearls.c"] diff --git a/sasmodels/models/lorentz.py b/sasmodels/models/lorentz.py index eaa884496..8d9cfeb4e 100644 --- a/sasmodels/models/lorentz.py +++ b/sasmodels/models/lorentz.py @@ -52,6 +52,7 @@ source = ["lorentz.c"] +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" pars = dict( diff --git a/sasmodels/models/mass_fractal.py b/sasmodels/models/mass_fractal.py index d21ba2305..116130497 100644 --- a/sasmodels/models/mass_fractal.py +++ b/sasmodels/models/mass_fractal.py @@ -92,6 +92,7 @@ # pylint: enable=bad-whitespace, line-too-long source = ["lib/sas_3j1x_x.c", "lib/sas_gamma.c", "mass_fractal.c"] +has_shape_visualization = False valid = "fractal_dim_mass >= 1.0" def random(): diff --git a/sasmodels/models/mass_surface_fractal.py b/sasmodels/models/mass_surface_fractal.py index cf5389b0d..3f5b0e6f9 100644 --- a/sasmodels/models/mass_surface_fractal.py +++ b/sasmodels/models/mass_surface_fractal.py @@ -96,6 +96,7 @@ # pylint: enable=bad-whitespace, line-too-long source = ["mass_surface_fractal.c"] +has_shape_visualization = False valid = "fractal_dim_mass + fractal_dim_surf <= 6.0" def random(): diff --git a/sasmodels/models/micromagnetic_FF_3D.py b/sasmodels/models/micromagnetic_FF_3D.py index d3ac9b06c..8ba0a0df3 100644 --- a/sasmodels/models/micromagnetic_FF_3D.py +++ b/sasmodels/models/micromagnetic_FF_3D.py @@ -153,6 +153,7 @@ source = ["lib/sas_3j1x_x.c", "lib/core_shell.c", "lib/gauss76.c", "lib/magnetic_functions.c", "micromagnetic_FF_3D.c"] +has_shape_visualization = False structure_factor = False have_Fq = False single=False diff --git a/sasmodels/models/mono_gauss_coil.py b/sasmodels/models/mono_gauss_coil.py index 1e82684d8..b415cf9b3 100644 --- a/sasmodels/models/mono_gauss_coil.py +++ b/sasmodels/models/mono_gauss_coil.py @@ -78,6 +78,7 @@ # pylint: enable=bad-whitespace, line-too-long source = ["mono_gauss_coil.c"] +has_shape_visualization = False have_Fq = False radius_effective_modes = ["R_g", "2R_g", "3R_g", "sqrt(5/3)*R_g"] diff --git a/sasmodels/models/multilayer_vesicle.py b/sasmodels/models/multilayer_vesicle.py index 95fd5d580..e8d055ab8 100644 --- a/sasmodels/models/multilayer_vesicle.py +++ b/sasmodels/models/multilayer_vesicle.py @@ -144,6 +144,7 @@ #polydispersity = ["radius", "thick_shell"] source = ["lib/sas_3j1x_x.c", "multilayer_vesicle.c"] +has_shape_visualization = False have_Fq = True radius_effective_modes = ["outer radius"] diff --git a/sasmodels/models/onion.py b/sasmodels/models/onion.py index e3fb931c2..526d5a129 100644 --- a/sasmodels/models/onion.py +++ b/sasmodels/models/onion.py @@ -334,6 +334,7 @@ # pylint: enable=bad-whitespace, line-too-long source = ["lib/sas_3j1x_x.c", "onion.c"] +has_shape_visualization = False single = False have_Fq = True radius_effective_modes = ["outer radius"] diff --git a/sasmodels/models/parallelepiped.py b/sasmodels/models/parallelepiped.py index 91a9ebf15..63a30e45f 100644 --- a/sasmodels/models/parallelepiped.py +++ b/sasmodels/models/parallelepiped.py @@ -238,6 +238,7 @@ "half length_a", "half length_b", "half length_c", "equivalent circular cross-section", "half ab diagonal", "half diagonal", ] +has_shape_visualization = True def random(): """Return a random parameter set for the model.""" diff --git a/sasmodels/models/peak_lorentz.py b/sasmodels/models/peak_lorentz.py index ab794ec27..b1d3a8875 100644 --- a/sasmodels/models/peak_lorentz.py +++ b/sasmodels/models/peak_lorentz.py @@ -65,6 +65,7 @@ def Iq(q, peak_pos, peak_hwhm): return inten Iq.vectorized = True # Iq accepts an array of q values +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" peak_pos = 10**np.random.uniform(-3, -1) diff --git a/sasmodels/models/pearl_necklace.py b/sasmodels/models/pearl_necklace.py index 2a33a83ac..f0fc9349c 100644 --- a/sasmodels/models/pearl_necklace.py +++ b/sasmodels/models/pearl_necklace.py @@ -104,6 +104,7 @@ valid = "thick_string < radius && num_pearls > 0.0" single = False # use double precision unless told otherwise radius_effective_modes = ["equivalent volume sphere"] +has_shape_visualization = True def random(): """Return a random parameter set for the model.""" diff --git a/sasmodels/models/poly_gauss_coil.py b/sasmodels/models/poly_gauss_coil.py index 64001e0aa..c3ff19620 100644 --- a/sasmodels/models/poly_gauss_coil.py +++ b/sasmodels/models/poly_gauss_coil.py @@ -108,6 +108,7 @@ def Iq(q, i_zero, rg, polydispersity): return i_zero * result Iq.vectorized = True # Iq accepts an array of q values +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" rg = 10**np.random.uniform(0, 4) diff --git a/sasmodels/models/polymer_excl_volume.py b/sasmodels/models/polymer_excl_volume.py index 59a3da8da..5884a01e1 100644 --- a/sasmodels/models/polymer_excl_volume.py +++ b/sasmodels/models/polymer_excl_volume.py @@ -167,6 +167,7 @@ def Iq(q, rg=60.0, porod_exp=3.0): Iq.vectorized = True # Iq accepts an array of q values +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" rg = 10**np.random.uniform(0, 4) diff --git a/sasmodels/models/polymer_micelle.py b/sasmodels/models/polymer_micelle.py index de1c302f8..c6f2caac8 100644 --- a/sasmodels/models/polymer_micelle.py +++ b/sasmodels/models/polymer_micelle.py @@ -111,6 +111,7 @@ ] # pylint: enable=bad-whitespace, line-too-long +has_shape_visualization = False single = False source = ["lib/sas_3j1x_x.c", "polymer_micelle.c"] diff --git a/sasmodels/models/porod.py b/sasmodels/models/porod.py index eef567de6..e8514124c 100644 --- a/sasmodels/models/porod.py +++ b/sasmodels/models/porod.py @@ -111,6 +111,7 @@ def Iq(q): Iq.vectorized = True # Iq accepts an array of q values +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" sld, solvent = np.random.uniform(-0.5, 12, size=2) diff --git a/sasmodels/models/power_law.py b/sasmodels/models/power_law.py index 0efe9ea10..89c28acc2 100644 --- a/sasmodels/models/power_law.py +++ b/sasmodels/models/power_law.py @@ -57,6 +57,7 @@ def Iq(q, power): return result Iq.vectorized = True # Iq accepts an array of q values +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" power = np.random.uniform(1, 6) diff --git a/sasmodels/models/pringle.py b/sasmodels/models/pringle.py index d086245a4..47e502fd5 100644 --- a/sasmodels/models/pringle.py +++ b/sasmodels/models/pringle.py @@ -81,6 +81,7 @@ "equivalent cylinder excluded volume", "equivalent volume sphere", "radius"] +has_shape_visualization = True def random(): """Return a random parameter set for the model.""" diff --git a/sasmodels/models/raspberry.py b/sasmodels/models/raspberry.py index 0e71a1da3..d530bcea1 100644 --- a/sasmodels/models/raspberry.py +++ b/sasmodels/models/raspberry.py @@ -156,6 +156,7 @@ source = ["lib/sas_3j1x_x.c", "raspberry.c"] radius_effective_modes = ["radius_large", "radius_outer"] +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" # Limit volume fraction to 20% each diff --git a/sasmodels/models/rectangular_prism.py b/sasmodels/models/rectangular_prism.py index 5ea236c4f..144ff45fb 100644 --- a/sasmodels/models/rectangular_prism.py +++ b/sasmodels/models/rectangular_prism.py @@ -145,6 +145,7 @@ ] source = ["lib/gauss76.c", "rectangular_prism.c"] +has_shape_visualization = False have_Fq = True radius_effective_modes = [ "equivalent cylinder excluded volume", "equivalent volume sphere", diff --git a/sasmodels/models/rpa.py b/sasmodels/models/rpa.py index ef62a3d05..424c7aa8d 100644 --- a/sasmodels/models/rpa.py +++ b/sasmodels/models/rpa.py @@ -139,6 +139,7 @@ source = ["rpa.c"] +has_shape_visualization = False single = False control = "case_num" diff --git a/sasmodels/models/sc_paracrystal.py b/sasmodels/models/sc_paracrystal.py index 911845bcf..5a5eed798 100644 --- a/sasmodels/models/sc_paracrystal.py +++ b/sasmodels/models/sc_paracrystal.py @@ -175,6 +175,7 @@ sldSolv: SLD of the solvent """ category = "shape:paracrystal" +has_shape_visualization = False single = False # pylint: disable=bad-whitespace, line-too-long # ["name", "units", default, [lower, upper], "type","description"], diff --git a/sasmodels/models/sphere.py b/sasmodels/models/sphere.py index f64070778..39e69e1d4 100644 --- a/sasmodels/models/sphere.py +++ b/sasmodels/models/sphere.py @@ -74,6 +74,64 @@ source = ["lib/sas_3j1x_x.c", "sphere.c"] have_Fq = True radius_effective_modes = ["radius"] +has_shape_visualization = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for sphere visualization.""" + import numpy as np + radius = params.get('radius', 50) + + # Create sphere + phi = np.linspace(0, np.pi, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + x = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z = radius * np.cos(phi_mesh) + + return {'sphere': (x, y, z)} + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the sphere.""" + import numpy as np + radius = params.get('radius', 50) + + # Create circle for all cross-sections (sphere is symmetric) + theta = np.linspace(0, 2*np.pi, 100) + circle_x = radius * np.cos(theta) + circle_y = radius * np.sin(theta) + + # XY plane (top view) + ax_xy.plot(circle_x, circle_y, 'b-', linewidth=2) + ax_xy.set_xlim(-radius*1.2, radius*1.2) + ax_xy.set_ylim(-radius*1.2, radius*1.2) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # XZ plane (side view) + ax_xz.plot(circle_x, circle_y, 'r-', linewidth=2) + ax_xz.set_xlim(-radius*1.2, radius*1.2) + ax_xz.set_ylim(-radius*1.2, radius*1.2) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section (Side View)') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + # YZ plane (front view) + ax_yz.plot(circle_x, circle_y, 'g-', linewidth=2) + ax_yz.set_xlim(-radius*1.2, radius*1.2) + ax_yz.set_ylim(-radius*1.2, radius*1.2) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section (Front View)') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) + #single = False def random(): """Return a random parameter set for the model.""" diff --git a/sasmodels/models/spherical_sld.py b/sasmodels/models/spherical_sld.py index a3234bb7a..28cf95e49 100644 --- a/sasmodels/models/spherical_sld.py +++ b/sasmodels/models/spherical_sld.py @@ -268,6 +268,7 @@ ] # pylint: enable=bad-whitespace, line-too-long source = ["lib/polevl.c", "lib/sas_erf.c", "lib/sas_3j1x_x.c", "spherical_sld.c"] +has_shape_visualization = False single = False # TODO: fix low q behaviour have_Fq = True radius_effective_modes = ["outer radius"] diff --git a/sasmodels/models/spinodal.py b/sasmodels/models/spinodal.py index eb94b996f..fe48764f2 100644 --- a/sasmodels/models/spinodal.py +++ b/sasmodels/models/spinodal.py @@ -96,6 +96,7 @@ def Iq(q, return inten Iq.vectorized = True # Iq accepts an array of q values +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" pars = dict( diff --git a/sasmodels/models/squarewell.py b/sasmodels/models/squarewell.py index 1a28a336b..f2ce074e4 100644 --- a/sasmodels/models/squarewell.py +++ b/sasmodels/models/squarewell.py @@ -75,6 +75,7 @@ in versions 4.2.2 and higher. """ category = "structure-factor" +has_shape_visualization = False structure_factor = True single = False diff --git a/sasmodels/models/stacked_disks.py b/sasmodels/models/stacked_disks.py index 036df24f1..d639946c1 100644 --- a/sasmodels/models/stacked_disks.py +++ b/sasmodels/models/stacked_disks.py @@ -147,6 +147,7 @@ # pylint: enable=bad-whitespace, line-too-long source = ["lib/polevl.c", "lib/sas_J1.c", "lib/gauss76.c", "stacked_disks.c"] +has_shape_visualization = True def random(): """Return a random parameter set for the model.""" diff --git a/sasmodels/models/star_polymer.py b/sasmodels/models/star_polymer.py index 8464e28b8..ad787b318 100644 --- a/sasmodels/models/star_polymer.py +++ b/sasmodels/models/star_polymer.py @@ -84,6 +84,7 @@ source = ["star_polymer.c"] +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" pars = dict( diff --git a/sasmodels/models/stickyhardsphere.py b/sasmodels/models/stickyhardsphere.py index 1e9316722..4a369ea90 100644 --- a/sasmodels/models/stickyhardsphere.py +++ b/sasmodels/models/stickyhardsphere.py @@ -103,6 +103,7 @@ in versions 4.2.2 and higher. """ category = "structure-factor" +has_shape_visualization = False structure_factor = True single = False diff --git a/sasmodels/models/superball.py b/sasmodels/models/superball.py index a490e898d..8b973f364 100644 --- a/sasmodels/models/superball.py +++ b/sasmodels/models/superball.py @@ -152,6 +152,7 @@ # lib/gauss76.c # lib/gauss20.c source = ["lib/gauss20.c", "lib/sas_gamma.c", "superball.c"] +has_shape_visualization = False have_Fq = True radius_effective_modes = [ "radius of gyration", diff --git a/sasmodels/models/surface_fractal.py b/sasmodels/models/surface_fractal.py index 3f6f84268..0ccf9bf6f 100644 --- a/sasmodels/models/surface_fractal.py +++ b/sasmodels/models/surface_fractal.py @@ -83,6 +83,7 @@ # pylint: enable=bad-whitespace, line-too-long source = ["lib/sas_3j1x_x.c", "lib/sas_gamma.c", "surface_fractal.c"] +has_shape_visualization = False # Don't need validity test since fractal_dim_surf is not polydisperse #valid = "fractal_dim_surf > 1.0 && fractal_dim_surf < 3.0" diff --git a/sasmodels/models/teubner_strey.py b/sasmodels/models/teubner_strey.py index f02d4dc61..d164ef9e1 100644 --- a/sasmodels/models/teubner_strey.py +++ b/sasmodels/models/teubner_strey.py @@ -106,6 +106,7 @@ def Iq(q, volfraction_a, sld_a, sld_b, d, xi): Iq.vectorized = True # Iq accepts an array of q values +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" d = 10**np.random.uniform(1, 4) diff --git a/sasmodels/models/triaxial_ellipsoid.py b/sasmodels/models/triaxial_ellipsoid.py index 4919c8cf2..b382f25c5 100644 --- a/sasmodels/models/triaxial_ellipsoid.py +++ b/sasmodels/models/triaxial_ellipsoid.py @@ -159,6 +159,7 @@ ] source = ["lib/sas_3j1x_x.c", "lib/gauss76.c", "triaxial_ellipsoid.c"] +has_shape_visualization = False # Equations do not require Ra <= Rb <= Rc so don't test for it. #valid = ("radius_equat_minor <= radius_equat_major" # " && radius_equat_major <= radius_polar") diff --git a/sasmodels/models/two_lorentzian.py b/sasmodels/models/two_lorentzian.py index c0584bdde..65986f62e 100644 --- a/sasmodels/models/two_lorentzian.py +++ b/sasmodels/models/two_lorentzian.py @@ -93,6 +93,7 @@ def Iq(q, Iq.vectorized = True # Iq accepts an array of q values +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" scale = 10**np.random.uniform(0, 4, 2) diff --git a/sasmodels/models/two_power_law.py b/sasmodels/models/two_power_law.py index 09b89240e..47f590ed6 100644 --- a/sasmodels/models/two_power_law.py +++ b/sasmodels/models/two_power_law.py @@ -98,6 +98,7 @@ def Iq(q, Iq.vectorized = True # Iq accepts an array of q values +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" coefficient_1 = 1 diff --git a/sasmodels/models/two_yukawa.py b/sasmodels/models/two_yukawa.py index 00d110d99..cc85173d6 100644 --- a/sasmodels/models/two_yukawa.py +++ b/sasmodels/models/two_yukawa.py @@ -128,6 +128,7 @@ description = """""" category = "structure-factor" +has_shape_visualization = False structure_factor = True single = False # make sure that it has double digit precision opencl = False # related with parallel computing diff --git a/sasmodels/models/unified_power_Rg.py b/sasmodels/models/unified_power_Rg.py index 007a9d8ee..b9c97037e 100644 --- a/sasmodels/models/unified_power_Rg.py +++ b/sasmodels/models/unified_power_Rg.py @@ -122,6 +122,7 @@ def Iq(q, level, rg, power, B, G): Iq.vectorized = True +has_shape_visualization = False def random(): """Return a random parameter set for the model.""" level = np.minimum(np.random.poisson(0.5) + 1, 6) diff --git a/sasmodels/models/vesicle.py b/sasmodels/models/vesicle.py index f5e8c0f17..376db331c 100644 --- a/sasmodels/models/vesicle.py +++ b/sasmodels/models/vesicle.py @@ -97,6 +97,7 @@ ] source = ["lib/sas_3j1x_x.c", "vesicle.c"] +has_shape_visualization = False have_Fq = True radius_effective_modes = ["outer radius"] From 1eac9df684056ae8da255a88c481d6ba20fdfe34 Mon Sep 17 00:00:00 2001 From: Wojtek Potrzebowski Date: Tue, 23 Dec 2025 07:47:25 +0100 Subject: [PATCH 2/7] visualization for additiobnal models added --- sasmodels/__init__.py | 2 +- sasmodels/models/capped_cylinder.py | 145 +++++++++++++++++ .../models/core_shell_bicelle_elliptical.py | 142 +++++++++++++++++ sasmodels/models/core_shell_cylinder.py | 146 ++++++++++++++++++ sasmodels/models/core_shell_sphere.py | 32 ++-- sasmodels/models/cylinder.py | 20 +-- sasmodels/models/ellipsoid.py | 70 +++++++++ sasmodels/models/elliptical_cylinder.py | 81 ++++++++++ .../models/flexible_cylinder_elliptical.py | 77 +++++++++ sasmodels/models/hollow_cylinder.py | 115 ++++++++++++++ sasmodels/models/parallelepiped.py | 100 ++++++++++++ sasmodels/models/pearl_necklace.py | 108 +++++++++++++ sasmodels/models/pringle.py | 85 ++++++++++ sasmodels/models/sphere.py | 14 +- sasmodels/models/stacked_disks.py | 99 ++++++++++++ 15 files changed, 1202 insertions(+), 34 deletions(-) diff --git a/sasmodels/__init__.py b/sasmodels/__init__.py index 475c0b847..2a9a48f43 100644 --- a/sasmodels/__init__.py +++ b/sasmodels/__init__.py @@ -20,7 +20,7 @@ __version__ = "0.0.0.dev" # Shape visualization API -from .shape_visualizer import generate_shape_image, SASModelsShapeDetector +from .shape_visualizer import SASModelsShapeDetector, generate_shape_image def data_files(): diff --git a/sasmodels/models/capped_cylinder.py b/sasmodels/models/capped_cylinder.py index ca6a5312e..743232267 100644 --- a/sasmodels/models/capped_cylinder.py +++ b/sasmodels/models/capped_cylinder.py @@ -146,6 +146,151 @@ ] has_shape_visualization = True +def create_shape_mesh(params, resolution=50): + import numpy as np + radius = params.get('radius', 20) + radius_cap = params.get('radius_cap', 25) + length = params.get('length', 400) + + if radius_cap < radius: + raise ValueError(f"Cap radius ({radius_cap}) must be >= cylinder radius ({radius})") + + # Calculate cap geometry + h = np.sqrt(radius_cap**2 - radius**2) + + # Create cylinder body + theta = np.linspace(0, 2*np.pi, resolution) + z_cyl = np.linspace(-length/2, length/2, resolution//2) + theta_cyl, z_cyl_mesh = np.meshgrid(theta, z_cyl) + x_cyl = radius * np.cos(theta_cyl) + y_cyl = radius * np.sin(theta_cyl) + + # Create spherical caps + phi_max = np.arccos(h / radius_cap) + phi = np.linspace(0, phi_max, resolution//4) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + # Top cap + x_cap_top = radius_cap * np.sin(phi_mesh) * np.cos(theta_mesh) + y_cap_top = radius_cap * np.sin(phi_mesh) * np.sin(theta_mesh) + z_cap_top = length/2 - h + radius_cap * np.cos(phi_mesh) + + # Bottom cap + x_cap_bottom = radius_cap * np.sin(phi_mesh) * np.cos(theta_mesh) + y_cap_bottom = radius_cap * np.sin(phi_mesh) * np.sin(theta_mesh) + z_cap_bottom = -length/2 + h - radius_cap * np.cos(phi_mesh) + + return { + 'cylinder': (x_cyl, y_cyl, z_cyl_mesh), + 'cap_top': (x_cap_top, y_cap_top, z_cap_top), + 'cap_bottom': (x_cap_bottom, y_cap_bottom, z_cap_bottom) + } + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + import numpy as np + radius = params.get('radius', 20) + radius_cap = params.get('radius_cap', 25) + length = params.get('length', 400) + + if radius_cap < radius: + return # Skip if invalid parameters + + h = np.sqrt(radius_cap**2 - radius**2) + + # XY plane (top view) - circle (same as cylinder) + theta = np.linspace(0, 2*np.pi, 100) + circle_x = radius * np.cos(theta) + circle_y = radius * np.sin(theta) + + ax_xy.plot(circle_x, circle_y, 'b-', linewidth=2, label='Cylinder') + ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.3) + + # Show cap outline if significantly larger + if radius_cap > radius * 1.1: + cap_circle_x = radius_cap * np.cos(theta) + cap_circle_y = radius_cap * np.sin(theta) + ax_xy.plot(cap_circle_x, cap_circle_y, 'r--', linewidth=1, alpha=0.7, label='Cap outline') + + ax_xy.set_xlim(-radius_cap*1.2, radius_cap*1.2) + ax_xy.set_ylim(-radius_cap*1.2, radius_cap*1.2) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ax_xy.legend() + + # XZ plane (side view) - cylinder + caps + # Cylinder body + cyl_x = [-length/2, -length/2, length/2, length/2, -length/2] + cyl_z = [-radius, radius, radius, -radius, -radius] + ax_xz.plot(cyl_x, cyl_z, 'b-', linewidth=2, label='Cylinder') + ax_xz.fill(cyl_x, cyl_z, 'lightblue', alpha=0.3) + + # Spherical caps + cap_angles = np.linspace(0, 2*np.pi, 100) + + # Top cap + cap_center_top = length/2 - h + cap_x_top = cap_center_top + radius_cap * np.cos(cap_angles) + cap_z_top = radius_cap * np.sin(cap_angles) + + # Only show the part that extends beyond cylinder + mask_top = cap_x_top >= length/2 + ax_xz.plot(cap_x_top[mask_top], cap_z_top[mask_top], 'r-', linewidth=2, label='Caps') + ax_xz.fill_between(cap_x_top[mask_top], cap_z_top[mask_top], 0, alpha=0.3, color='lightcoral') + + # Bottom cap + cap_center_bottom = -length/2 + h + cap_x_bottom = cap_center_bottom + radius_cap * np.cos(cap_angles) + cap_z_bottom = radius_cap * np.sin(cap_angles) + + mask_bottom = cap_x_bottom <= -length/2 + ax_xz.plot(cap_x_bottom[mask_bottom], cap_z_bottom[mask_bottom], 'r-', linewidth=2) + ax_xz.fill_between(cap_x_bottom[mask_bottom], cap_z_bottom[mask_bottom], 0, alpha=0.3, color='lightcoral') + + # Mark cap centers + ax_xz.plot(cap_center_top, 0, 'ro', markersize=6, label='Cap centers') + ax_xz.plot(cap_center_bottom, 0, 'ro', markersize=6) + + ax_xz.set_xlim((-length/2 - radius_cap*0.5), (length/2 + radius_cap*0.5)) + ax_xz.set_ylim(-radius_cap*1.2, radius_cap*1.2) + ax_xz.set_xlabel('Z (Å)') + ax_xz.set_ylabel('X (Å)') + ax_xz.set_title('XZ Cross-section (Side View)') + ax_xz.grid(True, alpha=0.3) + ax_xz.legend() + + # YZ plane (front view) - same as XZ + ax_yz.plot(cyl_x, cyl_z, 'g-', linewidth=2, label='Cylinder') + ax_yz.fill(cyl_x, cyl_z, 'lightgreen', alpha=0.3) + + ax_yz.plot(cap_x_top[mask_top], cap_z_top[mask_top], 'orange', linewidth=2, label='Caps') + ax_yz.fill_between(cap_x_top[mask_top], cap_z_top[mask_top], 0, alpha=0.3, color='moccasin') + ax_yz.plot(cap_x_bottom[mask_bottom], cap_z_bottom[mask_bottom], 'orange', linewidth=2) + ax_yz.fill_between(cap_x_bottom[mask_bottom], cap_z_bottom[mask_bottom], 0, alpha=0.3, color='moccasin') + + ax_yz.plot(cap_center_top, 0, 'o', color='orange', markersize=6, label='Cap centers') + ax_yz.plot(cap_center_bottom, 0, 'o', color='orange', markersize=6) + + ax_yz.set_xlim((-length/2 - radius_cap*0.5), (length/2 + radius_cap*0.5)) + ax_yz.set_ylim(-radius_cap*1.2, radius_cap*1.2) + ax_yz.set_xlabel('Z (Å)') + ax_yz.set_ylabel('Y (Å)') + ax_yz.set_title('YZ Cross-section (Front View)') + ax_yz.grid(True, alpha=0.3) + ax_yz.legend() + + # Add dimension annotations + ax_xz.annotate('', xy=(-length/2, -radius*1.4), xytext=(length/2, -radius*1.4), + arrowprops=dict(arrowstyle='<->', color='black')) + ax_xz.text(0, -radius*1.6, f'L = {length:.0f} Å', ha='center', fontsize=10) + + ax_xz.text(cap_center_top + radius_cap*0.3, radius_cap*0.7, f'R = {radius_cap:.0f} Å', + fontsize=10, rotation=45) + ax_xz.text(-length/4, radius*0.7, f'r = {radius:.0f} Å', fontsize=10) + ax_xz.text(cap_center_top, -radius*0.3, f'h = {h:.1f} Å', fontsize=10, ha='center') + def random(): """Return a random parameter set for the model.""" # TODO: increase volume range once problem with bell radius is fixed diff --git a/sasmodels/models/core_shell_bicelle_elliptical.py b/sasmodels/models/core_shell_bicelle_elliptical.py index f593bd563..77b380b62 100644 --- a/sasmodels/models/core_shell_bicelle_elliptical.py +++ b/sasmodels/models/core_shell_bicelle_elliptical.py @@ -154,6 +154,148 @@ ] has_shape_visualization = True +def create_shape_mesh(params, resolution=50): + import numpy as np + radius = params.get('radius', 30) # r_minor + x_core = params.get('x_core', 3) # r_major/r_minor ratio + thick_rim = params.get('thick_rim', 8) + thick_face = params.get('thick_face', 14) + length = params.get('length', 50) + + r_minor = radius + r_major = radius * x_core + + # Outer dimensions + outer_r_minor = r_minor + thick_rim + outer_r_major = r_major + thick_rim + outer_length = length + 2 * thick_face + + # Create core elliptical cylinder + theta = np.linspace(0, 2*np.pi, resolution) + z_core = np.linspace(-length/2, length/2, resolution//2) + theta_core, z_core_mesh = np.meshgrid(theta, z_core) + x_core_mesh = r_major * np.cos(theta_core) + y_core_mesh = r_minor * np.sin(theta_core) + + # Create shell elliptical cylinder (outer surface) + z_shell = np.linspace(-outer_length/2, outer_length/2, resolution//2) + theta_shell, z_shell_mesh = np.meshgrid(theta, z_shell) + x_shell = outer_r_major * np.cos(theta_shell) + y_shell = outer_r_minor * np.sin(theta_shell) + + # Create end caps + # Core end caps (elliptical) + u = np.linspace(0, 1, resolution//4) + theta_cap = np.linspace(0, 2*np.pi, resolution) + u_mesh, theta_cap_mesh = np.meshgrid(u, theta_cap) + + x_cap_core = u_mesh * r_major * np.cos(theta_cap_mesh) + y_cap_core = u_mesh * r_minor * np.sin(theta_cap_mesh) + z_cap_core_top = np.full_like(x_cap_core, length/2) + z_cap_core_bottom = np.full_like(x_cap_core, -length/2) + + # Shell end caps (elliptical) + x_cap_shell = u_mesh * outer_r_major * np.cos(theta_cap_mesh) + y_cap_shell = u_mesh * outer_r_minor * np.sin(theta_cap_mesh) + z_cap_shell_top = np.full_like(x_cap_shell, outer_length/2) + z_cap_shell_bottom = np.full_like(x_cap_shell, -outer_length/2) + + return { + 'core_cylinder': (x_core_mesh, y_core_mesh, z_core_mesh), + 'shell_cylinder': (x_shell, y_shell, z_shell_mesh), + 'core_cap_top': (x_cap_core, y_cap_core, z_cap_core_top), + 'core_cap_bottom': (x_cap_core, y_cap_core, z_cap_core_bottom), + 'shell_cap_top': (x_cap_shell, y_cap_shell, z_cap_shell_top), + 'shell_cap_bottom': (x_cap_shell, y_cap_shell, z_cap_shell_bottom), + } + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + import numpy as np + radius = params.get('radius', 30) + x_core = params.get('x_core', 3) + thick_rim = params.get('thick_rim', 8) + thick_face = params.get('thick_face', 14) + length = params.get('length', 50) + + r_minor = radius + r_major = radius * x_core + outer_r_minor = r_minor + thick_rim + outer_r_major = r_major + thick_rim + outer_length = length + 2 * thick_face + + # XY plane (top view) - nested ellipses + theta = np.linspace(0, 2*np.pi, 100) + + # Core ellipse + core_x = r_major * np.cos(theta) + core_y = r_minor * np.sin(theta) + + # Shell ellipse + shell_x = outer_r_major * np.cos(theta) + shell_y = outer_r_minor * np.sin(theta) + + ax_xy.plot(shell_x, shell_y, 'r-', linewidth=2, label='Shell rim') + ax_xy.fill(shell_x, shell_y, 'lightcoral', alpha=0.3) + ax_xy.plot(core_x, core_y, 'b-', linewidth=2, label='Core') + ax_xy.fill(core_x, core_y, 'lightblue', alpha=0.5) + + ax_xy.set_xlim(-outer_r_major*1.2, outer_r_major*1.2) + ax_xy.set_ylim(-outer_r_minor*1.2, outer_r_minor*1.2) + ax_xy.set_xlabel('X (Å) - Major axis') + ax_xy.set_ylabel('Y (Å) - Minor axis') + ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ax_xy.legend() + + # XZ plane (side view along major axis) - nested rectangles + # Core rectangle (using major axis) + core_rect_x = [-length/2, -length/2, length/2, length/2, -length/2] + core_rect_z = [-r_major, r_major, r_major, -r_major, -r_major] + + # Full outer shell with face thickness + shell_full_x = [-outer_length/2, -outer_length/2, outer_length/2, outer_length/2, -outer_length/2] + shell_full_z = [-outer_r_major, outer_r_major, outer_r_major, -outer_r_major, -outer_r_major] + + ax_xz.plot(shell_full_x, shell_full_z, 'r-', linewidth=2, label='Shell (rim+face)') + ax_xz.fill(shell_full_x, shell_full_z, 'lightcoral', alpha=0.3) + ax_xz.plot(core_rect_x, core_rect_z, 'b-', linewidth=2, label='Core') + ax_xz.fill(core_rect_x, core_rect_z, 'lightblue', alpha=0.5) + + ax_xz.set_xlim(-outer_length/2*1.2, outer_length/2*1.2) + ax_xz.set_ylim(-outer_r_major*1.3, outer_r_major*1.3) + ax_xz.set_xlabel('Z (Å)') + ax_xz.set_ylabel('X (Å) - Major axis') + ax_xz.set_title('XZ Cross-section (Major axis)') + ax_xz.grid(True, alpha=0.3) + ax_xz.legend() + + # Annotations + ax_xz.text(0, -outer_r_major*1.15, f'L = {length:.0f} Å', ha='center', fontsize=9) + ax_xz.text(0, outer_r_major*1.15, f'r_major = {r_major:.0f} Å', ha='center', fontsize=9) + ax_xz.text(outer_length/2*0.7, outer_r_major*0.7, f't_rim = {thick_rim:.0f}', fontsize=8) + ax_xz.text(length/2 + thick_face/2, 0, f't_face = {thick_face:.0f}', fontsize=8, rotation=90) + + # YZ plane (side view along minor axis) + core_rect_y = [-r_minor, r_minor, r_minor, -r_minor, -r_minor] + shell_body_y = [-outer_r_minor, outer_r_minor, outer_r_minor, -outer_r_minor, -outer_r_minor] + + ax_yz.plot(shell_full_x, shell_body_y, 'g-', linewidth=2, label='Shell (rim+face)') + ax_yz.fill(shell_full_x, shell_body_y, 'lightgreen', alpha=0.3) + ax_yz.plot(core_rect_x, core_rect_y, 'orange', linewidth=2, label='Core') + ax_yz.fill(core_rect_x, core_rect_y, 'moccasin', alpha=0.5) + + ax_yz.set_xlim(-outer_length/2*1.2, outer_length/2*1.2) + ax_yz.set_ylim(-outer_r_minor*1.3, outer_r_minor*1.3) + ax_yz.set_xlabel('Z (Å)') + ax_yz.set_ylabel('Y (Å) - Minor axis') + ax_yz.set_title('YZ Cross-section (Minor axis)') + ax_yz.grid(True, alpha=0.3) + ax_yz.legend() + + ax_yz.text(0, outer_r_minor*1.15, f'r_minor = {r_minor:.0f} Å', ha='center', fontsize=9) + ax_yz.text(0, -outer_r_minor*1.15, f'X_core = {x_core:.1f}', ha='center', fontsize=9) + def random(): """Return a random parameter set for the model.""" outer_major = 10**np.random.uniform(1, 4.7) diff --git a/sasmodels/models/core_shell_cylinder.py b/sasmodels/models/core_shell_cylinder.py index 8c1b31e1f..86b63ae6b 100644 --- a/sasmodels/models/core_shell_cylinder.py +++ b/sasmodels/models/core_shell_cylinder.py @@ -141,6 +141,152 @@ ] has_shape_visualization = True +def create_shape_mesh(params, resolution=50): + import numpy as np + radius = params.get('radius', 20) + thickness = params.get('thickness', 20) + length = params.get('length', 400) + + # Outer dimensions + outer_radius = radius + thickness + outer_length = length + 2 * thickness + + # Create core cylinder + theta = np.linspace(0, 2*np.pi, resolution) + z_core = np.linspace(-length/2, length/2, resolution//2) + theta_core, z_core_mesh = np.meshgrid(theta, z_core) + x_core = radius * np.cos(theta_core) + y_core = radius * np.sin(theta_core) + + # Create shell cylinder (outer surface) + z_shell = np.linspace(-outer_length/2, outer_length/2, resolution//2) + theta_shell, z_shell_mesh = np.meshgrid(theta, z_shell) + x_shell = outer_radius * np.cos(theta_shell) + y_shell = outer_radius * np.sin(theta_shell) + + # Create end caps (shell only, as annular disks) + r_cap_inner = np.linspace(0, radius, resolution//8) + r_cap_outer = np.linspace(radius, outer_radius, resolution//8) + theta_cap = np.linspace(0, 2*np.pi, resolution) + + # Inner caps (on core) + r_inner_mesh, theta_inner_mesh = np.meshgrid(r_cap_inner, theta_cap) + x_cap_core = r_inner_mesh * np.cos(theta_inner_mesh) + y_cap_core = r_inner_mesh * np.sin(theta_inner_mesh) + z_cap_core_top = np.full_like(x_cap_core, length/2) + z_cap_core_bottom = np.full_like(x_cap_core, -length/2) + + # Outer shell caps (annular rings on ends) + r_outer_mesh, theta_outer_mesh = np.meshgrid(r_cap_outer, theta_cap) + x_cap_shell = r_outer_mesh * np.cos(theta_outer_mesh) + y_cap_shell = r_outer_mesh * np.sin(theta_outer_mesh) + z_cap_shell_top = np.full_like(x_cap_shell, outer_length/2) + z_cap_shell_bottom = np.full_like(x_cap_shell, -outer_length/2) + + # Middle shell caps (between core and outer shell) + r_full = np.linspace(0, outer_radius, resolution//4) + r_full_mesh, theta_full_mesh = np.meshgrid(r_full, theta_cap) + x_cap_middle = r_full_mesh * np.cos(theta_full_mesh) + y_cap_middle = r_full_mesh * np.sin(theta_full_mesh) + z_cap_middle_top = np.full_like(x_cap_middle, length/2) + z_cap_middle_bottom = np.full_like(x_cap_middle, -length/2) + + return { + 'core_cylinder': (x_core, y_core, z_core_mesh), + 'shell_cylinder': (x_shell, y_shell, z_shell_mesh), + 'shell_cap_top': (x_cap_middle, y_cap_middle, z_cap_middle_top), + 'shell_cap_bottom': (x_cap_middle, y_cap_middle, z_cap_middle_bottom), + 'end_cap_top': (x_cap_shell, y_cap_shell, z_cap_shell_top), + 'end_cap_bottom': (x_cap_shell, y_cap_shell, z_cap_shell_bottom), + } + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + import numpy as np + radius = params.get('radius', 20) + thickness = params.get('thickness', 20) + length = params.get('length', 400) + + outer_radius = radius + thickness + outer_length = length + 2 * thickness + + # XY plane (top view) - concentric circles + theta = np.linspace(0, 2*np.pi, 100) + + # Core circle + core_x = radius * np.cos(theta) + core_y = radius * np.sin(theta) + + # Shell circle + shell_x = outer_radius * np.cos(theta) + shell_y = outer_radius * np.sin(theta) + + ax_xy.plot(shell_x, shell_y, 'r-', linewidth=2, label='Shell') + ax_xy.fill(shell_x, shell_y, 'lightcoral', alpha=0.3) + ax_xy.plot(core_x, core_y, 'b-', linewidth=2, label='Core') + ax_xy.fill(core_x, core_y, 'lightblue', alpha=0.5) + + ax_xy.set_xlim(-outer_radius*1.3, outer_radius*1.3) + ax_xy.set_ylim(-outer_radius*1.3, outer_radius*1.3) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ax_xy.legend() + + # XZ plane (side view) - nested rectangles + # Core rectangle + core_rect_x = [-length/2, -length/2, length/2, length/2, -length/2] + core_rect_z = [-radius, radius, radius, -radius, -radius] + + # Shell rectangle + shell_rect_x = [-outer_length/2, -outer_length/2, outer_length/2, outer_length/2, -outer_length/2] + shell_rect_z = [-outer_radius, outer_radius, outer_radius, -outer_radius, -outer_radius] + + ax_xz.plot(shell_rect_x, shell_rect_z, 'r-', linewidth=2, label='Shell') + ax_xz.fill(shell_rect_x, shell_rect_z, 'lightcoral', alpha=0.3) + ax_xz.plot(core_rect_x, core_rect_z, 'b-', linewidth=2, label='Core') + ax_xz.fill(core_rect_x, core_rect_z, 'lightblue', alpha=0.5) + + ax_xz.set_xlim(-outer_length/2*1.2, outer_length/2*1.2) + ax_xz.set_ylim(-outer_radius*1.3, outer_radius*1.3) + ax_xz.set_xlabel('Z (Å)') + ax_xz.set_ylabel('X (Å)') + ax_xz.set_title('XZ Cross-section (Side View)') + ax_xz.grid(True, alpha=0.3) + ax_xz.legend() + + # Add dimension annotations + ax_xz.annotate('', xy=(-length/2, -outer_radius*1.5), xytext=(length/2, -outer_radius*1.5), + arrowprops=dict(arrowstyle='<->', color='blue')) + ax_xz.text(0, -outer_radius*1.6, f'L = {length:.0f} Å (core)', ha='center', fontsize=9) + + ax_xz.annotate('', xy=(-outer_length/2, -outer_radius*1.8), xytext=(outer_length/2, -outer_radius*1.8), + arrowprops=dict(arrowstyle='<->', color='red')) + ax_xz.text(0, -outer_radius*1.9, f'L+2T = {outer_length:.0f} Å (total)', ha='center', fontsize=9, color='red') + + ax_xz.annotate('', xy=(outer_length/2*0.7, 0), xytext=(outer_length/2*0.7, radius), + arrowprops=dict(arrowstyle='<->', color='blue')) + ax_xz.text(outer_length/2*0.75, radius/2, f'r={radius:.0f}', fontsize=9, color='blue') + + ax_xz.annotate('', xy=(outer_length/2*0.85, 0), xytext=(outer_length/2*0.85, outer_radius), + arrowprops=dict(arrowstyle='<->', color='red')) + ax_xz.text(outer_length/2*0.9, outer_radius/2, f'R+T={outer_radius:.0f}', fontsize=9, color='red') + + # YZ plane (front view) - same as XZ + ax_yz.plot(shell_rect_x, shell_rect_z, 'g-', linewidth=2, label='Shell') + ax_yz.fill(shell_rect_x, shell_rect_z, 'lightgreen', alpha=0.3) + ax_yz.plot(core_rect_x, core_rect_z, 'orange', linewidth=2, label='Core') + ax_yz.fill(core_rect_x, core_rect_z, 'moccasin', alpha=0.5) + + ax_yz.set_xlim(-outer_length/2*1.2, outer_length/2*1.2) + ax_yz.set_ylim(-outer_radius*1.3, outer_radius*1.3) + ax_yz.set_xlabel('Z (Å)') + ax_yz.set_ylabel('Y (Å)') + ax_yz.set_title('YZ Cross-section (Front View)') + ax_yz.grid(True, alpha=0.3) + ax_yz.legend() + def random(): """Return a random parameter set for the model.""" outer_radius = 10**np.random.uniform(1, 4.7) diff --git a/sasmodels/models/core_shell_sphere.py b/sasmodels/models/core_shell_sphere.py index 5fc23d2eb..a8adaf1e1 100644 --- a/sasmodels/models/core_shell_sphere.py +++ b/sasmodels/models/core_shell_sphere.py @@ -92,22 +92,22 @@ def create_shape_mesh(params, resolution=50): import numpy as np radius = params.get('radius', 60) thickness = params.get('thickness', 10) - + phi = np.linspace(0, np.pi, resolution//2) theta = np.linspace(0, 2*np.pi, resolution) phi_mesh, theta_mesh = np.meshgrid(phi, theta) - + # Core x_core = radius * np.sin(phi_mesh) * np.cos(theta_mesh) y_core = radius * np.sin(phi_mesh) * np.sin(theta_mesh) z_core = radius * np.cos(phi_mesh) - + # Shell shell_radius = radius + thickness x_shell = shell_radius * np.sin(phi_mesh) * np.cos(theta_mesh) y_shell = shell_radius * np.sin(phi_mesh) * np.sin(theta_mesh) z_shell = shell_radius * np.cos(phi_mesh) - + return { 'core': (x_core, y_core, z_core), 'shell': (x_shell, y_shell, z_shell) @@ -119,24 +119,24 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): radius = params.get('radius', 60) thickness = params.get('thickness', 10) shell_radius = radius + thickness - + # Create circles for core and shell theta = np.linspace(0, 2*np.pi, 100) - + # Core circles core_x = radius * np.cos(theta) core_y = radius * np.sin(theta) - + # Shell circles shell_x = shell_radius * np.cos(theta) shell_y = shell_radius * np.sin(theta) - + # XY plane (top view) ax_xy.plot(shell_x, shell_y, 'r-', linewidth=2, label='Shell') ax_xy.fill(shell_x, shell_y, 'lightcoral', alpha=0.3) ax_xy.plot(core_x, core_y, 'b-', linewidth=2, label='Core') ax_xy.fill(core_x, core_y, 'lightblue', alpha=0.5) - + ax_xy.set_xlim(-shell_radius*1.2, shell_radius*1.2) ax_xy.set_ylim(-shell_radius*1.2, shell_radius*1.2) ax_xy.set_xlabel('X (Å)') @@ -145,13 +145,13 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_aspect('equal') ax_xy.grid(True, alpha=0.3) ax_xy.legend() - + # XZ plane (side view) ax_xz.plot(shell_x, shell_y, 'r-', linewidth=2, label='Shell') ax_xz.fill(shell_x, shell_y, 'lightcoral', alpha=0.3) ax_xz.plot(core_x, core_y, 'b-', linewidth=2, label='Core') ax_xz.fill(core_x, core_y, 'lightblue', alpha=0.5) - + ax_xz.set_xlim(-shell_radius*1.2, shell_radius*1.2) ax_xz.set_ylim(-shell_radius*1.2, shell_radius*1.2) ax_xz.set_xlabel('X (Å)') @@ -160,13 +160,13 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_aspect('equal') ax_xz.grid(True, alpha=0.3) ax_xz.legend() - + # YZ plane (front view) ax_yz.plot(shell_x, shell_y, 'g-', linewidth=2, label='Shell') ax_yz.fill(shell_x, shell_y, 'lightgreen', alpha=0.3) ax_yz.plot(core_x, core_y, 'orange', linewidth=2, label='Core') ax_yz.fill(core_x, core_y, 'moccasin', alpha=0.5) - + ax_yz.set_xlim(-shell_radius*1.2, shell_radius*1.2) ax_yz.set_ylim(-shell_radius*1.2, shell_radius*1.2) ax_yz.set_xlabel('Y (Å)') @@ -175,16 +175,16 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_yz.set_aspect('equal') ax_yz.grid(True, alpha=0.3) ax_yz.legend() - + # Add dimension annotations ax_xz.annotate('', xy=(-radius, 0), xytext=(radius, 0), arrowprops=dict(arrowstyle='<->', color='blue')) ax_xz.text(0, -radius*0.3, f'Core R = {radius:.0f} Å', ha='center', fontsize=10, color='blue') - + ax_xz.annotate('', xy=(-shell_radius, -radius*0.7), xytext=(shell_radius, -radius*0.7), arrowprops=dict(arrowstyle='<->', color='red')) ax_xz.text(0, -radius*0.9, f'Shell R = {shell_radius:.0f} Å', ha='center', fontsize=10, color='red') - + ax_xz.annotate('', xy=(radius*0.7, 0), xytext=(shell_radius*0.7, 0), arrowprops=dict(arrowstyle='<->', color='black')) ax_xz.text(radius*0.85, radius*0.2, f't = {thickness:.0f} Å', ha='center', fontsize=10, rotation=90) diff --git a/sasmodels/models/cylinder.py b/sasmodels/models/cylinder.py index afad6a7e9..10bf67c67 100644 --- a/sasmodels/models/cylinder.py +++ b/sasmodels/models/cylinder.py @@ -156,25 +156,25 @@ def create_shape_mesh(params, resolution=50): import numpy as np radius = params.get('radius', 20) length = params.get('length', 400) - + # Create cylinder theta = np.linspace(0, 2*np.pi, resolution) z = np.linspace(-length/2, length/2, resolution//2) theta_mesh, z_mesh = np.meshgrid(theta, z) - + x = radius * np.cos(theta_mesh) y = radius * np.sin(theta_mesh) - + # Create end caps r_cap = np.linspace(0, radius, resolution//4) theta_cap = np.linspace(0, 2*np.pi, resolution) r_cap_mesh, theta_cap_mesh = np.meshgrid(r_cap, theta_cap) - + x_cap = r_cap_mesh * np.cos(theta_cap_mesh) y_cap = r_cap_mesh * np.sin(theta_cap_mesh) z_cap_top = np.full_like(x_cap, length/2) z_cap_bottom = np.full_like(x_cap, -length/2) - + return { 'cylinder': (x, y, z_mesh), 'cap_top': (x_cap, y_cap, z_cap_top), @@ -186,12 +186,12 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): import numpy as np radius = params.get('radius', 20) length = params.get('length', 400) - + # XY plane (top view) - circle theta = np.linspace(0, 2*np.pi, 100) circle_x = radius * np.cos(theta) circle_y = radius * np.sin(theta) - + ax_xy.plot(circle_x, circle_y, 'b-', linewidth=2, label='Cylinder') ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.3) ax_xy.set_xlim(-radius*1.5, radius*1.5) @@ -201,11 +201,11 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_title('XY Cross-section (Top View)') ax_xy.set_aspect('equal') ax_xy.grid(True, alpha=0.3) - + # XZ plane (side view) - rectangle rect_x = [-length/2, -length/2, length/2, length/2, -length/2] rect_z = [-radius, radius, radius, -radius, -radius] - + ax_xz.plot(rect_x, rect_z, 'r-', linewidth=2, label='Cylinder') ax_xz.fill(rect_x, rect_z, 'lightcoral', alpha=0.3) ax_xz.set_xlim(-length/2*1.2, length/2*1.2) @@ -214,7 +214,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_ylabel('X (Å)') ax_xz.set_title('XZ Cross-section (Side View)') ax_xz.grid(True, alpha=0.3) - + # YZ plane (front view) - rectangle ax_yz.plot(rect_x, rect_z, 'g-', linewidth=2, label='Cylinder') ax_yz.fill(rect_x, rect_z, 'lightgreen', alpha=0.3) diff --git a/sasmodels/models/ellipsoid.py b/sasmodels/models/ellipsoid.py index 07e8cb677..3a309069d 100644 --- a/sasmodels/models/ellipsoid.py +++ b/sasmodels/models/ellipsoid.py @@ -160,6 +160,76 @@ ] has_shape_visualization = True +def create_shape_mesh(params, resolution=50): + import numpy as np + radius_polar = params.get('radius_polar', 20) + radius_equatorial = params.get('radius_equatorial', 400) + + # Create ellipsoid + phi = np.linspace(0, np.pi, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + x = radius_equatorial * np.sin(phi_mesh) * np.cos(theta_mesh) + y = radius_equatorial * np.sin(phi_mesh) * np.sin(theta_mesh) + z = radius_polar * np.cos(phi_mesh) + + return {'ellipsoid': (x, y, z)} + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + import numpy as np + radius_polar = params.get('radius_polar', 20) + radius_equatorial = params.get('radius_equatorial', 400) + + # XY plane (top view) - circle with equatorial radius + theta = np.linspace(0, 2*np.pi, 100) + circle_x = radius_equatorial * np.cos(theta) + circle_y = radius_equatorial * np.sin(theta) + + ax_xy.plot(circle_x, circle_y, 'b-', linewidth=2, label='Equatorial') + ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.3) + ax_xy.set_xlim(-radius_equatorial*1.2, radius_equatorial*1.2) + ax_xy.set_ylim(-radius_equatorial*1.2, radius_equatorial*1.2) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Equatorial)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # XZ plane (side view) - ellipse + ellipse_x = radius_equatorial * np.cos(theta) + ellipse_z = radius_polar * np.sin(theta) + + ax_xz.plot(ellipse_x, ellipse_z, 'r-', linewidth=2, label='Meridional') + ax_xz.fill(ellipse_x, ellipse_z, 'lightcoral', alpha=0.3) + ax_xz.set_xlim(-radius_equatorial*1.2, radius_equatorial*1.2) + ax_xz.set_ylim(-radius_polar*1.2, radius_polar*1.2) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section (Meridional)') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + # YZ plane (front view) - ellipse + ax_yz.plot(ellipse_x, ellipse_z, 'g-', linewidth=2, label='Meridional') + ax_yz.fill(ellipse_x, ellipse_z, 'lightgreen', alpha=0.3) + ax_yz.set_xlim(-radius_equatorial*1.2, radius_equatorial*1.2) + ax_yz.set_ylim(-radius_polar*1.2, radius_polar*1.2) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section (Meridional)') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) + + # Add dimension annotations + ax_xz.annotate('', xy=(-radius_equatorial, 0), xytext=(radius_equatorial, 0), + arrowprops=dict(arrowstyle='<->', color='black')) + ax_xz.text(0, -radius_polar*0.3, f'R_eq = {radius_equatorial:.0f} Å', ha='center', fontsize=10) + + ax_xz.annotate('', xy=(0, -radius_polar), xytext=(0, radius_polar), + arrowprops=dict(arrowstyle='<->', color='black')) + ax_xz.text(radius_equatorial*0.3, 0, f'R_pol = {radius_polar:.0f} Å', ha='center', fontsize=10, rotation=90) + def random(): """Return a random parameter set for the model.""" volume = 10**np.random.uniform(5, 12) diff --git a/sasmodels/models/elliptical_cylinder.py b/sasmodels/models/elliptical_cylinder.py index a48357c89..f07259a04 100644 --- a/sasmodels/models/elliptical_cylinder.py +++ b/sasmodels/models/elliptical_cylinder.py @@ -133,6 +133,87 @@ ] has_shape_visualization = True +def create_shape_mesh(params, resolution=50): + import numpy as np + radius_minor = params.get('radius_minor', 20) + axis_ratio = params.get('axis_ratio', 1.5) + length = params.get('length', 400) + + radius_major = radius_minor * axis_ratio + + # Create elliptical cylinder + theta = np.linspace(0, 2*np.pi, resolution) + z = np.linspace(-length/2, length/2, resolution//2) + theta_mesh, z_mesh = np.meshgrid(theta, z) + + x = radius_major * np.cos(theta_mesh) + y = radius_minor * np.sin(theta_mesh) + + # Create end caps (elliptical) + u = np.linspace(0, 1, resolution//4) + theta_cap = np.linspace(0, 2*np.pi, resolution) + u_mesh, theta_cap_mesh = np.meshgrid(u, theta_cap) + + x_cap = u_mesh * radius_major * np.cos(theta_cap_mesh) + y_cap = u_mesh * radius_minor * np.sin(theta_cap_mesh) + z_cap_top = np.full_like(x_cap, length/2) + z_cap_bottom = np.full_like(x_cap, -length/2) + + return { + 'cylinder': (x, y, z_mesh), + 'cap_top': (x_cap, y_cap, z_cap_top), + 'cap_bottom': (x_cap, y_cap, z_cap_bottom) + } + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + import numpy as np + radius_minor = params.get('radius_minor', 20) + axis_ratio = params.get('axis_ratio', 1.5) + length = params.get('length', 400) + radius_major = radius_minor * axis_ratio + + # XY plane (top view) - ellipse + theta = np.linspace(0, 2*np.pi, 100) + ellipse_x = radius_major * np.cos(theta) + ellipse_y = radius_minor * np.sin(theta) + + ax_xy.plot(ellipse_x, ellipse_y, 'b-', linewidth=2) + ax_xy.fill(ellipse_x, ellipse_y, 'lightblue', alpha=0.3) + ax_xy.set_xlim(-radius_major*1.3, radius_major*1.3) + ax_xy.set_ylim(-radius_minor*1.3, radius_minor*1.3) + ax_xy.set_xlabel('X (Å) - Major axis') + ax_xy.set_ylabel('Y (Å) - Minor axis') + ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # XZ plane (side view along major axis) + rect_x = [-length/2, -length/2, length/2, length/2, -length/2] + rect_z = [-radius_major, radius_major, radius_major, -radius_major, -radius_major] + + ax_xz.plot(rect_x, rect_z, 'r-', linewidth=2) + ax_xz.fill(rect_x, rect_z, 'lightcoral', alpha=0.3) + ax_xz.set_xlim(-length/2*1.2, length/2*1.2) + ax_xz.set_ylim(-radius_major*1.3, radius_major*1.3) + ax_xz.set_xlabel('Z (Å)') + ax_xz.set_ylabel('X (Å) - Major') + ax_xz.set_title('XZ Cross-section (Major axis)') + ax_xz.grid(True, alpha=0.3) + ax_xz.text(0, -radius_major*1.15, f'r_major = {radius_major:.0f} Å', ha='center', fontsize=9) + + # YZ plane (side view along minor axis) + rect_y = [-radius_minor, radius_minor, radius_minor, -radius_minor, -radius_minor] + + ax_yz.plot(rect_x, rect_y, 'g-', linewidth=2) + ax_yz.fill(rect_x, rect_y, 'lightgreen', alpha=0.3) + ax_yz.set_xlim(-length/2*1.2, length/2*1.2) + ax_yz.set_ylim(-radius_minor*1.3, radius_minor*1.3) + ax_yz.set_xlabel('Z (Å)') + ax_yz.set_ylabel('Y (Å) - Minor') + ax_yz.set_title('YZ Cross-section (Minor axis)') + ax_yz.grid(True, alpha=0.3) + ax_yz.text(0, -radius_minor*1.15, f'r_minor = {radius_minor:.0f} Å', ha='center', fontsize=9) + def random(): """Return a random parameter set for the model.""" # V = pi * radius_major * radius_minor * length; diff --git a/sasmodels/models/flexible_cylinder_elliptical.py b/sasmodels/models/flexible_cylinder_elliptical.py index 3bdcd30e8..b68a12d7d 100644 --- a/sasmodels/models/flexible_cylinder_elliptical.py +++ b/sasmodels/models/flexible_cylinder_elliptical.py @@ -121,6 +121,83 @@ "flexible_cylinder_elliptical.c"] has_shape_visualization = True +def create_shape_mesh(params, resolution=50): + import numpy as np + # Flexible cylinder is complex (worm-like chain), showing simplified straight version + length = params.get('length', 1000) + radius = params.get('radius', 20) + axis_ratio = params.get('axis_ratio', 1.5) + kuhn_length = params.get('kuhn_length', 100) + + radius_minor = radius + radius_major = radius * axis_ratio + + # Show as straight elliptical cylinder with annotation about flexibility + theta = np.linspace(0, 2*np.pi, resolution) + z = np.linspace(-length/2, length/2, resolution//2) + theta_mesh, z_mesh = np.meshgrid(theta, z) + + x = radius_major * np.cos(theta_mesh) + y = radius_minor * np.sin(theta_mesh) + + return { + 'cylinder': (x, y, z_mesh), + '_note': f'Simplified view - actual model is flexible with Kuhn length {kuhn_length} Å' + } + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + import numpy as np + radius = params.get('radius', 20) + axis_ratio = params.get('axis_ratio', 1.5) + length = params.get('length', 1000) + kuhn_length = params.get('kuhn_length', 100) + + radius_minor = radius + radius_major = radius * axis_ratio + + # XY plane (top view) - ellipse + theta = np.linspace(0, 2*np.pi, 100) + ellipse_x = radius_major * np.cos(theta) + ellipse_y = radius_minor * np.sin(theta) + + ax_xy.plot(ellipse_x, ellipse_y, 'b-', linewidth=2) + ax_xy.fill(ellipse_x, ellipse_y, 'lightblue', alpha=0.3) + ax_xy.set_xlim(-radius_major*1.3, radius_major*1.3) + ax_xy.set_ylim(-radius_minor*1.3, radius_minor*1.3) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Elliptical)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ax_xy.text(0, 0, 'NOTE:\nFlexible', ha='center', fontsize=8, style='italic') + + # XZ plane - show flexibility with wavy line + z_points = np.linspace(-length/2, length/2, 100) + x_points = radius_major * np.sin(z_points / kuhn_length * 2) # Simplified wave + + ax_xz.plot(z_points, x_points, 'r-', linewidth=2, label='Chain axis') + ax_xz.fill_between(z_points, x_points - radius_major, x_points + radius_major, + alpha=0.2, color='lightcoral') + ax_xz.set_xlim(-length/2*1.2, length/2*1.2) + ax_xz.set_ylim(-radius_major*5, radius_major*5) + ax_xz.set_xlabel('Z (Å)') + ax_xz.set_ylabel('X (Å)') + ax_xz.set_title('XZ Cross-section (Flexible chain)') + ax_xz.grid(True, alpha=0.3) + ax_xz.text(0, radius_major*4.5, f'Kuhn length = {kuhn_length:.0f} Å', ha='center', fontsize=8) + + # YZ plane + y_points = radius_minor * np.cos(z_points / kuhn_length * 2) + ax_yz.plot(z_points, y_points, 'g-', linewidth=2, label='Chain axis') + ax_yz.fill_between(z_points, y_points - radius_minor, y_points + radius_minor, + alpha=0.2, color='lightgreen') + ax_yz.set_xlim(-length/2*1.2, length/2*1.2) + ax_yz.set_ylim(-radius_minor*5, radius_minor*5) + ax_yz.set_xlabel('Z (Å)') + ax_yz.set_ylabel('Y (Å)') + ax_yz.set_title('YZ Cross-section (Flexible)') + ax_yz.grid(True, alpha=0.3) + def random(): """Return a random parameter set for the model.""" length = 10**np.random.uniform(2, 6) diff --git a/sasmodels/models/hollow_cylinder.py b/sasmodels/models/hollow_cylinder.py index bb0d51e6d..2d111133c 100644 --- a/sasmodels/models/hollow_cylinder.py +++ b/sasmodels/models/hollow_cylinder.py @@ -108,6 +108,121 @@ ] has_shape_visualization = True +def create_shape_mesh(params, resolution=50): + import numpy as np + radius = params.get('radius', 20) # Inner/core radius + thickness = params.get('thickness', 10) + length = params.get('length', 400) + + outer_radius = radius + thickness + + # Create inner cylinder surface + theta = np.linspace(0, 2*np.pi, resolution) + z = np.linspace(-length/2, length/2, resolution//2) + theta_mesh, z_mesh = np.meshgrid(theta, z) + + x_inner = radius * np.cos(theta_mesh) + y_inner = radius * np.sin(theta_mesh) + + # Create outer cylinder surface + x_outer = outer_radius * np.cos(theta_mesh) + y_outer = outer_radius * np.sin(theta_mesh) + + # Create end caps (annular disks) + r_cap = np.linspace(radius, outer_radius, resolution//4) + theta_cap = np.linspace(0, 2*np.pi, resolution) + r_cap_mesh, theta_cap_mesh = np.meshgrid(r_cap, theta_cap) + + x_cap = r_cap_mesh * np.cos(theta_cap_mesh) + y_cap = r_cap_mesh * np.sin(theta_cap_mesh) + z_cap_top = np.full_like(x_cap, length/2) + z_cap_bottom = np.full_like(x_cap, -length/2) + + return { + 'inner_cylinder': (x_inner, y_inner, z_mesh), + 'outer_cylinder': (x_outer, y_outer, z_mesh), + 'cap_top': (x_cap, y_cap, z_cap_top), + 'cap_bottom': (x_cap, y_cap, z_cap_bottom) + } + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + import numpy as np + radius = params.get('radius', 20) + thickness = params.get('thickness', 10) + length = params.get('length', 400) + outer_radius = radius + thickness + + # XY plane (top view) - annular ring + theta = np.linspace(0, 2*np.pi, 100) + inner_x = radius * np.cos(theta) + inner_y = radius * np.sin(theta) + outer_x = outer_radius * np.cos(theta) + outer_y = outer_radius * np.sin(theta) + + ax_xy.plot(outer_x, outer_y, 'r-', linewidth=2, label='Outer wall') + ax_xy.fill(outer_x, outer_y, 'lightcoral', alpha=0.3) + ax_xy.plot(inner_x, inner_y, 'w-', linewidth=2, label='Hollow core') + ax_xy.fill(inner_x, inner_y, 'white', alpha=1.0) + + ax_xy.set_xlim(-outer_radius*1.3, outer_radius*1.3) + ax_xy.set_ylim(-outer_radius*1.3, outer_radius*1.3) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Annular)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ax_xy.legend() + + # XZ plane (side view) - two vertical bars + ax_xz.fill([-length/2, -length/2, length/2, length/2], + [-outer_radius, outer_radius, outer_radius, -outer_radius], + 'lightcoral', alpha=0.3) + ax_xz.fill([-length/2, -length/2, length/2, length/2], + [-radius, radius, radius, -radius], + 'white', alpha=1.0) + + # Draw outlines + ax_xz.plot([-length/2, -length/2, length/2, length/2, -length/2], + [-outer_radius, outer_radius, outer_radius, -outer_radius, -outer_radius], + 'r-', linewidth=2, label='Wall') + ax_xz.plot([-length/2, -length/2, length/2, length/2, -length/2], + [-radius, radius, radius, -radius, -radius], + 'b--', linewidth=1, label='Hollow') + + ax_xz.set_xlim(-length/2*1.2, length/2*1.2) + ax_xz.set_ylim(-outer_radius*1.3, outer_radius*1.3) + ax_xz.set_xlabel('Z (Å)') + ax_xz.set_ylabel('X (Å)') + ax_xz.set_title('XZ Cross-section (Side View)') + ax_xz.grid(True, alpha=0.3) + ax_xz.legend() + + # Annotations + ax_xz.text(0, -outer_radius*1.15, f'L = {length:.0f} Å', ha='center', fontsize=9) + ax_xz.text(length/2*0.7, (radius + outer_radius)/2, f't = {thickness:.0f}', fontsize=9) + + # YZ plane (front view) - same as XZ + ax_yz.fill([-length/2, -length/2, length/2, length/2], + [-outer_radius, outer_radius, outer_radius, -outer_radius], + 'lightgreen', alpha=0.3) + ax_yz.fill([-length/2, -length/2, length/2, length/2], + [-radius, radius, radius, -radius], + 'white', alpha=1.0) + ax_yz.plot([-length/2, -length/2, length/2, length/2, -length/2], + [-outer_radius, outer_radius, outer_radius, -outer_radius, -outer_radius], + 'g-', linewidth=2, label='Wall') + ax_yz.plot([-length/2, -length/2, length/2, length/2, -length/2], + [-radius, radius, radius, -radius, -radius], + 'orange', linewidth=1, linestyle='--', label='Hollow') + + ax_yz.set_xlim(-length/2*1.2, length/2*1.2) + ax_yz.set_ylim(-outer_radius*1.3, outer_radius*1.3) + ax_yz.set_xlabel('Z (Å)') + ax_yz.set_ylabel('Y (Å)') + ax_yz.set_title('YZ Cross-section (Front View)') + ax_yz.grid(True, alpha=0.3) + ax_yz.legend() + def random(): """Return a random parameter set for the model.""" length = 10**np.random.uniform(1, 4.7) diff --git a/sasmodels/models/parallelepiped.py b/sasmodels/models/parallelepiped.py index 63a30e45f..b59655aca 100644 --- a/sasmodels/models/parallelepiped.py +++ b/sasmodels/models/parallelepiped.py @@ -240,6 +240,106 @@ ] has_shape_visualization = True +def create_shape_mesh(params, resolution=50): + import numpy as np + length_a = params.get('length_a', 35) + length_b = params.get('length_b', 75) + length_c = params.get('length_c', 400) + + # Create faces of the parallelepiped + faces = {} + + # Front and back faces (x = ±length_a/2) + y = np.linspace(-length_b/2, length_b/2, resolution//4) + z = np.linspace(-length_c/2, length_c/2, resolution//2) + y_mesh, z_mesh = np.meshgrid(y, z) + + faces['front'] = (np.full_like(y_mesh, length_a/2), y_mesh, z_mesh) + faces['back'] = (np.full_like(y_mesh, -length_a/2), y_mesh, z_mesh) + + # Left and right faces (y = ±length_b/2) + x = np.linspace(-length_a/2, length_a/2, resolution//4) + z = np.linspace(-length_c/2, length_c/2, resolution//2) + x_mesh, z_mesh = np.meshgrid(x, z) + + faces['right'] = (x_mesh, np.full_like(x_mesh, length_b/2), z_mesh) + faces['left'] = (x_mesh, np.full_like(x_mesh, -length_b/2), z_mesh) + + # Top and bottom faces (z = ±length_c/2) + x = np.linspace(-length_a/2, length_a/2, resolution//4) + y = np.linspace(-length_b/2, length_b/2, resolution//4) + x_mesh, y_mesh = np.meshgrid(x, y) + + faces['top'] = (x_mesh, y_mesh, np.full_like(x_mesh, length_c/2)) + faces['bottom'] = (x_mesh, y_mesh, np.full_like(x_mesh, -length_c/2)) + + return faces + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + import numpy as np + length_a = params.get('length_a', 35) + length_b = params.get('length_b', 75) + length_c = params.get('length_c', 400) + + # XY plane (top view) - rectangle A x B + rect_xy_x = [-length_a/2, -length_a/2, length_a/2, length_a/2, -length_a/2] + rect_xy_y = [-length_b/2, length_b/2, length_b/2, -length_b/2, -length_b/2] + + ax_xy.plot(rect_xy_x, rect_xy_y, 'b-', linewidth=2, label='A × B face') + ax_xy.fill(rect_xy_x, rect_xy_y, 'lightblue', alpha=0.3) + ax_xy.set_xlim(-length_a/2*1.3, length_a/2*1.3) + ax_xy.set_ylim(-length_b/2*1.3, length_b/2*1.3) + ax_xy.set_xlabel('X (Å) - Length A') + ax_xy.set_ylabel('Y (Å) - Length B') + ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # XZ plane (side view) - rectangle A x C + rect_xz_x = [-length_a/2, -length_a/2, length_a/2, length_a/2, -length_a/2] + rect_xz_z = [-length_c/2, length_c/2, length_c/2, -length_c/2, -length_c/2] + + ax_xz.plot(rect_xz_x, rect_xz_z, 'r-', linewidth=2, label='A × C face') + ax_xz.fill(rect_xz_x, rect_xz_z, 'lightcoral', alpha=0.3) + ax_xz.set_xlim(-length_a/2*1.3, length_a/2*1.3) + ax_xz.set_ylim(-length_c/2*1.3, length_c/2*1.3) + ax_xz.set_xlabel('X (Å) - Length A') + ax_xz.set_ylabel('Z (Å) - Length C') + ax_xz.set_title('XZ Cross-section (Side View)') + ax_xz.grid(True, alpha=0.3) + + # YZ plane (front view) - rectangle B x C + rect_yz_y = [-length_b/2, -length_b/2, length_b/2, length_b/2, -length_b/2] + rect_yz_z = [-length_c/2, length_c/2, length_c/2, -length_c/2, -length_c/2] + + ax_yz.plot(rect_yz_y, rect_yz_z, 'g-', linewidth=2, label='B × C face') + ax_yz.fill(rect_yz_y, rect_yz_z, 'lightgreen', alpha=0.3) + ax_yz.set_xlim(-length_b/2*1.3, length_b/2*1.3) + ax_yz.set_ylim(-length_c/2*1.3, length_c/2*1.3) + ax_yz.set_xlabel('Y (Å) - Length B') + ax_yz.set_ylabel('Z (Å) - Length C') + ax_yz.set_title('YZ Cross-section (Front View)') + ax_yz.grid(True, alpha=0.3) + + # Add dimension annotations + # XY plane + ax_xy.annotate('', xy=(-length_a/2, -length_b/2*1.4), xytext=(length_a/2, -length_b/2*1.4), + arrowprops=dict(arrowstyle='<->', color='black')) + ax_xy.text(0, -length_b/2*1.5, f'A = {length_a:.0f} Å', ha='center', fontsize=10) + + ax_xy.annotate('', xy=(-length_a/2*1.4, -length_b/2), xytext=(-length_a/2*1.4, length_b/2), + arrowprops=dict(arrowstyle='<->', color='black')) + ax_xy.text(-length_a/2*1.5, 0, f'B = {length_b:.0f} Å', ha='center', fontsize=10, rotation=90) + + # XZ plane + ax_xz.annotate('', xy=(-length_a/2, -length_c/2*1.2), xytext=(length_a/2, -length_c/2*1.2), + arrowprops=dict(arrowstyle='<->', color='black')) + ax_xz.text(0, -length_c/2*1.25, f'A = {length_a:.0f} Å', ha='center', fontsize=10) + + ax_xz.annotate('', xy=(-length_a/2*1.2, -length_c/2), xytext=(-length_a/2*1.2, length_c/2), + arrowprops=dict(arrowstyle='<->', color='black')) + ax_xz.text(-length_a/2*1.25, 0, f'C = {length_c:.0f} Å', ha='center', fontsize=10, rotation=90) + def random(): """Return a random parameter set for the model.""" length = 10**np.random.uniform(1, 4.7, size=3) diff --git a/sasmodels/models/pearl_necklace.py b/sasmodels/models/pearl_necklace.py index f0fc9349c..a453b61f6 100644 --- a/sasmodels/models/pearl_necklace.py +++ b/sasmodels/models/pearl_necklace.py @@ -106,6 +106,114 @@ radius_effective_modes = ["equivalent volume sphere"] has_shape_visualization = True +def create_shape_mesh(params, resolution=40): + import numpy as np + radius = params.get('radius', 80.0) + edge_sep = params.get('edge_sep', 350.0) # surface-to-surface distance + thick_string = params.get('thick_string', 2.5) + num_pearls = int(round(params.get('num_pearls', 3))) + num_pearls = max(num_pearls, 1) + + # Spacing between pearl centers + center_step = 2 * radius + edge_sep + z_positions = [ + (i - (num_pearls - 1) / 2.0) * center_step + for i in range(num_pearls) + ] + + # Sphere (pearl) mesh + phi = np.linspace(0, np.pi, resolution // 2) + theta = np.linspace(0, 2 * np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + pearls = {} + for i, z0 in enumerate(z_positions): + x = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z = radius * np.cos(phi_mesh) + z0 + pearls[f'pearl_{i}'] = (x, y, z) + + # String segments as thin cylinders between neighboring pearls + string_radius = thick_string / 2.0 + strings = {} + if num_pearls > 1 and string_radius > 0: + theta_c = np.linspace(0, 2 * np.pi, resolution) + for i in range(num_pearls - 1): + z_start = z_positions[i] + radius + z_end = z_positions[i + 1] - radius + z_seg = np.linspace(z_start, z_end, resolution // 2) + theta_mesh_c, z_seg_mesh = np.meshgrid(theta_c, z_seg) + x_c = string_radius * np.cos(theta_mesh_c) + y_c = string_radius * np.sin(theta_mesh_c) + strings[f'string_{i}'] = (x_c, y_c, z_seg_mesh) + + mesh = {} + mesh.update(pearls) + mesh.update(strings) + return mesh + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + import numpy as np + radius = params.get('radius', 80.0) + edge_sep = params.get('edge_sep', 350.0) + thick_string = params.get('thick_string', 2.5) + num_pearls = int(round(params.get('num_pearls', 3))) + num_pearls = max(num_pearls, 1) + + center_step = 2 * radius + edge_sep + z_positions = np.array( + [(i - (num_pearls - 1) / 2.0) * center_step for i in range(num_pearls)] + ) + + # XY: top view of a single pearl + string cross-section + theta = np.linspace(0, 2 * np.pi, 200) + pearl_x = radius * np.cos(theta) + pearl_y = radius * np.sin(theta) + ax_xy.plot(pearl_x, pearl_y, 'b-', linewidth=2, label='Pearl') + ax_xy.fill(pearl_x, pearl_y, 'lightblue', alpha=0.4) + + if thick_string > 0: + string_r = thick_string / 2.0 + sx = string_r * np.cos(theta) + sy = string_r * np.sin(theta) + ax_xy.plot(sx, sy, 'r--', linewidth=1, label='String') + ax_xy.fill(sx, sy, 'lightcoral', alpha=0.3) + + ax_xy.set_xlim(-radius * 1.4, radius * 1.4) + ax_xy.set_ylim(-radius * 1.4, radius * 1.4) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Single pearl + string)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ax_xy.legend() + + # XZ: chain of circles along Z + for z0 in z_positions: + circle_z = radius * np.sin(theta) + circle_x = radius * np.cos(theta) + ax_xz.plot(z0 + circle_z, circle_x, 'b-', alpha=0.7) + + # Draw string as line along centers + ax_xz.plot(z_positions, np.zeros_like(z_positions), 'r-', linewidth=2, label='String axis') + ax_xz.set_xlabel('Z (Å)') + ax_xz.set_ylabel('X (Å)') + ax_xz.set_title('XZ Cross-section (Chain of pearls)') + ax_xz.grid(True, alpha=0.3) + ax_xz.legend() + + # YZ: same as XZ but in Y + for z0 in z_positions: + circle_z = radius * np.sin(theta) + circle_y = radius * np.cos(theta) + ax_yz.plot(z0 + circle_z, circle_y, 'g-', alpha=0.7) + ax_yz.plot(z_positions, np.zeros_like(z_positions), 'r-', linewidth=2, label='String axis') + ax_yz.set_xlabel('Z (Å)') + ax_yz.set_ylabel('Y (Å)') + ax_yz.set_title('YZ Cross-section (Chain of pearls)') + ax_yz.grid(True, alpha=0.3) + ax_yz.legend() + def random(): """Return a random parameter set for the model.""" radius = 10**np.random.uniform(1, 3) # 1 - 1000 diff --git a/sasmodels/models/pringle.py b/sasmodels/models/pringle.py index 47e502fd5..5eef8002b 100644 --- a/sasmodels/models/pringle.py +++ b/sasmodels/models/pringle.py @@ -83,6 +83,91 @@ "radius"] has_shape_visualization = True +def create_shape_mesh(params, resolution=50): + import numpy as np + radius = params.get('radius', 60) + thickness = params.get('thickness', 10) + alpha = params.get('alpha', 0.001) + beta = params.get('beta', 0.02) + + # Create saddle surface (hyperbolic paraboloid approximation) + r = np.linspace(0, radius, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + r_mesh, theta_mesh = np.meshgrid(r, theta) + + x = r_mesh * np.cos(theta_mesh) + y = r_mesh * np.sin(theta_mesh) + + # Saddle shape: z = alpha * x^2 - beta * y^2 + z = alpha * x**2 - beta * y**2 + + # Top and bottom surfaces (offset by thickness) + z_top = z + thickness/2 + z_bottom = z - thickness/2 + + return { + 'top_surface': (x, y, z_top), + 'bottom_surface': (x, y, z_bottom) + } + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + import numpy as np + radius = params.get('radius', 60) + thickness = params.get('thickness', 10) + alpha = params.get('alpha', 0.001) + beta = params.get('beta', 0.02) + + # XY plane (top view) - circular outline + theta = np.linspace(0, 2*np.pi, 100) + circle_x = radius * np.cos(theta) + circle_y = radius * np.sin(theta) + + ax_xy.plot(circle_x, circle_y, 'b-', linewidth=2, label='Pringle edge') + ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.3) + ax_xy.set_xlim(-radius*1.3, radius*1.3) + ax_xy.set_ylim(-radius*1.3, radius*1.3) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # XZ plane (side view) - parabolic curve + x_profile = np.linspace(-radius, radius, 100) + z_profile = alpha * x_profile**2 + + ax_xz.plot(x_profile, z_profile + thickness/2, 'r-', linewidth=2, label='Top surface') + ax_xz.plot(x_profile, z_profile - thickness/2, 'r-', linewidth=2, label='Bottom surface') + ax_xz.fill_between(x_profile, z_profile - thickness/2, z_profile + thickness/2, + alpha=0.3, color='lightcoral') + + max_z = alpha * radius**2 + thickness + ax_xz.set_xlim(-radius*1.2, radius*1.2) + ax_xz.set_ylim(-max_z*1.5, max_z*1.5) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section (Parabolic)') + ax_xz.grid(True, alpha=0.3) + ax_xz.text(0, max_z*1.3, f'α = {alpha:.4f}', ha='center', fontsize=9) + + # YZ plane (side view) - parabolic curve (downward) + y_profile = np.linspace(-radius, radius, 100) + z_profile_y = -beta * y_profile**2 + + ax_yz.plot(y_profile, z_profile_y + thickness/2, 'g-', linewidth=2, label='Top surface') + ax_yz.plot(y_profile, z_profile_y - thickness/2, 'g-', linewidth=2, label='Bottom surface') + ax_yz.fill_between(y_profile, z_profile_y - thickness/2, z_profile_y + thickness/2, + alpha=0.3, color='lightgreen') + + max_z_y = beta * radius**2 + thickness + ax_yz.set_xlim(-radius*1.2, radius*1.2) + ax_yz.set_ylim(-max_z_y*1.5, max_z_y*1.5) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section (Hyperbolic)') + ax_yz.grid(True, alpha=0.3) + ax_yz.text(0, -max_z_y*1.3, f'β = {beta:.4f}', ha='center', fontsize=9) + def random(): """Return a random parameter set for the model.""" alpha, beta = 10**np.random.uniform(-1, 1, size=2) diff --git a/sasmodels/models/sphere.py b/sasmodels/models/sphere.py index 39e69e1d4..289ffc21a 100644 --- a/sasmodels/models/sphere.py +++ b/sasmodels/models/sphere.py @@ -80,28 +80,28 @@ def create_shape_mesh(params, resolution=50): """Create 3D mesh for sphere visualization.""" import numpy as np radius = params.get('radius', 50) - + # Create sphere phi = np.linspace(0, np.pi, resolution//2) theta = np.linspace(0, 2*np.pi, resolution) phi_mesh, theta_mesh = np.meshgrid(phi, theta) - + x = radius * np.sin(phi_mesh) * np.cos(theta_mesh) y = radius * np.sin(phi_mesh) * np.sin(theta_mesh) z = radius * np.cos(phi_mesh) - + return {'sphere': (x, y, z)} def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): """Plot 2D cross-sections of the sphere.""" import numpy as np radius = params.get('radius', 50) - + # Create circle for all cross-sections (sphere is symmetric) theta = np.linspace(0, 2*np.pi, 100) circle_x = radius * np.cos(theta) circle_y = radius * np.sin(theta) - + # XY plane (top view) ax_xy.plot(circle_x, circle_y, 'b-', linewidth=2) ax_xy.set_xlim(-radius*1.2, radius*1.2) @@ -111,7 +111,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_title('XY Cross-section (Top View)') ax_xy.set_aspect('equal') ax_xy.grid(True, alpha=0.3) - + # XZ plane (side view) ax_xz.plot(circle_x, circle_y, 'r-', linewidth=2) ax_xz.set_xlim(-radius*1.2, radius*1.2) @@ -121,7 +121,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_title('XZ Cross-section (Side View)') ax_xz.set_aspect('equal') ax_xz.grid(True, alpha=0.3) - + # YZ plane (front view) ax_yz.plot(circle_x, circle_y, 'g-', linewidth=2) ax_yz.set_xlim(-radius*1.2, radius*1.2) diff --git a/sasmodels/models/stacked_disks.py b/sasmodels/models/stacked_disks.py index d639946c1..aa5024126 100644 --- a/sasmodels/models/stacked_disks.py +++ b/sasmodels/models/stacked_disks.py @@ -149,6 +149,105 @@ source = ["lib/polevl.c", "lib/sas_J1.c", "lib/gauss76.c", "stacked_disks.c"] has_shape_visualization = True +def create_shape_mesh(params, resolution=40): + import numpy as np + thick_core = params.get('thick_core', 10.0) + thick_layer = params.get('thick_layer', 10.0) + radius = params.get('radius', 15.0) + n_stacking = max(int(round(params.get('n_stacking', 1.0))), 1) + + # Period between disk centers (approximate): core + two layers + d = thick_core + 2 * thick_layer + total_height = n_stacking * d + z_centers = [ + (i - (n_stacking - 1) / 2.0) * d + for i in range(n_stacking) + ] + + theta = np.linspace(0, 2 * np.pi, resolution) + z_disk = np.linspace(-d / 2, d / 2, resolution // 3) + theta_mesh, z_local = np.meshgrid(theta, z_disk) + x = radius * np.cos(theta_mesh) + y = radius * np.sin(theta_mesh) + + mesh = {} + for i, z0 in enumerate(z_centers): + z = z_local + z0 + mesh[f'disk_{i}'] = (x, y, z) + + return mesh + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + import numpy as np + thick_core = params.get('thick_core', 10.0) + thick_layer = params.get('thick_layer', 10.0) + radius = params.get('radius', 15.0) + n_stacking = max(int(round(params.get('n_stacking', 1.0))), 1) + + d = thick_core + 2 * thick_layer + z_centers = np.array( + [(i - (n_stacking - 1) / 2.0) * d for i in range(n_stacking)] + ) + total_height = n_stacking * d + + # XY: top view of a single disk + theta = np.linspace(0, 2 * np.pi, 200) + circle_x = radius * np.cos(theta) + circle_y = radius * np.sin(theta) + ax_xy.plot(circle_x, circle_y, 'b-', linewidth=2) + ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.4) + ax_xy.set_xlim(-radius * 1.3, radius * 1.3) + ax_xy.set_ylim(-radius * 1.3, radius * 1.3) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Disk)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # XZ: side view through center, show layered stack + for z0 in z_centers: + # Core region + core_z_top = z0 + thick_core / 2 + core_z_bottom = z0 - thick_core / 2 + # Layers above and below + layer_top_top = core_z_top + thick_layer + layer_top_bottom = core_z_top + layer_bottom_top = core_z_bottom + layer_bottom_bottom = core_z_bottom - thick_layer + + # Draw layers (rectangles along Z) + ax_xz.fill( + [radius, radius, -radius, -radius], + [layer_bottom_bottom, layer_bottom_top, layer_bottom_top, layer_bottom_bottom], + color='lightcoral', alpha=0.4 + ) + ax_xz.fill( + [radius, radius, -radius, -radius], + [layer_top_bottom, layer_top_top, layer_top_top, layer_top_bottom], + color='lightcoral', alpha=0.4 + ) + # Core + ax_xz.fill( + [radius, radius, -radius, -radius], + [core_z_bottom, core_z_top, core_z_top, core_z_bottom], + color='lightblue', alpha=0.6 + ) + + ax_xz.set_xlim(-radius * 1.5, radius * 1.5) + ax_xz.set_ylim(-total_height / 2 * 1.2, total_height / 2 * 1.2) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section (Stack of core+layer disks)') + ax_xz.grid(True, alpha=0.3) + + # YZ: identical to XZ (axisymmetric) + ax_yz.set_xlim(-radius * 1.5, radius * 1.5) + ax_yz.set_ylim(-total_height / 2 * 1.2, total_height / 2 * 1.2) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section (Stack of disks)') + ax_yz.grid(True, alpha=0.3) + def random(): """Return a random parameter set for the model.""" radius = 10**np.random.uniform(1, 4.7) From 7ed1af06ca2f31e9e5feeb50977b9017f3ea6011 Mon Sep 17 00:00:00 2001 From: Wojtek Potrzebowski Date: Tue, 23 Dec 2025 13:33:26 +0100 Subject: [PATCH 3/7] Added new models and exploration tools --- explore/add_visualization_to_models.py | 153 ++ explore/sasmodels_shape_gui.py | 350 ++++ explore/shape_visualizer.py | 1866 +++++++++++++++++ sasmodels/__init__.py | 4 +- sasmodels/models/barbell.py | 145 +- sasmodels/models/capped_cylinder.py | 142 +- sasmodels/models/core_shell_bicelle.py | 118 +- sasmodels/models/core_shell_ellipsoid.py | 102 +- sasmodels/models/core_shell_parallelepiped.py | 112 +- sasmodels/models/flexible_cylinder.py | 78 +- sasmodels/models/fuzzy_sphere.py | 101 +- sasmodels/models/hollow_rectangular_prism.py | 117 +- .../hollow_rectangular_prism_thin_walls.py | 84 +- sasmodels/models/linear_pearls.py | 99 +- sasmodels/models/multilayer_vesicle.py | 132 +- sasmodels/models/rectangular_prism.py | 89 +- sasmodels/models/superball.py | 99 +- sasmodels/models/triaxial_ellipsoid.py | 78 +- sasmodels/models/vesicle.py | 91 +- 19 files changed, 3865 insertions(+), 95 deletions(-) create mode 100644 explore/add_visualization_to_models.py create mode 100644 explore/sasmodels_shape_gui.py create mode 100644 explore/shape_visualizer.py diff --git a/explore/add_visualization_to_models.py b/explore/add_visualization_to_models.py new file mode 100644 index 000000000..acf2b76e0 --- /dev/null +++ b/explore/add_visualization_to_models.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +""" +Script to extract visualization code from shape_visualizer.py and add to model files. +""" +import os +import re + +# Read shape_visualizer.py +with open('shape_visualizer.py') as f: + viz_content = f.read() + +# Model to visualizer class mapping +MODELS = { + 'cylinder': ('CylinderVisualizer', 250), + 'core_shell_cylinder': ('CoreShellCylinderVisualizer', 721), + 'capped_cylinder': ('CappedCylinderVisualizer', 553), + 'ellipsoid': ('EllipsoidVisualizer', 341), + 'parallelepiped': ('ParallelepipedVisualizer', 430), + 'core_shell_bicelle_elliptical': ('CoreShellBicelleEllipticalVisualizer', 1012), + 'elliptical_cylinder': ('EllipticalCylinderVisualizer', 1189), + 'hollow_cylinder': ('HollowCylinderVisualizer', 1291), + 'pringle': ('PringleVisualizer', 1674), + 'pearl_necklace': ('PearlNecklaceVisualizer', 1427), + 'stacked_disks': ('StackedDisksVisualizer', 1555), + 'flexible_cylinder_elliptical': ('FlexibleCylinderEllipticalVisualizer', 1784), +} + +def extract_class_methods(class_name, start_line, content_lines): + """Extract create_mesh and _plot_cross_sections methods from a class.""" + # Find class start + class_start = None + for i in range(start_line - 1, min(start_line + 50, len(content_lines))): + if f'class {class_name}' in content_lines[i]: + class_start = i + break + + if class_start is None: + return None, None + + # Find class end (next class or end of file) + class_end = len(content_lines) + for i in range(class_start + 1, len(content_lines)): + if content_lines[i].startswith('class ') and i > class_start: + class_end = i + break + + class_lines = content_lines[class_start:class_end] + class_text = '\n'.join(class_lines) + + # Extract create_mesh + create_mesh_match = re.search( + r'def create_mesh\(self[^)]*\):.*?(?=\n def |\nclass |\Z)', + class_text, re.DOTALL + ) + + # Extract _plot_cross_sections + plot_match = re.search( + r'def _plot_cross_sections\(self[^)]*\):.*?(?=\n def |\nclass |\Z)', + class_text, re.DOTALL + ) + + if not create_mesh_match or not plot_match: + return None, None + + create_mesh_body = create_mesh_match.group(0) + plot_body = plot_match.group(0) + + # Remove 'self' parameter and fix indentation + create_mesh_body = re.sub(r'def create_mesh\(self[^)]*\):', 'def create_shape_mesh(params, resolution=50):', create_mesh_body) + create_mesh_body = re.sub(r'\bself\.', '', create_mesh_body) + # Fix indentation - remove 4 spaces from each line + create_lines = create_mesh_body.split('\n') + create_fixed = [] + for line in create_lines: + if line.startswith(' '): + create_fixed.append(line[4:]) + else: + create_fixed.append(line) + create_mesh_body = '\n'.join(create_fixed) + + plot_body = re.sub(r'def _plot_cross_sections\(self[^)]*\):', 'def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params):', plot_body) + plot_body = re.sub(r'\bself\.', '', plot_body) + plot_lines = plot_body.split('\n') + plot_fixed = [] + for line in plot_lines: + if line.startswith(' '): + plot_fixed.append(line[4:]) + else: + plot_fixed.append(line) + plot_body = '\n'.join(plot_fixed) + + return create_mesh_body, plot_body + +# Process each model +content_lines = viz_content.split('\n') +for model_name, (class_name, approx_line) in MODELS.items(): + model_file = f'../sasmodels/models/{model_name}.py' + + if not os.path.exists(model_file): + print(f"Warning: {model_file} not found") + continue + + # Check if already has functions + with open(model_file) as f: + model_content = f.read() + + if 'def create_shape_mesh' in model_content: + print(f"Skipping {model_name} - already has visualization functions") + continue + + # Extract methods + create_mesh_code, plot_code = extract_class_methods(class_name, approx_line, content_lines) + + if not create_mesh_code or not plot_code: + print(f"Warning: Could not extract code for {model_name}") + continue + + # Add import numpy statements + create_mesh_code = ' import numpy as np\n' + create_mesh_code.split('\n', 1)[1] if '\n' in create_mesh_code else create_mesh_code + plot_code = ' import numpy as np\n' + plot_code.split('\n', 1)[1] if '\n' in plot_code else plot_code + + # Add docstrings + create_mesh_code = f' """Create 3D mesh for {model_name} visualization."""\n' + create_mesh_code + plot_code = f' """Plot 2D cross-sections of the {model_name}."""\n' + plot_code + + # Wrap in function definitions + create_mesh_func = f'def create_shape_mesh(params, resolution=50):\n{create_mesh_code}' + plot_func = f'def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params):\n{plot_code}' + + # Find insertion point + lines = model_content.split('\n') + insert_idx = None + for i, line in enumerate(lines): + if 'has_shape_visualization = True' in line: + insert_idx = i + 1 + break + + if insert_idx is None: + print(f"Warning: Could not find insertion point for {model_name}") + continue + + # Insert functions + new_lines = lines[:insert_idx] + [''] + [create_mesh_func] + [''] + [plot_func] + [''] + lines[insert_idx:] + new_content = '\n'.join(new_lines) + + # Write back + with open(model_file, 'w') as f: + f.write(new_content) + + print(f"Added visualization functions to {model_name}") + +print("\nDone!") + diff --git a/explore/sasmodels_shape_gui.py b/explore/sasmodels_shape_gui.py new file mode 100644 index 000000000..6c09d51fa --- /dev/null +++ b/explore/sasmodels_shape_gui.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +""" +Simple GUI for SASModels Shape Visualizer + +Features: +- Lists available shape models (form-factor models with geometry) +- Lets you select a model and plot it with default parameters +- Optional toggle for cross-section subplots + +This is a lightweight front-end around `sasmodels_shape_visualizer`. + +Note on macOS: +- All matplotlib/Tkinter GUI work is kept on the main thread to avoid + NSException / Cocoa threading issues. +""" + +import tkinter as tk +from tkinter import messagebox, ttk + +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +from matplotlib.figure import Figure +from matplotlib.gridspec import GridSpec +from mpl_toolkits.mplot3d import Axes3D # noqa: F401 needed to enable 3D projection + +from shape_visualizer import SASModelsLoader, SASModelsShapeDetector + + +class ShapeVisualizerGUI(tk.Tk): + """Main GUI window for selecting and plotting models.""" + + def __init__(self) -> None: + super().__init__() + self.title("SASModels Shape Visualizer") + + # Make window reasonably sized (wider to accommodate new layout) + self.geometry("1200x700") + + # Track last plotted model so we don't replot on the same selection + self._last_plotted_name: str | None = None + self._canvas: FigureCanvasTkAgg | None = None + # Map listbox row index -> index into self.models, or None for category headers + self._list_index_map: list[int | None] = [] + + # UI Elements + self._create_widgets() + + # Load model list + self.models = self._load_models() + self._populate_model_list() + + def _create_widgets(self) -> None: + """Create all widgets.""" + # Overall grid: + # - Column 0: model list (full height) + # - Column 1: description on top, plot below + # - Row 2: controls + self.columnconfigure(0, weight=0) + self.columnconfigure(1, weight=1) + self.rowconfigure(0, weight=0) + self.rowconfigure(1, weight=1) + self.rowconfigure(2, weight=0) + + # Left: list of models (full height) + list_frame = ttk.Frame(self) + list_frame.grid(row=0, column=0, rowspan=2, sticky="nsew", padx=(10, 5), pady=10) + list_frame.columnconfigure(0, weight=1) + list_frame.rowconfigure(1, weight=1) + + ttk.Label(list_frame, text="Shape Models").grid(row=0, column=0, sticky="w") + + self.model_listbox = tk.Listbox(list_frame, exportselection=False) + self.model_listbox.grid(row=1, column=0, sticky="nsew", pady=(5, 0)) + + scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=self.model_listbox.yview) + scrollbar.grid(row=1, column=1, sticky="ns") + self.model_listbox.configure(yscrollcommand=scrollbar.set) + + self.model_listbox.bind("<>", self._on_model_select) + self.model_listbox.bind("", self._on_model_double_click) + + # Right-top: details (model description at the top) + info_frame = ttk.Frame(self) + info_frame.grid(row=0, column=1, sticky="nsew", padx=(5, 10), pady=(10, 5)) + info_frame.columnconfigure(0, weight=1) + info_frame.rowconfigure(0, weight=1) + + self.info_text = tk.Text(info_frame, wrap="word", height=6) + self.info_text.grid(row=0, column=0, sticky="nsew") + self.info_text.configure(state="disabled") + + # Right-bottom: plot area (embedded matplotlib figure) + self.plot_frame = ttk.Frame(self) + self.plot_frame.grid(row=1, column=1, sticky="nsew", padx=(5, 10), pady=(5, 10)) + self.plot_frame.columnconfigure(0, weight=1) + self.plot_frame.rowconfigure(0, weight=1) + + # Bottom: buttons and options + bottom_frame = ttk.Frame(self) + bottom_frame.grid(row=2, column=0, columnspan=2, sticky="ew", padx=10, pady=(0, 10)) + bottom_frame.columnconfigure(0, weight=1) + bottom_frame.columnconfigure(1, weight=0) + bottom_frame.columnconfigure(2, weight=0) + + self.cross_sections_var = tk.BooleanVar(value=True) + self.wireframe_var = tk.BooleanVar(value=False) + + options_frame = ttk.Frame(bottom_frame) + options_frame.grid(row=0, column=0, sticky="w") + ttk.Checkbutton( + options_frame, + text="Show cross-sections", + variable=self.cross_sections_var, + ).grid(row=0, column=0, padx=(0, 15)) + ttk.Checkbutton( + options_frame, + text="Wireframe", + variable=self.wireframe_var, + ).grid(row=0, column=1) + + ttk.Button( + bottom_frame, + text="Plot Selected", + command=self._on_plot_clicked, + ).grid(row=0, column=1, padx=(10, 5)) + + ttk.Button( + bottom_frame, + text="Close", + command=self.destroy, + ).grid(row=0, column=2, padx=(5, 0)) + + def _load_models(self): + """Load available shape models.""" + models = SASModelsLoader.list_available_models() + shape_models = [] + for name in models: + info = SASModelsLoader.load_model_info(name) + if not info: + continue + category = info.get("category", "") + if isinstance(category, str) and category.startswith("shape:"): + shape_models.append((name, info)) + # Sort by category, then model name + shape_models.sort(key=lambda x: (x[1].get("category", ""), x[0])) + return shape_models + + def _populate_model_list(self) -> None: + """Fill listbox with model names.""" + self.model_listbox.delete(0, tk.END) + self._list_index_map = [] + + current_category: str | None = None + for idx, (name, info) in enumerate(self.models): + category = info.get("category", "shape:other") + if category != current_category: + current_category = category + # Insert category header row + header = f"[{category}]" + self.model_listbox.insert(tk.END, header) + self._list_index_map.append(None) + # Insert model row, indented under category + # Check if visualization is available + has_viz = info.get("has_shape_visualization", False) + if has_viz: + display_name = f" {name}" + else: + # Add visual indicator for models without visualization + display_name = f" ○ {name}" + self.model_listbox.insert(tk.END, display_name) + self._list_index_map.append(idx) + + def _get_selected_model(self): + """Return (name, info) for current selection, or (None, None).""" + selection = self.model_listbox.curselection() + if not selection: + return None, None + row = selection[0] + if row >= len(self._list_index_map): + return None, None + model_idx = self._list_index_map[row] + if model_idx is None: + # Category header selected; treat as no model selected + return None, None + return self.models[model_idx] + + def _on_model_select(self, event=None) -> None: # noqa: ARG002 + """Update info pane when selection changes.""" + name, info = self._get_selected_model() + self.info_text.configure(state="normal") + self.info_text.delete("1.0", tk.END) + if not name: + self.info_text.insert("1.0", "Select a model to see details.") + else: + self._fill_info_text(name, info) + self.info_text.configure(state="disabled") + + # Automatically plot when a new model is selected + # Use a short defer to allow listbox state to settle + self.after(100, self._auto_plot_if_changed) + + def _auto_plot_if_changed(self) -> None: + """Plot automatically if the selection changed since last plot.""" + name, info = self._get_selected_model() + if not name: + return + if name == self._last_plotted_name: + return + # Plot synchronously on main thread (safe for Tk/macos) + self._plot_model(name, info) + + def _fill_info_text(self, name: str, info: dict) -> None: + """Fill the info text widget with model metadata.""" + title = info.get("title", "") + category = info.get("category", "") + description = info.get("description", "").strip() + + text_lines = [] + text_lines.append(f"Model: {name}") + if title: + text_lines.append(f"Title: {title}") + if category: + text_lines.append(f"Category: {category}") + text_lines.append("") + + # Show volume/orientation parameters + text_lines.append("Key parameters:") + params = info.get("parameters", []) + for p in params: + if len(p) < 5: + continue + pname, units, default, _bounds, ptype, *pdesc = p + if ptype not in ("volume", "orientation"): + continue + desc = pdesc[0] if pdesc else "" + unit_str = f" [{units}]" if units else "" + text_lines.append(f" - {pname}{unit_str} ({ptype}), default={default} {desc}") + + if description: + text_lines.append("") + text_lines.append("Description:") + text_lines.append(description.splitlines()[0]) + + self.info_text.insert("1.0", "\n".join(text_lines)) + + def _on_model_double_click(self, event=None) -> None: # noqa: ARG002 + """Double-click to plot.""" + self._on_plot_clicked() + + def _on_plot_clicked(self) -> None: + """Plot selected model with default parameters.""" + name, info = self._get_selected_model() + if not name: + messagebox.showinfo("No selection", "Please select a model first.") + return + self._plot_model(name, info) + + def _plot_model(self, name: str, info: dict) -> None: + """Create visualizer and show plot embedded in the GUI.""" + try: + visualizer = SASModelsShapeDetector.create_visualizer(info) + if visualizer is None: + messagebox.showerror("Visualizer error", f"No visualizer available for model '{name}'.") + return + params = visualizer.get_default_params() + show_cross = self.cross_sections_var.get() + show_wire = self.wireframe_var.get() + + # Create a new matplotlib Figure + # Use larger figure size to accommodate the new layout + fig = Figure(figsize=(10, 7), dpi=100) + + if show_cross: + # Use GridSpec for flexible layout: + # - Left column (wider): 3D plot + # - Right column (narrower): 3 cross-sections stacked vertically + # Increased hspace to prevent legend overlap between cross-sections + gs = GridSpec(3, 2, figure=fig, width_ratios=[3, 1], hspace=0.6, wspace=0.3) + + # 3D plot takes entire left column (all 3 rows) + ax_3d = fig.add_subplot(gs[:, 0], projection="3d") + + # Cross-sections stacked in right column + ax_xy = fig.add_subplot(gs[0, 1]) + ax_xz = fig.add_subplot(gs[1, 1]) + ax_yz = fig.add_subplot(gs[2, 1]) + else: + ax_3d = fig.add_subplot(111, projection="3d") + ax_xy = ax_xz = ax_yz = None # type: ignore[assignment] + + # Use same mesh logic as CLI visualizer + mesh_data = visualizer.create_mesh(params) + visualizer._plot_mesh_components(ax_3d, mesh_data, show_wire) # type: ignore[attr-defined] + visualizer._setup_3d_axis(ax_3d, mesh_data, params) # type: ignore[attr-defined] + + # Remove axes completely, title, and add parameter text + ax_3d.set_axis_off() # Hide all axes + ax_3d.set_title('') # Remove title (redundant with parameter box) + + # Get volume parameters to display + volume_params = visualizer.get_volume_params() + param_text_lines = [] + + # Add model name + model_name = name.replace('_', ' ').title() + param_text_lines.append(f"{model_name}") + param_text_lines.append("") # Empty line + + # Add key parameters + for param in volume_params[:5]: # Show up to 5 key parameters + if param in params: + value = params[param] + # Format nicely: remove underscores, capitalize, show value + param_display = param.replace('_', ' ').title() + param_text_lines.append(f"{param_display} = {value:.1f} Å") + + if param_text_lines: + param_text = '\n'.join(param_text_lines) + # Add text annotation in upper left corner of the plot + ax_3d.text2D(0.02, 0.98, param_text, transform=ax_3d.transAxes, + fontsize=10, verticalalignment='top', + bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8)) + + if show_cross and hasattr(visualizer, "_plot_cross_sections"): + visualizer._plot_cross_sections(ax_xy, ax_xz, ax_yz, params) # type: ignore[attr-defined] + + fig.tight_layout() + + # Replace any existing canvas + if self._canvas is not None: + self._canvas.get_tk_widget().destroy() + + self._canvas = FigureCanvasTkAgg(fig, master=self.plot_frame) + self._canvas.draw() + self._canvas.get_tk_widget().grid(row=0, column=0, sticky="nsew") + + self._last_plotted_name = name + except Exception as exc: # noqa: BLE001 + messagebox.showerror("Plot error", f"Error plotting model '{name}':\n{exc}") + + +def main() -> None: + """Run the GUI.""" + app = ShapeVisualizerGUI() + app.mainloop() + + +if __name__ == "__main__": + main() + + diff --git a/explore/shape_visualizer.py b/explore/shape_visualizer.py new file mode 100644 index 000000000..5e8e7ce0c --- /dev/null +++ b/explore/shape_visualizer.py @@ -0,0 +1,1866 @@ +#!/usr/bin/env python3 +""" +Generalized SASModels Shape Visualizer + +This module provides a comprehensive framework for visualizing 3D shapes +from any sasmodels model that has geometric parameters. It automatically +detects shape types and generates appropriate visualizations. + +Features: +- Automatic shape detection and classification +- Support for all major sasmodels shape categories +- Extensible architecture for adding new shapes +- Interactive 3D visualization with parameter controls +- Cross-section views and parameter comparison +- Export capabilities for high-quality figures +""" + +import argparse +import importlib +import os +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional, Tuple + +import matplotlib.pyplot as plt +import numpy as np + + +class ShapeVisualizer(ABC): + """Abstract base class for shape visualizers.""" + + def __init__(self, model_info: Dict[str, Any]): + self.model_info = model_info + self.name = model_info.get('name', 'unknown') + self.parameters = model_info.get('parameters', []) + self.category = model_info.get('category', 'unknown') + self._model_module = None # Cache for model module + + def _get_model_module(self): + """Get the model module, importing it if necessary.""" + if self._model_module is None: + try: + module_path = f'sasmodels.models.{self.name}' + self._model_module = importlib.import_module(module_path) + except ImportError: + self._model_module = False # Mark as failed + return self._model_module if self._model_module else None + + @abstractmethod + def create_mesh(self, params: Dict[str, float], resolution: int = 50) -> Dict[str, Any]: + """Create 3D mesh data for the shape.""" + pass + + @abstractmethod + def get_default_params(self) -> Dict[str, float]: + """Get default parameter values for the shape.""" + pass + + def get_volume_params(self) -> List[str]: + """Get list of volume-related parameters.""" + volume_params = [] + for param in self.parameters: + if len(param) >= 5 and param[4] == "volume": + volume_params.append(param[0]) + return volume_params + + def get_orientation_params(self) -> List[str]: + """Get list of orientation parameters.""" + orientation_params = [] + for param in self.parameters: + if len(param) >= 5 and param[4] == "orientation": + orientation_params.append(param[0]) + return orientation_params + + def plot_3d(self, params: Dict[str, float] = None, + save_file: str = None, show_wireframe: bool = False, + show_cross_sections: bool = True, + figsize: Tuple[int, int] = (16, 10)) -> None: + """Create 3D visualization of the shape with optional cross-sections.""" + if params is None: + params = self.get_default_params() + + try: + mesh_data = self.create_mesh(params) + except Exception as e: + print(f"Error creating mesh for {self.name}: {e}") + return + + if show_cross_sections: + # Create subplot layout: 3D view + cross-sections + fig = plt.figure(figsize=figsize) + + # Main 3D plot (left side, larger) + ax_3d = fig.add_subplot(2, 3, (1, 4), projection='3d') + + # Cross-section plots (right side) + ax_xy = fig.add_subplot(2, 3, 2) # XY plane (top view) + ax_xz = fig.add_subplot(2, 3, 3) # XZ plane (side view) + ax_yz = fig.add_subplot(2, 3, 5) # YZ plane (front view) + + # Plot 3D shape + self._plot_mesh_components(ax_3d, mesh_data, show_wireframe) + self._setup_3d_axis(ax_3d, mesh_data, params) + + # Plot cross-sections + self._plot_cross_sections(ax_xy, ax_xz, ax_yz, params) + + else: + # Original single 3D plot + fig = plt.figure(figsize=figsize) + ax_3d = fig.add_subplot(111, projection='3d') + + # Plot the shape components + self._plot_mesh_components(ax_3d, mesh_data, show_wireframe) + self._setup_3d_axis(ax_3d, mesh_data, params) + + # Add parameter info box + self._add_parameter_info(fig, params) + + plt.tight_layout() + + if save_file: + plt.savefig(save_file, dpi=300, bbox_inches='tight') + print(f"Plot saved as {save_file}") + + plt.show() + + def _setup_3d_axis(self, ax, mesh_data: Dict[str, Any], params: Dict[str, float]): + """Setup 3D axis labels, title, and limits.""" + # Set labels and title + ax.set_xlabel('X (Å)', fontsize=12) + ax.set_ylabel('Y (Å)', fontsize=12) + ax.set_zlabel('Z (Å)', fontsize=12) + + # Create title with parameters + title = f'{self.name.replace("_", " ").title()}\n' + volume_params = self.get_volume_params() + for param in volume_params[:3]: # Show first 3 volume parameters + if param in params: + title += f'{param} = {params[param]:.1f} Å, ' + title = title.rstrip(', ') + ax.set_title(title, fontsize=14, pad=20) + + # Set aspect ratio and limits + self._set_plot_limits(ax, mesh_data, params) + + @abstractmethod + def _plot_cross_sections(self, ax_xy, ax_xz, ax_yz, params: Dict[str, float]): + """Plot 2D cross-sections of the shape.""" + pass + + def _plot_mesh_components(self, ax, mesh_data: Dict[str, Any], show_wireframe: bool): + """Plot mesh components on the 3D axis.""" + colors = ['lightblue', 'lightcoral', 'lightgreen', 'lightyellow', 'lightpink'] + color_idx = 0 + + for component_name, component_data in mesh_data.items(): + if component_name.startswith('_'): # Skip metadata + continue + + if isinstance(component_data, tuple) and len(component_data) == 3: + x, y, z = component_data + color = colors[color_idx % len(colors)] + + if show_wireframe: + ax.plot_wireframe(x, y, z, alpha=0.6, color=color, linewidth=0.5) + else: + ax.plot_surface(x, y, z, alpha=0.7, color=color, + edgecolor='none', shade=True) + color_idx += 1 + + def _set_plot_limits(self, ax, mesh_data: Dict[str, Any], params: Dict[str, float]): + """Set appropriate plot limits based on shape dimensions.""" + # Default limits - subclasses can override + max_dim = 100 + volume_params = self.get_volume_params() + if volume_params and volume_params[0] in params: + max_dim = max(max_dim, params[volume_params[0]] * 1.2) + + ax.set_xlim([-max_dim, max_dim]) + ax.set_ylim([-max_dim, max_dim]) + ax.set_zlim([-max_dim, max_dim]) + + def _add_parameter_info(self, fig, params: Dict[str, float]): + """Add parameter information box to the figure.""" + param_text = f'Parameters ({self.category}):\n' + volume_params = self.get_volume_params() + for param in volume_params: + if param in params: + param_text += f'• {param}: {params[param]:.1f} Å\n' + + plt.figtext(0.02, 0.98, param_text, fontsize=10, verticalalignment='top', + bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8)) + + +class SphereVisualizer(ShapeVisualizer): + """Visualizer for spherical shapes.""" + + def create_mesh(self, params: Dict[str, float], resolution: int = 50) -> Dict[str, Any]: + """Create mesh by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'create_shape_mesh'): + return model_module.create_shape_mesh(params, resolution) + # Fallback to old implementation if model function not available + radius = params.get('radius', 50) + phi = np.linspace(0, np.pi, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + x = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z = radius * np.cos(phi_mesh) + return {'sphere': (x, y, z)} + + def get_default_params(self) -> Dict[str, float]: + # Try to get default from model parameters + for param in self.parameters: + if param[0] == 'radius': + return {'radius': param[2]} + return {'radius': 50} + + def _plot_cross_sections(self, ax_xy, ax_xz, ax_yz, params: Dict[str, float]): + """Plot cross-sections by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'plot_shape_cross_sections'): + model_module.plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params) + return + # Fallback to old implementation if model function not available + radius = params.get('radius', 50) + theta = np.linspace(0, 2*np.pi, 100) + circle_x = radius * np.cos(theta) + circle_y = radius * np.sin(theta) + ax_xy.plot(circle_x, circle_y, 'b-', linewidth=2) + ax_xy.set_xlim(-radius*1.2, radius*1.2) + ax_xy.set_ylim(-radius*1.2, radius*1.2) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ax_xz.plot(circle_x, circle_y, 'r-', linewidth=2) + ax_xz.set_xlim(-radius*1.2, radius*1.2) + ax_xz.set_ylim(-radius*1.2, radius*1.2) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section (Side View)') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + ax_yz.plot(circle_x, circle_y, 'g-', linewidth=2) + ax_yz.set_xlim(-radius*1.2, radius*1.2) + ax_yz.set_ylim(-radius*1.2, radius*1.2) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section (Front View)') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) + + +class CylinderVisualizer(ShapeVisualizer): + """Visualizer for cylindrical shapes.""" + + def create_mesh(self, params: Dict[str, float], resolution: int = 50) -> Dict[str, Any]: + """Create mesh by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'create_shape_mesh'): + return model_module.create_shape_mesh(params, resolution) + # Fallback to old implementation if model function not available + radius = params.get('radius', 20) + length = params.get('length', 400) + theta = np.linspace(0, 2*np.pi, resolution) + z = np.linspace(-length/2, length/2, resolution//2) + theta_mesh, z_mesh = np.meshgrid(theta, z) + x = radius * np.cos(theta_mesh) + y = radius * np.sin(theta_mesh) + r_cap = np.linspace(0, radius, resolution//4) + theta_cap = np.linspace(0, 2*np.pi, resolution) + r_cap_mesh, theta_cap_mesh = np.meshgrid(r_cap, theta_cap) + x_cap = r_cap_mesh * np.cos(theta_cap_mesh) + y_cap = r_cap_mesh * np.sin(theta_cap_mesh) + z_cap_top = np.full_like(x_cap, length/2) + z_cap_bottom = np.full_like(x_cap, -length/2) + return { + 'cylinder': (x, y, z_mesh), + 'cap_top': (x_cap, y_cap, z_cap_top), + 'cap_bottom': (x_cap, y_cap, z_cap_bottom) + } + + def get_default_params(self) -> Dict[str, float]: + # Extract defaults from model parameters + defaults = {} + for param in self.parameters: + if param[0] == 'radius': + defaults['radius'] = param[2] + elif param[0] == 'length': + defaults['length'] = param[2] + + # Fallback defaults + if 'radius' not in defaults: + defaults['radius'] = 20 + if 'length' not in defaults: + defaults['length'] = 400 + return defaults + + def _plot_cross_sections(self, ax_xy, ax_xz, ax_yz, params: Dict[str, float]): + """Plot cross-sections by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'plot_shape_cross_sections'): + model_module.plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params) + return + # Fallback to old implementation if model function not available + radius = params.get('radius', 20) + length = params.get('length', 400) + theta = np.linspace(0, 2*np.pi, 100) + circle_x = radius * np.cos(theta) + circle_y = radius * np.sin(theta) + ax_xy.plot(circle_x, circle_y, 'b-', linewidth=2, label='Cylinder') + ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.3) + ax_xy.set_xlim(-radius*1.5, radius*1.5) + ax_xy.set_ylim(-radius*1.5, radius*1.5) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + rect_x = [-length/2, -length/2, length/2, length/2, -length/2] + rect_z = [-radius, radius, radius, -radius, -radius] + ax_xz.plot(rect_x, rect_z, 'r-', linewidth=2, label='Cylinder') + ax_xz.fill(rect_x, rect_z, 'lightcoral', alpha=0.3) + ax_xz.set_xlim(-length/2*1.2, length/2*1.2) + ax_xz.set_ylim(-radius*1.5, radius*1.5) + ax_xz.set_xlabel('Z (Å)') + ax_xz.set_ylabel('X (Å)') + ax_xz.set_title('XZ Cross-section (Side View)') + ax_xz.grid(True, alpha=0.3) + ax_yz.plot(rect_x, rect_z, 'g-', linewidth=2, label='Cylinder') + ax_yz.fill(rect_x, rect_z, 'lightgreen', alpha=0.3) + ax_yz.set_xlim(-length/2*1.2, length/2*1.2) + ax_yz.set_ylim(-radius*1.5, radius*1.5) + ax_yz.set_xlabel('Z (Å)') + ax_yz.set_ylabel('Y (Å)') + ax_yz.set_title('YZ Cross-section (Front View)') + ax_yz.grid(True, alpha=0.3) + + +class EllipsoidVisualizer(ShapeVisualizer): + """Visualizer for ellipsoidal shapes.""" + + def create_mesh(self, params: Dict[str, float], resolution: int = 50) -> Dict[str, Any]: + """Create mesh by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'create_shape_mesh'): + return model_module.create_shape_mesh(params, resolution) + # Fallback to old implementation if model function not available + radius_polar = params.get('radius_polar', 20) + radius_equatorial = params.get('radius_equatorial', 400) + phi = np.linspace(0, np.pi, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + x = radius_equatorial * np.sin(phi_mesh) * np.cos(theta_mesh) + y = radius_equatorial * np.sin(phi_mesh) * np.sin(theta_mesh) + z = radius_polar * np.cos(phi_mesh) + return {'ellipsoid': (x, y, z)} + + def get_default_params(self) -> Dict[str, float]: + # Extract defaults from model parameters + defaults = {} + for param in self.parameters: + if param[0] == 'radius_polar': + defaults['radius_polar'] = param[2] + elif param[0] == 'radius_equatorial': + defaults['radius_equatorial'] = param[2] + + # Fallback defaults + if 'radius_polar' not in defaults: + defaults['radius_polar'] = 20 + if 'radius_equatorial' not in defaults: + defaults['radius_equatorial'] = 400 + return defaults + + def _plot_cross_sections(self, ax_xy, ax_xz, ax_yz, params: Dict[str, float]): + """Plot cross-sections by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'plot_shape_cross_sections'): + model_module.plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params) + return + # Fallback to old implementation if model function not available + radius_polar = params.get('radius_polar', 20) + radius_equatorial = params.get('radius_equatorial', 400) + theta = np.linspace(0, 2*np.pi, 100) + circle_x = radius_equatorial * np.cos(theta) + circle_y = radius_equatorial * np.sin(theta) + ax_xy.plot(circle_x, circle_y, 'b-', linewidth=2, label='Equatorial') + ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.3) + ax_xy.set_xlim(-radius_equatorial*1.2, radius_equatorial*1.2) + ax_xy.set_ylim(-radius_equatorial*1.2, radius_equatorial*1.2) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Equatorial)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ellipse_x = radius_equatorial * np.cos(theta) + ellipse_z = radius_polar * np.sin(theta) + ax_xz.plot(ellipse_x, ellipse_z, 'r-', linewidth=2, label='Meridional') + ax_xz.fill(ellipse_x, ellipse_z, 'lightcoral', alpha=0.3) + ax_xz.set_xlim(-radius_equatorial*1.2, radius_equatorial*1.2) + ax_xz.set_ylim(-radius_polar*1.2, radius_polar*1.2) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section (Meridional)') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + ax_yz.plot(ellipse_x, ellipse_z, 'g-', linewidth=2, label='Meridional') + ax_yz.fill(ellipse_x, ellipse_z, 'lightgreen', alpha=0.3) + ax_yz.set_xlim(-radius_equatorial*1.2, radius_equatorial*1.2) + ax_yz.set_ylim(-radius_polar*1.2, radius_polar*1.2) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section (Meridional)') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) + ax_xz.annotate('', xy=(-radius_equatorial, 0), xytext=(radius_equatorial, 0), + arrowprops=dict(arrowstyle='<->', color='black')) + ax_xz.text(0, -radius_polar*0.3, f'R_eq = {radius_equatorial:.0f} Å', ha='center', fontsize=10) + ax_xz.annotate('', xy=(0, -radius_polar), xytext=(0, radius_polar), + arrowprops=dict(arrowstyle='<->', color='black')) + ax_xz.text(radius_equatorial*0.3, 0, f'R_pol = {radius_polar:.0f} Å', ha='center', fontsize=10, rotation=90) + + +class ParallelepipedVisualizer(ShapeVisualizer): + """Visualizer for parallelepiped shapes.""" + + def create_mesh(self, params: Dict[str, float], resolution: int = 50) -> Dict[str, Any]: + """Create mesh by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'create_shape_mesh'): + return model_module.create_shape_mesh(params, resolution) + # Fallback to old implementation if model function not available + length_a = params.get('length_a', 35) + length_b = params.get('length_b', 75) + length_c = params.get('length_c', 400) + faces = {} + y = np.linspace(-length_b/2, length_b/2, resolution//4) + z = np.linspace(-length_c/2, length_c/2, resolution//2) + y_mesh, z_mesh = np.meshgrid(y, z) + faces['front'] = (np.full_like(y_mesh, length_a/2), y_mesh, z_mesh) + faces['back'] = (np.full_like(y_mesh, -length_a/2), y_mesh, z_mesh) + x = np.linspace(-length_a/2, length_a/2, resolution//4) + z = np.linspace(-length_c/2, length_c/2, resolution//2) + x_mesh, z_mesh = np.meshgrid(x, z) + faces['right'] = (x_mesh, np.full_like(x_mesh, length_b/2), z_mesh) + faces['left'] = (x_mesh, np.full_like(x_mesh, -length_b/2), z_mesh) + x = np.linspace(-length_a/2, length_a/2, resolution//4) + y = np.linspace(-length_b/2, length_b/2, resolution//4) + x_mesh, y_mesh = np.meshgrid(x, y) + faces['top'] = (x_mesh, y_mesh, np.full_like(x_mesh, length_c/2)) + faces['bottom'] = (x_mesh, y_mesh, np.full_like(x_mesh, -length_c/2)) + return faces + + def get_default_params(self) -> Dict[str, float]: + # Extract defaults from model parameters + defaults = {} + for param in self.parameters: + if param[0] == 'length_a': + defaults['length_a'] = param[2] + elif param[0] == 'length_b': + defaults['length_b'] = param[2] + elif param[0] == 'length_c': + defaults['length_c'] = param[2] + + # Fallback defaults + if 'length_a' not in defaults: + defaults['length_a'] = 35 + if 'length_b' not in defaults: + defaults['length_b'] = 75 + if 'length_c' not in defaults: + defaults['length_c'] = 400 + return defaults + + def _plot_cross_sections(self, ax_xy, ax_xz, ax_yz, params: Dict[str, float]): + """Plot cross-sections by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'plot_shape_cross_sections'): + model_module.plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params) + return + # Fallback to old implementation if model function not available + length_a = params.get('length_a', 35) + length_b = params.get('length_b', 75) + length_c = params.get('length_c', 400) + rect_xy_x = [-length_a/2, -length_a/2, length_a/2, length_a/2, -length_a/2] + rect_xy_y = [-length_b/2, length_b/2, length_b/2, -length_b/2, -length_b/2] + ax_xy.plot(rect_xy_x, rect_xy_y, 'b-', linewidth=2, label='A × B face') + ax_xy.fill(rect_xy_x, rect_xy_y, 'lightblue', alpha=0.3) + ax_xy.set_xlim(-length_a/2*1.3, length_a/2*1.3) + ax_xy.set_ylim(-length_b/2*1.3, length_b/2*1.3) + ax_xy.set_xlabel('X (Å) - Length A') + ax_xy.set_ylabel('Y (Å) - Length B') + ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + rect_xz_x = [-length_a/2, -length_a/2, length_a/2, length_a/2, -length_a/2] + rect_xz_z = [-length_c/2, length_c/2, length_c/2, -length_c/2, -length_c/2] + ax_xz.plot(rect_xz_x, rect_xz_z, 'r-', linewidth=2, label='A × C face') + ax_xz.fill(rect_xz_x, rect_xz_z, 'lightcoral', alpha=0.3) + ax_xz.set_xlim(-length_a/2*1.3, length_a/2*1.3) + ax_xz.set_ylim(-length_c/2*1.3, length_c/2*1.3) + ax_xz.set_xlabel('X (Å) - Length A') + ax_xz.set_ylabel('Z (Å) - Length C') + ax_xz.set_title('XZ Cross-section (Side View)') + ax_xz.grid(True, alpha=0.3) + rect_yz_y = [-length_b/2, -length_b/2, length_b/2, length_b/2, -length_b/2] + rect_yz_z = [-length_c/2, length_c/2, length_c/2, -length_c/2, -length_c/2] + ax_yz.plot(rect_yz_y, rect_yz_z, 'g-', linewidth=2, label='B × C face') + ax_yz.fill(rect_yz_y, rect_yz_z, 'lightgreen', alpha=0.3) + ax_yz.set_xlim(-length_b/2*1.3, length_b/2*1.3) + ax_yz.set_ylim(-length_c/2*1.3, length_c/2*1.3) + ax_yz.set_xlabel('Y (Å) - Length B') + ax_yz.set_ylabel('Z (Å) - Length C') + ax_yz.set_title('YZ Cross-section (Front View)') + ax_yz.grid(True, alpha=0.3) + ax_xy.annotate('', xy=(-length_a/2, -length_b/2*1.4), xytext=(length_a/2, -length_b/2*1.4), + arrowprops=dict(arrowstyle='<->', color='black')) + ax_xy.text(0, -length_b/2*1.5, f'A = {length_a:.0f} Å', ha='center', fontsize=10) + ax_xy.annotate('', xy=(-length_a/2*1.4, -length_b/2), xytext=(-length_a/2*1.4, length_b/2), + arrowprops=dict(arrowstyle='<->', color='black')) + ax_xy.text(-length_a/2*1.5, 0, f'B = {length_b:.0f} Å', ha='center', fontsize=10, rotation=90) + ax_xz.annotate('', xy=(-length_a/2, -length_c/2*1.2), xytext=(length_a/2, -length_c/2*1.2), + arrowprops=dict(arrowstyle='<->', color='black')) + ax_xz.text(0, -length_c/2*1.25, f'A = {length_a:.0f} Å', ha='center', fontsize=10) + ax_xz.annotate('', xy=(-length_a/2*1.2, -length_c/2), xytext=(-length_a/2*1.2, length_c/2), + arrowprops=dict(arrowstyle='<->', color='black')) + ax_xz.text(-length_a/2*1.25, 0, f'C = {length_c:.0f} Å', ha='center', fontsize=10, rotation=90) + + +class CappedCylinderVisualizer(ShapeVisualizer): + """Visualizer for capped cylinder shapes.""" + + def create_mesh(self, params: Dict[str, float], resolution: int = 50) -> Dict[str, Any]: + """Create mesh by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'create_shape_mesh'): + return model_module.create_shape_mesh(params, resolution) + # Fallback to old implementation if model function not available + radius = params.get('radius', 20) + radius_cap = params.get('radius_cap', 25) + length = params.get('length', 400) + if radius_cap < radius: + raise ValueError(f"Cap radius ({radius_cap}) must be >= cylinder radius ({radius})") + h = np.sqrt(radius_cap**2 - radius**2) + theta = np.linspace(0, 2*np.pi, resolution) + z_cyl = np.linspace(-length/2, length/2, resolution//2) + theta_cyl, z_cyl_mesh = np.meshgrid(theta, z_cyl) + x_cyl = radius * np.cos(theta_cyl) + y_cyl = radius * np.sin(theta_cyl) + phi_max = np.arccos(h / radius_cap) + phi = np.linspace(0, phi_max, resolution//4) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + x_cap_top = radius_cap * np.sin(phi_mesh) * np.cos(theta_mesh) + y_cap_top = radius_cap * np.sin(phi_mesh) * np.sin(theta_mesh) + z_cap_top = length/2 - h + radius_cap * np.cos(phi_mesh) + x_cap_bottom = radius_cap * np.sin(phi_mesh) * np.cos(theta_mesh) + y_cap_bottom = radius_cap * np.sin(phi_mesh) * np.sin(theta_mesh) + z_cap_bottom = -length/2 + h - radius_cap * np.cos(phi_mesh) + return { + 'cylinder': (x_cyl, y_cyl, z_cyl_mesh), + 'cap_top': (x_cap_top, y_cap_top, z_cap_top), + 'cap_bottom': (x_cap_bottom, y_cap_bottom, z_cap_bottom) + } + + def get_default_params(self) -> Dict[str, float]: + # Extract defaults from model parameters + defaults = {} + for param in self.parameters: + if param[0] == 'radius': + defaults['radius'] = param[2] + elif param[0] == 'radius_cap': + defaults['radius_cap'] = param[2] + elif param[0] == 'length': + defaults['length'] = param[2] + + # Fallback defaults + if 'radius' not in defaults: + defaults['radius'] = 20 + if 'radius_cap' not in defaults: + defaults['radius_cap'] = 25 + if 'length' not in defaults: + defaults['length'] = 400 + return defaults + + def _plot_cross_sections(self, ax_xy, ax_xz, ax_yz, params: Dict[str, float]): + """Plot cross-sections by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'plot_shape_cross_sections'): + model_module.plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params) + return + # Fallback to old implementation if model function not available + radius = params.get('radius', 20) + radius_cap = params.get('radius_cap', 25) + length = params.get('length', 400) + if radius_cap < radius: + return # Skip if invalid parameters + + h = np.sqrt(radius_cap**2 - radius**2) + + # XY plane (top view) - circle (same as cylinder) + theta = np.linspace(0, 2*np.pi, 100) + circle_x = radius * np.cos(theta) + circle_y = radius * np.sin(theta) + + ax_xy.plot(circle_x, circle_y, 'b-', linewidth=2, label='Cylinder') + ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.3) + + # Show cap outline if significantly larger + if radius_cap > radius * 1.1: + cap_circle_x = radius_cap * np.cos(theta) + cap_circle_y = radius_cap * np.sin(theta) + ax_xy.plot(cap_circle_x, cap_circle_y, 'r--', linewidth=1, alpha=0.7, label='Cap outline') + + ax_xy.set_xlim(-radius_cap*1.2, radius_cap*1.2) + ax_xy.set_ylim(-radius_cap*1.2, radius_cap*1.2) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ax_xy.legend() + + # XZ plane (side view) - cylinder + caps + # Cylinder body + cyl_x = [-length/2, -length/2, length/2, length/2, -length/2] + cyl_z = [-radius, radius, radius, -radius, -radius] + ax_xz.plot(cyl_x, cyl_z, 'b-', linewidth=2, label='Cylinder') + ax_xz.fill(cyl_x, cyl_z, 'lightblue', alpha=0.3) + + # Spherical caps + cap_angles = np.linspace(0, 2*np.pi, 100) + + # Top cap + cap_center_top = length/2 - h + cap_x_top = cap_center_top + radius_cap * np.cos(cap_angles) + cap_z_top = radius_cap * np.sin(cap_angles) + + # Only show the part that extends beyond cylinder + mask_top = cap_x_top >= length/2 + ax_xz.plot(cap_x_top[mask_top], cap_z_top[mask_top], 'r-', linewidth=2, label='Caps') + ax_xz.fill_between(cap_x_top[mask_top], cap_z_top[mask_top], 0, alpha=0.3, color='lightcoral') + + # Bottom cap + cap_center_bottom = -length/2 + h + cap_x_bottom = cap_center_bottom + radius_cap * np.cos(cap_angles) + cap_z_bottom = radius_cap * np.sin(cap_angles) + + mask_bottom = cap_x_bottom <= -length/2 + ax_xz.plot(cap_x_bottom[mask_bottom], cap_z_bottom[mask_bottom], 'r-', linewidth=2) + ax_xz.fill_between(cap_x_bottom[mask_bottom], cap_z_bottom[mask_bottom], 0, alpha=0.3, color='lightcoral') + + # Mark cap centers + ax_xz.plot(cap_center_top, 0, 'ro', markersize=6, label='Cap centers') + ax_xz.plot(cap_center_bottom, 0, 'ro', markersize=6) + + ax_xz.set_xlim((-length/2 - radius_cap*0.5), (length/2 + radius_cap*0.5)) + ax_xz.set_ylim(-radius_cap*1.2, radius_cap*1.2) + ax_xz.set_xlabel('Z (Å)') + ax_xz.set_ylabel('X (Å)') + ax_xz.set_title('XZ Cross-section (Side View)') + ax_xz.grid(True, alpha=0.3) + ax_xz.legend() + + # YZ plane (front view) - same as XZ + ax_yz.plot(cyl_x, cyl_z, 'g-', linewidth=2, label='Cylinder') + ax_yz.fill(cyl_x, cyl_z, 'lightgreen', alpha=0.3) + + ax_yz.plot(cap_x_top[mask_top], cap_z_top[mask_top], 'orange', linewidth=2, label='Caps') + ax_yz.fill_between(cap_x_top[mask_top], cap_z_top[mask_top], 0, alpha=0.3, color='moccasin') + ax_yz.plot(cap_x_bottom[mask_bottom], cap_z_bottom[mask_bottom], 'orange', linewidth=2) + ax_yz.fill_between(cap_x_bottom[mask_bottom], cap_z_bottom[mask_bottom], 0, alpha=0.3, color='moccasin') + + ax_yz.plot(cap_center_top, 0, 'o', color='orange', markersize=6, label='Cap centers') + ax_yz.plot(cap_center_bottom, 0, 'o', color='orange', markersize=6) + + ax_yz.set_xlim((-length/2 - radius_cap*0.5), (length/2 + radius_cap*0.5)) + ax_yz.set_ylim(-radius_cap*1.2, radius_cap*1.2) + ax_yz.set_xlabel('Z (Å)') + ax_yz.set_ylabel('Y (Å)') + ax_yz.set_title('YZ Cross-section (Front View)') + ax_yz.grid(True, alpha=0.3) + ax_yz.legend() + + # Add dimension annotations + ax_xz.annotate('', xy=(-length/2, -radius*1.4), xytext=(length/2, -radius*1.4), + arrowprops=dict(arrowstyle='<->', color='black')) + ax_xz.text(0, -radius*1.6, f'L = {length:.0f} Å', ha='center', fontsize=10) + + ax_xz.text(cap_center_top + radius_cap*0.3, radius_cap*0.7, f'R = {radius_cap:.0f} Å', + fontsize=10, rotation=45) + ax_xz.text(-length/4, radius*0.7, f'r = {radius:.0f} Å', fontsize=10) + ax_xz.text(cap_center_top, -radius*0.3, f'h = {h:.1f} Å', fontsize=10, ha='center') + + +class CoreShellCylinderVisualizer(ShapeVisualizer): + """Visualizer for core-shell cylinder shapes.""" + + def create_mesh(self, params: Dict[str, float], resolution: int = 50) -> Dict[str, Any]: + """Create mesh by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'create_shape_mesh'): + return model_module.create_shape_mesh(params, resolution) + # Fallback to old implementation if model function not available + radius = params.get('radius', 20) + thickness = params.get('thickness', 20) + length = params.get('length', 400) + + # Outer dimensions + outer_radius = radius + thickness + outer_length = length + 2 * thickness + + # Create core cylinder + theta = np.linspace(0, 2*np.pi, resolution) + z_core = np.linspace(-length/2, length/2, resolution//2) + theta_core, z_core_mesh = np.meshgrid(theta, z_core) + x_core = radius * np.cos(theta_core) + y_core = radius * np.sin(theta_core) + + # Create shell cylinder (outer surface) + z_shell = np.linspace(-outer_length/2, outer_length/2, resolution//2) + theta_shell, z_shell_mesh = np.meshgrid(theta, z_shell) + x_shell = outer_radius * np.cos(theta_shell) + y_shell = outer_radius * np.sin(theta_shell) + + # Create end caps (shell only, as annular disks) + r_cap_inner = np.linspace(0, radius, resolution//8) + r_cap_outer = np.linspace(radius, outer_radius, resolution//8) + theta_cap = np.linspace(0, 2*np.pi, resolution) + + # Inner caps (on core) + r_inner_mesh, theta_inner_mesh = np.meshgrid(r_cap_inner, theta_cap) + x_cap_core = r_inner_mesh * np.cos(theta_inner_mesh) + y_cap_core = r_inner_mesh * np.sin(theta_inner_mesh) + z_cap_core_top = np.full_like(x_cap_core, length/2) + z_cap_core_bottom = np.full_like(x_cap_core, -length/2) + + # Outer shell caps (annular rings on ends) + r_outer_mesh, theta_outer_mesh = np.meshgrid(r_cap_outer, theta_cap) + x_cap_shell = r_outer_mesh * np.cos(theta_outer_mesh) + y_cap_shell = r_outer_mesh * np.sin(theta_outer_mesh) + z_cap_shell_top = np.full_like(x_cap_shell, outer_length/2) + z_cap_shell_bottom = np.full_like(x_cap_shell, -outer_length/2) + + # Middle shell caps (between core and outer shell) + r_full = np.linspace(0, outer_radius, resolution//4) + r_full_mesh, theta_full_mesh = np.meshgrid(r_full, theta_cap) + x_cap_middle = r_full_mesh * np.cos(theta_full_mesh) + y_cap_middle = r_full_mesh * np.sin(theta_full_mesh) + z_cap_middle_top = np.full_like(x_cap_middle, length/2) + z_cap_middle_bottom = np.full_like(x_cap_middle, -length/2) + + return { + 'core_cylinder': (x_core, y_core, z_core_mesh), + 'shell_cylinder': (x_shell, y_shell, z_shell_mesh), + 'shell_cap_top': (x_cap_middle, y_cap_middle, z_cap_middle_top), + 'shell_cap_bottom': (x_cap_middle, y_cap_middle, z_cap_middle_bottom), + 'end_cap_top': (x_cap_shell, y_cap_shell, z_cap_shell_top), + 'end_cap_bottom': (x_cap_shell, y_cap_shell, z_cap_shell_bottom), + } + + def get_default_params(self) -> Dict[str, float]: + # Extract defaults from model parameters + defaults = {} + for param in self.parameters: + if param[0] == 'radius': + defaults['radius'] = param[2] + elif param[0] == 'thickness': + defaults['thickness'] = param[2] + elif param[0] == 'length': + defaults['length'] = param[2] + + # Fallback defaults + if 'radius' not in defaults: + defaults['radius'] = 20 + if 'thickness' not in defaults: + defaults['thickness'] = 20 + if 'length' not in defaults: + defaults['length'] = 400 + return defaults + + def _plot_cross_sections(self, ax_xy, ax_xz, ax_yz, params: Dict[str, float]): + """Plot cross-sections by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'plot_shape_cross_sections'): + model_module.plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params) + return + # Fallback to old implementation if model function not available + radius = params.get('radius', 20) + thickness = params.get('thickness', 20) + length = params.get('length', 400) + outer_radius = radius + thickness + outer_length = length + 2 * thickness + theta = np.linspace(0, 2*np.pi, 100) + core_x = radius * np.cos(theta) + core_y = radius * np.sin(theta) + shell_x = outer_radius * np.cos(theta) + shell_y = outer_radius * np.sin(theta) + ax_xy.plot(shell_x, shell_y, 'r-', linewidth=2, label='Shell') + ax_xy.fill(shell_x, shell_y, 'lightcoral', alpha=0.3) + ax_xy.plot(core_x, core_y, 'b-', linewidth=2, label='Core') + ax_xy.fill(core_x, core_y, 'lightblue', alpha=0.5) + ax_xy.set_xlim(-outer_radius*1.3, outer_radius*1.3) + ax_xy.set_ylim(-outer_radius*1.3, outer_radius*1.3) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ax_xy.legend() + core_rect_x = [-length/2, -length/2, length/2, length/2, -length/2] + core_rect_z = [-radius, radius, radius, -radius, -radius] + shell_rect_x = [-outer_length/2, -outer_length/2, outer_length/2, outer_length/2, -outer_length/2] + shell_rect_z = [-outer_radius, outer_radius, outer_radius, -outer_radius, -outer_radius] + ax_xz.plot(shell_rect_x, shell_rect_z, 'r-', linewidth=2, label='Shell') + ax_xz.fill(shell_rect_x, shell_rect_z, 'lightcoral', alpha=0.3) + ax_xz.plot(core_rect_x, core_rect_z, 'b-', linewidth=2, label='Core') + ax_xz.fill(core_rect_x, core_rect_z, 'lightblue', alpha=0.5) + ax_xz.set_xlim(-outer_length/2*1.2, outer_length/2*1.2) + ax_xz.set_ylim(-outer_radius*1.3, outer_radius*1.3) + ax_xz.set_xlabel('Z (Å)') + ax_xz.set_ylabel('X (Å)') + ax_xz.set_title('XZ Cross-section (Side View)') + ax_xz.grid(True, alpha=0.3) + ax_xz.legend() + ax_xz.annotate('', xy=(-length/2, -outer_radius*1.5), xytext=(length/2, -outer_radius*1.5), + arrowprops=dict(arrowstyle='<->', color='blue')) + ax_xz.text(0, -outer_radius*1.6, f'L = {length:.0f} Å (core)', ha='center', fontsize=9) + ax_xz.annotate('', xy=(-outer_length/2, -outer_radius*1.8), xytext=(outer_length/2, -outer_radius*1.8), + arrowprops=dict(arrowstyle='<->', color='red')) + ax_xz.text(0, -outer_radius*1.9, f'L+2T = {outer_length:.0f} Å (total)', ha='center', fontsize=9, color='red') + ax_xz.annotate('', xy=(outer_length/2*0.7, 0), xytext=(outer_length/2*0.7, radius), + arrowprops=dict(arrowstyle='<->', color='blue')) + ax_xz.text(outer_length/2*0.75, radius/2, f'r={radius:.0f}', fontsize=9, color='blue') + ax_xz.annotate('', xy=(outer_length/2*0.85, 0), xytext=(outer_length/2*0.85, outer_radius), + arrowprops=dict(arrowstyle='<->', color='red')) + ax_xz.text(outer_length/2*0.9, outer_radius/2, f'R+T={outer_radius:.0f}', fontsize=9, color='red') + ax_yz.plot(shell_rect_x, shell_rect_z, 'g-', linewidth=2, label='Shell') + ax_yz.fill(shell_rect_x, shell_rect_z, 'lightgreen', alpha=0.3) + ax_yz.plot(core_rect_x, core_rect_z, 'orange', linewidth=2, label='Core') + ax_yz.fill(core_rect_x, core_rect_z, 'moccasin', alpha=0.5) + ax_yz.set_xlim(-outer_length/2*1.2, outer_length/2*1.2) + ax_yz.set_ylim(-outer_radius*1.3, outer_radius*1.3) + ax_yz.set_xlabel('Z (Å)') + ax_yz.set_ylabel('Y (Å)') + ax_yz.set_title('YZ Cross-section (Front View)') + ax_yz.grid(True, alpha=0.3) + ax_yz.legend() + + +class CoreShellSphereVisualizer(ShapeVisualizer): + """Visualizer for core-shell sphere shapes.""" + + def create_mesh(self, params: Dict[str, float], resolution: int = 50) -> Dict[str, Any]: + """Create mesh by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'create_shape_mesh'): + return model_module.create_shape_mesh(params, resolution) + # Fallback to old implementation if model function not available + radius = params.get('radius', 60) + thickness = params.get('thickness', 10) + phi = np.linspace(0, np.pi, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + x_core = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y_core = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z_core = radius * np.cos(phi_mesh) + shell_radius = radius + thickness + x_shell = shell_radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y_shell = shell_radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z_shell = shell_radius * np.cos(phi_mesh) + return { + 'core': (x_core, y_core, z_core), + 'shell': (x_shell, y_shell, z_shell) + } + + def get_default_params(self) -> Dict[str, float]: + # Try to get defaults from model_info if available + defaults = {} + for param in self.parameters: + if param[0] == 'radius': + defaults['radius'] = param[2] # Default value is at index 2 + elif param[0] == 'thickness': + defaults['thickness'] = param[2] + + # Fallback to hardcoded defaults if not found + if 'radius' not in defaults: + defaults['radius'] = 60 + if 'thickness' not in defaults: + defaults['thickness'] = 10 + + return defaults + + def _plot_cross_sections(self, ax_xy, ax_xz, ax_yz, params: Dict[str, float]): + """Plot cross-sections by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'plot_shape_cross_sections'): + model_module.plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params) + return + # Fallback to old implementation if model function not available + raise NotImplementedError(f"Model '{self.name}' does not have plot_shape_cross_sections function") + + +class CoreShellBicelleEllipticalVisualizer(ShapeVisualizer): + """Visualizer for core-shell bicelle elliptical shapes.""" + + def create_mesh(self, params: Dict[str, float], resolution: int = 50) -> Dict[str, Any]: + """Create mesh by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'create_shape_mesh'): + return model_module.create_shape_mesh(params, resolution) + # Fallback to old implementation if model function not available + radius = params.get('radius', 30) # r_minor + x_core = params.get('x_core', 3) # r_major/r_minor ratio + thick_rim = params.get('thick_rim', 8) + thick_face = params.get('thick_face', 14) + length = params.get('length', 50) + + r_minor = radius + r_major = radius * x_core + + # Outer dimensions + outer_r_minor = r_minor + thick_rim + outer_r_major = r_major + thick_rim + outer_length = length + 2 * thick_face + + # Create core elliptical cylinder + theta = np.linspace(0, 2*np.pi, resolution) + z_core = np.linspace(-length/2, length/2, resolution//2) + theta_core, z_core_mesh = np.meshgrid(theta, z_core) + x_core = r_major * np.cos(theta_core) + y_core = r_minor * np.sin(theta_core) + + # Create shell elliptical cylinder (outer surface) + z_shell = np.linspace(-outer_length/2, outer_length/2, resolution//2) + theta_shell, z_shell_mesh = np.meshgrid(theta, z_shell) + x_shell = outer_r_major * np.cos(theta_shell) + y_shell = outer_r_minor * np.sin(theta_shell) + + # Create end caps + # Core end caps (elliptical) + u = np.linspace(0, 1, resolution//4) + theta_cap = np.linspace(0, 2*np.pi, resolution) + u_mesh, theta_cap_mesh = np.meshgrid(u, theta_cap) + + x_cap_core = u_mesh * r_major * np.cos(theta_cap_mesh) + y_cap_core = u_mesh * r_minor * np.sin(theta_cap_mesh) + z_cap_core_top = np.full_like(x_cap_core, length/2) + z_cap_core_bottom = np.full_like(x_cap_core, -length/2) + + # Shell end caps (elliptical) + x_cap_shell = u_mesh * outer_r_major * np.cos(theta_cap_mesh) + y_cap_shell = u_mesh * outer_r_minor * np.sin(theta_cap_mesh) + z_cap_shell_top = np.full_like(x_cap_shell, outer_length/2) + z_cap_shell_bottom = np.full_like(x_cap_shell, -outer_length/2) + + return { + 'core_cylinder': (x_core, y_core, z_core_mesh), + 'shell_cylinder': (x_shell, y_shell, z_shell_mesh), + 'core_cap_top': (x_cap_core, y_cap_core, z_cap_core_top), + 'core_cap_bottom': (x_cap_core, y_cap_core, z_cap_core_bottom), + 'shell_cap_top': (x_cap_shell, y_cap_shell, z_cap_shell_top), + 'shell_cap_bottom': (x_cap_shell, y_cap_shell, z_cap_shell_bottom), + } + + def get_default_params(self) -> Dict[str, float]: + # Extract defaults from model parameters + defaults = {} + for param in self.parameters: + if param[0] == 'radius': + defaults['radius'] = param[2] + elif param[0] == 'x_core': + defaults['x_core'] = param[2] + elif param[0] == 'thick_rim': + defaults['thick_rim'] = param[2] + elif param[0] == 'thick_face': + defaults['thick_face'] = param[2] + elif param[0] == 'length': + defaults['length'] = param[2] + + # Fallback defaults + if 'radius' not in defaults: + defaults['radius'] = 30 + if 'x_core' not in defaults: + defaults['x_core'] = 3 + if 'thick_rim' not in defaults: + defaults['thick_rim'] = 8 + if 'thick_face' not in defaults: + defaults['thick_face'] = 14 + if 'length' not in defaults: + defaults['length'] = 50 + return defaults + + def _plot_cross_sections(self, ax_xy, ax_xz, ax_yz, params: Dict[str, float]): + """Plot cross-sections by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'plot_shape_cross_sections'): + model_module.plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params) + return + # Fallback to old implementation if model function not available + raise NotImplementedError(f"Model '{self.name}' does not have plot_shape_cross_sections function") + + +class EllipticalCylinderVisualizer(ShapeVisualizer): + """Visualizer for elliptical cylinder shapes.""" + + def create_mesh(self, params: Dict[str, float], resolution: int = 50) -> Dict[str, Any]: + """Create mesh by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'create_shape_mesh'): + return model_module.create_shape_mesh(params, resolution) + # Fallback to old implementation if model function not available + radius_minor = params.get('radius_minor', 20) + axis_ratio = params.get('axis_ratio', 1.5) + length = params.get('length', 400) + + radius_major = radius_minor * axis_ratio + + # Create elliptical cylinder + theta = np.linspace(0, 2*np.pi, resolution) + z = np.linspace(-length/2, length/2, resolution//2) + theta_mesh, z_mesh = np.meshgrid(theta, z) + + x = radius_major * np.cos(theta_mesh) + y = radius_minor * np.sin(theta_mesh) + + # Create end caps (elliptical) + u = np.linspace(0, 1, resolution//4) + theta_cap = np.linspace(0, 2*np.pi, resolution) + u_mesh, theta_cap_mesh = np.meshgrid(u, theta_cap) + + x_cap = u_mesh * radius_major * np.cos(theta_cap_mesh) + y_cap = u_mesh * radius_minor * np.sin(theta_cap_mesh) + z_cap_top = np.full_like(x_cap, length/2) + z_cap_bottom = np.full_like(x_cap, -length/2) + + return { + 'cylinder': (x, y, z_mesh), + 'cap_top': (x_cap, y_cap, z_cap_top), + 'cap_bottom': (x_cap, y_cap, z_cap_bottom) + } + + def get_default_params(self) -> Dict[str, float]: + defaults = {} + for param in self.parameters: + if param[0] == 'radius_minor': + defaults['radius_minor'] = param[2] + elif param[0] == 'axis_ratio': + defaults['axis_ratio'] = param[2] + elif param[0] == 'length': + defaults['length'] = param[2] + + if 'radius_minor' not in defaults: + defaults['radius_minor'] = 20 + if 'axis_ratio' not in defaults: + defaults['axis_ratio'] = 1.5 + if 'length' not in defaults: + defaults['length'] = 400 + return defaults + + def _plot_cross_sections(self, ax_xy, ax_xz, ax_yz, params: Dict[str, float]): + """Plot cross-sections by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'plot_shape_cross_sections'): + model_module.plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params) + return + # Fallback to old implementation if model function not available + raise NotImplementedError(f"Model '{self.name}' does not have plot_shape_cross_sections function") + + +class HollowCylinderVisualizer(ShapeVisualizer): + """Visualizer for hollow cylinder shapes.""" + + def create_mesh(self, params: Dict[str, float], resolution: int = 50) -> Dict[str, Any]: + """Create mesh by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'create_shape_mesh'): + return model_module.create_shape_mesh(params, resolution) + # Fallback to old implementation if model function not available + radius = params.get('radius', 20) # Inner/core radius + thickness = params.get('thickness', 10) + length = params.get('length', 400) + + outer_radius = radius + thickness + + # Create inner cylinder surface + theta = np.linspace(0, 2*np.pi, resolution) + z = np.linspace(-length/2, length/2, resolution//2) + theta_mesh, z_mesh = np.meshgrid(theta, z) + + x_inner = radius * np.cos(theta_mesh) + y_inner = radius * np.sin(theta_mesh) + + # Create outer cylinder surface + x_outer = outer_radius * np.cos(theta_mesh) + y_outer = outer_radius * np.sin(theta_mesh) + + # Create end caps (annular disks) + r_cap = np.linspace(radius, outer_radius, resolution//4) + theta_cap = np.linspace(0, 2*np.pi, resolution) + r_cap_mesh, theta_cap_mesh = np.meshgrid(r_cap, theta_cap) + + x_cap = r_cap_mesh * np.cos(theta_cap_mesh) + y_cap = r_cap_mesh * np.sin(theta_cap_mesh) + z_cap_top = np.full_like(x_cap, length/2) + z_cap_bottom = np.full_like(x_cap, -length/2) + + return { + 'inner_cylinder': (x_inner, y_inner, z_mesh), + 'outer_cylinder': (x_outer, y_outer, z_mesh), + 'cap_top': (x_cap, y_cap, z_cap_top), + 'cap_bottom': (x_cap, y_cap, z_cap_bottom) + } + + def get_default_params(self) -> Dict[str, float]: + defaults = {} + for param in self.parameters: + if param[0] == 'radius': + defaults['radius'] = param[2] + elif param[0] == 'thickness': + defaults['thickness'] = param[2] + elif param[0] == 'length': + defaults['length'] = param[2] + + if 'radius' not in defaults: + defaults['radius'] = 20 + if 'thickness' not in defaults: + defaults['thickness'] = 10 + if 'length' not in defaults: + defaults['length'] = 400 + return defaults + + def _plot_cross_sections(self, ax_xy, ax_xz, ax_yz, params: Dict[str, float]): + """Plot cross-sections by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'plot_shape_cross_sections'): + model_module.plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params) + return + # Fallback to old implementation if model function not available + raise NotImplementedError(f"Model '{self.name}' does not have plot_shape_cross_sections function") + + +class PearlNecklaceVisualizer(ShapeVisualizer): + """Visualizer for pearl_necklace: chain of spheres connected by a thin string.""" + + def create_mesh(self, params: Dict[str, float], resolution: int = 40) -> Dict[str, Any]: + """Create mesh by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'create_shape_mesh'): + return model_module.create_shape_mesh(params, resolution) + # Fallback to old implementation if model function not available + radius = params.get('radius', 80.0) + edge_sep = params.get('edge_sep', 350.0) # surface-to-surface distance + thick_string = params.get('thick_string', 2.5) + num_pearls = int(round(params.get('num_pearls', 3))) + num_pearls = max(num_pearls, 1) + + # Spacing between pearl centers + center_step = 2 * radius + edge_sep + z_positions = [ + (i - (num_pearls - 1) / 2.0) * center_step + for i in range(num_pearls) + ] + + # Sphere (pearl) mesh + phi = np.linspace(0, np.pi, resolution // 2) + theta = np.linspace(0, 2 * np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + pearls = {} + for i, z0 in enumerate(z_positions): + x = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z = radius * np.cos(phi_mesh) + z0 + pearls[f'pearl_{i}'] = (x, y, z) + + # String segments as thin cylinders between neighboring pearls + string_radius = thick_string / 2.0 + strings = {} + if num_pearls > 1 and string_radius > 0: + theta_c = np.linspace(0, 2 * np.pi, resolution) + for i in range(num_pearls - 1): + z_start = z_positions[i] + radius + z_end = z_positions[i + 1] - radius + z_seg = np.linspace(z_start, z_end, resolution // 2) + theta_mesh_c, z_seg_mesh = np.meshgrid(theta_c, z_seg) + x_c = string_radius * np.cos(theta_mesh_c) + y_c = string_radius * np.sin(theta_mesh_c) + strings[f'string_{i}'] = (x_c, y_c, z_seg_mesh) + + mesh = {} + mesh.update(pearls) + mesh.update(strings) + return mesh + + def get_default_params(self) -> Dict[str, float]: + defaults = {} + for p in self.parameters: + if p[0] == 'radius': + defaults['radius'] = p[2] + elif p[0] == 'edge_sep': + defaults['edge_sep'] = p[2] + elif p[0] == 'thick_string': + defaults['thick_string'] = p[2] + elif p[0] == 'num_pearls': + defaults['num_pearls'] = p[2] + defaults.setdefault('radius', 80.0) + defaults.setdefault('edge_sep', 350.0) + defaults.setdefault('thick_string', 2.5) + defaults.setdefault('num_pearls', 3) + return defaults + + def _plot_cross_sections(self, ax_xy, ax_xz, ax_yz, params: Dict[str, float]): + """Plot cross-sections by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'plot_shape_cross_sections'): + model_module.plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params) + return + # Fallback to old implementation if model function not available + raise NotImplementedError(f"Model '{self.name}' does not have plot_shape_cross_sections function") + + +class StackedDisksVisualizer(ShapeVisualizer): + """Visualizer for stacked_disks: stack of core+layer disks.""" + + def create_mesh(self, params: Dict[str, float], resolution: int = 40) -> Dict[str, Any]: + """Create mesh by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'create_shape_mesh'): + return model_module.create_shape_mesh(params, resolution) + # Fallback to old implementation if model function not available + thick_core = params.get('thick_core', 10.0) + thick_layer = params.get('thick_layer', 10.0) + radius = params.get('radius', 15.0) + n_stacking = max(int(round(params.get('n_stacking', 1.0))), 1) + + # Period between disk centers (approximate): core + two layers + d = thick_core + 2 * thick_layer + total_height = n_stacking * d + z_centers = [ + (i - (n_stacking - 1) / 2.0) * d + for i in range(n_stacking) + ] + + theta = np.linspace(0, 2 * np.pi, resolution) + z_disk = np.linspace(-d / 2, d / 2, resolution // 3) + theta_mesh, z_local = np.meshgrid(theta, z_disk) + x = radius * np.cos(theta_mesh) + y = radius * np.sin(theta_mesh) + + mesh = {} + for i, z0 in enumerate(z_centers): + z = z_local + z0 + mesh[f'disk_{i}'] = (x, y, z) + + return mesh + + def get_default_params(self) -> Dict[str, float]: + defaults = {} + for p in self.parameters: + if p[0] == 'thick_core': + defaults['thick_core'] = p[2] + elif p[0] == 'thick_layer': + defaults['thick_layer'] = p[2] + elif p[0] == 'radius': + defaults['radius'] = p[2] + elif p[0] == 'n_stacking': + defaults['n_stacking'] = p[2] + defaults.setdefault('thick_core', 10.0) + defaults.setdefault('thick_layer', 10.0) + defaults.setdefault('radius', 15.0) + defaults.setdefault('n_stacking', 1.0) + return defaults + + def _plot_cross_sections(self, ax_xy, ax_xz, ax_yz, params: Dict[str, float]): + """Plot cross-sections by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'plot_shape_cross_sections'): + model_module.plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params) + return + # Fallback to old implementation if model function not available + raise NotImplementedError(f"Model '{self.name}' does not have plot_shape_cross_sections function") + + +class PringleVisualizer(ShapeVisualizer): + """Visualizer for pringle (saddle-shaped) disc.""" + + def create_mesh(self, params: Dict[str, float], resolution: int = 50) -> Dict[str, Any]: + """Create mesh by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'create_shape_mesh'): + return model_module.create_shape_mesh(params, resolution) + # Fallback to old implementation if model function not available + radius = params.get('radius', 60) + thickness = params.get('thickness', 10) + alpha = params.get('alpha', 0.001) + beta = params.get('beta', 0.02) + + # Create saddle surface (hyperbolic paraboloid approximation) + r = np.linspace(0, radius, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + r_mesh, theta_mesh = np.meshgrid(r, theta) + + x = r_mesh * np.cos(theta_mesh) + y = r_mesh * np.sin(theta_mesh) + + # Saddle shape: z = alpha * x^2 - beta * y^2 + z = alpha * x**2 - beta * y**2 + + # Top and bottom surfaces (offset by thickness) + z_top = z + thickness/2 + z_bottom = z - thickness/2 + + return { + 'top_surface': (x, y, z_top), + 'bottom_surface': (x, y, z_bottom) + } + + def get_default_params(self) -> Dict[str, float]: + defaults = {} + for param in self.parameters: + if param[0] == 'radius': + defaults['radius'] = param[2] + elif param[0] == 'thickness': + defaults['thickness'] = param[2] + elif param[0] == 'alpha': + defaults['alpha'] = param[2] + elif param[0] == 'beta': + defaults['beta'] = param[2] + + if 'radius' not in defaults: + defaults['radius'] = 60 + if 'thickness' not in defaults: + defaults['thickness'] = 10 + if 'alpha' not in defaults: + defaults['alpha'] = 0.001 + if 'beta' not in defaults: + defaults['beta'] = 0.02 + return defaults + + def _plot_cross_sections(self, ax_xy, ax_xz, ax_yz, params: Dict[str, float]): + """Plot cross-sections by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'plot_shape_cross_sections'): + model_module.plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params) + return + # Fallback to old implementation if model function not available + raise NotImplementedError(f"Model '{self.name}' does not have plot_shape_cross_sections function") + + +class FlexibleCylinderEllipticalVisualizer(ShapeVisualizer): + """Visualizer for flexible cylinder with elliptical cross-section (simplified as elliptical cylinder).""" + + def create_mesh(self, params: Dict[str, float], resolution: int = 50) -> Dict[str, Any]: + """Create mesh by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'create_shape_mesh'): + return model_module.create_shape_mesh(params, resolution) + # Fallback to old implementation if model function not available + length = params.get('length', 1000) + radius = params.get('radius', 20) + axis_ratio = params.get('axis_ratio', 1.5) + kuhn_length = params.get('kuhn_length', 100) + + radius_minor = radius + radius_major = radius * axis_ratio + + # Show as straight elliptical cylinder with annotation about flexibility + theta = np.linspace(0, 2*np.pi, resolution) + z = np.linspace(-length/2, length/2, resolution//2) + theta_mesh, z_mesh = np.meshgrid(theta, z) + + x = radius_major * np.cos(theta_mesh) + y = radius_minor * np.sin(theta_mesh) + + return { + 'cylinder': (x, y, z_mesh), + '_note': f'Simplified view - actual model is flexible with Kuhn length {kuhn_length} Å' + } + + def get_default_params(self) -> Dict[str, float]: + defaults = {} + for param in self.parameters: + if param[0] == 'length': + defaults['length'] = param[2] + elif param[0] == 'radius': + defaults['radius'] = param[2] + elif param[0] == 'axis_ratio': + defaults['axis_ratio'] = param[2] + elif param[0] == 'kuhn_length': + defaults['kuhn_length'] = param[2] + + if 'length' not in defaults: + defaults['length'] = 1000 + if 'radius' not in defaults: + defaults['radius'] = 20 + if 'axis_ratio' not in defaults: + defaults['axis_ratio'] = 1.5 + if 'kuhn_length' not in defaults: + defaults['kuhn_length'] = 100 + return defaults + + def _plot_cross_sections(self, ax_xy, ax_xz, ax_yz, params: Dict[str, float]): + """Plot cross-sections by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'plot_shape_cross_sections'): + model_module.plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params) + return + # Fallback to old implementation if model function not available + raise NotImplementedError(f"Model '{self.name}' does not have plot_shape_cross_sections function") + + +class GenericModelVisualizer(ShapeVisualizer): + """Generic visualizer that delegates to model functions.""" + + def create_mesh(self, params: Dict[str, float], resolution: int = 50) -> Dict[str, Any]: + """Create mesh by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'create_shape_mesh'): + return model_module.create_shape_mesh(params, resolution) + raise NotImplementedError(f"Model '{self.name}' does not have create_shape_mesh function") + + def get_default_params(self) -> Dict[str, float]: + """Extract defaults from model parameters.""" + defaults = {} + for param in self.parameters: + if len(param) >= 5 and param[4] == 'volume': + defaults[param[0]] = param[2] + return defaults + + def _plot_cross_sections(self, ax_xy, ax_xz, ax_yz, params: Dict[str, float]): + """Plot cross-sections by calling model function.""" + model_module = self._get_model_module() + if model_module and hasattr(model_module, 'plot_shape_cross_sections'): + model_module.plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params) + return + raise NotImplementedError(f"Model '{self.name}' does not have plot_shape_cross_sections function") + + +class BarbellVisualizer(GenericModelVisualizer): + """Visualizer for barbell shapes.""" + pass + + +class VesicleVisualizer(GenericModelVisualizer): + """Visualizer for vesicle (hollow sphere) shapes.""" + pass + + +class TriaxialEllipsoidVisualizer(GenericModelVisualizer): + """Visualizer for triaxial ellipsoid shapes.""" + pass + + +class RectangularPrismVisualizer(GenericModelVisualizer): + """Visualizer for rectangular prism shapes.""" + pass + + +class HollowRectangularPrismVisualizer(GenericModelVisualizer): + """Visualizer for hollow rectangular prism shapes.""" + pass + + +class LinearPearlsVisualizer(GenericModelVisualizer): + """Visualizer for linear pearls shapes.""" + pass + + +class CoreShellEllipsoidVisualizer(GenericModelVisualizer): + """Visualizer for core-shell ellipsoid shapes.""" + pass + + +class FuzzySphereVisualizer(GenericModelVisualizer): + """Visualizer for fuzzy sphere shapes.""" + pass + + +class FlexibleCylinderVisualizer(GenericModelVisualizer): + """Visualizer for flexible cylinder shapes.""" + pass + + +class MultilayerVesicleVisualizer(GenericModelVisualizer): + """Visualizer for multilayer vesicle shapes.""" + pass + + +class SuperballVisualizer(GenericModelVisualizer): + """Visualizer for superball shapes.""" + pass + + +class CoreShellParallelepipedVisualizer(GenericModelVisualizer): + """Visualizer for core-shell parallelepiped shapes.""" + pass + + +class HollowRectangularPrismThinWallsVisualizer(GenericModelVisualizer): + """Visualizer for hollow rectangular prism with thin walls.""" + pass + + +class CoreShellBicelleVisualizer(GenericModelVisualizer): + """Visualizer for core-shell bicelle shapes.""" + pass + + +class SASModelsShapeDetector: + """Automatically detect and classify sasmodels shapes.""" + + # Shape type mappings (order matters - more specific shapes first!) + SHAPE_MAPPINGS = { + 'core_shell_bicelle_elliptical': CoreShellBicelleEllipticalVisualizer, # Very specific + 'flexible_cylinder_elliptical': FlexibleCylinderEllipticalVisualizer, # Very specific + 'hollow_rectangular_prism_thin_walls': HollowRectangularPrismThinWallsVisualizer, # Most specific + 'hollow_rectangular_prism': HollowRectangularPrismVisualizer, # Must come before rectangular_prism + 'core_shell_parallelepiped': CoreShellParallelepipedVisualizer, # Must come before parallelepiped + 'core_shell_bicelle': CoreShellBicelleVisualizer, # Must come before cylinder + 'core_shell_ellipsoid': CoreShellEllipsoidVisualizer, # Must come before ellipsoid + 'triaxial_ellipsoid': TriaxialEllipsoidVisualizer, # Must come before ellipsoid + 'multilayer_vesicle': MultilayerVesicleVisualizer, # Must come before vesicle + 'pearl_necklace': PearlNecklaceVisualizer, + 'linear_pearls': LinearPearlsVisualizer, + 'stacked_disks': StackedDisksVisualizer, + 'fuzzy_sphere': FuzzySphereVisualizer, # Must come before sphere + 'core_shell_sphere': CoreShellSphereVisualizer, # Must come before 'sphere' + 'vesicle': VesicleVisualizer, # Must come before sphere + 'superball': SuperballVisualizer, # Must come before sphere + 'core_shell_cylinder': CoreShellCylinderVisualizer, # Must come before 'cylinder' + 'flexible_cylinder': FlexibleCylinderVisualizer, # Must come before 'cylinder' + 'elliptical_cylinder': EllipticalCylinderVisualizer, # Must come before 'cylinder' + 'hollow_cylinder': HollowCylinderVisualizer, # Must come before 'cylinder' + 'capped_cylinder': CappedCylinderVisualizer, # Must come before 'cylinder' + 'barbell': BarbellVisualizer, # Similar to capped_cylinder + 'rectangular_prism': RectangularPrismVisualizer, + 'pringle': PringleVisualizer, + 'sphere': SphereVisualizer, + 'cylinder': CylinderVisualizer, + 'ellipsoid': EllipsoidVisualizer, + 'parallelepiped': ParallelepipedVisualizer, + } + + @classmethod + def detect_shape_type(cls, model_info: Dict[str, Any]) -> str: + """Detect the shape type from model information.""" + name = model_info.get('name', '').lower() + category = model_info.get('category', '').lower() + param_names = [p[0] for p in model_info.get('parameters', [])] + + # Direct name matching (most specific first) + for shape_name in cls.SHAPE_MAPPINGS: + if shape_name in name: + return shape_name + + # Parameter-based detection for specific shapes (before category matching) + # Check for core-shell cylinder (has radius, thickness, and length) + if 'radius' in param_names and 'thickness' in param_names and 'length' in param_names: + return 'core_shell_cylinder' + + # Check for core-shell sphere (has radius and thickness, but no length) + if 'radius' in param_names and 'thickness' in param_names and 'length' not in param_names: + return 'core_shell_sphere' + + # Check for capped cylinder + if 'radius_cap' in param_names: + return 'capped_cylinder' + + # Category-based detection + if 'sphere' in category: + return 'sphere' + elif 'cylinder' in category: + return 'cylinder' + elif 'ellipsoid' in category: + return 'ellipsoid' + elif 'parallelepiped' in category: + return 'parallelepiped' + + # Fallback parameter-based detection + if 'radius' in param_names and 'length' not in param_names: + return 'sphere' + elif 'radius' in param_names and 'length' in param_names: + return 'cylinder' + elif 'radius_polar' in param_names and 'radius_equatorial' in param_names: + return 'ellipsoid' + elif 'length_a' in param_names and 'length_b' in param_names: + return 'parallelepiped' + + return 'unknown' + + @classmethod + def create_visualizer(cls, model_info: Dict[str, Any]) -> Optional[ShapeVisualizer]: + """Create appropriate visualizer for the model.""" + shape_type = cls.detect_shape_type(model_info) + + if shape_type in cls.SHAPE_MAPPINGS: + visualizer_class = cls.SHAPE_MAPPINGS[shape_type] + return visualizer_class(model_info) + + print(f"Warning: No visualizer available for shape type '{shape_type}'") + return None + + +class SASModelsLoader: + """Load sasmodels model information.""" + + @staticmethod + def load_model_info(model_name: str) -> Optional[Dict[str, Any]]: + """Load model information from sasmodels.""" + try: + # Import the model (we're already in sasmodels package) + module_path = f'sasmodels.models.{model_name}' + model_module = importlib.import_module(module_path) + + # Extract model information + model_info = { + 'name': getattr(model_module, 'name', model_name), + 'title': getattr(model_module, 'title', ''), + 'description': getattr(model_module, 'description', ''), + 'category': getattr(model_module, 'category', ''), + 'parameters': getattr(model_module, 'parameters', []), + 'has_shape_visualization': getattr(model_module, 'has_shape_visualization', False), + } + + return model_info + + except ImportError as e: + print(f"Error loading model '{model_name}': {e}") + return None + + @staticmethod + def list_available_models() -> List[str]: + """List all available sasmodels.""" + try: + # We're in explore/shape_visualizer.py, so models are in ../sasmodels/models/ + current_dir = os.path.dirname(__file__) + models_dir = os.path.join(os.path.dirname(current_dir), 'sasmodels', 'models') + + if not os.path.exists(models_dir): + print("Warning: Could not find sasmodels/models directory") + return [] + + models = [] + for file in os.listdir(models_dir): + if file.endswith('.py') and not file.startswith('_'): + model_name = file[:-3] # Remove .py extension + models.append(model_name) + + return sorted(models) + + except Exception as e: + print(f"Error listing models: {e}") + return [] + + +def create_comparison_plot(model_names: List[str], figsize: Tuple[int, int] = (15, 10)): + """Create a comparison plot of multiple shapes.""" + n_models = len(model_names) + if n_models == 0: + return + + # Calculate subplot layout + cols = min(3, n_models) + rows = (n_models + cols - 1) // cols + + fig = plt.figure(figsize=figsize) + + for i, model_name in enumerate(model_names): + model_info = SASModelsLoader.load_model_info(model_name) + if model_info is None: + continue + + visualizer = SASModelsShapeDetector.create_visualizer(model_info) + if visualizer is None: + continue + + ax = fig.add_subplot(rows, cols, i+1, projection='3d') + + try: + params = visualizer.get_default_params() + mesh_data = visualizer.create_mesh(params) + visualizer._plot_mesh_components(ax, mesh_data, False) + + ax.set_title(f'{model_name.replace("_", " ").title()}', fontsize=12) + ax.set_xlabel('X (Å)') + ax.set_ylabel('Y (Å)') + ax.set_zlabel('Z (Å)') + + # Set limits + visualizer._set_plot_limits(ax, mesh_data, params) + + except Exception as e: + ax.text(0.5, 0.5, 0.5, f'Error: {str(e)[:50]}...', + transform=ax.transAxes, ha='center', va='center') + + plt.tight_layout() + plt.show() + + +def main(): + """Main function with command line interface.""" + parser = argparse.ArgumentParser(description='Generalized SASModels Shape Visualizer') + parser.add_argument('model', nargs='?', help='Model name to visualize') + parser.add_argument('--list', action='store_true', help='List available models') + parser.add_argument('--compare', nargs='+', help='Compare multiple models') + parser.add_argument('--save', type=str, help='Save plot to file') + parser.add_argument('--wireframe', action='store_true', help='Show wireframe') + parser.add_argument('--no-cross-sections', action='store_true', help='Hide cross-section plots') + parser.add_argument('--resolution', type=int, default=50, help='Mesh resolution') + + # Parameter overrides + parser.add_argument('--params', type=str, help='Parameter overrides as JSON string') + + args = parser.parse_args() + + if args.list: + print("Available SASModels:") + print("=" * 50) + models = SASModelsLoader.list_available_models() + + # Group by category + categorized = {} + for model_name in models: + model_info = SASModelsLoader.load_model_info(model_name) + if model_info: + category = model_info.get('category', 'unknown') + if category not in categorized: + categorized[category] = [] + categorized[category].append(model_name) + + for category, model_list in sorted(categorized.items()): + print(f"\n{category}:") + for model in sorted(model_list): + print(f" {model}") + + return + + if args.compare: + print(f"Comparing models: {', '.join(args.compare)}") + create_comparison_plot(args.compare) + return + + if not args.model: + print("Please specify a model name or use --list to see available models") + return + + # Load and visualize single model + print(f"Loading model: {args.model}") + model_info = SASModelsLoader.load_model_info(args.model) + + if model_info is None: + print(f"Could not load model '{args.model}'") + return + + print(f"Model: {model_info['name']}") + print(f"Category: {model_info['category']}") + print(f"Title: {model_info['title']}") + + visualizer = SASModelsShapeDetector.create_visualizer(model_info) + + if visualizer is None: + print(f"No visualizer available for model '{args.model}'") + return + + # Get parameters + params = visualizer.get_default_params() + + # Override parameters if provided + if args.params: + try: + import json + param_overrides = json.loads(args.params) + params.update(param_overrides) + print(f"Parameter overrides: {param_overrides}") + except json.JSONDecodeError as e: + print(f"Error parsing parameters: {e}") + + print(f"Using parameters: {params}") + + # Create visualization + show_cross_sections = not args.no_cross_sections + visualizer.plot_3d(params, args.save, args.wireframe, show_cross_sections) + + +def generate_shape_image(model_name, params=None, output_file=None, + show_cross_sections=True, show_wireframe=False): + """ + Generate a PNG image of the model shape. + + This is the public API function for sasview to use. + + Args: + model_name: Name of the sasmodels model + params: Dictionary of parameter values (uses defaults if None) + output_file: Path to save PNG (returns BytesIO if None) + show_cross_sections: Whether to include cross-section views + show_wireframe: Whether to show wireframe instead of solid + + Returns: + BytesIO buffer with PNG data if output_file is None, or None if saved to file + + Raises: + ValueError: If model doesn't support visualization or parameters are invalid + """ + from io import BytesIO + + # Load model info + model_info = SASModelsLoader.load_model_info(model_name) + if model_info is None: + raise ValueError(f"Could not load model '{model_name}'") + + # Check if visualization is supported + # Note: This will be checked via model_info.has_shape_visualization once flags are added + visualizer = SASModelsShapeDetector.create_visualizer(model_info) + if visualizer is None: + raise ValueError(f"Model '{model_name}' does not support shape visualization") + + # Get parameters + if params is None: + params = visualizer.get_default_params() + + # Create figure + if show_cross_sections: + fig = plt.figure(figsize=(16, 10)) + ax_3d = fig.add_subplot(2, 3, (1, 4), projection='3d') + ax_xy = fig.add_subplot(2, 3, 2) + ax_xz = fig.add_subplot(2, 3, 3) + ax_yz = fig.add_subplot(2, 3, 5) + else: + fig = plt.figure(figsize=(10, 8)) + ax_3d = fig.add_subplot(111, projection='3d') + ax_xy = ax_xz = ax_yz = None + + # Create mesh and plot + try: + mesh_data = visualizer.create_mesh(params) + visualizer._plot_mesh_components(ax_3d, mesh_data, show_wireframe) + visualizer._setup_3d_axis(ax_3d, mesh_data, params) + + if show_cross_sections: + visualizer._plot_cross_sections(ax_xy, ax_xz, ax_yz, params) + + visualizer._add_parameter_info(fig, params) + plt.tight_layout() + + # Save or return + if output_file: + plt.savefig(output_file, dpi=300, bbox_inches='tight') + plt.close(fig) + return None + else: + buf = BytesIO() + plt.savefig(buf, format='png', dpi=300, bbox_inches='tight') + plt.close(fig) + buf.seek(0) + return buf + + except Exception as e: + plt.close(fig) + raise ValueError(f"Error generating visualization: {e}") + + +if __name__ == "__main__": + main() diff --git a/sasmodels/__init__.py b/sasmodels/__init__.py index 2a9a48f43..26e06b91e 100644 --- a/sasmodels/__init__.py +++ b/sasmodels/__init__.py @@ -19,8 +19,8 @@ except ImportError: __version__ = "0.0.0.dev" -# Shape visualization API -from .shape_visualizer import SASModelsShapeDetector, generate_shape_image +# Shape visualization API moved to explore/shape_visualizer.py +# from .shape_visualizer import SASModelsShapeDetector, generate_shape_image def data_files(): diff --git a/sasmodels/models/barbell.py b/sasmodels/models/barbell.py index 44c5520d2..e864a18d1 100644 --- a/sasmodels/models/barbell.py +++ b/sasmodels/models/barbell.py @@ -118,7 +118,150 @@ # pylint: enable=bad-whitespace, line-too-long source = ["lib/polevl.c", "lib/sas_J1.c", "lib/gauss76.c", "barbell.c"] -has_shape_visualization = False +has_shape_visualization = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for barbell visualization.""" + import numpy as np + radius = params.get('radius', 20) + radius_bell = params.get('radius_bell', 40) + length = params.get('length', 400) + + if radius_bell < radius: + radius_bell = radius # Ensure valid geometry + + # Height where bell meets cylinder + h = np.sqrt(radius_bell**2 - radius**2) + + # Create cylinder body + theta = np.linspace(0, 2*np.pi, resolution) + z_cyl = np.linspace(-length/2, length/2, resolution//2) + theta_cyl, z_cyl_mesh = np.meshgrid(theta, z_cyl) + x_cyl = radius * np.cos(theta_cyl) + y_cyl = radius * np.sin(theta_cyl) + + # Create spherical bells (larger than cylinder caps) + # The bell center is at distance h inside the cylinder end + phi_max = np.arcsin(radius / radius_bell) + phi = np.linspace(0, phi_max, resolution//4) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + # Top bell + x_bell_top = radius_bell * np.sin(phi_mesh) * np.cos(theta_mesh) + y_bell_top = radius_bell * np.sin(phi_mesh) * np.sin(theta_mesh) + z_bell_top = length/2 + h + radius_bell * np.cos(phi_mesh) - radius_bell + + # Bottom bell + x_bell_bottom = radius_bell * np.sin(phi_mesh) * np.cos(theta_mesh) + y_bell_bottom = radius_bell * np.sin(phi_mesh) * np.sin(theta_mesh) + z_bell_bottom = -length/2 - h - radius_bell * np.cos(phi_mesh) + radius_bell + + return { + 'cylinder': (x_cyl, y_cyl, z_cyl_mesh), + 'bell_top': (x_bell_top, y_bell_top, z_bell_top), + 'bell_bottom': (x_bell_bottom, y_bell_bottom, z_bell_bottom) + } + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the barbell matching SasView documentation.""" + import numpy as np + from matplotlib.patches import Circle, Arc, Polygon + from matplotlib.collections import PatchCollection + + radius = params.get('radius', 20) # r in docs + radius_bell = params.get('radius_bell', 40) # R in docs + length = params.get('length', 400) # L in docs + + if radius_bell < radius: + radius_bell = radius + + # h = sqrt(R^2 - r^2) per documentation + h = np.sqrt(radius_bell**2 - radius**2) + + theta = np.linspace(0, 2*np.pi, 100) + + # XY plane - circle (cylinder cross-section at center) + circle_x = radius * np.cos(theta) + circle_y = radius * np.sin(theta) + ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.5) + ax_xy.plot(circle_x, circle_y, 'b-', linewidth=2, label=f'r={radius:.0f}Å') + + # Show bell outline for reference + bell_x = radius_bell * np.cos(theta) + bell_y = radius_bell * np.sin(theta) + ax_xy.plot(bell_x, bell_y, 'r--', linewidth=1.5, alpha=0.6, label=f'R={radius_bell:.0f}Å') + + ax_xy.set_xlim(-radius_bell*1.4, radius_bell*1.4) + ax_xy.set_ylim(-radius_bell*1.4, radius_bell*1.4) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ax_xy.legend(fontsize=9) + + # XZ plane (side view) - matching documentation figure + # Draw cylinder body + cyl_z = np.array([-length/2, length/2, length/2, -length/2, -length/2]) + cyl_r = np.array([-radius, -radius, radius, radius, -radius]) + ax_xz.fill(cyl_z, cyl_r, 'lightblue', alpha=0.5) + ax_xz.plot(cyl_z, cyl_r, 'b-', linewidth=2) + + # Right bell (positive z) - sphere center at z = L/2 + h + bell_center_right = length/2 + h + # Arc from angle where it meets cylinder to the tip + # At cylinder junction: z = L/2, r = ±radius + # The arc angle at junction: sin(angle) = radius/radius_bell + angle_junction = np.arcsin(radius / radius_bell) + bell_angles = np.linspace(-angle_junction, angle_junction, 50) + # Parametric: z = center + R*cos(angle), r = R*sin(angle) + bell_z_right = bell_center_right - radius_bell * np.cos(bell_angles) + bell_r_right = radius_bell * np.sin(bell_angles) + + # Create closed polygon for right bell + right_bell_z = np.concatenate([[length/2], bell_z_right, [length/2]]) + right_bell_r = np.concatenate([[radius], bell_r_right, [-radius]]) + ax_xz.fill(right_bell_z, right_bell_r, 'lightcoral', alpha=0.5) + ax_xz.plot(bell_z_right, bell_r_right, 'r-', linewidth=2, label='Bell') + + # Left bell (negative z) - sphere center at z = -L/2 - h + bell_center_left = -length/2 - h + bell_z_left = bell_center_left + radius_bell * np.cos(bell_angles) + bell_r_left = radius_bell * np.sin(bell_angles) + + left_bell_z = np.concatenate([[-length/2], bell_z_left, [-length/2]]) + left_bell_r = np.concatenate([[radius], bell_r_left, [-radius]]) + ax_xz.fill(left_bell_z, left_bell_r, 'lightcoral', alpha=0.5) + ax_xz.plot(bell_z_left, bell_r_left, 'r-', linewidth=2) + + # Add dimension annotations like in documentation + total_length = length + 2*h + 2*(radius_bell - h) + ax_xz.annotate('', xy=(length/2, 0), xytext=(-length/2, 0), + arrowprops=dict(arrowstyle='<->', color='black')) + ax_xz.text(0, -radius*1.5, f'L={length:.0f}Å', ha='center', fontsize=9) + + ax_xz.set_xlim(-length/2 - radius_bell*1.3, length/2 + radius_bell*1.3) + ax_xz.set_ylim(-radius_bell*1.4, radius_bell*1.4) + ax_xz.set_xlabel('Z (Å) - along axis') + ax_xz.set_ylabel('Radial (Å)') + ax_xz.set_title('Side View (like documentation)') + ax_xz.grid(True, alpha=0.3) + ax_xz.legend(fontsize=9) + + # YZ plane - same profile + ax_yz.fill(cyl_z, cyl_r, 'lightgreen', alpha=0.5) + ax_yz.plot(cyl_z, cyl_r, 'g-', linewidth=2) + ax_yz.fill(right_bell_z, right_bell_r, 'moccasin', alpha=0.5) + ax_yz.plot(bell_z_right, bell_r_right, 'orange', linewidth=2) + ax_yz.fill(left_bell_z, left_bell_r, 'moccasin', alpha=0.5) + ax_yz.plot(bell_z_left, bell_r_left, 'orange', linewidth=2) + + ax_yz.set_xlim(-length/2 - radius_bell*1.3, length/2 + radius_bell*1.3) + ax_yz.set_ylim(-radius_bell*1.4, radius_bell*1.4) + ax_yz.set_xlabel('Z (Å)') + ax_yz.set_ylabel('Radial (Å)') + ax_yz.set_title('YZ Cross-section') + ax_yz.grid(True, alpha=0.3) valid = "radius_bell >= radius" have_Fq = True radius_effective_modes = [ diff --git a/sasmodels/models/capped_cylinder.py b/sasmodels/models/capped_cylinder.py index 743232267..337dda5bc 100644 --- a/sasmodels/models/capped_cylinder.py +++ b/sasmodels/models/capped_cylinder.py @@ -187,109 +187,93 @@ def create_shape_mesh(params, resolution=50): } def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot cross-sections matching SasView documentation figure.""" import numpy as np - radius = params.get('radius', 20) - radius_cap = params.get('radius_cap', 25) - length = params.get('length', 400) + + radius = params.get('radius', 20) # r in docs + radius_cap = params.get('radius_cap', 25) # R in docs + length = params.get('length', 400) # L in docs if radius_cap < radius: return # Skip if invalid parameters + # h = sqrt(R^2 - r^2), cap centers are INSIDE cylinder at z = ±(L/2 - h) h = np.sqrt(radius_cap**2 - radius**2) - # XY plane (top view) - circle (same as cylinder) theta = np.linspace(0, 2*np.pi, 100) + + # XY plane (top view) - circle (cylinder cross-section) circle_x = radius * np.cos(theta) circle_y = radius * np.sin(theta) + ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.5) + ax_xy.plot(circle_x, circle_y, 'b-', linewidth=2, label=f'r={radius:.0f}Å') - ax_xy.plot(circle_x, circle_y, 'b-', linewidth=2, label='Cylinder') - ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.3) - - # Show cap outline if significantly larger - if radius_cap > radius * 1.1: - cap_circle_x = radius_cap * np.cos(theta) - cap_circle_y = radius_cap * np.sin(theta) - ax_xy.plot(cap_circle_x, cap_circle_y, 'r--', linewidth=1, alpha=0.7, label='Cap outline') - - ax_xy.set_xlim(-radius_cap*1.2, radius_cap*1.2) - ax_xy.set_ylim(-radius_cap*1.2, radius_cap*1.2) + ax_xy.set_xlim(-radius*1.4, radius*1.4) + ax_xy.set_ylim(-radius*1.4, radius*1.4) ax_xy.set_xlabel('X (Å)') ax_xy.set_ylabel('Y (Å)') - ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_title('XY Cross-section') ax_xy.set_aspect('equal') ax_xy.grid(True, alpha=0.3) - ax_xy.legend() + ax_xy.legend(fontsize=9) - # XZ plane (side view) - cylinder + caps + # XZ plane (side view) - matching documentation figure # Cylinder body - cyl_x = [-length/2, -length/2, length/2, length/2, -length/2] - cyl_z = [-radius, radius, radius, -radius, -radius] - ax_xz.plot(cyl_x, cyl_z, 'b-', linewidth=2, label='Cylinder') - ax_xz.fill(cyl_x, cyl_z, 'lightblue', alpha=0.3) - - # Spherical caps - cap_angles = np.linspace(0, 2*np.pi, 100) - - # Top cap - cap_center_top = length/2 - h - cap_x_top = cap_center_top + radius_cap * np.cos(cap_angles) - cap_z_top = radius_cap * np.sin(cap_angles) + cyl_z = np.array([-length/2, length/2, length/2, -length/2, -length/2]) + cyl_r = np.array([-radius, -radius, radius, radius, -radius]) + ax_xz.fill(cyl_z, cyl_r, 'lightblue', alpha=0.5) + ax_xz.plot(cyl_z, cyl_r, 'b-', linewidth=2) + + # Right cap (positive z) - center at z = L/2 - h (inside cylinder) + cap_center_right = length/2 - h + angle_junction = np.arcsin(radius / radius_cap) + cap_angles = np.linspace(-angle_junction, angle_junction, 50) + # Cap extends from cylinder end outward + cap_z_right = cap_center_right + radius_cap * np.cos(cap_angles) + cap_r_right = radius_cap * np.sin(cap_angles) + + # Create closed polygon for right cap + right_cap_z = np.concatenate([[length/2], cap_z_right, [length/2]]) + right_cap_r = np.concatenate([[radius], cap_r_right, [-radius]]) + ax_xz.fill(right_cap_z, right_cap_r, 'lightcoral', alpha=0.5) + ax_xz.plot(cap_z_right, cap_r_right, 'r-', linewidth=2, label='Caps') + + # Left cap (negative z) - center at z = -L/2 + h (inside cylinder) + cap_center_left = -length/2 + h + cap_z_left = cap_center_left - radius_cap * np.cos(cap_angles) + cap_r_left = radius_cap * np.sin(cap_angles) + + left_cap_z = np.concatenate([[-length/2], cap_z_left, [-length/2]]) + left_cap_r = np.concatenate([[radius], cap_r_left, [-radius]]) + ax_xz.fill(left_cap_z, left_cap_r, 'lightcoral', alpha=0.5) + ax_xz.plot(cap_z_left, cap_r_left, 'r-', linewidth=2) + + # Show cap centers (inside cylinder per documentation) + ax_xz.plot(cap_center_right, 0, 'ko', markersize=4) + ax_xz.plot(cap_center_left, 0, 'ko', markersize=4) - # Only show the part that extends beyond cylinder - mask_top = cap_x_top >= length/2 - ax_xz.plot(cap_x_top[mask_top], cap_z_top[mask_top], 'r-', linewidth=2, label='Caps') - ax_xz.fill_between(cap_x_top[mask_top], cap_z_top[mask_top], 0, alpha=0.3, color='lightcoral') + # Add dimension annotations + ax_xz.annotate('', xy=(length/2, 0), xytext=(-length/2, 0), + arrowprops=dict(arrowstyle='<->', color='black')) + ax_xz.text(0, -radius*1.3, f'L={length:.0f}Å', ha='center', fontsize=9) - # Bottom cap - cap_center_bottom = -length/2 + h - cap_x_bottom = cap_center_bottom + radius_cap * np.cos(cap_angles) - cap_z_bottom = radius_cap * np.sin(cap_angles) - - mask_bottom = cap_x_bottom <= -length/2 - ax_xz.plot(cap_x_bottom[mask_bottom], cap_z_bottom[mask_bottom], 'r-', linewidth=2) - ax_xz.fill_between(cap_x_bottom[mask_bottom], cap_z_bottom[mask_bottom], 0, alpha=0.3, color='lightcoral') - - # Mark cap centers - ax_xz.plot(cap_center_top, 0, 'ro', markersize=6, label='Cap centers') - ax_xz.plot(cap_center_bottom, 0, 'ro', markersize=6) - - ax_xz.set_xlim((-length/2 - radius_cap*0.5), (length/2 + radius_cap*0.5)) - ax_xz.set_ylim(-radius_cap*1.2, radius_cap*1.2) - ax_xz.set_xlabel('Z (Å)') - ax_xz.set_ylabel('X (Å)') - ax_xz.set_title('XZ Cross-section (Side View)') + ax_xz.set_xlim(-length/2 - radius_cap*0.3, length/2 + radius_cap*0.3) + ax_xz.set_ylim(-radius*1.5, radius*1.5) + ax_xz.set_xlabel('Z (Å) - along axis') + ax_xz.set_ylabel('Radial (Å)') + ax_xz.set_title('Side View (caps inside)') ax_xz.grid(True, alpha=0.3) - ax_xz.legend() - - # YZ plane (front view) - same as XZ - ax_yz.plot(cyl_x, cyl_z, 'g-', linewidth=2, label='Cylinder') - ax_yz.fill(cyl_x, cyl_z, 'lightgreen', alpha=0.3) - - ax_yz.plot(cap_x_top[mask_top], cap_z_top[mask_top], 'orange', linewidth=2, label='Caps') - ax_yz.fill_between(cap_x_top[mask_top], cap_z_top[mask_top], 0, alpha=0.3, color='moccasin') - ax_yz.plot(cap_x_bottom[mask_bottom], cap_z_bottom[mask_bottom], 'orange', linewidth=2) - ax_yz.fill_between(cap_x_bottom[mask_bottom], cap_z_bottom[mask_bottom], 0, alpha=0.3, color='moccasin') + ax_xz.legend(fontsize=9) - ax_yz.plot(cap_center_top, 0, 'o', color='orange', markersize=6, label='Cap centers') - ax_yz.plot(cap_center_bottom, 0, 'o', color='orange', markersize=6) + ax_yz.fill(left_cap_z, left_cap_r, 'moccasin', alpha=0.5) + ax_yz.plot(cap_z_left, cap_r_left, 'orange', linewidth=2) - ax_yz.set_xlim((-length/2 - radius_cap*0.5), (length/2 + radius_cap*0.5)) - ax_yz.set_ylim(-radius_cap*1.2, radius_cap*1.2) + ax_yz.set_xlim(-length/2 - radius_cap*0.3, length/2 + radius_cap*0.3) + ax_yz.set_ylim(-radius*1.5, radius*1.5) ax_yz.set_xlabel('Z (Å)') - ax_yz.set_ylabel('Y (Å)') - ax_yz.set_title('YZ Cross-section (Front View)') + ax_yz.set_ylabel('Radial (Å)') + ax_yz.set_title('YZ Cross-section') ax_yz.grid(True, alpha=0.3) - ax_yz.legend() - - # Add dimension annotations - ax_xz.annotate('', xy=(-length/2, -radius*1.4), xytext=(length/2, -radius*1.4), - arrowprops=dict(arrowstyle='<->', color='black')) - ax_xz.text(0, -radius*1.6, f'L = {length:.0f} Å', ha='center', fontsize=10) - - ax_xz.text(cap_center_top + radius_cap*0.3, radius_cap*0.7, f'R = {radius_cap:.0f} Å', - fontsize=10, rotation=45) - ax_xz.text(-length/4, radius*0.7, f'r = {radius:.0f} Å', fontsize=10) - ax_xz.text(cap_center_top, -radius*0.3, f'h = {h:.1f} Å', fontsize=10, ha='center') def random(): """Return a random parameter set for the model.""" diff --git a/sasmodels/models/core_shell_bicelle.py b/sasmodels/models/core_shell_bicelle.py index 860da28e6..87904b9af 100644 --- a/sasmodels/models/core_shell_bicelle.py +++ b/sasmodels/models/core_shell_bicelle.py @@ -154,7 +154,123 @@ source = ["lib/sas_Si.c", "lib/polevl.c", "lib/sas_J1.c", "lib/gauss76.c", "core_shell_bicelle.c"] -has_shape_visualization = False +has_shape_visualization = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for core-shell bicelle visualization.""" + import numpy as np + radius = params.get('radius', 80) + thick_rim = params.get('thick_rim', 10) + thick_face = params.get('thick_face', 10) + length = params.get('length', 50) + + outer_radius = radius + thick_rim + total_length = length + 2 * thick_face + + theta = np.linspace(0, 2*np.pi, resolution) + z = np.linspace(-total_length/2, total_length/2, resolution//2) + theta_mesh, z_mesh = np.meshgrid(theta, z) + + # Outer cylinder (rim surface) + x_outer = outer_radius * np.cos(theta_mesh) + y_outer = outer_radius * np.sin(theta_mesh) + + # Top cap + r = np.linspace(0, outer_radius, resolution//4) + r_mesh, theta_cap = np.meshgrid(r, theta) + x_top = r_mesh * np.cos(theta_cap) + y_top = r_mesh * np.sin(theta_cap) + z_top = np.full_like(x_top, total_length/2) + + # Bottom cap + z_bottom = np.full_like(x_top, -total_length/2) + + return { + 'rim': (x_outer, y_outer, z_mesh), + 'top': (x_top, y_top, z_top), + 'bottom': (x_top, y_top, z_bottom) + } + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the core-shell bicelle.""" + import numpy as np + radius = params.get('radius', 80) + thick_rim = params.get('thick_rim', 10) + thick_face = params.get('thick_face', 10) + length = params.get('length', 50) + + outer_radius = radius + thick_rim + total_length = length + 2 * thick_face + + theta = np.linspace(0, 2*np.pi, 100) + + # XY plane - concentric circles (core and rim) + core_x = radius * np.cos(theta) + core_y = radius * np.sin(theta) + outer_x = outer_radius * np.cos(theta) + outer_y = outer_radius * np.sin(theta) + + ax_xy.fill(outer_x, outer_y, 'lightcoral', alpha=0.3, label='Rim') + ax_xy.fill(core_x, core_y, 'lightblue', alpha=0.5, label='Core') + ax_xy.plot(outer_x, outer_y, 'r-', linewidth=2) + ax_xy.plot(core_x, core_y, 'b-', linewidth=2) + ax_xy.set_xlim(-outer_radius*1.3, outer_radius*1.3) + ax_xy.set_ylim(-outer_radius*1.3, outer_radius*1.3) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title(f'XY Cross-section (R={radius:.0f}, t_rim={thick_rim:.0f}Å)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ax_xy.legend(fontsize=8) + + # XZ plane - side view showing bicelle structure + # Rim (outer rectangle) + rim_z = np.array([-total_length/2, total_length/2, total_length/2, -total_length/2, -total_length/2]) + rim_r = np.array([-outer_radius, -outer_radius, outer_radius, outer_radius, -outer_radius]) + + # Core (inner rectangle, only in center) + core_z = np.array([-length/2, length/2, length/2, -length/2, -length/2]) + core_r = np.array([-radius, -radius, radius, radius, -radius]) + + # Face regions (top and bottom) + ax_xz.fill(rim_z, rim_r, 'lightcoral', alpha=0.3) # Rim (outer) + ax_xz.fill(core_z, core_r, 'lightblue', alpha=0.5) # Core + + # Draw faces (different color) + # Top face + face_top_z = np.array([length/2, total_length/2, total_length/2, length/2, length/2]) + face_top_r = np.array([-radius, -radius, radius, radius, -radius]) + ax_xz.fill(face_top_z, face_top_r, 'lightgreen', alpha=0.5) + + # Bottom face + face_bot_z = np.array([-total_length/2, -length/2, -length/2, -total_length/2, -total_length/2]) + face_bot_r = np.array([-radius, -radius, radius, radius, -radius]) + ax_xz.fill(face_bot_z, face_bot_r, 'lightgreen', alpha=0.5) + + ax_xz.plot(rim_z, rim_r, 'r-', linewidth=2) + ax_xz.plot(core_z, core_r, 'b-', linewidth=2) + + ax_xz.set_xlim(-total_length/2*1.2, total_length/2*1.2) + ax_xz.set_ylim(-outer_radius*1.3, outer_radius*1.3) + ax_xz.set_xlabel('Z (Å) - along axis') + ax_xz.set_ylabel('Radial (Å)') + ax_xz.set_title(f'Side View (L={length:.0f}, t_face={thick_face:.0f}Å)') + ax_xz.grid(True, alpha=0.3) + + # YZ plane - same as XZ + ax_yz.fill(rim_z, rim_r, 'moccasin', alpha=0.3) + ax_yz.fill(core_z, core_r, 'lightyellow', alpha=0.5) + ax_yz.fill(face_top_z, face_top_r, 'lightpink', alpha=0.5) + ax_yz.fill(face_bot_z, face_bot_r, 'lightpink', alpha=0.5) + ax_yz.plot(rim_z, rim_r, 'orange', linewidth=2) + ax_yz.plot(core_z, core_r, 'gold', linewidth=2) + + ax_yz.set_xlim(-total_length/2*1.2, total_length/2*1.2) + ax_yz.set_ylim(-outer_radius*1.3, outer_radius*1.3) + ax_yz.set_xlabel('Z (Å)') + ax_yz.set_ylabel('Radial (Å)') + ax_yz.set_title('YZ Cross-section') + ax_yz.grid(True, alpha=0.3) have_Fq = True radius_effective_modes = [ "excluded volume", "equivalent volume sphere", "outer rim radius", diff --git a/sasmodels/models/core_shell_ellipsoid.py b/sasmodels/models/core_shell_ellipsoid.py index 75d19fc93..1fa87cc71 100644 --- a/sasmodels/models/core_shell_ellipsoid.py +++ b/sasmodels/models/core_shell_ellipsoid.py @@ -150,7 +150,107 @@ # pylint: enable=bad-whitespace, line-too-long source = ["lib/sas_3j1x_x.c", "lib/gauss76.c", "core_shell_ellipsoid.c"] -has_shape_visualization = False +has_shape_visualization = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for core-shell ellipsoid visualization.""" + import numpy as np + radius_equat_core = params.get('radius_equat_core', 20) + x_core = params.get('x_core', 3) # ratio polar/equatorial + thick_shell = params.get('thick_shell', 30) + x_polar_shell = params.get('x_polar_shell', 1) + + # Core radii + radius_polar_core = radius_equat_core * x_core + + # Shell outer radii + radius_equat_outer = radius_equat_core + thick_shell + radius_polar_outer = radius_polar_core + thick_shell * x_polar_shell + + phi = np.linspace(0, np.pi, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + # Core ellipsoid + x_core_mesh = radius_equat_core * np.sin(phi_mesh) * np.cos(theta_mesh) + y_core_mesh = radius_equat_core * np.sin(phi_mesh) * np.sin(theta_mesh) + z_core_mesh = radius_polar_core * np.cos(phi_mesh) + + # Shell ellipsoid + x_shell = radius_equat_outer * np.sin(phi_mesh) * np.cos(theta_mesh) + y_shell = radius_equat_outer * np.sin(phi_mesh) * np.sin(theta_mesh) + z_shell = radius_polar_outer * np.cos(phi_mesh) + + return { + 'core': (x_core_mesh, y_core_mesh, z_core_mesh), + 'shell': (x_shell, y_shell, z_shell) + } + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the core-shell ellipsoid.""" + import numpy as np + radius_equat_core = params.get('radius_equat_core', 20) + x_core = params.get('x_core', 3) + thick_shell = params.get('thick_shell', 30) + x_polar_shell = params.get('x_polar_shell', 1) + + radius_polar_core = radius_equat_core * x_core + radius_equat_outer = radius_equat_core + thick_shell + radius_polar_outer = radius_polar_core + thick_shell * x_polar_shell + + theta = np.linspace(0, 2*np.pi, 100) + + # XY plane (equatorial) - circles + core_x = radius_equat_core * np.cos(theta) + core_y = radius_equat_core * np.sin(theta) + shell_x = radius_equat_outer * np.cos(theta) + shell_y = radius_equat_outer * np.sin(theta) + + ax_xy.fill(shell_x, shell_y, 'lightcoral', alpha=0.3, label='Shell') + ax_xy.fill(core_x, core_y, 'lightblue', alpha=0.5, label='Core') + ax_xy.plot(shell_x, shell_y, 'r-', linewidth=2) + ax_xy.plot(core_x, core_y, 'b-', linewidth=2) + max_xy = radius_equat_outer * 1.2 + ax_xy.set_xlim(-max_xy, max_xy) + ax_xy.set_ylim(-max_xy, max_xy) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Equatorial)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ax_xy.legend() + + # XZ plane (meridional) - ellipses + core_xz_x = radius_equat_core * np.cos(theta) + core_xz_z = radius_polar_core * np.sin(theta) + shell_xz_x = radius_equat_outer * np.cos(theta) + shell_xz_z = radius_polar_outer * np.sin(theta) + + ax_xz.fill(shell_xz_x, shell_xz_z, 'lightcoral', alpha=0.3) + ax_xz.fill(core_xz_x, core_xz_z, 'lightblue', alpha=0.5) + ax_xz.plot(shell_xz_x, shell_xz_z, 'r-', linewidth=2) + ax_xz.plot(core_xz_x, core_xz_z, 'b-', linewidth=2) + max_xz = max(radius_equat_outer, radius_polar_outer) * 1.2 + ax_xz.set_xlim(-max_xz, max_xz) + ax_xz.set_ylim(-max_xz, max_xz) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section (Meridional)') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + # YZ plane + ax_yz.fill(shell_xz_x, shell_xz_z, 'lightgreen', alpha=0.3) + ax_yz.fill(core_xz_x, core_xz_z, 'moccasin', alpha=0.5) + ax_yz.plot(shell_xz_x, shell_xz_z, 'g-', linewidth=2) + ax_yz.plot(core_xz_x, core_xz_z, 'orange', linewidth=2) + ax_yz.set_xlim(-max_xz, max_xz) + ax_yz.set_ylim(-max_xz, max_xz) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section (Meridional)') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) have_Fq = True radius_effective_modes = [ "average outer curvature", "equivalent volume sphere", diff --git a/sasmodels/models/core_shell_parallelepiped.py b/sasmodels/models/core_shell_parallelepiped.py index b6cb0b768..649362936 100644 --- a/sasmodels/models/core_shell_parallelepiped.py +++ b/sasmodels/models/core_shell_parallelepiped.py @@ -225,7 +225,117 @@ ] source = ["lib/gauss76.c", "core_shell_parallelepiped.c"] -has_shape_visualization = False +has_shape_visualization = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for core-shell parallelepiped visualization.""" + import numpy as np + length_a = params.get('length_a', 35) + length_b = params.get('length_b', 75) + length_c = params.get('length_c', 400) + thick_a = params.get('thick_rim_a', 10) + thick_b = params.get('thick_rim_b', 10) + thick_c = params.get('thick_rim_c', 10) + + faces = {} + + # Outer dimensions (core + shells on each face) + outer_a = length_a + 2 * thick_a + outer_b = length_b + 2 * thick_b + outer_c = length_c + 2 * thick_c + + # Create outer box faces + y = np.linspace(-outer_b/2, outer_b/2, resolution//4) + z = np.linspace(-outer_c/2, outer_c/2, resolution//2) + y_mesh, z_mesh = np.meshgrid(y, z) + faces['outer_front'] = (np.full_like(y_mesh, outer_a/2), y_mesh, z_mesh) + faces['outer_back'] = (np.full_like(y_mesh, -outer_a/2), y_mesh, z_mesh) + + x = np.linspace(-outer_a/2, outer_a/2, resolution//4) + z = np.linspace(-outer_c/2, outer_c/2, resolution//2) + x_mesh, z_mesh = np.meshgrid(x, z) + faces['outer_right'] = (x_mesh, np.full_like(x_mesh, outer_b/2), z_mesh) + faces['outer_left'] = (x_mesh, np.full_like(x_mesh, -outer_b/2), z_mesh) + + x = np.linspace(-outer_a/2, outer_a/2, resolution//4) + y = np.linspace(-outer_b/2, outer_b/2, resolution//4) + x_mesh, y_mesh = np.meshgrid(x, y) + faces['outer_top'] = (x_mesh, y_mesh, np.full_like(x_mesh, outer_c/2)) + faces['outer_bottom'] = (x_mesh, y_mesh, np.full_like(x_mesh, -outer_c/2)) + + return faces + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the core-shell parallelepiped.""" + import numpy as np + length_a = params.get('length_a', 35) + length_b = params.get('length_b', 75) + length_c = params.get('length_c', 400) + thick_a = params.get('thick_rim_a', 10) + thick_b = params.get('thick_rim_b', 10) + thick_c = params.get('thick_rim_c', 10) + + outer_a = length_a + 2 * thick_a + outer_b = length_b + 2 * thick_b + outer_c = length_c + 2 * thick_c + + # XY plane (top view) - shows A and B dimensions + # Outer rectangle + outer_x = [-outer_a/2, -outer_a/2, outer_a/2, outer_a/2, -outer_a/2] + outer_y = [-outer_b/2, outer_b/2, outer_b/2, -outer_b/2, -outer_b/2] + # Core rectangle + core_x = [-length_a/2, -length_a/2, length_a/2, length_a/2, -length_a/2] + core_y = [-length_b/2, length_b/2, length_b/2, -length_b/2, -length_b/2] + + ax_xy.fill(outer_x, outer_y, 'lightcoral', alpha=0.3, label='Shell') + ax_xy.fill(core_x, core_y, 'lightblue', alpha=0.5, label='Core') + ax_xy.plot(outer_x, outer_y, 'r-', linewidth=2) + ax_xy.plot(core_x, core_y, 'b-', linewidth=2) + max_xy = max(outer_a, outer_b) / 2 * 1.3 + ax_xy.set_xlim(-max_xy, max_xy) + ax_xy.set_ylim(-max_xy, max_xy) + ax_xy.set_xlabel('X (Å) - A') + ax_xy.set_ylabel('Y (Å) - B') + ax_xy.set_title(f'XY Cross-section (t_a={thick_a:.0f}, t_b={thick_b:.0f}Å)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ax_xy.legend(fontsize=8) + + # XZ plane (side view) - shows A and C dimensions + outer_xz_x = [-outer_a/2, -outer_a/2, outer_a/2, outer_a/2, -outer_a/2] + outer_xz_z = [-outer_c/2, outer_c/2, outer_c/2, -outer_c/2, -outer_c/2] + core_xz_x = [-length_a/2, -length_a/2, length_a/2, length_a/2, -length_a/2] + core_xz_z = [-length_c/2, length_c/2, length_c/2, -length_c/2, -length_c/2] + + ax_xz.fill(outer_xz_x, outer_xz_z, 'lightcoral', alpha=0.3) + ax_xz.fill(core_xz_x, core_xz_z, 'lightblue', alpha=0.5) + ax_xz.plot(outer_xz_x, outer_xz_z, 'r-', linewidth=2) + ax_xz.plot(core_xz_x, core_xz_z, 'b-', linewidth=2) + max_xz = max(outer_a, outer_c) / 2 * 1.1 + ax_xz.set_xlim(-max_xz, max_xz) + ax_xz.set_ylim(-max_xz, max_xz) + ax_xz.set_xlabel('X (Å) - A') + ax_xz.set_ylabel('Z (Å) - C') + ax_xz.set_title(f'XZ Cross-section (t_c={thick_c:.0f}Å)') + ax_xz.grid(True, alpha=0.3) + + # YZ plane (front view) - shows B and C dimensions + outer_yz_y = [-outer_b/2, -outer_b/2, outer_b/2, outer_b/2, -outer_b/2] + outer_yz_z = [-outer_c/2, outer_c/2, outer_c/2, -outer_c/2, -outer_c/2] + core_yz_y = [-length_b/2, -length_b/2, length_b/2, length_b/2, -length_b/2] + core_yz_z = [-length_c/2, length_c/2, length_c/2, -length_c/2, -length_c/2] + + ax_yz.fill(outer_yz_y, outer_yz_z, 'lightgreen', alpha=0.3) + ax_yz.fill(core_yz_y, core_yz_z, 'moccasin', alpha=0.5) + ax_yz.plot(outer_yz_y, outer_yz_z, 'g-', linewidth=2) + ax_yz.plot(core_yz_y, core_yz_z, 'orange', linewidth=2) + max_yz = max(outer_b, outer_c) / 2 * 1.1 + ax_yz.set_xlim(-max_yz, max_yz) + ax_yz.set_ylim(-max_yz, max_yz) + ax_yz.set_xlabel('Y (Å) - B') + ax_yz.set_ylabel('Z (Å) - C') + ax_yz.set_title('YZ Cross-section') + ax_yz.grid(True, alpha=0.3) have_Fq = True radius_effective_modes = [ "equivalent cylinder excluded volume", diff --git a/sasmodels/models/flexible_cylinder.py b/sasmodels/models/flexible_cylinder.py index 8e9f2c6b4..610e384b8 100644 --- a/sasmodels/models/flexible_cylinder.py +++ b/sasmodels/models/flexible_cylinder.py @@ -98,7 +98,83 @@ """ category = "shape:cylinder" -has_shape_visualization = False +has_shape_visualization = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for flexible cylinder (shown as simplified straight cylinder).""" + import numpy as np + length = params.get('length', 1000) + radius = params.get('radius', 20) + kuhn_length = params.get('kuhn_length', 100) + + # Show as straight cylinder (simplified view) + theta = np.linspace(0, 2*np.pi, resolution) + z = np.linspace(-length/2, length/2, resolution//2) + theta_mesh, z_mesh = np.meshgrid(theta, z) + + x = radius * np.cos(theta_mesh) + y = radius * np.sin(theta_mesh) + + return { + 'cylinder': (x, y, z_mesh), + '_note': f'Simplified straight view - actual model is flexible with Kuhn length {kuhn_length:.0f} Å' + } + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the flexible cylinder.""" + import numpy as np + length = params.get('length', 1000) + radius = params.get('radius', 20) + kuhn_length = params.get('kuhn_length', 100) + + theta = np.linspace(0, 2*np.pi, 100) + + # XY plane - circular cross-section + circle_x = radius * np.cos(theta) + circle_y = radius * np.sin(theta) + ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.5) + ax_xy.plot(circle_x, circle_y, 'b-', linewidth=2) + ax_xy.set_xlim(-radius*2, radius*2) + ax_xy.set_ylim(-radius*2, radius*2) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title(f'Cross-section (R={radius:.0f}Å)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # XZ plane - side view with Kuhn segments indicated + cyl_z = np.array([-length/2, length/2, length/2, -length/2, -length/2]) + cyl_r = np.array([-radius, -radius, radius, radius, -radius]) + ax_xz.fill(cyl_z, cyl_r, 'lightblue', alpha=0.5) + ax_xz.plot(cyl_z, cyl_r, 'b-', linewidth=2) + + # Mark Kuhn length segments + n_kuhn = int(length / kuhn_length) + for i in range(1, min(n_kuhn, 10)): # Show up to 10 segments + z_pos = -length/2 + i * kuhn_length + if z_pos < length/2: + ax_xz.axvline(z_pos, color='red', linestyle='--', alpha=0.5, linewidth=1) + + ax_xz.set_xlim(-length/2*1.1, length/2*1.1) + ax_xz.set_ylim(-radius*3, radius*3) + ax_xz.set_xlabel('Z (Å) - Contour Length') + ax_xz.set_ylabel('Radial (Å)') + ax_xz.set_title(f'Side View (L={length:.0f}Å, b={kuhn_length:.0f}Å)') + ax_xz.grid(True, alpha=0.3) + + # Add annotation + ax_xz.text(0, radius*2.5, f'Kuhn length b = {kuhn_length:.0f} Å (red lines)', + ha='center', fontsize=9, color='red') + + # YZ plane + ax_yz.fill(cyl_z, cyl_r, 'lightgreen', alpha=0.5) + ax_yz.plot(cyl_z, cyl_r, 'g-', linewidth=2) + ax_yz.set_xlim(-length/2*1.1, length/2*1.1) + ax_yz.set_ylim(-radius*3, radius*3) + ax_yz.set_xlabel('Z (Å)') + ax_yz.set_ylabel('Radial (Å)') + ax_yz.set_title('YZ Cross-section') + ax_yz.grid(True, alpha=0.3) single = False # double precision only! # pylint: disable=bad-whitespace, line-too-long diff --git a/sasmodels/models/fuzzy_sphere.py b/sasmodels/models/fuzzy_sphere.py index 096a536a9..c533eaab8 100644 --- a/sasmodels/models/fuzzy_sphere.py +++ b/sasmodels/models/fuzzy_sphere.py @@ -89,7 +89,106 @@ # pylint: enable=bad-whitespace,line-too-long source = ["lib/sas_3j1x_x.c", "fuzzy_sphere.c"] -has_shape_visualization = False +has_shape_visualization = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for fuzzy sphere visualization.""" + import numpy as np + radius = params.get('radius', 60) + fuzziness = params.get('fuzziness', 10) + + phi = np.linspace(0, np.pi, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + # Core sphere (where density is ~full) + core_radius = radius - 2 * fuzziness + core_radius = max(core_radius, radius * 0.5) # Ensure visible core + x_core = core_radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y_core = core_radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z_core = core_radius * np.cos(phi_mesh) + + # Outer sphere (where density approaches zero) + outer_radius = radius + 2 * fuzziness + x_outer = outer_radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y_outer = outer_radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z_outer = outer_radius * np.cos(phi_mesh) + + return { + 'core': (x_core, y_core, z_core), + 'fuzzy_boundary': (x_outer, y_outer, z_outer) + } + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the fuzzy sphere.""" + import numpy as np + radius = params.get('radius', 60) + fuzziness = params.get('fuzziness', 10) + + theta = np.linspace(0, 2*np.pi, 100) + + # Core, half-density, and outer radii + core_radius = max(radius - 2 * fuzziness, radius * 0.5) + outer_radius = radius + 2 * fuzziness + + core_x = core_radius * np.cos(theta) + core_y = core_radius * np.sin(theta) + mid_x = radius * np.cos(theta) + mid_y = radius * np.sin(theta) + outer_x = outer_radius * np.cos(theta) + outer_y = outer_radius * np.sin(theta) + + # XY plane + ax_xy.fill(outer_x, outer_y, 'lightyellow', alpha=0.3, label='Fuzzy interface') + ax_xy.fill(mid_x, mid_y, 'lightblue', alpha=0.4, label='Half-density (R)') + ax_xy.fill(core_x, core_y, 'blue', alpha=0.3, label='Dense core') + ax_xy.plot(outer_x, outer_y, 'y--', linewidth=1.5, alpha=0.7) + ax_xy.plot(mid_x, mid_y, 'b-', linewidth=2) + ax_xy.plot(core_x, core_y, 'b:', linewidth=1.5) + ax_xy.set_xlim(-outer_radius*1.2, outer_radius*1.2) + ax_xy.set_ylim(-outer_radius*1.2, outer_radius*1.2) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ax_xy.legend(fontsize=8) + + # XZ plane + ax_xz.fill(outer_x, outer_y, 'lightyellow', alpha=0.3) + ax_xz.fill(mid_x, mid_y, 'lightcoral', alpha=0.4) + ax_xz.fill(core_x, core_y, 'red', alpha=0.3) + ax_xz.plot(outer_x, outer_y, 'y--', linewidth=1.5, alpha=0.7) + ax_xz.plot(mid_x, mid_y, 'r-', linewidth=2) + ax_xz.plot(core_x, core_y, 'r:', linewidth=1.5) + ax_xz.set_xlim(-outer_radius*1.2, outer_radius*1.2) + ax_xz.set_ylim(-outer_radius*1.2, outer_radius*1.2) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + # Add annotations + ax_xz.annotate('', xy=(radius, 0), xytext=(0, 0), + arrowprops=dict(arrowstyle='->', color='black')) + ax_xz.text(radius/2, -outer_radius*0.15, f'R={radius:.0f}Å', ha='center', fontsize=9) + ax_xz.text(outer_radius*0.7, outer_radius*0.7, f'σ={fuzziness:.0f}Å', fontsize=9) + + # YZ plane + ax_yz.fill(outer_x, outer_y, 'lightyellow', alpha=0.3) + ax_yz.fill(mid_x, mid_y, 'lightgreen', alpha=0.4) + ax_yz.fill(core_x, core_y, 'green', alpha=0.3) + ax_yz.plot(outer_x, outer_y, 'y--', linewidth=1.5, alpha=0.7) + ax_yz.plot(mid_x, mid_y, 'g-', linewidth=2) + ax_yz.plot(core_x, core_y, 'g:', linewidth=1.5) + ax_yz.set_xlim(-outer_radius*1.2, outer_radius*1.2) + ax_yz.set_ylim(-outer_radius*1.2, outer_radius*1.2) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) have_Fq = True radius_effective_modes = ["radius", "radius + fuzziness"] diff --git a/sasmodels/models/hollow_rectangular_prism.py b/sasmodels/models/hollow_rectangular_prism.py index 2a5c139de..db73686c5 100644 --- a/sasmodels/models/hollow_rectangular_prism.py +++ b/sasmodels/models/hollow_rectangular_prism.py @@ -147,7 +147,122 @@ ] source = ["lib/gauss76.c", "hollow_rectangular_prism.c"] -has_shape_visualization = False +has_shape_visualization = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for hollow rectangular prism visualization.""" + import numpy as np + length_a = params.get('length_a', 35) + b2a_ratio = params.get('b2a_ratio', 1) + c2a_ratio = params.get('c2a_ratio', 1) + thickness = params.get('thickness', 1) + + # Outer dimensions + outer_a = length_a + outer_b = length_a * b2a_ratio + outer_c = length_a * c2a_ratio + + # Inner dimensions + inner_a = outer_a - 2 * thickness + inner_b = outer_b - 2 * thickness + inner_c = outer_c - 2 * thickness + + # Ensure inner dimensions are positive + inner_a = max(inner_a, 0.1) + inner_b = max(inner_b, 0.1) + inner_c = max(inner_c, 0.1) + + faces = {} + + # Outer faces + y = np.linspace(-outer_b/2, outer_b/2, resolution//4) + z = np.linspace(-outer_c/2, outer_c/2, resolution//2) + y_mesh, z_mesh = np.meshgrid(y, z) + faces['outer_front'] = (np.full_like(y_mesh, outer_a/2), y_mesh, z_mesh) + faces['outer_back'] = (np.full_like(y_mesh, -outer_a/2), y_mesh, z_mesh) + + x = np.linspace(-outer_a/2, outer_a/2, resolution//4) + z = np.linspace(-outer_c/2, outer_c/2, resolution//2) + x_mesh, z_mesh = np.meshgrid(x, z) + faces['outer_right'] = (x_mesh, np.full_like(x_mesh, outer_b/2), z_mesh) + faces['outer_left'] = (x_mesh, np.full_like(x_mesh, -outer_b/2), z_mesh) + + x = np.linspace(-outer_a/2, outer_a/2, resolution//4) + y = np.linspace(-outer_b/2, outer_b/2, resolution//4) + x_mesh, y_mesh = np.meshgrid(x, y) + faces['outer_top'] = (x_mesh, y_mesh, np.full_like(x_mesh, outer_c/2)) + faces['outer_bottom'] = (x_mesh, y_mesh, np.full_like(x_mesh, -outer_c/2)) + + return faces + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the hollow rectangular prism.""" + import numpy as np + length_a = params.get('length_a', 35) + b2a_ratio = params.get('b2a_ratio', 1) + c2a_ratio = params.get('c2a_ratio', 1) + thickness = params.get('thickness', 1) + + outer_a, outer_b, outer_c = length_a, length_a * b2a_ratio, length_a * c2a_ratio + inner_a = max(outer_a - 2*thickness, 0.1) + inner_b = max(outer_b - 2*thickness, 0.1) + inner_c = max(outer_c - 2*thickness, 0.1) + + # XY plane + outer_x = [-outer_a/2, -outer_a/2, outer_a/2, outer_a/2, -outer_a/2] + outer_y = [-outer_b/2, outer_b/2, outer_b/2, -outer_b/2, -outer_b/2] + inner_x = [-inner_a/2, -inner_a/2, inner_a/2, inner_a/2, -inner_a/2] + inner_y = [-inner_b/2, inner_b/2, inner_b/2, -inner_b/2, -inner_b/2] + + ax_xy.fill(outer_x, outer_y, 'lightblue', alpha=0.3) + ax_xy.fill(inner_x, inner_y, 'white', alpha=1.0) + ax_xy.plot(outer_x, outer_y, 'b-', linewidth=2, label='Outer') + ax_xy.plot(inner_x, inner_y, 'r--', linewidth=2, label='Inner (hollow)') + max_xy = max(outer_a, outer_b) / 2 * 1.3 + ax_xy.set_xlim(-max_xy, max_xy) + ax_xy.set_ylim(-max_xy, max_xy) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ax_xy.legend() + + # XZ plane + outer_xz_x = [-outer_a/2, -outer_a/2, outer_a/2, outer_a/2, -outer_a/2] + outer_xz_z = [-outer_c/2, outer_c/2, outer_c/2, -outer_c/2, -outer_c/2] + inner_xz_x = [-inner_a/2, -inner_a/2, inner_a/2, inner_a/2, -inner_a/2] + inner_xz_z = [-inner_c/2, inner_c/2, inner_c/2, -inner_c/2, -inner_c/2] + + ax_xz.fill(outer_xz_x, outer_xz_z, 'lightcoral', alpha=0.3) + ax_xz.fill(inner_xz_x, inner_xz_z, 'white', alpha=1.0) + ax_xz.plot(outer_xz_x, outer_xz_z, 'r-', linewidth=2) + ax_xz.plot(inner_xz_x, inner_xz_z, 'b--', linewidth=2) + max_xz = max(outer_a, outer_c) / 2 * 1.3 + ax_xz.set_xlim(-max_xz, max_xz) + ax_xz.set_ylim(-max_xz, max_xz) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section (Side View)') + ax_xz.grid(True, alpha=0.3) + + # YZ plane + outer_yz_y = [-outer_b/2, -outer_b/2, outer_b/2, outer_b/2, -outer_b/2] + outer_yz_z = [-outer_c/2, outer_c/2, outer_c/2, -outer_c/2, -outer_c/2] + inner_yz_y = [-inner_b/2, -inner_b/2, inner_b/2, inner_b/2, -inner_b/2] + inner_yz_z = [-inner_c/2, inner_c/2, inner_c/2, -inner_c/2, -inner_c/2] + + ax_yz.fill(outer_yz_y, outer_yz_z, 'lightgreen', alpha=0.3) + ax_yz.fill(inner_yz_y, inner_yz_z, 'white', alpha=1.0) + ax_yz.plot(outer_yz_y, outer_yz_z, 'g-', linewidth=2) + ax_yz.plot(inner_yz_y, inner_yz_z, 'orange', linewidth=2) + max_yz = max(outer_b, outer_c) / 2 * 1.3 + ax_yz.set_xlim(-max_yz, max_yz) + ax_yz.set_ylim(-max_yz, max_yz) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section (Front View)') + ax_yz.grid(True, alpha=0.3) have_Fq = True radius_effective_modes = [ "equivalent cylinder excluded volume", "equivalent outer volume sphere", diff --git a/sasmodels/models/hollow_rectangular_prism_thin_walls.py b/sasmodels/models/hollow_rectangular_prism_thin_walls.py index 7b8177573..f448546df 100644 --- a/sasmodels/models/hollow_rectangular_prism_thin_walls.py +++ b/sasmodels/models/hollow_rectangular_prism_thin_walls.py @@ -108,7 +108,89 @@ ] source = ["lib/gauss76.c", "hollow_rectangular_prism_thin_walls.c"] -has_shape_visualization = False +has_shape_visualization = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for hollow rectangular prism with thin walls.""" + import numpy as np + length_a = params.get('length_a', 35) + b2a_ratio = params.get('b2a_ratio', 1) + c2a_ratio = params.get('c2a_ratio', 1) + + length_b = length_a * b2a_ratio + length_c = length_a * c2a_ratio + + faces = {} + + # Create box frame (just the edges, since walls are infinitely thin) + # Front and back faces (as wireframe rectangles) + y = np.linspace(-length_b/2, length_b/2, resolution//4) + z = np.linspace(-length_c/2, length_c/2, resolution//2) + y_mesh, z_mesh = np.meshgrid(y, z) + faces['front'] = (np.full_like(y_mesh, length_a/2), y_mesh, z_mesh) + faces['back'] = (np.full_like(y_mesh, -length_a/2), y_mesh, z_mesh) + + x = np.linspace(-length_a/2, length_a/2, resolution//4) + z = np.linspace(-length_c/2, length_c/2, resolution//2) + x_mesh, z_mesh = np.meshgrid(x, z) + faces['right'] = (x_mesh, np.full_like(x_mesh, length_b/2), z_mesh) + faces['left'] = (x_mesh, np.full_like(x_mesh, -length_b/2), z_mesh) + + x = np.linspace(-length_a/2, length_a/2, resolution//4) + y = np.linspace(-length_b/2, length_b/2, resolution//4) + x_mesh, y_mesh = np.meshgrid(x, y) + faces['top'] = (x_mesh, y_mesh, np.full_like(x_mesh, length_c/2)) + faces['bottom'] = (x_mesh, y_mesh, np.full_like(x_mesh, -length_c/2)) + + return faces + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the hollow rectangular prism (thin walls).""" + import numpy as np + length_a = params.get('length_a', 35) + b2a_ratio = params.get('b2a_ratio', 1) + c2a_ratio = params.get('c2a_ratio', 1) + + length_b = length_a * b2a_ratio + length_c = length_a * c2a_ratio + + # XY plane - rectangle outline only (infinitely thin walls) + rect_x = [-length_a/2, -length_a/2, length_a/2, length_a/2, -length_a/2] + rect_y = [-length_b/2, length_b/2, length_b/2, -length_b/2, -length_b/2] + ax_xy.plot(rect_x, rect_y, 'b-', linewidth=3, label='Thin walls') + max_xy = max(length_a, length_b) / 2 * 1.3 + ax_xy.set_xlim(-max_xy, max_xy) + ax_xy.set_ylim(-max_xy, max_xy) + ax_xy.set_xlabel('X (Å) - A') + ax_xy.set_ylabel('Y (Å) - B') + ax_xy.set_title('XY Cross-section (wireframe)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ax_xy.legend(fontsize=9) + + # XZ plane + rect_xz_x = [-length_a/2, -length_a/2, length_a/2, length_a/2, -length_a/2] + rect_xz_z = [-length_c/2, length_c/2, length_c/2, -length_c/2, -length_c/2] + ax_xz.plot(rect_xz_x, rect_xz_z, 'r-', linewidth=3) + max_xz = max(length_a, length_c) / 2 * 1.3 + ax_xz.set_xlim(-max_xz, max_xz) + ax_xz.set_ylim(-max_xz, max_xz) + ax_xz.set_xlabel('X (Å) - A') + ax_xz.set_ylabel('Z (Å) - C') + ax_xz.set_title('XZ Cross-section') + ax_xz.grid(True, alpha=0.3) + + # YZ plane + rect_yz_y = [-length_b/2, -length_b/2, length_b/2, length_b/2, -length_b/2] + rect_yz_z = [-length_c/2, length_c/2, length_c/2, -length_c/2, -length_c/2] + ax_yz.plot(rect_yz_y, rect_yz_z, 'g-', linewidth=3) + max_yz = max(length_b, length_c) / 2 * 1.3 + ax_yz.set_xlim(-max_yz, max_yz) + ax_yz.set_ylim(-max_yz, max_yz) + ax_yz.set_xlabel('Y (Å) - B') + ax_yz.set_ylabel('Z (Å) - C') + ax_yz.set_title('YZ Cross-section') + ax_yz.grid(True, alpha=0.3) have_Fq = True radius_effective_modes = [ "equivalent cylinder excluded volume", "equivalent outer volume sphere", diff --git a/sasmodels/models/linear_pearls.py b/sasmodels/models/linear_pearls.py index fd06741ec..95e3e8885 100644 --- a/sasmodels/models/linear_pearls.py +++ b/sasmodels/models/linear_pearls.py @@ -66,7 +66,104 @@ ["sld_solvent", "1e-6/Ang^2", 6.3, [-inf, inf], "sld", "SLD of the solvent"], ] # pylint: enable=bad-whitespace, line-too-long -has_shape_visualization = False +has_shape_visualization = True + +def create_shape_mesh(params, resolution=40): + """Create 3D mesh for linear pearls visualization.""" + import numpy as np + radius = params.get('radius', 80.0) + edge_sep = params.get('edge_sep', 350.0) + num_pearls = int(round(params.get('num_pearls', 3))) + num_pearls = max(num_pearls, 1) + + # Spacing between pearl centers + center_step = 2 * radius + edge_sep + z_positions = [ + (i - (num_pearls - 1) / 2.0) * center_step + for i in range(num_pearls) + ] + + # Sphere mesh + phi = np.linspace(0, np.pi, resolution // 2) + theta = np.linspace(0, 2 * np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + pearls = {} + for i, z0 in enumerate(z_positions): + x = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z = radius * np.cos(phi_mesh) + z0 + pearls[f'pearl_{i}'] = (x, y, z) + + return pearls + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the linear pearls.""" + import numpy as np + radius = params.get('radius', 80.0) + edge_sep = params.get('edge_sep', 350.0) + num_pearls = int(round(params.get('num_pearls', 3))) + num_pearls = max(num_pearls, 1) + + center_step = 2 * radius + edge_sep + z_positions = [ + (i - (num_pearls - 1) / 2.0) * center_step + for i in range(num_pearls) + ] + + theta = np.linspace(0, 2 * np.pi, 100) + circle_x = radius * np.cos(theta) + circle_y = radius * np.sin(theta) + + # XY plane - single pearl cross-section + ax_xy.plot(circle_x, circle_y, 'b-', linewidth=2) + ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.3) + ax_xy.set_xlim(-radius*1.5, radius*1.5) + ax_xy.set_ylim(-radius*1.5, radius*1.5) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Single Pearl)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # XZ plane - all pearls in a line + colors = ['lightblue', 'lightcoral', 'lightgreen', 'lightyellow', 'lightpink'] + for i, z0 in enumerate(z_positions): + color = colors[i % len(colors)] + pearl_z = circle_y + z0 + ax_xz.plot(circle_x, pearl_z, 'b-', linewidth=2) + ax_xz.fill(circle_x, pearl_z, color, alpha=0.5) + + # Draw connecting lines + for i in range(num_pearls - 1): + ax_xz.plot([0, 0], [z_positions[i] + radius, z_positions[i+1] - radius], + 'k-', linewidth=1, alpha=0.5) + + total_length = z_positions[-1] - z_positions[0] + 2*radius if num_pearls > 1 else 2*radius + ax_xz.set_xlim(-radius*2, radius*2) + ax_xz.set_ylim(z_positions[0] - radius*1.5, z_positions[-1] + radius*1.5) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title(f'XZ Cross-section ({num_pearls} pearls)') + ax_xz.grid(True, alpha=0.3) + + # YZ plane - same as XZ + for i, z0 in enumerate(z_positions): + color = colors[i % len(colors)] + pearl_z = circle_y + z0 + ax_yz.plot(circle_x, pearl_z, 'g-', linewidth=2) + ax_yz.fill(circle_x, pearl_z, color, alpha=0.5) + + for i in range(num_pearls - 1): + ax_yz.plot([0, 0], [z_positions[i] + radius, z_positions[i+1] - radius], + 'k-', linewidth=1, alpha=0.5) + + ax_yz.set_xlim(-radius*2, radius*2) + ax_yz.set_ylim(z_positions[0] - radius*1.5, z_positions[-1] + radius*1.5) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title(f'YZ Cross-section ({num_pearls} pearls)') + ax_yz.grid(True, alpha=0.3) single = False source = ["lib/sas_3j1x_x.c", "linear_pearls.c"] diff --git a/sasmodels/models/multilayer_vesicle.py b/sasmodels/models/multilayer_vesicle.py index e8d055ab8..668accea2 100644 --- a/sasmodels/models/multilayer_vesicle.py +++ b/sasmodels/models/multilayer_vesicle.py @@ -126,6 +126,137 @@ background: incoherent background """ category = "shape:sphere" +has_shape_visualization = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for multilayer vesicle visualization.""" + import numpy as np + radius = params.get('radius', 60) + thick_shell = params.get('thick_shell', 10) + thick_solvent = params.get('thick_solvent', 10) + n_shells = int(round(params.get('n_shells', 2))) + n_shells = max(n_shells, 1) + + phi = np.linspace(0, np.pi, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + meshes = {} + # Core (solvent-filled) + x_core = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y_core = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z_core = radius * np.cos(phi_mesh) + meshes['core'] = (x_core, y_core, z_core) + + # Each shell + solvent layer pair + current_r = radius + for i in range(n_shells): + # Shell + shell_inner = current_r + shell_outer = current_r + thick_shell + x = shell_outer * np.sin(phi_mesh) * np.cos(theta_mesh) + y = shell_outer * np.sin(phi_mesh) * np.sin(theta_mesh) + z = shell_outer * np.cos(phi_mesh) + meshes[f'shell_{i}'] = (x, y, z) + current_r = shell_outer + + # Solvent layer (except for outermost) + if i < n_shells - 1: + current_r += thick_solvent + + return meshes + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the multilayer vesicle.""" + import numpy as np + radius = params.get('radius', 60) + thick_shell = params.get('thick_shell', 10) + thick_solvent = params.get('thick_solvent', 10) + n_shells = int(round(params.get('n_shells', 2))) + n_shells = max(n_shells, 1) + + theta = np.linspace(0, 2*np.pi, 100) + + # Calculate all radii + radii = [radius] # Core outer = first shell inner + current_r = radius + for i in range(n_shells): + shell_outer = current_r + thick_shell + radii.append(shell_outer) + if i < n_shells - 1: + radii.append(shell_outer + thick_solvent) + current_r = shell_outer + thick_solvent + else: + current_r = shell_outer + + outer_radius = radii[-1] + colors = ['lightblue', 'lightcoral', 'lightgreen', 'lightyellow', 'lightpink'] + + # XY plane - concentric circles + for i, r in enumerate(radii): + circle_x = r * np.cos(theta) + circle_y = r * np.sin(theta) + if i == 0: + ax_xy.fill(circle_x, circle_y, 'white', alpha=1.0) + ax_xy.plot(circle_x, circle_y, 'b--', linewidth=1.5, label='Core') + else: + is_shell = (i % 2 == 1) if n_shells > 1 else True + if is_shell: + ax_xy.plot(circle_x, circle_y, 'r-', linewidth=2) + else: + ax_xy.plot(circle_x, circle_y, 'b--', linewidth=1, alpha=0.5) + + ax_xy.set_xlim(-outer_radius*1.2, outer_radius*1.2) + ax_xy.set_ylim(-outer_radius*1.2, outer_radius*1.2) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title(f'XY Cross-section ({n_shells} shells)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # XZ plane - show layered structure + current_r = radius + # Draw from outside in for proper layering + for i in range(n_shells - 1, -1, -1): + if i < n_shells - 1: + solv_outer = radii[2*i + 2] if 2*i + 2 < len(radii) else radii[-1] + shell_outer = radius + (i + 1) * thick_shell + i * thick_solvent + shell_inner = radius + i * thick_shell + i * thick_solvent + + # Shell + shell_x = shell_outer * np.cos(theta) + shell_y = shell_outer * np.sin(theta) + ax_xz.fill(shell_x, shell_y, colors[i % len(colors)], alpha=0.6) + + inner_x = shell_inner * np.cos(theta) + inner_y = shell_inner * np.sin(theta) + ax_xz.fill(inner_x, inner_y, 'white', alpha=1.0) + + ax_xz.plot(radius * np.cos(theta), radius * np.sin(theta), 'b-', linewidth=2) + ax_xz.set_xlim(-outer_radius*1.2, outer_radius*1.2) + ax_xz.set_ylim(-outer_radius*1.2, outer_radius*1.2) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title(f'XZ Cross-section') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + # YZ plane - same as XZ + for i in range(n_shells - 1, -1, -1): + shell_outer = radius + (i + 1) * thick_shell + i * thick_solvent + shell_inner = radius + i * thick_shell + i * thick_solvent + ax_yz.fill(shell_outer * np.cos(theta), shell_outer * np.sin(theta), + colors[i % len(colors)], alpha=0.6) + ax_yz.fill(shell_inner * np.cos(theta), shell_inner * np.sin(theta), + 'white', alpha=1.0) + + ax_yz.set_xlim(-outer_radius*1.2, outer_radius*1.2) + ax_yz.set_ylim(-outer_radius*1.2, outer_radius*1.2) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title(f't_shell={thick_shell:.0f}Å, t_solv={thick_solvent:.0f}Å') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) # pylint: disable=bad-whitespace, line-too-long # ["name", "units", default, [lower, upper], "type","description"], @@ -144,7 +275,6 @@ #polydispersity = ["radius", "thick_shell"] source = ["lib/sas_3j1x_x.c", "multilayer_vesicle.c"] -has_shape_visualization = False have_Fq = True radius_effective_modes = ["outer radius"] diff --git a/sasmodels/models/rectangular_prism.py b/sasmodels/models/rectangular_prism.py index 144ff45fb..b3dbf809d 100644 --- a/sasmodels/models/rectangular_prism.py +++ b/sasmodels/models/rectangular_prism.py @@ -145,7 +145,94 @@ ] source = ["lib/gauss76.c", "rectangular_prism.c"] -has_shape_visualization = False +has_shape_visualization = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for rectangular prism visualization.""" + import numpy as np + length_a = params.get('length_a', 35) + b2a_ratio = params.get('b2a_ratio', 1) + c2a_ratio = params.get('c2a_ratio', 1) + + length_b = length_a * b2a_ratio + length_c = length_a * c2a_ratio + + faces = {} + # Front and back faces (YZ planes at x = ±a/2) + y = np.linspace(-length_b/2, length_b/2, resolution//4) + z = np.linspace(-length_c/2, length_c/2, resolution//2) + y_mesh, z_mesh = np.meshgrid(y, z) + faces['front'] = (np.full_like(y_mesh, length_a/2), y_mesh, z_mesh) + faces['back'] = (np.full_like(y_mesh, -length_a/2), y_mesh, z_mesh) + + # Left and right faces (XZ planes at y = ±b/2) + x = np.linspace(-length_a/2, length_a/2, resolution//4) + z = np.linspace(-length_c/2, length_c/2, resolution//2) + x_mesh, z_mesh = np.meshgrid(x, z) + faces['right'] = (x_mesh, np.full_like(x_mesh, length_b/2), z_mesh) + faces['left'] = (x_mesh, np.full_like(x_mesh, -length_b/2), z_mesh) + + # Top and bottom faces (XY planes at z = ±c/2) + x = np.linspace(-length_a/2, length_a/2, resolution//4) + y = np.linspace(-length_b/2, length_b/2, resolution//4) + x_mesh, y_mesh = np.meshgrid(x, y) + faces['top'] = (x_mesh, y_mesh, np.full_like(x_mesh, length_c/2)) + faces['bottom'] = (x_mesh, y_mesh, np.full_like(x_mesh, -length_c/2)) + + return faces + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the rectangular prism.""" + import numpy as np + length_a = params.get('length_a', 35) + b2a_ratio = params.get('b2a_ratio', 1) + c2a_ratio = params.get('c2a_ratio', 1) + + length_b = length_a * b2a_ratio + length_c = length_a * c2a_ratio + + # XY plane (A x B) + rect_x = [-length_a/2, -length_a/2, length_a/2, length_a/2, -length_a/2] + rect_y = [-length_b/2, length_b/2, length_b/2, -length_b/2, -length_b/2] + ax_xy.plot(rect_x, rect_y, 'b-', linewidth=2) + ax_xy.fill(rect_x, rect_y, 'lightblue', alpha=0.3) + max_xy = max(length_a, length_b) / 2 * 1.3 + ax_xy.set_xlim(-max_xy, max_xy) + ax_xy.set_ylim(-max_xy, max_xy) + ax_xy.set_xlabel('X (Å) - A') + ax_xy.set_ylabel('Y (Å) - B') + ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # Add dimensions + ax_xy.text(0, -max_xy*0.9, f'A={length_a:.0f}Å, B={length_b:.0f}Å', ha='center', fontsize=9) + + # XZ plane (A x C) + rect_x_xz = [-length_a/2, -length_a/2, length_a/2, length_a/2, -length_a/2] + rect_z = [-length_c/2, length_c/2, length_c/2, -length_c/2, -length_c/2] + ax_xz.plot(rect_x_xz, rect_z, 'r-', linewidth=2) + ax_xz.fill(rect_x_xz, rect_z, 'lightcoral', alpha=0.3) + max_xz = max(length_a, length_c) / 2 * 1.3 + ax_xz.set_xlim(-max_xz, max_xz) + ax_xz.set_ylim(-max_xz, max_xz) + ax_xz.set_xlabel('X (Å) - A') + ax_xz.set_ylabel('Z (Å) - C') + ax_xz.set_title('XZ Cross-section (Side View)') + ax_xz.grid(True, alpha=0.3) + + # YZ plane (B x C) + rect_y_yz = [-length_b/2, -length_b/2, length_b/2, length_b/2, -length_b/2] + rect_z_yz = [-length_c/2, length_c/2, length_c/2, -length_c/2, -length_c/2] + ax_yz.plot(rect_y_yz, rect_z_yz, 'g-', linewidth=2) + ax_yz.fill(rect_y_yz, rect_z_yz, 'lightgreen', alpha=0.3) + max_yz = max(length_b, length_c) / 2 * 1.3 + ax_yz.set_xlim(-max_yz, max_yz) + ax_yz.set_ylim(-max_yz, max_yz) + ax_yz.set_xlabel('Y (Å) - B') + ax_yz.set_ylabel('Z (Å) - C') + ax_yz.set_title('YZ Cross-section (Front View)') + ax_yz.grid(True, alpha=0.3) have_Fq = True radius_effective_modes = [ "equivalent cylinder excluded volume", "equivalent volume sphere", diff --git a/sasmodels/models/superball.py b/sasmodels/models/superball.py index 8b973f364..63ba849b2 100644 --- a/sasmodels/models/superball.py +++ b/sasmodels/models/superball.py @@ -152,7 +152,104 @@ # lib/gauss76.c # lib/gauss20.c source = ["lib/gauss20.c", "lib/sas_gamma.c", "superball.c"] -has_shape_visualization = False +has_shape_visualization = True + +def create_shape_mesh(params, resolution=60): + """Create 3D mesh for superball visualization.""" + import numpy as np + length_a = params.get('length_a', 50) + p = params.get('exponent_p', 2.5) + + # Superball equation: |x|^(2p) + |y|^(2p) + |z|^(2p) <= (a/2)^(2p) + # Parametric surface approximation + R = length_a / 2 + + # Use spherical-like coordinates but with superellipse shape + u = np.linspace(0, np.pi, resolution) + v = np.linspace(0, 2*np.pi, resolution) + u_mesh, v_mesh = np.meshgrid(u, v) + + # Superellipsoid parametric equations + def sign_pow(x, n): + return np.sign(x) * np.abs(x)**n + + cos_u = np.cos(u_mesh) + sin_u = np.sin(u_mesh) + cos_v = np.cos(v_mesh) + sin_v = np.sin(v_mesh) + + # Exponent for shape (1/p gives the superellipsoid exponent) + e = 1.0 / p + + x = R * sign_pow(sin_u, e) * sign_pow(cos_v, e) + y = R * sign_pow(sin_u, e) * sign_pow(sin_v, e) + z = R * sign_pow(cos_u, e) + + return {'superball': (x, y, z)} + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the superball.""" + import numpy as np + length_a = params.get('length_a', 50) + p = params.get('exponent_p', 2.5) + R = length_a / 2 + + # Superellipse: |x/R|^(2p) + |y/R|^(2p) = 1 + # Parametric: x = R*sign(cos(t))*|cos(t)|^(1/p), y = R*sign(sin(t))*|sin(t)|^(1/p) + t = np.linspace(0, 2*np.pi, 200) + + def sign_pow(x, n): + return np.sign(x) * np.abs(x)**n + + e = 1.0 / p + superellipse_x = R * sign_pow(np.cos(t), e) + superellipse_y = R * sign_pow(np.sin(t), e) + + # Reference shapes + circle_x = R * np.cos(t) + circle_y = R * np.sin(t) + + # XY plane + ax_xy.fill(superellipse_x, superellipse_y, 'lightblue', alpha=0.5) + ax_xy.plot(superellipse_x, superellipse_y, 'b-', linewidth=2, label=f'p={p:.1f}') + ax_xy.plot(circle_x, circle_y, 'g--', linewidth=1, alpha=0.5, label='sphere (p=1)') + # Square reference + sq = R * np.array([-1, -1, 1, 1, -1]) + ax_xy.plot(sq, np.array([-1, 1, 1, -1, -1])*R, 'r--', linewidth=1, alpha=0.5, label='cube (p=∞)') + + ax_xy.set_xlim(-R*1.4, R*1.4) + ax_xy.set_ylim(-R*1.4, R*1.4) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title(f'XY Cross-section (a={length_a:.0f}Å)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ax_xy.legend(fontsize=8) + + # XZ plane + ax_xz.fill(superellipse_x, superellipse_y, 'lightcoral', alpha=0.5) + ax_xz.plot(superellipse_x, superellipse_y, 'r-', linewidth=2) + ax_xz.plot(circle_x, circle_y, 'g--', linewidth=1, alpha=0.5) + ax_xz.plot(sq, np.array([-1, 1, 1, -1, -1])*R, 'b--', linewidth=1, alpha=0.5) + + ax_xz.set_xlim(-R*1.4, R*1.4) + ax_xz.set_ylim(-R*1.4, R*1.4) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title(f'XZ Cross-section (p={p:.2f})') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + # YZ plane + ax_yz.fill(superellipse_x, superellipse_y, 'lightgreen', alpha=0.5) + ax_yz.plot(superellipse_x, superellipse_y, 'g-', linewidth=2) + ax_yz.set_xlim(-R*1.4, R*1.4) + ax_yz.set_ylim(-R*1.4, R*1.4) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) have_Fq = True radius_effective_modes = [ "radius of gyration", diff --git a/sasmodels/models/triaxial_ellipsoid.py b/sasmodels/models/triaxial_ellipsoid.py index b382f25c5..7bbfd0539 100644 --- a/sasmodels/models/triaxial_ellipsoid.py +++ b/sasmodels/models/triaxial_ellipsoid.py @@ -159,7 +159,83 @@ ] source = ["lib/sas_3j1x_x.c", "lib/gauss76.c", "triaxial_ellipsoid.c"] -has_shape_visualization = False +has_shape_visualization = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for triaxial ellipsoid visualization.""" + import numpy as np + radius_a = params.get('radius_equat_minor', 20) + radius_b = params.get('radius_equat_major', 400) + radius_c = params.get('radius_polar', 10) + + phi = np.linspace(0, np.pi, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + x = radius_a * np.sin(phi_mesh) * np.cos(theta_mesh) + y = radius_b * np.sin(phi_mesh) * np.sin(theta_mesh) + z = radius_c * np.cos(phi_mesh) + + return {'ellipsoid': (x, y, z)} + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the triaxial ellipsoid.""" + import numpy as np + radius_a = params.get('radius_equat_minor', 20) + radius_b = params.get('radius_equat_major', 400) + radius_c = params.get('radius_polar', 10) + + theta = np.linspace(0, 2*np.pi, 100) + + # XY plane (Ra x Rb ellipse) + ellipse_x = radius_a * np.cos(theta) + ellipse_y = radius_b * np.sin(theta) + ax_xy.plot(ellipse_x, ellipse_y, 'b-', linewidth=2) + ax_xy.fill(ellipse_x, ellipse_y, 'lightblue', alpha=0.3) + max_xy = max(radius_a, radius_b) * 1.2 + ax_xy.set_xlim(-max_xy, max_xy) + ax_xy.set_ylim(-max_xy, max_xy) + ax_xy.set_xlabel('X (Å) - Ra') + ax_xy.set_ylabel('Y (Å) - Rb') + ax_xy.set_title('XY Cross-section (Equatorial)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # Annotations + ax_xy.annotate('', xy=(radius_a, 0), xytext=(0, 0), + arrowprops=dict(arrowstyle='->', color='blue')) + ax_xy.text(radius_a/2, -max_xy*0.15, f'Ra={radius_a:.0f}Å', ha='center', fontsize=9) + ax_xy.annotate('', xy=(0, radius_b), xytext=(0, 0), + arrowprops=dict(arrowstyle='->', color='blue')) + ax_xy.text(-max_xy*0.15, radius_b/2, f'Rb={radius_b:.0f}Å', ha='center', fontsize=9, rotation=90) + + # XZ plane (Ra x Rc ellipse) + ellipse_x_xz = radius_a * np.cos(theta) + ellipse_z = radius_c * np.sin(theta) + ax_xz.plot(ellipse_x_xz, ellipse_z, 'r-', linewidth=2) + ax_xz.fill(ellipse_x_xz, ellipse_z, 'lightcoral', alpha=0.3) + max_xz = max(radius_a, radius_c) * 1.2 + ax_xz.set_xlim(-max_xz, max_xz) + ax_xz.set_ylim(-max_xz, max_xz) + ax_xz.set_xlabel('X (Å) - Ra') + ax_xz.set_ylabel('Z (Å) - Rc') + ax_xz.set_title('XZ Cross-section (Meridional)') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + # YZ plane (Rb x Rc ellipse) + ellipse_y_yz = radius_b * np.cos(theta) + ellipse_z_yz = radius_c * np.sin(theta) + ax_yz.plot(ellipse_y_yz, ellipse_z_yz, 'g-', linewidth=2) + ax_yz.fill(ellipse_y_yz, ellipse_z_yz, 'lightgreen', alpha=0.3) + max_yz = max(radius_b, radius_c) * 1.2 + ax_yz.set_xlim(-max_yz, max_yz) + ax_yz.set_ylim(-max_yz, max_yz) + ax_yz.set_xlabel('Y (Å) - Rb') + ax_yz.set_ylabel('Z (Å) - Rc') + ax_yz.set_title('YZ Cross-section (Meridional)') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) # Equations do not require Ra <= Rb <= Rc so don't test for it. #valid = ("radius_equat_minor <= radius_equat_major" # " && radius_equat_major <= radius_polar") diff --git a/sasmodels/models/vesicle.py b/sasmodels/models/vesicle.py index 376db331c..95e3e4b40 100644 --- a/sasmodels/models/vesicle.py +++ b/sasmodels/models/vesicle.py @@ -97,7 +97,96 @@ ] source = ["lib/sas_3j1x_x.c", "vesicle.c"] -has_shape_visualization = False +has_shape_visualization = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for vesicle visualization (hollow sphere).""" + import numpy as np + radius = params.get('radius', 100) + thickness = params.get('thickness', 30) + + phi = np.linspace(0, np.pi, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + # Inner surface (core) + x_inner = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y_inner = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z_inner = radius * np.cos(phi_mesh) + + # Outer surface (shell) + outer_radius = radius + thickness + x_outer = outer_radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y_outer = outer_radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z_outer = outer_radius * np.cos(phi_mesh) + + return { + 'inner': (x_inner, y_inner, z_inner), + 'outer': (x_outer, y_outer, z_outer) + } + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the vesicle.""" + import numpy as np + radius = params.get('radius', 100) + thickness = params.get('thickness', 30) + outer_radius = radius + thickness + + theta = np.linspace(0, 2*np.pi, 100) + + # Inner and outer circles + inner_x = radius * np.cos(theta) + inner_y = radius * np.sin(theta) + outer_x = outer_radius * np.cos(theta) + outer_y = outer_radius * np.sin(theta) + + # XY plane + ax_xy.fill(outer_x, outer_y, 'lightcoral', alpha=0.3, label='Shell') + ax_xy.fill(inner_x, inner_y, 'white', alpha=1.0) + ax_xy.plot(outer_x, outer_y, 'r-', linewidth=2) + ax_xy.plot(inner_x, inner_y, 'b-', linewidth=2, label='Core boundary') + ax_xy.set_xlim(-outer_radius*1.2, outer_radius*1.2) + ax_xy.set_ylim(-outer_radius*1.2, outer_radius*1.2) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ax_xy.legend() + + # XZ plane + ax_xz.fill(outer_x, outer_y, 'lightcoral', alpha=0.3) + ax_xz.fill(inner_x, inner_y, 'white', alpha=1.0) + ax_xz.plot(outer_x, outer_y, 'r-', linewidth=2) + ax_xz.plot(inner_x, inner_y, 'b-', linewidth=2) + ax_xz.set_xlim(-outer_radius*1.2, outer_radius*1.2) + ax_xz.set_ylim(-outer_radius*1.2, outer_radius*1.2) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section (Side View)') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + # Add dimension annotations + ax_xz.annotate('', xy=(0, radius), xytext=(0, 0), + arrowprops=dict(arrowstyle='->', color='blue')) + ax_xz.text(5, radius/2, f'r={radius:.0f}Å', fontsize=9, color='blue') + ax_xz.annotate('', xy=(0, outer_radius), xytext=(0, radius), + arrowprops=dict(arrowstyle='->', color='red')) + ax_xz.text(5, radius + thickness/2, f't={thickness:.0f}Å', fontsize=9, color='red') + + # YZ plane + ax_yz.fill(outer_x, outer_y, 'lightgreen', alpha=0.3) + ax_yz.fill(inner_x, inner_y, 'white', alpha=1.0) + ax_yz.plot(outer_x, outer_y, 'g-', linewidth=2) + ax_yz.plot(inner_x, inner_y, 'orange', linewidth=2) + ax_yz.set_xlim(-outer_radius*1.2, outer_radius*1.2) + ax_yz.set_ylim(-outer_radius*1.2, outer_radius*1.2) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section (Front View)') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) have_Fq = True radius_effective_modes = ["outer radius"] From 5c33686abbe00af87a80c2263a595e9452f69798 Mon Sep 17 00:00:00 2001 From: Wojtek Potrzebowski Date: Tue, 20 Jan 2026 13:35:23 +0100 Subject: [PATCH 4/7] More visualizations enabled --- sasmodels/models/adsorbed_layer.py | 94 +++++++++++- sasmodels/models/bcc_paracrystal.py | 114 +++++++++++++- sasmodels/models/binary_hard_sphere.py | 89 ++++++++++- sasmodels/models/core_multi_shell.py | 109 ++++++++++++- sasmodels/models/fcc_paracrystal.py | 127 ++++++++++++++- sasmodels/models/fractal.py | 120 ++++++++++++++- sasmodels/models/fractal_core_shell.py | 134 +++++++++++++++- sasmodels/models/lamellar.py | 76 ++++++++- sasmodels/models/lamellar_hg.py | 92 ++++++++++- sasmodels/models/lamellar_hg_stack_caille.py | 97 +++++++++++- sasmodels/models/lamellar_stack_caille.py | 84 +++++++++- .../models/lamellar_stack_paracrystal.py | 89 ++++++++++- sasmodels/models/mass_fractal.py | 110 ++++++++++++- sasmodels/models/onion.py | 113 +++++++++++++- sasmodels/models/polymer_micelle.py | 89 ++++++++++- sasmodels/models/raspberry.py | 145 +++++++++++++++++- sasmodels/models/sc_paracrystal.py | 89 ++++++++++- sasmodels/models/spherical_sld.py | 109 ++++++++++++- sasmodels/models/surface_fractal.py | 82 +++++++++- 19 files changed, 1943 insertions(+), 19 deletions(-) diff --git a/sasmodels/models/adsorbed_layer.py b/sasmodels/models/adsorbed_layer.py index 50df36a54..5d82e8201 100644 --- a/sasmodels/models/adsorbed_layer.py +++ b/sasmodels/models/adsorbed_layer.py @@ -95,7 +95,99 @@ def Iq(q, second_moment, adsorbed_amount, density_shell, radius, return inten Iq.vectorized = True # Iq accepts an array of q values -has_shape_visualization = False +has_shape_visualization = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for adsorbed layer visualization.""" + import numpy as np + radius = params.get('radius', 500) + second_moment = params.get('second_moment', 23) + + # Estimate layer thickness from second moment + # For a step function, sigma = sqrt(t^2/12), so t = sigma * sqrt(12) + layer_thickness = second_moment * np.sqrt(12) + + phi = np.linspace(0, np.pi, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + mesh_data = {} + + # Core sphere (contrast matched, shown as wireframe conceptually) + x_core = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y_core = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z_core = radius * np.cos(phi_mesh) + mesh_data['core'] = (x_core, y_core, z_core) + + # Adsorbed layer (outer surface) + outer_radius = radius + layer_thickness + x_layer = outer_radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y_layer = outer_radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z_layer = outer_radius * np.cos(phi_mesh) + mesh_data['adsorbed_layer'] = (x_layer, y_layer, z_layer) + + return mesh_data + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of adsorbed layer on particle.""" + import numpy as np + radius = params.get('radius', 500) + second_moment = params.get('second_moment', 23) + + layer_thickness = second_moment * np.sqrt(12) + outer_radius = radius + layer_thickness + + theta = np.linspace(0, 2*np.pi, 100) + max_r = outer_radius * 1.2 + + # Core circle + core_x = radius * np.cos(theta) + core_y = radius * np.sin(theta) + + # Outer circle (with layer) + outer_x = outer_radius * np.cos(theta) + outer_y = outer_radius * np.sin(theta) + + # XY plane + ax_xy.fill(outer_x, outer_y, 'lightgreen', alpha=0.4, label='Adsorbed layer') + ax_xy.fill(core_x, core_y, 'lightgray', alpha=0.5, label='Core (matched)') + ax_xy.plot(outer_x, outer_y, 'g-', linewidth=2) + ax_xy.plot(core_x, core_y, 'k--', linewidth=1.5) + ax_xy.set_xlim(-max_r, max_r) + ax_xy.set_ylim(-max_r, max_r) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section') + ax_xy.set_aspect('equal') + ax_xy.legend(loc='upper right', fontsize=7) + ax_xy.grid(True, alpha=0.3) + + # XZ plane + ax_xz.fill(outer_x, outer_y, 'lightgreen', alpha=0.4) + ax_xz.fill(core_x, core_y, 'lightgray', alpha=0.5) + ax_xz.plot(outer_x, outer_y, 'g-', linewidth=2) + ax_xz.plot(core_x, core_y, 'k--', linewidth=1.5) + ax_xz.set_xlim(-max_r, max_r) + ax_xz.set_ylim(-max_r, max_r) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title(f'XZ Cross-section (layer~{layer_thickness:.1f}Å)') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + # YZ plane + ax_yz.fill(outer_x, outer_y, 'lightgreen', alpha=0.4) + ax_yz.fill(core_x, core_y, 'lightgray', alpha=0.5) + ax_yz.plot(outer_x, outer_y, 'g-', linewidth=2) + ax_yz.plot(core_x, core_y, 'k--', linewidth=1.5) + ax_yz.set_xlim(-max_r, max_r) + ax_yz.set_ylim(-max_r, max_r) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) + def random(): """Return a random parameter set for the model.""" # only care about the value of second_moment: diff --git a/sasmodels/models/bcc_paracrystal.py b/sasmodels/models/bcc_paracrystal.py index 817c723a0..a5c87811e 100644 --- a/sasmodels/models/bcc_paracrystal.py +++ b/sasmodels/models/bcc_paracrystal.py @@ -179,9 +179,121 @@ category = "shape:paracrystal" #note - calculation requires double precision -has_shape_visualization = False +has_shape_visualization = True single = False +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for body-centered cubic paracrystal visualization.""" + import numpy as np + dnn = params.get('dnn', 220) # nearest neighbor distance + radius = params.get('radius', 40) + + # For BCC, conventional cell parameter a = 2*dnn/sqrt(3) + a = 2 * dnn / np.sqrt(3) + + phi = np.linspace(0, np.pi, resolution//3) + theta = np.linspace(0, 2*np.pi, resolution//2) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + mesh_data = {} + + # BCC lattice: corners + center of cube + # Create a 2x2x2 arrangement to show the structure + positions = [] + for i in range(-1, 2): + for j in range(-1, 2): + for k in range(-1, 2): + # Corner positions + positions.append((i * a, j * a, k * a)) + # Body center (offset by a/2 in all directions) + if i < 1 and j < 1 and k < 1: + positions.append(((i + 0.5) * a, (j + 0.5) * a, (k + 0.5) * a)) + + for idx, (px, py, pz) in enumerate(positions): + x = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + px + y = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + py + z = radius * np.cos(phi_mesh) + pz + mesh_data[f'sphere_{idx}'] = (x, y, z) + + return mesh_data + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of BCC paracrystal.""" + import numpy as np + dnn = params.get('dnn', 220) + radius = params.get('radius', 40) + + a = 2 * dnn / np.sqrt(3) + theta = np.linspace(0, 2*np.pi, 100) + max_extent = a * 1.5 + radius + + # XY plane at z=0 - shows corner atoms + for i in range(-1, 2): + for j in range(-1, 2): + cx, cy = i * a, j * a + circle_x = radius * np.cos(theta) + cx + circle_y = radius * np.sin(theta) + cy + ax_xy.plot(circle_x, circle_y, 'b-', linewidth=1.5) + ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.3) + + ax_xy.set_xlim(-max_extent, max_extent) + ax_xy.set_ylim(-max_extent, max_extent) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title(f'XY Cross-section (BCC, dnn={dnn:.0f}Å)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # XZ plane at y=0 - shows corner atoms and body centers + for i in range(-1, 2): + for k in range(-1, 2): + # Corner atoms + cx, cz = i * a, k * a + circle_x = radius * np.cos(theta) + cx + circle_z = radius * np.sin(theta) + cz + ax_xz.plot(circle_x, circle_z, 'b-', linewidth=1.5) + ax_xz.fill(circle_x, circle_z, 'lightblue', alpha=0.3) + # Body center (at y=a/2, visible as projection) + if i < 1 and k < 1: + cx_c = (i + 0.5) * a + cz_c = (k + 0.5) * a + circle_x_c = radius * np.cos(theta) + cx_c + circle_z_c = radius * np.sin(theta) + cz_c + ax_xz.plot(circle_x_c, circle_z_c, 'r-', linewidth=1.5) + ax_xz.fill(circle_x_c, circle_z_c, 'lightcoral', alpha=0.3) + + ax_xz.set_xlim(-max_extent, max_extent) + ax_xz.set_ylim(-max_extent, max_extent) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + # YZ plane at x=0 + for j in range(-1, 2): + for k in range(-1, 2): + cy, cz = j * a, k * a + circle_y = radius * np.cos(theta) + cy + circle_z = radius * np.sin(theta) + cz + ax_yz.plot(circle_y, circle_z, 'b-', linewidth=1.5) + ax_yz.fill(circle_y, circle_z, 'lightblue', alpha=0.3) + if j < 1 and k < 1: + cy_c = (j + 0.5) * a + cz_c = (k + 0.5) * a + circle_y_c = radius * np.cos(theta) + cy_c + circle_z_c = radius * np.sin(theta) + cz_c + ax_yz.plot(circle_y_c, circle_z_c, 'r-', linewidth=1.5) + ax_yz.fill(circle_y_c, circle_z_c, 'lightcoral', alpha=0.3) + + ax_yz.set_xlim(-max_extent, max_extent) + ax_yz.set_ylim(-max_extent, max_extent) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) + # pylint: disable=bad-whitespace, line-too-long # ["name", "units", default, [lower, upper], "type","description" ], parameters = [["dnn", "Ang", 220, [-inf, inf], "", "Nearest neighbour distance"], diff --git a/sasmodels/models/binary_hard_sphere.py b/sasmodels/models/binary_hard_sphere.py index 6ee4e5c98..b261d991d 100644 --- a/sasmodels/models/binary_hard_sphere.py +++ b/sasmodels/models/binary_hard_sphere.py @@ -77,9 +77,96 @@ from numpy import inf category = "shape:sphere" -has_shape_visualization = False +has_shape_visualization = True single = False # double precision only! +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for binary hard sphere visualization.""" + import numpy as np + radius_lg = params.get('radius_lg', 100) + radius_sm = params.get('radius_sm', 25) + + # Create spheres + phi = np.linspace(0, np.pi, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + # Large sphere (centered at origin) + x_lg = radius_lg * np.sin(phi_mesh) * np.cos(theta_mesh) + y_lg = radius_lg * np.sin(phi_mesh) * np.sin(theta_mesh) + z_lg = radius_lg * np.cos(phi_mesh) + + # Small sphere (offset to the side) + offset = radius_lg * 1.5 + x_sm = radius_sm * np.sin(phi_mesh) * np.cos(theta_mesh) + offset + y_sm = radius_sm * np.sin(phi_mesh) * np.sin(theta_mesh) + z_sm = radius_sm * np.cos(phi_mesh) + + return { + 'large_sphere': (x_lg, y_lg, z_lg), + 'small_sphere': (x_sm, y_sm, z_sm) + } + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of binary hard spheres.""" + import numpy as np + radius_lg = params.get('radius_lg', 100) + radius_sm = params.get('radius_sm', 25) + + theta = np.linspace(0, 2*np.pi, 100) + offset = radius_lg * 1.5 + + # Large sphere circle + lg_x = radius_lg * np.cos(theta) + lg_y = radius_lg * np.sin(theta) + + # Small sphere circle (offset) + sm_x = radius_sm * np.cos(theta) + offset + sm_y = radius_sm * np.sin(theta) + + max_extent = offset + radius_sm * 1.5 + + # XY plane + ax_xy.plot(lg_x, lg_y, 'b-', linewidth=2, label=f'Large (R={radius_lg:.0f}Å)') + ax_xy.fill(lg_x, lg_y, 'lightblue', alpha=0.3) + ax_xy.plot(sm_x, sm_y, 'r-', linewidth=2, label=f'Small (R={radius_sm:.0f}Å)') + ax_xy.fill(sm_x, sm_y, 'lightcoral', alpha=0.3) + ax_xy.set_xlim(-radius_lg*1.5, max_extent) + ax_xy.set_ylim(-radius_lg*1.5, radius_lg*1.5) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section') + ax_xy.set_aspect('equal') + ax_xy.legend(loc='upper left', fontsize=8) + ax_xy.grid(True, alpha=0.3) + + # XZ plane + ax_xz.plot(lg_x, lg_y, 'b-', linewidth=2) + ax_xz.fill(lg_x, lg_y, 'lightblue', alpha=0.3) + ax_xz.plot(sm_x, sm_y, 'r-', linewidth=2) + ax_xz.fill(sm_x, sm_y, 'lightcoral', alpha=0.3) + ax_xz.set_xlim(-radius_lg*1.5, max_extent) + ax_xz.set_ylim(-radius_lg*1.5, radius_lg*1.5) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + # YZ plane + ax_yz.plot(lg_y, lg_y, 'b-', linewidth=2) + ax_yz.fill(lg_y, lg_y, 'lightblue', alpha=0.3) + # Small sphere at origin in YZ plane + ax_yz.plot(sm_y - offset, sm_y, 'r-', linewidth=2) + ax_yz.fill(sm_y - offset, sm_y, 'lightcoral', alpha=0.3) + ax_yz.set_xlim(-radius_lg*1.5, radius_lg*1.5) + ax_yz.set_ylim(-radius_lg*1.5, radius_lg*1.5) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) + name = "binary_hard_sphere" title = "binary mixture of hard spheres with hard sphere interactions." description = """Describes the scattering from a mixture of two distinct diff --git a/sasmodels/models/core_multi_shell.py b/sasmodels/models/core_multi_shell.py index de7c6665c..598922a22 100644 --- a/sasmodels/models/core_multi_shell.py +++ b/sasmodels/models/core_multi_shell.py @@ -99,8 +99,115 @@ ] source = ["lib/sas_3j1x_x.c", "core_multi_shell.c"] -has_shape_visualization = False +has_shape_visualization = True have_Fq = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for core multi-shell sphere visualization.""" + import numpy as np + radius = params.get('radius', 200) + n = int(params.get('n', 1)) + + phi = np.linspace(0, np.pi, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + mesh_data = {} + + # Core sphere + x_core = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y_core = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z_core = radius * np.cos(phi_mesh) + mesh_data['core'] = (x_core, y_core, z_core) + + # Add shells + current_radius = radius + for i in range(n): + thickness = params.get(f'thickness{i+1}', params.get('thickness', 40)) + if isinstance(thickness, (list, np.ndarray)): + thickness = thickness[i] if i < len(thickness) else thickness[-1] + current_radius += thickness + + x_shell = current_radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y_shell = current_radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z_shell = current_radius * np.cos(phi_mesh) + mesh_data[f'shell_{i+1}'] = (x_shell, y_shell, z_shell) + + return mesh_data + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of core multi-shell sphere.""" + import numpy as np + radius = params.get('radius', 200) + n = int(params.get('n', 1)) + + theta = np.linspace(0, 2*np.pi, 100) + colors = ['blue', 'red', 'green', 'orange', 'purple', 'brown', 'pink', 'gray', 'olive', 'cyan'] + + # Calculate all radii + radii = [radius] + current_radius = radius + for i in range(n): + thickness = params.get(f'thickness{i+1}', params.get('thickness', 40)) + if isinstance(thickness, (list, np.ndarray)): + thickness = thickness[i] if i < len(thickness) else thickness[-1] + current_radius += thickness + radii.append(current_radius) + + max_r = radii[-1] * 1.2 + + # XY plane + for i, r in enumerate(radii): + circle_x = r * np.cos(theta) + circle_y = r * np.sin(theta) + color = colors[i % len(colors)] + label = 'Core' if i == 0 else f'Shell {i}' + ax_xy.plot(circle_x, circle_y, '-', color=color, linewidth=2, label=label) + if i == 0: + ax_xy.fill(circle_x, circle_y, color=color, alpha=0.2) + + ax_xy.set_xlim(-max_r, max_r) + ax_xy.set_ylim(-max_r, max_r) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section') + ax_xy.set_aspect('equal') + ax_xy.legend(loc='upper right', fontsize=7) + ax_xy.grid(True, alpha=0.3) + + # XZ plane + for i, r in enumerate(radii): + circle_x = r * np.cos(theta) + circle_z = r * np.sin(theta) + color = colors[i % len(colors)] + ax_xz.plot(circle_x, circle_z, '-', color=color, linewidth=2) + if i == 0: + ax_xz.fill(circle_x, circle_z, color=color, alpha=0.2) + + ax_xz.set_xlim(-max_r, max_r) + ax_xz.set_ylim(-max_r, max_r) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + # YZ plane + for i, r in enumerate(radii): + circle_y = r * np.cos(theta) + circle_z = r * np.sin(theta) + color = colors[i % len(colors)] + ax_yz.plot(circle_y, circle_z, '-', color=color, linewidth=2) + if i == 0: + ax_yz.fill(circle_y, circle_z, color=color, alpha=0.2) + + ax_yz.set_xlim(-max_r, max_r) + ax_yz.set_ylim(-max_r, max_r) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) radius_effective_modes = ["outer radius", "core radius"] def random(): diff --git a/sasmodels/models/fcc_paracrystal.py b/sasmodels/models/fcc_paracrystal.py index 759095fe8..1e00b367a 100644 --- a/sasmodels/models/fcc_paracrystal.py +++ b/sasmodels/models/fcc_paracrystal.py @@ -177,9 +177,134 @@ """ category = "shape:paracrystal" -has_shape_visualization = False +has_shape_visualization = True single = False +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for face-centered cubic paracrystal visualization.""" + import numpy as np + dnn = params.get('dnn', 220) # nearest neighbor distance + radius = params.get('radius', 40) + + # For FCC, conventional cell parameter a = sqrt(2)*dnn + a = np.sqrt(2) * dnn + + phi = np.linspace(0, np.pi, resolution//3) + theta = np.linspace(0, 2*np.pi, resolution//2) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + mesh_data = {} + + # FCC lattice: corners + face centers + positions = [] + for i in range(-1, 2): + for j in range(-1, 2): + for k in range(-1, 2): + # Corner positions + positions.append((i * a, j * a, k * a)) + # Face centers (6 faces, but only add those within our range) + if i < 1 and j < 1: + positions.append(((i + 0.5) * a, (j + 0.5) * a, k * a)) # xy face + if i < 1 and k < 1: + positions.append(((i + 0.5) * a, j * a, (k + 0.5) * a)) # xz face + if j < 1 and k < 1: + positions.append((i * a, (j + 0.5) * a, (k + 0.5) * a)) # yz face + + # Remove duplicates + positions = list(set(positions)) + + for idx, (px, py, pz) in enumerate(positions): + x = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + px + y = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + py + z = radius * np.cos(phi_mesh) + pz + mesh_data[f'sphere_{idx}'] = (x, y, z) + + return mesh_data + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of FCC paracrystal.""" + import numpy as np + dnn = params.get('dnn', 220) + radius = params.get('radius', 40) + + a = np.sqrt(2) * dnn + theta = np.linspace(0, 2*np.pi, 100) + max_extent = a * 1.5 + radius + + # XY plane at z=0 - shows corner atoms and xy-face centers + for i in range(-1, 2): + for j in range(-1, 2): + # Corner atoms + cx, cy = i * a, j * a + circle_x = radius * np.cos(theta) + cx + circle_y = radius * np.sin(theta) + cy + ax_xy.plot(circle_x, circle_y, 'b-', linewidth=1.5) + ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.3) + # Face center atoms + if i < 1 and j < 1: + cx_f = (i + 0.5) * a + cy_f = (j + 0.5) * a + circle_x_f = radius * np.cos(theta) + cx_f + circle_y_f = radius * np.sin(theta) + cy_f + ax_xy.plot(circle_x_f, circle_y_f, 'r-', linewidth=1.5) + ax_xy.fill(circle_x_f, circle_y_f, 'lightcoral', alpha=0.3) + + ax_xy.set_xlim(-max_extent, max_extent) + ax_xy.set_ylim(-max_extent, max_extent) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title(f'XY Cross-section (FCC, dnn={dnn:.0f}Å)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # XZ plane at y=0 + for i in range(-1, 2): + for k in range(-1, 2): + cx, cz = i * a, k * a + circle_x = radius * np.cos(theta) + cx + circle_z = radius * np.sin(theta) + cz + ax_xz.plot(circle_x, circle_z, 'b-', linewidth=1.5) + ax_xz.fill(circle_x, circle_z, 'lightblue', alpha=0.3) + if i < 1 and k < 1: + cx_f = (i + 0.5) * a + cz_f = (k + 0.5) * a + circle_x_f = radius * np.cos(theta) + cx_f + circle_z_f = radius * np.sin(theta) + cz_f + ax_xz.plot(circle_x_f, circle_z_f, 'r-', linewidth=1.5) + ax_xz.fill(circle_x_f, circle_z_f, 'lightcoral', alpha=0.3) + + ax_xz.set_xlim(-max_extent, max_extent) + ax_xz.set_ylim(-max_extent, max_extent) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + # YZ plane at x=0 + for j in range(-1, 2): + for k in range(-1, 2): + cy, cz = j * a, k * a + circle_y = radius * np.cos(theta) + cy + circle_z = radius * np.sin(theta) + cz + ax_yz.plot(circle_y, circle_z, 'b-', linewidth=1.5) + ax_yz.fill(circle_y, circle_z, 'lightblue', alpha=0.3) + if j < 1 and k < 1: + cy_f = (j + 0.5) * a + cz_f = (k + 0.5) * a + circle_y_f = radius * np.cos(theta) + cy_f + circle_z_f = radius * np.sin(theta) + cz_f + ax_yz.plot(circle_y_f, circle_z_f, 'r-', linewidth=1.5) + ax_yz.fill(circle_y_f, circle_z_f, 'lightcoral', alpha=0.3) + + ax_yz.set_xlim(-max_extent, max_extent) + ax_yz.set_ylim(-max_extent, max_extent) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) + # pylint: disable=bad-whitespace, line-too-long # ["name", "units", default, [lower, upper], "type","description"], parameters = [["dnn", "Ang", 220, [-inf, inf], "", "Nearest neighbour distance"], diff --git a/sasmodels/models/fractal.py b/sasmodels/models/fractal.py index 073cf9b41..7eca55a15 100644 --- a/sasmodels/models/fractal.py +++ b/sasmodels/models/fractal.py @@ -96,9 +96,127 @@ # pylint: enable=bad-whitespace, line-too-long source = ["lib/sas_3j1x_x.c", "lib/sas_gamma.c", "lib/fractal_sq.c", "fractal.c"] -has_shape_visualization = False +has_shape_visualization = True valid = "fractal_dim >= 0.0" +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for fractal aggregate visualization.""" + import numpy as np + radius = params.get('radius', 5) + cor_length = params.get('cor_length', 100) + fractal_dim = params.get('fractal_dim', 2) + + phi = np.linspace(0, np.pi, resolution//3) + theta = np.linspace(0, 2*np.pi, resolution//2) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + mesh_data = {} + + # Generate fractal-like distribution of spheres + # Use a simplified DLA-like arrangement + np.random.seed(42) # For reproducibility + n_spheres = min(30, int(10 * fractal_dim)) + + # Central sphere + x = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z = radius * np.cos(phi_mesh) + mesh_data['sphere_0'] = (x, y, z) + + # Add surrounding spheres in a fractal-like pattern + positions = [(0, 0, 0)] + for i in range(1, n_spheres): + # Random walk from existing positions + parent = positions[np.random.randint(len(positions))] + angle_theta = np.random.uniform(0, 2*np.pi) + angle_phi = np.random.uniform(0, np.pi) + dist = 2 * radius * (1 + 0.1 * np.random.randn()) + + new_x = parent[0] + dist * np.sin(angle_phi) * np.cos(angle_theta) + new_y = parent[1] + dist * np.sin(angle_phi) * np.sin(angle_theta) + new_z = parent[2] + dist * np.cos(angle_phi) + + positions.append((new_x, new_y, new_z)) + + xs = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + new_x + ys = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + new_y + zs = radius * np.cos(phi_mesh) + new_z + mesh_data[f'sphere_{i}'] = (xs, ys, zs) + + return mesh_data + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of fractal aggregate.""" + import numpy as np + radius = params.get('radius', 5) + fractal_dim = params.get('fractal_dim', 2) + + theta = np.linspace(0, 2*np.pi, 100) + + # Generate same positions as mesh + np.random.seed(42) + n_spheres = min(30, int(10 * fractal_dim)) + + positions = [(0, 0, 0)] + for i in range(1, n_spheres): + parent = positions[np.random.randint(len(positions))] + angle_theta = np.random.uniform(0, 2*np.pi) + angle_phi = np.random.uniform(0, np.pi) + dist = 2 * radius * (1 + 0.1 * np.random.randn()) + + new_x = parent[0] + dist * np.sin(angle_phi) * np.cos(angle_theta) + new_y = parent[1] + dist * np.sin(angle_phi) * np.sin(angle_theta) + new_z = parent[2] + dist * np.cos(angle_phi) + positions.append((new_x, new_y, new_z)) + + positions = np.array(positions) + max_extent = np.max(np.abs(positions)) + radius * 2 + + # XY plane + for px, py, pz in positions: + circle_x = radius * np.cos(theta) + px + circle_y = radius * np.sin(theta) + py + ax_xy.plot(circle_x, circle_y, 'b-', linewidth=0.8) + ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.3) + + ax_xy.set_xlim(-max_extent, max_extent) + ax_xy.set_ylim(-max_extent, max_extent) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title(f'XY Projection (Df={fractal_dim:.1f})') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # XZ plane + for px, py, pz in positions: + circle_x = radius * np.cos(theta) + px + circle_z = radius * np.sin(theta) + pz + ax_xz.plot(circle_x, circle_z, 'r-', linewidth=0.8) + ax_xz.fill(circle_x, circle_z, 'lightcoral', alpha=0.3) + + ax_xz.set_xlim(-max_extent, max_extent) + ax_xz.set_ylim(-max_extent, max_extent) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Projection') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + # YZ plane + for px, py, pz in positions: + circle_y = radius * np.cos(theta) + py + circle_z = radius * np.sin(theta) + pz + ax_yz.plot(circle_y, circle_z, 'g-', linewidth=0.8) + ax_yz.fill(circle_y, circle_z, 'lightgreen', alpha=0.3) + + ax_yz.set_xlim(-max_extent, max_extent) + ax_yz.set_ylim(-max_extent, max_extent) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Projection') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) + def random(): """Return a random parameter set for the model.""" radius = 10**np.random.uniform(0.7, 4) diff --git a/sasmodels/models/fractal_core_shell.py b/sasmodels/models/fractal_core_shell.py index 6de7e605e..a2bdec4f0 100644 --- a/sasmodels/models/fractal_core_shell.py +++ b/sasmodels/models/fractal_core_shell.py @@ -96,7 +96,139 @@ source = ["lib/sas_3j1x_x.c", "lib/sas_gamma.c", "lib/core_shell.c", "lib/fractal_sq.c", "fractal_core_shell.c"] -has_shape_visualization = False +has_shape_visualization = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for fractal core-shell aggregate visualization.""" + import numpy as np + radius = params.get('radius', 60) + thickness = params.get('thickness', 10) + fractal_dim = params.get('fractal_dim', 2) + + outer_radius = radius + thickness + + phi = np.linspace(0, np.pi, resolution//3) + theta = np.linspace(0, 2*np.pi, resolution//2) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + mesh_data = {} + + # Generate fractal-like distribution + np.random.seed(42) + n_spheres = min(20, int(8 * fractal_dim)) + + positions = [(0, 0, 0)] + for i in range(1, n_spheres): + parent = positions[np.random.randint(len(positions))] + angle_theta = np.random.uniform(0, 2*np.pi) + angle_phi = np.random.uniform(0, np.pi) + dist = 2 * outer_radius * (1 + 0.1 * np.random.randn()) + + new_x = parent[0] + dist * np.sin(angle_phi) * np.cos(angle_theta) + new_y = parent[1] + dist * np.sin(angle_phi) * np.sin(angle_theta) + new_z = parent[2] + dist * np.cos(angle_phi) + positions.append((new_x, new_y, new_z)) + + for i, (px, py, pz) in enumerate(positions): + # Outer shell + x_out = outer_radius * np.sin(phi_mesh) * np.cos(theta_mesh) + px + y_out = outer_radius * np.sin(phi_mesh) * np.sin(theta_mesh) + py + z_out = outer_radius * np.cos(phi_mesh) + pz + mesh_data[f'shell_{i}'] = (x_out, y_out, z_out) + + # Inner core + x_core = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + px + y_core = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + py + z_core = radius * np.cos(phi_mesh) + pz + mesh_data[f'core_{i}'] = (x_core, y_core, z_core) + + return mesh_data + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of fractal core-shell aggregate.""" + import numpy as np + radius = params.get('radius', 60) + thickness = params.get('thickness', 10) + fractal_dim = params.get('fractal_dim', 2) + + outer_radius = radius + thickness + theta = np.linspace(0, 2*np.pi, 100) + + # Generate same positions as mesh + np.random.seed(42) + n_spheres = min(20, int(8 * fractal_dim)) + + positions = [(0, 0, 0)] + for i in range(1, n_spheres): + parent = positions[np.random.randint(len(positions))] + angle_theta = np.random.uniform(0, 2*np.pi) + angle_phi = np.random.uniform(0, np.pi) + dist = 2 * outer_radius * (1 + 0.1 * np.random.randn()) + + new_x = parent[0] + dist * np.sin(angle_phi) * np.cos(angle_theta) + new_y = parent[1] + dist * np.sin(angle_phi) * np.sin(angle_theta) + new_z = parent[2] + dist * np.cos(angle_phi) + positions.append((new_x, new_y, new_z)) + + positions = np.array(positions) + max_extent = np.max(np.abs(positions)) + outer_radius * 1.5 + + # XY plane + for px, py, pz in positions: + # Shell + shell_x = outer_radius * np.cos(theta) + px + shell_y = outer_radius * np.sin(theta) + py + ax_xy.plot(shell_x, shell_y, 'b-', linewidth=0.8) + ax_xy.fill(shell_x, shell_y, 'lightblue', alpha=0.2) + # Core + core_x = radius * np.cos(theta) + px + core_y = radius * np.sin(theta) + py + ax_xy.fill(core_x, core_y, 'coral', alpha=0.4) + + ax_xy.set_xlim(-max_extent, max_extent) + ax_xy.set_ylim(-max_extent, max_extent) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title(f'XY Projection (Df={fractal_dim:.1f})') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # XZ plane + for px, py, pz in positions: + shell_x = outer_radius * np.cos(theta) + px + shell_z = outer_radius * np.sin(theta) + pz + ax_xz.plot(shell_x, shell_z, 'b-', linewidth=0.8) + ax_xz.fill(shell_x, shell_z, 'lightblue', alpha=0.2) + core_x = radius * np.cos(theta) + px + core_z = radius * np.sin(theta) + pz + ax_xz.fill(core_x, core_z, 'coral', alpha=0.4) + + ax_xz.set_xlim(-max_extent, max_extent) + ax_xz.set_ylim(-max_extent, max_extent) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Projection') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + # YZ plane + for px, py, pz in positions: + shell_y = outer_radius * np.cos(theta) + py + shell_z = outer_radius * np.sin(theta) + pz + ax_yz.plot(shell_y, shell_z, 'b-', linewidth=0.8) + ax_yz.fill(shell_y, shell_z, 'lightblue', alpha=0.2) + core_y = radius * np.cos(theta) + py + core_z = radius * np.sin(theta) + pz + ax_yz.fill(core_y, core_z, 'coral', alpha=0.4) + + ax_yz.set_xlim(-max_extent, max_extent) + ax_yz.set_ylim(-max_extent, max_extent) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Projection') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) + def random(): """Return a random parameter set for the model.""" outer_radius = 10**np.random.uniform(0.7, 4) diff --git a/sasmodels/models/lamellar.py b/sasmodels/models/lamellar.py index 628bba77e..bd619d317 100644 --- a/sasmodels/models/lamellar.py +++ b/sasmodels/models/lamellar.py @@ -94,7 +94,81 @@ return 4.0e-4*M_PI*sub*sub/qsq * 2.0*sinq2*sinq2 / (thickness*qsq); """ -has_shape_visualization = False +has_shape_visualization = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for lamellar (flat sheet) visualization.""" + import numpy as np + thickness = params.get('thickness', 50) + + # Create a flat sheet (lamella) - represent as a thin rectangular slab + sheet_size = thickness * 4 # Make sheet visually larger than thickness + + # Top surface + x_top = np.array([[-sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2, -sheet_size/2]]) + y_top = np.array([[-sheet_size/2, -sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2]]) + z_top = np.full_like(x_top, thickness/2) + + # Bottom surface + z_bottom = np.full_like(x_top, -thickness/2) + + # Create meshgrid for surfaces + x_grid = np.linspace(-sheet_size/2, sheet_size/2, resolution) + y_grid = np.linspace(-sheet_size/2, sheet_size/2, resolution) + x_mesh, y_mesh = np.meshgrid(x_grid, y_grid) + z_top_mesh = np.full_like(x_mesh, thickness/2) + z_bottom_mesh = np.full_like(x_mesh, -thickness/2) + + return { + 'top_surface': (x_mesh, y_mesh, z_top_mesh), + 'bottom_surface': (x_mesh, y_mesh, z_bottom_mesh) + } + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the lamellar structure.""" + import numpy as np + thickness = params.get('thickness', 50) + sheet_size = thickness * 4 + + # XY plane (top view) - shows the sheet as a square + rect_x = [-sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2, -sheet_size/2] + rect_y = [-sheet_size/2, -sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2] + + ax_xy.plot(rect_x, rect_y, 'b-', linewidth=2, label='Lamella') + ax_xy.fill(rect_x, rect_y, 'lightblue', alpha=0.3) + ax_xy.set_xlim(-sheet_size*0.7, sheet_size*0.7) + ax_xy.set_ylim(-sheet_size*0.7, sheet_size*0.7) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # XZ plane (side view) - shows the thickness + rect_x_side = [-sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2, -sheet_size/2] + rect_z_side = [-thickness/2, -thickness/2, thickness/2, thickness/2, -thickness/2] + + ax_xz.plot(rect_x_side, rect_z_side, 'r-', linewidth=2, label='Lamella') + ax_xz.fill(rect_x_side, rect_z_side, 'lightcoral', alpha=0.3) + ax_xz.set_xlim(-sheet_size*0.7, sheet_size*0.7) + ax_xz.set_ylim(-thickness*2, thickness*2) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title(f'XZ Cross-section (thickness={thickness:.1f}Å)') + ax_xz.grid(True, alpha=0.3) + + # YZ plane (front view) - same as XZ + rect_y_front = [-sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2, -sheet_size/2] + + ax_yz.plot(rect_y_front, rect_z_side, 'g-', linewidth=2, label='Lamella') + ax_yz.fill(rect_y_front, rect_z_side, 'lightgreen', alpha=0.3) + ax_yz.set_xlim(-sheet_size*0.7, sheet_size*0.7) + ax_yz.set_ylim(-thickness*2, thickness*2) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section (Front View)') + ax_yz.grid(True, alpha=0.3) + def random(): """Return a random parameter set for the model.""" thickness = 10**np.random.uniform(1, 4) diff --git a/sasmodels/models/lamellar_hg.py b/sasmodels/models/lamellar_hg.py index 46d8880fc..45c79f41c 100644 --- a/sasmodels/models/lamellar_hg.py +++ b/sasmodels/models/lamellar_hg.py @@ -88,7 +88,97 @@ source = ["lamellar_hg.c"] -has_shape_visualization = False +has_shape_visualization = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for lamellar with head groups visualization.""" + import numpy as np + length_tail = params.get('length_tail', 15) + length_head = params.get('length_head', 10) + + total_thickness = 2 * (length_head + length_tail) # H+T+T+H + sheet_size = total_thickness * 3 + + # Create meshgrid for surfaces + x_grid = np.linspace(-sheet_size/2, sheet_size/2, resolution) + y_grid = np.linspace(-sheet_size/2, sheet_size/2, resolution) + x_mesh, y_mesh = np.meshgrid(x_grid, y_grid) + + # Outer surfaces (head groups) + z_top_outer = np.full_like(x_mesh, total_thickness/2) + z_bottom_outer = np.full_like(x_mesh, -total_thickness/2) + + # Inner surfaces (between head and tail) + z_top_inner = np.full_like(x_mesh, length_tail) # Top head/tail interface + z_bottom_inner = np.full_like(x_mesh, -length_tail) # Bottom head/tail interface + + return { + 'top_head': (x_mesh, y_mesh, z_top_outer), + 'top_interface': (x_mesh, y_mesh, z_top_inner), + 'bottom_interface': (x_mesh, y_mesh, z_bottom_inner), + 'bottom_head': (x_mesh, y_mesh, z_bottom_outer) + } + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the lamellar with head groups.""" + import numpy as np + length_tail = params.get('length_tail', 15) + length_head = params.get('length_head', 10) + + total_thickness = 2 * (length_head + length_tail) + sheet_size = total_thickness * 3 + + # XY plane (top view) + rect_x = [-sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2, -sheet_size/2] + rect_y = [-sheet_size/2, -sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2] + + ax_xy.plot(rect_x, rect_y, 'b-', linewidth=2) + ax_xy.fill(rect_x, rect_y, 'lightblue', alpha=0.3) + ax_xy.set_xlim(-sheet_size*0.7, sheet_size*0.7) + ax_xy.set_ylim(-sheet_size*0.7, sheet_size*0.7) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # XZ plane (side view) - shows head and tail regions + # Top head group + head_top = [[-sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2, -sheet_size/2], + [length_tail, length_tail, total_thickness/2, total_thickness/2, length_tail]] + # Tail region + tail = [[-sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2, -sheet_size/2], + [-length_tail, -length_tail, length_tail, length_tail, -length_tail]] + # Bottom head group + head_bottom = [[-sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2, -sheet_size/2], + [-total_thickness/2, -total_thickness/2, -length_tail, -length_tail, -total_thickness/2]] + + ax_xz.fill(head_top[0], head_top[1], 'coral', alpha=0.5, label='Head') + ax_xz.fill(tail[0], tail[1], 'lightblue', alpha=0.5, label='Tail') + ax_xz.fill(head_bottom[0], head_bottom[1], 'coral', alpha=0.5) + ax_xz.plot([-sheet_size/2, sheet_size/2], [total_thickness/2, total_thickness/2], 'r-', linewidth=2) + ax_xz.plot([-sheet_size/2, sheet_size/2], [-total_thickness/2, -total_thickness/2], 'r-', linewidth=2) + ax_xz.set_xlim(-sheet_size*0.7, sheet_size*0.7) + ax_xz.set_ylim(-total_thickness, total_thickness) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section (Side View)') + ax_xz.legend(loc='upper right', fontsize=8) + ax_xz.grid(True, alpha=0.3) + + # YZ plane (front view) + ax_yz.fill(head_top[0], head_top[1], 'coral', alpha=0.5) + ax_yz.fill(tail[0], tail[1], 'lightblue', alpha=0.5) + ax_yz.fill(head_bottom[0], head_bottom[1], 'coral', alpha=0.5) + ax_yz.plot([-sheet_size/2, sheet_size/2], [total_thickness/2, total_thickness/2], 'r-', linewidth=2) + ax_yz.plot([-sheet_size/2, sheet_size/2], [-total_thickness/2, -total_thickness/2], 'r-', linewidth=2) + ax_yz.set_xlim(-sheet_size*0.7, sheet_size*0.7) + ax_yz.set_ylim(-total_thickness, total_thickness) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section (Front View)') + ax_yz.grid(True, alpha=0.3) + def random(): """Return a random parameter set for the model.""" thickness = 10**np.random.uniform(1, 4) diff --git a/sasmodels/models/lamellar_hg_stack_caille.py b/sasmodels/models/lamellar_hg_stack_caille.py index 2889b689a..d255e272b 100644 --- a/sasmodels/models/lamellar_hg_stack_caille.py +++ b/sasmodels/models/lamellar_hg_stack_caille.py @@ -98,8 +98,103 @@ """ category = "shape:lamellae" -has_shape_visualization = False +has_shape_visualization = True single = False # TODO: check + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for stacked lamellar with head groups (Caille) visualization.""" + import numpy as np + length_tail = params.get('length_tail', 10) + length_head = params.get('length_head', 2) + Nlayers = int(params.get('Nlayers', 30)) + d_spacing = params.get('d_spacing', 40) + + total_thickness = 2 * (length_head + length_tail) + n_vis = min(Nlayers, 4) + sheet_size = d_spacing * 2 + + x_grid = np.linspace(-sheet_size/2, sheet_size/2, resolution) + y_grid = np.linspace(-sheet_size/2, sheet_size/2, resolution) + x_mesh, y_mesh = np.meshgrid(x_grid, y_grid) + + mesh_data = {} + total_height = (n_vis - 1) * d_spacing + start_z = -total_height / 2 + + for i in range(n_vis): + z_center = start_z + i * d_spacing + z_top = np.full_like(x_mesh, z_center + total_thickness/2) + z_bottom = np.full_like(x_mesh, z_center - total_thickness/2) + mesh_data[f'layer_{i}_top'] = (x_mesh, y_mesh, z_top) + mesh_data[f'layer_{i}_bottom'] = (x_mesh, y_mesh, z_bottom) + + return mesh_data + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of stacked lamellar with head groups.""" + import numpy as np + length_tail = params.get('length_tail', 10) + length_head = params.get('length_head', 2) + Nlayers = int(params.get('Nlayers', 30)) + d_spacing = params.get('d_spacing', 40) + + total_thickness = 2 * (length_head + length_tail) + n_vis = min(Nlayers, 4) + sheet_size = d_spacing * 2 + + rect_x = [-sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2, -sheet_size/2] + rect_y = [-sheet_size/2, -sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2] + + ax_xy.plot(rect_x, rect_y, 'b-', linewidth=2) + ax_xy.fill(rect_x, rect_y, 'lightblue', alpha=0.3) + ax_xy.set_xlim(-sheet_size*0.7, sheet_size*0.7) + ax_xy.set_ylim(-sheet_size*0.7, sheet_size*0.7) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title(f'XY Cross-section ({Nlayers} layers)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + total_height = (n_vis - 1) * d_spacing + start_z = -total_height / 2 + + for i in range(n_vis): + z_center = start_z + i * d_spacing + # Head regions (top and bottom of each bilayer) + head_top_z = [z_center + length_tail, z_center + length_tail, + z_center + total_thickness/2, z_center + total_thickness/2, + z_center + length_tail] + head_bottom_z = [z_center - total_thickness/2, z_center - total_thickness/2, + z_center - length_tail, z_center - length_tail, + z_center - total_thickness/2] + # Tail region + tail_z = [z_center - length_tail, z_center - length_tail, + z_center + length_tail, z_center + length_tail, + z_center - length_tail] + + ax_xz.fill(rect_x, head_top_z, 'coral', alpha=0.5) + ax_xz.fill(rect_x, tail_z, 'lightblue', alpha=0.5) + ax_xz.fill(rect_x, head_bottom_z, 'coral', alpha=0.5) + + ax_yz.fill(rect_y, head_top_z, 'coral', alpha=0.5) + ax_yz.fill(rect_y, tail_z, 'lightblue', alpha=0.5) + ax_yz.fill(rect_y, head_bottom_z, 'coral', alpha=0.5) + + max_z = total_height/2 + total_thickness + ax_xz.set_xlim(-sheet_size*0.7, sheet_size*0.7) + ax_xz.set_ylim(-max_z, max_z) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title(f'XZ Cross-section (d={d_spacing:.0f}Å)') + ax_xz.grid(True, alpha=0.3) + + ax_yz.set_xlim(-sheet_size*0.7, sheet_size*0.7) + ax_yz.set_ylim(-max_z, max_z) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section') + ax_yz.grid(True, alpha=0.3) + parameters = [ # [ "name", "units", default, [lower, upper], "type", # "description" ], diff --git a/sasmodels/models/lamellar_stack_caille.py b/sasmodels/models/lamellar_stack_caille.py index 5361f2fad..12ee57add 100644 --- a/sasmodels/models/lamellar_stack_caille.py +++ b/sasmodels/models/lamellar_stack_caille.py @@ -92,8 +92,90 @@ """ category = "shape:lamellae" -has_shape_visualization = False +has_shape_visualization = True single = False # TODO: check + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for stacked lamellar (Caille) visualization.""" + import numpy as np + thickness = params.get('thickness', 30) + Nlayers = int(params.get('Nlayers', 20)) + d_spacing = params.get('d_spacing', 200) + + n_vis = min(Nlayers, 5) + sheet_size = d_spacing * 2 + + x_grid = np.linspace(-sheet_size/2, sheet_size/2, resolution) + y_grid = np.linspace(-sheet_size/2, sheet_size/2, resolution) + x_mesh, y_mesh = np.meshgrid(x_grid, y_grid) + + mesh_data = {} + total_height = (n_vis - 1) * d_spacing + start_z = -total_height / 2 + + for i in range(n_vis): + z_center = start_z + i * d_spacing + z_top = np.full_like(x_mesh, z_center + thickness/2) + z_bottom = np.full_like(x_mesh, z_center - thickness/2) + mesh_data[f'layer_{i}_top'] = (x_mesh, y_mesh, z_top) + mesh_data[f'layer_{i}_bottom'] = (x_mesh, y_mesh, z_bottom) + + return mesh_data + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of stacked lamellar (Caille) structure.""" + import numpy as np + thickness = params.get('thickness', 30) + Nlayers = int(params.get('Nlayers', 20)) + d_spacing = params.get('d_spacing', 200) + + n_vis = min(Nlayers, 5) + sheet_size = d_spacing * 2 + + rect_x = [-sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2, -sheet_size/2] + rect_y = [-sheet_size/2, -sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2] + + ax_xy.plot(rect_x, rect_y, 'b-', linewidth=2) + ax_xy.fill(rect_x, rect_y, 'lightblue', alpha=0.3) + ax_xy.set_xlim(-sheet_size*0.7, sheet_size*0.7) + ax_xy.set_ylim(-sheet_size*0.7, sheet_size*0.7) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title(f'XY Cross-section ({Nlayers} layers)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + total_height = (n_vis - 1) * d_spacing + start_z = -total_height / 2 + colors = ['lightblue', 'lightcoral', 'lightgreen', 'lightyellow', 'plum'] + + for i in range(n_vis): + z_center = start_z + i * d_spacing + rect_z = [z_center - thickness/2, z_center - thickness/2, + z_center + thickness/2, z_center + thickness/2, + z_center - thickness/2] + color = colors[i % len(colors)] + + ax_xz.plot(rect_x, rect_z, 'b-', linewidth=1) + ax_xz.fill(rect_x, rect_z, color, alpha=0.5) + ax_yz.plot(rect_y, rect_z, 'b-', linewidth=1) + ax_yz.fill(rect_y, rect_z, color, alpha=0.5) + + max_z = total_height/2 + thickness * 2 + ax_xz.set_xlim(-sheet_size*0.7, sheet_size*0.7) + ax_xz.set_ylim(-max_z, max_z) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title(f'XZ Cross-section (d={d_spacing:.0f}Å)') + ax_xz.grid(True, alpha=0.3) + + ax_yz.set_xlim(-sheet_size*0.7, sheet_size*0.7) + ax_yz.set_ylim(-max_z, max_z) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section') + ax_yz.grid(True, alpha=0.3) + # pylint: disable=bad-whitespace, line-too-long # ["name", "units", default, [lower, upper], "type","description"], parameters = [ diff --git a/sasmodels/models/lamellar_stack_paracrystal.py b/sasmodels/models/lamellar_stack_paracrystal.py index ab184207a..893ae7b64 100644 --- a/sasmodels/models/lamellar_stack_paracrystal.py +++ b/sasmodels/models/lamellar_stack_paracrystal.py @@ -113,9 +113,96 @@ """ category = "shape:lamellae" -has_shape_visualization = False +has_shape_visualization = True single = False +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for stacked lamellar paracrystal visualization.""" + import numpy as np + thickness = params.get('thickness', 33) + Nlayers = int(params.get('Nlayers', 20)) + d_spacing = params.get('d_spacing', 250) + + # Limit layers for visualization + n_vis = min(Nlayers, 5) + sheet_size = d_spacing * 2 + + x_grid = np.linspace(-sheet_size/2, sheet_size/2, resolution) + y_grid = np.linspace(-sheet_size/2, sheet_size/2, resolution) + x_mesh, y_mesh = np.meshgrid(x_grid, y_grid) + + mesh_data = {} + + # Create stacked layers + total_height = (n_vis - 1) * d_spacing + start_z = -total_height / 2 + + for i in range(n_vis): + z_center = start_z + i * d_spacing + z_top = np.full_like(x_mesh, z_center + thickness/2) + z_bottom = np.full_like(x_mesh, z_center - thickness/2) + mesh_data[f'layer_{i}_top'] = (x_mesh, y_mesh, z_top) + mesh_data[f'layer_{i}_bottom'] = (x_mesh, y_mesh, z_bottom) + + return mesh_data + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of stacked lamellar structure.""" + import numpy as np + thickness = params.get('thickness', 33) + Nlayers = int(params.get('Nlayers', 20)) + d_spacing = params.get('d_spacing', 250) + + n_vis = min(Nlayers, 5) + sheet_size = d_spacing * 2 + + # XY plane (top view of one layer) + rect_x = [-sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2, -sheet_size/2] + rect_y = [-sheet_size/2, -sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2] + + ax_xy.plot(rect_x, rect_y, 'b-', linewidth=2) + ax_xy.fill(rect_x, rect_y, 'lightblue', alpha=0.3) + ax_xy.set_xlim(-sheet_size*0.7, sheet_size*0.7) + ax_xy.set_ylim(-sheet_size*0.7, sheet_size*0.7) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title(f'XY Cross-section ({Nlayers} layers)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # XZ/YZ planes - show stacked layers + total_height = (n_vis - 1) * d_spacing + start_z = -total_height / 2 + colors = ['lightblue', 'lightcoral', 'lightgreen', 'lightyellow', 'plum'] + + for i in range(n_vis): + z_center = start_z + i * d_spacing + rect_z = [z_center - thickness/2, z_center - thickness/2, + z_center + thickness/2, z_center + thickness/2, + z_center - thickness/2] + color = colors[i % len(colors)] + + ax_xz.plot(rect_x, rect_z, 'b-', linewidth=1) + ax_xz.fill(rect_x, rect_z, color, alpha=0.5) + + ax_yz.plot(rect_y, rect_z, 'b-', linewidth=1) + ax_yz.fill(rect_y, rect_z, color, alpha=0.5) + + max_z = total_height/2 + thickness * 2 + ax_xz.set_xlim(-sheet_size*0.7, sheet_size*0.7) + ax_xz.set_ylim(-max_z, max_z) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title(f'XZ Cross-section (d={d_spacing:.0f}Å)') + ax_xz.grid(True, alpha=0.3) + + ax_yz.set_xlim(-sheet_size*0.7, sheet_size*0.7) + ax_yz.set_ylim(-max_z, max_z) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section') + ax_yz.grid(True, alpha=0.3) + # ["name", "units", default, [lower, upper], "type","description"], parameters = [["thickness", "Ang", 33.0, [0, inf], "volume", "sheet thickness"], diff --git a/sasmodels/models/mass_fractal.py b/sasmodels/models/mass_fractal.py index 116130497..e729f4cbc 100644 --- a/sasmodels/models/mass_fractal.py +++ b/sasmodels/models/mass_fractal.py @@ -92,9 +92,117 @@ # pylint: enable=bad-whitespace, line-too-long source = ["lib/sas_3j1x_x.c", "lib/sas_gamma.c", "mass_fractal.c"] -has_shape_visualization = False +has_shape_visualization = True valid = "fractal_dim_mass >= 1.0" +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for mass fractal aggregate visualization.""" + import numpy as np + radius = params.get('radius', 10) + fractal_dim_mass = params.get('fractal_dim_mass', 1.9) + + phi = np.linspace(0, np.pi, resolution//3) + theta = np.linspace(0, 2*np.pi, resolution//2) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + mesh_data = {} + + np.random.seed(42) + n_spheres = min(25, int(8 * fractal_dim_mass)) + + positions = [(0, 0, 0)] + for i in range(1, n_spheres): + parent = positions[np.random.randint(len(positions))] + angle_theta = np.random.uniform(0, 2*np.pi) + angle_phi = np.random.uniform(0, np.pi) + dist = 2 * radius * (1 + 0.1 * np.random.randn()) + + new_x = parent[0] + dist * np.sin(angle_phi) * np.cos(angle_theta) + new_y = parent[1] + dist * np.sin(angle_phi) * np.sin(angle_theta) + new_z = parent[2] + dist * np.cos(angle_phi) + positions.append((new_x, new_y, new_z)) + + xs = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + new_x + ys = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + new_y + zs = radius * np.cos(phi_mesh) + new_z + mesh_data[f'sphere_{i}'] = (xs, ys, zs) + + # Central sphere + x = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z = radius * np.cos(phi_mesh) + mesh_data['sphere_0'] = (x, y, z) + + return mesh_data + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of mass fractal aggregate.""" + import numpy as np + radius = params.get('radius', 10) + fractal_dim_mass = params.get('fractal_dim_mass', 1.9) + + theta = np.linspace(0, 2*np.pi, 100) + + np.random.seed(42) + n_spheres = min(25, int(8 * fractal_dim_mass)) + + positions = [(0, 0, 0)] + for i in range(1, n_spheres): + parent = positions[np.random.randint(len(positions))] + angle_theta = np.random.uniform(0, 2*np.pi) + angle_phi = np.random.uniform(0, np.pi) + dist = 2 * radius * (1 + 0.1 * np.random.randn()) + + new_x = parent[0] + dist * np.sin(angle_phi) * np.cos(angle_theta) + new_y = parent[1] + dist * np.sin(angle_phi) * np.sin(angle_theta) + new_z = parent[2] + dist * np.cos(angle_phi) + positions.append((new_x, new_y, new_z)) + + positions = np.array(positions) + max_extent = np.max(np.abs(positions)) + radius * 2 + + for px, py, pz in positions: + circle_x = radius * np.cos(theta) + px + circle_y = radius * np.sin(theta) + py + ax_xy.plot(circle_x, circle_y, 'b-', linewidth=0.8) + ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.3) + + ax_xy.set_xlim(-max_extent, max_extent) + ax_xy.set_ylim(-max_extent, max_extent) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title(f'XY Projection (Dm={fractal_dim_mass:.1f})') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + for px, py, pz in positions: + circle_x = radius * np.cos(theta) + px + circle_z = radius * np.sin(theta) + pz + ax_xz.plot(circle_x, circle_z, 'r-', linewidth=0.8) + ax_xz.fill(circle_x, circle_z, 'lightcoral', alpha=0.3) + + ax_xz.set_xlim(-max_extent, max_extent) + ax_xz.set_ylim(-max_extent, max_extent) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Projection') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + for px, py, pz in positions: + circle_y = radius * np.cos(theta) + py + circle_z = radius * np.sin(theta) + pz + ax_yz.plot(circle_y, circle_z, 'g-', linewidth=0.8) + ax_yz.fill(circle_y, circle_z, 'lightgreen', alpha=0.3) + + ax_yz.set_xlim(-max_extent, max_extent) + ax_yz.set_ylim(-max_extent, max_extent) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Projection') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) + def random(): """Return a random parameter set for the model.""" radius = 10**np.random.uniform(0.7, 4) diff --git a/sasmodels/models/onion.py b/sasmodels/models/onion.py index 526d5a129..9b787bb9e 100644 --- a/sasmodels/models/onion.py +++ b/sasmodels/models/onion.py @@ -334,8 +334,119 @@ # pylint: enable=bad-whitespace, line-too-long source = ["lib/sas_3j1x_x.c", "onion.c"] -has_shape_visualization = False +has_shape_visualization = True single = False + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for onion (multi-shell) visualization.""" + import numpy as np + radius_core = params.get('radius_core', 200) + n_shells = int(params.get('n_shells', 1)) + + phi = np.linspace(0, np.pi, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + mesh_data = {} + + # Core sphere + x_core = radius_core * np.sin(phi_mesh) * np.cos(theta_mesh) + y_core = radius_core * np.sin(phi_mesh) * np.sin(theta_mesh) + z_core = radius_core * np.cos(phi_mesh) + mesh_data['core'] = (x_core, y_core, z_core) + + # Add shells + current_radius = radius_core + thickness_arr = params.get('thickness', [40]) + if not isinstance(thickness_arr, (list, np.ndarray)): + thickness_arr = [thickness_arr] + + for i in range(n_shells): + thickness = thickness_arr[i] if i < len(thickness_arr) else thickness_arr[-1] + current_radius += thickness + + x_shell = current_radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y_shell = current_radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z_shell = current_radius * np.cos(phi_mesh) + mesh_data[f'shell_{i+1}'] = (x_shell, y_shell, z_shell) + + return mesh_data + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of onion structure.""" + import numpy as np + radius_core = params.get('radius_core', 200) + n_shells = int(params.get('n_shells', 1)) + + theta = np.linspace(0, 2*np.pi, 100) + colors = ['blue', 'red', 'green', 'orange', 'purple', 'brown', 'pink', 'gray', 'olive', 'cyan'] + + # Calculate all radii + radii = [radius_core] + current_radius = radius_core + thickness_arr = params.get('thickness', [40]) + if not isinstance(thickness_arr, (list, np.ndarray)): + thickness_arr = [thickness_arr] + + for i in range(n_shells): + thickness = thickness_arr[i] if i < len(thickness_arr) else thickness_arr[-1] + current_radius += thickness + radii.append(current_radius) + + max_r = radii[-1] * 1.2 + + # XY plane + for i, r in enumerate(radii): + circle_x = r * np.cos(theta) + circle_y = r * np.sin(theta) + color = colors[i % len(colors)] + label = 'Core' if i == 0 else f'Shell {i}' + ax_xy.plot(circle_x, circle_y, '-', color=color, linewidth=2, label=label) + if i == 0: + ax_xy.fill(circle_x, circle_y, color=color, alpha=0.2) + + ax_xy.set_xlim(-max_r, max_r) + ax_xy.set_ylim(-max_r, max_r) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section') + ax_xy.set_aspect('equal') + ax_xy.legend(loc='upper right', fontsize=7) + ax_xy.grid(True, alpha=0.3) + + # XZ plane + for i, r in enumerate(radii): + circle_x = r * np.cos(theta) + circle_z = r * np.sin(theta) + color = colors[i % len(colors)] + ax_xz.plot(circle_x, circle_z, '-', color=color, linewidth=2) + if i == 0: + ax_xz.fill(circle_x, circle_z, color=color, alpha=0.2) + + ax_xz.set_xlim(-max_r, max_r) + ax_xz.set_ylim(-max_r, max_r) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + # YZ plane + for i, r in enumerate(radii): + circle_y = r * np.cos(theta) + circle_z = r * np.sin(theta) + color = colors[i % len(colors)] + ax_yz.plot(circle_y, circle_z, '-', color=color, linewidth=2) + if i == 0: + ax_yz.fill(circle_y, circle_z, color=color, alpha=0.2) + + ax_yz.set_xlim(-max_r, max_r) + ax_yz.set_ylim(-max_r, max_r) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) have_Fq = True radius_effective_modes = ["outer radius"] diff --git a/sasmodels/models/polymer_micelle.py b/sasmodels/models/polymer_micelle.py index c6f2caac8..a63fec5f2 100644 --- a/sasmodels/models/polymer_micelle.py +++ b/sasmodels/models/polymer_micelle.py @@ -111,9 +111,96 @@ ] # pylint: enable=bad-whitespace, line-too-long -has_shape_visualization = False +has_shape_visualization = True single = False +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for polymer micelle visualization.""" + import numpy as np + radius_core = params.get('radius_core', 45) + rg = params.get('rg', 20) # radius of gyration of corona chains + d_penetration = params.get('d_penetration', 1.0) + + phi = np.linspace(0, np.pi, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + mesh_data = {} + + # Core sphere + x_core = radius_core * np.sin(phi_mesh) * np.cos(theta_mesh) + y_core = radius_core * np.sin(phi_mesh) * np.sin(theta_mesh) + z_core = radius_core * np.cos(phi_mesh) + mesh_data['core'] = (x_core, y_core, z_core) + + # Corona (fuzzy outer region) - represented as a larger transparent sphere + corona_radius = radius_core + d_penetration * rg * 2 + x_corona = corona_radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y_corona = corona_radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z_corona = corona_radius * np.cos(phi_mesh) + mesh_data['corona'] = (x_corona, y_corona, z_corona) + + return mesh_data + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of polymer micelle.""" + import numpy as np + radius_core = params.get('radius_core', 45) + rg = params.get('rg', 20) + d_penetration = params.get('d_penetration', 1.0) + + theta = np.linspace(0, 2*np.pi, 100) + corona_radius = radius_core + d_penetration * rg * 2 + max_r = corona_radius * 1.3 + + # Core circle + core_x = radius_core * np.cos(theta) + core_y = radius_core * np.sin(theta) + + # Corona circle + corona_x = corona_radius * np.cos(theta) + corona_y = corona_radius * np.sin(theta) + + # XY plane + ax_xy.fill(corona_x, corona_y, 'lightyellow', alpha=0.3, label='Corona') + ax_xy.plot(corona_x, corona_y, 'orange', linewidth=2, linestyle='--') + ax_xy.fill(core_x, core_y, 'lightblue', alpha=0.5, label='Core') + ax_xy.plot(core_x, core_y, 'b-', linewidth=2) + ax_xy.set_xlim(-max_r, max_r) + ax_xy.set_ylim(-max_r, max_r) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section') + ax_xy.set_aspect('equal') + ax_xy.legend(loc='upper right', fontsize=8) + ax_xy.grid(True, alpha=0.3) + + # XZ plane + ax_xz.fill(corona_x, corona_y, 'lightyellow', alpha=0.3) + ax_xz.plot(corona_x, corona_y, 'orange', linewidth=2, linestyle='--') + ax_xz.fill(core_x, core_y, 'lightblue', alpha=0.5) + ax_xz.plot(core_x, core_y, 'b-', linewidth=2) + ax_xz.set_xlim(-max_r, max_r) + ax_xz.set_ylim(-max_r, max_r) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + # YZ plane + ax_yz.fill(corona_x, corona_y, 'lightyellow', alpha=0.3) + ax_yz.plot(corona_x, corona_y, 'orange', linewidth=2, linestyle='--') + ax_yz.fill(core_x, core_y, 'lightblue', alpha=0.5) + ax_yz.plot(core_x, core_y, 'b-', linewidth=2) + ax_yz.set_xlim(-max_r, max_r) + ax_yz.set_ylim(-max_r, max_r) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) + source = ["lib/sas_3j1x_x.c", "polymer_micelle.c"] def random(): diff --git a/sasmodels/models/raspberry.py b/sasmodels/models/raspberry.py index d530bcea1..81c7328cb 100644 --- a/sasmodels/models/raspberry.py +++ b/sasmodels/models/raspberry.py @@ -156,7 +156,150 @@ source = ["lib/sas_3j1x_x.c", "raspberry.c"] radius_effective_modes = ["radius_large", "radius_outer"] -has_shape_visualization = False +has_shape_visualization = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for raspberry (Pickering emulsion) visualization.""" + import numpy as np + radius_lg = params.get('radius_lg', 5000) + radius_sm = params.get('radius_sm', 100) + penetration = params.get('penetration', 0) + + # Scale down for visualization if too large + scale = 1.0 + if radius_lg > 1000: + scale = 1000 / radius_lg + radius_lg *= scale + radius_sm *= scale + + phi = np.linspace(0, np.pi, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + # Large central sphere + x_lg = radius_lg * np.sin(phi_mesh) * np.cos(theta_mesh) + y_lg = radius_lg * np.sin(phi_mesh) * np.sin(theta_mesh) + z_lg = radius_lg * np.cos(phi_mesh) + + mesh_data = {'large_sphere': (x_lg, y_lg, z_lg)} + + # Add small spheres on the surface (representative sample) + # Distance from center to small sphere center + dist = radius_lg + radius_sm * (1 - penetration) + + # Place small spheres at strategic positions + positions = [ + (0, 0, 1), # top + (0, 0, -1), # bottom + (1, 0, 0), # front + (-1, 0, 0), # back + (0, 1, 0), # right + (0, -1, 0), # left + (0.7, 0.7, 0), # diagonal positions + (-0.7, 0.7, 0), + (0.7, -0.7, 0), + (-0.7, -0.7, 0), + (0, 0.7, 0.7), + (0, -0.7, 0.7), + ] + + phi_sm = np.linspace(0, np.pi, resolution//4) + theta_sm = np.linspace(0, 2*np.pi, resolution//2) + phi_sm_mesh, theta_sm_mesh = np.meshgrid(phi_sm, theta_sm) + + for i, (px, py, pz) in enumerate(positions): + norm = np.sqrt(px**2 + py**2 + pz**2) + if norm > 0: + px, py, pz = px/norm, py/norm, pz/norm + cx, cy, cz = dist * px, dist * py, dist * pz + + x_sm = radius_sm * np.sin(phi_sm_mesh) * np.cos(theta_sm_mesh) + cx + y_sm = radius_sm * np.sin(phi_sm_mesh) * np.sin(theta_sm_mesh) + cy + z_sm = radius_sm * np.cos(phi_sm_mesh) + cz + + mesh_data[f'small_sphere_{i}'] = (x_sm, y_sm, z_sm) + + return mesh_data + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of raspberry structure.""" + import numpy as np + radius_lg = params.get('radius_lg', 5000) + radius_sm = params.get('radius_sm', 100) + penetration = params.get('penetration', 0) + + # Scale for visualization + scale = 1.0 + if radius_lg > 1000: + scale = 1000 / radius_lg + radius_lg *= scale + radius_sm *= scale + + theta = np.linspace(0, 2*np.pi, 100) + dist = radius_lg + radius_sm * (1 - penetration) + + # Large sphere + lg_x = radius_lg * np.cos(theta) + lg_y = radius_lg * np.sin(theta) + + max_r = dist + radius_sm * 1.3 + + # XY plane + ax_xy.plot(lg_x, lg_y, 'b-', linewidth=2, label='Large sphere') + ax_xy.fill(lg_x, lg_y, 'lightblue', alpha=0.3) + + # Small spheres around equator + for angle in np.linspace(0, 2*np.pi, 8, endpoint=False): + cx = dist * np.cos(angle) + cy = dist * np.sin(angle) + sm_x = radius_sm * np.cos(theta) + cx + sm_y = radius_sm * np.sin(theta) + cy + ax_xy.plot(sm_x, sm_y, 'r-', linewidth=1) + ax_xy.fill(sm_x, sm_y, 'lightcoral', alpha=0.3) + + ax_xy.set_xlim(-max_r, max_r) + ax_xy.set_ylim(-max_r, max_r) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Equatorial)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # XZ plane + ax_xz.plot(lg_x, lg_y, 'b-', linewidth=2) + ax_xz.fill(lg_x, lg_y, 'lightblue', alpha=0.3) + # Small spheres at poles and sides + for pos in [(dist, 0), (-dist, 0), (0, dist), (0, -dist)]: + sm_x = radius_sm * np.cos(theta) + pos[0] + sm_z = radius_sm * np.sin(theta) + pos[1] + ax_xz.plot(sm_x, sm_z, 'r-', linewidth=1) + ax_xz.fill(sm_x, sm_z, 'lightcoral', alpha=0.3) + + ax_xz.set_xlim(-max_r, max_r) + ax_xz.set_ylim(-max_r, max_r) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section (Meridional)') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + # YZ plane + ax_yz.plot(lg_x, lg_y, 'b-', linewidth=2) + ax_yz.fill(lg_x, lg_y, 'lightblue', alpha=0.3) + for pos in [(dist, 0), (-dist, 0), (0, dist), (0, -dist)]: + sm_y = radius_sm * np.cos(theta) + pos[0] + sm_z = radius_sm * np.sin(theta) + pos[1] + ax_yz.plot(sm_y, sm_z, 'r-', linewidth=1) + ax_yz.fill(sm_y, sm_z, 'lightcoral', alpha=0.3) + + ax_yz.set_xlim(-max_r, max_r) + ax_yz.set_ylim(-max_r, max_r) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section (Meridional)') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) + def random(): """Return a random parameter set for the model.""" # Limit volume fraction to 20% each diff --git a/sasmodels/models/sc_paracrystal.py b/sasmodels/models/sc_paracrystal.py index 5a5eed798..0ff73ce40 100644 --- a/sasmodels/models/sc_paracrystal.py +++ b/sasmodels/models/sc_paracrystal.py @@ -175,8 +175,95 @@ sldSolv: SLD of the solvent """ category = "shape:paracrystal" -has_shape_visualization = False +has_shape_visualization = True single = False + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for simple cubic paracrystal visualization.""" + import numpy as np + dnn = params.get('dnn', 220) # nearest neighbor distance + radius = params.get('radius', 40) + + phi = np.linspace(0, np.pi, resolution//3) + theta = np.linspace(0, 2*np.pi, resolution//2) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + mesh_data = {} + + # Create a 3x3x3 lattice of spheres + positions = [] + for i in range(-1, 2): + for j in range(-1, 2): + for k in range(-1, 2): + positions.append((i * dnn, j * dnn, k * dnn)) + + for idx, (px, py, pz) in enumerate(positions): + x = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + px + y = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + py + z = radius * np.cos(phi_mesh) + pz + mesh_data[f'sphere_{idx}'] = (x, y, z) + + return mesh_data + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of simple cubic paracrystal.""" + import numpy as np + dnn = params.get('dnn', 220) + radius = params.get('radius', 40) + + theta = np.linspace(0, 2*np.pi, 100) + max_extent = dnn * 1.5 + radius + + # XY plane - show central layer + for i in range(-1, 2): + for j in range(-1, 2): + cx, cy = i * dnn, j * dnn + circle_x = radius * np.cos(theta) + cx + circle_y = radius * np.sin(theta) + cy + ax_xy.plot(circle_x, circle_y, 'b-', linewidth=1.5) + ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.3) + + ax_xy.set_xlim(-max_extent, max_extent) + ax_xy.set_ylim(-max_extent, max_extent) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title(f'XY Cross-section (SC lattice, a={dnn:.0f}Å)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # XZ plane + for i in range(-1, 2): + for k in range(-1, 2): + cx, cz = i * dnn, k * dnn + circle_x = radius * np.cos(theta) + cx + circle_z = radius * np.sin(theta) + cz + ax_xz.plot(circle_x, circle_z, 'r-', linewidth=1.5) + ax_xz.fill(circle_x, circle_z, 'lightcoral', alpha=0.3) + + ax_xz.set_xlim(-max_extent, max_extent) + ax_xz.set_ylim(-max_extent, max_extent) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + # YZ plane + for j in range(-1, 2): + for k in range(-1, 2): + cy, cz = j * dnn, k * dnn + circle_y = radius * np.cos(theta) + cy + circle_z = radius * np.sin(theta) + cz + ax_yz.plot(circle_y, circle_z, 'g-', linewidth=1.5) + ax_yz.fill(circle_y, circle_z, 'lightgreen', alpha=0.3) + + ax_yz.set_xlim(-max_extent, max_extent) + ax_yz.set_ylim(-max_extent, max_extent) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) # pylint: disable=bad-whitespace, line-too-long # ["name", "units", default, [lower, upper], "type","description"], parameters = [["dnn", "Ang", 220.0, [0.0, inf], "", "Nearest neighbor distance"], diff --git a/sasmodels/models/spherical_sld.py b/sasmodels/models/spherical_sld.py index 28cf95e49..e8a4228e4 100644 --- a/sasmodels/models/spherical_sld.py +++ b/sasmodels/models/spherical_sld.py @@ -268,8 +268,115 @@ ] # pylint: enable=bad-whitespace, line-too-long source = ["lib/polevl.c", "lib/sas_erf.c", "lib/sas_3j1x_x.c", "spherical_sld.c"] -has_shape_visualization = False +has_shape_visualization = True single = False # TODO: fix low q behaviour + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for spherical SLD profile visualization.""" + import numpy as np + radius_core = params.get('radius_core', 50) + n_shells = int(params.get('n_shells', 1)) + + phi = np.linspace(0, np.pi, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + mesh_data = {} + + # Core sphere + x_core = radius_core * np.sin(phi_mesh) * np.cos(theta_mesh) + y_core = radius_core * np.sin(phi_mesh) * np.sin(theta_mesh) + z_core = radius_core * np.cos(phi_mesh) + mesh_data['core'] = (x_core, y_core, z_core) + + # Add shells + current_radius = radius_core + thickness_arr = params.get('thickness', [50]) + if not isinstance(thickness_arr, (list,)): + thickness_arr = [thickness_arr] + + for i in range(min(n_shells, len(thickness_arr))): + current_radius += thickness_arr[i] + x_shell = current_radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y_shell = current_radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z_shell = current_radius * np.cos(phi_mesh) + mesh_data[f'shell_{i+1}'] = (x_shell, y_shell, z_shell) + + return mesh_data + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of spherical SLD profile.""" + import numpy as np + radius_core = params.get('radius_core', 50) + n_shells = int(params.get('n_shells', 1)) + + theta = np.linspace(0, 2*np.pi, 100) + colors = ['blue', 'red', 'green', 'orange', 'purple', 'brown', 'pink', 'gray'] + + radii = [radius_core] + current_radius = radius_core + thickness_arr = params.get('thickness', [50]) + if not isinstance(thickness_arr, (list,)): + thickness_arr = [thickness_arr] + + for i in range(min(n_shells, len(thickness_arr))): + current_radius += thickness_arr[i] + radii.append(current_radius) + + max_r = radii[-1] * 1.2 + + # XY plane + for i, r in enumerate(radii): + circle_x = r * np.cos(theta) + circle_y = r * np.sin(theta) + color = colors[i % len(colors)] + label = 'Core' if i == 0 else f'Shell {i}' + ax_xy.plot(circle_x, circle_y, '-', color=color, linewidth=2, label=label) + if i == 0: + ax_xy.fill(circle_x, circle_y, color=color, alpha=0.2) + + ax_xy.set_xlim(-max_r, max_r) + ax_xy.set_ylim(-max_r, max_r) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section') + ax_xy.set_aspect('equal') + ax_xy.legend(loc='upper right', fontsize=7) + ax_xy.grid(True, alpha=0.3) + + # XZ plane + for i, r in enumerate(radii): + circle_x = r * np.cos(theta) + circle_z = r * np.sin(theta) + color = colors[i % len(colors)] + ax_xz.plot(circle_x, circle_z, '-', color=color, linewidth=2) + if i == 0: + ax_xz.fill(circle_x, circle_z, color=color, alpha=0.2) + + ax_xz.set_xlim(-max_r, max_r) + ax_xz.set_ylim(-max_r, max_r) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + # YZ plane + for i, r in enumerate(radii): + circle_y = r * np.cos(theta) + circle_z = r * np.sin(theta) + color = colors[i % len(colors)] + ax_yz.plot(circle_y, circle_z, '-', color=color, linewidth=2) + if i == 0: + ax_yz.fill(circle_y, circle_z, color=color, alpha=0.2) + + ax_yz.set_xlim(-max_r, max_r) + ax_yz.set_ylim(-max_r, max_r) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) have_Fq = True radius_effective_modes = ["outer radius"] diff --git a/sasmodels/models/surface_fractal.py b/sasmodels/models/surface_fractal.py index 0ccf9bf6f..23d82ca7a 100644 --- a/sasmodels/models/surface_fractal.py +++ b/sasmodels/models/surface_fractal.py @@ -83,8 +83,88 @@ # pylint: enable=bad-whitespace, line-too-long source = ["lib/sas_3j1x_x.c", "lib/sas_gamma.c", "surface_fractal.c"] -has_shape_visualization = False +has_shape_visualization = True # Don't need validity test since fractal_dim_surf is not polydisperse + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for surface fractal visualization.""" + import numpy as np + radius = params.get('radius', 10) + fractal_dim_surf = params.get('fractal_dim_surf', 2.0) + + phi = np.linspace(0, np.pi, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + # Create a sphere with a rough surface (surface fractal) + # Add perturbations to simulate surface roughness + np.random.seed(42) + roughness = (3.0 - fractal_dim_surf) * 0.15 * radius # Rougher for lower Ds + + # Generate surface perturbations + perturb = roughness * np.random.randn(*phi_mesh.shape) + r = radius + perturb + + x = r * np.sin(phi_mesh) * np.cos(theta_mesh) + y = r * np.sin(phi_mesh) * np.sin(theta_mesh) + z = r * np.cos(phi_mesh) + + return {'surface_fractal': (x, y, z)} + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of surface fractal.""" + import numpy as np + radius = params.get('radius', 10) + fractal_dim_surf = params.get('fractal_dim_surf', 2.0) + + # Surface fractal: rough surface on a sphere + n_points = 200 + theta = np.linspace(0, 2*np.pi, n_points) + + np.random.seed(42) + roughness = (3.0 - fractal_dim_surf) * 0.15 * radius + + # Create rough circles + perturb_xy = roughness * np.random.randn(n_points) + perturb_xz = roughness * np.random.randn(n_points) + perturb_yz = roughness * np.random.randn(n_points) + + r_xy = radius + perturb_xy + r_xz = radius + perturb_xz + r_yz = radius + perturb_yz + + # XY plane + ax_xy.plot(r_xy * np.cos(theta), r_xy * np.sin(theta), 'b-', linewidth=1.5) + ax_xy.fill(r_xy * np.cos(theta), r_xy * np.sin(theta), 'lightblue', alpha=0.3) + ax_xy.set_xlim(-radius*1.5, radius*1.5) + ax_xy.set_ylim(-radius*1.5, radius*1.5) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title(f'XY Cross-section (Ds={fractal_dim_surf:.1f})') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # XZ plane + ax_xz.plot(r_xz * np.cos(theta), r_xz * np.sin(theta), 'r-', linewidth=1.5) + ax_xz.fill(r_xz * np.cos(theta), r_xz * np.sin(theta), 'lightcoral', alpha=0.3) + ax_xz.set_xlim(-radius*1.5, radius*1.5) + ax_xz.set_ylim(-radius*1.5, radius*1.5) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + + # YZ plane + ax_yz.plot(r_yz * np.cos(theta), r_yz * np.sin(theta), 'g-', linewidth=1.5) + ax_yz.fill(r_yz * np.cos(theta), r_yz * np.sin(theta), 'lightgreen', alpha=0.3) + ax_yz.set_xlim(-radius*1.5, radius*1.5) + ax_yz.set_ylim(-radius*1.5, radius*1.5) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) #valid = "fractal_dim_surf > 1.0 && fractal_dim_surf < 3.0" def random(): From 27b111287234976b1f12ad9a5e4604c1b4600345 Mon Sep 17 00:00:00 2001 From: Wojtek Potrzebowski Date: Tue, 20 Jan 2026 14:14:54 +0100 Subject: [PATCH 5/7] Trying to fix ruff --- explore/sasmodels_shape_gui.py | 15 +++---- explore/shape_visualizer.py | 2 +- sasmodels/models/adsorbed_layer.py | 26 +++++------ sasmodels/models/barbell.py | 2 - sasmodels/models/bcc_paracrystal.py | 26 +++++------ sasmodels/models/binary_hard_sphere.py | 22 +++++----- sasmodels/models/core_multi_shell.py | 30 ++++++------- sasmodels/models/core_shell_parallelepiped.py | 1 - sasmodels/models/fcc_paracrystal.py | 28 ++++++------ sasmodels/models/fractal.py | 40 ++++++++--------- sasmodels/models/fractal_core_shell.py | 40 ++++++++--------- sasmodels/models/hollow_rectangular_prism.py | 1 - .../hollow_rectangular_prism_thin_walls.py | 1 - sasmodels/models/lamellar.py | 23 +++++----- sasmodels/models/lamellar_hg.py | 23 +++++----- sasmodels/models/lamellar_hg_stack_caille.py | 29 ++++++------ sasmodels/models/lamellar_stack_caille.py | 31 +++++++------ .../models/lamellar_stack_paracrystal.py | 35 +++++++-------- sasmodels/models/mass_fractal.py | 38 ++++++++-------- sasmodels/models/multilayer_vesicle.py | 2 +- sasmodels/models/onion.py | 34 +++++++------- sasmodels/models/parallelepiped.py | 1 - sasmodels/models/polymer_micelle.py | 22 +++++----- sasmodels/models/raspberry.py | 44 +++++++++---------- sasmodels/models/rectangular_prism.py | 1 - sasmodels/models/sc_paracrystal.py | 24 +++++----- sasmodels/models/spherical_sld.py | 32 +++++++------- sasmodels/models/surface_fractal.py | 24 +++++----- 28 files changed, 292 insertions(+), 305 deletions(-) diff --git a/explore/sasmodels_shape_gui.py b/explore/sasmodels_shape_gui.py index 6c09d51fa..c3e846552 100644 --- a/explore/sasmodels_shape_gui.py +++ b/explore/sasmodels_shape_gui.py @@ -21,7 +21,6 @@ from matplotlib.figure import Figure from matplotlib.gridspec import GridSpec from mpl_toolkits.mplot3d import Axes3D # noqa: F401 needed to enable 3D projection - from shape_visualizer import SASModelsLoader, SASModelsShapeDetector @@ -275,10 +274,10 @@ def _plot_model(self, name: str, info: dict) -> None: # - Right column (narrower): 3 cross-sections stacked vertically # Increased hspace to prevent legend overlap between cross-sections gs = GridSpec(3, 2, figure=fig, width_ratios=[3, 1], hspace=0.6, wspace=0.3) - + # 3D plot takes entire left column (all 3 rows) ax_3d = fig.add_subplot(gs[:, 0], projection="3d") - + # Cross-sections stacked in right column ax_xy = fig.add_subplot(gs[0, 1]) ax_xz = fig.add_subplot(gs[1, 1]) @@ -291,20 +290,20 @@ def _plot_model(self, name: str, info: dict) -> None: mesh_data = visualizer.create_mesh(params) visualizer._plot_mesh_components(ax_3d, mesh_data, show_wire) # type: ignore[attr-defined] visualizer._setup_3d_axis(ax_3d, mesh_data, params) # type: ignore[attr-defined] - + # Remove axes completely, title, and add parameter text ax_3d.set_axis_off() # Hide all axes ax_3d.set_title('') # Remove title (redundant with parameter box) - + # Get volume parameters to display volume_params = visualizer.get_volume_params() param_text_lines = [] - + # Add model name model_name = name.replace('_', ' ').title() param_text_lines.append(f"{model_name}") param_text_lines.append("") # Empty line - + # Add key parameters for param in volume_params[:5]: # Show up to 5 key parameters if param in params: @@ -312,7 +311,7 @@ def _plot_model(self, name: str, info: dict) -> None: # Format nicely: remove underscores, capitalize, show value param_display = param.replace('_', ' ').title() param_text_lines.append(f"{param_display} = {value:.1f} Å") - + if param_text_lines: param_text = '\n'.join(param_text_lines) # Add text annotation in upper left corner of the plot diff --git a/explore/shape_visualizer.py b/explore/shape_visualizer.py index 5e8e7ce0c..ade170a56 100644 --- a/explore/shape_visualizer.py +++ b/explore/shape_visualizer.py @@ -34,7 +34,7 @@ def __init__(self, model_info: Dict[str, Any]): self.parameters = model_info.get('parameters', []) self.category = model_info.get('category', 'unknown') self._model_module = None # Cache for model module - + def _get_model_module(self): """Get the model module, importing it if necessary.""" if self._model_module is None: diff --git a/sasmodels/models/adsorbed_layer.py b/sasmodels/models/adsorbed_layer.py index 5d82e8201..654e191ca 100644 --- a/sasmodels/models/adsorbed_layer.py +++ b/sasmodels/models/adsorbed_layer.py @@ -102,30 +102,30 @@ def create_shape_mesh(params, resolution=50): import numpy as np radius = params.get('radius', 500) second_moment = params.get('second_moment', 23) - + # Estimate layer thickness from second moment # For a step function, sigma = sqrt(t^2/12), so t = sigma * sqrt(12) layer_thickness = second_moment * np.sqrt(12) - + phi = np.linspace(0, np.pi, resolution//2) theta = np.linspace(0, 2*np.pi, resolution) phi_mesh, theta_mesh = np.meshgrid(phi, theta) - + mesh_data = {} - + # Core sphere (contrast matched, shown as wireframe conceptually) x_core = radius * np.sin(phi_mesh) * np.cos(theta_mesh) y_core = radius * np.sin(phi_mesh) * np.sin(theta_mesh) z_core = radius * np.cos(phi_mesh) mesh_data['core'] = (x_core, y_core, z_core) - + # Adsorbed layer (outer surface) outer_radius = radius + layer_thickness x_layer = outer_radius * np.sin(phi_mesh) * np.cos(theta_mesh) y_layer = outer_radius * np.sin(phi_mesh) * np.sin(theta_mesh) z_layer = outer_radius * np.cos(phi_mesh) mesh_data['adsorbed_layer'] = (x_layer, y_layer, z_layer) - + return mesh_data def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): @@ -133,21 +133,21 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): import numpy as np radius = params.get('radius', 500) second_moment = params.get('second_moment', 23) - + layer_thickness = second_moment * np.sqrt(12) outer_radius = radius + layer_thickness - + theta = np.linspace(0, 2*np.pi, 100) max_r = outer_radius * 1.2 - + # Core circle core_x = radius * np.cos(theta) core_y = radius * np.sin(theta) - + # Outer circle (with layer) outer_x = outer_radius * np.cos(theta) outer_y = outer_radius * np.sin(theta) - + # XY plane ax_xy.fill(outer_x, outer_y, 'lightgreen', alpha=0.4, label='Adsorbed layer') ax_xy.fill(core_x, core_y, 'lightgray', alpha=0.5, label='Core (matched)') @@ -161,7 +161,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_aspect('equal') ax_xy.legend(loc='upper right', fontsize=7) ax_xy.grid(True, alpha=0.3) - + # XZ plane ax_xz.fill(outer_x, outer_y, 'lightgreen', alpha=0.4) ax_xz.fill(core_x, core_y, 'lightgray', alpha=0.5) @@ -174,7 +174,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_title(f'XZ Cross-section (layer~{layer_thickness:.1f}Å)') ax_xz.set_aspect('equal') ax_xz.grid(True, alpha=0.3) - + # YZ plane ax_yz.fill(outer_x, outer_y, 'lightgreen', alpha=0.4) ax_yz.fill(core_x, core_y, 'lightgray', alpha=0.5) diff --git a/sasmodels/models/barbell.py b/sasmodels/models/barbell.py index e864a18d1..3c6667dcf 100644 --- a/sasmodels/models/barbell.py +++ b/sasmodels/models/barbell.py @@ -165,8 +165,6 @@ def create_shape_mesh(params, resolution=50): def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): """Plot 2D cross-sections of the barbell matching SasView documentation.""" import numpy as np - from matplotlib.patches import Circle, Arc, Polygon - from matplotlib.collections import PatchCollection radius = params.get('radius', 20) # r in docs radius_bell = params.get('radius_bell', 40) # R in docs diff --git a/sasmodels/models/bcc_paracrystal.py b/sasmodels/models/bcc_paracrystal.py index a5c87811e..045206ffb 100644 --- a/sasmodels/models/bcc_paracrystal.py +++ b/sasmodels/models/bcc_paracrystal.py @@ -187,16 +187,16 @@ def create_shape_mesh(params, resolution=50): import numpy as np dnn = params.get('dnn', 220) # nearest neighbor distance radius = params.get('radius', 40) - + # For BCC, conventional cell parameter a = 2*dnn/sqrt(3) a = 2 * dnn / np.sqrt(3) - + phi = np.linspace(0, np.pi, resolution//3) theta = np.linspace(0, 2*np.pi, resolution//2) phi_mesh, theta_mesh = np.meshgrid(phi, theta) - + mesh_data = {} - + # BCC lattice: corners + center of cube # Create a 2x2x2 arrangement to show the structure positions = [] @@ -208,13 +208,13 @@ def create_shape_mesh(params, resolution=50): # Body center (offset by a/2 in all directions) if i < 1 and j < 1 and k < 1: positions.append(((i + 0.5) * a, (j + 0.5) * a, (k + 0.5) * a)) - + for idx, (px, py, pz) in enumerate(positions): x = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + px y = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + py z = radius * np.cos(phi_mesh) + pz mesh_data[f'sphere_{idx}'] = (x, y, z) - + return mesh_data def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): @@ -222,11 +222,11 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): import numpy as np dnn = params.get('dnn', 220) radius = params.get('radius', 40) - + a = 2 * dnn / np.sqrt(3) theta = np.linspace(0, 2*np.pi, 100) max_extent = a * 1.5 + radius - + # XY plane at z=0 - shows corner atoms for i in range(-1, 2): for j in range(-1, 2): @@ -235,7 +235,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): circle_y = radius * np.sin(theta) + cy ax_xy.plot(circle_x, circle_y, 'b-', linewidth=1.5) ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.3) - + ax_xy.set_xlim(-max_extent, max_extent) ax_xy.set_ylim(-max_extent, max_extent) ax_xy.set_xlabel('X (Å)') @@ -243,7 +243,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_title(f'XY Cross-section (BCC, dnn={dnn:.0f}Å)') ax_xy.set_aspect('equal') ax_xy.grid(True, alpha=0.3) - + # XZ plane at y=0 - shows corner atoms and body centers for i in range(-1, 2): for k in range(-1, 2): @@ -261,7 +261,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): circle_z_c = radius * np.sin(theta) + cz_c ax_xz.plot(circle_x_c, circle_z_c, 'r-', linewidth=1.5) ax_xz.fill(circle_x_c, circle_z_c, 'lightcoral', alpha=0.3) - + ax_xz.set_xlim(-max_extent, max_extent) ax_xz.set_ylim(-max_extent, max_extent) ax_xz.set_xlabel('X (Å)') @@ -269,7 +269,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_title('XZ Cross-section') ax_xz.set_aspect('equal') ax_xz.grid(True, alpha=0.3) - + # YZ plane at x=0 for j in range(-1, 2): for k in range(-1, 2): @@ -285,7 +285,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): circle_z_c = radius * np.sin(theta) + cz_c ax_yz.plot(circle_y_c, circle_z_c, 'r-', linewidth=1.5) ax_yz.fill(circle_y_c, circle_z_c, 'lightcoral', alpha=0.3) - + ax_yz.set_xlim(-max_extent, max_extent) ax_yz.set_ylim(-max_extent, max_extent) ax_yz.set_xlabel('Y (Å)') diff --git a/sasmodels/models/binary_hard_sphere.py b/sasmodels/models/binary_hard_sphere.py index b261d991d..1b9edc08c 100644 --- a/sasmodels/models/binary_hard_sphere.py +++ b/sasmodels/models/binary_hard_sphere.py @@ -85,23 +85,23 @@ def create_shape_mesh(params, resolution=50): import numpy as np radius_lg = params.get('radius_lg', 100) radius_sm = params.get('radius_sm', 25) - + # Create spheres phi = np.linspace(0, np.pi, resolution//2) theta = np.linspace(0, 2*np.pi, resolution) phi_mesh, theta_mesh = np.meshgrid(phi, theta) - + # Large sphere (centered at origin) x_lg = radius_lg * np.sin(phi_mesh) * np.cos(theta_mesh) y_lg = radius_lg * np.sin(phi_mesh) * np.sin(theta_mesh) z_lg = radius_lg * np.cos(phi_mesh) - + # Small sphere (offset to the side) offset = radius_lg * 1.5 x_sm = radius_sm * np.sin(phi_mesh) * np.cos(theta_mesh) + offset y_sm = radius_sm * np.sin(phi_mesh) * np.sin(theta_mesh) z_sm = radius_sm * np.cos(phi_mesh) - + return { 'large_sphere': (x_lg, y_lg, z_lg), 'small_sphere': (x_sm, y_sm, z_sm) @@ -112,20 +112,20 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): import numpy as np radius_lg = params.get('radius_lg', 100) radius_sm = params.get('radius_sm', 25) - + theta = np.linspace(0, 2*np.pi, 100) offset = radius_lg * 1.5 - + # Large sphere circle lg_x = radius_lg * np.cos(theta) lg_y = radius_lg * np.sin(theta) - + # Small sphere circle (offset) sm_x = radius_sm * np.cos(theta) + offset sm_y = radius_sm * np.sin(theta) - + max_extent = offset + radius_sm * 1.5 - + # XY plane ax_xy.plot(lg_x, lg_y, 'b-', linewidth=2, label=f'Large (R={radius_lg:.0f}Å)') ax_xy.fill(lg_x, lg_y, 'lightblue', alpha=0.3) @@ -139,7 +139,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_aspect('equal') ax_xy.legend(loc='upper left', fontsize=8) ax_xy.grid(True, alpha=0.3) - + # XZ plane ax_xz.plot(lg_x, lg_y, 'b-', linewidth=2) ax_xz.fill(lg_x, lg_y, 'lightblue', alpha=0.3) @@ -152,7 +152,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_title('XZ Cross-section') ax_xz.set_aspect('equal') ax_xz.grid(True, alpha=0.3) - + # YZ plane ax_yz.plot(lg_y, lg_y, 'b-', linewidth=2) ax_yz.fill(lg_y, lg_y, 'lightblue', alpha=0.3) diff --git a/sasmodels/models/core_multi_shell.py b/sasmodels/models/core_multi_shell.py index 598922a22..569f217a8 100644 --- a/sasmodels/models/core_multi_shell.py +++ b/sasmodels/models/core_multi_shell.py @@ -107,19 +107,19 @@ def create_shape_mesh(params, resolution=50): import numpy as np radius = params.get('radius', 200) n = int(params.get('n', 1)) - + phi = np.linspace(0, np.pi, resolution//2) theta = np.linspace(0, 2*np.pi, resolution) phi_mesh, theta_mesh = np.meshgrid(phi, theta) - + mesh_data = {} - + # Core sphere x_core = radius * np.sin(phi_mesh) * np.cos(theta_mesh) y_core = radius * np.sin(phi_mesh) * np.sin(theta_mesh) z_core = radius * np.cos(phi_mesh) mesh_data['core'] = (x_core, y_core, z_core) - + # Add shells current_radius = radius for i in range(n): @@ -127,12 +127,12 @@ def create_shape_mesh(params, resolution=50): if isinstance(thickness, (list, np.ndarray)): thickness = thickness[i] if i < len(thickness) else thickness[-1] current_radius += thickness - + x_shell = current_radius * np.sin(phi_mesh) * np.cos(theta_mesh) y_shell = current_radius * np.sin(phi_mesh) * np.sin(theta_mesh) z_shell = current_radius * np.cos(phi_mesh) mesh_data[f'shell_{i+1}'] = (x_shell, y_shell, z_shell) - + return mesh_data def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): @@ -140,10 +140,10 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): import numpy as np radius = params.get('radius', 200) n = int(params.get('n', 1)) - + theta = np.linspace(0, 2*np.pi, 100) colors = ['blue', 'red', 'green', 'orange', 'purple', 'brown', 'pink', 'gray', 'olive', 'cyan'] - + # Calculate all radii radii = [radius] current_radius = radius @@ -153,9 +153,9 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): thickness = thickness[i] if i < len(thickness) else thickness[-1] current_radius += thickness radii.append(current_radius) - + max_r = radii[-1] * 1.2 - + # XY plane for i, r in enumerate(radii): circle_x = r * np.cos(theta) @@ -165,7 +165,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.plot(circle_x, circle_y, '-', color=color, linewidth=2, label=label) if i == 0: ax_xy.fill(circle_x, circle_y, color=color, alpha=0.2) - + ax_xy.set_xlim(-max_r, max_r) ax_xy.set_ylim(-max_r, max_r) ax_xy.set_xlabel('X (Å)') @@ -174,7 +174,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_aspect('equal') ax_xy.legend(loc='upper right', fontsize=7) ax_xy.grid(True, alpha=0.3) - + # XZ plane for i, r in enumerate(radii): circle_x = r * np.cos(theta) @@ -183,7 +183,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.plot(circle_x, circle_z, '-', color=color, linewidth=2) if i == 0: ax_xz.fill(circle_x, circle_z, color=color, alpha=0.2) - + ax_xz.set_xlim(-max_r, max_r) ax_xz.set_ylim(-max_r, max_r) ax_xz.set_xlabel('X (Å)') @@ -191,7 +191,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_title('XZ Cross-section') ax_xz.set_aspect('equal') ax_xz.grid(True, alpha=0.3) - + # YZ plane for i, r in enumerate(radii): circle_y = r * np.cos(theta) @@ -200,7 +200,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_yz.plot(circle_y, circle_z, '-', color=color, linewidth=2) if i == 0: ax_yz.fill(circle_y, circle_z, color=color, alpha=0.2) - + ax_yz.set_xlim(-max_r, max_r) ax_yz.set_ylim(-max_r, max_r) ax_yz.set_xlabel('Y (Å)') diff --git a/sasmodels/models/core_shell_parallelepiped.py b/sasmodels/models/core_shell_parallelepiped.py index 649362936..b1e702750 100644 --- a/sasmodels/models/core_shell_parallelepiped.py +++ b/sasmodels/models/core_shell_parallelepiped.py @@ -267,7 +267,6 @@ def create_shape_mesh(params, resolution=50): def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): """Plot 2D cross-sections of the core-shell parallelepiped.""" - import numpy as np length_a = params.get('length_a', 35) length_b = params.get('length_b', 75) length_c = params.get('length_c', 400) diff --git a/sasmodels/models/fcc_paracrystal.py b/sasmodels/models/fcc_paracrystal.py index 1e00b367a..6bbc86b6a 100644 --- a/sasmodels/models/fcc_paracrystal.py +++ b/sasmodels/models/fcc_paracrystal.py @@ -185,16 +185,16 @@ def create_shape_mesh(params, resolution=50): import numpy as np dnn = params.get('dnn', 220) # nearest neighbor distance radius = params.get('radius', 40) - + # For FCC, conventional cell parameter a = sqrt(2)*dnn a = np.sqrt(2) * dnn - + phi = np.linspace(0, np.pi, resolution//3) theta = np.linspace(0, 2*np.pi, resolution//2) phi_mesh, theta_mesh = np.meshgrid(phi, theta) - + mesh_data = {} - + # FCC lattice: corners + face centers positions = [] for i in range(-1, 2): @@ -209,16 +209,16 @@ def create_shape_mesh(params, resolution=50): positions.append(((i + 0.5) * a, j * a, (k + 0.5) * a)) # xz face if j < 1 and k < 1: positions.append((i * a, (j + 0.5) * a, (k + 0.5) * a)) # yz face - + # Remove duplicates positions = list(set(positions)) - + for idx, (px, py, pz) in enumerate(positions): x = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + px y = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + py z = radius * np.cos(phi_mesh) + pz mesh_data[f'sphere_{idx}'] = (x, y, z) - + return mesh_data def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): @@ -226,11 +226,11 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): import numpy as np dnn = params.get('dnn', 220) radius = params.get('radius', 40) - + a = np.sqrt(2) * dnn theta = np.linspace(0, 2*np.pi, 100) max_extent = a * 1.5 + radius - + # XY plane at z=0 - shows corner atoms and xy-face centers for i in range(-1, 2): for j in range(-1, 2): @@ -248,7 +248,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): circle_y_f = radius * np.sin(theta) + cy_f ax_xy.plot(circle_x_f, circle_y_f, 'r-', linewidth=1.5) ax_xy.fill(circle_x_f, circle_y_f, 'lightcoral', alpha=0.3) - + ax_xy.set_xlim(-max_extent, max_extent) ax_xy.set_ylim(-max_extent, max_extent) ax_xy.set_xlabel('X (Å)') @@ -256,7 +256,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_title(f'XY Cross-section (FCC, dnn={dnn:.0f}Å)') ax_xy.set_aspect('equal') ax_xy.grid(True, alpha=0.3) - + # XZ plane at y=0 for i in range(-1, 2): for k in range(-1, 2): @@ -272,7 +272,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): circle_z_f = radius * np.sin(theta) + cz_f ax_xz.plot(circle_x_f, circle_z_f, 'r-', linewidth=1.5) ax_xz.fill(circle_x_f, circle_z_f, 'lightcoral', alpha=0.3) - + ax_xz.set_xlim(-max_extent, max_extent) ax_xz.set_ylim(-max_extent, max_extent) ax_xz.set_xlabel('X (Å)') @@ -280,7 +280,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_title('XZ Cross-section') ax_xz.set_aspect('equal') ax_xz.grid(True, alpha=0.3) - + # YZ plane at x=0 for j in range(-1, 2): for k in range(-1, 2): @@ -296,7 +296,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): circle_z_f = radius * np.sin(theta) + cz_f ax_yz.plot(circle_y_f, circle_z_f, 'r-', linewidth=1.5) ax_yz.fill(circle_y_f, circle_z_f, 'lightcoral', alpha=0.3) - + ax_yz.set_xlim(-max_extent, max_extent) ax_yz.set_ylim(-max_extent, max_extent) ax_yz.set_xlabel('Y (Å)') diff --git a/sasmodels/models/fractal.py b/sasmodels/models/fractal.py index 7eca55a15..2d6b0f7b7 100644 --- a/sasmodels/models/fractal.py +++ b/sasmodels/models/fractal.py @@ -105,24 +105,24 @@ def create_shape_mesh(params, resolution=50): radius = params.get('radius', 5) cor_length = params.get('cor_length', 100) fractal_dim = params.get('fractal_dim', 2) - + phi = np.linspace(0, np.pi, resolution//3) theta = np.linspace(0, 2*np.pi, resolution//2) phi_mesh, theta_mesh = np.meshgrid(phi, theta) - + mesh_data = {} - + # Generate fractal-like distribution of spheres # Use a simplified DLA-like arrangement np.random.seed(42) # For reproducibility n_spheres = min(30, int(10 * fractal_dim)) - + # Central sphere x = radius * np.sin(phi_mesh) * np.cos(theta_mesh) y = radius * np.sin(phi_mesh) * np.sin(theta_mesh) z = radius * np.cos(phi_mesh) mesh_data['sphere_0'] = (x, y, z) - + # Add surrounding spheres in a fractal-like pattern positions = [(0, 0, 0)] for i in range(1, n_spheres): @@ -131,18 +131,18 @@ def create_shape_mesh(params, resolution=50): angle_theta = np.random.uniform(0, 2*np.pi) angle_phi = np.random.uniform(0, np.pi) dist = 2 * radius * (1 + 0.1 * np.random.randn()) - + new_x = parent[0] + dist * np.sin(angle_phi) * np.cos(angle_theta) new_y = parent[1] + dist * np.sin(angle_phi) * np.sin(angle_theta) new_z = parent[2] + dist * np.cos(angle_phi) - + positions.append((new_x, new_y, new_z)) - + xs = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + new_x ys = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + new_y zs = radius * np.cos(phi_mesh) + new_z mesh_data[f'sphere_{i}'] = (xs, ys, zs) - + return mesh_data def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): @@ -150,35 +150,35 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): import numpy as np radius = params.get('radius', 5) fractal_dim = params.get('fractal_dim', 2) - + theta = np.linspace(0, 2*np.pi, 100) - + # Generate same positions as mesh np.random.seed(42) n_spheres = min(30, int(10 * fractal_dim)) - + positions = [(0, 0, 0)] for i in range(1, n_spheres): parent = positions[np.random.randint(len(positions))] angle_theta = np.random.uniform(0, 2*np.pi) angle_phi = np.random.uniform(0, np.pi) dist = 2 * radius * (1 + 0.1 * np.random.randn()) - + new_x = parent[0] + dist * np.sin(angle_phi) * np.cos(angle_theta) new_y = parent[1] + dist * np.sin(angle_phi) * np.sin(angle_theta) new_z = parent[2] + dist * np.cos(angle_phi) positions.append((new_x, new_y, new_z)) - + positions = np.array(positions) max_extent = np.max(np.abs(positions)) + radius * 2 - + # XY plane for px, py, pz in positions: circle_x = radius * np.cos(theta) + px circle_y = radius * np.sin(theta) + py ax_xy.plot(circle_x, circle_y, 'b-', linewidth=0.8) ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.3) - + ax_xy.set_xlim(-max_extent, max_extent) ax_xy.set_ylim(-max_extent, max_extent) ax_xy.set_xlabel('X (Å)') @@ -186,14 +186,14 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_title(f'XY Projection (Df={fractal_dim:.1f})') ax_xy.set_aspect('equal') ax_xy.grid(True, alpha=0.3) - + # XZ plane for px, py, pz in positions: circle_x = radius * np.cos(theta) + px circle_z = radius * np.sin(theta) + pz ax_xz.plot(circle_x, circle_z, 'r-', linewidth=0.8) ax_xz.fill(circle_x, circle_z, 'lightcoral', alpha=0.3) - + ax_xz.set_xlim(-max_extent, max_extent) ax_xz.set_ylim(-max_extent, max_extent) ax_xz.set_xlabel('X (Å)') @@ -201,14 +201,14 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_title('XZ Projection') ax_xz.set_aspect('equal') ax_xz.grid(True, alpha=0.3) - + # YZ plane for px, py, pz in positions: circle_y = radius * np.cos(theta) + py circle_z = radius * np.sin(theta) + pz ax_yz.plot(circle_y, circle_z, 'g-', linewidth=0.8) ax_yz.fill(circle_y, circle_z, 'lightgreen', alpha=0.3) - + ax_yz.set_xlim(-max_extent, max_extent) ax_yz.set_ylim(-max_extent, max_extent) ax_yz.set_xlabel('Y (Å)') diff --git a/sasmodels/models/fractal_core_shell.py b/sasmodels/models/fractal_core_shell.py index a2bdec4f0..be9d1de4c 100644 --- a/sasmodels/models/fractal_core_shell.py +++ b/sasmodels/models/fractal_core_shell.py @@ -104,44 +104,44 @@ def create_shape_mesh(params, resolution=50): radius = params.get('radius', 60) thickness = params.get('thickness', 10) fractal_dim = params.get('fractal_dim', 2) - + outer_radius = radius + thickness - + phi = np.linspace(0, np.pi, resolution//3) theta = np.linspace(0, 2*np.pi, resolution//2) phi_mesh, theta_mesh = np.meshgrid(phi, theta) - + mesh_data = {} - + # Generate fractal-like distribution np.random.seed(42) n_spheres = min(20, int(8 * fractal_dim)) - + positions = [(0, 0, 0)] for i in range(1, n_spheres): parent = positions[np.random.randint(len(positions))] angle_theta = np.random.uniform(0, 2*np.pi) angle_phi = np.random.uniform(0, np.pi) dist = 2 * outer_radius * (1 + 0.1 * np.random.randn()) - + new_x = parent[0] + dist * np.sin(angle_phi) * np.cos(angle_theta) new_y = parent[1] + dist * np.sin(angle_phi) * np.sin(angle_theta) new_z = parent[2] + dist * np.cos(angle_phi) positions.append((new_x, new_y, new_z)) - + for i, (px, py, pz) in enumerate(positions): # Outer shell x_out = outer_radius * np.sin(phi_mesh) * np.cos(theta_mesh) + px y_out = outer_radius * np.sin(phi_mesh) * np.sin(theta_mesh) + py z_out = outer_radius * np.cos(phi_mesh) + pz mesh_data[f'shell_{i}'] = (x_out, y_out, z_out) - + # Inner core x_core = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + px y_core = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + py z_core = radius * np.cos(phi_mesh) + pz mesh_data[f'core_{i}'] = (x_core, y_core, z_core) - + return mesh_data def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): @@ -150,29 +150,29 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): radius = params.get('radius', 60) thickness = params.get('thickness', 10) fractal_dim = params.get('fractal_dim', 2) - + outer_radius = radius + thickness theta = np.linspace(0, 2*np.pi, 100) - + # Generate same positions as mesh np.random.seed(42) n_spheres = min(20, int(8 * fractal_dim)) - + positions = [(0, 0, 0)] for i in range(1, n_spheres): parent = positions[np.random.randint(len(positions))] angle_theta = np.random.uniform(0, 2*np.pi) angle_phi = np.random.uniform(0, np.pi) dist = 2 * outer_radius * (1 + 0.1 * np.random.randn()) - + new_x = parent[0] + dist * np.sin(angle_phi) * np.cos(angle_theta) new_y = parent[1] + dist * np.sin(angle_phi) * np.sin(angle_theta) new_z = parent[2] + dist * np.cos(angle_phi) positions.append((new_x, new_y, new_z)) - + positions = np.array(positions) max_extent = np.max(np.abs(positions)) + outer_radius * 1.5 - + # XY plane for px, py, pz in positions: # Shell @@ -184,7 +184,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): core_x = radius * np.cos(theta) + px core_y = radius * np.sin(theta) + py ax_xy.fill(core_x, core_y, 'coral', alpha=0.4) - + ax_xy.set_xlim(-max_extent, max_extent) ax_xy.set_ylim(-max_extent, max_extent) ax_xy.set_xlabel('X (Å)') @@ -192,7 +192,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_title(f'XY Projection (Df={fractal_dim:.1f})') ax_xy.set_aspect('equal') ax_xy.grid(True, alpha=0.3) - + # XZ plane for px, py, pz in positions: shell_x = outer_radius * np.cos(theta) + px @@ -202,7 +202,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): core_x = radius * np.cos(theta) + px core_z = radius * np.sin(theta) + pz ax_xz.fill(core_x, core_z, 'coral', alpha=0.4) - + ax_xz.set_xlim(-max_extent, max_extent) ax_xz.set_ylim(-max_extent, max_extent) ax_xz.set_xlabel('X (Å)') @@ -210,7 +210,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_title('XZ Projection') ax_xz.set_aspect('equal') ax_xz.grid(True, alpha=0.3) - + # YZ plane for px, py, pz in positions: shell_y = outer_radius * np.cos(theta) + py @@ -220,7 +220,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): core_y = radius * np.cos(theta) + py core_z = radius * np.sin(theta) + pz ax_yz.fill(core_y, core_z, 'coral', alpha=0.4) - + ax_yz.set_xlim(-max_extent, max_extent) ax_yz.set_ylim(-max_extent, max_extent) ax_yz.set_xlabel('Y (Å)') diff --git a/sasmodels/models/hollow_rectangular_prism.py b/sasmodels/models/hollow_rectangular_prism.py index db73686c5..3d09aee98 100644 --- a/sasmodels/models/hollow_rectangular_prism.py +++ b/sasmodels/models/hollow_rectangular_prism.py @@ -197,7 +197,6 @@ def create_shape_mesh(params, resolution=50): def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): """Plot 2D cross-sections of the hollow rectangular prism.""" - import numpy as np length_a = params.get('length_a', 35) b2a_ratio = params.get('b2a_ratio', 1) c2a_ratio = params.get('c2a_ratio', 1) diff --git a/sasmodels/models/hollow_rectangular_prism_thin_walls.py b/sasmodels/models/hollow_rectangular_prism_thin_walls.py index f448546df..44c914f3f 100644 --- a/sasmodels/models/hollow_rectangular_prism_thin_walls.py +++ b/sasmodels/models/hollow_rectangular_prism_thin_walls.py @@ -146,7 +146,6 @@ def create_shape_mesh(params, resolution=50): def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): """Plot 2D cross-sections of the hollow rectangular prism (thin walls).""" - import numpy as np length_a = params.get('length_a', 35) b2a_ratio = params.get('b2a_ratio', 1) c2a_ratio = params.get('c2a_ratio', 1) diff --git a/sasmodels/models/lamellar.py b/sasmodels/models/lamellar.py index bd619d317..dd44dc8bf 100644 --- a/sasmodels/models/lamellar.py +++ b/sasmodels/models/lamellar.py @@ -100,25 +100,25 @@ def create_shape_mesh(params, resolution=50): """Create 3D mesh for lamellar (flat sheet) visualization.""" import numpy as np thickness = params.get('thickness', 50) - + # Create a flat sheet (lamella) - represent as a thin rectangular slab sheet_size = thickness * 4 # Make sheet visually larger than thickness - + # Top surface x_top = np.array([[-sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2, -sheet_size/2]]) y_top = np.array([[-sheet_size/2, -sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2]]) z_top = np.full_like(x_top, thickness/2) - + # Bottom surface z_bottom = np.full_like(x_top, -thickness/2) - + # Create meshgrid for surfaces x_grid = np.linspace(-sheet_size/2, sheet_size/2, resolution) y_grid = np.linspace(-sheet_size/2, sheet_size/2, resolution) x_mesh, y_mesh = np.meshgrid(x_grid, y_grid) z_top_mesh = np.full_like(x_mesh, thickness/2) z_bottom_mesh = np.full_like(x_mesh, -thickness/2) - + return { 'top_surface': (x_mesh, y_mesh, z_top_mesh), 'bottom_surface': (x_mesh, y_mesh, z_bottom_mesh) @@ -126,14 +126,13 @@ def create_shape_mesh(params, resolution=50): def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): """Plot 2D cross-sections of the lamellar structure.""" - import numpy as np thickness = params.get('thickness', 50) sheet_size = thickness * 4 - + # XY plane (top view) - shows the sheet as a square rect_x = [-sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2, -sheet_size/2] rect_y = [-sheet_size/2, -sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2] - + ax_xy.plot(rect_x, rect_y, 'b-', linewidth=2, label='Lamella') ax_xy.fill(rect_x, rect_y, 'lightblue', alpha=0.3) ax_xy.set_xlim(-sheet_size*0.7, sheet_size*0.7) @@ -143,11 +142,11 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_title('XY Cross-section (Top View)') ax_xy.set_aspect('equal') ax_xy.grid(True, alpha=0.3) - + # XZ plane (side view) - shows the thickness rect_x_side = [-sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2, -sheet_size/2] rect_z_side = [-thickness/2, -thickness/2, thickness/2, thickness/2, -thickness/2] - + ax_xz.plot(rect_x_side, rect_z_side, 'r-', linewidth=2, label='Lamella') ax_xz.fill(rect_x_side, rect_z_side, 'lightcoral', alpha=0.3) ax_xz.set_xlim(-sheet_size*0.7, sheet_size*0.7) @@ -156,10 +155,10 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_ylabel('Z (Å)') ax_xz.set_title(f'XZ Cross-section (thickness={thickness:.1f}Å)') ax_xz.grid(True, alpha=0.3) - + # YZ plane (front view) - same as XZ rect_y_front = [-sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2, -sheet_size/2] - + ax_yz.plot(rect_y_front, rect_z_side, 'g-', linewidth=2, label='Lamella') ax_yz.fill(rect_y_front, rect_z_side, 'lightgreen', alpha=0.3) ax_yz.set_xlim(-sheet_size*0.7, sheet_size*0.7) diff --git a/sasmodels/models/lamellar_hg.py b/sasmodels/models/lamellar_hg.py index 45c79f41c..3e4b5e208 100644 --- a/sasmodels/models/lamellar_hg.py +++ b/sasmodels/models/lamellar_hg.py @@ -95,23 +95,23 @@ def create_shape_mesh(params, resolution=50): import numpy as np length_tail = params.get('length_tail', 15) length_head = params.get('length_head', 10) - + total_thickness = 2 * (length_head + length_tail) # H+T+T+H sheet_size = total_thickness * 3 - + # Create meshgrid for surfaces x_grid = np.linspace(-sheet_size/2, sheet_size/2, resolution) y_grid = np.linspace(-sheet_size/2, sheet_size/2, resolution) x_mesh, y_mesh = np.meshgrid(x_grid, y_grid) - + # Outer surfaces (head groups) z_top_outer = np.full_like(x_mesh, total_thickness/2) z_bottom_outer = np.full_like(x_mesh, -total_thickness/2) - + # Inner surfaces (between head and tail) z_top_inner = np.full_like(x_mesh, length_tail) # Top head/tail interface z_bottom_inner = np.full_like(x_mesh, -length_tail) # Bottom head/tail interface - + return { 'top_head': (x_mesh, y_mesh, z_top_outer), 'top_interface': (x_mesh, y_mesh, z_top_inner), @@ -121,17 +121,16 @@ def create_shape_mesh(params, resolution=50): def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): """Plot 2D cross-sections of the lamellar with head groups.""" - import numpy as np length_tail = params.get('length_tail', 15) length_head = params.get('length_head', 10) - + total_thickness = 2 * (length_head + length_tail) sheet_size = total_thickness * 3 - + # XY plane (top view) rect_x = [-sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2, -sheet_size/2] rect_y = [-sheet_size/2, -sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2] - + ax_xy.plot(rect_x, rect_y, 'b-', linewidth=2) ax_xy.fill(rect_x, rect_y, 'lightblue', alpha=0.3) ax_xy.set_xlim(-sheet_size*0.7, sheet_size*0.7) @@ -141,7 +140,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_title('XY Cross-section (Top View)') ax_xy.set_aspect('equal') ax_xy.grid(True, alpha=0.3) - + # XZ plane (side view) - shows head and tail regions # Top head group head_top = [[-sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2, -sheet_size/2], @@ -152,7 +151,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): # Bottom head group head_bottom = [[-sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2, -sheet_size/2], [-total_thickness/2, -total_thickness/2, -length_tail, -length_tail, -total_thickness/2]] - + ax_xz.fill(head_top[0], head_top[1], 'coral', alpha=0.5, label='Head') ax_xz.fill(tail[0], tail[1], 'lightblue', alpha=0.5, label='Tail') ax_xz.fill(head_bottom[0], head_bottom[1], 'coral', alpha=0.5) @@ -165,7 +164,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_title('XZ Cross-section (Side View)') ax_xz.legend(loc='upper right', fontsize=8) ax_xz.grid(True, alpha=0.3) - + # YZ plane (front view) ax_yz.fill(head_top[0], head_top[1], 'coral', alpha=0.5) ax_yz.fill(tail[0], tail[1], 'lightblue', alpha=0.5) diff --git a/sasmodels/models/lamellar_hg_stack_caille.py b/sasmodels/models/lamellar_hg_stack_caille.py index d255e272b..5ead72cd1 100644 --- a/sasmodels/models/lamellar_hg_stack_caille.py +++ b/sasmodels/models/lamellar_hg_stack_caille.py @@ -108,43 +108,42 @@ def create_shape_mesh(params, resolution=50): length_head = params.get('length_head', 2) Nlayers = int(params.get('Nlayers', 30)) d_spacing = params.get('d_spacing', 40) - + total_thickness = 2 * (length_head + length_tail) n_vis = min(Nlayers, 4) sheet_size = d_spacing * 2 - + x_grid = np.linspace(-sheet_size/2, sheet_size/2, resolution) y_grid = np.linspace(-sheet_size/2, sheet_size/2, resolution) x_mesh, y_mesh = np.meshgrid(x_grid, y_grid) - + mesh_data = {} total_height = (n_vis - 1) * d_spacing start_z = -total_height / 2 - + for i in range(n_vis): z_center = start_z + i * d_spacing z_top = np.full_like(x_mesh, z_center + total_thickness/2) z_bottom = np.full_like(x_mesh, z_center - total_thickness/2) mesh_data[f'layer_{i}_top'] = (x_mesh, y_mesh, z_top) mesh_data[f'layer_{i}_bottom'] = (x_mesh, y_mesh, z_bottom) - + return mesh_data def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): """Plot 2D cross-sections of stacked lamellar with head groups.""" - import numpy as np length_tail = params.get('length_tail', 10) length_head = params.get('length_head', 2) Nlayers = int(params.get('Nlayers', 30)) d_spacing = params.get('d_spacing', 40) - + total_thickness = 2 * (length_head + length_tail) n_vis = min(Nlayers, 4) sheet_size = d_spacing * 2 - + rect_x = [-sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2, -sheet_size/2] rect_y = [-sheet_size/2, -sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2] - + ax_xy.plot(rect_x, rect_y, 'b-', linewidth=2) ax_xy.fill(rect_x, rect_y, 'lightblue', alpha=0.3) ax_xy.set_xlim(-sheet_size*0.7, sheet_size*0.7) @@ -154,10 +153,10 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_title(f'XY Cross-section ({Nlayers} layers)') ax_xy.set_aspect('equal') ax_xy.grid(True, alpha=0.3) - + total_height = (n_vis - 1) * d_spacing start_z = -total_height / 2 - + for i in range(n_vis): z_center = start_z + i * d_spacing # Head regions (top and bottom of each bilayer) @@ -171,15 +170,15 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): tail_z = [z_center - length_tail, z_center - length_tail, z_center + length_tail, z_center + length_tail, z_center - length_tail] - + ax_xz.fill(rect_x, head_top_z, 'coral', alpha=0.5) ax_xz.fill(rect_x, tail_z, 'lightblue', alpha=0.5) ax_xz.fill(rect_x, head_bottom_z, 'coral', alpha=0.5) - + ax_yz.fill(rect_y, head_top_z, 'coral', alpha=0.5) ax_yz.fill(rect_y, tail_z, 'lightblue', alpha=0.5) ax_yz.fill(rect_y, head_bottom_z, 'coral', alpha=0.5) - + max_z = total_height/2 + total_thickness ax_xz.set_xlim(-sheet_size*0.7, sheet_size*0.7) ax_xz.set_ylim(-max_z, max_z) @@ -187,7 +186,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_ylabel('Z (Å)') ax_xz.set_title(f'XZ Cross-section (d={d_spacing:.0f}Å)') ax_xz.grid(True, alpha=0.3) - + ax_yz.set_xlim(-sheet_size*0.7, sheet_size*0.7) ax_yz.set_ylim(-max_z, max_z) ax_yz.set_xlabel('Y (Å)') diff --git a/sasmodels/models/lamellar_stack_caille.py b/sasmodels/models/lamellar_stack_caille.py index 12ee57add..29bd1cbc3 100644 --- a/sasmodels/models/lamellar_stack_caille.py +++ b/sasmodels/models/lamellar_stack_caille.py @@ -101,40 +101,39 @@ def create_shape_mesh(params, resolution=50): thickness = params.get('thickness', 30) Nlayers = int(params.get('Nlayers', 20)) d_spacing = params.get('d_spacing', 200) - + n_vis = min(Nlayers, 5) sheet_size = d_spacing * 2 - + x_grid = np.linspace(-sheet_size/2, sheet_size/2, resolution) y_grid = np.linspace(-sheet_size/2, sheet_size/2, resolution) x_mesh, y_mesh = np.meshgrid(x_grid, y_grid) - + mesh_data = {} total_height = (n_vis - 1) * d_spacing start_z = -total_height / 2 - + for i in range(n_vis): z_center = start_z + i * d_spacing z_top = np.full_like(x_mesh, z_center + thickness/2) z_bottom = np.full_like(x_mesh, z_center - thickness/2) mesh_data[f'layer_{i}_top'] = (x_mesh, y_mesh, z_top) mesh_data[f'layer_{i}_bottom'] = (x_mesh, y_mesh, z_bottom) - + return mesh_data def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): """Plot 2D cross-sections of stacked lamellar (Caille) structure.""" - import numpy as np thickness = params.get('thickness', 30) Nlayers = int(params.get('Nlayers', 20)) d_spacing = params.get('d_spacing', 200) - + n_vis = min(Nlayers, 5) sheet_size = d_spacing * 2 - + rect_x = [-sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2, -sheet_size/2] rect_y = [-sheet_size/2, -sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2] - + ax_xy.plot(rect_x, rect_y, 'b-', linewidth=2) ax_xy.fill(rect_x, rect_y, 'lightblue', alpha=0.3) ax_xy.set_xlim(-sheet_size*0.7, sheet_size*0.7) @@ -144,23 +143,23 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_title(f'XY Cross-section ({Nlayers} layers)') ax_xy.set_aspect('equal') ax_xy.grid(True, alpha=0.3) - + total_height = (n_vis - 1) * d_spacing start_z = -total_height / 2 colors = ['lightblue', 'lightcoral', 'lightgreen', 'lightyellow', 'plum'] - + for i in range(n_vis): z_center = start_z + i * d_spacing - rect_z = [z_center - thickness/2, z_center - thickness/2, - z_center + thickness/2, z_center + thickness/2, + rect_z = [z_center - thickness/2, z_center - thickness/2, + z_center + thickness/2, z_center + thickness/2, z_center - thickness/2] color = colors[i % len(colors)] - + ax_xz.plot(rect_x, rect_z, 'b-', linewidth=1) ax_xz.fill(rect_x, rect_z, color, alpha=0.5) ax_yz.plot(rect_y, rect_z, 'b-', linewidth=1) ax_yz.fill(rect_y, rect_z, color, alpha=0.5) - + max_z = total_height/2 + thickness * 2 ax_xz.set_xlim(-sheet_size*0.7, sheet_size*0.7) ax_xz.set_ylim(-max_z, max_z) @@ -168,7 +167,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_ylabel('Z (Å)') ax_xz.set_title(f'XZ Cross-section (d={d_spacing:.0f}Å)') ax_xz.grid(True, alpha=0.3) - + ax_yz.set_xlim(-sheet_size*0.7, sheet_size*0.7) ax_yz.set_ylim(-max_z, max_z) ax_yz.set_xlabel('Y (Å)') diff --git a/sasmodels/models/lamellar_stack_paracrystal.py b/sasmodels/models/lamellar_stack_paracrystal.py index 893ae7b64..f41598831 100644 --- a/sasmodels/models/lamellar_stack_paracrystal.py +++ b/sasmodels/models/lamellar_stack_paracrystal.py @@ -122,44 +122,43 @@ def create_shape_mesh(params, resolution=50): thickness = params.get('thickness', 33) Nlayers = int(params.get('Nlayers', 20)) d_spacing = params.get('d_spacing', 250) - + # Limit layers for visualization n_vis = min(Nlayers, 5) sheet_size = d_spacing * 2 - + x_grid = np.linspace(-sheet_size/2, sheet_size/2, resolution) y_grid = np.linspace(-sheet_size/2, sheet_size/2, resolution) x_mesh, y_mesh = np.meshgrid(x_grid, y_grid) - + mesh_data = {} - + # Create stacked layers total_height = (n_vis - 1) * d_spacing start_z = -total_height / 2 - + for i in range(n_vis): z_center = start_z + i * d_spacing z_top = np.full_like(x_mesh, z_center + thickness/2) z_bottom = np.full_like(x_mesh, z_center - thickness/2) mesh_data[f'layer_{i}_top'] = (x_mesh, y_mesh, z_top) mesh_data[f'layer_{i}_bottom'] = (x_mesh, y_mesh, z_bottom) - + return mesh_data def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): """Plot 2D cross-sections of stacked lamellar structure.""" - import numpy as np thickness = params.get('thickness', 33) Nlayers = int(params.get('Nlayers', 20)) d_spacing = params.get('d_spacing', 250) - + n_vis = min(Nlayers, 5) sheet_size = d_spacing * 2 - + # XY plane (top view of one layer) rect_x = [-sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2, -sheet_size/2] rect_y = [-sheet_size/2, -sheet_size/2, sheet_size/2, sheet_size/2, -sheet_size/2] - + ax_xy.plot(rect_x, rect_y, 'b-', linewidth=2) ax_xy.fill(rect_x, rect_y, 'lightblue', alpha=0.3) ax_xy.set_xlim(-sheet_size*0.7, sheet_size*0.7) @@ -169,25 +168,25 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_title(f'XY Cross-section ({Nlayers} layers)') ax_xy.set_aspect('equal') ax_xy.grid(True, alpha=0.3) - + # XZ/YZ planes - show stacked layers total_height = (n_vis - 1) * d_spacing start_z = -total_height / 2 colors = ['lightblue', 'lightcoral', 'lightgreen', 'lightyellow', 'plum'] - + for i in range(n_vis): z_center = start_z + i * d_spacing - rect_z = [z_center - thickness/2, z_center - thickness/2, - z_center + thickness/2, z_center + thickness/2, + rect_z = [z_center - thickness/2, z_center - thickness/2, + z_center + thickness/2, z_center + thickness/2, z_center - thickness/2] color = colors[i % len(colors)] - + ax_xz.plot(rect_x, rect_z, 'b-', linewidth=1) ax_xz.fill(rect_x, rect_z, color, alpha=0.5) - + ax_yz.plot(rect_y, rect_z, 'b-', linewidth=1) ax_yz.fill(rect_y, rect_z, color, alpha=0.5) - + max_z = total_height/2 + thickness * 2 ax_xz.set_xlim(-sheet_size*0.7, sheet_size*0.7) ax_xz.set_ylim(-max_z, max_z) @@ -195,7 +194,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_ylabel('Z (Å)') ax_xz.set_title(f'XZ Cross-section (d={d_spacing:.0f}Å)') ax_xz.grid(True, alpha=0.3) - + ax_yz.set_xlim(-sheet_size*0.7, sheet_size*0.7) ax_yz.set_ylim(-max_z, max_z) ax_yz.set_xlabel('Y (Å)') diff --git a/sasmodels/models/mass_fractal.py b/sasmodels/models/mass_fractal.py index e729f4cbc..d0ffe7511 100644 --- a/sasmodels/models/mass_fractal.py +++ b/sasmodels/models/mass_fractal.py @@ -100,39 +100,39 @@ def create_shape_mesh(params, resolution=50): import numpy as np radius = params.get('radius', 10) fractal_dim_mass = params.get('fractal_dim_mass', 1.9) - + phi = np.linspace(0, np.pi, resolution//3) theta = np.linspace(0, 2*np.pi, resolution//2) phi_mesh, theta_mesh = np.meshgrid(phi, theta) - + mesh_data = {} - + np.random.seed(42) n_spheres = min(25, int(8 * fractal_dim_mass)) - + positions = [(0, 0, 0)] for i in range(1, n_spheres): parent = positions[np.random.randint(len(positions))] angle_theta = np.random.uniform(0, 2*np.pi) angle_phi = np.random.uniform(0, np.pi) dist = 2 * radius * (1 + 0.1 * np.random.randn()) - + new_x = parent[0] + dist * np.sin(angle_phi) * np.cos(angle_theta) new_y = parent[1] + dist * np.sin(angle_phi) * np.sin(angle_theta) new_z = parent[2] + dist * np.cos(angle_phi) positions.append((new_x, new_y, new_z)) - + xs = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + new_x ys = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + new_y zs = radius * np.cos(phi_mesh) + new_z mesh_data[f'sphere_{i}'] = (xs, ys, zs) - + # Central sphere x = radius * np.sin(phi_mesh) * np.cos(theta_mesh) y = radius * np.sin(phi_mesh) * np.sin(theta_mesh) z = radius * np.cos(phi_mesh) mesh_data['sphere_0'] = (x, y, z) - + return mesh_data def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): @@ -140,33 +140,33 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): import numpy as np radius = params.get('radius', 10) fractal_dim_mass = params.get('fractal_dim_mass', 1.9) - + theta = np.linspace(0, 2*np.pi, 100) - + np.random.seed(42) n_spheres = min(25, int(8 * fractal_dim_mass)) - + positions = [(0, 0, 0)] for i in range(1, n_spheres): parent = positions[np.random.randint(len(positions))] angle_theta = np.random.uniform(0, 2*np.pi) angle_phi = np.random.uniform(0, np.pi) dist = 2 * radius * (1 + 0.1 * np.random.randn()) - + new_x = parent[0] + dist * np.sin(angle_phi) * np.cos(angle_theta) new_y = parent[1] + dist * np.sin(angle_phi) * np.sin(angle_theta) new_z = parent[2] + dist * np.cos(angle_phi) positions.append((new_x, new_y, new_z)) - + positions = np.array(positions) max_extent = np.max(np.abs(positions)) + radius * 2 - + for px, py, pz in positions: circle_x = radius * np.cos(theta) + px circle_y = radius * np.sin(theta) + py ax_xy.plot(circle_x, circle_y, 'b-', linewidth=0.8) ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.3) - + ax_xy.set_xlim(-max_extent, max_extent) ax_xy.set_ylim(-max_extent, max_extent) ax_xy.set_xlabel('X (Å)') @@ -174,13 +174,13 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_title(f'XY Projection (Dm={fractal_dim_mass:.1f})') ax_xy.set_aspect('equal') ax_xy.grid(True, alpha=0.3) - + for px, py, pz in positions: circle_x = radius * np.cos(theta) + px circle_z = radius * np.sin(theta) + pz ax_xz.plot(circle_x, circle_z, 'r-', linewidth=0.8) ax_xz.fill(circle_x, circle_z, 'lightcoral', alpha=0.3) - + ax_xz.set_xlim(-max_extent, max_extent) ax_xz.set_ylim(-max_extent, max_extent) ax_xz.set_xlabel('X (Å)') @@ -188,13 +188,13 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_title('XZ Projection') ax_xz.set_aspect('equal') ax_xz.grid(True, alpha=0.3) - + for px, py, pz in positions: circle_y = radius * np.cos(theta) + py circle_z = radius * np.sin(theta) + pz ax_yz.plot(circle_y, circle_z, 'g-', linewidth=0.8) ax_yz.fill(circle_y, circle_z, 'lightgreen', alpha=0.3) - + ax_yz.set_xlim(-max_extent, max_extent) ax_yz.set_ylim(-max_extent, max_extent) ax_yz.set_xlabel('Y (Å)') diff --git a/sasmodels/models/multilayer_vesicle.py b/sasmodels/models/multilayer_vesicle.py index 668accea2..5e4d28de1 100644 --- a/sasmodels/models/multilayer_vesicle.py +++ b/sasmodels/models/multilayer_vesicle.py @@ -237,7 +237,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_ylim(-outer_radius*1.2, outer_radius*1.2) ax_xz.set_xlabel('X (Å)') ax_xz.set_ylabel('Z (Å)') - ax_xz.set_title(f'XZ Cross-section') + ax_xz.set_title('XZ Cross-section') ax_xz.set_aspect('equal') ax_xz.grid(True, alpha=0.3) diff --git a/sasmodels/models/onion.py b/sasmodels/models/onion.py index 9b787bb9e..cc1c65a26 100644 --- a/sasmodels/models/onion.py +++ b/sasmodels/models/onion.py @@ -342,34 +342,34 @@ def create_shape_mesh(params, resolution=50): import numpy as np radius_core = params.get('radius_core', 200) n_shells = int(params.get('n_shells', 1)) - + phi = np.linspace(0, np.pi, resolution//2) theta = np.linspace(0, 2*np.pi, resolution) phi_mesh, theta_mesh = np.meshgrid(phi, theta) - + mesh_data = {} - + # Core sphere x_core = radius_core * np.sin(phi_mesh) * np.cos(theta_mesh) y_core = radius_core * np.sin(phi_mesh) * np.sin(theta_mesh) z_core = radius_core * np.cos(phi_mesh) mesh_data['core'] = (x_core, y_core, z_core) - + # Add shells current_radius = radius_core thickness_arr = params.get('thickness', [40]) if not isinstance(thickness_arr, (list, np.ndarray)): thickness_arr = [thickness_arr] - + for i in range(n_shells): thickness = thickness_arr[i] if i < len(thickness_arr) else thickness_arr[-1] current_radius += thickness - + x_shell = current_radius * np.sin(phi_mesh) * np.cos(theta_mesh) y_shell = current_radius * np.sin(phi_mesh) * np.sin(theta_mesh) z_shell = current_radius * np.cos(phi_mesh) mesh_data[f'shell_{i+1}'] = (x_shell, y_shell, z_shell) - + return mesh_data def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): @@ -377,24 +377,24 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): import numpy as np radius_core = params.get('radius_core', 200) n_shells = int(params.get('n_shells', 1)) - + theta = np.linspace(0, 2*np.pi, 100) colors = ['blue', 'red', 'green', 'orange', 'purple', 'brown', 'pink', 'gray', 'olive', 'cyan'] - + # Calculate all radii radii = [radius_core] current_radius = radius_core thickness_arr = params.get('thickness', [40]) if not isinstance(thickness_arr, (list, np.ndarray)): thickness_arr = [thickness_arr] - + for i in range(n_shells): thickness = thickness_arr[i] if i < len(thickness_arr) else thickness_arr[-1] current_radius += thickness radii.append(current_radius) - + max_r = radii[-1] * 1.2 - + # XY plane for i, r in enumerate(radii): circle_x = r * np.cos(theta) @@ -404,7 +404,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.plot(circle_x, circle_y, '-', color=color, linewidth=2, label=label) if i == 0: ax_xy.fill(circle_x, circle_y, color=color, alpha=0.2) - + ax_xy.set_xlim(-max_r, max_r) ax_xy.set_ylim(-max_r, max_r) ax_xy.set_xlabel('X (Å)') @@ -413,7 +413,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_aspect('equal') ax_xy.legend(loc='upper right', fontsize=7) ax_xy.grid(True, alpha=0.3) - + # XZ plane for i, r in enumerate(radii): circle_x = r * np.cos(theta) @@ -422,7 +422,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.plot(circle_x, circle_z, '-', color=color, linewidth=2) if i == 0: ax_xz.fill(circle_x, circle_z, color=color, alpha=0.2) - + ax_xz.set_xlim(-max_r, max_r) ax_xz.set_ylim(-max_r, max_r) ax_xz.set_xlabel('X (Å)') @@ -430,7 +430,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_title('XZ Cross-section') ax_xz.set_aspect('equal') ax_xz.grid(True, alpha=0.3) - + # YZ plane for i, r in enumerate(radii): circle_y = r * np.cos(theta) @@ -439,7 +439,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_yz.plot(circle_y, circle_z, '-', color=color, linewidth=2) if i == 0: ax_yz.fill(circle_y, circle_z, color=color, alpha=0.2) - + ax_yz.set_xlim(-max_r, max_r) ax_yz.set_ylim(-max_r, max_r) ax_yz.set_xlabel('Y (Å)') diff --git a/sasmodels/models/parallelepiped.py b/sasmodels/models/parallelepiped.py index b59655aca..c593830d8 100644 --- a/sasmodels/models/parallelepiped.py +++ b/sasmodels/models/parallelepiped.py @@ -276,7 +276,6 @@ def create_shape_mesh(params, resolution=50): return faces def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): - import numpy as np length_a = params.get('length_a', 35) length_b = params.get('length_b', 75) length_c = params.get('length_c', 400) diff --git a/sasmodels/models/polymer_micelle.py b/sasmodels/models/polymer_micelle.py index a63fec5f2..e5a5fff38 100644 --- a/sasmodels/models/polymer_micelle.py +++ b/sasmodels/models/polymer_micelle.py @@ -120,26 +120,26 @@ def create_shape_mesh(params, resolution=50): radius_core = params.get('radius_core', 45) rg = params.get('rg', 20) # radius of gyration of corona chains d_penetration = params.get('d_penetration', 1.0) - + phi = np.linspace(0, np.pi, resolution//2) theta = np.linspace(0, 2*np.pi, resolution) phi_mesh, theta_mesh = np.meshgrid(phi, theta) - + mesh_data = {} - + # Core sphere x_core = radius_core * np.sin(phi_mesh) * np.cos(theta_mesh) y_core = radius_core * np.sin(phi_mesh) * np.sin(theta_mesh) z_core = radius_core * np.cos(phi_mesh) mesh_data['core'] = (x_core, y_core, z_core) - + # Corona (fuzzy outer region) - represented as a larger transparent sphere corona_radius = radius_core + d_penetration * rg * 2 x_corona = corona_radius * np.sin(phi_mesh) * np.cos(theta_mesh) y_corona = corona_radius * np.sin(phi_mesh) * np.sin(theta_mesh) z_corona = corona_radius * np.cos(phi_mesh) mesh_data['corona'] = (x_corona, y_corona, z_corona) - + return mesh_data def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): @@ -148,19 +148,19 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): radius_core = params.get('radius_core', 45) rg = params.get('rg', 20) d_penetration = params.get('d_penetration', 1.0) - + theta = np.linspace(0, 2*np.pi, 100) corona_radius = radius_core + d_penetration * rg * 2 max_r = corona_radius * 1.3 - + # Core circle core_x = radius_core * np.cos(theta) core_y = radius_core * np.sin(theta) - + # Corona circle corona_x = corona_radius * np.cos(theta) corona_y = corona_radius * np.sin(theta) - + # XY plane ax_xy.fill(corona_x, corona_y, 'lightyellow', alpha=0.3, label='Corona') ax_xy.plot(corona_x, corona_y, 'orange', linewidth=2, linestyle='--') @@ -174,7 +174,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_aspect('equal') ax_xy.legend(loc='upper right', fontsize=8) ax_xy.grid(True, alpha=0.3) - + # XZ plane ax_xz.fill(corona_x, corona_y, 'lightyellow', alpha=0.3) ax_xz.plot(corona_x, corona_y, 'orange', linewidth=2, linestyle='--') @@ -187,7 +187,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_title('XZ Cross-section') ax_xz.set_aspect('equal') ax_xz.grid(True, alpha=0.3) - + # YZ plane ax_yz.fill(corona_x, corona_y, 'lightyellow', alpha=0.3) ax_yz.plot(corona_x, corona_y, 'orange', linewidth=2, linestyle='--') diff --git a/sasmodels/models/raspberry.py b/sasmodels/models/raspberry.py index 81c7328cb..03d8aef6f 100644 --- a/sasmodels/models/raspberry.py +++ b/sasmodels/models/raspberry.py @@ -164,29 +164,29 @@ def create_shape_mesh(params, resolution=50): radius_lg = params.get('radius_lg', 5000) radius_sm = params.get('radius_sm', 100) penetration = params.get('penetration', 0) - + # Scale down for visualization if too large scale = 1.0 if radius_lg > 1000: scale = 1000 / radius_lg radius_lg *= scale radius_sm *= scale - + phi = np.linspace(0, np.pi, resolution//2) theta = np.linspace(0, 2*np.pi, resolution) phi_mesh, theta_mesh = np.meshgrid(phi, theta) - + # Large central sphere x_lg = radius_lg * np.sin(phi_mesh) * np.cos(theta_mesh) y_lg = radius_lg * np.sin(phi_mesh) * np.sin(theta_mesh) z_lg = radius_lg * np.cos(phi_mesh) - + mesh_data = {'large_sphere': (x_lg, y_lg, z_lg)} - + # Add small spheres on the surface (representative sample) # Distance from center to small sphere center dist = radius_lg + radius_sm * (1 - penetration) - + # Place small spheres at strategic positions positions = [ (0, 0, 1), # top @@ -202,23 +202,23 @@ def create_shape_mesh(params, resolution=50): (0, 0.7, 0.7), (0, -0.7, 0.7), ] - + phi_sm = np.linspace(0, np.pi, resolution//4) theta_sm = np.linspace(0, 2*np.pi, resolution//2) phi_sm_mesh, theta_sm_mesh = np.meshgrid(phi_sm, theta_sm) - + for i, (px, py, pz) in enumerate(positions): norm = np.sqrt(px**2 + py**2 + pz**2) if norm > 0: px, py, pz = px/norm, py/norm, pz/norm cx, cy, cz = dist * px, dist * py, dist * pz - + x_sm = radius_sm * np.sin(phi_sm_mesh) * np.cos(theta_sm_mesh) + cx y_sm = radius_sm * np.sin(phi_sm_mesh) * np.sin(theta_sm_mesh) + cy z_sm = radius_sm * np.cos(phi_sm_mesh) + cz - + mesh_data[f'small_sphere_{i}'] = (x_sm, y_sm, z_sm) - + return mesh_data def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): @@ -227,27 +227,27 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): radius_lg = params.get('radius_lg', 5000) radius_sm = params.get('radius_sm', 100) penetration = params.get('penetration', 0) - + # Scale for visualization scale = 1.0 if radius_lg > 1000: scale = 1000 / radius_lg radius_lg *= scale radius_sm *= scale - + theta = np.linspace(0, 2*np.pi, 100) dist = radius_lg + radius_sm * (1 - penetration) - + # Large sphere lg_x = radius_lg * np.cos(theta) lg_y = radius_lg * np.sin(theta) - + max_r = dist + radius_sm * 1.3 - + # XY plane ax_xy.plot(lg_x, lg_y, 'b-', linewidth=2, label='Large sphere') ax_xy.fill(lg_x, lg_y, 'lightblue', alpha=0.3) - + # Small spheres around equator for angle in np.linspace(0, 2*np.pi, 8, endpoint=False): cx = dist * np.cos(angle) @@ -256,7 +256,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): sm_y = radius_sm * np.sin(theta) + cy ax_xy.plot(sm_x, sm_y, 'r-', linewidth=1) ax_xy.fill(sm_x, sm_y, 'lightcoral', alpha=0.3) - + ax_xy.set_xlim(-max_r, max_r) ax_xy.set_ylim(-max_r, max_r) ax_xy.set_xlabel('X (Å)') @@ -264,7 +264,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_title('XY Cross-section (Equatorial)') ax_xy.set_aspect('equal') ax_xy.grid(True, alpha=0.3) - + # XZ plane ax_xz.plot(lg_x, lg_y, 'b-', linewidth=2) ax_xz.fill(lg_x, lg_y, 'lightblue', alpha=0.3) @@ -274,7 +274,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): sm_z = radius_sm * np.sin(theta) + pos[1] ax_xz.plot(sm_x, sm_z, 'r-', linewidth=1) ax_xz.fill(sm_x, sm_z, 'lightcoral', alpha=0.3) - + ax_xz.set_xlim(-max_r, max_r) ax_xz.set_ylim(-max_r, max_r) ax_xz.set_xlabel('X (Å)') @@ -282,7 +282,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_title('XZ Cross-section (Meridional)') ax_xz.set_aspect('equal') ax_xz.grid(True, alpha=0.3) - + # YZ plane ax_yz.plot(lg_x, lg_y, 'b-', linewidth=2) ax_yz.fill(lg_x, lg_y, 'lightblue', alpha=0.3) @@ -291,7 +291,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): sm_z = radius_sm * np.sin(theta) + pos[1] ax_yz.plot(sm_y, sm_z, 'r-', linewidth=1) ax_yz.fill(sm_y, sm_z, 'lightcoral', alpha=0.3) - + ax_yz.set_xlim(-max_r, max_r) ax_yz.set_ylim(-max_r, max_r) ax_yz.set_xlabel('Y (Å)') diff --git a/sasmodels/models/rectangular_prism.py b/sasmodels/models/rectangular_prism.py index b3dbf809d..f0e000f67 100644 --- a/sasmodels/models/rectangular_prism.py +++ b/sasmodels/models/rectangular_prism.py @@ -183,7 +183,6 @@ def create_shape_mesh(params, resolution=50): def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): """Plot 2D cross-sections of the rectangular prism.""" - import numpy as np length_a = params.get('length_a', 35) b2a_ratio = params.get('b2a_ratio', 1) c2a_ratio = params.get('c2a_ratio', 1) diff --git a/sasmodels/models/sc_paracrystal.py b/sasmodels/models/sc_paracrystal.py index 0ff73ce40..130e495bf 100644 --- a/sasmodels/models/sc_paracrystal.py +++ b/sasmodels/models/sc_paracrystal.py @@ -183,26 +183,26 @@ def create_shape_mesh(params, resolution=50): import numpy as np dnn = params.get('dnn', 220) # nearest neighbor distance radius = params.get('radius', 40) - + phi = np.linspace(0, np.pi, resolution//3) theta = np.linspace(0, 2*np.pi, resolution//2) phi_mesh, theta_mesh = np.meshgrid(phi, theta) - + mesh_data = {} - + # Create a 3x3x3 lattice of spheres positions = [] for i in range(-1, 2): for j in range(-1, 2): for k in range(-1, 2): positions.append((i * dnn, j * dnn, k * dnn)) - + for idx, (px, py, pz) in enumerate(positions): x = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + px y = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + py z = radius * np.cos(phi_mesh) + pz mesh_data[f'sphere_{idx}'] = (x, y, z) - + return mesh_data def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): @@ -210,10 +210,10 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): import numpy as np dnn = params.get('dnn', 220) radius = params.get('radius', 40) - + theta = np.linspace(0, 2*np.pi, 100) max_extent = dnn * 1.5 + radius - + # XY plane - show central layer for i in range(-1, 2): for j in range(-1, 2): @@ -222,7 +222,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): circle_y = radius * np.sin(theta) + cy ax_xy.plot(circle_x, circle_y, 'b-', linewidth=1.5) ax_xy.fill(circle_x, circle_y, 'lightblue', alpha=0.3) - + ax_xy.set_xlim(-max_extent, max_extent) ax_xy.set_ylim(-max_extent, max_extent) ax_xy.set_xlabel('X (Å)') @@ -230,7 +230,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_title(f'XY Cross-section (SC lattice, a={dnn:.0f}Å)') ax_xy.set_aspect('equal') ax_xy.grid(True, alpha=0.3) - + # XZ plane for i in range(-1, 2): for k in range(-1, 2): @@ -239,7 +239,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): circle_z = radius * np.sin(theta) + cz ax_xz.plot(circle_x, circle_z, 'r-', linewidth=1.5) ax_xz.fill(circle_x, circle_z, 'lightcoral', alpha=0.3) - + ax_xz.set_xlim(-max_extent, max_extent) ax_xz.set_ylim(-max_extent, max_extent) ax_xz.set_xlabel('X (Å)') @@ -247,7 +247,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_title('XZ Cross-section') ax_xz.set_aspect('equal') ax_xz.grid(True, alpha=0.3) - + # YZ plane for j in range(-1, 2): for k in range(-1, 2): @@ -256,7 +256,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): circle_z = radius * np.sin(theta) + cz ax_yz.plot(circle_y, circle_z, 'g-', linewidth=1.5) ax_yz.fill(circle_y, circle_z, 'lightgreen', alpha=0.3) - + ax_yz.set_xlim(-max_extent, max_extent) ax_yz.set_ylim(-max_extent, max_extent) ax_yz.set_xlabel('Y (Å)') diff --git a/sasmodels/models/spherical_sld.py b/sasmodels/models/spherical_sld.py index e8a4228e4..9dcde828d 100644 --- a/sasmodels/models/spherical_sld.py +++ b/sasmodels/models/spherical_sld.py @@ -276,32 +276,32 @@ def create_shape_mesh(params, resolution=50): import numpy as np radius_core = params.get('radius_core', 50) n_shells = int(params.get('n_shells', 1)) - + phi = np.linspace(0, np.pi, resolution//2) theta = np.linspace(0, 2*np.pi, resolution) phi_mesh, theta_mesh = np.meshgrid(phi, theta) - + mesh_data = {} - + # Core sphere x_core = radius_core * np.sin(phi_mesh) * np.cos(theta_mesh) y_core = radius_core * np.sin(phi_mesh) * np.sin(theta_mesh) z_core = radius_core * np.cos(phi_mesh) mesh_data['core'] = (x_core, y_core, z_core) - + # Add shells current_radius = radius_core thickness_arr = params.get('thickness', [50]) if not isinstance(thickness_arr, (list,)): thickness_arr = [thickness_arr] - + for i in range(min(n_shells, len(thickness_arr))): current_radius += thickness_arr[i] x_shell = current_radius * np.sin(phi_mesh) * np.cos(theta_mesh) y_shell = current_radius * np.sin(phi_mesh) * np.sin(theta_mesh) z_shell = current_radius * np.cos(phi_mesh) mesh_data[f'shell_{i+1}'] = (x_shell, y_shell, z_shell) - + return mesh_data def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): @@ -309,22 +309,22 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): import numpy as np radius_core = params.get('radius_core', 50) n_shells = int(params.get('n_shells', 1)) - + theta = np.linspace(0, 2*np.pi, 100) colors = ['blue', 'red', 'green', 'orange', 'purple', 'brown', 'pink', 'gray'] - + radii = [radius_core] current_radius = radius_core thickness_arr = params.get('thickness', [50]) if not isinstance(thickness_arr, (list,)): thickness_arr = [thickness_arr] - + for i in range(min(n_shells, len(thickness_arr))): current_radius += thickness_arr[i] radii.append(current_radius) - + max_r = radii[-1] * 1.2 - + # XY plane for i, r in enumerate(radii): circle_x = r * np.cos(theta) @@ -334,7 +334,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.plot(circle_x, circle_y, '-', color=color, linewidth=2, label=label) if i == 0: ax_xy.fill(circle_x, circle_y, color=color, alpha=0.2) - + ax_xy.set_xlim(-max_r, max_r) ax_xy.set_ylim(-max_r, max_r) ax_xy.set_xlabel('X (Å)') @@ -343,7 +343,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_aspect('equal') ax_xy.legend(loc='upper right', fontsize=7) ax_xy.grid(True, alpha=0.3) - + # XZ plane for i, r in enumerate(radii): circle_x = r * np.cos(theta) @@ -352,7 +352,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.plot(circle_x, circle_z, '-', color=color, linewidth=2) if i == 0: ax_xz.fill(circle_x, circle_z, color=color, alpha=0.2) - + ax_xz.set_xlim(-max_r, max_r) ax_xz.set_ylim(-max_r, max_r) ax_xz.set_xlabel('X (Å)') @@ -360,7 +360,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_title('XZ Cross-section') ax_xz.set_aspect('equal') ax_xz.grid(True, alpha=0.3) - + # YZ plane for i, r in enumerate(radii): circle_y = r * np.cos(theta) @@ -369,7 +369,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_yz.plot(circle_y, circle_z, '-', color=color, linewidth=2) if i == 0: ax_yz.fill(circle_y, circle_z, color=color, alpha=0.2) - + ax_yz.set_xlim(-max_r, max_r) ax_yz.set_ylim(-max_r, max_r) ax_yz.set_xlabel('Y (Å)') diff --git a/sasmodels/models/surface_fractal.py b/sasmodels/models/surface_fractal.py index 23d82ca7a..6057085e9 100644 --- a/sasmodels/models/surface_fractal.py +++ b/sasmodels/models/surface_fractal.py @@ -91,24 +91,24 @@ def create_shape_mesh(params, resolution=50): import numpy as np radius = params.get('radius', 10) fractal_dim_surf = params.get('fractal_dim_surf', 2.0) - + phi = np.linspace(0, np.pi, resolution//2) theta = np.linspace(0, 2*np.pi, resolution) phi_mesh, theta_mesh = np.meshgrid(phi, theta) - + # Create a sphere with a rough surface (surface fractal) # Add perturbations to simulate surface roughness np.random.seed(42) roughness = (3.0 - fractal_dim_surf) * 0.15 * radius # Rougher for lower Ds - + # Generate surface perturbations perturb = roughness * np.random.randn(*phi_mesh.shape) r = radius + perturb - + x = r * np.sin(phi_mesh) * np.cos(theta_mesh) y = r * np.sin(phi_mesh) * np.sin(theta_mesh) z = r * np.cos(phi_mesh) - + return {'surface_fractal': (x, y, z)} def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): @@ -116,23 +116,23 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): import numpy as np radius = params.get('radius', 10) fractal_dim_surf = params.get('fractal_dim_surf', 2.0) - + # Surface fractal: rough surface on a sphere n_points = 200 theta = np.linspace(0, 2*np.pi, n_points) - + np.random.seed(42) roughness = (3.0 - fractal_dim_surf) * 0.15 * radius - + # Create rough circles perturb_xy = roughness * np.random.randn(n_points) perturb_xz = roughness * np.random.randn(n_points) perturb_yz = roughness * np.random.randn(n_points) - + r_xy = radius + perturb_xy r_xz = radius + perturb_xz r_yz = radius + perturb_yz - + # XY plane ax_xy.plot(r_xy * np.cos(theta), r_xy * np.sin(theta), 'b-', linewidth=1.5) ax_xy.fill(r_xy * np.cos(theta), r_xy * np.sin(theta), 'lightblue', alpha=0.3) @@ -143,7 +143,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xy.set_title(f'XY Cross-section (Ds={fractal_dim_surf:.1f})') ax_xy.set_aspect('equal') ax_xy.grid(True, alpha=0.3) - + # XZ plane ax_xz.plot(r_xz * np.cos(theta), r_xz * np.sin(theta), 'r-', linewidth=1.5) ax_xz.fill(r_xz * np.cos(theta), r_xz * np.sin(theta), 'lightcoral', alpha=0.3) @@ -154,7 +154,7 @@ def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): ax_xz.set_title('XZ Cross-section') ax_xz.set_aspect('equal') ax_xz.grid(True, alpha=0.3) - + # YZ plane ax_yz.plot(r_yz * np.cos(theta), r_yz * np.sin(theta), 'g-', linewidth=1.5) ax_yz.fill(r_yz * np.cos(theta), r_yz * np.sin(theta), 'lightgreen', alpha=0.3) From df266ca0a4ae1fdba9fe1cd6d596292cd4ac5423 Mon Sep 17 00:00:00 2001 From: Wojtek Potrzebowski Date: Tue, 10 Feb 2026 14:48:00 +0100 Subject: [PATCH 6/7] Added a few models visualizations --- explore/sasmodels_shape_gui.py | 9 + explore/shape_visualizer.py | 22 +++ ...ore_shell_bicelle_elliptical_belt_rough.py | 155 +++++++++++++++++- sasmodels/models/micromagnetic_FF_3D.py | 93 ++++++++++- sasmodels/models/octahedron_truncated.py | 110 +++++++++++++ 5 files changed, 387 insertions(+), 2 deletions(-) diff --git a/explore/sasmodels_shape_gui.py b/explore/sasmodels_shape_gui.py index c3e846552..fad65a090 100644 --- a/explore/sasmodels_shape_gui.py +++ b/explore/sasmodels_shape_gui.py @@ -14,6 +14,15 @@ NSException / Cocoa threading issues. """ +import os +import sys + +# Ensure sasmodels package is importable when running from explore/ directory +_explore_dir = os.path.dirname(os.path.abspath(__file__)) +_parent_dir = os.path.dirname(_explore_dir) +if _parent_dir not in sys.path: + sys.path.insert(0, _parent_dir) + import tkinter as tk from tkinter import messagebox, ttk diff --git a/explore/shape_visualizer.py b/explore/shape_visualizer.py index ade170a56..ff48290db 100644 --- a/explore/shape_visualizer.py +++ b/explore/shape_visualizer.py @@ -18,12 +18,20 @@ import argparse import importlib import os +import sys from abc import ABC, abstractmethod from typing import Any, Dict, List, Optional, Tuple import matplotlib.pyplot as plt import numpy as np +# Ensure sasmodels package is importable when running from explore/ directory +# Add parent directory to path so sasmodels can be imported +_explore_dir = os.path.dirname(os.path.abspath(__file__)) +_parent_dir = os.path.dirname(_explore_dir) +if _parent_dir not in sys.path: + sys.path.insert(0, _parent_dir) + class ShapeVisualizer(ABC): """Abstract base class for shape visualizers.""" @@ -1599,6 +1607,20 @@ def create_visualizer(cls, model_info: Dict[str, Any]) -> Optional[ShapeVisualiz visualizer_class = cls.SHAPE_MAPPINGS[shape_type] return visualizer_class(model_info) + # Fallback: If model has has_shape_visualization=True, use GenericModelVisualizer + # which will use the model's own create_shape_mesh() and plot_shape_cross_sections() + if model_info.get('has_shape_visualization', False): + # Verify the model module has the required functions + model_name = model_info.get('name', '') + try: + module_path = f'sasmodels.models.{model_name}' + model_module = importlib.import_module(module_path) + if (hasattr(model_module, 'create_shape_mesh') and + hasattr(model_module, 'plot_shape_cross_sections')): + return GenericModelVisualizer(model_info) + except ImportError: + pass # Will fall through to warning below + print(f"Warning: No visualizer available for shape type '{shape_type}'") return None diff --git a/sasmodels/models/core_shell_bicelle_elliptical_belt_rough.py b/sasmodels/models/core_shell_bicelle_elliptical_belt_rough.py index 228919a1d..b3934c04a 100644 --- a/sasmodels/models/core_shell_bicelle_elliptical_belt_rough.py +++ b/sasmodels/models/core_shell_bicelle_elliptical_belt_rough.py @@ -159,7 +159,7 @@ source = ["lib/sas_Si.c", "lib/polevl.c", "lib/sas_J1.c", "lib/gauss76.c", "core_shell_bicelle_elliptical_belt_rough.c"] -has_shape_visualization = False +has_shape_visualization = True have_Fq = True radius_effective_modes = [ "equivalent cylinder excluded volume", "equivalent volume sphere", @@ -167,6 +167,159 @@ "outer max radius", "half outer thickness", "half diagonal", ] +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for elliptical bicelle with belt visualization.""" + import numpy as np + radius = params.get('radius', 30) + x_core = params.get('x_core', 3) + thick_rim = params.get('thick_rim', 8) + thick_face = params.get('thick_face', 14) + length = params.get('length', 50) + + r_minor = radius + r_major = radius * x_core + rim_r_minor = r_minor + thick_rim + rim_r_major = r_major + thick_rim + + theta = np.linspace(0, 2*np.pi, resolution) + + # Core elliptical cylinder + z_core = np.linspace(-length/2, length/2, resolution//2) + theta_core, z_core_mesh = np.meshgrid(theta, z_core) + x_core_mesh = r_major * np.cos(theta_core) + y_core_mesh = r_minor * np.sin(theta_core) + + # Rim/belt cylinder (same height as core, wider cross-section) + theta_rim, z_rim_mesh = np.meshgrid(theta, z_core) + x_rim = rim_r_major * np.cos(theta_rim) + y_rim = rim_r_minor * np.sin(theta_rim) + + # Face shell top surface (core cross-section, extends above core) + z_face_top = np.linspace(length/2, length/2 + thick_face, max(resolution//8, 2)) + theta_ft, z_ft_mesh = np.meshgrid(theta, z_face_top) + x_ft = r_major * np.cos(theta_ft) + y_ft = r_minor * np.sin(theta_ft) + + # Face shell bottom surface + z_face_bot = np.linspace(-length/2 - thick_face, -length/2, max(resolution//8, 2)) + theta_fb, z_fb_mesh = np.meshgrid(theta, z_face_bot) + x_fb = r_major * np.cos(theta_fb) + y_fb = r_minor * np.sin(theta_fb) + + # End caps (elliptical disks) + u = np.linspace(0, 1, resolution//4) + u_mesh, theta_cap = np.meshgrid(u, theta) + + # Face cap top + x_cap_top = u_mesh * r_major * np.cos(theta_cap) + y_cap_top = u_mesh * r_minor * np.sin(theta_cap) + z_cap_top = np.full_like(x_cap_top, length/2 + thick_face) + + # Face cap bottom + z_cap_bot = np.full_like(x_cap_top, -length/2 - thick_face) + + # Rim annular caps at z = +/- length/2 + r_frac = np.linspace(0, 1, resolution//4) + rf_mesh, theta_ann = np.meshgrid(r_frac, theta) + x_ann = (r_major + rf_mesh * thick_rim) * np.cos(theta_ann) + y_ann = (r_minor + rf_mesh * thick_rim) * np.sin(theta_ann) + z_ann_top = np.full_like(x_ann, length/2) + z_ann_bot = np.full_like(x_ann, -length/2) + + return { + 'core_cylinder': (x_core_mesh, y_core_mesh, z_core_mesh), + 'rim_cylinder': (x_rim, y_rim, z_rim_mesh), + 'face_top': (x_ft, y_ft, z_ft_mesh), + 'face_bottom': (x_fb, y_fb, z_fb_mesh), + 'face_cap_top': (x_cap_top, y_cap_top, z_cap_top), + 'face_cap_bottom': (x_cap_top, y_cap_top, z_cap_bot), + 'rim_cap_top': (x_ann, y_ann, z_ann_top), + 'rim_cap_bottom': (x_ann, y_ann, z_ann_bot), + } + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the elliptical bicelle with belt.""" + import numpy as np + radius = params.get('radius', 30) + x_core = params.get('x_core', 3) + thick_rim = params.get('thick_rim', 8) + thick_face = params.get('thick_face', 14) + length = params.get('length', 50) + + r_minor = radius + r_major = radius * x_core + rim_r_minor = r_minor + thick_rim + rim_r_major = r_major + thick_rim + half_L = length / 2 + + # --- XY plane (top view, z=0): core ellipse + rim annulus --- + theta = np.linspace(0, 2*np.pi, 100) + core_x = r_major * np.cos(theta) + core_y = r_minor * np.sin(theta) + rim_x = rim_r_major * np.cos(theta) + rim_y = rim_r_minor * np.sin(theta) + + ax_xy.plot(rim_x, rim_y, 'r-', linewidth=2, label='Rim/belt') + ax_xy.fill(rim_x, rim_y, 'lightcoral', alpha=0.3) + ax_xy.plot(core_x, core_y, 'b-', linewidth=2, label='Core') + ax_xy.fill(core_x, core_y, 'lightblue', alpha=0.5) + ax_xy.set_xlim(-rim_r_major*1.2, rim_r_major*1.2) + ax_xy.set_ylim(-rim_r_minor*1.2, rim_r_minor*1.2) + ax_xy.set_xlabel('X (Å) - Major axis') + ax_xy.set_ylabel('Y (Å) - Minor axis') + ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ax_xy.legend(fontsize=8) + + # --- XZ plane (side view, major axis): cross / plus shape --- + # Rim rectangle (wider, core height only) + rim_rect_x = [-half_L, -half_L, half_L, half_L, -half_L] + rim_rect_z = [-rim_r_major, rim_r_major, rim_r_major, -rim_r_major, -rim_r_major] + # Face rectangle (core width, full height) + face_rect_x = [-(half_L + thick_face), -(half_L + thick_face), + half_L + thick_face, half_L + thick_face, -(half_L + thick_face)] + face_rect_z = [-r_major, r_major, r_major, -r_major, -r_major] + # Core rectangle + core_rect_x = [-half_L, -half_L, half_L, half_L, -half_L] + core_rect_z = [-r_major, r_major, r_major, -r_major, -r_major] + + ax_xz.fill(rim_rect_x, rim_rect_z, 'lightcoral', alpha=0.3, label='Rim/belt') + ax_xz.plot(rim_rect_x, rim_rect_z, 'r-', linewidth=2) + ax_xz.fill(face_rect_x, face_rect_z, 'lightyellow', alpha=0.5, label='Face') + ax_xz.plot(face_rect_x, face_rect_z, color='goldenrod', linewidth=2) + ax_xz.fill(core_rect_x, core_rect_z, 'lightblue', alpha=0.5, label='Core') + ax_xz.plot(core_rect_x, core_rect_z, 'b-', linewidth=2) + max_x = (half_L + thick_face) * 1.2 + max_z = rim_r_major * 1.2 + ax_xz.set_xlim(-max_x, max_x) + ax_xz.set_ylim(-max_z, max_z) + ax_xz.set_xlabel('Z (Å)') + ax_xz.set_ylabel('X (Å) - Major axis') + ax_xz.set_title('XZ Cross-section (Major axis)') + ax_xz.grid(True, alpha=0.3) + ax_xz.legend(fontsize=8) + + # --- YZ plane (side view, minor axis): cross / plus shape --- + rim_rect_y = [-rim_r_minor, rim_r_minor, rim_r_minor, -rim_r_minor, -rim_r_minor] + face_rect_y = [-r_minor, r_minor, r_minor, -r_minor, -r_minor] + core_rect_y = [-r_minor, r_minor, r_minor, -r_minor, -r_minor] + + ax_yz.fill(rim_rect_x, rim_rect_y, 'lightgreen', alpha=0.3, label='Rim/belt') + ax_yz.plot(rim_rect_x, rim_rect_y, 'g-', linewidth=2) + ax_yz.fill(face_rect_x, face_rect_y, 'lightyellow', alpha=0.5, label='Face') + ax_yz.plot(face_rect_x, face_rect_y, color='goldenrod', linewidth=2) + ax_yz.fill(core_rect_x, core_rect_y, 'lightblue', alpha=0.5, label='Core') + ax_yz.plot(core_rect_x, core_rect_y, 'b-', linewidth=2) + max_y = rim_r_minor * 1.2 + ax_yz.set_xlim(-max_x, max_x) + ax_yz.set_ylim(-max_y, max_y) + ax_yz.set_xlabel('Z (Å)') + ax_yz.set_ylabel('Y (Å) - Minor axis') + ax_yz.set_title('YZ Cross-section (Minor axis)') + ax_yz.grid(True, alpha=0.3) + ax_yz.legend(fontsize=8) + # TODO: No random() for core-shell bicelle elliptical belt rough q = 0.1 diff --git a/sasmodels/models/micromagnetic_FF_3D.py b/sasmodels/models/micromagnetic_FF_3D.py index 8ba0a0df3..13c783088 100644 --- a/sasmodels/models/micromagnetic_FF_3D.py +++ b/sasmodels/models/micromagnetic_FF_3D.py @@ -153,12 +153,103 @@ source = ["lib/sas_3j1x_x.c", "lib/core_shell.c", "lib/gauss76.c", "lib/magnetic_functions.c", "micromagnetic_FF_3D.c"] -has_shape_visualization = False +has_shape_visualization = True structure_factor = False have_Fq = False single=False opencl = False +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for core-shell sphere visualization.""" + import numpy as np + radius = params.get('radius', 50) + thickness = params.get('thickness', 40) + outer_radius = radius + thickness + + phi = np.linspace(0, np.pi, resolution//2) + theta = np.linspace(0, 2*np.pi, resolution) + phi_mesh, theta_mesh = np.meshgrid(phi, theta) + + # Core sphere + x_core = radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y_core = radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z_core = radius * np.cos(phi_mesh) + + # Shell (outer) sphere + x_shell = outer_radius * np.sin(phi_mesh) * np.cos(theta_mesh) + y_shell = outer_radius * np.sin(phi_mesh) * np.sin(theta_mesh) + z_shell = outer_radius * np.cos(phi_mesh) + + return { + 'core': (x_core, y_core, z_core), + 'shell': (x_shell, y_shell, z_shell), + } + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the core-shell sphere.""" + import numpy as np + radius = params.get('radius', 50) + thickness = params.get('thickness', 40) + outer_radius = radius + thickness + + theta = np.linspace(0, 2*np.pi, 100) + core_x = radius * np.cos(theta) + core_y = radius * np.sin(theta) + shell_x = outer_radius * np.cos(theta) + shell_y = outer_radius * np.sin(theta) + + # XY plane + ax_xy.plot(shell_x, shell_y, 'r-', linewidth=2, label='Shell') + ax_xy.fill(shell_x, shell_y, 'lightcoral', alpha=0.3) + ax_xy.plot(core_x, core_y, 'b-', linewidth=2, label='Core') + ax_xy.fill(core_x, core_y, 'lightblue', alpha=0.5) + ax_xy.set_xlim(-outer_radius*1.2, outer_radius*1.2) + ax_xy.set_ylim(-outer_radius*1.2, outer_radius*1.2) + ax_xy.set_xlabel('X (Å)') + ax_xy.set_ylabel('Y (Å)') + ax_xy.set_title('XY Cross-section (Top View)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + ax_xy.legend() + + # XZ plane + ax_xz.plot(shell_x, shell_y, 'r-', linewidth=2, label='Shell') + ax_xz.fill(shell_x, shell_y, 'lightcoral', alpha=0.3) + ax_xz.plot(core_x, core_y, 'b-', linewidth=2, label='Core') + ax_xz.fill(core_x, core_y, 'lightblue', alpha=0.5) + ax_xz.set_xlim(-outer_radius*1.2, outer_radius*1.2) + ax_xz.set_ylim(-outer_radius*1.2, outer_radius*1.2) + ax_xz.set_xlabel('X (Å)') + ax_xz.set_ylabel('Z (Å)') + ax_xz.set_title('XZ Cross-section (Side View)') + ax_xz.set_aspect('equal') + ax_xz.grid(True, alpha=0.3) + ax_xz.legend() + + # YZ plane + ax_yz.plot(shell_x, shell_y, 'g-', linewidth=2, label='Shell') + ax_yz.fill(shell_x, shell_y, 'lightgreen', alpha=0.3) + ax_yz.plot(core_x, core_y, 'orange', linewidth=2, label='Core') + ax_yz.fill(core_x, core_y, 'moccasin', alpha=0.5) + ax_yz.set_xlim(-outer_radius*1.2, outer_radius*1.2) + ax_yz.set_ylim(-outer_radius*1.2, outer_radius*1.2) + ax_yz.set_xlabel('Y (Å)') + ax_yz.set_ylabel('Z (Å)') + ax_yz.set_title('YZ Cross-section (Front View)') + ax_yz.set_aspect('equal') + ax_yz.grid(True, alpha=0.3) + ax_yz.legend() + + # Dimension annotations + ax_xz.annotate('', xy=(-radius, 0), xytext=(radius, 0), + arrowprops=dict(arrowstyle='<->', color='blue')) + ax_xz.text(0, -radius*0.3, f'R = {radius:.0f} Å', + ha='center', fontsize=10, color='blue') + ax_xz.annotate('', xy=(-outer_radius, -radius*0.7), + xytext=(outer_radius, -radius*0.7), + arrowprops=dict(arrowstyle='<->', color='red')) + ax_xz.text(0, -radius*0.9, f'R+t = {outer_radius:.0f} Å', + ha='center', fontsize=10, color='red') def random(): """Return a random parameter set for the model.""" diff --git a/sasmodels/models/octahedron_truncated.py b/sasmodels/models/octahedron_truncated.py index d2f3c454c..6d5f8fa26 100644 --- a/sasmodels/models/octahedron_truncated.py +++ b/sasmodels/models/octahedron_truncated.py @@ -259,6 +259,116 @@ # Fq() function is used in the .c code have_Fq = True +has_shape_visualization = True + +def create_shape_mesh(params, resolution=50): + """Create 3D mesh for truncated octahedron visualization.""" + import numpy as np + length_a = params.get('length_a', 400) + b2a_ratio = params.get('b2a_ratio', 1) + c2a_ratio = params.get('c2a_ratio', 1) + t = params.get('t', 0.89) + + a = length_a + b = length_a * b2a_ratio + c = length_a * c2a_ratio + + # Spherical parametrization: for each direction find distance to surface + theta = np.linspace(0.001, np.pi - 0.001, resolution) + phi = np.linspace(0, 2*np.pi, resolution) + theta_mesh, phi_mesh = np.meshgrid(theta, phi) + + # Absolute direction cosines + sx = np.abs(np.sin(theta_mesh) * np.cos(phi_mesh)) + sy = np.abs(np.sin(theta_mesh) * np.sin(phi_mesh)) + sz = np.abs(np.cos(theta_mesh)) + + # Octahedron constraint: |x/a| + |y/b| + |z/c| <= 1 + r_oct = 1.0 / (sx/a + sy/b + sz/c + 1e-30) + + # Truncation constraints: |x| <= t*a, |y| <= t*b, |z| <= t*c + r_tx = t * a / (sx + 1e-30) + r_ty = t * b / (sy + 1e-30) + r_tz = t * c / (sz + 1e-30) + + # Surface is the minimum (most restrictive) distance + r_surface = np.minimum(np.minimum(np.minimum(r_oct, r_tx), r_ty), r_tz) + + x = r_surface * np.sin(theta_mesh) * np.cos(phi_mesh) + y = r_surface * np.sin(theta_mesh) * np.sin(phi_mesh) + z = r_surface * np.cos(theta_mesh) + + return {'octahedron': (x, y, z)} + +def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): + """Plot 2D cross-sections of the truncated octahedron.""" + import numpy as np + length_a = params.get('length_a', 400) + b2a_ratio = params.get('b2a_ratio', 1) + c2a_ratio = params.get('c2a_ratio', 1) + t = params.get('t', 0.89) + + a = length_a + b = length_a * b2a_ratio + c = length_a * c2a_ratio + + def _truncated_diamond_vertices(d1, d2, t_val): + """Compute vertices of the truncated diamond cross-section. + + The cross-section through a principal plane of the truncated + octahedron is the intersection of a diamond |x/d1|+|y/d2|<=1 + with the rectangle |x|<=t*d1, |y|<=t*d2. The result is an + octagon (or a diamond/rectangle at the limits t=1 / t=0.5). + """ + td1 = t_val * d1 + td2 = t_val * d2 + d2_cut = d2 * (1 - t_val) + d1_cut = d1 * (1 - t_val) + # 8 vertices counterclockwise, closing the polygon + xs = [td1, d1_cut, -d1_cut, -td1, + -td1, -d1_cut, d1_cut, td1, td1] + ys = [d2_cut, td2, td2, d2_cut, + -d2_cut, -td2, -td2, -d2_cut, d2_cut] + return xs, ys + + # XY cross-section (z=0 plane) + xy_x, xy_y = _truncated_diamond_vertices(a, b, t) + ax_xy.plot(xy_x, xy_y, 'b-', linewidth=2) + ax_xy.fill(xy_x, xy_y, 'lightblue', alpha=0.3) + max_dim = max(a, b) * 1.2 + ax_xy.set_xlim(-max_dim, max_dim) + ax_xy.set_ylim(-max_dim, max_dim) + ax_xy.set_xlabel('X (Å) - a axis') + ax_xy.set_ylabel('Y (Å) - b axis') + ax_xy.set_title('XY Cross-section (z=0)') + ax_xy.set_aspect('equal') + ax_xy.grid(True, alpha=0.3) + + # XZ cross-section (y=0 plane) + xz_x, xz_z = _truncated_diamond_vertices(a, c, t) + ax_xz.plot(xz_x, xz_z, 'r-', linewidth=2) + ax_xz.fill(xz_x, xz_z, 'lightcoral', alpha=0.3) + max_dim = max(a, c) * 1.2 + ax_xz.set_xlim(-max_dim, max_dim) + ax_xz.set_ylim(-max_dim, max_dim) + ax_xz.set_xlabel('X (Å) - a axis') + ax_xz.set_ylabel('Z (Å) - c axis') + ax_xz.set_title('XZ Cross-section (y=0)') + ax_xz.grid(True, alpha=0.3) + ax_xz.set_aspect('equal') + + # YZ cross-section (x=0 plane) + yz_y, yz_z = _truncated_diamond_vertices(b, c, t) + ax_yz.plot(yz_y, yz_z, 'g-', linewidth=2) + ax_yz.fill(yz_y, yz_z, 'lightgreen', alpha=0.3) + max_dim = max(b, c) * 1.2 + ax_yz.set_xlim(-max_dim, max_dim) + ax_yz.set_ylim(-max_dim, max_dim) + ax_yz.set_xlabel('Y (Å) - b axis') + ax_yz.set_ylabel('Z (Å) - c axis') + ax_yz.set_title('YZ Cross-section (x=0)') + ax_yz.grid(True, alpha=0.3) + ax_yz.set_aspect('equal') tests = [ [{"background": 0, "scale": 1, "length_a": 100, "t": 1, "sld": 1., "sld_solvent": 0.}, From 5f4138bdbbbb2659c046e976fb7cdee97ce17b87 Mon Sep 17 00:00:00 2001 From: Wojtek Potrzebowski Date: Thu, 2 Apr 2026 09:35:10 +0200 Subject: [PATCH 7/7] Fixing octahedron plot to make ruff running --- sasmodels/models/octahedron_truncated.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sasmodels/models/octahedron_truncated.py b/sasmodels/models/octahedron_truncated.py index 6d5f8fa26..47a449b0e 100644 --- a/sasmodels/models/octahedron_truncated.py +++ b/sasmodels/models/octahedron_truncated.py @@ -302,7 +302,6 @@ def create_shape_mesh(params, resolution=50): def plot_shape_cross_sections(ax_xy, ax_xz, ax_yz, params): """Plot 2D cross-sections of the truncated octahedron.""" - import numpy as np length_a = params.get('length_a', 400) b2a_ratio = params.get('b2a_ratio', 1) c2a_ratio = params.get('c2a_ratio', 1)