/**
 * prototype_tetrahedrize_mesh.js
 *
 * Drag-and-drop a GLTF/GLB file. Every mesh inside the scene is converted to a
 * BinaryTopology, tetrahedralized via `compute_tetrahedral_mesh_from_surface`,
 * and rendered as a pile of slightly shrunken tetrahedra so the volumetric
 * structure is visible. Each input geometry is shown in its own solid colour
 * to make them easy to tell apart.
 *
 * This module is intentionally self-contained — no engine harness, no ECS,
 * just bare three.js + the tetrahedralization pipeline.
 *
 * Reference prototypes:
 *   - prototypeTetrahedraBuilder.js (tet mesh creation patterns)
 *   - prototypeMeshSimplification.js (raw three.js bootstrap, drop-file handling style)
 *   - prototypeBinaryTopology.js (BinaryTopology construction from indexed geometry)
 */

import {
    AmbientLight,
    BufferAttribute,
    BufferGeometry,
    DirectionalLight,
    Float32BufferAttribute,
    Group,
    Mesh,
    MeshStandardMaterial,
    PerspectiveCamera,
    Scene,
    Vector3 as ThreeVector3,
    WebGLRenderer
} from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { BinaryTopology } from "../topology/struct/binary/BinaryTopology.js";
import {
    bt_mesh_from_indexed_geometry
} from "../topology/struct/binary/io/bt_mesh_from_indexed_geometry.js";
import {
    triangle_mesh_compute_signed_volume
} from "../triangle/triangle_mesh_compute_signed_volume.js";
import { compute_tetrahedral_mesh_from_surface } from "./compute_tetrahedral_mesh_from_surface.js";
import { compute_tetrahedron_quality } from "./compute_tetrahedron_quality.js";
import { compute_tetrahedron_volume } from "./compute_tetrahedron_volume.js";
import { tetrahedral_mesh_improve_quality } from "./tetrahedral_mesh_improve_quality.js";
import { TetrahedralMesh } from "./TetrahedralMesh.js";

// Quality threshold below which we count a tet as a sliver. Matches the
// usual "shape is starting to hurt simulation conditioning" cutoff for the
// radius-ratio metric.
const SLIVER_QUALITY_THRESHOLD = 0.05;

// Sliver-removal driver config. The driver now builds a vertex→tet
// adjacency cache once per pass and shares it with the boundary check,
// the local quality eval, and the smoothing primitive — bringing per-pass
// cost from O(N²) down to O(N·deg). On a 3,242-tet densified cube the
// driver dropped from ~23 s to ~3 s; on closed-surface meshes (Suzanne,
// Teapot) where every vertex is boundary, smoothing is a near no-op and
// the pass should finish in well under a second.
//
// BEFORE → AFTER numbers are logged in the per-mesh console line.
const ENABLE_IMPROVE_QUALITY = true;
const IMPROVE_TARGET_QUALITY = 0.05; // sliver cutoff only — keep the workload small
const IMPROVE_MAX_PASSES = 1;

// ---------------------------------------------------------------------------
// Visual / behaviour tunables
// ---------------------------------------------------------------------------

const BACKGROUND_COLOR = 0x202428;
const EXPLODE_FACTOR = 0.88;    // scale each tet toward its centroid; <1 reveals gaps
const COLOR_SATURATION = 0.62;
const COLOR_VALUE = 0.92;
const COLOR_HUE_STEP = 0.61803398875; // golden-ratio conjugate → good separation
const CAMERA_FIT_PADDING = 1.5;

// ---------------------------------------------------------------------------
// Colour helpers
// ---------------------------------------------------------------------------

/**
 * Standard HSV → packed 0xRRGGBB conversion.
 *
 * @param {number} h hue in [0,1)
 * @param {number} s saturation in [0,1]
 * @param {number} v value in [0,1]
 * @returns {number}
 */
function hsv_to_rgb_hex(h, s, v) {
    const i = Math.floor(h * 6);
    const f = h * 6 - i;
    const p = v * (1 - s);
    const q = v * (1 - f * s);
    const t = v * (1 - (1 - f) * s);

    let r, g, b;
    switch (i % 6) {
        case 0: r = v; g = t; b = p; break;
        case 1: r = q; g = v; b = p; break;
        case 2: r = p; g = v; b = t; break;
        case 3: r = p; g = q; b = v; break;
        case 4: r = t; g = p; b = v; break;
        case 5: r = v; g = p; b = q; break;
    }

    return (Math.round(r * 255) << 16) | (Math.round(g * 255) << 8) | Math.round(b * 255);
}

/**
 * Pick a visually distinct colour for the i-th geometry. Golden-angle hue
 * stepping spreads neighbouring indices far apart in colour space.
 *
 * @param {number} i
 * @returns {number} 0xRRGGBB
 */
function color_for_index(i) {
    return hsv_to_rgb_hex((i * COLOR_HUE_STEP) % 1, COLOR_SATURATION, COLOR_VALUE);
}

// ---------------------------------------------------------------------------
// Exploded-tet geometry construction
// ---------------------------------------------------------------------------

/**
 * Emit a single triangle as 3 positions + 3 (flat) normals into the output
 * arrays at `write_offset`. The vertex order is chosen so the normal points
 * away from `opposite` — guaranteeing outward-facing tet faces regardless of
 * the Delaunay output's vertex winding.
 *
 * Returns the new write offset (always advances by 9).
 */
function emit_face(positions, normals, write_offset, p0, p1, p2, opposite) {
    const ex = p1[0] - p0[0], ey = p1[1] - p0[1], ez = p1[2] - p0[2];
    const fx = p2[0] - p0[0], fy = p2[1] - p0[1], fz = p2[2] - p0[2];

    let nx = ey * fz - ez * fy;
    let ny = ez * fx - ex * fz;
    let nz = ex * fy - ey * fx;

    // If normal points toward the opposite vertex, flip the winding.
    const dx = p0[0] - opposite[0], dy = p0[1] - opposite[1], dz = p0[2] - opposite[2];
    if (nx * dx + ny * dy + nz * dz < 0) {
        nx = -nx; ny = -ny; nz = -nz;
        const tmp = p1; p1 = p2; p2 = tmp;
    }

    const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
    nx /= len; ny /= len; nz /= len;

    positions[write_offset + 0] = p0[0]; positions[write_offset + 1] = p0[1]; positions[write_offset + 2] = p0[2];
    positions[write_offset + 3] = p1[0]; positions[write_offset + 4] = p1[1]; positions[write_offset + 5] = p1[2];
    positions[write_offset + 6] = p2[0]; positions[write_offset + 7] = p2[1]; positions[write_offset + 8] = p2[2];

    for (let k = 0; k < 3; k++) {
        normals[write_offset + k * 3 + 0] = nx;
        normals[write_offset + k * 3 + 1] = ny;
        normals[write_offset + k * 3 + 2] = nz;
    }

    return write_offset + 9;
}

/**
 * Build a flat-shaded triangle geometry where each tetrahedron is rendered
 * as four outward-facing triangles, with the tet's vertices contracted
 * toward its centroid by `shrink`. The contraction creates visible gaps
 * between adjacent tets so the volumetric tessellation is obvious.
 *
 * @param {TetrahedralMesh} tet_mesh
 * @param {Float32Array|number[]} points flat (x,y,z) positions array
 * @param {number} shrink (0,1] — 1 = touching tets, 0.85 = clear gaps
 * @returns {BufferGeometry}
 */
function build_exploded_tet_geometry(tet_mesh, points, shrink) {
    const tet_count = tet_mesh.count;
    const float_count = tet_count * 4 * 3 * 3; // 4 faces × 3 verts × xyz
    const positions = new Float32Array(float_count);
    const normals = new Float32Array(float_count);
    const s = shrink;
    const t = 1 - s;

    let write = 0;

    tet_mesh.forEach((tet) => {
        const ai = tet_mesh.getVertexIndex(tet, 0);
        const bi = tet_mesh.getVertexIndex(tet, 1);
        const ci = tet_mesh.getVertexIndex(tet, 2);
        const di = tet_mesh.getVertexIndex(tet, 3);

        const ax = points[ai * 3], ay = points[ai * 3 + 1], az = points[ai * 3 + 2];
        const bx = points[bi * 3], by = points[bi * 3 + 1], bz = points[bi * 3 + 2];
        const cx = points[ci * 3], cy = points[ci * 3 + 1], cz = points[ci * 3 + 2];
        const dx = points[di * 3], dy = points[di * 3 + 1], dz = points[di * 3 + 2];

        const ccx = (ax + bx + cx + dx) * 0.25;
        const ccy = (ay + by + cy + dy) * 0.25;
        const ccz = (az + bz + cz + dz) * 0.25;

        const A = [ax * s + ccx * t, ay * s + ccy * t, az * s + ccz * t];
        const B = [bx * s + ccx * t, by * s + ccy * t, bz * s + ccz * t];
        const C = [cx * s + ccx * t, cy * s + ccy * t, cz * s + ccz * t];
        const D = [dx * s + ccx * t, dy * s + ccy * t, dz * s + ccz * t];

        write = emit_face(positions, normals, write, B, C, D, A); // face opposite A
        write = emit_face(positions, normals, write, A, D, C, B); // face opposite B
        write = emit_face(positions, normals, write, A, B, D, C); // face opposite C
        write = emit_face(positions, normals, write, A, C, B, D); // face opposite D
    });

    const geometry = new BufferGeometry();
    geometry.setAttribute('position', new Float32BufferAttribute(positions, 3));
    geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3));
    return geometry;
}

// ---------------------------------------------------------------------------
// Tetrahedralization pipeline for a single input geometry
// ---------------------------------------------------------------------------

/**
 * Turn a three.js BufferGeometry (with its world matrix baked in) into a
 * flat-shaded exploded-tet mesh plus measurement stats. Returns null if
 * the input cannot be tetrahedralized.
 *
 * Stats included:
 *   - tet_count: number of live tets in the result
 *   - surface_volume: divergence-theorem volume of the input surface (the
 *                     target we're trying to fill — caveat: only correct
 *                     if the surface is properly closed and consistently
 *                     wound. For meshes with holes / mixed winding the
 *                     number is an approximation.)
 *   - tet_volume: sum of all tet volumes in the output
 *   - min_quality, avg_quality: radius-ratio quality (1 = regular tet,
 *                              0 = degenerate)
 *   - sliver_count: tets below SLIVER_QUALITY_THRESHOLD
 *
 * @param {BufferGeometry} input_geometry
 * @returns {{
 *   geometry: BufferGeometry,
 *   tet_count: number,
 *   surface_volume: number,
 *   tet_volume: number,
 *   min_quality: number,
 *   avg_quality: number,
 *   sliver_count: number,
 * } | null}
 */
function tetrahedralize_geometry(input_geometry) {
    const positions_attr = input_geometry.getAttribute('position');
    if (!positions_attr || positions_attr.count < 3) return null;

    // Pull out positions + indices, fabricating trivial indices if the input
    // is non-indexed (every 3 vertices = 1 triangle).
    const positions = Array.from(positions_attr.array);
    let indices;
    if (input_geometry.index) {
        indices = Array.from(input_geometry.index.array);
    } else {
        const n = positions_attr.count;
        indices = new Array(n);
        for (let i = 0; i < n; i++) indices[i] = i;
    }
    if (indices.length < 3) return null;

    // GLTF often duplicates positions at smoothing/UV splits — that's fine,
    // compute_tetrahedral_mesh_from_surface merges coincident vertices via
    // bt_merge_verts_by_distance internally before tetrahedralizing.
    const surface = new BinaryTopology();
    bt_mesh_from_indexed_geometry(surface, indices, positions);

    const surface_volume = Math.abs(triangle_mesh_compute_signed_volume(positions, indices));

    const tet_mesh = new TetrahedralMesh();
    let tet_positions;
    try {
        tet_positions = compute_tetrahedral_mesh_from_surface(tet_mesh, surface);
    } catch (e) {
        console.warn('Tetrahedralization failed for one geometry:', e);
        return null;
    }

    if (tet_mesh.count === 0) {
        return null;
    }

    const stats_before = measure_tet_stats(tet_mesh, tet_positions);

    // Sliver-removal driver: try Laplacian smoothing + 2-3 / 3-2 flips on
    // every tet below IMPROVE_TARGET_QUALITY. Off by toggling the constant
    // at top of file. Stats are captured before+after so the per-mesh
    // console line shows whether the driver actually moved the needle.
    let stats_after = stats_before;
    let driver_stats = null;
    let driver_ms = 0;
    if (ENABLE_IMPROVE_QUALITY) {
        const t = performance.now();
        driver_stats = tetrahedral_mesh_improve_quality(tet_mesh, tet_positions, {
            target_quality: IMPROVE_TARGET_QUALITY,
            max_passes: IMPROVE_MAX_PASSES,
        });
        driver_ms = performance.now() - t;
        stats_after = measure_tet_stats(tet_mesh, tet_positions);
    }

    const out_geom = build_exploded_tet_geometry(tet_mesh, tet_positions, EXPLODE_FACTOR);

    return {
        geometry: out_geom,
        tet_count: tet_mesh.count,
        surface_volume,
        tet_volume: stats_after.tet_volume,
        min_quality: stats_after.min_quality,
        avg_quality: stats_after.avg_quality,
        sliver_count: stats_after.sliver_count,
        stats_before,
        stats_after,
        driver_stats,
        driver_ms,
    };
}

/**
 * One pass over a tet mesh: total signed volume, min and average
 * radius-ratio quality, count of tets below the sliver threshold.
 *
 * @param {TetrahedralMesh} tet_mesh
 * @param {Float32Array|number[]} tet_positions
 * @returns {{tet_volume: number, min_quality: number, avg_quality: number, sliver_count: number}}
 */
function measure_tet_stats(tet_mesh, tet_positions) {
    let tet_volume = 0;
    let min_quality = 1;
    let q_sum = 0;
    let sliver_count = 0;
    tet_mesh.forEach((tet, m) => {
        tet_volume += compute_tetrahedron_volume(m, tet_positions, tet);
        const q = compute_tetrahedron_quality(m, tet_positions, tet);
        if (q < min_quality) min_quality = q;
        q_sum += q;
        if (q < SLIVER_QUALITY_THRESHOLD) sliver_count++;
    });
    const avg_quality = tet_mesh.count > 0 ? q_sum / tet_mesh.count : 0;
    return { tet_volume, min_quality, avg_quality, sliver_count };
}

// ---------------------------------------------------------------------------
// Scene management
// ---------------------------------------------------------------------------

const scene = new Scene();
scene.background = null; // let CSS show through

const camera = new PerspectiveCamera(45, 1, 0.01, 10000);
camera.position.set(4, 4, 6);

const renderer = new WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setClearColor(BACKGROUND_COLOR, 1);

document.body.style.margin = '0';
document.body.style.overflow = 'hidden';
document.body.style.background = `#${BACKGROUND_COLOR.toString(16).padStart(6, '0')}`;
document.body.appendChild(renderer.domElement);

scene.add(new AmbientLight(0xffffff, 0.45));
const key_light = new DirectionalLight(0xffffff, 0.9);
key_light.position.set(3, 5, 4);
scene.add(key_light);
const fill_light = new DirectionalLight(0xa0b8ff, 0.35);
fill_light.position.set(-4, -2, -3);
scene.add(fill_light);

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;

const loaded_group = new Group();
scene.add(loaded_group);

function clear_loaded() {
    while (loaded_group.children.length > 0) {
        const child = loaded_group.children.pop();
        if (child.geometry) child.geometry.dispose();
        if (child.material) child.material.dispose();
    }
}

function fit_camera_to(group) {
    const min = new ThreeVector3(Infinity, Infinity, Infinity);
    const max = new ThreeVector3(-Infinity, -Infinity, -Infinity);
    const tmp = new ThreeVector3();

    group.traverse((obj) => {
        if (!obj.geometry) return;
        const attr = obj.geometry.getAttribute('position');
        if (!attr) return;
        for (let i = 0; i < attr.count; i++) {
            tmp.set(attr.getX(i), attr.getY(i), attr.getZ(i));
            obj.localToWorld(tmp);
            min.min(tmp);
            max.max(tmp);
        }
    });

    if (!isFinite(min.x)) return;

    const center = min.clone().add(max).multiplyScalar(0.5);
    const size = max.clone().sub(min);
    const radius = size.length() * 0.5;
    const dist = (radius / Math.sin((camera.fov * Math.PI / 180) / 2)) * CAMERA_FIT_PADDING;

    const dir = new ThreeVector3(1, 0.6, 1.2).normalize();
    camera.position.copy(center).add(dir.multiplyScalar(dist));
    camera.near = Math.max(0.001, dist * 0.001);
    camera.far = dist * 100;
    camera.updateProjectionMatrix();

    controls.target.copy(center);
    controls.update();
}

// ---------------------------------------------------------------------------
// Drag & drop UI
// ---------------------------------------------------------------------------

const overlay = document.createElement('div');
Object.assign(overlay.style, {
    position: 'fixed',
    inset: '0',
    display: 'flex',
    flexDirection: 'column',
    alignItems: 'center',
    justifyContent: 'center',
    color: 'rgba(255,255,255,0.55)',
    fontFamily: 'system-ui, sans-serif',
    fontSize: '20px',
    pointerEvents: 'none',
    transition: 'opacity 0.2s ease',
    textAlign: 'center',
    padding: '24px',
});
overlay.innerHTML = `
    <div style="font-size:42px;margin-bottom:12px;">⬇</div>
    <div>Drop a <b>.gltf</b> or <b>.glb</b> file anywhere</div>
    <div style="font-size:14px;margin-top:8px;opacity:0.7;">
        Each mesh inside will be tetrahedralized and shown in a distinct colour.
    </div>
`;
document.body.appendChild(overlay);

const status_bar = document.createElement('div');
Object.assign(status_bar.style, {
    position: 'fixed',
    left: '12px',
    bottom: '12px',
    color: 'rgba(255,255,255,0.85)',
    fontFamily: 'monospace',
    fontSize: '13px',
    background: 'rgba(0,0,0,0.4)',
    padding: '6px 10px',
    borderRadius: '4px',
    pointerEvents: 'none',
    whiteSpace: 'pre',
});
document.body.appendChild(status_bar);

function set_status(text) {
    status_bar.textContent = text;
}

set_status('Ready. Drop a .gltf or .glb file.');

['dragenter', 'dragover'].forEach((ev) => {
    window.addEventListener(ev, (e) => {
        e.preventDefault();
        overlay.style.background = 'rgba(60,100,140,0.25)';
    });
});
['dragleave', 'drop'].forEach((ev) => {
    window.addEventListener(ev, (e) => {
        e.preventDefault();
        overlay.style.background = '';
    });
});

window.addEventListener('drop', (e) => {
    const file = e.dataTransfer?.files?.[0];
    if (!file) return;
    handle_dropped_file(file);
});

const gltf_loader = new GLTFLoader();

/**
 * @param {File} file
 */
function handle_dropped_file(file) {
    set_status(`Reading ${file.name}…`);

    const reader = new FileReader();
    reader.onload = () => {
        const buffer = reader.result;
        const is_glb = file.name.toLowerCase().endsWith('.glb');

        // GLTFLoader.parse accepts an ArrayBuffer (for .glb) or a JSON string
        // (for .gltf). For .gltf, the result.scenes won't include external
        // .bin buffers — that's a known limitation of single-file drag-drop.
        try {
            let data;
            if (is_glb) {
                data = buffer;
            } else {
                data = new TextDecoder().decode(buffer);
            }

            gltf_loader.parse(
                data,
                '',
                (gltf) => process_gltf(gltf, file.name),
                (err) => {
                    console.error(err);
                    set_status(`Failed to parse ${file.name}: ${err?.message ?? err}`);
                }
            );
        } catch (err) {
            console.error(err);
            set_status(`Failed to read ${file.name}: ${err.message}`);
        }
    };
    reader.onerror = () => set_status(`Failed to read ${file.name}`);
    reader.readAsArrayBuffer(file);
}

/**
 * @param {{scene: import("three").Group}} gltf
 * @param {string} file_name
 */
function process_gltf(gltf, file_name) {
    clear_loaded();
    overlay.style.opacity = '0';

    gltf.scene.updateWorldMatrix(true, true);

    /** @type {Array<{name: string, geometry: BufferGeometry}>} */
    const inputs = [];

    gltf.scene.traverse((obj) => {
        if (!obj.isMesh || !obj.geometry) return;
        // Bake world transform so every geometry lives in the same coordinate frame.
        const baked = obj.geometry.clone();
        baked.applyMatrix4(obj.matrixWorld);
        inputs.push({ name: obj.name || `mesh_${inputs.length}`, geometry: baked });
    });

    if (inputs.length === 0) {
        set_status(`${file_name}: no meshes found.`);
        overlay.style.opacity = '1';
        return;
    }

    set_status(`${file_name}: tetrahedralizing ${inputs.length} mesh(es)…`);

    let total_tets = 0;
    let total_slivers = 0;
    let total_surface_vol = 0;
    let total_tet_vol = 0;
    let global_min_quality = 1;
    let q_sum_weighted = 0; // sum of avg_quality × tet_count, for a proper aggregate average
    let succeeded = 0;
    const t0 = performance.now();

    inputs.forEach((input, i) => {
        const color = color_for_index(i);

        let result;
        try {
            result = tetrahedralize_geometry(input.geometry);
        } catch (e) {
            console.warn(`Tetrahedralization threw for "${input.name}":`, e);
            return;
        }
        if (result === null) {
            console.warn(`Tetrahedralization yielded no tets for "${input.name}".`);
            return;
        }

        const material = new MeshStandardMaterial({
            color,
            flatShading: true,
            roughness: 0.6,
            metalness: 0.05,
        });
        const mesh = new Mesh(result.geometry, material);
        mesh.name = input.name;
        loaded_group.add(mesh);

        succeeded += 1;
        total_tets += result.tet_count;
        total_slivers += result.sliver_count;
        total_surface_vol += result.surface_volume;
        total_tet_vol += result.tet_volume;
        q_sum_weighted += result.avg_quality * result.tet_count;
        if (result.min_quality < global_min_quality) global_min_quality = result.min_quality;

        // Per-mesh details to the console. The line shows BEFORE → AFTER
        // the improve-quality driver so we can immediately see whether it
        // moved the needle on this specific mesh.
        const gap_pct = result.surface_volume > 0
            ? ((result.tet_volume - result.surface_volume) / result.surface_volume * 100).toFixed(2) + '%'
            : 'n/a';
        const before = result.stats_before;
        const after = result.stats_after;
        const driver_summary = result.driver_stats
            ? ` (${result.driver_ms.toFixed(0)}ms; smooth=${result.driver_stats.smoothings_committed}, flip23=${result.driver_stats.flips_23_committed}, flip32=${result.driver_stats.flips_32_committed})`
            : '';
        // eslint-disable-next-line no-console
        console.log(
            `[${input.name}] tets=${result.tet_count}  ` +
            `surf_vol=${result.surface_volume.toFixed(4)}  tet_vol=${result.tet_volume.toFixed(4)}  gap=${gap_pct}\n` +
            `   min_q: ${before.min_quality.toFixed(3)} → ${after.min_quality.toFixed(3)}   ` +
            `avg_q: ${before.avg_quality.toFixed(3)} → ${after.avg_quality.toFixed(3)}   ` +
            `slivers: ${before.sliver_count} → ${after.sliver_count}${driver_summary}`
        );
    });

    const ms = (performance.now() - t0).toFixed(0);

    if (succeeded === 0) {
        set_status(`${file_name}: all ${inputs.length} mesh(es) failed to tetrahedralize. See console.`);
        overlay.style.opacity = '1';
        return;
    }

    fit_camera_to(loaded_group);

    const aggregate_avg_q = total_tets > 0 ? q_sum_weighted / total_tets : 0;
    const aggregate_gap = total_surface_vol > 0
        ? ((total_tet_vol - total_surface_vol) / total_surface_vol * 100).toFixed(2) + '%'
        : 'n/a';
    const sliver_pct = total_tets > 0
        ? (total_slivers / total_tets * 100).toFixed(1) + '%'
        : '0%';

    set_status(
        `${file_name}\n` +
        `meshes: ${succeeded}/${inputs.length}   tets: ${total_tets.toLocaleString()}   time: ${ms} ms\n` +
        `vol: surf=${total_surface_vol.toFixed(4)}  tet=${total_tet_vol.toFixed(4)}  gap=${aggregate_gap}\n` +
        `quality: min=${global_min_quality.toFixed(3)}  avg=${aggregate_avg_q.toFixed(3)}   slivers: ${total_slivers} (${sliver_pct})\n` +
        `(drop another file to replace; per-mesh details in console)`
    );
}

// ---------------------------------------------------------------------------
// Render loop & resize
// ---------------------------------------------------------------------------

function on_resize() {
    const w = window.innerWidth;
    const h = window.innerHeight;
    renderer.setSize(w, h, false);
    camera.aspect = w / h;
    camera.updateProjectionMatrix();
}
window.addEventListener('resize', on_resize);
on_resize();

(function animate() {
    requestAnimationFrame(animate);
    controls.update();
    renderer.render(scene, camera);
})();
