// GPU rendering primitives, shader::Program / shader::Primitive impls,
// and entity render-style helpers for the Scene.

use acadrust::tables::LineType;
use acadrust::types::{Color as AcadColor, LineWeight};
use acadrust::{CadDocument, EntityType, Handle};
use glam::Mat4;
use iced::mouse;
use iced::widget::shader::{self, Viewport};
use iced::{Rectangle, Size};

use std::sync::Arc;

use crate::scene::pipeline::viewcube::{hover_id, VIEWCUBE_PX};
use crate::scene::pipeline::MultiPipeline;
use crate::scene::convert::tess_util;
use crate::scene::{HatchModel, ImageModel, MeshLodSet, Scene, Uniforms, ViewportInstance, WireModel};

// ── Camera hover state (shader::Program::State) ───────────────────────────

#[derive(Clone, Default)]
pub struct CameraState {
    pub hover_region: Option<usize>,
}

// ── GPU primitive ─────────────────────────────────────────────────────────

/// Everything needed to render one viewport: its geometry, camera, render
/// mode, and the screen rectangle it occupies. The unified renderer carries
/// a `Vec<ViewportData>` (one per tiled / floating viewport); each gets its
/// own inner `Pipeline` instance drawn into its own rectangle.
#[derive(Debug)]
pub struct ViewportData {
    pub(in crate::scene) wires: Arc<Vec<WireModel>>,
    /// Live command-preview / interim / grip-drag overlay wires. Kept out of
    /// the main `wires` buffer so a drag re-uploads only this small set each
    /// frame, never the resident base buffer. Drawn on top in the wire pass.
    pub(in crate::scene) preview_wires: Arc<Vec<WireModel>>,
    /// 3DFACE entity wires — separated so they are uploaded to the dedicated
    /// face3d pipeline (fill + batched edges) instead of N individual WireGpu.
    pub(in crate::scene) face3d_wires: Arc<Vec<WireModel>>,
    /// Per-entity normalized draw-order depth (handle.value() → (0,1)), used
    /// by the wire / face3d pipelines as a clip-z bias. WireModels carry no
    /// depth field (84 construction sites); the bias is looked up by handle
    /// at GPU-upload time from this map instead.
    pub(in crate::scene) draw_depths: Arc<rustc_hash::FxHashMap<u64, f32>>,
    pub(in crate::scene) hatches: Arc<Vec<HatchModel>>,
    /// Wipeout fills — rendered in a separate pass AFTER wires.
    pub(in crate::scene) wipeout_hatches: Arc<Vec<HatchModel>>,
    pub(in crate::scene) images: Arc<Vec<ImageModel>>,
    pub(in crate::scene) meshes: Arc<Vec<MeshLodSet>>,
    pub(in crate::scene) uniforms: Uniforms,
    /// Camera rotation matrix derived from the quaternion.
    /// Used by the ViewCube pipeline — no gimbal lock.
    pub(in crate::scene) cam_rotation: Mat4,
    pub(in crate::scene) hover_region: Option<usize>,
    pub(in crate::scene) show_viewcube: bool,
    /// Header.fill_mode (FILLMODE): when false, hatch / wipeout / face3d-fill
    /// uploads short-circuit so the renderer draws only wireframe.
    pub(in crate::scene) fill_mode: bool,
    /// Per-view "Wireframe vs Solid" toggle. When `true`, 3D face fills
    /// are dropped on the upload path so 3D faces draw as edges only.
    /// Hatch / wipeout uploads are deliberately *not* gated by this flag —
    /// the user toggle should only affect 3D solids, not 2D fills.
    pub(in crate::scene) view_wireframe: bool,
    /// Whether the active render mode wants 3D mesh fills uploaded. Off
    /// in `Wireframe2D` / `Wireframe3D`; on for every shaded variant. Set
    /// at the same point `view_wireframe` is computed so the two stay in
    /// lock-step for the gating logic in `prepare()`.
    pub(in crate::scene) mesh_fill: bool,
    /// Whether the active render mode wants 3D mesh / face edges
    /// rendered on top of fills. Most shaded modes turn this off; the
    /// `*WithEdges` variants and the pure wireframes leave it on.
    pub(in crate::scene) show_3d_edges: bool,
    /// HiddenLine routes 3D fills through a depth-only prepass so edges
    /// occluded by closer geometry are culled by the LessEqual depth
    /// test on the wire passes that follow.
    pub(in crate::scene) hidden_line: bool,
    pub(in crate::scene) geometry_epoch: u64,
    /// Camera generation captured when this Primitive was assembled. Paired
    /// with `geometry_epoch` so the per-frame scissor / LOD recompute runs.
    pub(in crate::scene) camera_generation: u64,
    /// Content id of `wires` (Phase 3.2). Stable across a pure pan that reuses
    /// the Model-tile tessellation, so `prepare` skips re-uploading the
    /// world-space wire buffer when only the camera moved. Non-tile and
    /// preview/interim frames carry a fresh id each time → always re-upload.
    pub(in crate::scene) wire_content_id: u64,
    /// `selected ∪ hover` handles for the GPU xray overlay. The highlight is no
    /// longer baked into the wire tessellation, so `prepare` rebuilds the
    /// selected-wire batch by filtering `wires` against this set.
    pub(in crate::scene) highlight_handles: Arc<rustc_hash::FxHashSet<acadrust::Handle>>,
    /// Bumped on selection / hover change. Paired with `wire_content_id` to
    /// decide when the xray overlay batch needs rebuilding.
    pub(in crate::scene) selection_generation: u64,
    /// Signature of the *selected set* only (not hover). Gates the static-buffer
    /// re-upload (hatch tint, issue #71) so a hover doesn't re-upload every
    /// hatch / face3d buffer on hatch-heavy drawings.
    pub(in crate::scene) selected_sig: u64,
    /// Screen rectangle this viewport fills, **normalized** to the widget
    /// bounds (each component in 0..1). A single full-widget view is
    /// `(0, 0, 1, 1)`; tiled / floating viewports are sub-rectangles.
    /// Normalized form lets `render()` derive the physical sub-clip from
    /// the surface clip without needing the scale factor.
    pub(in crate::scene) screen_rect: Rectangle,
}

#[derive(Debug)]
pub struct Primitive {
    /// One entry per viewport drawn this frame (≥1).
    pub(in crate::scene) viewports: Vec<ViewportData>,
    /// Background color used to clear each viewport's MSAA buffer.
    pub(in crate::scene) bg_color: [f32; 4],
}

/// Flags the render pipeline consumes, derived from
/// [`acadrust::entities::ViewportRenderMode`]. Each shaded variant fills
/// 3D faces and meshes; the pure wireframes drop the fill and keep only
/// edges. `*WithEdges` variants render both. HiddenLine uses a depth
/// prepass: face/mesh fills are uploaded but routed through depth-only
/// pipelines so hidden edges drop out. `FlatShaded` vs `GouraudShaded`
/// differ in shader uniform only and produce identical fill flags here.
#[derive(Clone, Copy, Debug)]
pub struct RenderModeFlags {
    pub face3d_fill: bool,
    pub mesh_fill: bool,
    pub show_3d_edges: bool,
    pub hidden_line: bool,
    /// `true` for FlatShaded / FlatShadedWithEdges. The mesh shader
    /// reads `Uniforms.flat_shade` and replaces the smooth per-vertex
    /// normal with a per-triangle face normal so each triangle reads
    /// as a single tone.
    pub flat_shade: bool,
}

pub fn render_mode_flags(
    mode: acadrust::entities::ViewportRenderMode,
) -> RenderModeFlags {
    use acadrust::entities::ViewportRenderMode as M;
    match mode {
        M::Wireframe2D | M::Wireframe3D => RenderModeFlags {
            face3d_fill: false,
            mesh_fill: false,
            show_3d_edges: true,
            hidden_line: false,
            flat_shade: false,
        },
        M::HiddenLine => RenderModeFlags {
            face3d_fill: true,
            mesh_fill: true,
            show_3d_edges: true,
            hidden_line: true,
            flat_shade: false,
        },
        M::FlatShaded => RenderModeFlags {
            face3d_fill: true,
            mesh_fill: true,
            show_3d_edges: false,
            hidden_line: false,
            flat_shade: true,
        },
        M::GouraudShaded => RenderModeFlags {
            face3d_fill: true,
            mesh_fill: true,
            show_3d_edges: false,
            hidden_line: false,
            flat_shade: false,
        },
        M::FlatShadedWithEdges => RenderModeFlags {
            face3d_fill: true,
            mesh_fill: true,
            show_3d_edges: true,
            hidden_line: false,
            flat_shade: true,
        },
        M::GouraudShadedWithEdges => RenderModeFlags {
            face3d_fill: true,
            mesh_fill: true,
            show_3d_edges: true,
            hidden_line: false,
            flat_shade: false,
        },
    }
}

// ── shader::Primitive impl ────────────────────────────────────────────────

impl shader::Primitive for Primitive {
    type Pipeline = MultiPipeline;

    fn prepare(
        &self,
        pipeline: &mut MultiPipeline,
        device: &iced::wgpu::Device,
        queue: &iced::wgpu::Queue,
        bounds: &Rectangle,
        viewport: &Viewport,
    ) {
        let phys = viewport.physical_size();
        let full_size = Size::new(phys.width, phys.height);
        let scale = viewport.scale_factor() as f32;
        pipeline.ensure_len(device, queue, self.viewports.len());

        for (i, vp) in self.viewports.iter().enumerate() {
            let inner = &mut pipeline.inners[i];
            // The MSAA / depth / resolve textures are always sized to the
            // FULL viewport rectangle (not the on-canvas-visible portion)
            // so the camera matrices render at consistent aspect / scale.
            // The blit step picks the visible sub-rectangle out via the
            // shader's UV crop uniform, which lets partially off-canvas
            // viewports composite to their visible surface area without
            // drift.
            let clip_size = Size::new(
                (vp.screen_rect.width * bounds.width * scale).ceil().max(1.0) as u32,
                (vp.screen_rect.height * bounds.height * scale).ceil().max(1.0) as u32,
            );
            inner.ensure_depth_texture(device, clip_size);
            inner.viewcube.ensure_depth_texture(device, full_size);
            // Compute the UV crop for this viewport. `screen_rect` is in
            // normalized canvas units (0..1) but may extend negative or
            // beyond 1 when the viewport hangs off the canvas. The on-
            // canvas portion in viewport-local UV is straightforward to
            // derive from how much sticks out on each side.
            let sr = vp.screen_rect;
            let (uo_x, us_x) = uv_crop_axis(sr.x, sr.width);
            let (uo_y, us_y) = uv_crop_axis(sr.y, sr.height);
            inner.upload_blit_uv(queue, [uo_x, uo_y], [us_x, us_y]);
            inner.upload_uniforms(queue, &vp.uniforms);
            // Third component is the *selected-set* signature (not
            // selection_generation, which also bumps on hover) so a rollover
            // doesn't re-upload the static hatch / face3d buffers.
            let cur_key = (vp.geometry_epoch, vp.camera_generation, vp.selected_sig);
            let fill_mode = vp.fill_mode;
            // 3D face fill requires *both* the doc-level FILLMODE *and* the
            // per-view Solid toggle. Hatches / wipeouts deliberately ignore
            // the view toggle so 2D fills stay on even when the user picks
            // the Wireframe overlay style.
            let face3d_fill_active = fill_mode && !vp.view_wireframe;
            if cur_key != inner.cached_epoch {
                // Hatches carry a selected-tint, so re-upload on a geometry OR
                // a selection change (issue #71); images / meshes only need a
                // geometry change.
                let geo_changed = vp.geometry_epoch != inner.cached_epoch.0;
                let sel_changed = vp.selected_sig != inner.cached_epoch.2;
                if geo_changed || sel_changed {
                    if fill_mode {
                        inner.upload_hatches(device, &vp.hatches[..]);
                        inner.upload_wipeouts(device, &vp.wipeout_hatches[..]);
                    } else {
                        inner.upload_hatches(device, &[]);
                        inner.upload_wipeouts(device, &[]);
                    }
                }
                if geo_changed {
                    inner.upload_images(device, queue, &vp.images[..]);
                    inner.upload_meshes(device, &vp.meshes[..]);
                }
                inner.upload_face3d(
                    device,
                    &vp.face3d_wires[..],
                    &vp.wires[..],
                    !face3d_fill_active,
                    &vp.draw_depths,
                );
                inner.cached_epoch = cur_key;
            }
            // Wire buffers are world-space, so a camera move alone doesn't
            // change them — only the view_proj uniform (uploaded every frame).
            // Gate the upload on the wire content id instead of the camera tick
            // (Phase 3.2): a pan that reused the Model-tile tessellation keeps
            // the id, skipping the vertex re-pack + GPU write. Kept independent
            // of the `cur_key` block so a preview/interim wire change still
            // uploads even when the camera didn't move.
            if vp.wire_content_id != inner.cached_wire_id {
                inner.upload_wires(device, &vp.wires[..], &vp.draw_depths);
                inner.cached_wire_id = vp.wire_content_id;
            }
            // Selection xray overlay — rebuilt when the selection changes or the
            // underlying wires changed. A pick bumps only selection_generation,
            // so this refreshes without re-tessellating or re-uploading the main
            // wire buffers.
            let sel_key = (vp.wire_content_id, vp.selection_generation);
            if sel_key != inner.cached_selection {
                inner.upload_selected_wires(
                    device,
                    &vp.wires[..],
                    &vp.highlight_handles,
                    &vp.draw_depths,
                );
                inner.cached_selection = sel_key;
            }
            // Live overlay (command preview / interim / grip drag) — small and
            // refreshed every frame it's present, so a drag never re-uploads
            // the resident base wire buffer.
            inner.upload_preview_wires(device, &vp.preview_wires[..], &vp.draw_depths);
            let vproj = vp.uniforms.view_proj;
            inner.compute_wire_scissors(vproj, clip_size.width, clip_size.height);
            inner.compute_wipeout_scissors(vproj, clip_size.width, clip_size.height);
            inner.compute_image_scissors(vproj, clip_size.width, clip_size.height);
            inner.compute_hatch_lod(queue, vproj, clip_size.width, clip_size.height);
            inner.compute_wipeout_lod(vproj, clip_size.width, clip_size.height);
            inner.compute_mesh_lod(vproj, clip_size.width, clip_size.height);
            if vp.show_viewcube {
                inner.viewcube.upload(
                    queue,
                    vp.cam_rotation,
                    (vp.screen_rect.width * bounds.width) as u32,
                    (vp.screen_rect.height * bounds.height) as u32,
                    vp.hover_region,
                );
            }
        }
    }

    fn render(
        &self,
        pipeline: &MultiPipeline,
        encoder: &mut iced::wgpu::CommandEncoder,
        target: &iced::wgpu::TextureView,
        clip: &Rectangle<u32>,
    ) {
        let cw = clip.width as f32;
        let ch = clip.height as f32;
        let clip_right = clip.x + clip.width;
        let clip_bottom = clip.y + clip.height;
        for (i, vp) in self.viewports.iter().enumerate() {
            let Some(inner) = pipeline.inners.get(i) else {
                break;
            };
            // Where the viewport would land on the surface in absolute
            // pixels (i32 because either edge may stick off the canvas).
            let vp_full_x = clip.x as i32 + (vp.screen_rect.x * cw) as i32;
            let vp_full_y = clip.y as i32 + (vp.screen_rect.y * ch) as i32;
            let vp_full_w = (vp.screen_rect.width * cw).max(1.0) as i32;
            let vp_full_h = (vp.screen_rect.height * ch).max(1.0) as i32;
            // Intersect with the surface clip — that's the slice we blit.
            let dest_x = vp_full_x.max(clip.x as i32);
            let dest_y = vp_full_y.max(clip.y as i32);
            let dest_right = (vp_full_x + vp_full_w).min(clip_right as i32);
            let dest_bottom = (vp_full_y + vp_full_h).min(clip_bottom as i32);
            if dest_right <= dest_x || dest_bottom <= dest_y {
                continue;
            }
            let surface_dest = Rectangle {
                x: dest_x as u32,
                y: dest_y as u32,
                width: (dest_right - dest_x) as u32,
                height: (dest_bottom - dest_y) as u32,
            };
            let vp_size = Size::new(vp_full_w.max(1) as u32, vp_full_h.max(1) as u32);
            // `mesh_fill` is false for Wireframe 2D / Wireframe 3D — flip
            // the draw path so meshes use the wireframe pipeline + the
            // pre-built triangle-edge index buffer.
            let mesh_wireframe = !vp.mesh_fill;
            inner.render(
                encoder,
                target,
                vp_size,
                surface_dest,
                self.bg_color,
                mesh_wireframe,
                vp.hidden_line,
                vp.show_3d_edges,
            );
            // The ViewCube renders directly to the surface at the full
            // viewport rect. Skip it when the viewport's top-right corner
            // (where the cube sits) is off-canvas — wgpu's `set_viewport`
            // rejects negative origins, and a clamped cube would scale
            // distortedly. The active viewport is normally fully visible.
            if vp.show_viewcube
                && vp_full_x >= clip.x as i32
                && vp_full_y >= clip.y as i32
                && vp_full_x + vp_full_w <= clip_right as i32
                && vp_full_y + vp_full_h <= clip_bottom as i32
            {
                let vp_clip = Rectangle {
                    x: vp_full_x as u32,
                    y: vp_full_y as u32,
                    width: vp_full_w as u32,
                    height: vp_full_h as u32,
                };
                inner.viewcube.render(encoder, target, vp_clip);
            }
        }
    }
}

/// On-canvas-visible UV crop on one axis. `pos` and `size` are in the
/// shader widget's normalized 0..1 coords. Returns `(uv_offset, uv_scale)`
/// applied as `actual_uv = quad_uv * uv_scale + uv_offset` in the blit
/// shader — identity `(0.0, 1.0)` for fully on-canvas viewports.
fn uv_crop_axis(pos: f32, size: f32) -> (f32, f32) {
    if size <= 0.0 {
        return (0.0, 1.0);
    }
    let left_off = (-pos).max(0.0);
    let right_off = (pos + size - 1.0).max(0.0);
    let visible = (size - left_off - right_off).max(0.0);
    (left_off / size, visible / size)
}

/// Apply a clip-space crop to `view_proj` so the sub-rect of the original
/// view defined by UV offset `(uo, vo)` + scale `(us, vs)` is remapped to
/// NDC `[-1, 1]^2`. Identity transform when the sub-rect is the whole
/// view (`uo=vo=0`, `us=vs=1`). Used by viewports that hang off the
/// canvas — the camera frustum stays at full-vp aspect, but only the
/// visible portion lands in the MSAA target.
fn crop_view_proj(view_proj: glam::Mat4, uo: f32, vo: f32, us: f32, vs: f32) -> glam::Mat4 {
    // Build the matrix that maps the visible clip-space sub-rect
    //   x ∈ [2uo - 1, 2(uo+us) - 1]
    //   y ∈ [1 - 2(vo+vs), 1 - 2vo]
    // back to NDC [-1, 1]^2. (Texture v is top-down → camera y flips.)
    let us = us.max(1e-6);
    let vs = vs.max(1e-6);
    let sx = 1.0 / us;
    let sy = 1.0 / vs;
    let tx = (1.0 - 2.0 * uo - us) / us;
    let ty = -(1.0 - 2.0 * vo - vs) / vs;
    let crop = glam::Mat4::from_cols_array(&[
        sx, 0.0, 0.0, 0.0, // col 0
        0.0, sy, 0.0, 0.0, // col 1
        0.0, 0.0, 1.0, 0.0, // col 2
        tx, ty, 0.0, 1.0, // col 3
    ]);
    crop * view_proj
}

// ── Render-style helpers (impl Scene) ────────────────────────────────────

impl Scene {
    /// Returns (entity_color, pattern_length, pattern, line_weight_px, aci).
    pub(in crate::scene) fn render_style(&self, e: &EntityType) -> ([f32; 4], f32, [f32; 8], f32, u8) {
        let (color, pl, pat, lw, aci) = render_style_for(&self.document, e);
        let bg = if self.current_layout == "Model" {
            self.bg_color
        } else {
            self.paper_bg_color
        };
        (adapt_to_bg(color, bg), pl, pat, lw, aci)
    }
}

// ── Document-only render-style helpers (no &self, safe to call from parallel contexts) ──

/// Resolves the effective linetype name for an entity, falling back to the
/// layer's linetype when the entity's own linetype is "ByLayer".
pub(in crate::scene) fn linetype_name_for<'a>(document: &'a CadDocument, e: &'a EntityType) -> &'a str {
    let elt = &e.common().linetype;
    if elt.is_empty() || elt.eq_ignore_ascii_case("bylayer") {
        document
            .layers
            .get(&e.common().layer)
            .map(|l| l.line_type.as_str())
            .unwrap_or("Continuous")
    } else {
        elt.as_str()
    }
}

/// Returns `(entity_color, pattern_length, pattern, line_weight_px, aci)` for
/// an entity, resolving ByLayer color and linetype from the document.
pub(in crate::scene) fn render_style_for(
    document: &CadDocument,
    e: &EntityType,
) -> ([f32; 4], f32, [f32; 8], f32, u8) {
    let layer_name = &e.common().layer;
    let (entity_color, aci) = {
        let ec = &e.common().color;
        let resolved = if *ec == AcadColor::ByLayer {
            document
                .layers
                .get(layer_name)
                .map(|l| &l.color)
                .unwrap_or(&AcadColor::WHITE)
        } else {
            ec
        };
        let aci = match resolved {
            AcadColor::Index(i) => *i,
            _ => 0,
        };
        let [r, g, b, _] = tess_util::aci_to_rgba(resolved);
        let alpha = 1.0 - e.common().transparency.as_percent() as f32;
        ([r, g, b, alpha], aci)
    };

    let lt_name = linetype_name_for(document, e);
    // Effective scale = global LTSCALE × per-entity scale (both default to 1.0).
    let lt_scale = document.header.linetype_scale as f32 * e.common().linetype_scale as f32;
    let (pattern_length, pattern) = resolve_pattern(&document.line_types, lt_name, lt_scale);

    let line_weight_px = {
        // LWDISPLAY is no longer evaluated here — the toggle is now applied in
        // the wire shader via `Uniforms.lwdisplay_enable`, so we always bake the
        // entity's resolved (layer-inherited) weight. Toggling lineweight
        // visibility costs only a uniform write, not a retessellate.
        let ew = &e.common().line_weight;
        let resolved = match ew {
            LineWeight::ByLayer | LineWeight::ByBlock | LineWeight::Default => document
                .layers
                .get(layer_name)
                .map(|l| &l.line_weight)
                .unwrap_or(&LineWeight::Default),
            _ => ew,
        };
        const MM_TO_PX: f32 = 96.0 / 25.4;
        resolved
            .millimeters()
            .map(|mm| (mm as f32 * MM_TO_PX).max(1.0))
            .unwrap_or(1.0)
    };

    (entity_color, pattern_length, pattern, line_weight_px, aci)
}

/// Like `render_style_for` but resolves ByBlock properties by inheriting from
/// the INSERT entity's already-resolved style. Call this for exploded block
/// sub-entities so that ByBlock color/linetype/lineweight propagate correctly.
pub(crate) fn render_style_for_block_sub(
    document: &CadDocument,
    e: &EntityType,
    insert_color: [f32; 4],
    insert_pat_len: f32,
    insert_pat: [f32; 8],
    insert_lw_px: f32,
) -> ([f32; 4], f32, [f32; 8], f32, u8) {
    let (color, pat_len, pat, lw_px, aci) = render_style_for(document, e);
    let common = e.common();

    let final_color = if common.color == AcadColor::ByBlock {
        insert_color
    } else {
        color
    };

    let (final_pat_len, final_pat) = if common.linetype.eq_ignore_ascii_case("byblock") {
        (insert_pat_len, insert_pat)
    } else {
        (pat_len, pat)
    };

    let final_lw = if matches!(common.line_weight, LineWeight::ByBlock) {
        insert_lw_px
    } else {
        lw_px
    };

    (final_color, final_pat_len, final_pat, final_lw, aci)
}

/// Adapt white→black or black→white based on background luminance.
/// White entities on light backgrounds become black, black entities on dark
/// backgrounds become white. All other colors pass through unchanged.
pub(crate) fn adapt_to_bg(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] {
    let lum = 0.299 * bg[0] + 0.587 * bg[1] + 0.114 * bg[2];
    let is_white = color[0] > 0.95 && color[1] > 0.95 && color[2] > 0.95;
    let is_black = color[0] < 0.05 && color[1] < 0.05 && color[2] < 0.05;
    if is_white && lum > 0.5 {
        [0.0, 0.0, 0.0, color[3]]
    } else if is_black && lum <= 0.5 {
        [1.0, 1.0, 1.0, color[3]]
    } else {
        color
    }
}

// ── Primitive builder helpers (called by ViewportPane's shader::Program impl) ──

impl Scene {
    /// Build the unified multi-viewport `Primitive` for the current layout.
    /// Model layout → one full-window viewport (more once tiled); paper
    /// layout → one viewport per floating content viewport. Each entry is
    /// rendered into its own screen rectangle by its own inner pipeline.
    pub(in crate::scene) fn build_viewports(
        &self,
        bounds: Rectangle,
        model_render_mode: acadrust::entities::ViewportRenderMode,
        _hover_region: Option<usize>,
    ) -> Primitive {
        // Hover comes from the scene cell driven by the app-level
        // `CursorMoved` handler — the cube overlay sits above the shader
        // and would otherwise mask the move event from `Program::update`.
        let hover_region = self.viewcube_hover.get();
        self.selection.borrow_mut().vp_size = (bounds.width, bounds.height);
        if bounds.height > 0.0 {
            self.set_render_aspect(bounds.width / bounds.height);
            self.set_render_pixel_scale(bounds.width, bounds.height);
        }
        let canvas = (bounds.width.max(1.0), bounds.height.max(1.0));
        let instances = self.active_viewports(canvas.0, canvas.1, model_render_mode);
        // Transparent clear — outside drawn geometry the resolve texture
        // stays at alpha=0, so the alpha-blended blit reveals the container
        // background (model bg, or the desk colour in a paper layout).
        let bg_color = [0.0, 0.0, 0.0, 0.0];
        let viewports: Vec<ViewportData> = instances
            .iter()
            .filter_map(|inst| self.viewport_data_for(inst, canvas, hover_region))
            .collect();
        // Empty viewports → blit nothing; the container background (model bg
        // or the paper desk colour) stays visible.
        Primitive { viewports, bg_color }
    }

    /// Build one `ViewportData` from a `ViewportInstance`: gathers the
    /// viewport's geometry (full model for the Model view / `Handle::NULL`,
    /// or the layer-frozen subset for a paper viewport), its camera
    /// uniforms, and the normalized screen rectangle.
    fn viewport_data_for(
        &self,
        inst: &ViewportInstance,
        canvas: (f32, f32),
        hover_region: Option<usize>,
    ) -> Option<ViewportData> {
        let flags = render_mode_flags(inst.render_mode);
        let view_wireframe = !flags.face3d_fill;

        // Clip the viewport rect to the canvas; size the per-viewport MSAA
        // / depth / resolve textures to that visible portion. Sizing them
        // to the full vp rect would blow past wgpu's per-dimension texture
        // limit (8192 on common GPUs) once paper-space zoom grows the rect
        // far enough off the canvas.
        let full = inst.screen_rect;
        if full.width <= 0.0 || full.height <= 0.0 {
            return None;
        }
        let visible_x = full.x.max(0.0);
        let visible_y = full.y.max(0.0);
        let visible_x_end = (full.x + full.width).min(canvas.0);
        let visible_y_end = (full.y + full.height).min(canvas.1);
        let visible_w = (visible_x_end - visible_x).max(0.0);
        let visible_h = (visible_y_end - visible_y).max(0.0);
        if visible_w < 1.0 || visible_h < 1.0 {
            return None;
        }
        let uo = ((visible_x - full.x) / full.width).clamp(0.0, 1.0);
        let vo = ((visible_y - full.y) / full.height).clamp(0.0, 1.0);
        let us = (visible_w / full.width).clamp(0.0, 1.0);
        let vs = (visible_h / full.height).clamp(0.0, 1.0);

        // Model tiles each get their own tessellation cache keyed off the
        // tile's camera — culling/LOD depend on per-tile zoom, not the
        // active tile's. Paper-space content viewports already had a
        // per-viewport cache.
        // `tile_wire_gen` is the Model-tile content id (Phase 3.2): present
        // only on the tiled Model path, where a pan reuses the tessellation
        // and its id so the GPU wire upload can be skipped. The other wire
        // sources don't participate and force a re-upload below.
        let (base_arc, tile_wire_gen) = if let Some(tile_idx) = inst.tile_idx {
            let aspect = if full.height > 0.0 {
                full.width / full.height
            } else {
                1.0
            };
            let arc = self.model_tile_wires_arc(tile_idx, &inst.camera, aspect, full.height);
            (arc, Some(self.last_model_wire_gen.get()))
        } else if inst.paper_sheet {
            // The sheet renders the paper block's own entities + viewport
            // borders — NOT the projected viewport content (the GPU content
            // viewports draw that themselves).
            (self.paper_sheet_wires_arc(), None)
        } else if inst.handle == acadrust::Handle::NULL {
            (self.entity_wires_arc(), None)
        } else {
            (self.model_wires_for_viewport_arc(inst.handle, full.height), None)
        };
        // Wire-buffer content id for the upload gate. Preview / interim wires
        // are NOT part of this buffer anymore (they go in a separate per-frame
        // overlay buffer below), so the base id is the stable tile content gen
        // — a drag no longer re-uploads the whole base wire set every move.
        // Non-tile paths have no stable id and force a re-upload.
        let wire_content_id = match tile_wire_gen {
            Some(g) => g,
            None => {
                let n = self.wire_force_nonce.get().wrapping_add(1);
                self.wire_force_nonce.set(n);
                n | (1u64 << 63)
            }
        };
        // Split Face3D wires from the rest. The split is content-only (keyed
        // by the tile's wire id), so on a pan-reuse it's memoized rather than
        // re-walking every wire (handle lookup + clone) each frame. Non-tile
        // paths have no stable id and split inline as before.
        let (face3d_wires, other_arc) = match tile_wire_gen {
            Some(gen) => {
                let cached = {
                    let c = self.split_cache.borrow();
                    c.as_ref().filter(|(g, ..)| *g == gen).map(|(_, f, o)| (f.clone(), o.clone()))
                };
                cached.unwrap_or_else(|| {
                    let (f, o) = split_face3d_wires(&base_arc, &self.document);
                    let (fa, oa) = (Arc::new(f), Arc::new(o));
                    *self.split_cache.borrow_mut() = Some((gen, fa.clone(), oa.clone()));
                    (fa, oa)
                })
            }
            None => {
                let (f, o) = split_face3d_wires(&base_arc, &self.document);
                (Arc::new(f), Arc::new(o))
            }
        };
        // Base wire set — the cached `other` Arc directly, never cloned to
        // append overlays. Preview / interim wires ride in their own small
        // per-frame buffer so the (potentially huge) base buffer stays resident
        // and unchanged while a command preview or grip drag is live.
        let all_wires = other_arc;
        let preview_wires = if self.interim_wire.is_none() && self.preview_wires.is_empty() {
            Arc::new(Vec::new())
        } else {
            let mut v: Vec<WireModel> = Vec::with_capacity(self.preview_wires.len() + 1);
            if let Some(iw) = &self.interim_wire {
                v.push(iw.clone());
            }
            v.extend(self.preview_wires.iter().cloned());
            Arc::new(v)
        };

        // Build the camera at the *full* viewport's aspect so the ortho
        // frustum matches what the viewport entity stores, then post-
        // multiply by a clip-space "zoom into the visible sub-rect" that
        // maps the visible portion to NDC [-1, 1]. Geometry passes
        // rasterize into a visible-sized MSAA, so `viewport_size` (used
        // by the wire shader to extrude line thickness in screen pixels)
        // must be the visible size — but `world_per_pixel` is invariant
        // under cropping (full_h cancels with vs) so the value computed
        // from the full bounds is the one we want.
        let full_bounds = Rectangle {
            x: 0.0,
            y: 0.0,
            width: full.width.max(1.0),
            height: full.height.max(1.0),
        };
        let mut uniforms =
            Uniforms::new(&inst.camera, full_bounds, self.document.header.lineweight_display);
        uniforms.view_proj = crop_view_proj(uniforms.view_proj, uo, vo, us, vs);
        uniforms.viewport_size = [visible_w, visible_h];
        uniforms.flat_shade = if flags.flat_shade { 1.0 } else { 0.0 };
        uniforms.transparency_enable = if self.transparency_display { 1.0 } else { 0.0 };

        // `screen_rect` carries the *visible* sub-rectangle in normalized
        // canvas coords — that's what `Pipeline::prepare` uses to size
        // the per-viewport textures and what `Primitive::render` uses to
        // pick the surface destination. The UV crop uniform reads as
        // identity here, since the texture already covers exactly the
        // visible portion.
        let screen_rect = Rectangle {
            x: visible_x / canvas.0,
            y: visible_y / canvas.1,
            width: visible_w / canvas.0,
            height: visible_h / canvas.1,
        };

        // The paper sheet instance renders only the paper layout block's own
        // fills (plus a synthetic white fill for the printable area) — NOT the
        // model-block hatches. Those belong inside the floating content
        // viewports; rendering them on the full-canvas sheet would let them
        // bleed past the viewport borders whenever model coords overlap the
        // paper area. Content viewports keep the full set (the model camera +
        // per-viewport scissor place / clip them correctly).
        let (hatches, wipeout_hatches) = if inst.paper_sheet {
            let mut v: Vec<HatchModel> = Vec::new();
            if let Some(sheet) = self.paper_sheet_fill() {
                v.push(sheet);
            }
            v.extend(self.paper_canvas_hatches().iter().cloned());
            (Arc::new(v), self.paper_canvas_wipeouts())
        } else {
            (self.hatch_models_arc(), self.wipeout_models_arc())
        };
        let images = if inst.paper_sheet {
            self.paper_sheet_images()
        } else {
            self.images_arc()
        };

        Some(ViewportData {
            wires: all_wires,
            preview_wires,
            face3d_wires,
            draw_depths: self.draw_depth_map(),
            hatches,
            wipeout_hatches,
            images,
            meshes: self.meshes_arc(),
            uniforms,
            cam_rotation: inst.camera.view_rotation_mat(),
            // Only the active viewport gets the hovered-region highlight.
            hover_region: if inst.active { hover_region } else { None },
            show_viewcube: inst.active,
            fill_mode: self.document.header.fill_mode,
            view_wireframe,
            mesh_fill: flags.mesh_fill,
            show_3d_edges: flags.show_3d_edges,
            hidden_line: flags.hidden_line,
            geometry_epoch: self.geometry_epoch,
            camera_generation: self.camera_generation,
            wire_content_id,
            highlight_handles: self.highlight_handles(),
            selection_generation: self.selection_generation,
            selected_sig: self.selected_set_sig(),
            screen_rect,
        })
    }

    /// Update viewcube hover state from cursor position within `bounds`.
    ///
    /// The cube draws in the top-right of the *active model tile* (which fills
    /// the canvas when there is a single tile), so the hover hit-test maps the
    /// cursor into that tile's local space and uses the tile's dimensions.
    pub(in crate::scene) fn update_viewcube_state(
        &self,
        state: &mut CameraState,
        bounds: Rectangle,
        cursor: mouse::Cursor,
    ) {
        let pos = cursor.position_in(bounds);
        let cam_rotation = self.camera.borrow().view_rotation_mat();
        if let Some(p) = pos {
            let tile = self.active_model_tile_bounds(bounds.width, bounds.height);
            state.hover_region = hover_id(
                p.x - tile.x,
                p.y - tile.y,
                tile.width,
                tile.height,
                cam_rotation,
                VIEWCUBE_PX,
            );
        } else {
            state.hover_region = None;
        }
    }

    pub(in crate::scene) fn viewcube_mouse_interaction(&self, state: &CameraState) -> mouse::Interaction {
        if state.hover_region.is_some() {
            mouse::Interaction::Pointer
        } else {
            mouse::Interaction::default()
        }
    }
}

// ── Linetype pattern helper ───────────────────────────────────────────────

pub(crate) fn resolve_pattern(
    table: &acadrust::tables::Table<LineType>,
    name: &str,
    scale: f32,
) -> (f32, [f32; 8]) {
    let solid = (0.0, [0.0f32; 8]);
    if name.eq_ignore_ascii_case("continuous")
        || name.eq_ignore_ascii_case("bylayer")
        || name.eq_ignore_ascii_case("byblock")
        || name.is_empty()
    {
        return solid;
    }
    let lt = match table.get(name) {
        Some(lt) => lt,
        None => return solid,
    };
    if lt.is_continuous() || lt.elements.is_empty() {
        return solid;
    }

    let mut pat = [0.0f32; 8];
    let mut pat_len = 0.0f32;
    for (i, el) in lt.elements.iter().take(8).enumerate() {
        let raw = el.length as f32 * scale;
        let encoded = if raw == 0.0 {
            0.01 * scale.max(0.01)
        } else {
            raw
        };
        pat[i] = encoded;
        pat_len += encoded.abs();
    }
    if pat_len < 1e-6 {
        return solid;
    }
    (pat_len, pat)
}

/// Partition a wire list into (face3d_wires, other_wires).
///
/// Uses a document handle lookup so no changes to WireModel are needed.
/// O(N) per geometry epoch — acceptable since it runs once per epoch.
fn split_face3d_wires(
    wires: &[WireModel],
    document: &acadrust::CadDocument,
) -> (Vec<WireModel>, Vec<WireModel>) {
    let mut face3d = Vec::new();
    let mut others = Vec::new();
    for w in wires {
        let is_face3d = w
            .name
            .parse::<u64>()
            .ok()
            .and_then(|v| document.get_entity(Handle::new(v)))
            .map(|e| matches!(e, EntityType::Face3D(_)))
            .unwrap_or(false);
        if is_face3d {
            face3d.push(w.clone());
        } else {
            others.push(w.clone());
        }
    }
    (face3d, others)
}
