//  ************************************************************************************************
//
//  BornAgain: simulate and fit reflection and scattering
//
//! @file      Tests/Unit/Sim/SceneGeometryTest.cpp
//! @brief     Tests 3D scene geometry export.
//!
//! @homepage  http://www.bornagainproject.org
//! @license   GNU General Public License v3 or higher (see COPYING)
//! @copyright Forschungszentrum Jülich GmbH 2026
//! @authors   Scientific Computing Group at MLZ (see CITATION, AUTHORS)
//
//  ************************************************************************************************

#include "Sim/Export/SceneGeometry.h"
#include "Sample/Aggregate/Crystal1D.h"
#include "Sample/Aggregate/Crystal2D.h"
#include "Sample/Aggregate/Dense2D.h"
#include "Sample/Aggregate/Dilute2D.h"
#include "Sample/Aggregate/FiniteCrystal2D.h"
#include "Sample/Aggregate/Paracrystal2D.h"
#include "Sample/Aggregate/RadialParacrystal.h"
#include "Sample/Aggregate/Struct2D.h"
#include "Sample/Correlation/Profiles1D.h"
#include "Sample/Correlation/Profiles2D.h"
#include "Sample/HardParticle/Cylinder.h"
#include "Sample/HardParticle/Sphere.h"
#include "Sample/Lattice/Lattice2D.h"
#include "Sample/Lattice/Lattice3D.h"
#include "Sample/Material/SLDMaterial.h"
#include "Sample/Material/Vacuum.h"
#include "Sample/Multilayer/Layer.h"
#include "Sample/Particle/Compound.h"
#include "Sample/Particle/Crystal.h"
#include "Sample/Particle/Mesocrystal.h"
#include "Sample/Particle/Mixture.h"
#include "Sample/Particle/Particle.h"
#include "Sample/Sample/Sample.h"
#include "Tests/GTestWrapper/google_test.h"
#include <algorithm>
#include <cmath>
#include <limits>

namespace {

constexpr double dense2d_particle_radius = 5.0;
constexpr double dense2d_particle_height = 5.0;
// Keeps Dense2D viewer tests around O(100) particles: dense enough for overlaps, still fast.
constexpr double dense2d_view_half_extent = 60.0;
// Test scenarios, not model limits. The high case is close to the PY validation cutoff;
// the overpacked case is intentionally above hexagonal close packing and must still render.
constexpr double dense2d_moderate_density = 0.006;
constexpr double dense2d_high_density = 0.008;
constexpr double dense2d_overpacked_density = 0.012;
constexpr size_t dense2d_moderate_count = 86;    // floor(density * display patch area)
constexpr size_t dense2d_high_count = 115;       // floor(density * display patch area)
constexpr size_t dense2d_overpacked_count = 172; // floor(density * display patch area)

//! Creates a named spherical particle with a stable material label for lookup.
Particle makeParticle(const std::string& name, double radius)
{
    const SLDMaterial material(name, MaterialColor(0.5, 0.5, 0.5), 1.0, 0.0);
    return Particle(material, Sphere(radius));
}

//! Builds a deposited 2D scene using a paracrystal, enough to inspect exported particles.
SceneDescription sceneForParticle(const IParticle& particle)
{
    const Vacuum vacuum;
    const SLDMaterial film("film", MaterialColor(0.6, 0.6, 0.6), 0.0, 0.0);
    const SLDMaterial substrate("substrate", MaterialColor(0.3, 0.3, 0.3), 0.0, 0.0);

    const RadialParacrystal structure(particle, 10.0, 100.0);
    Layer top(vacuum);
    Layer layer(film, 100.0);
    layer.deposit2D(structure);
    Layer bottom(substrate);

    Sample sample;
    sample.addLayer(top);
    sample.addLayer(layer);
    sample.addLayer(bottom);
    return buildScene3D(sample, 50.0, 17);
}

//! Builds a deposited square-lattice scene to exercise layout-size dependent export paths.
SceneDescription latticeSceneForParticle(const IParticle& particle)
{
    const Vacuum vacuum;
    const SLDMaterial substrate("substrate", MaterialColor(0.3, 0.3, 0.3), 0.0, 0.0);

    const Crystal2D structure(particle, SquareLattice2D(10.0, 0.0));
    Layer top(vacuum);
    top.deposit2D(structure);
    Layer bottom(substrate);

    Sample sample;
    sample.addLayer(top);
    sample.addLayer(bottom);
    return buildScene3D(sample, 50.0, 17);
}

//! Builds a suspended square-lattice scene to verify top-alignment of nested Mixtures.
SceneDescription suspendedLatticeSceneForParticle(const IParticle& particle)
{
    const Vacuum vacuum;
    const SLDMaterial substrate("substrate", MaterialColor(0.3, 0.3, 0.3), 0.0, 0.0);

    const Crystal2D structure(particle, SquareLattice2D(10.0, 0.0));
    Layer top(vacuum);
    top.suspend2D(structure);
    Layer bottom(substrate);

    Sample sample;
    sample.addLayer(top);
    sample.addLayer(bottom);
    return buildScene3D(sample, 50.0, 17);
}

//! Builds a deposited 2D scene for inspecting structure-specific layout positions.
SceneDescription sceneForStructure(const Struct2D& structure, double half_extent, unsigned seed)
{
    const Vacuum vacuum;
    const SLDMaterial substrate("substrate", MaterialColor(0.3, 0.3, 0.3), 0.0, 0.0);

    Layer top(vacuum);
    top.deposit2D(structure);
    Layer bottom(substrate);

    Sample sample;
    sample.addLayer(top);
    sample.addLayer(bottom);
    return buildScene3D(sample, half_extent, seed);
}

//! Builds a Dense2D hard-disk scene with enough density to expose visual overlaps.
SceneDescription dense2DScene(double density, double half_extent)
{
    const Vacuum vacuum;
    const SLDMaterial material("particle", MaterialColor(0.5, 0.5, 0.5), 1.0, 0.0);
    const SLDMaterial substrate("substrate", MaterialColor(0.3, 0.3, 0.3), 0.0, 0.0);

    const Particle particle(material, Cylinder(dense2d_particle_radius, dense2d_particle_height));
    const Dense2D structure(density, particle);
    Layer top(vacuum);
    top.deposit2D(structure);
    Layer bottom(substrate);

    Sample sample;
    sample.addLayer(top);
    sample.addLayer(bottom);
    return buildScene3D(sample, half_extent, 0);
}

//! Builds a scene with one dilute population split into many sub-density bins.
SceneDescription splitDilute2DScene(size_t n_bins, double expected_count_per_bin,
                                    double half_extent)
{
    const Vacuum vacuum;
    const SLDMaterial material("particle", MaterialColor(0.5, 0.5, 0.5), 1.0, 0.0);
    const SLDMaterial substrate("substrate", MaterialColor(0.3, 0.3, 0.3), 0.0, 0.0);
    const double patch_area = 4.0 * half_extent * half_extent;

    Layer top(vacuum);
    for (size_t i = 0; i < n_bins; ++i) {
        const Particle particle(material, Cylinder(1.0 + static_cast<double>(i) * 0.01, 1.0));
        const Dilute2D structure(expected_count_per_bin / patch_area, particle);
        top.deposit2D(structure);
    }
    Layer bottom(substrate);

    Sample sample;
    sample.addLayer(top);
    sample.addLayer(bottom);
    return buildScene3D(sample, half_extent, 0);
}

//! Returns the only exported structure and asserts that no extra structs were produced.
const StructData& onlyStruct(const SceneDescription& scene)
{
    EXPECT_EQ(scene.structs.size(), 1u);
    return scene.structs.front();
}

//! Finds an exported particle template by material name.
const ParticleData* findParticle(const StructData& sd, const std::string& material)
{
    for (const ParticleData& pd : sd.particles)
        if (pd.material == material)
            return &pd;
    return nullptr;
}

//! Compares normalized Mixture weights without hiding exact-value regressions.
void expectWeights(const std::vector<double>& actual, const std::vector<double>& expected)
{
    ASSERT_EQ(actual.size(), expected.size());
    for (size_t i = 0; i < expected.size(); ++i)
        EXPECT_DOUBLE_EQ(actual[i], expected[i]);
}

//! Checks the Mixture activation path carried by one exported particle leaf.
void expectPath(const ParticleData& pd, const std::vector<int>& groups,
                const std::vector<int>& variants)
{
    EXPECT_EQ(pd.mixture_groups, groups);
    EXPECT_EQ(pd.mixture_variants, variants);
}

//! Verifies that no exported layout positions are closer than min_distance.
void expectNoPositionOverlap(const StructData& sd, double min_distance)
{
    ASSERT_EQ(sd.pos_x.size(), sd.pos_y.size());
    const double min_distance2 = min_distance * min_distance;
    for (size_t i = 0; i < sd.pos_x.size(); ++i) {
        for (size_t j = i + 1; j < sd.pos_x.size(); ++j) {
            const double dx = sd.pos_x[i] - sd.pos_x[j];
            const double dy = sd.pos_y[i] - sd.pos_y[j];
            EXPECT_GE(dx * dx + dy * dy, min_distance2);
        }
    }
}

//! Returns the distance to the nearest node of a lattice with basis vectors a1/a2.
double nearestLatticeDistance(double x, double y, double a1x, double a1y, double a2x, double a2y)
{
    const double det = a1x * a2y - a1y * a2x;
    const double i_center = (x * a2y - y * a2x) / det;
    const double j_center = (a1x * y - a1y * x) / det;

    double result = std::numeric_limits<double>::infinity();
    for (int i = static_cast<int>(std::floor(i_center)) - 1;
         i <= static_cast<int>(std::ceil(i_center)) + 1; ++i) {
        for (int j = static_cast<int>(std::floor(j_center)) - 1;
             j <= static_cast<int>(std::ceil(j_center)) + 1; ++j) {
            const double dx = x - (i * a1x + j * a2x);
            const double dy = y - (i * a1y + j * a2y);
            result = std::min(result, std::hypot(dx, dy));
        }
    }
    return result;
}

//! Returns the largest displacement from the nearest node of a square reference lattice.
double maxSquareLatticeOffset(const StructData& sd, double length)
{
    double result = 0.0;
    for (size_t i = 0; i < sd.pos_x.size(); ++i)
        result = std::max(
            result, nearestLatticeDistance(sd.pos_x[i], sd.pos_y[i], length, 0.0, 0.0, length));
    return result;
}

//! Returns the largest displacement from the nearest node of a hexagonal reference lattice.
double maxHexagonalLatticeOffset(const StructData& sd, double length)
{
    double result = 0.0;
    const double a2x = 0.5 * length;
    const double a2y = 0.5 * std::sqrt(3.0) * length;
    for (size_t i = 0; i < sd.pos_x.size(); ++i)
        result = std::max(result,
                          nearestLatticeDistance(sd.pos_x[i], sd.pos_y[i], length, 0.0, a2x, a2y));
    return result;
}

//! Returns the smallest exported distance to the origin.
double minDistanceToOrigin(const StructData& sd)
{
    double result = std::numeric_limits<double>::infinity();
    for (size_t i = 0; i < sd.pos_x.size(); ++i)
        result = std::min(result, std::hypot(sd.pos_x[i], sd.pos_y[i]));
    return result;
}

//! Computes the RMS shift between two coordinate arrays with identical layout ordering.
double rmsShift(const StructData& shifted, const StructData& reference)
{
    if (shifted.pos_x.size() != reference.pos_x.size()
        || shifted.pos_y.size() != reference.pos_y.size())
        return std::numeric_limits<double>::quiet_NaN();

    double sum2 = 0.0;
    for (size_t i = 0; i < shifted.pos_x.size(); ++i) {
        const double dx = shifted.pos_x[i] - reference.pos_x[i];
        const double dy = shifted.pos_y[i] - reference.pos_y[i];
        sum2 += dx * dx + dy * dy;
    }
    return std::sqrt(sum2 / static_cast<double>(shifted.pos_x.size()));
}

} // namespace

TEST(SceneGeometry, Paracrystal2DPositionsAccumulateBondDisorder)
{
    // A Paracrystal2D is an ICrystal2D subclass, but the viewer must use accumulated
    // paracrystal bond errors rather than the generic ideal-crystal lattice path.
    const Particle particle = makeParticle("particle", 1.0);
    Paracrystal2D structure(particle, SquareLattice2D(10.0, 0.0), 1000.0, 100.0, 100.0);
    const Profile2DGauss pdf(3.0, 3.0, 0.0);
    structure.setProbabilityDistributions(pdf, pdf);

    const SceneDescription scene = sceneForStructure(structure, 40.0, 23);
    const StructData& sd = onlyStruct(scene);

    ASSERT_EQ(sd.type, "Paracrystal2D");
    ASSERT_GT(sd.pos_x.size(), 1u);
    EXPECT_LT(minDistanceToOrigin(sd), 1e-12);
    EXPECT_GT(maxSquareLatticeOffset(sd, 10.0), 0.5);
}

TEST(SceneGeometry, RadialParacrystalPositionsUseHexagonalRepresentativeDomain)
{
    // The radial model is orientation-averaged for scattering; the viewer shows one
    // representative hexagonal paracrystal domain with the same accumulated disorder path.
    const Particle particle = makeParticle("particle", 1.0);
    RadialParacrystal structure(particle, 20.0, 1000.0);
    const Profile1DGauss pdf(4.0);
    structure.setProbabilityDistribution(pdf);

    const SceneDescription scene = sceneForStructure(structure, 70.0, 31);
    const StructData& sd = onlyStruct(scene);

    ASSERT_EQ(sd.type, "RadialParacrystal");
    ASSERT_GT(sd.pos_x.size(), 1u);
    EXPECT_LT(minDistanceToOrigin(sd), 1e-12);
    EXPECT_GT(maxHexagonalLatticeOffset(sd, 20.0), 0.5);
}

TEST(SceneGeometry, Paracrystal2DDomainSizesClipVisibleDomain)
{
    // Paracrystal domain sizes are finite coherent widths; the viewer clips the represented
    // lattice domain even when the display patch is larger.
    const Particle particle = makeParticle("particle", 1.0);
    const Paracrystal2D structure(particle, SquareLattice2D(10.0, 0.0), 0.0, 30.0, 50.0);

    const SceneDescription scene = sceneForStructure(structure, 100.0, 17);
    const StructData& sd = onlyStruct(scene);

    ASSERT_EQ(sd.type, "Paracrystal2D");
    ASSERT_EQ(sd.pos_x.size(), 15u);
    for (size_t i = 0; i < sd.pos_x.size(); ++i) {
        EXPECT_LE(std::abs(sd.pos_x[i]), 15.0);
        EXPECT_LE(std::abs(sd.pos_y[i]), 25.0);
    }
}

TEST(SceneGeometry, Paracrystal2DDampingLengthCanBeShortestVisibleLimit)
{
    // damping_length is another finite-coherence length for paracrystals; visually the shortest
    // active length wins over larger domain sizes and the display patch.
    const Particle particle = makeParticle("particle", 1.0);
    const Paracrystal2D structure(particle, SquareLattice2D(10.0, 0.0), 30.0, 200.0, 200.0);

    const SceneDescription scene = sceneForStructure(structure, 100.0, 17);
    const StructData& sd = onlyStruct(scene);

    ASSERT_EQ(sd.type, "Paracrystal2D");
    ASSERT_EQ(sd.pos_x.size(), 9u);
    for (size_t i = 0; i < sd.pos_x.size(); ++i) {
        EXPECT_LE(std::abs(sd.pos_x[i]), 15.0);
        EXPECT_LE(std::abs(sd.pos_y[i]), 15.0);
    }
}

TEST(SceneGeometry, RadialParacrystalDomainSizeClipsRepresentativeDomain)
{
    // The radial model is shown as one hexagonal representative domain, clipped by domain size.
    const Particle particle = makeParticle("particle", 1.0);
    RadialParacrystal structure(particle, 10.0, 0.0);
    structure.setDomainSize(40.0);

    const SceneDescription scene = sceneForStructure(structure, 100.0, 17);
    const StructData& sd = onlyStruct(scene);

    ASSERT_EQ(sd.type, "RadialParacrystal");
    ASSERT_EQ(sd.pos_x.size(), 19u);
    for (size_t i = 0; i < sd.pos_x.size(); ++i)
        EXPECT_LE(std::hypot(sd.pos_x[i], sd.pos_y[i]), 20.0 + 1e-12);
}

TEST(SceneGeometry, RadialParacrystalDampingLengthCanBeShortestVisibleLimit)
{
    // Radial domain size and damping length both bound the coherent representative domain.
    const Particle particle = makeParticle("particle", 1.0);
    RadialParacrystal structure(particle, 10.0, 40.0);
    structure.setDomainSize(100.0);

    const SceneDescription scene = sceneForStructure(structure, 100.0, 17);
    const StructData& sd = onlyStruct(scene);

    ASSERT_EQ(sd.type, "RadialParacrystal");
    ASSERT_EQ(sd.pos_x.size(), 19u);
    for (size_t i = 0; i < sd.pos_x.size(); ++i)
        EXPECT_LE(std::hypot(sd.pos_x[i], sd.pos_y[i]), 20.0 + 1e-12);
}

TEST(SceneGeometry, Crystal2DCoherenceProfileClipsVisibleLattice)
{
    // Crystal2D decay profiles define the finite coherent region shown around the origin.
    const Particle particle = makeParticle("particle", 1.0);
    Crystal2D structure(particle, SquareLattice2D(10.0, 0.0));
    const Profile2DGauss decay(15.0, 15.0, 0.0);
    structure.setDecayFunction(decay);

    const SceneDescription scene = sceneForStructure(structure, 50.0, 17);
    const StructData& sd = onlyStruct(scene);

    ASSERT_EQ(sd.type, "Crystal2D");
    ASSERT_EQ(sd.pos_x.size(), 9u);
    for (size_t i = 0; i < sd.pos_x.size(); ++i) {
        const double radius2 = sd.pos_x[i] * sd.pos_x[i] + sd.pos_y[i] * sd.pos_y[i];
        EXPECT_LE(radius2, 15.0 * 15.0);
    }
}

TEST(SceneGeometry, FiniteCrystal2DUsesExactUnitCellCounts)
{
    // A finite 2D crystal is bounded by its N1 x N2 lattice size before the display patch clips it.
    const Particle particle = makeParticle("particle", 1.0);
    const FiniteCrystal2D structure(particle, SquareLattice2D(10.0, 0.0), 4, 3);

    const SceneDescription scene = sceneForStructure(structure, 100.0, 17);
    const StructData& sd = onlyStruct(scene);

    ASSERT_EQ(sd.type, "FiniteCrystal2D");
    EXPECT_EQ(sd.pos_x.size(), 12u);
}

TEST(SceneGeometry, Crystal1DCoherenceProfileClipsVisibleLine)
{
    // Crystal1D decay profiles limit the visible line to the coherent interval.
    const Particle particle = makeParticle("particle", 1.0);
    Crystal1D structure(particle, 10.0, 0.0, 1.0);
    const Profile1DGauss decay(15.0);
    structure.setDecayFunction(decay);

    const SceneDescription scene = sceneForStructure(structure, 50.0, 17);
    const StructData& sd = onlyStruct(scene);

    ASSERT_EQ(sd.type, "Crystal1D");
    ASSERT_EQ(sd.pos_x.size(), 3u);
    EXPECT_DOUBLE_EQ(sd.pos_x[0], -10.0);
    EXPECT_DOUBLE_EQ(sd.pos_x[1], 0.0);
    EXPECT_DOUBLE_EQ(sd.pos_x[2], 10.0);
    for (double y : sd.pos_y)
        EXPECT_DOUBLE_EQ(y, 0.0);
}

TEST(SceneGeometry, LongRangeCrystalDisorderUsesConfiguredRmsShift)
{
    // lateralPositionVariance() is the RMS displacement used in the Debye-Waller factor; the
    // viewer must apply it directly, not as a variance that still needs a square root.
    const Particle particle = makeParticle("particle", 1.0);
    const Crystal2D reference(particle, SquareLattice2D(10.0, 0.0));
    Crystal2D shifted(particle, SquareLattice2D(10.0, 0.0));
    shifted.setLateralPositionVariance(4.0);

    const SceneDescription reference_scene = sceneForStructure(reference, 50.0, 19);
    const SceneDescription shifted_scene = sceneForStructure(shifted, 50.0, 19);
    const StructData& reference_sd = onlyStruct(reference_scene);
    const StructData& shifted_sd = onlyStruct(shifted_scene);

    ASSERT_EQ(shifted_sd.type, "Crystal2D");
    ASSERT_EQ(shifted_sd.pos_x.size(), reference_sd.pos_x.size());
    EXPECT_GT(rmsShift(shifted_sd, reference_sd), 4.0);
}

TEST(SceneGeometry, SplitDilute2DFractionalBinsAreNotDropped)
{
    // Polydisperse examples often split one dilute population into many radius bins. Each bin
    // may have less than one expected particle in the viewer patch; flooring each bin separately
    // would erase the whole population.
    constexpr size_t n_bins = 25;
    const SceneDescription scene = splitDilute2DScene(n_bins, 0.6, 10.0);

    ASSERT_EQ(scene.structs.size(), n_bins);
    size_t total_positions = 0;
    size_t occupied_bins = 0;
    for (const StructData& sd : scene.structs) {
        ASSERT_EQ(sd.type, "Dilute2D");
        ASSERT_LE(sd.pos_x.size(), 1u);
        total_positions += sd.pos_x.size();
        if (!sd.pos_x.empty())
            ++occupied_bins;
    }
    EXPECT_EQ(total_positions, 12u);
    EXPECT_EQ(total_positions, occupied_bins);
}

TEST(SceneGeometry, Dense2DPositionsDoNotOverlap)
{
    // Moderate density exercises the Dense2D hard-disk visual placement.
    const SceneDescription scene = dense2DScene(dense2d_moderate_density, dense2d_view_half_extent);
    const StructData& sd = onlyStruct(scene);

    // The exported viewer positions must keep the cylinder hard cores separated.
    ASSERT_EQ(sd.type, "Dense2D");
    ASSERT_EQ(sd.pos_x.size(), dense2d_moderate_count);
    expectNoPositionOverlap(sd, 2.0 * dense2d_particle_radius);
}

TEST(SceneGeometry, Dense2DZeroDensityExportsNoPositions)
{
    // Zero density means an empty Dense2D layout, not a representative particle.
    const SceneDescription scene = dense2DScene(0.0, dense2d_view_half_extent);
    const StructData& sd = onlyStruct(scene);

    ASSERT_EQ(sd.type, "Dense2D");
    EXPECT_TRUE(sd.pos_x.empty());
    EXPECT_TRUE(sd.pos_y.empty());
    EXPECT_TRUE(sd.pos_z.empty());
}

TEST(SceneGeometry, Dense2DHighPackingPositionsDoNotOverlap)
{
    // High packing is the main overlap-regression case for the Dense2D positioner.
    const SceneDescription scene = dense2DScene(dense2d_high_density, dense2d_view_half_extent);
    const StructData& sd = onlyStruct(scene);

    // The final positions must still preserve the cylinder hard-core distance.
    ASSERT_EQ(sd.type, "Dense2D");
    ASSERT_EQ(sd.pos_x.size(), dense2d_high_count);
    expectNoPositionOverlap(sd, 2.0 * dense2d_particle_radius);
}

TEST(SceneGeometry, Dense2DAboveHexagonalPackingStillBuildsViewerScene)
{
    // Viewer geometry intentionally remains permissive so invalid dense samples can be inspected.
    const SceneDescription scene =
        dense2DScene(dense2d_overpacked_density, dense2d_view_half_extent);
    const StructData& sd = onlyStruct(scene);

    // The same density is rejected for scattering, but the scene export should still show it.
    ASSERT_EQ(sd.type, "Dense2D");
    ASSERT_EQ(sd.pos_x.size(), dense2d_overpacked_count);
}

TEST(SceneGeometry, MixtureExportsActivationPathsAndNormalizedWeights)
{
    const Particle particle_a = makeParticle("A", 1.0);
    const Particle particle_b = makeParticle("B", 2.0);

    Mixture mixture;
    mixture.addParticle(particle_a, 4.0);
    mixture.addParticle(particle_b, 1.0);

    const SceneDescription scene = sceneForParticle(mixture);
    const StructData& sd = onlyStruct(scene);

    ASSERT_EQ(sd.particles.size(), 2u);
    ASSERT_EQ(sd.mixture_weights.size(), 1u);
    expectWeights(sd.mixture_weights[0], {0.8, 0.2});

    const ParticleData* a = findParticle(sd, "A");
    const ParticleData* b = findParticle(sd, "B");
    ASSERT_NE(a, nullptr);
    ASSERT_NE(b, nullptr);
    expectPath(*a, {0}, {0});
    expectPath(*b, {0}, {1});
}

TEST(SceneGeometry, LatticeMixtureBuildsSceneAfterParticleValidation)
{
    const Particle particle_a = makeParticle("A", 1.0);
    const Particle particle_b = makeParticle("B", 2.0);

    Mixture mixture;
    mixture.addParticle(particle_a, 2.0);
    mixture.addParticle(particle_b, 1.0);

    const SceneDescription scene = latticeSceneForParticle(mixture);
    const StructData& sd = onlyStruct(scene);

    ASSERT_EQ(sd.particles.size(), 2u);
    ASSERT_EQ(sd.mixture_weights.size(), 1u);
    expectWeights(sd.mixture_weights[0], {2.0 / 3.0, 1.0 / 3.0});
    EXPECT_GT(sd.pos_x.size(), 1u);
}

TEST(SceneGeometry, NestedMixtureExportsFullActivationPath)
{
    const Particle particle_a = makeParticle("A", 1.0);
    const Particle particle_b = makeParticle("B", 2.0);
    const Particle particle_c = makeParticle("C", 3.0);

    Mixture inner;
    inner.addParticle(particle_b, 2.0);
    inner.addParticle(particle_c, 3.0);

    Mixture outer;
    outer.addParticle(particle_a, 1.0);
    outer.addParticle(inner, 1.0);

    const SceneDescription scene = sceneForParticle(outer);
    const StructData& sd = onlyStruct(scene);

    ASSERT_EQ(sd.particles.size(), 3u);
    ASSERT_EQ(sd.mixture_weights.size(), 2u);
    expectWeights(sd.mixture_weights[0], {0.5, 0.5});
    expectWeights(sd.mixture_weights[1], {0.4, 0.6});

    const ParticleData* a = findParticle(sd, "A");
    const ParticleData* b = findParticle(sd, "B");
    const ParticleData* c = findParticle(sd, "C");
    ASSERT_NE(a, nullptr);
    ASSERT_NE(b, nullptr);
    ASSERT_NE(c, nullptr);
    expectPath(*a, {0}, {0});
    expectPath(*b, {0, 1}, {1, 0});
    expectPath(*c, {0, 1}, {1, 1});
}

TEST(SceneGeometry, NestedMixtureSuspendAlignsLeafAlternativesIndividually)
{
    const Particle particle_a = makeParticle("A", 3.0);
    const Particle particle_b = makeParticle("B", 1.0);
    const Particle particle_c = makeParticle("C", 2.0);

    Mixture inner;
    inner.addParticle(particle_b, 1.0);
    inner.addParticle(particle_c, 1.0);

    Mixture outer;
    outer.addParticle(particle_a, 1.0);
    outer.addParticle(inner, 1.0);

    const SceneDescription scene = suspendedLatticeSceneForParticle(outer);
    const StructData& sd = onlyStruct(scene);

    const ParticleData* a = findParticle(sd, "A");
    const ParticleData* b = findParticle(sd, "B");
    const ParticleData* c = findParticle(sd, "C");
    ASSERT_NE(a, nullptr);
    ASSERT_NE(b, nullptr);
    ASSERT_NE(c, nullptr);

    EXPECT_DOUBLE_EQ(a->pos_z, -6.0);
    EXPECT_DOUBLE_EQ(b->pos_z, -2.0);
    EXPECT_DOUBLE_EQ(c->pos_z, -4.0);
}

TEST(SceneGeometry, CompoundKeepsMixturePathsLocal)
{
    const Particle particle_a = makeParticle("A", 1.0);
    const Particle particle_b = makeParticle("B", 2.0);
    const Particle particle_c = makeParticle("C", 3.0);

    Mixture mixture;
    mixture.addParticle(particle_a, 3.0);
    mixture.addParticle(particle_b, 1.0);

    Compound compound;
    compound.addComponent(mixture);
    compound.addComponent(particle_c);

    const SceneDescription scene = sceneForParticle(compound);
    const StructData& sd = onlyStruct(scene);

    ASSERT_EQ(sd.particles.size(), 3u);
    ASSERT_EQ(sd.mixture_weights.size(), 1u);
    expectWeights(sd.mixture_weights[0], {0.75, 0.25});

    const ParticleData* a = findParticle(sd, "A");
    const ParticleData* b = findParticle(sd, "B");
    const ParticleData* c = findParticle(sd, "C");
    ASSERT_NE(a, nullptr);
    ASSERT_NE(b, nullptr);
    ASSERT_NE(c, nullptr);
    expectPath(*a, {0}, {0});
    expectPath(*b, {0}, {1});
    expectPath(*c, {}, {});
}

TEST(SceneGeometry, MesocrystalMixtureBasisGetsIndependentGroupPerNode)
{
    const Particle particle_a = makeParticle("A", 0.2);
    const Particle particle_b = makeParticle("B", 0.2);

    Mixture basis;
    basis.addParticle(particle_a, 1.0);
    basis.addParticle(particle_b, 3.0);

    const Lattice3D lattice(R3(1.0, 0.0, 0.0), R3(0.0, 1.0, 0.0), R3(0.0, 0.0, 1.0));
    const Crystal crystal(basis, lattice);
    const Mesocrystal mesocrystal(crystal, Sphere(1.2));

    const SceneDescription scene = sceneForParticle(mesocrystal);
    const StructData& sd = onlyStruct(scene);

    ASSERT_GT(sd.mixture_weights.size(), 1u);
    ASSERT_EQ(sd.particles.size(), 2u * sd.mixture_weights.size());
    for (const std::vector<double>& weights : sd.mixture_weights)
        expectWeights(weights, {0.25, 0.75});

    std::vector<int> counts(sd.mixture_weights.size(), 0);
    for (const ParticleData& pd : sd.particles) {
        ASSERT_EQ(pd.mixture_groups.size(), 1u);
        ASSERT_EQ(pd.mixture_variants.size(), 1u);
        ASSERT_GE(pd.mixture_groups[0], 0);
        ASSERT_LT(static_cast<size_t>(pd.mixture_groups[0]), sd.mixture_weights.size());
        ASSERT_TRUE(pd.mixture_variants[0] == 0 || pd.mixture_variants[0] == 1);
        ++counts[pd.mixture_groups[0]];
    }
    for (const int count : counts)
        EXPECT_EQ(count, 2);
}
