//  ************************************************************************************************
//
//  BornAgain: simulate and fit reflection and scattering
//
//! @file      Sim/Export/ScenePositionBuilder.cpp
//! @brief     Generates StructData layout coordinates for structures in the 3D scene.
//!
//! @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/ScenePositionBuilder.h"
#include "Base/Util/Assert.h"
#include "Sample/Aggregate/Crystal1D.h"
#include "Sample/Aggregate/Crystal2D.h"
#include "Sample/Aggregate/Dense2D.h"
#include "Sample/Aggregate/FiniteCrystal2D.h"
#include "Sample/Aggregate/ICrystal2D.h"
#include "Sample/Aggregate/LongRangeOrdered2D.h"
#include "Sample/Aggregate/Paracrystal2D.h"
#include "Sample/Aggregate/RadialParacrystal.h"
#include "Sample/Aggregate/Struct.h"
#include "Sample/Aggregate/Struct2D.h"
#include "Sample/Aggregate/Struct3D.h"
#include "Sample/Correlation/Profiles1D.h"
#include "Sample/Correlation/Profiles2D.h"
#include "Sample/Lattice/Lattice2D.h"
#include "Sample/Particle/IParticle.h"
#include "Sim/Export/SceneGeometry.h"
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <limits>
#include <numbers>
#include <random>
#include <stdexcept>
#include <utility>

namespace {

// ---- Position generation ----

//! Integer range of lattice coordinates needed to cover the visible patch.
struct LatticeIndexRange {
    int i_min, i_max;
    int j_min, j_max;
};

//! 2D point used by position generators before data are copied to StructData arrays.
struct Point2D {
    double x = 0.0;
    double y = 0.0;
};

//! Cartesian basis vectors of a 2D lattice in viewer coordinates.
struct LatticeBasis2D {
    double a1x, a1y;
    double a2x, a2y;
};

//! Lattice site with integer coordinates; paracrystals use the indices for accumulated disorder.
struct LatticeSite {
    int i = 0;
    int j = 0;
    double x = 0.0;
    double y = 0.0;
};

//! Optional finite coherent domain for paracrystal viewer positions.
struct ParacrystalDomain {
    enum class Shape { unlimited, lattice, radial };

    Shape shape = Shape::unlimited;
    double half_size1 = std::numeric_limits<double>::infinity(); //!< Lattice axis 1; radial radius.
    double half_size2 =
        std::numeric_limits<double>::infinity(); //!< Lattice axis 2; ignored otherwise.
};

LatticeBasis2D latticeBasis(const Lattice2D& lat)
{
    const double l1 = lat.length1();
    const double l2 = lat.length2();
    const double angle = lat.latticeAngle();
    const double xi = lat.rotationAngle();
    return {l1 * std::cos(xi), l1 * std::sin(xi), l2 * std::cos(xi + angle),
            l2 * std::sin(xi + angle)};
}

LatticeIndexRange latticeIndexRange(const LatticeBasis2D& basis, double extent)
{
    const double det = basis.a1x * basis.a2y - basis.a1y * basis.a2x;
    ASSERT(std::abs(det) > 0);

    double i_min = 0;
    double i_max = 0;
    double j_min = 0;
    double j_max = 0;

    const auto include = [&](double x, double y, bool first) {
        const double i = (x * basis.a2y - y * basis.a2x) / det;
        const double j = (basis.a1x * y - basis.a1y * x) / det;
        if (first) {
            i_min = i_max = i;
            j_min = j_max = j;
        } else {
            i_min = std::min(i_min, i);
            i_max = std::max(i_max, i);
            j_min = std::min(j_min, j);
            j_max = std::max(j_max, j);
        }
    };

    include(-extent, -extent, true);
    include(-extent, extent, false);
    include(extent, -extent, false);
    include(extent, extent, false);

    return {static_cast<int>(std::floor(i_min)) - 1, static_cast<int>(std::ceil(i_max)) + 1,
            static_cast<int>(std::floor(j_min)) - 1, static_cast<int>(std::ceil(j_max)) + 1};
}

//! Returns lattice candidates near the visible patch, retaining their integer lattice indices.
std::vector<LatticeSite> latticeSites(const Lattice2D& lat, double half_extent,
                                      double candidate_margin)
{
    const LatticeBasis2D basis = latticeBasis(lat);
    const double candidate_extent = half_extent + candidate_margin;
    const LatticeIndexRange range = latticeIndexRange(basis, candidate_extent);

    std::vector<LatticeSite> result;
    for (int i = range.i_min; i <= range.i_max; ++i) {
        for (int j = range.j_min; j <= range.j_max; ++j) {
            const double x = i * basis.a1x + j * basis.a2x;
            const double y = i * basis.a1y + j * basis.a2y;
            if (std::abs(x) <= candidate_extent && std::abs(y) <= candidate_extent)
                result.push_back({i, j, x, y});
        }
    }
    return result;
}

//! Fills pos_x/pos_y with lattice candidates near the visible patch.
void generateLatticePositions(const Lattice2D& lat, double half_extent, double candidate_margin,
                              std::vector<double>& pos_x, std::vector<double>& pos_y)
{
    for (const LatticeSite& site : latticeSites(lat, half_extent, candidate_margin)) {
        pos_x.push_back(site.x);
        pos_y.push_back(site.y);
    }
}

//! Fills pos_x/pos_y with finite N1×N2 lattice candidates near half_extent.
//! Large finite crystals are shown as a representative central patch with the
//! same display density as the infinite-lattice case.
void generateFiniteLatticePositions(const Lattice2D& lat, unsigned N1, unsigned N2,
                                    double half_extent, double candidate_margin,
                                    std::vector<double>& pos_x, std::vector<double>& pos_y)
{
    if (N1 == 0 || N2 == 0)
        return;

    const LatticeBasis2D basis = latticeBasis(lat);
    const double candidate_extent = half_extent + candidate_margin;
    const LatticeIndexRange range = latticeIndexRange(basis, candidate_extent);
    const double offset1 = 0.5 * static_cast<double>(N1 - 1);
    const double offset2 = 0.5 * static_cast<double>(N2 - 1);
    const int i_min =
        std::max(0, static_cast<int>(std::ceil(static_cast<double>(range.i_min) + offset1)));
    const int i_max =
        std::min(static_cast<int>(N1) - 1,
                 static_cast<int>(std::floor(static_cast<double>(range.i_max) + offset1)));
    const int j_min =
        std::max(0, static_cast<int>(std::ceil(static_cast<double>(range.j_min) + offset2)));
    const int j_max =
        std::min(static_cast<int>(N2) - 1,
                 static_cast<int>(std::floor(static_cast<double>(range.j_max) + offset2)));
    for (int ui = i_min; ui <= i_max; ++ui) {
        for (int uj = j_min; uj <= j_max; ++uj) {
            const double i = static_cast<double>(ui) - offset1;
            const double j = static_cast<double>(uj) - offset2;
            const double x = i * basis.a1x + j * basis.a2x;
            const double y = i * basis.a1y + j * basis.a2y;
            if (std::abs(x) <= candidate_extent && std::abs(y) <= candidate_extent) {
                pos_x.push_back(x);
                pos_y.push_back(y);
            }
        }
    }
}

//! Fills pos_x/pos_y with 1D lattice candidates near the visible patch.
void generate1DPositions(double length, double xi, double half_extent, double candidate_margin,
                         std::vector<double>& pos_x, std::vector<double>& pos_y)
{
    const double ux = std::cos(xi);
    const double uy = std::sin(xi);
    const double candidate_extent = half_extent + candidate_margin;
    const int h = static_cast<int>(
                      std::ceil(candidate_extent / length / std::max(std::abs(ux), std::abs(uy))))
                  + 1;
    for (int i = -h; i <= h; ++i) {
        const double x = i * length * ux;
        const double y = i * length * uy;
        if (std::abs(x) <= candidate_extent && std::abs(y) <= candidate_extent) {
            pos_x.push_back(x);
            pos_y.push_back(y);
        }
    }
}

//! Mixes a base viewer seed with stable stream/index tags into an independent deterministic seed.
template <typename... Tags> std::uint32_t mixSeed(unsigned seed, Tags... tags)
{
    const std::array<std::uint32_t, sizeof...(Tags) + 1> values{
        static_cast<std::uint32_t>(seed), static_cast<std::uint32_t>(tags)...};

    std::uint32_t result = 0;
    std::seed_seq seq(values.begin(), values.end());
    seq.generate(&result, &result + 1);
    return result;
}

//! Stable per-bond seed for deterministic visual disorder independent of traversal order.
int disorderSeed(unsigned seed, int axis, int i, int j)
{
    // Signed lattice indices are intentionally folded into the seed modulo 2^32. This keeps
    // negative and positive bond coordinates deterministic without adding traversal-order state.
    const std::uint32_t value = mixSeed(seed, axis, i, j);
    return static_cast<int>(value % static_cast<std::uint32_t>(std::numeric_limits<int>::max()));
}

//! Rotates a sample from a profile's principal axes into viewer coordinates.
Point2D rotateProfileSample(double x, double y, double gamma, double delta)
{
    return {x * std::cos(gamma) + y * std::cos(gamma + delta),
            x * std::sin(gamma) + y * std::sin(gamma + delta)};
}

//! Samples one paracrystal bond error in a 2D profile's principal axes.
Point2D bondOffset(const Distribution2DSampler* sampler, const Profile2D* pdf, unsigned seed,
                   int axis, int i, int j, double axis_angle)
{
    if (!sampler || !pdf)
        return {};

    const auto sample = sampler->randomSample(disorderSeed(seed, axis, i, j));
    return rotateProfileSample(sample.first, sample.second, axis_angle + pdf->gamma(),
                               pdf->delta());
}

//! Samples one scalar radial-paracrystal bond error along one representative lattice direction.
Point2D bondOffset(const Distribution1DSampler* sampler, unsigned seed, int axis, int i, int j,
                   double axis_angle)
{
    if (!sampler)
        return {};

    const double sample = sampler->randomSample(disorderSeed(seed, axis, i, j));
    return {sample * std::cos(axis_angle), sample * std::sin(axis_angle)};
}

double profileScale(const Profile2D* pdf)
{
    // Profile omega values have distribution-specific meanings (sigma, half-width, HWHM, ...).
    // Here they only provide a conservative display margin for accumulated bond disorder.
    return pdf ? std::hypot(pdf->omegaX(), pdf->omegaY()) : 0.0;
}

double profileScale(const Profile1D* pdf)
{
    // Profile omega values have distribution-specific meanings (sigma, half-width, HWHM, ...).
    // Here they only provide a conservative display margin for accumulated bond disorder.
    return pdf ? pdf->omega() : 0.0;
}

//! Expected one-sigma random-walk displacement across the visible paracrystal patch.
double paracrystalDisorderMargin(double output_extent, double min_lattice_spacing,
                                 double bond_disorder_scale)
{
    if (min_lattice_spacing <= 0.0 || bond_disorder_scale <= 0.0)
        return 0.0;

    const double steps_to_edge = std::max(1.0, output_extent / min_lattice_spacing);
    return bond_disorder_scale * std::sqrt(steps_to_edge);
}

//! Converts "0 means no limit" model parameters to finite viewer extents.
double finiteLimit(double value)
{
    ASSERT(value >= 0.0);
    return std::isfinite(value) && value > 0.0 ? value : std::numeric_limits<double>::infinity();
}

//! Returns the shortest positive finite length; unbounded inputs do not constrain the viewer.
double shortestFiniteLimit(double a, double b)
{
    return std::min(finiteLimit(a), finiteLimit(b));
}

//! Uses finite paracrystal lengths as full coherent widths, centered on the origin.
ParacrystalDomain latticeParacrystalDomain(double size1, double size2, double damping_length)
{
    const double limit1 = shortestFiniteLimit(size1, damping_length);
    const double limit2 = shortestFiniteLimit(size2, damping_length);
    if (!std::isfinite(limit1) && !std::isfinite(limit2))
        return {};
    return {ParacrystalDomain::Shape::lattice, 0.5 * limit1, 0.5 * limit2};
}

//! Uses finite radial paracrystal lengths as the full coherent-domain diameter.
ParacrystalDomain radialParacrystalDomain(double size, double damping_length)
{
    const double limit = shortestFiniteLimit(size, damping_length);
    if (!std::isfinite(limit))
        return {};
    return {ParacrystalDomain::Shape::radial, 0.5 * limit, std::numeric_limits<double>::infinity()};
}

//! Reduces the generated ideal-index window when a lattice-domain cutoff is known up front.
void applyLatticeDomainToRange(const Lattice2D& lat, const ParacrystalDomain& domain,
                               LatticeIndexRange& range)
{
    if (domain.shape != ParacrystalDomain::Shape::lattice)
        return;

    if (std::isfinite(domain.half_size1)) {
        const int h1 = static_cast<int>(std::floor(domain.half_size1 / lat.length1()));
        range.i_min = std::max(range.i_min, -h1);
        range.i_max = std::min(range.i_max, h1);
    }
    if (std::isfinite(domain.half_size2)) {
        const int h2 = static_cast<int>(std::floor(domain.half_size2 / lat.length2()));
        range.j_min = std::max(range.j_min, -h2);
        range.j_max = std::min(range.j_max, h2);
    }
}

//! Keeps paracrystal centers inside the coherent domain represented in the viewer.
bool inParacrystalDomain(const Lattice2D& lat, const ParacrystalDomain& domain, int i, int j,
                         const Point2D& point)
{
    const auto within_half_size = [](double value, double half_size) {
        const double tolerance = 1e-12 * std::max(1.0, half_size);
        return std::abs(value) <= half_size + tolerance;
    };

    switch (domain.shape) {
    case ParacrystalDomain::Shape::unlimited:
        return true;
    case ParacrystalDomain::Shape::lattice:
        return (!std::isfinite(domain.half_size1)
                || within_half_size(i * lat.length1(), domain.half_size1))
               && (!std::isfinite(domain.half_size2)
                   || within_half_size(j * lat.length2(), domain.half_size2));
    case ParacrystalDomain::Shape::radial:
        return within_half_size(std::hypot(point.x, point.y), domain.half_size1);
    }
    ASSERT_NEVER;
}

template <typename BondOffset>
void generateParacrystalPositions(const Lattice2D& lat, double half_extent, double candidate_margin,
                                  const ParacrystalDomain& domain, double bond_disorder_scale,
                                  BondOffset bond_offset, std::vector<double>& pos_x,
                                  std::vector<double>& pos_y)
{
    const LatticeBasis2D basis = latticeBasis(lat);
    const double angle1 = lat.rotationAngle();
    const double angle2 = angle1 + lat.latticeAngle();
    const double output_extent = half_extent + candidate_margin;
    const double domain_extent = domain.shape == ParacrystalDomain::Shape::radial
                                     ? std::min(output_extent, domain.half_size1)
                                     : output_extent;
    const double index_extent =
        domain_extent
        + paracrystalDisorderMargin(domain_extent, std::min(lat.length1(), lat.length2()),
                                    bond_disorder_scale);
    LatticeIndexRange range = latticeIndexRange(basis, index_extent);
    applyLatticeDomainToRange(lat, domain, range);

    if (range.i_min > range.i_max || range.j_min > range.j_max)
        return;

    const int width = range.i_max - range.i_min + 1;
    const int height = range.j_max - range.j_min + 1;
    ASSERT(width > 0 && height > 0);

    std::vector<Point2D> points(static_cast<size_t>(width * height));
    std::vector<bool> ready(static_cast<size_t>(width * height), false);

    const auto contains = [&](int i, int j) {
        return range.i_min <= i && i <= range.i_max && range.j_min <= j && j <= range.j_max;
    };
    const auto index = [&](int i, int j) {
        return static_cast<size_t>((i - range.i_min) * height + (j - range.j_min));
    };
    const auto axis_step = [&](int sign, double ax, double ay, const Point2D& offset) {
        return Point2D{sign * ax + offset.x, sign * ay + offset.y};
    };

    const int max_distance = std::max(std::abs(range.i_min), std::abs(range.i_max))
                             + std::max(std::abs(range.j_min), std::abs(range.j_max));

    // Restores the retired GUI viewer's visual intent: extend from already-built neighbours so
    // bond errors accumulate with distance. Interior sites average the two lattice-axis paths,
    // which preserves local continuity but is not byte-identical to the old one-axis walk.
    for (int distance = 0; distance <= max_distance; ++distance) {
        for (int i = range.i_min; i <= range.i_max; ++i) {
            for (int j = range.j_min; j <= range.j_max; ++j) {
                if (std::abs(i) + std::abs(j) != distance)
                    continue;

                const size_t idx = index(i, j);
                if (i == 0 && j == 0) {
                    ready[idx] = true;
                    continue;
                }

                Point2D sum;
                int paths = 0;
                if (i != 0) {
                    const int sign = i > 0 ? 1 : -1;
                    const int prev_i = i - sign;
                    if (contains(prev_i, j) && ready[index(prev_i, j)]) {
                        const Point2D prev = points[index(prev_i, j)];
                        const Point2D step =
                            axis_step(sign, basis.a1x, basis.a1y, bond_offset(0, i, j, angle1));
                        sum.x += prev.x + step.x;
                        sum.y += prev.y + step.y;
                        ++paths;
                    }
                }
                if (j != 0) {
                    const int sign = j > 0 ? 1 : -1;
                    const int prev_j = j - sign;
                    if (contains(i, prev_j) && ready[index(i, prev_j)]) {
                        const Point2D prev = points[index(i, prev_j)];
                        const Point2D step =
                            axis_step(sign, basis.a2x, basis.a2y, bond_offset(1, i, j, angle2));
                        sum.x += prev.x + step.x;
                        sum.y += prev.y + step.y;
                        ++paths;
                    }
                }

                ASSERT(paths > 0);
                points[idx] = {sum.x / paths, sum.y / paths};
                ready[idx] = true;
            }
        }
    }

    for (int i = range.i_min; i <= range.i_max; ++i) {
        for (int j = range.j_min; j <= range.j_max; ++j) {
            const Point2D point = points[index(i, j)];
            if (std::abs(point.x) <= output_extent && std::abs(point.y) <= output_extent
                && inParacrystalDomain(lat, domain, i, j, point)) {
                pos_x.push_back(point.x);
                pos_y.push_back(point.y);
            }
        }
    }
}

//! Fills pos_x/pos_y with a 2D paracrystal: bond errors accumulate with lattice distance.
void generateParacrystalPositions(const Lattice2D& lat, const Profile2D* pdf1,
                                  const Profile2D* pdf2, const ParacrystalDomain& domain,
                                  double half_extent, double candidate_margin, unsigned seed,
                                  std::vector<double>& pos_x, std::vector<double>& pos_y)
{
    const auto sampler1 = pdf1 ? pdf1->createSampler() : nullptr;
    const auto sampler2 = pdf2 ? pdf2->createSampler() : nullptr;
    const double disorder_scale = std::max(profileScale(pdf1), profileScale(pdf2));
    generateParacrystalPositions(
        lat, half_extent, candidate_margin, domain, disorder_scale,
        [&](int axis, int i, int j, double axis_angle) {
            return axis == 0 ? bondOffset(sampler1.get(), pdf1, seed, axis, i, j, axis_angle)
                             : bondOffset(sampler2.get(), pdf2, seed, axis, i, j, axis_angle);
        },
        pos_x, pos_y);
}

//! Fills pos_x/pos_y with a representative hexagonal radial paracrystal domain.
void generateParacrystalPositions(const Lattice2D& lat, const Profile1D* pdf, double half_extent,
                                  double candidate_margin, const ParacrystalDomain& domain,
                                  unsigned seed, std::vector<double>& pos_x,
                                  std::vector<double>& pos_y)
{
    const auto sampler = pdf ? pdf->createSampler() : nullptr;
    generateParacrystalPositions(
        lat, half_extent, candidate_margin, domain, profileScale(pdf),
        [&](int axis, int i, int j, double axis_angle) {
            return bondOffset(sampler.get(), seed, axis, i, j, axis_angle);
        },
        pos_x, pos_y);
}

template <typename Predicate>
void erasePositionsIf(std::vector<double>& pos_x, std::vector<double>& pos_y, Predicate drop)
{
    size_t out = 0;
    for (size_t in = 0; in < pos_x.size(); ++in) {
        if (drop(pos_x[in], pos_y[in]))
            continue;
        pos_x[out] = pos_x[in];
        pos_y[out] = pos_y[in];
        ++out;
    }
    pos_x.resize(out);
    pos_y.resize(out);
}

//! Restricts a 1D crystal display to its finite coherence length, if a decay profile is set.
void filterCrystal1DCoherence(const Crystal1D* crystal, std::vector<double>& pos_x,
                              std::vector<double>& pos_y)
{
    const double length = crystal->decayLength();
    if (!std::isfinite(length))
        return;

    const double ux = std::cos(crystal->xi());
    const double uy = std::sin(crystal->xi());
    erasePositionsIf(pos_x, pos_y,
                     [&](double x, double y) { return std::abs(x * ux + y * uy) > length; });
}

//! Restricts a 2D crystal display to the coherence ellipse defined by its decay profile.
void filterCrystal2DCoherence(const Crystal2D* crystal, std::vector<double>& pos_x,
                              std::vector<double>& pos_y)
{
    const double omega_x = crystal->decayLengthX();
    const double omega_y = crystal->decayLengthY();
    if (!std::isfinite(omega_x) && !std::isfinite(omega_y))
        return;

    const double angle = crystal->lattice().rotationAngle() + crystal->decayGamma();
    const double cos_a = std::cos(angle);
    const double sin_a = std::sin(angle);
    erasePositionsIf(pos_x, pos_y, [&](double x, double y) {
        const double X = x * cos_a + y * sin_a;
        const double Y = -x * sin_a + y * cos_a;
        const double rx =
            std::isfinite(omega_x) && omega_x > 0.0 ? X * X / (omega_x * omega_x) : 0.0;
        const double ry =
            std::isfinite(omega_y) && omega_y > 0.0 ? Y * Y / (omega_y * omega_y) : 0.0;
        return rx + ry > 1.0;
    });
}

//! Applies non-accumulating positional disorder to long-range ordered crystals.
void addIndependentLateralDisorder(double rms_displacement, unsigned seed,
                                   std::vector<double>& pos_x, std::vector<double>& pos_y)
{
    if (rms_displacement <= 0.0)
        return;

    std::mt19937 rng(seed);
    // Despite the API name, lateralPositionVariance() is the RMS displacement used by DWfactor().
    std::normal_distribution<double> normal(0.0, rms_displacement);
    for (size_t i = 0; i < pos_x.size(); ++i) {
        pos_x[i] += normal(rng);
        pos_y[i] += normal(rng);
    }
}

// Add new stream values here; do not reuse values for different viewer RNG decisions.
enum class RandomStream : std::uint32_t { count = 1 };

//! Derives a deterministic sub-seed so independent viewer choices do not reuse one RNG stream.
unsigned seedForRandomStream(unsigned seed, RandomStream stream)
{
    return mixSeed(seed, stream);
}

//! Draws a deterministic half-open [0, 1) value from the standardized mt19937 engine.
double unitDraw(std::mt19937& rng)
{
    constexpr double scale = 1.0 / (static_cast<double>(std::mt19937::max()) + 1.0);
    return static_cast<double>(rng()) * scale;
}

//! Converts an expected particle count to an integer without dropping fractional densities.
size_t stochasticRoundedCount(double expected_count, unsigned seed)
{
    if (expected_count <= 0.0)
        return 0;
    if (expected_count >= static_cast<double>(std::numeric_limits<size_t>::max()))
        return std::numeric_limits<size_t>::max();

    double integral_part = 0.0;
    const double fractional_part = std::modf(expected_count, &integral_part);
    size_t result = static_cast<size_t>(integral_part);
    if (fractional_part <= 0.0)
        return result;

    // Polydisperse examples may represent one physical dilute population as many low-density
    // structures. Stochastic rounding preserves the expected total instead of flooring every
    // fractional bin to zero.
    std::mt19937 rng(seed);
    const double draw = unitDraw(rng);
    if (draw < fractional_part)
        ++result;
    return result;
}

//! Fills pos_x/pos_y with random positions for a density-based (disordered) struct.
void generateDilutePositions(double density, double half_extent, unsigned seed,
                             std::vector<double>& pos_x, std::vector<double>& pos_y)
{
    const double area = 4.0 * half_extent * half_extent;
    const size_t n =
        stochasticRoundedCount(density * area, seedForRandomStream(seed, RandomStream::count));

    // Position stream uses the original seed; the independent count stream was derived above.
    std::mt19937 rng(seed);
    std::uniform_real_distribution<double> dist(-half_extent, half_extent);
    for (size_t i = 0; i < n; ++i) {
        pos_x.push_back(dist(rng));
        pos_y.push_back(dist(rng));
    }
}

//! Wraps one coordinate into the periodic interval [-half_extent, half_extent).
double wrapPeriodic(double x, double half_extent)
{
    const double side = 2.0 * half_extent;
    return x - side * std::floor((x + half_extent) / side);
}

//! Returns the particle count for given Dense2D and display patch area.
size_t dense2DCount(const Dense2D* dense, double half_extent)
{
    const double area = 4.0 * half_extent * half_extent;
    return static_cast<size_t>(dense->areaNumberDensity() * area);
}

//! Returns the hard-core radius used by Dense2D/Percus-Yevick visualization.
double dense2DRadius(const Dense2D* dense)
{
    const IParticle* particle = dense->particle();
    ASSERT(particle);
    const double radius = particle->radius();
    ASSERT(radius > 0.0);
    return radius;
}

//! Computes Dense2D hard-disk viewer positions by MD relaxation with periodic boundaries.
void generateDense2DPositions(const Dense2D* dense, double half_extent, unsigned seed,
                              std::vector<double>& pos_x, std::vector<double>& pos_y)
{
    // Keep one representative particle for nonzero tiny densities; zero density stays empty.
    const size_t n = dense->areaNumberDensity() > 0.0
                         ? std::max<size_t>(1, dense2DCount(dense, half_extent))
                         : 0;
    if (n == 0)
        return;
    const double sigma = 2.0 * dense2DRadius(dense);
    const double side = 2.0 * half_extent;
    const size_t grid_side = static_cast<size_t>(std::ceil(std::sqrt(static_cast<double>(n))));
    const double spacing = side / static_cast<double>(grid_side);

    std::vector<Point2D> pos(n), vel(n), acc(n);
    size_t idx = 0;

    // Start from a regular square grid before MD equilibration.
    for (size_t ix = 0; ix < grid_side && idx < n; ++ix) {
        for (size_t iy = 0; iy < grid_side && idx < n; ++iy, ++idx) {
            pos[idx].x = (ix + 0.5) * spacing - half_extent;
            pos[idx].y = (iy + 0.5) * spacing - half_extent;
        }
    }

    std::mt19937 rng(seed);
    std::normal_distribution<double> normal(0.0, 1.0);
    Point2D v_cm;

    // Viewer-only annealing scale: this dimensionless velocity is large enough to disturb
    // the square grid within the fixed relaxation budget, but it is not a physical temperature.
    constexpr double initial_velocity_scale = 2.0;

    // Draw initial velocities and remove center-of-mass drift before MD equilibration.
    for (Point2D& v : vel) {
        v.x = initial_velocity_scale * normal(rng);
        v.y = initial_velocity_scale * normal(rng);
        v_cm.x += v.x;
        v_cm.y += v.y;
    }
    v_cm.x /= static_cast<double>(n);
    v_cm.y /= static_cast<double>(n);
    for (Point2D& v : vel) {
        v.x -= v_cm.x;
        v.y -= v_cm.y;
    }

    // Dimensionless viewer MD parameters. They are empirical, not part of PY theory:
    // epsilon and mass define arbitrary units, power_n=36 gives a near-hard repulsion,
    // dt is stable for that force, and steps is the tested relaxation budget.
    constexpr double epsilon = 1.0;
    constexpr int power_n = 36;
    constexpr double dt = 0.005;
    constexpr double mass = 1.0;
    constexpr size_t steps = 1000;
    constexpr double coincident_distance = 1e-10;
    constexpr double coincident_distance2 = coincident_distance * coincident_distance;
    // At twice the hard-core diameter the r^-36 force is negligible; the cutoff bounds cost.
    const double cutoff2 = 4.0 * sigma * sigma;

    // Velocity-Verlet integration with a steep soft repulsion approximating hard disks.
    for (size_t step = 0; step < steps; ++step) {
        for (size_t i = 0; i < n; ++i) {
            pos[i].x =
                wrapPeriodic(pos[i].x + vel[i].x * dt + 0.5 * acc[i].x * dt * dt, half_extent);
            pos[i].y =
                wrapPeriodic(pos[i].y + vel[i].y * dt + 0.5 * acc[i].y * dt * dt, half_extent);
            vel[i].x += 0.5 * acc[i].x * dt;
            vel[i].y += 0.5 * acc[i].y * dt;
            acc[i] = {};
        }

        // Pair forces use the minimum-image convention in the periodic display patch.
        for (size_t i = 0; i < n; ++i) {
            for (size_t j = i + 1; j < n; ++j) {
                double dx = pos[i].x - pos[j].x;
                double dy = pos[i].y - pos[j].y;
                dx -= side * std::round(dx / side);
                dy -= side * std::round(dy / side);

                const double r2 = dx * dx + dy * dy;
                if (r2 <= coincident_distance2 || r2 > cutoff2)
                    continue;

                const double inv_r2 = 1.0 / r2;
                const double sr = sigma * std::sqrt(inv_r2);
                const double fac = power_n * epsilon * std::pow(sr, power_n) * inv_r2;
                const double fx = fac * dx / mass;
                const double fy = fac * dy / mass;

                acc[i].x += fx;
                acc[i].y += fy;
                acc[j].x -= fx;
                acc[j].y -= fy;
            }
        }

        for (size_t i = 0; i < n; ++i) {
            vel[i].x += 0.5 * acc[i].x * dt;
            vel[i].y += 0.5 * acc[i].y * dt;
        }
    }

    // Geometry cleanup is not physics. The 0.1% clearance avoids floating-point contacts;
    // the iteration cap prevents hanging if the requested visual patch is overpacked.
    constexpr int max_iterations = 5000;
    constexpr double tolerance = 1.001;
    std::uniform_real_distribution<double> angle_dist(0.0, 2.0 * std::numbers::pi);

    // Resolve any residual overlaps geometrically; this makes the final viewer positions hard-core.
    for (int iter = 0; iter < max_iterations; ++iter) {
        bool has_overlap = false;
        for (size_t i = 0; i < n; ++i) {
            for (size_t j = i + 1; j < n; ++j) {
                double dx = pos[i].x - pos[j].x;
                double dy = pos[i].y - pos[j].y;
                dx -= side * std::round(dx / side);
                dy -= side * std::round(dy / side);

                double dist = std::hypot(dx, dy);
                if (dist >= sigma * tolerance)
                    continue;

                has_overlap = true;
                if (dist < coincident_distance) {
                    const double angle = angle_dist(rng);
                    dx = std::cos(angle);
                    dy = std::sin(angle);
                    dist = 1.0;
                }

                const double push = 0.5 * (sigma * tolerance - dist);
                pos[i].x = wrapPeriodic(pos[i].x + dx / dist * push, half_extent);
                pos[i].y = wrapPeriodic(pos[i].y + dy / dist * push, half_extent);
                pos[j].x = wrapPeriodic(pos[j].x - dx / dist * push, half_extent);
                pos[j].y = wrapPeriodic(pos[j].y - dy / dist * push, half_extent);
            }
        }
        if (!has_overlap)
            break;
    }

    for (const Point2D& p : pos) {
        pos_x.push_back(p.x);
        pos_y.push_back(p.y);
    }
}


} // namespace

void SceneGeometryDetail::fillStructPositions(const Struct* s, const PositionContext& ctx,
                                              StructData& sd)
{
    // Paracrystal2D derives from ICrystal2D; handle it before the generic crystal branch so
    // viewer positions receive accumulated bond disorder instead of an ideal lattice.
    if (const auto* pc = dynamic_cast<const Paracrystal2D*>(s)) {
        const double candidate_margin = std::max(
            {pc->lattice().length1(), pc->lattice().length2(), s->particle()->radialExtension()});
        const std::vector<double> domain_sizes = pc->domainSizes();
        generateParacrystalPositions(
            pc->lattice(), pc->pdf1(), pc->pdf2(),
            latticeParacrystalDomain(domain_sizes[0], domain_sizes[1], pc->dampingLength()),
            ctx.lateral_half_extent, candidate_margin, ctx.seed, sd.pos_x, sd.pos_y);
    } else if (const auto* c2d = dynamic_cast<const ICrystal2D*>(s)) {
        const double candidate_margin = std::max(
            {c2d->lattice().length1(), c2d->lattice().length2(), s->particle()->radialExtension()});
        if (const auto* fc = dynamic_cast<const FiniteCrystal2D*>(s))
            generateFiniteLatticePositions(c2d->lattice(), fc->numberUnitCells1(),
                                           fc->numberUnitCells2(), ctx.lateral_half_extent,
                                           candidate_margin, sd.pos_x, sd.pos_y);
        else
            generateLatticePositions(c2d->lattice(), ctx.lateral_half_extent, candidate_margin,
                                     sd.pos_x, sd.pos_y);
        if (const auto* crystal = dynamic_cast<const Crystal2D*>(s))
            filterCrystal2DCoherence(crystal, sd.pos_x, sd.pos_y);
        if (const auto* lro = dynamic_cast<const LongRangeOrdered2D*>(s))
            addIndependentLateralDisorder(lro->lateralPositionVariance(), ctx.seed, sd.pos_x,
                                          sd.pos_y);
    } else if (const auto* c1d = dynamic_cast<const Crystal1D*>(s)) {
        const double candidate_margin = std::max(c1d->length(), s->particle()->radialExtension());
        generate1DPositions(c1d->length(), c1d->xi(), ctx.lateral_half_extent, candidate_margin,
                            sd.pos_x, sd.pos_y);
        filterCrystal1DCoherence(c1d, sd.pos_x, sd.pos_y);
        addIndependentLateralDisorder(c1d->lateralPositionVariance(), ctx.seed, sd.pos_x, sd.pos_y);
    } else if (const auto* rpc = dynamic_cast<const RadialParacrystal*>(s)) {
        // A RadialParacrystal is the orientation-average of a hexagonal paracrystal; the
        // viewer shows one representative hexagonal domain without the orientation average.
        const HexagonalLattice2D lattice(rpc->peakDistance(), 0.0);
        const double candidate_margin = rpc->peakDistance();
        generateParacrystalPositions(
            lattice, rpc->pdf(), ctx.lateral_half_extent, candidate_margin,
            radialParacrystalDomain(rpc->domainSize(), rpc->dampingLength()), ctx.seed, sd.pos_x,
            sd.pos_y);
    } else if (const auto* dense = dynamic_cast<const Dense2D*>(s)) {
        generateDense2DPositions(dense, ctx.lateral_half_extent, ctx.seed, sd.pos_x, sd.pos_y);
    } else if (const auto* s2d = dynamic_cast<const Struct2D*>(s)) {
        // Other density-based 2D structs use independent random display positions.
        generateDilutePositions(s2d->areaNumberDensity(), ctx.lateral_half_extent, ctx.seed,
                                sd.pos_x, sd.pos_y);
    } else if (const auto* s3d = dynamic_cast<const Struct3D*>(s)) {
        const IParticle* particle = s3d->particle();
        // After translate-on-deposit (fill3D: shift by -intrinsicCenterZ), the particle's
        // bottomZ/topZ already reflect the correct struct-frame position.
        const double z_bottom = particle->bottomZ();
        const double z_top = particle->topZ();
        const double z_low = ctx.layer_z_bottom - z_bottom;
        const double z_high = ctx.layer_z_top - z_top;
        if (z_low <= z_high) {
            // Keep the displayed count proportional to the physical host-layer volume.
            // z_low/z_high only restrict admissible layout references for finite particles.
            const double layer_volume =
                4.0 * ctx.lateral_half_extent * ctx.lateral_half_extent * ctx.layer_thickness;
            const size_t n =
                stochasticRoundedCount(s3d->volumeNumberDensity() * layer_volume,
                                       seedForRandomStream(ctx.seed, RandomStream::count));
            std::mt19937 rng(ctx.seed);
            std::uniform_real_distribution<double> xy_dist(-ctx.lateral_half_extent,
                                                           ctx.lateral_half_extent);
            std::uniform_real_distribution<double> z_dist(z_low, z_high);
            for (size_t i = 0; i < n; ++i) {
                sd.pos_x.push_back(xy_dist(rng));
                sd.pos_y.push_back(xy_dist(rng));
                sd.pos_z.push_back(z_dist(rng));
            }
        }
    } else {
        sd.pos_x.push_back(0.0);
        sd.pos_y.push_back(0.0);
    }
    if (sd.pos_z.empty())
        sd.pos_z.assign(sd.pos_x.size(), 0.0);
    ASSERT(sd.pos_x.size() == sd.pos_y.size());
    ASSERT(sd.pos_z.size() == sd.pos_x.size());
}
