diff --git a/src/scripts/config_gen.yml b/src/scripts/config_gen.yml
deleted file mode 100644
index f1742a04c8a8e56f6faf64c5333c238b8a194fe7..0000000000000000000000000000000000000000
--- a/src/scripts/config_gen.yml
+++ /dev/null
@@ -1,93 +0,0 @@
-system_settings:
-  max_host_ram : 0
-  max_gpu_ram  : 0
-
-input_settings:
-  input_folder : "."
-  spheres_file : "DEDFB"
-  geometry_file: "DCLU"
-  
-output_settings:
-  output_folder: "."
-  output_name  : "c_OCLU"
-  formats      : [ "LEGACY", "HDF5" ]
-  jwtm         : 1
-
-particle_settings:
-  application : "CLUSTER"
-  n_spheres   : 8
-  n_types     : 2
-  sph_types   : [ 1, 1, 1, 1, 2, 2, 2, 2 ]
-  n_layers    : [ 1, 1 ]
-  radii       : [ 6.000e-08, 4.000e-8 ]
-  rad_frac    : [ [ 1.0 ], [ 1.0 ] ]
-  dielec_id   : [ [ 1 ], [ 1 ] ]
-
-material_settings:
-  diel_flag   : 0
-  extern_diel : 1.00e+00
-  dielec_path : "../../ref_data"
-  dielec_file : [ "eps_ashok_C.csv" ]
-  dielec_fmt  : [ "CSV" ]
-  match_mode  : "GRID"
-  diel_const  : [ ]
-
-radiation_settings:
-  polarization: "LINEAR"
-  scale_start : 1.00e-06
-  scale_end   : 2.00e-05
-  scale_step  : 5.00e-09
-  wp          : 2.99792e+08
-  xip         : 1.00e+00
-  step_flag   : 0
-  scale_name  : "WAVELENGTH"
-
-geometry_settings:
-  li          : 4
-  le          : 8
-  npnt        : 149
-  npntts      : 300
-  iavm        : 0
-  isam        : 0
-  in_th_start : 0.0
-  in_th_step  : 0.0
-  in_th_end   : 0.0
-  in_ph_start : 0.0
-  in_ph_step  : 0.0
-  in_ph_end   : 0.0
-  sc_th_start : 0.0
-  sc_th_step  : 0.0
-  sc_th_end   : 0.0
-  sc_ph_start : 0.0
-  sc_ph_step  : 0.0
-  sc_ph_end   : 0.0
-  x_coords    : [
-    8.00e-08,
-    -8.00e-08,
-    0.00e+00,
-    0.00e+00,
-    0.00e+00,
-    0.00e+00,
-    0.00e+00,
-    0.00e+00
-    ]
-  y_coords    : [
-    0.00e+00,
-    0.00e+00,
-    8.00e-08,
-    -8.00e-08,
-    0.00e+00,
-    0.00e+00,
-    0.00e+00,
-    0.00e+00
-    ]
-  z_coords    : [
-    0.00e+00,
-    0.00e+00,
-    0.00e+00,
-    0.00e+00,
-    8.00e-08,
-    -8.00e-08,
-    16.00e-08,
-    -16.00e-08
-    ]
diff --git a/src/scripts/generator.py b/src/scripts/generator.py
deleted file mode 100644
index 97491a5f92f33e029b1691d70228fedd790c3cfd..0000000000000000000000000000000000000000
--- a/src/scripts/generator.py
+++ /dev/null
@@ -1,95 +0,0 @@
-import math
-import random
-import yaml
-
-#import pdb
-
-from pathlib import PurePath
-from sys import argv
-
-seed = int(argv[1])
-
-random.seed(seed)
-
-config = {}
-with open('config.yml', 'r') as stream:
-    config = yaml.safe_load(stream)
-
-nsph = int(config['particle_settings']['n_spheres'])
-
-vec_thetas = [0.0 for i in range(nsph)]
-vec_phis = [0.0 for i in range(nsph)]
-vec_rads = [0.0 for i in range(nsph)]
-vec_sph_x = [0.0 for i in range(nsph)]
-vec_sph_y = [0.0 for i in range(nsph)]
-vec_sph_z = [0.0 for i in range(nsph)]
-
-sph_type_index = config['particle_settings']['sph_types'][0] - 1
-vec_rads[0] = config['particle_settings']['radii'][sph_type_index]
-max_rad = 20.0 * vec_rads[0]
-placed_spheres = 1
-attempts = 0
-max_attempts = 100
-for i in range(1, nsph):
-    sph_type_index = config['particle_settings']['sph_types'][i] - 1
-    vec_rads[i] = config['particle_settings']['radii'][sph_type_index]
-    is_placed = False
-    #breakpoint()
-    while (not is_placed):
-        if (attempts > max_attempts):
-            print("WARNING: could not place sphere %d in allowed radius!"%i)
-            break # while(not is_placed)
-        vec_thetas[i] = math.pi * random.random()
-        vec_phis[i] = 2.0 * math.pi * random.random()
-        rho = vec_rads[0] + vec_rads[i]
-        z = rho * math.cos(vec_thetas[i])
-        y = rho * math.sin(vec_thetas[i]) * math.sin(vec_phis[i])
-        x = rho * math.sin(vec_thetas[i]) * math.cos(vec_phis[i])
-        j = 0
-        while (j < i - 1):
-            j += 1
-            dx2 = (x - vec_sph_x[j]) * (x - vec_sph_x[j])
-            dy2 = (y - vec_sph_y[j]) * (y - vec_sph_y[j])
-            dz2 = (z - vec_sph_z[j]) * (z - vec_sph_z[j])
-            dist2 = dx2 + dy2 + dz2
-            rr2 = (vec_rads[i] + vec_rads[j]) * (vec_rads[i] + vec_rads[j])
-            if (dist2 < rr2):
-                # Spheres i and j are compenetrating.
-                # Sphere i is moved out radially until it becomes externally
-                # tangent to sphere j. Then the check is repeated, to verify
-                # that no other sphere was penetrated. The process is iterated
-                # until sphere i is placed or the maximum allowed radius is
-                # reached.
-                sinthi = math.sin(vec_thetas[i])
-                sinthj = math.sin(vec_thetas[j])
-                costhi = math.cos(vec_thetas[i])
-                costhj = math.cos(vec_thetas[j])
-                sinphi = math.sin(vec_phis[i])
-                sinphj = math.sin(vec_phis[j])
-                cosphi = math.cos(vec_phis[i])
-                cosphj = math.cos(vec_phis[j])
-                cosalpha = (
-                    sinthi * cosphi * sinthj * cosphj
-                    + sinthi * sinphi * sinthj * sinphj
-                    + costhi * costhj
-                )
-                rho += 2.0 * vec_rads[j] * cosalpha
-                z = rho * math.cos(vec_thetas[i])
-                y = rho * math.sin(vec_thetas[i]) * math.sin(vec_phis[i])
-                x = rho * math.sin(vec_thetas[i]) * math.cos(vec_phis[i])
-                j = 0
-                continue # while(j < i - 1)
-        if (rho + vec_rads[i] > max_rad):
-            # The current direction is filled. Try another one.
-            attempts += 1
-            continue # while(not is_placed)
-        vec_sph_x[i] = x
-        vec_sph_y[i] = y
-        vec_sph_z[i] = z
-        is_placed = True
-        placed_spheres += 1
-        attempts = 0
-
-print(vec_sph_x)
-print(vec_sph_y)
-print(vec_sph_z)
diff --git a/src/scripts/model_maker.py b/src/scripts/model_maker.py
index 21a2eb1bb710bf56dd9f1a8271bba565d4937634..6e2d3d50e20f538a528ba64cf1850ebda9ab21b2 100755
--- a/src/scripts/model_maker.py
+++ b/src/scripts/model_maker.py
@@ -1,6 +1,6 @@
-#!/bin/python3
+#!/usr/bin/env python3
 
-#   Copyright (C) 2024   INAF - Osservatorio Astronomico di Cagliari
+#   Copyright (C) 2025   INAF - Osservatorio Astronomico di Cagliari
 #
 #   This program is free software: you can redistribute it and/or modify
 #   it under the terms of the GNU General Public License as published by
@@ -23,9 +23,22 @@
 #
 #  The script requires python3.
 
+import math
+import multiprocessing
+import numpy as np
+import os
+import pdb
+import random
 import yaml
 
-from pathlib import PurePath
+allow_3d = True
+try:
+    import pyvista as pv
+except ModuleNotFoundError as ex:
+    print("WARNING: pyvista not found!")
+    allow_3d = False
+
+from pathlib import Path
 from sys import argv
 
 ## \brief Main execution code
@@ -58,7 +71,7 @@ def interpolate_constants(sconf):
     for i in range(sconf['configurations']):
         for j in range(sconf['nshl'][i]):
             file_idx = sconf['dielec_id'][i][j]
-            dielec_path = PurePath(sconf['dielec_path'], sconf['dielec_file'][int(file_idx) - 1])
+            dielec_path = Path(sconf['dielec_path'], sconf['dielec_file'][int(file_idx) - 1])
             file_name = str(dielec_path)
             dielec_file = open(file_name, 'r')
             wavelengths = []
@@ -131,9 +144,18 @@ def load_model(model_file):
     except FileNotFoundError:
         print("ERROR: " + model_file + " was not found!")
     if model is not None:
+        max_rad = 0.0
+        make_3d = False
+        try:
+            make_3d = False if model['system_settings']['make_3D'] == "0" else True
+        except KeyError:
+            make_3d = False
+        if (make_3d and not allow_3d):
+            print("WARNING: 3D visualization of models is not available. Disabling.")
+            make_3d = False
         # Create the sconf dict
         sconf = {
-            'out_file': PurePath(
+            'out_file': Path(
                 model['input_settings']['input_folder'],
                 model['input_settings']['spheres_file']
             )
@@ -252,11 +274,21 @@ def load_model(model_file):
             print("ERROR: %s is not a supported scaling mode!"%(model['radiation_settings']['scale_name']))
             return (None, None)
         sph_types = model['particle_settings']['sph_types']
-        if (len(sph_types) != sconf['nsph'] and len(sph_types) != 0):
-            print("ERROR: vector of sphere types does not match the declared number of spheres!")
-            return (None, None)
-        else:
+        if (len(sph_types) == sconf['nsph']):
             sconf['vec_types'] = [int(str_typ) for str_typ in sph_types]
+        else:
+            if (len(sph_types) != 0):
+                print("ERROR: vector of sphere types does not match the declared number of spheres!")
+                return (None, None)
+            else: # len(sph_types) = 0
+                len_vec_x = len(model['geometry_settings']['x_coords'])
+                len_vec_y = len(model['geometry_settings']['y_coords'])
+                len_vec_z = len(model['geometry_settings']['z_coords'])
+                if (len_vec_x != 0 or len_vec_y != 0 or len_vec_z != 0):
+                    print("ERROR: cannot assign random types with explicit sphere positions!")
+                    return (None, None)
+                else:
+                    sconf['vec_types'] = [0 for ti in range(sconf['nsph'])]
         if (len(model['particle_settings']['radii']) != sconf['configurations']):
             print("ERROR: Declared number of radii does not match number of types!")
             return (None, None)
@@ -290,7 +322,7 @@ def load_model(model_file):
             print("ERROR: %s is not a recognized polarization state."%str_polar)
             return (None, None)
         gconf = {
-            'out_file': PurePath(
+            'out_file': Path(
                 model['input_settings']['input_folder'],
                 model['input_settings']['geometry_file']
             )
@@ -324,22 +356,45 @@ def load_model(model_file):
         gconf['vec_sph_y'] = [0.0 for i in range(gconf['nsph'])]
         gconf['vec_sph_z'] = [0.0 for i in range(gconf['nsph'])]
         if (gconf['application'] != "SPHERE" or gconf['nsph'] != 1):
-            if (len(model['geometry_settings']['x_coords']) != len(model['geometry_settings']['y_coords'])):
+            len_vec_x = len(model['geometry_settings']['x_coords'])
+            len_vec_y = len(model['geometry_settings']['y_coords'])
+            len_vec_z = len(model['geometry_settings']['z_coords'])
+            if (len_vec_x != len_vec_y):
                 print("ERROR: X and Y coordinate vectors have different lengths!")
                 return (None, None)
-            if (len(model['geometry_settings']['x_coords']) != len(model['geometry_settings']['z_coords'])):
+            if (len_vec_x != len_vec_z):
                 print("ERROR: X and Z coordinate vectors have different lengths!")
                 return (None, None)
-            if (len(model['geometry_settings']['y_coords']) != len(model['geometry_settings']['z_coords'])):
+            if (len_vec_y != len_vec_z):
                 print("ERROR: Y and Z coordinate vectors have different lengths!")
                 return (None, None)
-            if (len(model['particle_settings']['sph_types']) == 0 and len(model['geometry_settings']['x_coords']) != 0):
-                print("ERROR: random type assignment is not allowed with assigned coordinates!")
-                return (None, None)
-            # TODO: put here a test to allow for empty vectors in case of random generation
-            if (len(model['geometry_settings']['x_coords']) == 0):
+            if (len_vec_x == 0):
                 # Generate random cluster
-                print("DEBUG: would generate random cluster.")
+                rnd_seed = int(model['system_settings']['rnd_seed'])
+                try:
+                    max_rad = float(model['particle_settings']['max_rad'])
+                except KeyError:
+                    print("ERROR: random model generation requires specification of particle_settings:max_rad.")
+                    return (None, None)
+                rnd_engine = "COMPACT"
+                try:
+                    rnd_engine = model['system_settings']['rnd_engine']
+                except KeyError:
+                    # use compact generator, if no specification is given
+                    rnd_engine = "COMPACT"
+                if (rnd_engine == "COMPACT"):
+                    check = random_compact(sconf, gconf, rnd_seed, max_rad)
+                    if (check == 1):
+                        print("ERROR: compact random generator works only when all sphere types have the same radius.")
+                        return (None, None)
+                elif (rnd_engine == "LOOSE"):
+                    # random_aggregate() checks internally whether application is INCLUSION
+                    check = random_aggregate(sconf, gconf, rnd_seed, max_rad)
+                else:
+                    print("ERROR: unrecognized random generator engine.")
+                    return (None, None)
+                if (check != 0):
+                    print("WARNING: %d sphere(s) could not be placed."%check)
             else:
                 if (len(model['geometry_settings']['x_coords']) != gconf['nsph']):
                     print("ERROR: coordinate vectors do not match the number of spheres!")
@@ -348,6 +403,40 @@ def load_model(model_file):
                     gconf['vec_sph_x'][si] = float(model['geometry_settings']['x_coords'][si])
                     gconf['vec_sph_y'][si] = float(model['geometry_settings']['y_coords'][si])
                     gconf['vec_sph_z'][si] = float(model['geometry_settings']['z_coords'][si])
+        if (make_3d and allow_3d):
+            if (max_rad == 0.0):
+                max_rad = 20.0 * max(sconf['ros'])
+            write_obj(sconf, gconf, max_rad)
+        try:
+            max_gpu_ram = int(model['system_settings']['max_gpu_ram'])
+            if (max_gpu_ram > 0):
+                max_gpu_ram_bytes = max_gpu_ram * 1024 * 1024 * 1024
+                matrix_dim = 2 * gconf['nsph'] * gconf['li'] * (gconf['li'] + 2)
+                matrix_size_bytes = 16 * matrix_dim * matrix_dim
+                if (matrix_size_bytes < max_gpu_ram_bytes):
+                    max_gpu_processes = int(max_gpu_ram_bytes / matrix_size_bytes)
+                    print("INFO: system supports up to %d simultaneous processes on GPU."%max_gpu_processes)
+                else:
+                    print("WARNING: estimated matrix size is larger than available GPU memory!")
+            else:
+                print("INFO: no GPU RAM declared.")
+            max_host_ram = int(model['system_settings']['max_host_ram'])
+            if (max_host_ram > 0):
+                max_host_ram_bytes = max_host_ram * 1024 * 1024 * 1024
+                matrix_dim = 2 * gconf['nsph'] * gconf['li'] * (gconf['li'] + 2)
+                matrix_size_bytes = 16 * matrix_dim * matrix_dim
+                if (matrix_size_bytes < max_host_ram_bytes):
+                    max_host_processes = int(max_host_ram_bytes / matrix_size_bytes / 2)
+                    print("INFO: system supports up to %d simultaneous processes."%max_host_processes)
+                else:
+                    print("WARNING: estimated matrix size is larger than available host memory!")
+            else:
+                print("WARNING: no host RAM declared!")
+        except KeyError as ex:
+            print(ex)
+            print("WARNING: missing system description! Cannot estimate recommended execution.")
+        cpu_count = multiprocessing.cpu_count()
+        print("INFO: the number of detected CPUs is %d."%cpu_count)
     else: # model is None
         print("ERROR: could not parse " + model_file + "!")
     return (sconf, gconf)
@@ -371,7 +460,7 @@ def match_grid(sconf):
             layers += 1
         for j in range(layers):
             file_idx = sconf['dielec_id'][i][j]
-            dielec_path = PurePath(sconf['dielec_path'], sconf['dielec_file'][int(file_idx) - 1])
+            dielec_path = Path(sconf['dielec_path'], sconf['dielec_file'][int(file_idx) - 1])
             file_name = str(dielec_path)
             dielec_file = open(file_name, 'r')
             wavelengths = []
@@ -475,6 +564,242 @@ def print_help():
     print("--help                Print this help and exit.")
     print("                                               ")
 
+
+## \brief Generate a random cluster aggregate from YAML configuration options.
+#
+#  This function generates a random aggregate of spheres using radial ejection
+#  in random directions of new spheres until they become tangent to the
+#  outermost sphere existing in that direction. The result of the generated
+#  model is directly saved in the parameters of the scatterer and geometry
+#  configuration dictionaries.
+#
+#  \param scatterer: `dict` Scatterer configuration dictionary (gets modified)
+#  \param geometry: `dict` Geometry configuration dictionary (gets modified)
+#  \param seed: `int` Seed for the random sequence generation
+#  \param max_rad: `float` Maximum allowed radial extension of the aggregate
+#  \param max_attempts: `int` Maximum number of attempts to place a particle in any direction
+#  \return result: `int` Function exit code (0 for success, otherwise number of
+#  spheres that could not be placed)
+def random_aggregate(scatterer, geometry, seed, max_rad, max_attempts=100):
+    result = 0
+    random.seed(seed)
+    nsph = scatterer['nsph']
+    vec_thetas = [0.0 for i in range(nsph)]
+    vec_phis = [0.0 for i in range(nsph)]
+    vec_rads = [0.0 for i in range(nsph)]
+    n_types = scatterer['configurations']
+    if (0 in scatterer['vec_types']):
+        tincrement = 1 if scatterer['application'] != "INCLUSION" else 2
+        for ti in range(nsph):
+            itype = tincrement + int(n_types * random.random())
+            scatterer['vec_types'][ti] = itype
+    sph_type_index = scatterer['vec_types'][0] - 1
+    vec_spheres = [{'itype': sph_type_index + 1, 'x': 0.0, 'y': 0.0, 'z': 0.0}]
+    vec_rads[0] = scatterer['ros'][sph_type_index]
+    placed_spheres = 1
+    attempts = 0
+    for i in range(1, nsph):
+        sph_type_index = scatterer['vec_types'][i] - 1
+        vec_rads[i] = scatterer['ros'][sph_type_index]
+        is_placed = False
+        while (not is_placed):
+            if (attempts > max_attempts):
+                result += 1
+                break # while(not is_placed)
+            vec_thetas[i] = math.pi * random.random()
+            vec_phis[i] = 2.0 * math.pi * random.random()
+            rho = vec_rads[0] + vec_rads[i]
+            z = rho * math.cos(vec_thetas[i])
+            y = rho * math.sin(vec_thetas[i]) * math.sin(vec_phis[i])
+            x = rho * math.sin(vec_thetas[i]) * math.cos(vec_phis[i])
+            j = 0
+            while (j < i - 1):
+                j += 1
+                dx2 = (x - vec_spheres[j]['x']) * (x - vec_spheres[j]['x'])
+                dy2 = (y - vec_spheres[j]['y']) * (y - vec_spheres[j]['y'])
+                dz2 = (z - vec_spheres[j]['z']) * (z - vec_spheres[j]['z'])
+                dist2 = dx2 + dy2 + dz2
+                rr2 = (vec_rads[i] + vec_rads[j]) * (vec_rads[i] + vec_rads[j])
+                if (dist2 < 0.9999 * rr2):
+                    # Spheres i and j are compenetrating.
+                    # Sphere i is moved out radially until it becomes externally
+                    # tangent to sphere j. Then the check is repeated, to verify
+                    # that no other sphere was penetrated. The process is iterated
+                    # until sphere i is placed or the maximum allowed radius is
+                    # reached.
+                    # breakpoint()
+                    sinthi = math.sin(vec_thetas[i])
+                    sinthj = math.sin(vec_thetas[j])
+                    costhi = math.cos(vec_thetas[i])
+                    costhj = math.cos(vec_thetas[j])
+                    sinphi = math.sin(vec_phis[i])
+                    sinphj = math.sin(vec_phis[j])
+                    cosphi = math.cos(vec_phis[i])
+                    cosphj = math.cos(vec_phis[j])
+                    cosalpha = (
+                        sinthi * cosphi * sinthj * cosphj
+                        + sinthi * sinphi * sinthj * sinphj
+                        + costhi * costhj
+                    )
+                    D12 = math.sqrt(
+                        vec_spheres[j]['x'] * vec_spheres[j]['x']
+                        + vec_spheres[j]['y'] * vec_spheres[j]['y']
+                        + vec_spheres[j]['z'] * vec_spheres[j]['z']
+                    )
+                    O1K = D12 * cosalpha
+                    sinalpha = math.sqrt(1.0 - cosalpha * cosalpha)
+                    sinbetaprime = D12 / (vec_rads[i] + vec_rads[j]) * sinalpha
+                    cosbetaprime = math.sqrt(1.0 - sinbetaprime * sinbetaprime)
+                    Op3K = (vec_rads[i] + vec_rads[j]) * cosbetaprime
+                    rho = O1K + Op3K
+                    z = rho * math.cos(vec_thetas[i])
+                    y = rho * math.sin(vec_thetas[i]) * math.sin(vec_phis[i])
+                    x = rho * math.sin(vec_thetas[i]) * math.cos(vec_phis[i])
+                    j = 0
+                    continue # while(j < i - 1)
+            if (rho + vec_rads[i] > max_rad):
+                # The current direction is filled. Try another one.
+                attempts += 1
+                continue # while(not is_placed)
+            vec_spheres.append({
+                'itype': sph_type_index + 1,
+                'x': x,
+                'y': y,
+                'z': z
+            })
+            is_placed = True
+            placed_spheres += 1
+            attempts = 0
+    sph_index = 0
+    for sphere in sorted(vec_spheres, key=lambda item: item['itype']):
+        scatterer['vec_types'][sph_index] = sphere['itype']
+        geometry['vec_sph_x'][sph_index] = sphere['x']
+        geometry['vec_sph_y'][sph_index] = sphere['y']
+        geometry['vec_sph_z'][sph_index] = sphere['z']
+        sph_index += 1
+    return result
+
+## \brief Generate a random compact cluster from YAML configuration options.
+#
+#  This function generates a random aggregate of spheres using the maximum
+#  compactness packaging to fill a spherical volume with given maximum radius,
+#  then it proceeds by subtracting random spheres from the outer layers, until
+#  the aggregate is reduced to the desired number of spheres. The function
+#  can only be used if all sphere types have the same radius. The result of the
+#  generated model is directly saved in the parameters of the scatterer and
+#  geometry configuration dictionaries.
+#
+#  \param scatterer: `dict` Scatterer configuration dictionary (gets modified)
+#  \param geometry: `dict` Geometry configuration dictionary (gets modified)
+#  \param seed: `int` Seed for the random sequence generation
+#  \param max_rad: `float` Maximum allowed radial extension of the aggregate
+#  \return result: `int` Function exit code (0 for success, otherwise error code)
+def random_compact(scatterer, geometry, seed, max_rad):
+    result = 0
+    random.seed(seed)
+    nsph = scatterer['nsph']
+    n_types = scatterer['configurations']
+    if (0 in scatterer['vec_types']):
+        tincrement = 1 if scatterer['application'] != "INCLUSION" else 2
+        for ti in range(nsph):
+            itype = tincrement + int(n_types * random.random())
+            scatterer['vec_types'][ti] = itype
+    if (max(scatterer['ros']) != min(scatterer['ros'])):
+        result = 1
+    else:
+        radius = scatterer['ros'][0]
+        x_centers = np.arange(-1.0 * max_rad + radius, max_rad, 2.0 * radius)
+        y_centers = np.arange(-1.0 * max_rad + radius, max_rad, math.sqrt(3.0) * radius)
+        z_centers = np.arange(-1.0 * max_rad + radius, max_rad, math.sqrt(3.0) * radius)
+        x_offset = radius
+        y_offset = radius
+        x_layer_offset = radius
+        y_layer_offset = radius / math.sqrt(3.0)
+        tmp_spheres = []
+        n_cells = len(x_centers) * len(y_centers) * len(z_centers)
+        print("INFO: the cubic space would contain %d spheres."%n_cells)
+        n_max_spheres = int((max_rad / radius) * (max_rad / radius) * (max_rad / radius) * 0.74)
+        print("INFO: the maximum radius allows for %d spheres."%n_max_spheres)
+        for zi in range(len(z_centers)):
+            if (x_layer_offset == 0.0):
+                x_layer_offset = radius
+            else:
+                x_layer_offset = 0.0
+            if (y_offset == 0.0):
+                y_offset = radius
+            else:
+                y_offset = 0.0
+            for yi in range(len(y_centers)):
+                if (x_offset == 0.0):
+                    x_offset = radius
+                else:
+                    x_offset = 0.0
+                for xi in range(len(x_centers)):
+                    x = x_centers[xi] + x_offset + x_layer_offset
+                    y = y_centers[yi] + y_offset
+                    z = z_centers[zi]
+                    extent = radius + math.sqrt(x * x + y * y + z * z)
+                    if (extent < max_rad):
+                        tmp_spheres.append({
+                            'itype': 1,
+                            'x': x,
+                            'y': y,
+                            'z': z
+                        })
+        #tmp_spheres = [{'itype': 1, 'x': 0.0, 'y': 0.0, 'z': 0.0}]
+        current_n = len(tmp_spheres)
+        print("INFO: before erosion there are %d spheres in use."%current_n)
+        rho = 10.0 * max_rad
+        discard_rad = 100.0 * max_rad
+        while (current_n > nsph):
+            theta = math.pi * random.random()
+            phi = 2.0 * math.pi * random.random()
+            x0 = rho * math.sin(theta) * math.cos(phi)
+            y0 = rho * math.sin(theta) * math.sin(phi)
+            z0 = rho * math.cos(theta)
+            closest_index = 0
+            minimum_distance = 1000.0 * max_rad
+            for di in range(len(tmp_spheres)):
+                x1 = tmp_spheres[di]['x']
+                if (x1 == discard_rad):
+                    continue
+                y1 = tmp_spheres[di]['y']
+                z1 = tmp_spheres[di]['z']
+                distance = math.sqrt(
+                    (x1 - x0) * (x1 - x0)
+                    + (y1 - y0) * (y1 - y0)
+                    + (z1 - z0) * (z1 - z0)
+                )
+                if (distance < minimum_distance):
+                    closest_index = di
+                    minimum_distance = distance
+            tmp_spheres[closest_index]['x'] = discard_rad
+            current_n -= 1
+        vec_spheres = []
+        sph_index = 0
+        for ti in range(len(tmp_spheres)):
+            sphere = tmp_spheres[ti]
+            if (sphere['x'] < max_rad):
+                sphere['itype'] = scatterer['vec_types'][sph_index]
+                sph_index += 1
+                vec_spheres.append(sphere)
+        #pl = pv.Plotter()
+        #for si in range(len(vec_spheres)):
+        #    x = vec_spheres[si]['x'] / max_rad
+        #    y = vec_spheres[si]['y'] / max_rad
+        #    z = vec_spheres[si]['z'] / max_rad
+        #    mesh = pv.Sphere(radius / max_rad, (x, y, z))
+        #    pl.add_mesh(mesh)
+        #pl.export_obj("scene.obj")
+        sph_index = 0
+        for sphere in sorted(vec_spheres, key=lambda item: item['itype']):
+            scatterer['vec_types'][sph_index] = sphere['itype']
+            geometry['vec_sph_x'][sph_index] = sphere['x']
+            geometry['vec_sph_y'][sph_index] = sphere['y']
+            geometry['vec_sph_z'][sph_index] = sphere['z']
+            sph_index += 1
+    return result
+    
 ## \brief Write the geometry configuration dictionary to legacy format.
 #
 #  \param conf: `dict` Geometry configuration dictionary.
@@ -625,6 +950,82 @@ def write_legacy_sconf(conf):
     output.close()
     return result
 
+## \brief Export the model to a set of OBJ files for 3D visualization.
+#
+#  This function exports the model as a set of OBJ files (one for every
+#  spherical unit, plus a single scene file) to allow for model visualization
+#  with 3D software tools.
+#
+#  \param scatterer: `dict` Scatterer configuration dictionary (gets modified)
+#  \param geometry: `dict` Geometry configuration dictionary (gets modified)
+#  \param max_rad: `float` Maximum allowed radial extension of the aggregate
+def write_obj(scatterer, geometry, max_rad):
+    out_dir = scatterer['out_file'].absolute().parent
+    out_model_path = Path(str(geometry['out_file']) + ".obj")
+    out_material_path = Path(str(geometry['out_file']) + ".mtl")
+    color_strings = [
+        "0.5 0.5 0.5\n", # white
+        "0.3 0.0 0.0\n", # red
+        "0.0 0.0 0.3\n", # blue
+        "0.0 0.3 0.0\n", # green
+    ]
+    color_names = [
+        "white", "red", "blue", "green"
+    ]
+    mtl_file = open(str(out_material_path), "w")
+    for mi in range(len(color_strings)):
+        mtl_line = "newmtl "  + color_names[mi] + "\n"
+        mtl_file.write(mtl_line)
+        color_line = color_strings[mi]
+        mtl_file.write("   Ka " + color_line)
+        mtl_file.write("   Ks " + color_line)
+        mtl_file.write("   Kd " + color_line)
+        mtl_file.write("   Ns 100.0\n")
+        mtl_file.write("   Tr 0.0\n")
+        mtl_file.write("   illum 2\n\n")
+    mtl_file.close()
+    pl = pv.Plotter()
+    for si in range(scatterer['nsph']):
+        sph_type_index = scatterer['vec_types'][si]
+        # color_index = 1 + (sph_type_index % (len(color_strings) - 1))
+        # color_by_name = color_names[sph_type_index]
+        radius = scatterer['ros'][sph_type_index - 1] / max_rad
+        x = geometry['vec_sph_x'][si] / max_rad
+        y = geometry['vec_sph_y'][si] / max_rad
+        z = geometry['vec_sph_z'][si] / max_rad
+        mesh = pv.Sphere(radius, (x, y, z))
+        pl.add_mesh(mesh, color=None)
+    pl.export_obj(str(Path(str(out_dir), "TMP_MODEL.obj")))
+    tmp_model_file = open(str(Path(str(out_dir), "TMP_MODEL.obj")), "r")
+    out_model_file = open(str(out_model_path), "w")
+    mtl_line = "mtllib {0:s}\n".format(out_material_path.name)
+    sph_index = 0
+    sph_type_index = 0
+    old_sph_type_index = 0
+    str_line = tmp_model_file.readline()
+    while (str_line != ""):
+        if (str_line.startswith("mtllib")):
+            str_line = mtl_line
+        elif (str_line.startswith("g ")):
+            sph_index += 1
+            sph_type_index = scatterer['vec_types'][sph_index - 1]
+            if (sph_type_index == old_sph_type_index):
+                str_line = tmp_model_file.readline()
+                str_line = tmp_model_file.readline()
+            else:
+                old_sph_type_index = sph_type_index
+                color_index = sph_type_index % (len(color_names) - 1)
+                str_line = "g grp{0:04d}\n".format(sph_type_index)
+                out_model_file.write(str_line)
+                str_line = tmp_model_file.readline()
+                str_line = "usemtl {0:s}\n".format(color_names[color_index])
+        out_model_file.write(str_line)
+        str_line = tmp_model_file.readline()
+    out_model_file.close()
+    tmp_model_file.close()
+    os.remove(str(Path(str(out_dir), "TMP_MODEL.obj")))
+    os.remove(str(Path(str(out_dir), "TMP_MODEL.mtl")))
+
 ## \brief Exit code (0 for success)
 exit_code = main()
 exit(exit_code)
diff --git a/src/scripts/pycompare.py b/src/scripts/pycompare.py
index b4b0a0c39702116396203f7d401778ce134e28d1..778e40554789e73f1231893f9c4110fb0e4a75b3 100755
--- a/src/scripts/pycompare.py
+++ b/src/scripts/pycompare.py
@@ -1,6 +1,6 @@
-#!/bin/python3
+#!/usr/bin/env python3
 
-#   Copyright (C) 2024   INAF - Osservatorio Astronomico di Cagliari
+#   Copyright (C) 2025   INAF - Osservatorio Astronomico di Cagliari
 #
 #   This program is free software: you can redistribute it and/or modify
 #   it under the terms of the GNU General Public License as published by
diff --git a/src/scripts/pydynrange.py b/src/scripts/pydynrange.py
index 491f68f80e83ac7542ce3b9eb873e1fbe813f6a2..5dd250a3b6f5dc0d6272b38b2c08c25873b8b9a0 100755
--- a/src/scripts/pydynrange.py
+++ b/src/scripts/pydynrange.py
@@ -1,6 +1,6 @@
-#!/bin/python3
+#!/usr/bin/env python3
 
-#   Copyright (C) 2024   INAF - Osservatorio Astronomico di Cagliari
+#   Copyright (C) 2025   INAF - Osservatorio Astronomico di Cagliari
 #
 #   This program is free software: you can redistribute it and/or modify
 #   it under the terms of the GNU General Public License as published by
diff --git a/src/scripts/pytiming.py b/src/scripts/pytiming.py
index bc1ab97ae595ea8af25197e031f4befc50c38fce..c2d2e99e4acaf54250350612954df659f86a3ef9 100755
--- a/src/scripts/pytiming.py
+++ b/src/scripts/pytiming.py
@@ -1,6 +1,6 @@
-#!/bin/python3
+#!/usr/bin/env python3
 
-#   Copyright (C) 2024   INAF - Osservatorio Astronomico di Cagliari
+#   Copyright (C) 2025   INAF - Osservatorio Astronomico di Cagliari
 #
 #   This program is free software: you can redistribute it and/or modify
 #   it under the terms of the GNU General Public License as published by
diff --git a/test_data/config_random_example.yml b/test_data/config_random_example.yml
new file mode 100644
index 0000000000000000000000000000000000000000..deb73c39dfb0ecda1c28f72c6253e16247001311
--- /dev/null
+++ b/test_data/config_random_example.yml
@@ -0,0 +1,140 @@
+# EXAMPLE CONFIGURATION FILE TO BUILD A RANDOM CLUSTER WITH 100 SPHERES.
+
+system_settings:
+  # Limit on host RAM use in Gb (0 for no configuration limit)
+  max_host_ram : 24
+  # Limit on GPU RAM use in Gb (0 for no configuration limit)
+  max_gpu_ram  : 8
+  # Random seed (a positive integer number)
+  rnd_seed     : 105
+  # Random engine (COMPACT or LOOSE)
+  rnd_engine   : "COMPACT"
+  # OBJ model export flag (requires pyvista; 0 is FALSE)
+  make_3D      : 0
+
+input_settings:
+  # Folder to write the code input configuration files
+  input_folder : "."
+  # Name of the scatterer description file
+  spheres_file : "DEDFB_comp"
+  # Name of the geometry description file
+  geometry_file: "DCLU_comp"
+  
+output_settings:
+  # Folder for the code output storage
+  output_folder: "."
+  # Name of the main output file
+  output_name  : "c_OCLU"
+  # Requested output formats
+  formats      : [ "LEGACY", "HDF5" ]
+  # Index of the scale for transition matrix output
+  jwtm         : 1
+
+particle_settings:
+  # What application to use (SPHERE | CLUSTER | INCLUSION)
+  application : "CLUSTER"
+  # Number of spheres
+  n_spheres   : 100
+  # Number of sphere types
+  n_types     : 2
+  # Vector of sphere type identifiers (what type is each sphere)
+  # For random genration it can be empty (random sphere types),
+  # or contain "n_spheres" elements (random position with assigned
+  # types).
+  sph_types   : [ ]
+  # Vector of layers in types (how many layers in each type)
+  n_layers    : [ 1, 1 ]
+  # Spherical monomer radii in m (one size for each type)
+  radii       : [ 4.000e-8, 4.000e-8 ]
+  # Layer fractional radii (one per layer in each type)
+  rad_frac    : [ [ 1.0 ], [ 1.0 ] ]
+  # Index of the dielectric constants (one per layer in each type)
+  #
+  # 1 is first file in `dielec_file`, 2 is second ...
+  dielec_id   : [ [ 1 ], [ 1 ] ]
+  # Maximum radius for random particle size in m
+  max_rad     : 3.0e-7
+
+material_settings:
+  # Dielectric properties definition (-1 = scaled, 0 = tabulated)
+  diel_flag   : 0
+  # External medium dielectric constant
+  extern_diel : 1.00e+00
+  # Dielectric constant files folder
+  dielec_path : "../../ref_data"
+  # List of dielectric constant files (used if diel_flag = 0)
+  dielec_file : [ "eps_ashok_C.csv" ]
+  # Dielectric constant files format (same for all files)
+  dielec_fmt  : [ "CSV" ]
+  # Matching method between optical constants and radiation wavelengths
+  #
+  # INTERPOLATE: the constants are interpolated on wavelengths
+  # GRID: only the wavelengths with defined constants are computed
+  #
+  match_mode  : "GRID"
+  # Reference dielectric constants (used if diel_flag = -1)
+  #
+  # One real and one imaginary part for each layer in each type.
+  diel_const  : [ ]
+
+radiation_settings:
+  # Radiation field polarization (LINEAR | CIRCULAR)
+  polarization: "LINEAR"
+  # First scale to be used
+  scale_start : 1.00e-06
+  # Last scale to be used
+  scale_end   : 2.00e-05
+  # Calculation step (overridden if `match_mode` is GRID)
+  scale_step  : 5.00e-09
+  # Peak Omega
+  wp          : 2.99792e+08
+  # Peak scale
+  xip         : 1.00e+00
+  # Define scale explicitly (0) or in equal steps (1)
+  step_flag   : 0
+  # Type of scaling variable
+  scale_name  : "WAVELENGTH"
+
+geometry_settings:
+  # Maximum internal field expansion
+  li          : 6
+  # Maximum external field expansion (not used by SPHERE)
+  le          : 8
+  # Number of transition layer integration points
+  npnt        : 149
+  # Number of non transition layer integration points
+  npntts      : 300
+  # Averaging mode
+  iavm        : 0
+  # Meridional plane flag
+  isam        : 0
+  # Starting incidence azimuth angle
+  in_th_start : 0.0
+  # Incidence azimuth angle incremental step
+  in_th_step  : 0.0
+  # Ending incidence azimuth angle
+  in_th_end   : 0.0
+  # Starting incidence elevation angle
+  in_ph_start : 0.0
+  # Incidence elevation angle incremental step
+  in_ph_step  : 0.0
+  # Ending incidence elevation angle
+  in_ph_end   : 0.0
+  # Starting scattered azimuth angle
+  sc_th_start : 0.0
+  # Scattered azimuth angle incremental step
+  sc_th_step  : 0.0
+  # Ending scattered azimuth angle
+  sc_th_end   : 0.0
+  # Starting scattered elevation angle
+  sc_ph_start : 0.0
+  # Scattered elevation angle incremental step
+  sc_ph_step  : 0.0
+  # Ending scattered elevation angle
+  sc_ph_end   : 0.0
+  # Vector of sphere X coordinates (one per sphere or empty for random)
+  x_coords    : [ ]
+  # Vector of sphere Y coordinates (one per sphere or empty for random)
+  y_coords    : [ ]
+  # Vector of sphere Z coordinates (one per sphere or empty for random)
+  z_coords    : [ ]