//! Asset-backed mesh viewer.
//!
//! The viewer renders committed mesh assets ([STL], OBJ, glTF/GLB) with [`three-d`] on a
//! Dioxus-owned canvas. The design (see the "STL viewer renderer decision" Confluence
//! note) rests on three production decisions:
//!
//! * **One WebGL2 session per page mount.** The session is retained across
//!   model switches and only the GPU mesh resources are replaced in place
//!   ([`ViewerRenderer::replace_mesh`]). The [`BrowserRendererSession`]
//!   component is identified by position (no per-model key), stays mounted
//!   through the async loading window, and drives a [`RendererLifecycle`]
//!   reconcile on signal changes — so the WebGL context is acquired exactly
//!   once per page mount.
//! * **One parsed mesh per load.** Asset bytes are fetched on demand and parsed a
//!   single time into a [`ParsedMesh`], which then flows to the renderer. The
//!   renderer never parses itself.
//! * **On-demand assets.** Demo models live under `public/models/` and are fetched
//!   at runtime instead of being embedded in the WASM bundle.
//!
//! [STL]: crate::mesh::stl
// The renderer session machinery (the three-d adapter, the reuse lifecycle, and
// the typed error) is production-live only in the web target, where the wasm
// `BrowserRendererSession` drives it. Off-web it exists to compile and to back
// the unit tests, so allow dead code there rather than cfg-gating the whole
// abstraction out of the non-wasm build.
#![allow(dead_code)]

use crate::mesh::companions::{discover_companion_uris, is_safe_relative_uri};
use crate::mesh::contract::{MeshFormat, MeshLoadError, ModelMesh};
use crate::mesh::parser::{MeshParser, MeshResources, MeshSource, ProductionMeshParser};
use dioxus::prelude::*;
use std::hash::{Hash, Hasher};
use std::sync::Arc;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DemoModel {
    pub id: &'static str,
    pub name: &'static str,
    pub asset_path: &'static str,
    pub generation: u64,
    pub vertices: usize,
    pub triangles: usize,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ViewerFallbackImage {
    pub url: String,
    pub alt: String,
}

const DEMO_MODELS: [DemoModel; 2] = [
    DemoModel {
        id: "body-f-chest-v4",
        name: "Body F chest v4",
        asset_path: "/models/body_f_chest-v4.stl",
        generation: 1,
        vertices: 41051,
        triangles: 82122,
    },
    DemoModel {
        id: "body-f-forearm-2x-v9",
        name: "Body F forearm 2x v9",
        asset_path: "/models/body_f_forearm_2x-v9.stl",
        generation: 1,
        vertices: 18271,
        triangles: 36542,
    },
];

pub fn demo_models() -> &'static [DemoModel] {
    &DEMO_MODELS
}

pub fn demo_model_for_route(model_id: &str) -> DemoModel {
    demo_models()
        .iter()
        .copied()
        .find(|model| model.id == model_id)
        .unwrap_or(DEMO_MODELS[0])
}

/// A mesh parsed once from its source bytes, carrying an identity hash so it can
/// be compared cheaply for equality (used to decide mount vs. replace).
///
/// Equality is content-addressed over [`ParsedMesh::content_hash`]; the float
/// payload is never compared. Held behind [`Arc`] so it flows through Dioxus
/// props (which require [`Clone`] + [`PartialEq`]) without `ModelMesh` itself
/// needing to be `Clone`.
#[derive(Debug)]
pub struct ParsedMesh {
    pub content_hash: u64,
    pub mesh: ModelMesh,
}

impl PartialEq for ParsedMesh {
    fn eq(&self, other: &Self) -> bool {
        self.content_hash == other.content_hash
    }
}

impl Eq for ParsedMesh {}

/// Deterministic, in-process identity hash of a mesh source.
///
/// Covers the primary bytes plus the sorted set of `(companion URI, companion bytes)`,
/// so changing a companion (or none vs. some) changes identity and forces a reload. For
/// a companion-free source this reduces to hashing the primary bytes alone.
fn identity_hash(primary: &[u8], companions: &[(String, Vec<u8>)]) -> u64 {
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    primary.hash(&mut hasher);
    if !companions.is_empty() {
        // A marker so "primary alone" can never collide with "primary + companions".
        0xFFu8.hash(&mut hasher);
        let mut sorted: Vec<(&str, &[u8])> = companions
            .iter()
            .map(|(uri, bytes)| (uri.as_str(), bytes.as_slice()))
            .collect();
        sorted.sort_unstable();
        for (uri, bytes) in &sorted {
            uri.hash(&mut hasher);
            bytes.hash(&mut hasher);
        }
    }
    hasher.finish()
}

/// Hash a single byte slice (companion-free sources).
fn hash_bytes(bytes: &[u8]) -> u64 {
    identity_hash(bytes, &[])
}

/// Parse boundary: the shared [`MeshParser`] (in [`crate::mesh::parser`]) turns a
/// [`MeshSource`] into a [`ModelMesh`]. Production uses
/// [`ProductionMeshParser`] (which dispatches by format); tests inject a counting parser
/// to assert one parse per load.
///
/// Build a [`ParsedMesh`] from a mesh source: hash the primary plus companions, then
/// parse once through the parser.
async fn build_parsed_mesh(
    source: &MeshSource<'_>,
    parser: &impl MeshParser,
) -> Result<Arc<ParsedMesh>, MeshLoadError> {
    let companions: Vec<(String, Vec<u8>)> = source
        .resources
        .iter()
        .map(|(uri, bytes)| (uri.to_string(), bytes.to_vec()))
        .collect();
    let content_hash = identity_hash(source.primary, &companions);
    let mesh = parser.parse(source).await?;
    Ok(Arc::new(ParsedMesh { content_hash, mesh }))
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MeshSummary {
    pub display_name: &'static str,
    pub asset_path: &'static str,
    pub vertices: usize,
    pub triangles: usize,
}

fn mesh_summary_from_model(model: DemoModel, mesh: &ModelMesh) -> MeshSummary {
    MeshSummary {
        display_name: model.name,
        asset_path: model.asset_path,
        vertices: mesh.trimesh.positions.len(),
        triangles: mesh.trimesh.triangle_count(),
    }
}

/// A successfully loaded demo model: its parsed mesh plus display metadata.
/// Carried through signals to the renderer and the overlay.
#[derive(Clone, Debug)]
pub struct LoadedMesh {
    pub model: DemoModel,
    pub summary: MeshSummary,
    pub parsed: Arc<ParsedMesh>,
}

/// Load a demo model on demand (fetch the primary, prefetch glTF companions, parse
/// once) into a [`LoadedMesh`]. The format is derived from the asset extension; an
/// unrecognized extension surfaces as [`ViewerIssue::MeshUnavailable`].
async fn load_demo_mesh(model: DemoModel) -> Result<LoadedMesh, ViewerIssue> {
    let format =
        MeshFormat::from_extension(model.asset_path).ok_or(ViewerIssue::MeshUnavailable)?;
    let primary = read_bytes(model.asset_path).await?;
    let companions = prefetch_companions(&primary, model.asset_path, format).await?;
    let source = MeshSource {
        format,
        primary: &primary,
        resources: if companions.is_empty() {
            MeshResources::empty()
        } else {
            MeshResources::new(&companions)
        },
    };
    let parsed = build_parsed_mesh(&source, &ProductionMeshParser::default())
        .await
        .map_err(|_| ViewerIssue::MeshUnavailable)?;
    let summary = mesh_summary_from_model(model, &parsed.mesh);
    Ok(LoadedMesh {
        model,
        summary,
        parsed,
    })
}

/// Read a model resource's bytes: `fetch` on the web target, a hermetic disk read
/// elsewhere (so the non-wasm build compiles and tests stay filesystem-backed).
async fn read_bytes(asset_path: &str) -> Result<Vec<u8>, ViewerIssue> {
    #[cfg(all(target_family = "wasm", target_os = "unknown"))]
    {
        fetch_bytes(asset_path).await
    }
    #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
    {
        let path = format!("{}/public{}", env!("CARGO_MANIFEST_DIR"), asset_path);
        std::fs::read(&path).map_err(|_| ViewerIssue::MeshUnavailable)
    }
}

/// For a multi-file `.gltf`, discover and fetch its companion buffers/textures — only
/// safe, relative, in-directory URIs. GLB (magic `glTF`) and embedded-base64 assets are
/// self-contained and need no companions. Companions are keyed by their exact raw URI.
async fn prefetch_companions(
    primary: &[u8],
    asset_path: &str,
    format: MeshFormat,
) -> Result<Vec<(String, Vec<u8>)>, ViewerIssue> {
    if format != MeshFormat::Gltf || primary.starts_with(b"glTF") {
        return Ok(Vec::new());
    }
    let base_dir = asset_path
        .rsplit_once('/')
        .map(|(dir, _)| dir)
        .unwrap_or("");
    let mut companions = Vec::new();
    for uri in discover_companion_uris(primary) {
        if !is_safe_relative_uri(&uri) {
            return Err(ViewerIssue::MeshUnavailable);
        }
        let bytes = read_bytes(&format!("{base_dir}/{uri}")).await?;
        companions.push((uri, bytes));
    }
    Ok(companions)
}

#[cfg(all(target_family = "wasm", target_os = "unknown"))]
async fn fetch_bytes(url: &str) -> Result<Vec<u8>, ViewerIssue> {
    use wasm_bindgen::JsCast;
    use wasm_bindgen_futures::JsFuture;

    let window = web_sys::window().ok_or(ViewerIssue::MeshUnavailable)?;
    let resp_value = JsFuture::from(window.fetch_with_str(url))
        .await
        .map_err(|_| ViewerIssue::MeshUnavailable)?;
    let response: web_sys::Response = resp_value
        .dyn_into()
        .map_err(|_| ViewerIssue::MeshUnavailable)?;
    let array_buffer = response
        .array_buffer()
        .map_err(|_| ViewerIssue::MeshUnavailable)?;
    let buffer = JsFuture::from(array_buffer)
        .await
        .map_err(|_| ViewerIssue::MeshUnavailable)?;
    Ok(js_sys::Uint8Array::new(&buffer).to_vec())
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SessionKey {
    model_id: String,
    generation: u64,
}

impl SessionKey {
    pub fn new(model_id: impl Into<String>, generation: u64) -> Self {
        Self {
            model_id: model_id.into(),
            generation,
        }
    }

    pub fn model_id(&self) -> &str {
        &self.model_id
    }

    pub fn generation(&self) -> u64 {
        self.generation
    }

    #[cfg(test)]
    pub fn switch_to(&self, next_model_id: impl Into<String>) -> Self {
        Self {
            model_id: next_model_id.into(),
            generation: self.generation + 1,
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ViewerIssue {
    MeshUnavailable,
    RendererInit,
}

impl ViewerIssue {
    pub fn message(self) -> &'static str {
        match self {
            Self::MeshUnavailable => "This model could not be loaded for the viewer.",
            Self::RendererInit => "The viewer could not start on this canvas.",
        }
    }
}

/// Viewer state surfaced to the UI and the renderer reconcile.
///
/// `Loading` is its own variant so the retained renderer can be kept alive
/// across the async fetch window: [`RendererLifecycle::reconcile`] treats
/// `Loading` (no parsed mesh yet) as "keep the active renderer", not "drop it".
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ViewerStatus {
    /// A parsed mesh is available for `key`.
    Ready(SessionKey),
    /// A fetch/parse for `key` is in flight; a previously-loaded mesh may still
    /// be rendering on the retained canvas.
    Loading(SessionKey),
    /// Fetching or parsing the mesh failed.
    MeshError(ViewerIssue),
    /// The renderer could not be initialized (e.g. WebGL2 unavailable).
    RendererError(ViewerIssue),
}

impl ViewerStatus {
    pub fn label(&self) -> &'static str {
        match self {
            Self::Ready(_) => "Viewer ready",
            Self::Loading(_) => "Loading model",
            Self::MeshError(_) => "Mesh unavailable",
            Self::RendererError(_) => "Renderer unavailable",
        }
    }

    pub fn message(&self) -> &'static str {
        match self {
            Self::Ready(_) => "STL asset is loaded and ready for the canvas renderer.",
            Self::Loading(_) => "Fetching and parsing the STL asset for the viewer.",
            Self::MeshError(issue) | Self::RendererError(issue) => issue.message(),
        }
    }

    /// Whether this status wants a live renderer session. `Loading` keeps the
    /// session (and the retained canvas) alive; only hard errors do not.
    pub fn should_have_session(&self) -> bool {
        matches!(self, Self::Ready(_) | Self::Loading(_))
    }

    pub fn key(&self) -> Option<&SessionKey> {
        match self {
            Self::Ready(key) | Self::Loading(key) => Some(key),
            Self::MeshError(_) | Self::RendererError(_) => None,
        }
    }
}

/// Everything the renderer needs for one mount or mesh replacement.
///
/// `mesh` is the parsed payload behind an [`Arc`] (compared by content hash for
/// [`PartialEq`]); `summary` is the display metadata; `key` identifies the model.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RendererMount {
    pub key: SessionKey,
    pub mesh: Arc<ParsedMesh>,
    pub summary: MeshSummary,
}

impl RendererMount {
    pub fn new(key: SessionKey, mesh: Arc<ParsedMesh>, summary: MeshSummary) -> Self {
        Self { key, mesh, summary }
    }
}

/// A renderer adapter that can be mounted once and have its mesh replaced.
///
/// The canvas source is renderer-specific: the three-d adapter looks its canvas
/// up by id, so its `Canvas` type is `()`.
pub trait ViewerRenderer: Sized {
    type Canvas;
    type Error;

    /// Acquire the rendering context and build the initial scene from `mount`.
    fn mount(canvas: Self::Canvas, mount: RendererMount) -> Result<Self, Self::Error>;
    /// Replace the mesh resources on the existing context, reusing everything else.
    fn replace_mesh(&mut self, mount: RendererMount) -> Result<(), Self::Error>;
    /// Re-read the canvas size and re-render.
    fn resize(&mut self) -> Result<(), Self::Error>;
}

/// Mount-once / replace-mesh lifecycle for a [`ViewerRenderer`], keyed by mesh
/// identity so re-renders of the same model are no-ops and only a real model
/// switch triggers [`ViewerRenderer::replace_mesh`].
///
/// `reconcile` is loading-safe: a transient absence of a mesh (the async fetch
/// window) or a mesh-load error keeps the existing renderer alive; only a hard
/// renderer error or a missing canvas tears the session down.
pub struct RendererLifecycle<R: ViewerRenderer> {
    active: Option<R>,
    active_hash: Option<u64>,
    mounts: u64,
}

impl<R: ViewerRenderer> RendererLifecycle<R>
where
    R::Canvas: Clone,
{
    pub fn new() -> Self {
        Self {
            active: None,
            active_hash: None,
            mounts: 0,
        }
    }

    pub fn reconcile(
        &mut self,
        canvas: Option<R::Canvas>,
        status: &ViewerStatus,
        mount: Option<RendererMount>,
    ) -> Result<(), R::Error> {
        // No canvas: the component unmounted or the canvas is gone. Tear down.
        let Some(canvas) = canvas else {
            self.active = None;
            self.active_hash = None;
            return Ok(());
        };
        // A hard renderer failure tears the session down; mesh failures and the
        // async Loading window keep the retained renderer alive.
        if let ViewerStatus::RendererError(_) = status {
            self.active = None;
            self.active_hash = None;
            return Ok(());
        }
        // No mesh yet (Loading, or no model selected): keep the active renderer.
        let Some(mount) = mount else {
            return Ok(());
        };
        let hash = mount.mesh.content_hash;
        match self.active.as_mut() {
            None => {
                let mut renderer = R::mount(canvas, mount)?;
                renderer.resize()?;
                self.active = Some(renderer);
                self.active_hash = Some(hash);
                self.mounts += 1;
            }
            Some(_) if self.active_hash == Some(hash) => {
                // Same mesh identity: no-op. The retained context and resources stay.
            }
            Some(renderer) => {
                renderer.replace_mesh(mount)?;
                self.active_hash = Some(hash);
            }
        }
        Ok(())
    }

    pub fn resize(&mut self) -> Result<(), R::Error> {
        if let Some(renderer) = self.active.as_mut() {
            renderer.resize()?;
        }
        Ok(())
    }

    /// Borrow the active renderer for external event dispatch (pointer/wheel).
    pub fn active_mut(&mut self) -> Option<&mut R> {
        self.active.as_mut()
    }

    pub fn has_active_session(&self) -> bool {
        self.active.is_some()
    }

    /// Number of times `mount` has run. This is the WebGL-context acquisition
    /// count: it should be exactly one per page mount regardless of switches.
    pub fn mounts(&self) -> u64 {
        self.mounts
    }
}

impl<R: ViewerRenderer> Default for RendererLifecycle<R>
where
    R::Canvas: Clone,
{
    fn default() -> Self {
        Self::new()
    }
}

#[component]
pub fn ViewerPage(initial_model: DemoModel) -> Element {
    let mut selected_model = use_signal(|| initial_model);
    let canvas_mounted = use_signal(|| false);
    let resize_count = use_signal(|| 0_u64);
    let context_acquisitions = use_signal(|| 0_u64);
    let renderer_failed = use_signal(|| false);
    let loaded: Signal<Option<LoadedMesh>> = use_signal(|| None);
    let status: Signal<ViewerStatus> = use_signal(|| {
        ViewerStatus::Loading(SessionKey::new(initial_model.id, initial_model.generation))
    });

    // Fetch + parse the selected model on demand, re-keying on selection so a
    // rapid switch cancels the in-flight load. Side effects update `loaded` and
    // `status`; the returned resource is intentionally unused.
    let _mesh_load = use_resource(move || {
        let model = *selected_model.read();
        let mut loaded = loaded;
        let mut status = status;
        async move {
            status.set(ViewerStatus::Loading(SessionKey::new(
                model.id,
                model.generation,
            )));
            match load_demo_mesh(model).await {
                Ok(result) => {
                    status.set(ViewerStatus::Ready(SessionKey::new(
                        model.id,
                        model.generation,
                    )));
                    loaded.set(Some(result));
                }
                Err(issue) => {
                    status.set(ViewerStatus::MeshError(issue));
                }
            }
        }
    });

    let model = *selected_model.read();

    rsx! {
        section { class: "model-viewer-shell", aria_label: "Asset-backed STL model viewer",
            header { class: "model-viewer-header",
                div {
                    p { class: "viewer-eyebrow", "Asset-backed STL viewer" }
                    h1 { "Model viewer" }
                    p { "Loads the committed STL asset through the same metadata a renderer adapter receives." }
                }
                div { class: "viewer-status-pill", "{status.read().label()}" }
            }

            div { class: "viewer-demo-controls", aria_label: "Model choices",
                for model_option in demo_models() {
                    button {
                        class: if *model_option == model { "viewer-demo-button is-active" } else { "viewer-demo-button" },
                        r#type: "button",
                        onclick: move |_| selected_model.set(*model_option),
                        "{model_option.name}"
                    }
                }
            }

            ModelViewer {
                loaded,
                status,
                canvas_mounted,
                resize_count,
                context_acquisitions,
                renderer_failed,
                fallback: None,
            }
        }
    }
}

#[component]
pub fn AssetViewer(initial_model: DemoModel, fallback: Option<ViewerFallbackImage>) -> Element {
    let canvas_mounted = use_signal(|| false);
    let resize_count = use_signal(|| 0_u64);
    let context_acquisitions = use_signal(|| 0_u64);
    let renderer_failed = use_signal(|| false);
    let loaded: Signal<Option<LoadedMesh>> = use_signal(|| None);
    let status: Signal<ViewerStatus> = use_signal(|| {
        ViewerStatus::Loading(SessionKey::new(initial_model.id, initial_model.generation))
    });

    let _mesh_load = use_resource(move || {
        let mut loaded = loaded;
        let mut status = status;
        async move {
            status.set(ViewerStatus::Loading(SessionKey::new(
                initial_model.id,
                initial_model.generation,
            )));
            match load_demo_mesh(initial_model).await {
                Ok(result) => {
                    status.set(ViewerStatus::Ready(SessionKey::new(
                        initial_model.id,
                        initial_model.generation,
                    )));
                    loaded.set(Some(result));
                }
                Err(issue) => status.set(ViewerStatus::MeshError(issue)),
            }
        }
    });

    rsx! {
        div { class: "detail-asset-viewer", aria_label: "Asset-backed 3D model viewer",
            ModelViewer {
                loaded,
                status,
                canvas_mounted,
                resize_count,
                context_acquisitions,
                renderer_failed,
                fallback,
            }
        }
    }
}

#[derive(Props, Clone, PartialEq)]
pub struct ModelViewerProps {
    loaded: Signal<Option<LoadedMesh>>,
    status: Signal<ViewerStatus>,
    canvas_mounted: Signal<bool>,
    resize_count: Signal<u64>,
    context_acquisitions: Signal<u64>,
    renderer_failed: Signal<bool>,
    fallback: Option<ViewerFallbackImage>,
}

#[component]
pub fn ModelViewer(props: ModelViewerProps) -> Element {
    let canvas_mounted = props.canvas_mounted;
    let resize_count = props.resize_count;
    let context_acquisitions = props.context_acquisitions;
    let renderer_failed = props.renderer_failed;
    let fallback = props.fallback;
    let loaded = props.loaded;
    let status = props.status;

    let loaded_val = loaded.read().clone();
    let load_status = status.read().clone();
    // A renderer-init failure overrides the load status so the user sees the
    // renderer error rather than a stale "ready" while no context is active.
    let status_val = if *renderer_failed.read() {
        ViewerStatus::RendererError(ViewerIssue::RendererInit)
    } else {
        load_status
    };
    let summary = loaded_val.as_ref().map(|loaded| loaded.summary);
    let asset_path = summary.map(|summary| summary.asset_path).unwrap_or("");
    let session_label = loaded_val
        .as_ref()
        .map(|loaded| format!("{}#{}", loaded.model.id, loaded.model.generation))
        .or_else(|| {
            status_val
                .key()
                .map(|key| format!("{}#{}", key.model_id(), key.generation()))
        })
        .unwrap_or_default();
    let acquisitions = *context_acquisitions.read();
    let renderer_active = acquisitions > 0 && !*renderer_failed.read();
    let renderer_kind = if renderer_active {
        "three-d"
    } else {
        "fallback"
    };

    rsx! {
        div {
            class: "model-viewer-boundary",
            id: "polymodel-viewer-boundary",
            "data-viewer-status": "{status_val.label()}",
            "data-session-wanted": "{status_val.should_have_session()}",
            "data-resize-count": "{resize_count.read()}",
            "data-session-label": session_label.as_str(),
            "data-asset-path": asset_path.to_string(),
            "data-renderer-kind": renderer_kind,
            "data-context-acquisitions": "{acquisitions}",

            div { class: "model-viewer-stage",
                canvas {
                    id: "polymodel-viewer-canvas",
                    "data-mounted": "{canvas_mounted.read()}",
                    "data-renderer-kind": renderer_kind,
                    class: "model-viewer-canvas",
                    aria_label: "Polymodel model viewer canvas",
                    onmounted: move |event| mark_canvas_mounted(event, canvas_mounted),
                    onwheel: move |event| event.stop_propagation(),
                    onpointerdown: move |event| event.stop_propagation(),
                }
                if !renderer_active {
                    if let Some(fallback) = fallback {
                        img {
                            id: "polymodel-viewer-preview",
                            class: "model-viewer-preview-image",
                            src: "{fallback.url}",
                            alt: "{fallback.alt}"
                        }
                    } else if let Some(summary) = summary {
                        AssetPreview { summary }
                    }
                }
            }
        }
        CanvasRendererSession {
            loaded,
            status,
            canvas_mounted,
            resize_count,
            context_acquisitions,
            renderer_failed,
        }
    }
}

#[component]
fn AssetPreview(summary: MeshSummary) -> Element {
    rsx! {
        svg {
            id: "polymodel-viewer-preview",
            class: "model-viewer-preview",
            view_box: "0 0 640 420",
            role: "img",
            "aria-label": "Preview silhouette for {summary.display_name}",
            path { class: "model-preview-shadow", d: "M158 346 C245 384 406 386 493 347 C425 330 235 329 158 346 Z" }
            path { class: "model-preview-body", d: "M320 56 C263 71 220 115 201 173 C177 247 202 312 278 337 L362 337 C438 312 463 247 439 173 C420 115 377 71 320 56 Z" }
            path { class: "model-preview-cut", d: "M249 191 C268 154 292 136 320 136 C348 136 372 154 391 191 C365 181 275 181 249 191 Z" }
            path { class: "model-preview-line", d: "M218 250 C267 276 373 276 422 250" }
            path { class: "model-preview-line", d: "M238 302 C286 322 354 322 402 302" }
            text { class: "model-preview-label", x: "320", y: "388", text_anchor: "middle", "{summary.triangles} triangles · {summary.vertices} vertices" }
        }
    }
}

/// Always renders the browser renderer session. It has no per-model key, so it
/// keeps its position-based identity and stays mounted across loading/model
/// switches — which is what preserves the WebGL context.
#[component]
fn CanvasRendererSession(
    loaded: Signal<Option<LoadedMesh>>,
    status: Signal<ViewerStatus>,
    canvas_mounted: Signal<bool>,
    resize_count: Signal<u64>,
    context_acquisitions: Signal<u64>,
    renderer_failed: Signal<bool>,
) -> Element {
    rsx! {
        BrowserRendererSession {
            loaded,
            status,
            canvas_mounted,
            resize_count,
            context_acquisitions,
            renderer_failed,
        }
    }
}

#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
#[component]
fn BrowserRendererSession(
    loaded: Signal<Option<LoadedMesh>>,
    status: Signal<ViewerStatus>,
    canvas_mounted: Signal<bool>,
    resize_count: Signal<u64>,
    context_acquisitions: Signal<u64>,
    renderer_failed: Signal<bool>,
) -> Element {
    let _ = (
        loaded,
        status,
        canvas_mounted,
        resize_count,
        context_acquisitions,
        renderer_failed,
    );
    rsx! {}
}

#[cfg(all(target_family = "wasm", target_os = "unknown"))]
#[component]
fn BrowserRendererSession(
    loaded: Signal<Option<LoadedMesh>>,
    status: Signal<ViewerStatus>,
    canvas_mounted: Signal<bool>,
    resize_count: Signal<u64>,
    context_acquisitions: Signal<u64>,
    mut renderer_failed: Signal<bool>,
) -> Element {
    use std::cell::RefCell;
    use std::rc::Rc;

    // Persistent retained session: a renderer lifecycle plus a guard that
    // detaches the canvas listeners on unmount. The guard is held first so it
    // drops (and detaches listeners) before the renderer (and its WebGL
    // context) is released. Listener closures hold only a `Weak` reference to
    // the lifecycle, so they never keep the session alive.
    let holders = use_hook(|| {
        let lifecycle: Rc<RefCell<RendererLifecycle<ThreeDRenderer>>> =
            Rc::new(RefCell::new(RendererLifecycle::new()));
        let guard: Rc<RefCell<ListenerGuard>> = Rc::new(RefCell::new(ListenerGuard::default()));
        Rc::new((guard, lifecycle))
    });

    let weak_lifecycle = Rc::downgrade(&holders.1);
    use_effect(move || {
        let canvas_present = *canvas_mounted.read();
        let status_val = status.read().clone();
        let loaded_val = loaded.read().clone();
        let mount_opt = loaded_val.as_ref().map(|loaded| {
            RendererMount::new(
                SessionKey::new(loaded.model.id, loaded.model.generation),
                loaded.parsed.clone(),
                loaded.summary,
            )
        });

        // Attach canvas listeners once, as soon as the canvas is in the DOM.
        let needs_attach = canvas_present && holders.0.borrow().cleanup.is_none();
        if needs_attach && let Some(canvas) = lookup_viewer_canvas() {
            let cleanup = attach_renderer_listeners(canvas, weak_lifecycle.clone(), resize_count);
            holders.0.borrow_mut().cleanup = Some(cleanup);
        }

        let mut lifecycle = holders.1.borrow_mut();
        let result = lifecycle.reconcile(canvas_present.then_some(()), &status_val, mount_opt);
        let mounts = lifecycle.mounts();
        drop(lifecycle);

        // Publish the context-acquisition count (the single WebGL-context signal).
        // This effect reads only canvas_mounted/status/loaded, so writing these signals never re-runs it.
        context_acquisitions.set(mounts);
        renderer_failed.set(result.is_err());
        if let Err(err) = result {
            tracing::warn!(?err, "three-d renderer reconcile failed");
        }
    });

    rsx! {}
}

/// Holds the canvas-listener cleanup closure and runs it on drop (component
/// unmount), detaching listeners from the canvas and window.
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
#[derive(Default)]
struct ListenerGuard {
    cleanup: Option<Box<dyn FnOnce()>>,
}

#[cfg(all(target_family = "wasm", target_os = "unknown"))]
impl Drop for ListenerGuard {
    fn drop(&mut self) {
        if let Some(cleanup) = self.cleanup.take() {
            cleanup();
        }
    }
}

#[cfg(all(target_family = "wasm", target_os = "unknown"))]
fn lookup_viewer_canvas() -> Option<web_sys::HtmlCanvasElement> {
    use wasm_bindgen::JsCast;
    web_sys::window()?
        .document()?
        .get_element_by_id("polymodel-viewer-canvas")?
        .dyn_into::<web_sys::HtmlCanvasElement>()
        .ok()
}

#[cfg(all(target_family = "wasm", target_os = "unknown"))]
fn attach_renderer_listeners(
    canvas: web_sys::HtmlCanvasElement,
    weak: std::rc::Weak<std::cell::RefCell<RendererLifecycle<ThreeDRenderer>>>,
    resize_count: Signal<u64>,
) -> Box<dyn FnOnce()> {
    use wasm_bindgen::JsCast;
    use wasm_bindgen::closure::Closure;
    use web_sys::{Event, MouseEvent, WheelEvent};

    let mut cleanups: Vec<Box<dyn FnOnce()>> = Vec::new();

    // Window resize: bump the resize counter and refresh the active renderer.
    if let Some(window) = web_sys::window() {
        let weak_resize = weak.clone();
        let mut resize_counter = resize_count;
        let resize_closure = Closure::<dyn FnMut(Event)>::wrap(Box::new(move |_| {
            let next = *resize_counter.read() + 1;
            resize_counter.set(next);
            if let Some(handle) = weak_resize.upgrade()
                && let Ok(mut lifecycle) = handle.try_borrow_mut()
            {
                let _ = lifecycle.resize();
            }
        }));
        if window
            .add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref())
            .is_ok()
        {
            cleanups.push(Box::new(move || {
                if let Some(window) = web_sys::window() {
                    let _ = window.remove_event_listener_with_callback(
                        "resize",
                        resize_closure.as_ref().unchecked_ref(),
                    );
                }
            }));
        }
    }

    if let Some(cleanup) =
        register_canvas_pointer(&canvas, "pointerdown", weak.clone(), |renderer, event| {
            if let Some(target) = event
                .target()
                .and_then(|target| target.dyn_into::<web_sys::HtmlCanvasElement>().ok())
            {
                let _ = target.set_pointer_capture(event.pointer_id());
            }
            renderer.pointer_down(event.client_x() as f32, event.client_y() as f32);
        })
    {
        cleanups.push(cleanup);
    }

    if let Some(cleanup) =
        register_canvas_pointer(&canvas, "pointermove", weak.clone(), |renderer, event| {
            renderer.pointer_move(event.client_x() as f32, event.client_y() as f32);
        })
    {
        cleanups.push(cleanup);
    }

    if let Some(cleanup) =
        register_canvas_pointer(&canvas, "pointerup", weak.clone(), |renderer, _event| {
            renderer.pointer_up();
        })
    {
        cleanups.push(cleanup);
    }

    // pointerleave is dispatched as a MouseEvent; release any in-progress drag.
    {
        let canvas = canvas.clone();
        let leave_weak = weak.clone();
        let closure = Closure::<dyn FnMut(MouseEvent)>::wrap(Box::new(move |_| {
            if let Some(handle) = leave_weak.upgrade()
                && let Ok(mut lifecycle) = handle.try_borrow_mut()
                && let Some(renderer) = lifecycle.active_mut()
            {
                renderer.pointer_up();
            }
        }));
        if canvas
            .add_event_listener_with_callback("mouseleave", closure.as_ref().unchecked_ref())
            .is_ok()
        {
            cleanups.push(Box::new(move || {
                let _ = canvas.remove_event_listener_with_callback(
                    "mouseleave",
                    closure.as_ref().unchecked_ref(),
                );
            }));
        }
    }

    // wheel zoom.
    {
        let canvas = canvas.clone();
        let wheel_weak = weak;
        let closure = Closure::<dyn FnMut(WheelEvent)>::wrap(Box::new(move |event: WheelEvent| {
            event.prevent_default();
            if let Some(handle) = wheel_weak.upgrade()
                && let Ok(mut lifecycle) = handle.try_borrow_mut()
                && let Some(renderer) = lifecycle.active_mut()
            {
                renderer.wheel(
                    event.delta_y() as f32,
                    event.client_x() as f32,
                    event.client_y() as f32,
                );
            }
        }));
        if canvas
            .add_event_listener_with_callback("wheel", closure.as_ref().unchecked_ref())
            .is_ok()
        {
            cleanups.push(Box::new(move || {
                let _ = canvas
                    .remove_event_listener_with_callback("wheel", closure.as_ref().unchecked_ref());
            }));
        }
    }

    Box::new(move || {
        for cleanup in cleanups.into_iter().rev() {
            cleanup();
        }
    })
}

/// Register a pointer listener on the canvas that dispatches to the active
/// renderer through a `Weak` lifecycle handle. Returns a cleanup closure on
/// success. Kept as a function (rather than a closure) to keep the types simple.
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
fn register_canvas_pointer(
    canvas: &web_sys::HtmlCanvasElement,
    name: &'static str,
    weak: std::rc::Weak<std::cell::RefCell<RendererLifecycle<ThreeDRenderer>>>,
    dispatch: impl Fn(&mut ThreeDRenderer, web_sys::PointerEvent) + 'static,
) -> Option<Box<dyn FnOnce()>> {
    use wasm_bindgen::JsCast;
    use wasm_bindgen::closure::Closure;

    let closure = Closure::<dyn FnMut(web_sys::PointerEvent)>::wrap(Box::new(
        move |event: web_sys::PointerEvent| {
            event.prevent_default();
            if let Some(handle) = weak.upgrade()
                && let Ok(mut lifecycle) = handle.try_borrow_mut()
                && let Some(renderer) = lifecycle.active_mut()
            {
                dispatch(renderer, event);
            }
        },
    ));
    if canvas
        .add_event_listener_with_callback(name, closure.as_ref().unchecked_ref())
        .is_ok()
    {
        let canvas = canvas.clone();
        Some(Box::new(move || {
            let _ =
                canvas.remove_event_listener_with_callback(name, closure.as_ref().unchecked_ref());
        }))
    } else {
        None
    }
}

/// Typed renderer-initialization error, replacing the spike's `String` boundary.
///
/// Parse/asset failures are handled at the loader boundary
/// ([`ViewerIssue::MeshUnavailable`]); this covers only what the renderer can
/// fail on while acquiring its WebGL2 context and building its scene.
#[derive(Debug, thiserror::Error)]
pub(crate) enum ThreeDRendererError {
    #[error("browser window is unavailable")]
    WindowUnavailable,
    #[error("browser document is unavailable")]
    DocumentUnavailable,
    #[error("viewer canvas element not found")]
    CanvasNotFound,
    #[error("viewer element is not a canvas")]
    NotCanvas,
    #[error("WebGL2 context could not be created")]
    WebGL2Unavailable,
    #[error("rendering context is not a WebGL2 context")]
    NotWebGL2,
    #[error("three-d core context error: {0}")]
    Core(#[from] three_d::core::CoreError),
}

impl From<ThreeDRendererError> for ViewerIssue {
    fn from(_: ThreeDRendererError) -> Self {
        ViewerIssue::RendererInit
    }
}

#[cfg(all(target_family = "wasm", target_os = "unknown"))]
struct ThreeDRenderer {
    canvas: web_sys::HtmlCanvasElement,
    context: three_d::Context,
    mesh: three_d::Gm<three_d::Mesh, three_d::PhysicalMaterial>,
    ambient_light: three_d::AmbientLight,
    key_light: three_d::DirectionalLight,
    fill_light: three_d::DirectionalLight,
    camera: three_d::Camera,
    control: three_d::OrbitControl,
    button_down: bool,
    last_pointer: Option<(f32, f32)>,
    frame_started_ms: f64,
    first_render_logged: bool,
}

/// Viewport-parameterized scene construction shared by the viewer and offscreen
/// preview capture. The `viewport` dims MUST equal the backing-store dims of the
/// canvas the caller intends to render into (it does NOT call `canvas_viewport`
/// or apply `devicePixelRatio` — the caller owns canvas sizing).
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
pub(crate) fn build_scene_with_viewport(
    context: &three_d::Context,
    model: &ModelMesh,
    viewport: three_d::Viewport,
) -> (
    three_d::Gm<three_d::Mesh, three_d::PhysicalMaterial>,
    three_d::Camera,
    three_d::OrbitControl,
) {
    let fit = model.camera_fit();
    let mesh = three_d::Gm::new(
        three_d::Mesh::new(context, &model.trimesh),
        three_d::PhysicalMaterial {
            albedo: three_d::Srgba::new_opaque(116, 180, 255),
            roughness: 0.72,
            metallic: 0.0,
            ..Default::default()
        },
    );
    let radius = fit.radius.max(1.0);
    let target = three_d::vec3(fit.center[0], fit.center[1], fit.center[2]);
    let position = target + three_d::vec3(radius * 1.8, -radius * 2.4, radius * 1.6);
    let camera = three_d::Camera::new_perspective(
        viewport,
        position,
        target,
        three_d::vec3(0.0, 0.0, 1.0),
        three_d::degrees(45.0),
        (radius / 100.0).max(0.1),
        radius * 20.0,
    );
    let control = three_d::OrbitControl::new(target, radius * 0.05, radius * 12.0);
    (mesh, camera, control)
}

/// Standard three-light rig shared by the viewer and preview capture so both
/// produce identical lighting.
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
pub(crate) fn standard_lights(
    context: &three_d::Context,
) -> (
    three_d::AmbientLight,
    three_d::DirectionalLight,
    three_d::DirectionalLight,
) {
    let ambient_light =
        three_d::AmbientLight::new(context, 0.45, three_d::Srgba::new_opaque(255, 255, 255));
    let key_light = three_d::DirectionalLight::new(
        context,
        2.6,
        three_d::Srgba::new_opaque(255, 255, 255),
        three_d::vec3(-0.45, -0.55, -0.70),
    );
    let fill_light = three_d::DirectionalLight::new(
        context,
        0.9,
        three_d::Srgba::new_opaque(170, 205, 255),
        three_d::vec3(0.55, 0.35, -0.35),
    );
    (ambient_light, key_light, fill_light)
}

#[cfg(all(target_family = "wasm", target_os = "unknown"))]
impl ThreeDRenderer {
    /// Build the GPU mesh + camera framing for `model` against an existing
    /// context. Shared by the initial mount and mesh replacement.
    fn build_scene(
        context: &three_d::Context,
        canvas: &web_sys::HtmlCanvasElement,
        model: &ModelMesh,
    ) -> (
        three_d::Gm<three_d::Mesh, three_d::PhysicalMaterial>,
        three_d::Camera,
        three_d::OrbitControl,
    ) {
        build_scene_with_viewport(context, model, canvas_viewport(canvas))
    }

    fn resize_and_render(&mut self) {
        let viewport = canvas_viewport(&self.canvas);
        self.camera.set_viewport(viewport);
        self.render();
    }

    fn pointer_down(&mut self, x: f32, y: f32) {
        self.button_down = true;
        self.last_pointer = Some((x, y));
    }

    fn pointer_move(&mut self, x: f32, y: f32) {
        if !self.button_down {
            return;
        }
        let Some((last_x, last_y)) = self.last_pointer.replace((x, y)) else {
            return;
        };
        let mut event = three_d::Event::MouseMotion {
            button: Some(three_d::MouseButton::Left),
            delta: (x - last_x, y - last_y),
            position: (x, y).into(),
            modifiers: Default::default(),
            handled: false,
        };
        self.control
            .handle_events(&mut self.camera, std::slice::from_mut(&mut event));
        self.render();
    }

    fn pointer_up(&mut self) {
        self.button_down = false;
        self.last_pointer = None;
    }

    fn wheel(&mut self, delta_y: f32, x: f32, y: f32) {
        let mut event = three_d::Event::MouseWheel {
            delta: (0.0, -delta_y),
            position: (x, y).into(),
            modifiers: Default::default(),
            handled: false,
        };
        self.control
            .handle_events(&mut self.camera, std::slice::from_mut(&mut event));
        self.render();
    }

    fn render(&mut self) {
        let viewport = self.camera.viewport();
        let screen = three_d::RenderTarget::screen(&self.context, viewport.width, viewport.height);
        let _ = screen
            .clear(three_d::ClearState::color_and_depth(
                0.035, 0.043, 0.067, 1.0, 1.0,
            ))
            .render(
                &self.camera,
                [&self.mesh],
                &[&self.ambient_light, &self.key_light, &self.fill_light],
            );
        if !self.first_render_logged {
            self.first_render_logged = true;
            let elapsed = web_sys::window()
                .and_then(|window| window.performance())
                .map(|performance| performance.now() - self.frame_started_ms)
                .unwrap_or(0.0);
            tracing::info!(
                backend = "WebGL2",
                first_render_ms = elapsed,
                "three-d renderer ready"
            );
        }
    }
}

#[cfg(all(target_family = "wasm", target_os = "unknown"))]
impl ViewerRenderer for ThreeDRenderer {
    type Canvas = ();
    type Error = ThreeDRendererError;

    fn mount(_: Self::Canvas, mount: RendererMount) -> Result<Self, Self::Error> {
        use wasm_bindgen::JsCast;

        let model = &mount.mesh.mesh;
        let window = web_sys::window().ok_or(ThreeDRendererError::WindowUnavailable)?;
        let document = window
            .document()
            .ok_or(ThreeDRendererError::DocumentUnavailable)?;
        let canvas = document
            .get_element_by_id("polymodel-viewer-canvas")
            .ok_or(ThreeDRendererError::CanvasNotFound)?
            .dyn_into::<web_sys::HtmlCanvasElement>()
            .map_err(|_| ThreeDRendererError::NotCanvas)?;
        let gl = canvas
            .get_context("webgl2")
            .map_err(|_| ThreeDRendererError::WebGL2Unavailable)?
            .ok_or(ThreeDRendererError::WebGL2Unavailable)?
            .dyn_into::<web_sys::WebGl2RenderingContext>()
            .map_err(|_| ThreeDRendererError::NotWebGL2)?;
        let glow_context = three_d::context::Context::from_webgl2_context(gl);
        #[allow(
            clippy::arc_with_non_send_sync,
            reason = "three-d requires Arc<glow::Context> for browser WebGL context construction"
        )]
        let context = three_d::Context::from_gl_context(std::sync::Arc::new(glow_context))?;
        tracing::info!(backend = "WebGL2", "three-d WebGL2 context acquired");

        let (mesh, camera, control) = Self::build_scene(&context, &canvas, model);
        let (ambient_light, key_light, fill_light) = standard_lights(&context);
        let frame_started_ms = window
            .performance()
            .map(|performance| performance.now())
            .unwrap_or(0.0);

        Ok(Self {
            canvas,
            context,
            mesh,
            ambient_light,
            key_light,
            fill_light,
            camera,
            control,
            button_down: false,
            last_pointer: None,
            frame_started_ms,
            first_render_logged: false,
        })
    }

    fn replace_mesh(&mut self, mount: RendererMount) -> Result<(), Self::Error> {
        let model = &mount.mesh.mesh;
        let (mesh, camera, control) = Self::build_scene(&self.context, &self.canvas, model);
        self.mesh = mesh;
        self.camera = camera;
        self.control = control;
        self.render();
        Ok(())
    }

    fn resize(&mut self) -> Result<(), Self::Error> {
        self.resize_and_render();
        Ok(())
    }
}

#[cfg(all(target_family = "wasm", target_os = "unknown"))]
fn canvas_viewport(canvas: &web_sys::HtmlCanvasElement) -> three_d::Viewport {
    let rect = canvas.get_bounding_client_rect();
    let scale = web_sys::window()
        .map(|window| window.device_pixel_ratio())
        .unwrap_or(1.0);
    let width = ((rect.width() * scale).round() as u32).max(1);
    let height = ((rect.height() * scale).round() as u32).max(1);
    canvas.set_width(width);
    canvas.set_height(height);
    three_d::Viewport::new_at_origo(width, height)
}

#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
fn mark_canvas_mounted(_: MountedEvent, mut mounted: Signal<bool>) {
    mounted.set(true);
}

#[cfg(all(target_family = "wasm", target_os = "unknown"))]
fn mark_canvas_mounted(event: MountedEvent, mut mounted: Signal<bool>) {
    use dioxus::web::WebEventExt;
    use wasm_bindgen::JsCast;
    let element = event.as_web_event();
    mounted.set(element.dyn_into::<web_sys::HtmlCanvasElement>().is_ok());
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::mesh::contract::{LengthUnit, MeshFormat, Units};
    use crate::mesh::stl::load_stl;
    use std::cell::Cell;
    use std::rc::Rc;

    fn fixture_bytes(asset_path: &str) -> Vec<u8> {
        let path = format!("{}/public{}", env!("CARGO_MANIFEST_DIR"), asset_path);
        std::fs::read(&path)
            .unwrap_or_else(|_| panic!("demo asset {asset_path} should be committed at {path}"))
    }

    async fn parsed_for(model: DemoModel) -> Arc<ParsedMesh> {
        let bytes = fixture_bytes(model.asset_path);
        let source = MeshSource {
            format: MeshFormat::Stl,
            primary: &bytes,
            resources: MeshResources::empty(),
        };
        build_parsed_mesh(&source, &ProductionMeshParser::default())
            .await
            .expect("demo STL asset should parse")
    }

    async fn mount_for(model: DemoModel) -> RendererMount {
        let parsed = parsed_for(model).await;
        let summary = mesh_summary_from_model(model, &parsed.mesh);
        RendererMount::new(SessionKey::new(model.id, model.generation), parsed, summary)
    }

    #[derive(Clone)]
    struct FakeCanvas;

    struct FakeRenderer {
        events: Rc<Cell<usize>>,
        drops: Rc<Cell<usize>>,
    }

    impl ViewerRenderer for FakeRenderer {
        type Canvas = (FakeCanvas, Rc<Cell<usize>>, Rc<Cell<usize>>);
        type Error = ();

        fn mount(canvas: Self::Canvas, _mount: RendererMount) -> Result<Self, Self::Error> {
            canvas.1.set(canvas.1.get() + 1);
            Ok(Self {
                events: canvas.1,
                drops: canvas.2,
            })
        }

        fn replace_mesh(&mut self, _mount: RendererMount) -> Result<(), Self::Error> {
            self.events.set(self.events.get() + 1);
            Ok(())
        }

        fn resize(&mut self) -> Result<(), Self::Error> {
            self.events.set(self.events.get() + 1);
            Ok(())
        }
    }

    impl Drop for FakeRenderer {
        fn drop(&mut self) {
            self.drops.set(self.drops.get() + 1);
        }
    }

    #[test]
    fn demo_route_uses_committed_stl_asset() {
        let model = demo_model_for_route("body-f-chest-v4");
        assert_eq!(model.asset_path, "/models/body_f_chest-v4.stl");
        assert_eq!(model.vertices, 41051);
        assert_eq!(model.triangles, 82122);
    }

    #[test]
    fn demo_model_metadata_matches_committed_fixture() {
        let model = demo_models()[0];
        let bytes = fixture_bytes(model.asset_path);
        let mesh = load_stl(&bytes).expect("demo STL asset should parse");

        assert_eq!(mesh.trimesh.positions.len(), model.vertices);
        assert_eq!(mesh.trimesh.triangle_count(), model.triangles);
        assert_eq!(mesh.format, MeshFormat::Stl);
        assert_eq!(
            mesh.units,
            Units {
                source: LengthUnit::Unknown,
                assumed: Some(LengthUnit::Millimeter),
            }
        );
    }

    #[test]
    fn switching_models_advances_session_generation() {
        let first = SessionKey::new("body-f-chest-v4", 7);
        let second = first.switch_to("body-f-forearm-2x-v9");

        assert_eq!(second.model_id(), "body-f-forearm-2x-v9");
        assert_eq!(second.generation(), 8);
    }

    #[test]
    fn status_labels_are_user_facing() {
        let ready = ViewerStatus::Ready(SessionKey::new("body-f-chest-v4", 1));
        let loading = ViewerStatus::Loading(SessionKey::new("body-f-chest-v4", 1));

        assert_eq!(ready.label(), "Viewer ready");
        assert_eq!(loading.label(), "Loading model");
        assert_eq!(
            ViewerStatus::MeshError(ViewerIssue::MeshUnavailable).label(),
            "Mesh unavailable"
        );
        assert!(ready.should_have_session());
        assert!(loading.should_have_session());
        assert!(!ViewerStatus::MeshError(ViewerIssue::MeshUnavailable).should_have_session());
    }

    #[tokio::test]
    async fn parsed_mesh_equality_uses_content_hash() {
        let chest = parsed_for(DEMO_MODELS[0]).await;
        let chest_again = parsed_for(DEMO_MODELS[0]).await;
        let forearm = parsed_for(DEMO_MODELS[1]).await;

        assert_eq!(chest, chest_again, "same source bytes must be equal");
        assert_ne!(chest, forearm, "different source bytes must differ");
        assert_eq!(&chest, &chest_again, "Arc/ParsedMesh compare by hash");
    }

    #[tokio::test]
    async fn parses_each_loaded_mesh_once() {
        struct CountingParser {
            calls: Rc<Cell<usize>>,
        }
        impl CountingParser {
            fn new() -> Self {
                Self {
                    calls: Rc::new(Cell::new(0)),
                }
            }
        }
        impl MeshParser for CountingParser {
            async fn parse(&self, source: &MeshSource<'_>) -> Result<ModelMesh, MeshLoadError> {
                self.calls.set(self.calls.get() + 1);
                crate::mesh::stl::load_stl(source.primary)
            }
        }

        let parser = CountingParser::new();
        let bytes = fixture_bytes(DEMO_MODELS[0].asset_path);
        let source = MeshSource {
            format: MeshFormat::Stl,
            primary: &bytes,
            resources: MeshResources::empty(),
        };
        let parsed = build_parsed_mesh(&source, &parser).await.expect("parses");
        assert_eq!(
            parser.calls.get(),
            1,
            "the boundary must parse once per load"
        );

        let again = build_parsed_mesh(&source, &parser).await.unwrap();
        assert_eq!(
            again.content_hash, parsed.content_hash,
            "the same bytes must hash to the same identity"
        );
    }

    #[test]
    fn identity_changes_when_a_companion_changes() {
        // No companions -> reduces to hashing the primary.
        let primary = b"primary bytes";
        let none = identity_hash(primary, &[]);
        assert_eq!(none, hash_bytes(primary));

        let with_a = identity_hash(primary, &[("box.bin".to_string(), b"AAA".to_vec())]);
        let with_b = identity_hash(primary, &[("box.bin".to_string(), b"BBB".to_vec())]);
        assert_ne!(none, with_a, "adding a companion changes identity");
        assert_ne!(with_a, with_b, "changing a companion changes identity");
        assert_eq!(
            with_a,
            identity_hash(primary, &[("box.bin".to_string(), b"AAA".to_vec())]),
            "same companions hash to the same identity"
        );
    }

    #[test]
    fn typed_renderer_error_covers_all_cases_and_maps_to_renderer_init() {
        let errors: Vec<ThreeDRendererError> = vec![
            ThreeDRendererError::WindowUnavailable,
            ThreeDRendererError::DocumentUnavailable,
            ThreeDRendererError::CanvasNotFound,
            ThreeDRendererError::NotCanvas,
            ThreeDRendererError::WebGL2Unavailable,
            ThreeDRendererError::NotWebGL2,
            ThreeDRendererError::Core(three_d::core::CoreError::ContextCreation("boom".into())),
        ];
        for err in errors {
            let issue: ViewerIssue = err.into();
            assert_eq!(issue, ViewerIssue::RendererInit);
        }
    }

    #[tokio::test]
    async fn loaded_mesh_carries_parsed_payload_and_summary() {
        let model = DEMO_MODELS[0];
        let loaded = load_demo_mesh(model)
            .await
            .expect("non-wasm disk load should succeed");

        assert_eq!(loaded.model, model);
        assert_eq!(loaded.summary.asset_path, model.asset_path);
        assert_eq!(loaded.summary.vertices, model.vertices);
        assert_eq!(loaded.summary.triangles, model.triangles);
        assert_eq!(
            loaded.parsed.content_hash,
            hash_bytes(&fixture_bytes(model.asset_path))
        );
    }

    #[tokio::test]
    async fn renderer_lifecycle_mounts_replaces_resizes_and_drops() {
        let events = Rc::new(Cell::new(0));
        let drops = Rc::new(Cell::new(0));
        let canvas = (FakeCanvas, Rc::clone(&events), Rc::clone(&drops));
        let mut lifecycle = RendererLifecycle::<FakeRenderer>::new();
        let chest = mount_for(DEMO_MODELS[0]).await;
        let forearm = mount_for(DEMO_MODELS[1]).await;
        let ready_chest = ViewerStatus::Ready(chest.key.clone());

        // Initial mount.
        lifecycle
            .reconcile(Some(canvas.clone()), &ready_chest, Some(chest.clone()))
            .expect("mount should succeed");
        assert!(lifecycle.has_active_session());
        assert!(lifecycle.active_mut().is_some());
        assert_eq!(
            events.get(),
            2,
            "initial mount must also draw the first frame"
        );
        assert_eq!(lifecycle.mounts(), 1);

        // Resize forwards to the active renderer.
        lifecycle.resize().expect("resize should forward");
        assert_eq!(events.get(), 3);

        // Same-identity re-render is a no-op (no replace).
        lifecycle
            .reconcile(Some(canvas.clone()), &ready_chest, Some(chest.clone()))
            .expect("same-identity reconcile should succeed");
        assert_eq!(events.get(), 3);

        // Switch to a different model: replace once, still a single mount.
        let ready_forearm = ViewerStatus::Ready(forearm.key.clone());
        lifecycle
            .reconcile(Some(canvas.clone()), &ready_forearm, Some(forearm.clone()))
            .expect("replace should succeed");
        assert_eq!(events.get(), 4);
        assert_eq!(drops.get(), 0);
        assert_eq!(lifecycle.mounts(), 1);

        // Loading window (no mesh yet) keeps the active renderer alive.
        lifecycle
            .reconcile(
                Some(canvas.clone()),
                &ViewerStatus::Loading(SessionKey::new("body-f-forearm-2x-v9", 1)),
                None,
            )
            .expect("loading reconcile should succeed");
        assert!(lifecycle.has_active_session());
        assert_eq!(drops.get(), 0);

        // A mesh-load error also keeps the retained renderer.
        lifecycle
            .reconcile(
                Some(canvas.clone()),
                &ViewerStatus::MeshError(ViewerIssue::MeshUnavailable),
                None,
            )
            .expect("mesh-error reconcile should succeed");
        assert!(lifecycle.has_active_session());

        // A hard renderer error tears the session down.
        lifecycle
            .reconcile(
                Some(canvas.clone()),
                &ViewerStatus::RendererError(ViewerIssue::RendererInit),
                Some(forearm.clone()),
            )
            .expect("renderer-error reconcile should succeed");
        assert!(!lifecycle.has_active_session());
        assert!(lifecycle.active_mut().is_none());
        assert_eq!(drops.get(), 1);

        // Canvas gone with no active session is a clean no-op.
        lifecycle
            .reconcile(None, &ready_forearm, None)
            .expect("drop path should succeed");
        assert!(!lifecycle.has_active_session());
        assert_eq!(drops.get(), 1);
    }
}
