indicatrix-cut 0.7.1

Desktop faceting-design editor: library browsing, spectral 3D rendering, material retargeting, and a solid inspection view.
//! The editor-side controller struct itself ([`SolidPreviewState`]) and its
//! UI-thread-facing entry points: construction, the cheap camera-follow
//! reproject/facet-overlay submissions, the replan submission, and the tier-
//! cutoff cache. See the parent module's doc comment for the two-worker split
//! these methods feed into.

use super::{
    RedrawGate,
    request::{PlanJob, RedrawRequest, ReplanRequest},
    sink::PreviewSink,
    types::{CameraPose, FacetOverlay},
};
use glam::Vec3;
use indicatrix_cut_core::{Design, ManufacturabilityWarning};
use indicatrix_solid::preview::{Outlines, SharedOutlines, StoneGeometryBuf};
use std::sync::{
    Arc, Mutex, PoisonError, Weak,
    atomic::{AtomicU64, Ordering},
    mpsc::Sender,
};

/// The editor-side controller described in the parent module's doc comment.
pub struct SolidPreviewState {
    pub(super) sink: Arc<dyn PreviewSink>,
    /// Consumed only by the RENDER worker (`super::render`'s `spawn_worker`) --
    /// the two-worker split, see the module doc comment.
    pub(super) gate: Arc<RedrawGate<RedrawRequest>>,
    /// Wake channel for the RENDER worker, created lazily on the first
    /// `request_*`/finished-plan call. `None` until then, so a `SolidPreviewState`
    /// never asked to redraw never spawns a thread.
    pub(super) wake: Mutex<Option<Sender<()>>>,
    /// Consumed only by the PLAN worker (`super::plan_worker`'s `spawn_plan_worker`)
    /// -- a Separate queue from `gate` so a slow solve never blocks `Reproject` requests.
    pub(super) plan_gate: Arc<RedrawGate<PlanJob>>,
    /// [`Self::wake`]'s counterpart for the PLAN worker, created lazily the same
    /// way on the first [`Self::request_replan`] call.
    pub(super) plan_wake: Mutex<Option<Sender<()>>>,
    /// A weak handle to this same value, filled in immediately after
    /// construction -- lets the PLAN worker (which holds no other reference back
    /// to `Self`) hand a finished `super::request::PlannedFrame` to the RENDER
    /// worker through `super::render`'s `submit`, the exact same lazy-spawn-and-wake
    /// path a `Reproject` call already uses, rather than duplicating that contract
    /// a second time. `Weak`, not `Arc`: the PLAN worker thread must never be the
    /// reason a `SolidPreviewState` outlives every caller's own handle to it.
    pub(super) self_weak: Mutex<Weak<Self>>,
    /// "Show through tier N" slider: `Some(n)` truncates the plane arrangement
    /// to `design.tiers[..=n]`. Cached here for UI access via [`Self::set_tier_cutoff`].
    pub(super) tier_cutoff: Mutex<Option<usize>>,
    /// The Cut slider: `Some(k)` draws the stone after the first `k` cutting steps
    /// (`Design::preview_steps`), `Some(0)` being the rough, `None` the finished stone.
    /// Wins over [`Self::tier_cutoff`] in a replan; set by [`Self::set_cut_steps`].
    pub(super) cut_steps: Mutex<Option<usize>>,
    /// bumped by `gui::editor::auto_solve::scheduling::
    /// reset_for_new_design` on every wholesale design replacement (New/Load
    /// Selected/Open) via [`Self::bump_generation_floor`] -- lets the
    /// PLAN worker (`super::plan_worker::spawn_plan_worker`) tell a `PlanJob`
    /// queued or in flight for the design being REPLACED apart from one for
    /// the design that replaced it, even though `self.plan_gate`
    /// (`super::RedrawGate`) itself only ever coalesces to "the latest queued
    /// job," never "the latest queued job for the CURRENT design." A `PlanJob`
    /// whose own `generation` is below this floor is dropped rather than
    /// solved and handed back as a frame.
    pub(super) generation_floor: Arc<AtomicU64>,
    /// The Slice tool's provisional-tier outline and the drag-follower outline, shared
    /// with the RENDER worker's `WorkerMemory::outlines`. Written synchronously by
    /// [`Self::set_outlines`] (from the overlay's `provisional` / `moved` fields) and
    /// read by the worker when it DRAWS, so a `Planned` request that
    /// rebuilds the style, or a later request that supersedes an overlay update in
    /// the "latest wins" gate, can neither drop nor resurrect them.
    pub(super) outlines: SharedOutlines,
    /// When `Some`, replaces the `planes` of every [`Self::request_redraw_with_gear`]
    /// call: the Slice tool sets it to the provisional design's planes so a camera
    /// orbit, a view-mode switch or a background-solve redraw (all of which reproject
    /// the COMMITTED `RenderContext::active_planes`) keeps showing the provisional
    /// facet instead of snapping back to the committed stone.
    pub(super) planes_override: Mutex<Option<Vec<(Vec3, f32)>>>,
    /// The manufacturability findings the PLAN worker computed for its latest planned
    /// designs. The worker writes an entry right AFTER it hands the frame on
    /// ([`super::plan_worker`]: a frame never waits for the pass) and also delivers the
    /// findings to the sink as their own update ([`super::PreviewSink::apply_findings`]).
    /// The render worker looks the entry up when a planned frame reaches it -- it is there
    /// only when the pass beat the render -- and copies it onto every frame it draws from
    /// then on (the replan itself, but also the camera-follow reproject and overlay frames
    /// that carry the last plan's masts forward), so the UI thread only DISPLAYS the
    /// findings. Check 6 of the pass builds the stone's solid and the tools' polytopes,
    /// which is far too slow for a UI thread -- see
    /// `indicatrix_cut_core::manufacturability::concave_tool_warnings`.
    pub(super) warnings: SharedWarnings,
}

/// One planned design's manufacturability findings, found again by the very `Arc` the plan
/// job carried (the planned frame hands the same allocation on), never by generation.
///
/// The editor's generation moves only when the design does, so a tier selection, a Cut slider
/// move or an idle replan plans the same design again under the same generation, with a fresh
/// `Arc`. Such a plan finds no entry of an earlier plan here, so its frame goes out without
/// findings unless its own pass beats the render. `gui::solid_sink`'s `LateRows` is what
/// brings the generation's findings back to its rows, from the copy it keeps.
pub(super) struct PlanFindings {
    /// The design the findings were computed for. Held so the allocation cannot be freed and
    /// reused for another design while this entry can still be looked up.
    pub(super) design: Arc<Design>,
    pub(super) warnings: Arc<Vec<ManufacturabilityWarning>>,
}

/// The few most recent [`PlanFindings`], newest last -- see [`SolidPreviewState::warnings`].
pub(super) type SharedWarnings = Arc<Mutex<Vec<PlanFindings>>>;

impl SolidPreviewState {
    /// Builds a controller that hands every finished frame to `sink`. No thread is
    /// spawned yet -- see [`Self::request_redraw`].
    #[must_use]
    pub fn new(sink: Arc<dyn PreviewSink>) -> Arc<Self> {
        let state = Arc::new(Self {
            sink,
            gate: Arc::new(RedrawGate::new()),
            wake: Mutex::new(None),
            plan_gate: Arc::new(RedrawGate::new()),
            plan_wake: Mutex::new(None),
            self_weak: Mutex::new(Weak::new()),
            tier_cutoff: Mutex::new(None),
            cut_steps: Mutex::new(None),
            generation_floor: Arc::new(AtomicU64::new(0)),
            outlines: Arc::new(Mutex::new(Outlines::default())),
            planes_override: Mutex::new(None),
            warnings: Arc::new(Mutex::new(Vec::new())),
        });
        *state
            .self_weak
            .lock()
            .unwrap_or_else(PoisonError::into_inner) = Arc::downgrade(&state);
        state
    }

    /// Submits one cheap camera-follow redraw (see `super::request::RedrawRequest::
    /// Reproject`) and returns immediately; the finished frame reaches
    /// `super::sink::PreviewSink::apply` asynchronously. `size` must be the
    /// viewport's LOGICAL size (points, not render resolution). `view_mode` (0
    /// Solid / 1 Path-traced / 2 Both / 3 Diagram) decides whether the worker also
    /// renders the "Both" mode's edges layer, or the Diagram mode's 2D facet
    /// diagram, this frame -- a Diagram-mode request rebuilds the diagram at the
    /// worker's last-known gear info/labels (see `super::state::DiagramMemory`)
    /// since a `Reproject` request carries no `Design`. A burst of calls coalesces
    /// to exactly the LAST one submitted -- see `RedrawGate`'s doc comment.
    ///
    /// A thin `gear: None` wrapper over [`Self::request_redraw_with_gear`], kept at
    /// this exact signature so every pre-existing caller (`gui::editor::view::
    /// refresh_viewport`, `gui::editor::auto_solve`'s background-solve resubmit)
    /// keeps compiling unchanged. `None` means the worker's last-known Diagram-mode
    /// gear info is left as-is, which is only correct when that caller's design is
    /// the same one the worker last replanned -- prefer
    /// [`Self::request_redraw_with_gear`] with the loaded design's actual gear info
    /// (`bridge::render_thread::RenderContext::design_gear`) wherever that is
    /// available, as `gui::render::camera_lighting::resubmit_at_current_pose`
    /// already does.
    pub fn request_redraw(
        &self,
        planes: &[(Vec3, f32)],
        camera: CameraPose,
        size: (u32, u32),
        view_mode: u8,
    ) {
        self.request_redraw_with_gear(planes, camera, size, view_mode, None);
    }

    /// Same contract as [`Self::request_redraw`], plus `gear`: the currently loaded
    /// design's gear tooth count/reference angle (`bridge::render_thread::
    /// RenderContext::design_gear`), threaded through to a Diagram-mode (`view_mode`
    /// 3) reproject so it shows THIS design's gear wheel rather than whatever a
    /// previous editor replan happened to leave in the worker's own
    /// `super::state::DiagramMemory` -- see `super::request::RedrawRequest::
    /// Reproject`'s `gear` field doc comment. `None` leaves the worker's
    /// last-known gear info untouched.
    pub fn request_redraw_with_gear(
        &self,
        planes: &[(Vec3, f32)],
        camera: CameraPose,
        size: (u32, u32),
        view_mode: u8,
        gear: Option<(u32, f32)>,
    ) {
        self.request_redraw_geometry(
            StoneGeometryBuf::from_halfspaces(planes),
            camera,
            size,
            view_mode,
            gear,
        );
    }

    /// [`Self::request_redraw_with_gear`] for a stone that may carry concave tools: the
    /// camera-follow redraw of the whole `geometry`, planes, tools and the
    /// `(tier, placement)` of each tool. Every caller that knows the design's tools
    /// (`refresh_viewport`, the background-solve apply, a camera drag from
    /// `RenderContext`) goes through here so a concave stone never loses its notches on
    /// the way to the worker; the planes-only entry point wraps it.
    ///
    /// The Slice tool's [`Self::set_planes_override`] still replaces the PLANES only: a
    /// provisional flat facet is drawn over the committed stone, tools included.
    pub fn request_redraw_geometry(
        &self,
        mut geometry: StoneGeometryBuf,
        camera: CameraPose,
        size: (u32, u32),
        view_mode: u8,
        gear: Option<(u32, f32)>,
    ) {
        let override_planes = self
            .planes_override
            .lock()
            .unwrap_or_else(PoisonError::into_inner)
            .clone();
        if let Some(planes) = override_planes {
            geometry.planes = StoneGeometryBuf::from_halfspaces(&planes).planes;
        }
        self.submit(RedrawRequest::Reproject {
            geometry,
            camera,
            size,
            view_mode,
            gear,
        });
    }

    /// [`Self::request_redraw_geometry`] for a viewport that shows the COMMITTED design
    /// only: the Slice tool's [`Self::set_planes_override`] is not applied. The Live
    /// Render tab's Solid raster calls this, so the provisional tier -- an Edit-tab
    /// preview of a slice that has not been kept -- never shows there.
    pub fn request_redraw_committed_geometry(
        &self,
        geometry: StoneGeometryBuf,
        camera: CameraPose,
        size: (u32, u32),
        view_mode: u8,
        gear: Option<(u32, f32)>,
    ) {
        self.submit(RedrawRequest::Reproject {
            geometry,
            camera,
            size,
            view_mode,
            gear,
        });
    }

    /// Submits a facet-id-keyed highlight update ("which facet did I click", multi-
    /// select, hover) -- see `super::types::FacetOverlay`'s doc comment. Re-renders
    /// at whatever camera/planes/size/`view_mode` the worker last used for a
    /// [`Self::request_redraw_with_gear`]/[`Self::request_replan`] call, exactly
    /// like an ordinary camera-follow reproject, so a caller that already has a
    /// facet id from a pick buffer never needs to also track/resend the camera
    /// pose or the design's planes just to show it. A no-op before the worker has
    /// rendered anything at all.
    pub fn request_facet_overlay(&self, overlay: FacetOverlay) {
        self.submit(RedrawRequest::UpdateFacetOverlay(overlay));
    }

    /// Stores the provisional-tier and drag-follower outlines where the RENDER worker
    /// reads them when it draws -- see [`Self::outlines`] -- so they survive a
    /// `Planned` request and an overlay update superseded in the "latest wins" gate
    /// alike. Does NOT redraw: follow with [`Self::request_facet_overlay`]. The
    /// editor's 3D overlay path calls this with the merged overlay's two fields on
    /// every update; the Diagram view's own hover/click overlays never do, so they
    /// cannot clear an outline they know nothing about.
    pub fn set_outlines(&self, provisional: &[u32], moved: &[u32]) {
        let mut outlines = self.outlines.lock().unwrap_or_else(PoisonError::into_inner);
        outlines.provisional.clear();
        outlines.provisional.extend_from_slice(provisional);
        outlines.moved.clear();
        outlines.moved.extend_from_slice(moved);
    }

    /// Makes every later [`Self::request_redraw_with_gear`] draw `planes` instead of
    /// the planes its caller passes (`None` restores the caller's). Used by the Slice
    /// tool while a provisional facet is on screen -- see [`Self::planes_override`].
    pub fn set_planes_override(&self, planes: Option<Vec<(Vec3, f32)>>) {
        *self
            .planes_override
            .lock()
            .unwrap_or_else(PoisonError::into_inner) = planes;
    }

    /// Submits a replan-on-edit request to the PLAN worker.
    /// `live_update::plan_preview` never runs on the UI thread.
    pub fn request_replan(&self, request: ReplanRequest) {
        let tier_cutoff = *self
            .tier_cutoff
            .lock()
            .unwrap_or_else(PoisonError::into_inner);
        let cut_steps = *self
            .cut_steps
            .lock()
            .unwrap_or_else(PoisonError::into_inner);
        self.submit_plan(PlanJob {
            design: request.design,
            dirty: request.dirty,
            last_solved: request.last_solved,
            camera: request.camera,
            size: request.size,
            selected_tier: request.selected_tier,
            n_d: request.n_d,
            view_mode: request.view_mode,
            generation: request.generation,
            show_preform: request.show_preform,
            enlarged_panel: request.enlarged_panel,
            tier_cutoff,
            cut_steps,
        });
    }

    /// Sets the Cut slider for the NEXT [`Self::request_replan`]: `Some(k)` draws the
    /// stone after the first `k` cutting steps (`Design::preview_steps`; `0` is the
    /// rough, the preform alone), `None` the finished stone. Does NOT redraw; the
    /// caller submits a replan afterwards, as `gui::editor::view::submit_preview_replan`
    /// does after reading the slider (`gui::solid_preview::cut_slider`).
    pub fn set_cut_steps(&self, steps: Option<usize>) {
        *self
            .cut_steps
            .lock()
            .unwrap_or_else(PoisonError::into_inner) = steps;
    }

    /// Sets the "show through tier N" cutoff: `Some(n)` truncates to
    /// `design.tiers[..=n]`. Does NOT trigger a redraw; the caller must submit
    /// [`Self::request_replan`] afterwards.
    ///
    /// Called from `gui::editor::view::submit_preview_replan_for`, which reads
    /// `SolidPreviewModel.tier_cutoff` (`-1` means "no cutoff", matching this
    /// crate's existing `-1`-means-`None` convention for e.g.
    /// `diagram_enlarged_panel`) and converts it with
    /// `usize::try_from(cutoff).ok()` before calling this and then
    /// `request_replan` -- the same three-file shape `show_preform_planes`/
    /// `diagram_enlarged_panel` already went through, just with the read happening
    /// here instead of as a `ReplanRequest` field (see this state's own
    /// `tier_cutoff` field doc comment for why).
    pub fn set_tier_cutoff(&self, cutoff: Option<usize>) {
        *self
            .tier_cutoff
            .lock()
            .unwrap_or_else(PoisonError::into_inner) = cutoff;
    }

    /// bumps [`Self::generation_floor`] to (at least) `floor` -- called
    /// once per wholesale design replacement by `gui::editor::auto_solve::
    /// scheduling::reset_for_new_design`. `fetch_max`, not a plain store: this
    /// must never move BACKWARDS even if called out of order (defensive only
    /// -- `EditorState::generation` is itself only ever bumped, never reset,
    /// so a caller's own `floor` values are already non-decreasing in
    /// practice).
    pub fn bump_generation_floor(&self, floor: u64) {
        self.generation_floor.fetch_max(floor, Ordering::Relaxed);
    }
}