diff --git a/Cargo.lock b/Cargo.lock index 7857920..1ac5ab1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1662,6 +1662,7 @@ dependencies = [ "crossterm", "flate2", "image", + "libc", "pdbtbx", "ratatui", "ratatui-image", @@ -1671,6 +1672,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "toml", ] [[package]] @@ -2130,6 +2132,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2450,6 +2461,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -3048,6 +3100,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index bbefc87..dff9334 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ ratatui = "0.29" crossterm = "0.28" ratatui-image = { version = "9.0.0", default-features = false, features = ["crossterm"] } image = "0.25" +libc = "0.2" pdbtbx = { version = "0.12", features = ["rstar"] } clap = { version = "4", features = ["derive"] } anyhow = "1" @@ -26,6 +27,7 @@ flate2 = "1" rayon = "1" reqwest = { version = "0.12", features = ["blocking"], optional = true } serde = { version = "1", features = ["derive"] } +toml = "0.8" serde_json = "1" tempfile = { version = "3.27.0", optional = true } tokio = { version = "1", features = ["rt", "macros"], optional = true } diff --git a/README.md b/README.md index a53bf83..59c84b0 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Terminal molecular structure viewer — load, rotate, and explore proteins, nucl - **RNA/DNA support** — backbone, wireframe, and cartoon modes with base-type coloring - **Small molecule rendering** — ligands as ball-and-stick, ions as spheres - **Interface analysis** — inter-chain contacts, binding pockets, and interaction visualization (H-bonds, salt bridges, hydrophobic contacts) +- **Sequence panel** — scroll every chain's sequence, select residues, and show them as ball-and-stick in the 3D view - **7 color schemes** — structure, chain, element (CPK), B-factor, rainbow, pLDDT (AlphaFold) - **Interactive controls** — vim-style rotation, zoom, pan with auto-rotation - **PDB & mmCIF** — both formats supported, with RCSB PDB fetch (`--fetch`) @@ -248,10 +249,50 @@ leave the prior frame, state, and revision unchanged. | `I` | Interface interactions | | `g` | Toggle ligands | | `[`/`]` | Prev/next chain | +| `S` | Sequence panel | +| `b` | Ball-and-stick for the selection | +| `z` | Centre the view on the selection | | `Space` | Auto-rotate | | `?` | Help | | `q` | Quit | +While the sequence panel is open it takes the arrow keys; `h`/`j`/`k`/`l` still +rotate the view, so you can turn the structure while picking residues. + +| Key | Action in the sequence panel | +|-----|------------------------------| +| `←`/`→` | Move the cursor one residue (across chain ends) | +| `↑`/`↓` | Move one row | +| `Shift`+arrow | Extend the selection from the cursor | +| `PgUp`/`PgDn` | Move a screenful | +| `Home`/`End` | Start / end of the chain | +| `Enter` | Select or deselect the residue | +| `A` | Select or deselect the whole chain | +| `x` | Clear the selection | +| `[`/`]` | Jump to the previous / next chain | +| `<`/`>` | Shrink / grow the panel | +| `S` / `Esc` | Close the panel | + +## Sequence Panel & Residue Selection + +Press `S` to open a scrollable panel listing the sequence of every chain in +one-letter codes — amino acids and nucleotides alike, numbered in the gutter and +grouped in tens. Each chain gets a header with its type, length, and residue +range, so a 52-chain ribosome reads as one continuous list. + +The cursor moves with the arrow keys; `Enter` picks a residue, `Shift`+arrow +extends a range, and `A` takes a whole chain. Picked residues are drawn in the +3D view as ball-and-stick over whatever mode is active, with the selection color +on carbons and CPK colors elsewhere, z-buffered so a side chain that really is +behind the structure stays behind it. `b` turns the ball-and-stick off, leaving a +marker sphere per residue; `z` centres the view on the selection, which is how +you find a handful of residues inside something the size of a ribosome. The +selection survives closing the panel, and the status bar keeps its count. + +Letters are colored by the active color scheme, so the panel and the structure +read as one picture; the selection and cursor colors come from `[selection]` in +the palette file. + ## Color Schemes | Scheme | Description | @@ -263,6 +304,33 @@ leave the prior frame, state, and revision unchanged. | **Rainbow** | N-terminus (blue) to C-terminus (red). | | **pLDDT** | AlphaFold confidence (blue=high, orange=low). | +### Customizing the palette + +Every fixed color above can be changed from a TOML file. ProteinView reads +`~/.config/proteinview/palette.toml` (or `$XDG_CONFIG_HOME/proteinview/palette.toml`) +when it exists, and `--palette ` overrides that: + +```bash +proteinview examples/1UBQ.pdb --palette my-palette.toml +``` + +Every key is optional — anything you leave out keeps its built-in default, so a +file this short is valid: + +```toml +[structure] +helix = "#00FFFF" +``` + +Colors are six hex digits, with or without a leading `#`, in either case. +Element symbols merge onto the built-in CPK table, so overriding carbon leaves +the rest alone, while `[chain] colors` replaces the chain cycle outright. Unknown +keys are rejected rather than ignored, so a typo tells you rather than silently +doing nothing. + +See [`docs/palette.example.toml`](docs/palette.example.toml) for a fully +commented file listing every setting at its default value. + ## Terminal Support | Terminal | Braille | HD | FullHD | diff --git a/docs/palette.example.toml b/docs/palette.example.toml new file mode 100644 index 0000000..62a55f2 --- /dev/null +++ b/docs/palette.example.toml @@ -0,0 +1,106 @@ +# ProteinView color palette +# +# Copy to ~/.config/proteinview/palette.toml to have it picked up automatically, +# or point at it explicitly: +# +# proteinview structure.pdb --palette docs/palette.example.toml +# +# Every key is optional. Anything you leave out keeps its built-in default, so a +# file containing nothing but a single color is perfectly valid. Colors are six +# hexadecimal digits, with or without a leading '#', in either case. +# +# Unknown keys are an error rather than being ignored, so a typo tells you +# instead of silently doing nothing. + +# --- Structure scheme (the default, keyed by secondary structure) ------------ +[structure] +helix = "FF0080" +sheet = "FFC800" +turn = "6080FF" +coil = "00CC00" + +# --- Nucleic acid bases ------------------------------------------------------ +# Used for DNA/RNA residues under the Structure scheme. +[nucleotide] +adenine = "DC3C3C" +uracil = "3C3CDC" +thymine = "3C3CDC" +guanine = "3CB43C" +cytosine = "DCC828" +inosine = "9664B4" + +# --- Chain scheme ------------------------------------------------------------ +# Cycled by chain id. Order matters, and the list replaces the default outright +# rather than merging, so give it as many colors as you want in the rotation. +[chain] +colors = [ + "00B4FF", + "FF6400", + "00DC64", + "FF3296", + "B464FF", + "FFDC00", + "00C8C8", + "FF9696", +] + +# --- Element scheme (CPK) ---------------------------------------------------- +[element] +# Color for any element not listed below. +fallback = "C8C8C8" + +# These *merge* onto the built-in table, so overriding carbon leaves every other +# element alone. Keys are element symbols and are matched case-insensitively. +[element.symbols] +C = "909090" +N = "3050F8" +O = "FF0D0D" +S = "FFFF30" +H = "FFFFFF" +P = "FF8000" +FE = "E06633" +MG = "00B400" +ZN = "7D80B0" +CA = "3DFF00" +MN = "9C7AC7" +CO = "F090A0" +CU = "C88033" +NI = "50D050" +CL = "1FF01F" +BR = "A62929" + +# --- pLDDT scheme (AlphaFold confidence bands) ------------------------------- +[plddt] +very_high = "0053D6" # >= 90 +high = "65CBF3" # >= 70 +low = "FFDB13" # >= 50 +very_low = "FF7D45" # < 50 + +# --- B-factor scheme --------------------------------------------------------- +# Endpoints of a linear gradient across roughly the 5..80 B-factor range. +[bfactor] +low = "0000FF" +high = "FF0000" + +# --- Interface highlighting ('f') -------------------------------------------- +[interface] +focus_contact = "00FF64" # focus chain, touching a partner +focus_other = "28643C" # focus chain, away from the interface +partner_contact = "FFA500" # partner chain, touching the focus chain +partner_other = "64503C" # partner chain, away from the interface +ligand = "FFFFFF" # ligands, kept bright so they stay visible + +# --- Small molecules --------------------------------------------------------- +[ligand] +ligand = "FF00FF" +ion = "00FFFF" +rainbow = "FF00FF" # ligands under the Rainbow scheme + +# --- Sequence panel selection ('S') ------------------------------------------ +# Residues picked in the sequence panel. Carbons of the ball-and-stick overlay +# take `carbon`; every other element keeps its CPK color, so a picked residue +# still reads as itself. +[selection] +carbon = "00E68C" # ball-and-stick carbons of picked residues +marker = "00E68C" # marker sphere shown when ball-and-stick is off ('b') +cursor = "FFC800" # the sequence panel's cursor cell diff --git a/src/app.rs b/src/app.rs index ca2659b..98535fc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,8 +3,10 @@ use std::sync::mpsc; use ratatui_image::picker::Picker; use crate::model::interface::{InterfaceAnalysis, analyze_binding_pockets, analyze_interface}; -use crate::model::protein::Protein; +use crate::model::protein::{Protein, Residue}; +use crate::model::residue_selection::ResidueSelection; use crate::model::selection::ResidueColorOverrides; +use crate::model::sequence::{SeqRow, SequenceLayout, wrap_for_width}; use crate::render::camera::Camera; use crate::render::color::{ColorScheme, ColorSchemeType}; use crate::render::ribbon::{RibbonTriangle, generate_ribbon_mesh}; @@ -13,6 +15,72 @@ use crate::render::ribbon::{RibbonTriangle, generate_ribbon_mesh}; /// optimizations (background interface analysis, backbone default, reduced LOD). pub const LARGE_STRUCTURE_THRESHOLD: usize = 5000; +/// Upper bound on the FullHD framebuffer, in pixels. +/// +/// A graphics-protocol viewport is sized in *device* pixels, so on a HiDPI panel +/// it is several times the area the cell grid suggests, and every per-pixel +/// stage scales with it. This is a backstop against a framebuffer so large it +/// costs real memory, not a frame-rate control: a still frame is rendered once +/// and then the loop idles, and everything drawn *while* the view moves is +/// already quartered by [`FULLHD_INTERACTIVE_SCALE`]. +/// +/// Set it generously, because capping is not free. The terminal scales the +/// result back up, and a cap that barely engages buys a few percent of the +/// pixels in exchange for a non-integer resample of every still frame — worse +/// output for no useful saving. 12 MP clears a 4K viewport and a full-screen +/// HiDPI laptop with room to spare, so the cap only meets 5K and above, where +/// the framebuffer would otherwise run past a hundred megabytes. +pub const FULLHD_MAX_PIXELS: f64 = 12_000_000.0; + +/// Resolution multiplier used while the camera is moving. +/// +/// Halving each axis quarters every per-pixel cost, and the terminal scales the +/// result back up via the protocol's `c=`/`r=` keys. Motion hides the +/// softness; the full-resolution frame lands as soon as the camera settles. +pub const FULLHD_INTERACTIVE_SCALE: f64 = 0.5; + +/// How long after the last camera change the view still counts as interacting. +/// +/// Long enough to cover the gap between key repeats, so held keys never +/// oscillate between resolutions, and short enough that the sharp frame feels +/// immediate once the user stops. +pub const INTERACTION_LINGER: std::time::Duration = std::time::Duration::from_millis(220); + +/// Still-frame pixel dimensions of the FullHD framebuffer for a viewport of +/// `vp_cols` by `vp_rows` cells. +/// +/// This is the single source of truth for FullHD sizing: both the zoom +/// calculation and the renderer go through it, so the framebuffer and the zoom +/// computed for it can never disagree. While the camera is moving the renderer +/// scales this down by [`FULLHD_INTERACTIVE_SCALE`]; the still-frame size is +/// what zoom is defined against. +pub fn fullhd_framebuffer_size( + vp_cols: f64, + vp_rows: f64, + font_w: u16, + font_h: u16, + is_graphics: bool, +) -> (f64, f64) { + if !is_graphics { + // Colored-braille fallback: 2x4 dots per cell. + return (vp_cols * 2.0, vp_rows * 4.0); + } + + let native_w = vp_cols * f64::from(font_w); + let native_h = vp_rows * f64::from(font_h); + + // Cap by area rather than by either axis, so ultrawide and tall windows are + // treated alike. + let area = native_w * native_h; + let scale = if area > FULLHD_MAX_PIXELS { + (FULLHD_MAX_PIXELS / area).sqrt() + } else { + 1.0 + }; + + ((native_w * scale).max(1.0), (native_h * scale).max(1.0)) +} + /// Visualization mode #[derive(Debug, Clone, Copy, PartialEq)] pub enum VizMode { @@ -47,6 +115,11 @@ pub enum RenderMode { /// HD-quality colored braille via software rasterizer (Lambert shading, /// z-buffer, depth fog). Fast everywhere including SSH. HalfBlock, + /// Same 2x4 braille grid as [`RenderMode::HalfBlock`], but rasterized into a + /// supersampled framebuffer and box-filtered back down, with color + /// quantization applied during the conversion. Anti-aliased silhouettes and + /// stable per-cell color, for the same number of characters on the wire. + HalfBlockPlus, /// Full pixel graphics via Sixel/Kitty/iTerm2 - best quality, high bandwidth FullHD, } @@ -56,6 +129,7 @@ impl RenderMode { match self { Self::Braille => "Braille", Self::HalfBlock => "HD", + Self::HalfBlockPlus => "HDplus", Self::FullHD => "FullHD", } } @@ -142,9 +216,38 @@ pub struct App { interface_computed: bool, /// Receiver for background interface analysis (large structures only). interface_rx: Option>, - /// Cached result of `total_residues > LARGE_STRUCTURE_THRESHOLD`, set once - /// in `App::new` to avoid per-frame O(n) `residue_count()` calls. - pub is_large: bool, + /// When the user last moved the camera, driving `is_interacting`. + /// `None` until the first camera change. + last_camera_change: Option, + /// Whether the terminal accepted a shared-memory graphics transmission at + /// startup. Set once by the probe in `main`; `false` over SSH. + pub kitty_shm: bool, + /// Next shared-memory slot to hand the terminal. A `Cell` because the + /// viewport renders from `&App` but each frame needs its own object. + shm_slot: std::cell::Cell, + /// Whether the scrollable chain-sequence panel is open. + pub show_sequence: bool, + /// Residues picked in the sequence panel. + pub selection: ResidueSelection, + /// Whether the selection is drawn as ball-and-stick in the 3D view. + /// When off, selected residues are still marked with a highlight sphere. + pub show_ball_stick: bool, + /// Cursor position in the sequence panel, as `(chain, residue)` indices. + pub seq_cursor: (usize, usize), + /// Anchor for shift-extended range selection, cleared by any unshifted move. + seq_anchor: Option<(usize, usize)>, + /// First layout row drawn in the panel. + pub seq_scroll: usize, + /// Wrapped row layout, rebuilt whenever the panel width changes. + seq_layout: SequenceLayout, + /// Sequence rows the panel can currently show, set from the drawn area. + seq_visible_rows: usize, + /// Panel height in terminal rows, adjustable with `<` and `>`. + pub seq_panel_height: u16, + /// A chain to scroll to once the layout for the current width exists. + /// Opening the panel happens before the frame that sizes it, so the jump + /// has to wait for a layout to jump within. + seq_pending_goto: Option, } impl App { @@ -187,7 +290,7 @@ impl App { }; 0.9 * px_w.min(px_h) / (2.0 * radius) } - RenderMode::HalfBlock => { + RenderMode::HalfBlock | RenderMode::HalfBlockPlus => { let px_w = vp_cols * 2.0; let px_h = vp_rows * 4.0; 0.9 * px_w.min(px_h) / (2.0 * radius) @@ -261,6 +364,15 @@ impl App { let connection_type = ConnectionType::detect(); + let selection = ResidueSelection::new(&protein); + // The first chain with residues is where the sequence cursor starts; + // a structure can open with an empty leading chain. + let cursor_chain = protein + .chains + .iter() + .position(|chain| !chain.residues.is_empty()) + .unwrap_or(0); + Self { protein, camera, @@ -286,7 +398,19 @@ impl App { residue_colors, interface_computed, interface_rx, - is_large, + last_camera_change: None, + kitty_shm: false, + shm_slot: std::cell::Cell::new(0), + show_sequence: false, + selection, + show_ball_stick: true, + seq_cursor: (cursor_chain, 0), + seq_anchor: None, + seq_scroll: 0, + seq_layout: SequenceLayout::default(), + seq_visible_rows: 1, + seq_panel_height: DEFAULT_SEQUENCE_PANEL_HEIGHT, + seq_pending_goto: None, } } @@ -303,6 +427,16 @@ impl App { } } + /// Whether the ribbon mesh will be rebuilt on the next `ribbon_mesh()` call. + pub fn mesh_is_dirty(&self) -> bool { + self.mesh_dirty + } + + /// Whether a background interface analysis is still outstanding. + pub fn interface_pending(&self) -> bool { + !self.interface_computed + } + /// Poll the background interface analysis thread (non-blocking). /// Called each frame so results are absorbed as soon as they're ready. pub fn poll_background_interface(&mut self) { @@ -427,6 +561,9 @@ impl App { if self.show_interface { self.rebuild_interface_colors(); } + if self.show_sequence { + self.seq_goto_chain(self.current_chain); + } } } @@ -440,6 +577,9 @@ impl App { if self.show_interface { self.rebuild_interface_colors(); } + if self.show_sequence { + self.seq_goto_chain(self.current_chain); + } } } @@ -447,11 +587,35 @@ impl App { self.protein.chains.iter().map(|c| c.id.clone()).collect() } - /// Returns `true` when the scene is being actively animated (e.g. auto-rotate). - /// Used to trigger half-resolution rendering in FullHD mode for smoother - /// frame rates on large structures. + /// Returns `true` while the view is moving — auto-rotating, or within + /// [`INTERACTION_LINGER`] of the last manual camera change. + /// + /// FullHD renders at reduced resolution whenever this holds, so rotating, + /// panning and zooming stay smooth; the full-resolution frame is drawn once + /// the camera settles. pub fn is_interacting(&self) -> bool { - self.camera.auto_rotate + if self.camera.auto_rotate { + return true; + } + self.last_camera_change + .is_some_and(|at| at.elapsed() < INTERACTION_LINGER) + } + + /// Claim the next shared-memory slot, cycling through the ring so the + /// terminal is never still reading the object we are about to replace. + pub fn next_shm_slot(&self) -> u32 { + let slot = self.shm_slot.get(); + self.shm_slot + .set((slot + 1) % crate::render::kitty_shm::SLOTS); + slot + } + + /// Record that the user just moved the camera. + /// + /// Called by the input loop for every key that rotates, pans, zooms or + /// resets the view. + pub fn note_camera_change(&mut self) { + self.last_camera_change = Some(std::time::Instant::now()); } pub fn tick(&mut self) { @@ -484,28 +648,28 @@ impl App { let (px_w, px_h) = match self.render_mode { RenderMode::FullHD => { let proto = self.picker.protocol_type(); - if proto != ratatui_image::picker::ProtocolType::Halfblocks + let is_graphics = proto != ratatui_image::picker::ProtocolType::Halfblocks && font_w > 0 - && font_h > 0 - { - (vp_cols * font_w as f64, vp_rows * font_h as f64) - } else { - (vp_cols * 2.0, vp_rows * 4.0) - } + && font_h > 0; + // Zoom is defined against the still-frame resolution; the + // renderer scales the camera itself when it drops to the + // interactive resolution. + fullhd_framebuffer_size(vp_cols, vp_rows, font_w, font_h, is_graphics) } - RenderMode::HalfBlock => (vp_cols * 2.0, vp_rows * 4.0), + RenderMode::HalfBlock | RenderMode::HalfBlockPlus => (vp_cols * 2.0, vp_rows * 4.0), RenderMode::Braille => (vp_cols * 2.0, vp_rows * 4.0), }; self.camera.zoom = 0.9 * px_w.min(px_h) / (2.0 * radius); } - /// Cycle lower render tiers: Braille -> HalfBlock -> Braille. - /// From FullHD, steps down to HalfBlock (next lower tier). + /// Cycle lower render tiers: Braille -> HD -> HDplus -> Braille. + /// From FullHD, steps down to HD (next lower tier). /// Bound to `m`. pub fn toggle_hd(&mut self, term_cols: u16, term_rows: u16) { self.render_mode = match self.render_mode { RenderMode::Braille => RenderMode::HalfBlock, - RenderMode::HalfBlock => RenderMode::Braille, + RenderMode::HalfBlock => RenderMode::HalfBlockPlus, + RenderMode::HalfBlockPlus => RenderMode::Braille, RenderMode::FullHD => RenderMode::HalfBlock, }; // Dismiss any stale SSH warning (no longer in FullHD) @@ -537,3 +701,552 @@ impl App { self.recalculate_zoom(term_cols, term_rows); } } + +/// Panel rows that are not sequence: the top border and the cursor line. +pub const SEQUENCE_PANEL_CHROME: u16 = 2; + +/// Default height of the sequence panel, in terminal rows. +pub const DEFAULT_SEQUENCE_PANEL_HEIGHT: u16 = 10; + +/// Bounds for `<` / `>` resizing. The panel is additionally capped at half the +/// terminal height by the layout, so the 3D view never disappears. +pub const MIN_SEQUENCE_PANEL_HEIGHT: u16 = 4; +pub const MAX_SEQUENCE_PANEL_HEIGHT: u16 = 30; + +/// Sequence panel: layout, cursor navigation and residue selection. +impl App { + /// Open or close the panel. The selection outlives it, so closing the + /// panel keeps whatever is drawn in 3D. + pub fn toggle_sequence_panel(&mut self) { + self.show_sequence = !self.show_sequence; + if self.show_sequence { + // Follow the chain the rest of the UI is focused on, once there is + // a layout to scroll within. + self.seq_pending_goto = Some(self.current_chain); + } + } + + /// Tell the panel how much room it has, rebuilding the wrapped layout when + /// the width changes. Called once per frame before input is handled, so + /// navigation and rendering always share one layout. + pub fn set_sequence_viewport(&mut self, width: u16, height: u16) { + let avail = width.saturating_sub(crate::ui::sequence_panel::GUTTER) as usize; + let wrap = wrap_for_width(avail); + if self.seq_layout.width != width || self.seq_layout.wrap != wrap { + self.seq_layout = SequenceLayout::build(&self.protein, wrap, width); + } + self.seq_visible_rows = height.saturating_sub(SEQUENCE_PANEL_CHROME).max(1) as usize; + if let Some(chain) = self.seq_pending_goto.take() { + self.seq_goto_chain(chain); + } + self.scroll_cursor_into_view(); + } + + pub fn sequence_layout(&self) -> &SequenceLayout { + &self.seq_layout + } + + /// Grow or shrink the panel by `delta` rows, within the fixed bounds. + pub fn resize_sequence_panel(&mut self, delta: i16) { + let height = i32::from(self.seq_panel_height) + i32::from(delta); + self.seq_panel_height = height.clamp( + i32::from(MIN_SEQUENCE_PANEL_HEIGHT), + i32::from(MAX_SEQUENCE_PANEL_HEIGHT), + ) as u16; + } + + /// The residue under the cursor, if the structure has one. + pub fn seq_cursor_residue(&self) -> Option<(&crate::model::protein::Chain, &Residue)> { + let chain = self.protein.chains.get(self.seq_cursor.0)?; + let residue = chain.residues.get(self.seq_cursor.1)?; + Some((chain, residue)) + } + + fn seq_chain_len(&self, chain: usize) -> usize { + self.protein + .chains + .get(chain) + .map_or(0, |chain| chain.residues.len()) + } + + /// Move the cursor to an exact residue, extending the selection when + /// `extend` is set. + /// + /// Extension is anchored at the cursor position the first shifted move + /// started from and only ever adds residues, so a shift-arrow can never + /// silently drop part of an existing selection. + fn seq_move_to(&mut self, chain: usize, residue: usize, extend: bool) { + if self.seq_chain_len(chain) == 0 { + return; + } + let previous = self.seq_cursor; + let residue = residue.min(self.seq_chain_len(chain) - 1); + self.seq_cursor = (chain, residue); + self.current_chain = chain; + // Interface coloring is keyed on the focus chain, so moving the cursor + // into another chain has to refresh it exactly as `[` / `]` does. + if previous.0 != chain && self.show_interface { + self.rebuild_interface_colors(); + } + + if extend { + let anchor = *self.seq_anchor.get_or_insert(previous); + if anchor.0 == chain { + self.selection.set_range(chain, anchor.1, residue, true); + } else { + // A range that crosses chains is not a range; restart the + // anchor in the new chain rather than selecting everything in + // between. + self.selection.set(chain, residue, true); + self.seq_anchor = Some((chain, residue)); + } + } else { + self.seq_anchor = None; + } + + self.scroll_cursor_into_view(); + } + + /// Flat index of a residue across all chains, used for horizontal movement + /// that runs off the end of a chain into the next one. + fn seq_flat_index(&self, chain: usize, residue: usize) -> usize { + self.protein.chains[..chain.min(self.protein.chains.len())] + .iter() + .map(|chain| chain.residues.len()) + .sum::() + + residue + } + + fn seq_from_flat(&self, mut index: usize) -> Option<(usize, usize)> { + for (chain_index, chain) in self.protein.chains.iter().enumerate() { + if index < chain.residues.len() { + return Some((chain_index, index)); + } + index -= chain.residues.len(); + } + None + } + + /// Move by `delta` residues, crossing chain boundaries at the ends. + pub fn seq_move_horizontal(&mut self, delta: isize, extend: bool) { + let total: usize = self + .protein + .chains + .iter() + .map(|chain| chain.residues.len()) + .sum(); + if total == 0 { + return; + } + let flat = self.seq_flat_index(self.seq_cursor.0, self.seq_cursor.1) as isize; + let target = (flat + delta).clamp(0, total as isize - 1) as usize; + if let Some((chain, residue)) = self.seq_from_flat(target) { + self.seq_move_to(chain, residue, extend); + } + } + + /// Move `delta` layout rows, keeping the column and skipping chain headers. + pub fn seq_move_vertical(&mut self, delta: isize, extend: bool) { + let Some((row, column)) = self.seq_layout.locate(self.seq_cursor.0, self.seq_cursor.1) + else { + return; + }; + let rows = self.seq_layout.row_count() as isize; + if rows == 0 { + return; + } + let target = (row as isize + delta).clamp(0, rows - 1); + let step = if delta >= 0 { 1 } else { -1 }; + + // Headers carry no residue, so walk past them — first onwards in the + // direction of travel, then backwards if that ran off the end. + let landing = seek_residue_row(&self.seq_layout, target, step, rows) + .or_else(|| seek_residue_row(&self.seq_layout, target, -step, rows)); + let Some(landing) = landing else { + return; + }; + if let Some((chain, residue)) = self.seq_layout.residue_at(landing, column) { + self.seq_move_to(chain, residue, extend); + } + } + + /// Jump to the first or last residue of the cursor's chain. + pub fn seq_move_to_chain_edge(&mut self, end: bool, extend: bool) { + let chain = self.seq_cursor.0; + let len = self.seq_chain_len(chain); + if len == 0 { + return; + } + self.seq_move_to(chain, if end { len - 1 } else { 0 }, extend); + } + + /// Move by one screenful. + pub fn seq_page(&mut self, forward: bool, extend: bool) { + let rows = self.seq_visible_rows.max(1) as isize; + self.seq_move_vertical(if forward { rows } else { -rows }, extend); + } + + /// Put the cursor on the first residue of `chain` and show its header. + pub fn seq_goto_chain(&mut self, chain: usize) { + if self.protein.chains.is_empty() { + return; + } + let chain = chain.min(self.protein.chains.len() - 1); + if self.seq_chain_len(chain) == 0 { + // Nothing to put a cursor on; still scroll the header into view. + if let Some(header) = self.seq_layout.header_row(chain) { + self.seq_scroll = self.clamp_scroll(header); + } + self.current_chain = chain; + return; + } + self.seq_move_to(chain, 0, false); + if let Some(header) = self.seq_layout.header_row(chain) { + self.seq_scroll = self.clamp_scroll(header); + } + } + + /// Toggle the residue under the cursor. + pub fn seq_toggle_selection(&mut self) { + let (chain, residue) = self.seq_cursor; + if self.seq_chain_len(chain) == 0 { + return; + } + self.selection.toggle(chain, residue); + // A later shift-arrow extends from here. + self.seq_anchor = Some((chain, residue)); + } + + /// Select the cursor's whole chain, or clear it if it is already fully in. + pub fn seq_toggle_chain_selection(&mut self) { + let chain = self.seq_cursor.0; + let len = self.seq_chain_len(chain); + if len == 0 { + return; + } + let fully_selected = (0..len).all(|residue| self.selection.contains(chain, residue)); + self.selection.set_chain(chain, !fully_selected); + self.seq_anchor = None; + } + + pub fn clear_selection(&mut self) { + self.selection.clear(); + self.seq_anchor = None; + } + + pub fn toggle_ball_stick(&mut self) { + self.show_ball_stick = !self.show_ball_stick; + } + + /// Centre the view on the selection, or on the cursor residue when nothing + /// is selected. Zoom is left alone: finding the residue is the hard part, + /// and the user still owns the magnification. + pub fn focus_on_selection(&mut self) -> bool { + let target = self.selection.centroid(&self.protein).or_else(|| { + let (_, residue) = self.seq_cursor_residue()?; + let atoms = &residue.atoms; + if atoms.is_empty() { + return None; + } + let n = atoms.len() as f64; + Some([ + atoms.iter().map(|a| a.x).sum::() / n, + atoms.iter().map(|a| a.y).sum::() / n, + atoms.iter().map(|a| a.z).sum::() / n, + ]) + }); + let Some([x, y, z]) = target else { + return false; + }; + // `project` already includes the current pan, so subtracting the + // projected offset lands the target exactly on the view centre for any + // rotation or zoom. + let projected = self.camera.project(x, y, z); + self.camera.pan_x -= projected.x; + self.camera.pan_y -= projected.y; + self.note_camera_change(); + true + } + + fn clamp_scroll(&self, scroll: usize) -> usize { + let max = self + .seq_layout + .row_count() + .saturating_sub(self.seq_visible_rows.max(1)); + scroll.min(max) + } + + fn scroll_cursor_into_view(&mut self) { + let Some((row, _)) = self.seq_layout.locate(self.seq_cursor.0, self.seq_cursor.1) else { + return; + }; + let visible = self.seq_visible_rows.max(1); + if row < self.seq_scroll { + self.seq_scroll = row; + } else if row >= self.seq_scroll + visible { + self.seq_scroll = row + 1 - visible; + } + self.seq_scroll = self.clamp_scroll(self.seq_scroll); + } +} + +/// First residue row at or after `from`, walking in `step` direction. +fn seek_residue_row( + layout: &SequenceLayout, + from: isize, + step: isize, + rows: isize, +) -> Option { + let mut row = from; + while (0..rows).contains(&row) { + if !matches!(layout.rows[row as usize], SeqRow::Header(_)) { + return Some(row as usize); + } + row += step; + } + None +} + +#[cfg(test)] +mod sequence_navigation_tests { + use super::*; + use crate::model::protein::{Atom, Chain, MoleculeType, SecondaryStructure}; + use crate::model::selection::ResidueColorOverrides; + use ratatui_image::picker::Picker; + + fn chain(id: &str, count: usize) -> Chain { + Chain { + id: id.to_string(), + molecule_type: MoleculeType::Protein, + residues: (0..count) + .map(|i| Residue { + name: "ALA".to_string(), + seq_num: i as i32 + 1, + insertion_code: None, + atoms: vec![Atom { + name: "CA".to_string(), + element: "C".to_string(), + x: i as f64, + y: 0.0, + z: 0.0, + b_factor: 10.0, + is_backbone: true, + is_hetero: false, + }], + secondary_structure: SecondaryStructure::Coil, + }) + .collect(), + } + } + + /// Chains of 25, 0 and 8 residues: the empty one in the middle is what + /// makes navigation interesting. + fn app() -> App { + let protein = Protein { + name: "nav".to_string(), + chains: vec![chain("A", 25), chain("B", 0), chain("C", 8)], + ligands: Vec::new(), + }; + let mut app = App::new( + protein, + AppConfig { + render_mode: RenderMode::Braille, + viz_mode: VizMode::Backbone, + user_explicit_mode: true, + color_override: None, + residue_colors: ResidueColorOverrides::default(), + }, + 80, + 40, + Picker::halfblocks(), + ); + app.show_sequence = true; + // Gutter plus three groups of ten: ten residues per row. + app.set_sequence_viewport(7 + 10, 8); + app + } + + #[test] + fn horizontal_movement_crosses_into_the_next_non_empty_chain() { + let mut app = app(); + app.seq_move_to_chain_edge(true, false); + assert_eq!(app.seq_cursor, (0, 24)); + // Past the end of chain A, straight over the empty chain B. + app.seq_move_horizontal(1, false); + assert_eq!(app.seq_cursor, (2, 0)); + assert_eq!(app.current_chain, 2); + // And back again. + app.seq_move_horizontal(-1, false); + assert_eq!(app.seq_cursor, (0, 24)); + } + + #[test] + fn vertical_movement_skips_chain_headers() { + let mut app = app(); + app.seq_move_to(0, 22, false); + // Row below the last row of chain A is chain C's header; the cursor + // must land on residues, not on it. + app.seq_move_vertical(1, false); + assert_eq!(app.seq_cursor.0, 2, "expected to land in chain C"); + app.seq_move_vertical(-1, false); + assert_eq!(app.seq_cursor.0, 0, "expected to land back in chain A"); + } + + #[test] + fn shift_extension_selects_a_range_and_restarts_across_chains() { + let mut app = app(); + app.seq_move_to(0, 4, false); + app.seq_toggle_selection(); + app.seq_move_horizontal(3, true); + assert_eq!(app.selection.count(), 4); + assert!(app.selection.contains(0, 7)); + + // Extending into another chain must not select everything between. + app.seq_move_to(2, 3, true); + assert_eq!(app.selection.count(), 5); + assert!(app.selection.contains(2, 3)); + assert!(!app.selection.contains(2, 0)); + } + + #[test] + fn the_cursor_stays_inside_the_visible_rows() { + let mut app = app(); + let visible = app.seq_visible_rows; + app.seq_move_to_chain_edge(true, false); + let (row, _) = app + .sequence_layout() + .locate(app.seq_cursor.0, app.seq_cursor.1) + .unwrap(); + assert!( + (app.seq_scroll..app.seq_scroll + visible).contains(&row), + "row {row} outside scroll window {}..{}", + app.seq_scroll, + app.seq_scroll + visible + ); + } + + #[test] + fn empty_chains_never_take_the_cursor() { + let mut app = app(); + app.seq_goto_chain(1); + assert_eq!(app.current_chain, 1, "the header still gets focus"); + assert_ne!(app.seq_cursor.0, 1, "but no residue cursor lands there"); + } + + #[test] + fn focusing_the_selection_centres_it() { + let mut app = app(); + app.selection.set_range(0, 0, 4, true); + assert!(app.focus_on_selection()); + let centroid = app.selection.centroid(&app.protein).unwrap(); + let projected = app.camera.project(centroid[0], centroid[1], centroid[2]); + assert!( + projected.x.abs() < 1e-9 && projected.y.abs() < 1e-9, + "selection should project to the view centre, got {projected:?}" + ); + } + + #[test] + fn the_panel_never_squeezes_out_the_viewport() { + let mut app = app(); + for _ in 0..100 { + app.resize_sequence_panel(1); + } + assert_eq!(app.seq_panel_height, MAX_SEQUENCE_PANEL_HEIGHT); + // On a 20-row terminal the layout still leaves the rest of the UI room. + assert_eq!( + crate::ui::sequence_panel::height_for(&app, 20), + 13, + "panel should give back the seven rows the rest of the UI needs" + ); + for _ in 0..100 { + app.resize_sequence_panel(-1); + } + assert_eq!(app.seq_panel_height, MIN_SEQUENCE_PANEL_HEIGHT); + } +} + +#[cfg(test)] +mod fullhd_sizing_tests { + use super::*; + + /// Measured: a full-screen kitty at font_size 14 on a 2560x1600 Retina + /// panel reports 20x43 device-pixel cells over a 144x36 viewport. + const RETINA: (f64, f64, u16, u16) = (144.0, 36.0, 20, 43); + + #[test] + fn native_resolution_is_used_below_the_cap() { + let (cols, rows, fw, fh) = RETINA; + let (w, h) = fullhd_framebuffer_size(cols, rows, fw, fh, true); + assert_eq!((w, h), (cols * f64::from(fw), rows * f64::from(fh))); + assert!( + w * h <= FULLHD_MAX_PIXELS, + "{w}x{h} should be under the cap" + ); + } + + /// Regression: the cap was first set at 4 MP, which a real full-screen + /// HiDPI laptop viewport (4.46 MP) tripped by 10% — paying a non-integer + /// upscale of every still frame to save almost nothing. + #[test] + fn a_full_screen_hidpi_laptop_is_not_capped() { + let (cols, rows, fw, fh) = RETINA; + let native = cols * f64::from(fw) * rows * f64::from(fh); + assert!( + native < FULLHD_MAX_PIXELS, + "{native} px viewport should render natively, cap is {FULLHD_MAX_PIXELS}" + ); + } + + /// A 4K viewport should also pass through untouched. + #[test] + fn a_4k_viewport_is_not_capped() { + let (w, h) = fullhd_framebuffer_size(3840.0, 2160.0, 1, 1, true); + assert_eq!((w, h), (3840.0, 2160.0)); + } + + #[test] + fn oversized_viewports_are_capped_by_area_and_keep_their_aspect() { + // A 6K display: well past the cap. + let (native_w, native_h) = (6016.0, 3384.0); + let (w, h) = fullhd_framebuffer_size(6016.0, 3384.0, 1, 1, true); + + assert!( + w * h <= FULLHD_MAX_PIXELS * 1.001, + "{w}x{h} = {} px exceeds the cap", + w * h + ); + let aspect_error = (w / h) - (native_w / native_h); + assert!( + aspect_error.abs() < 1e-6, + "aspect drifted by {aspect_error}" + ); + } + + #[test] + fn braille_fallback_ignores_font_size() { + let (w, h) = fullhd_framebuffer_size(100.0, 40.0, 17, 35, false); + assert_eq!((w, h), (200.0, 160.0)); + } + + #[test] + fn dimensions_never_collapse_to_zero() { + let (w, h) = fullhd_framebuffer_size(0.0, 0.0, 17, 35, true); + assert!(w >= 1.0 && h >= 1.0, "got {w}x{h}"); + } + + #[test] + fn interactive_scale_quarters_the_pixel_count() { + let (cols, rows, fw, fh) = RETINA; + let (still_w, still_h) = fullhd_framebuffer_size(cols, rows, fw, fh, true); + let (moving_w, moving_h) = ( + still_w * FULLHD_INTERACTIVE_SCALE, + still_h * FULLHD_INTERACTIVE_SCALE, + ); + let ratio = (still_w * still_h) / (moving_w * moving_h); + assert!( + (ratio - 4.0).abs() < 1e-9, + "expected 4x fewer pixels, got {ratio}" + ); + } +} diff --git a/src/main.rs b/src/main.rs index 36c16c7..bbd328f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,7 +48,7 @@ struct Cli { #[arg(long, alias = "pixel")] fullhd: bool, - /// Render mode: braille, halfblock (or hd), fullhd (or pixel) + /// Render mode: braille, halfblock (or hd), hdplus (or hd+), fullhd (or pixel) #[arg(long = "render", value_name = "MODE")] render_mode: Option, @@ -60,6 +60,10 @@ struct Cli { #[arg(long, value_name = "SELECTOR=RRGGBB")] residue_color: Vec, + /// Palette file (TOML). Defaults to ~/.config/proteinview/palette.toml when present + #[arg(long, value_name = "FILE")] + palette: Option, + /// Visualization mode: cartoon, backbone, wireframe #[arg(long, default_value = "cartoon")] mode: String, @@ -120,18 +124,77 @@ struct Cli { #[arg(long)] log: Option, - /// Number of render threads (default: 4) - #[arg(long, default_value = "4")] - threads: usize, + /// Number of render threads (default: one per core) + #[arg(long)] + threads: Option, +} + +/// Rebuild the sequence panel's wrapped layout for the current terminal size. +/// +/// Cheap unless the width actually changed, so it is called both before and +/// after input handling. +fn sync_sequence_viewport(app: &mut App, fallback: (u16, u16)) { + if !app.show_sequence { + return; + } + let (cols, rows) = crossterm::terminal::size().unwrap_or(fallback); + let panel_width = if app.show_interface { + cols.saturating_sub(ui::interface_panel::SIDEBAR_WIDTH) + } else { + cols + }; + let panel_height = ui::sequence_panel::height_for(app, rows); + app.set_sequence_viewport(panel_width, panel_height); +} + +/// Keys the sequence panel owns while it is open. +/// +/// Returns `true` when the key was consumed, so anything the panel does not +/// claim still reaches the normal bindings and the camera stays live. +fn handle_sequence_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool { + use crossterm::event::KeyModifiers; + + // Shift plus an arrow extends the selection from where the cursor was, the + // way a text editor does. + let extend = key.modifiers.contains(KeyModifiers::SHIFT); + match key.code { + KeyCode::Left => app.seq_move_horizontal(-1, extend), + KeyCode::Right => app.seq_move_horizontal(1, extend), + KeyCode::Up => app.seq_move_vertical(-1, extend), + KeyCode::Down => app.seq_move_vertical(1, extend), + KeyCode::PageUp => app.seq_page(false, extend), + KeyCode::PageDown => app.seq_page(true, extend), + KeyCode::Home => app.seq_move_to_chain_edge(false, extend), + KeyCode::End => app.seq_move_to_chain_edge(true, extend), + KeyCode::Enter => app.seq_toggle_selection(), + KeyCode::Char('A') => app.seq_toggle_chain_selection(), + KeyCode::Char('x') => app.clear_selection(), + KeyCode::Char('<') | KeyCode::Char(',') => app.resize_sequence_panel(-1), + KeyCode::Char('>') | KeyCode::Char('.') => app.resize_sequence_panel(1), + _ => return false, + } + true } fn main() -> Result<()> { let cli = Cli::parse(); - // Cap rayon thread pool. 4 threads is the sweet spot: the framebuffer - // only has ~60 tiles (64x64) so more threads hit diminishing returns, - // and 4 leaves cores free for the terminal emulator and OS. - let num_threads = cli.threads.max(1); + // Resolve the color palette before anything renders. A bad palette is a + // hard error rather than a silent fallback: ignoring a file the user wrote + // is worse than refusing to start. + render::palette::init(cli.palette.as_deref())?; + + // Rasterization splits the framebuffer into bands, so it scales with cores + // until it becomes memory-bound. Default to one thread per core: with the + // shared-memory transport the terminal no longer has a frame to decompress + // on every tick, so there is no longer a reason to hold cores back for it. + // Cap at 16 -- beyond that the bands get too thin to be worth a thread. + let num_threads = cli.threads.unwrap_or_else(|| { + std::thread::available_parallelism() + .map_or(4, std::num::NonZeroUsize::get) + .min(16) + }); + let num_threads = num_threads.max(1); match rayon::ThreadPoolBuilder::new() .num_threads(num_threads) .build_global() @@ -206,6 +269,7 @@ fn main() -> Result<()> { match mode_str.to_ascii_lowercase().as_str() { "braille" => RenderMode::Braille, "halfblock" | "hd" | "half-block" => RenderMode::HalfBlock, + "hdplus" | "hd+" | "halfblockplus" | "half-block-plus" => RenderMode::HalfBlockPlus, "fullhd" | "pixel" | "full-hd" => RenderMode::FullHD, _ => { eprintln!("Warning: unknown render mode '{}', using default", mode_str); @@ -347,6 +411,7 @@ fn main() -> Result<()> { std::panic::set_hook(Box::new(move |info| { let _ = disable_raw_mode(); let _ = execute!(io::stderr(), LeaveAlternateScreen); + render::kitty_shm::unlink_all(); original_hook(info); })); @@ -369,6 +434,15 @@ fn main() -> Result<()> { picker.font_size() ); + // Ask the terminal whether it can read pixels straight out of shared + // memory. Must happen here: after the picker's own query has drained its + // responses, and before the input thread starts consuming stdin. Over SSH + // there is no shared filesystem to share memory through, so don't ask. + let kitty_shm = picker.protocol_type() == ratatui_image::picker::ProtocolType::Kitty + && connection_type == ConnectionType::Local + && render::kitty_shm::probe(Duration::from_millis(500)); + log!(logfile, "kitty shared-memory transport: {}", kitty_shm); + // Create app with actual terminal dimensions for dynamic zoom let mut app = App::new( protein, @@ -383,6 +457,7 @@ fn main() -> Result<()> { term_rows, picker, ); + app.kitty_shm = kitty_shm; log!( logfile, "app created: render_mode={:?} chains={} zoom={:.2}", @@ -402,8 +477,15 @@ fn main() -> Result<()> { // when rendering is too slow (prevents PTY buffer saturation & freezes). let mut last_draw_duration = Duration::ZERO; let mut frames_to_skip: u32 = 0; + // Tracks the interaction state of the previous drawn frame, so the + // transition back to a still view can trigger one full-resolution redraw. + let mut was_interacting = false; loop { + // Cursor movement is expressed in layout rows, so the layout has to be + // current before keys are handled. + sync_sequence_viewport(&mut app, (term_cols, term_rows)); + // Drain all queued input from the dedicated input thread let mut had_input = false; while let Ok(app_event) = input_rx.try_recv() { @@ -416,6 +498,12 @@ fn main() -> Result<()> { } event::AppEvent::Key(key) => { log!(logfile, "key: {:?}", key.code); + // While the panel is open it owns the arrow keys and the + // selection keys; h/j/k/l keep driving the camera, so the + // view stays steerable with the sequence in front of you. + if app.show_sequence && handle_sequence_key(&mut app, key) { + continue; + } match key.code { KeyCode::Char('q') => app.should_quit = true, KeyCode::Char('c') @@ -425,23 +513,63 @@ fn main() -> Result<()> { { app.should_quit = true } - KeyCode::Char('h') | KeyCode::Left => app.camera.rotate_y(-1.0), - KeyCode::Char('l') | KeyCode::Right => app.camera.rotate_y(1.0), - KeyCode::Char('j') | KeyCode::Down => app.camera.rotate_x(1.0), - KeyCode::Char('k') | KeyCode::Up => app.camera.rotate_x(-1.0), - KeyCode::Char('u') => app.camera.rotate_z(-1.0), - KeyCode::Char('i') => app.camera.rotate_z(1.0), - KeyCode::Char('+') | KeyCode::Char('=') => app.camera.zoom_in(), - KeyCode::Char('-') => app.camera.zoom_out(), - KeyCode::Char('w') => app.camera.pan(0.0, 1.0), - KeyCode::Char('s') => app.camera.pan(0.0, -1.0), - KeyCode::Char('a') => app.camera.pan(-1.0, 0.0), - KeyCode::Char('d') => app.camera.pan(1.0, 0.0), + // Every camera-moving key notes an interaction, which + // drops FullHD to its reduced resolution until the view + // settles again. + KeyCode::Char('h') | KeyCode::Left => { + app.camera.rotate_y(-1.0); + app.note_camera_change(); + } + KeyCode::Char('l') | KeyCode::Right => { + app.camera.rotate_y(1.0); + app.note_camera_change(); + } + KeyCode::Char('j') | KeyCode::Down => { + app.camera.rotate_x(1.0); + app.note_camera_change(); + } + KeyCode::Char('k') | KeyCode::Up => { + app.camera.rotate_x(-1.0); + app.note_camera_change(); + } + KeyCode::Char('u') => { + app.camera.rotate_z(-1.0); + app.note_camera_change(); + } + KeyCode::Char('i') => { + app.camera.rotate_z(1.0); + app.note_camera_change(); + } + KeyCode::Char('+') | KeyCode::Char('=') => { + app.camera.zoom_in(); + app.note_camera_change(); + } + KeyCode::Char('-') => { + app.camera.zoom_out(); + app.note_camera_change(); + } + KeyCode::Char('w') => { + app.camera.pan(0.0, 1.0); + app.note_camera_change(); + } + KeyCode::Char('s') => { + app.camera.pan(0.0, -1.0); + app.note_camera_change(); + } + KeyCode::Char('a') => { + app.camera.pan(-1.0, 0.0); + app.note_camera_change(); + } + KeyCode::Char('d') => { + app.camera.pan(1.0, 0.0); + app.note_camera_change(); + } KeyCode::Char('r') => { let (cols, rows) = crossterm::terminal::size().unwrap_or((term_cols, term_rows)); app.camera.reset(); app.recalculate_zoom(cols, rows); + app.note_camera_change(); } KeyCode::Char('c') => app.cycle_color(), KeyCode::Char('v') => app.cycle_viz_mode(), @@ -462,9 +590,16 @@ fn main() -> Result<()> { KeyCode::Char('I') => app.toggle_interactions(), KeyCode::Char('g') => app.toggle_ligands(), KeyCode::Char('?') => app.show_help = !app.show_help, + KeyCode::Char('S') => app.toggle_sequence_panel(), + KeyCode::Char('b') => app.toggle_ball_stick(), + KeyCode::Char('z') => { + app.focus_on_selection(); + } KeyCode::Esc => { if app.show_help { app.show_help = false; + } else if app.show_sequence { + app.show_sequence = false; } } _ => {} @@ -482,13 +617,16 @@ fn main() -> Result<()> { // Only rebuild when in Cartoon mode — Backbone/Wireframe don't use the // ribbon mesh, so skipping this preserves the lazy-mesh optimization for // large structures that start in a non-Cartoon mode. + let mesh_was_rebuilt = app.viz_mode == VizMode::Cartoon && app.mesh_is_dirty(); if app.viz_mode == VizMode::Cartoon { app.ribbon_mesh(); } // Always poll the background interface thread, even during skipped // frames, so the result is absorbed as soon as it's available. + let interface_was_pending = app.interface_pending(); app.poll_background_interface(); + let interface_absorbed = interface_was_pending && !app.interface_pending(); // Adaptive frame skipping: if the previous draw took longer than the // tick rate, skip frames proportionally. User input always forces a @@ -507,6 +645,32 @@ fn main() -> Result<()> { continue; } + // Nothing on screen changes unless input arrived, an animation is + // running, or background state was just absorbed. Redrawing anyway + // would re-run the whole rasterize + transmit pipeline at every tick, + // forever, for an image identical to the one already on screen. + // + // The one extra case is `settled`: FullHD renders at a reduced + // resolution while the camera moves, so the frame after it comes to + // rest must be drawn to replace it with the sharp one. + let interacting = app.is_interacting(); + let settled = was_interacting && !interacting; + was_interacting = interacting; + + let must_redraw = had_input + || interacting + || settled + || app.ssh_hd_warning + || app.needs_clear + || mesh_was_rebuilt + || interface_absorbed + || frame_count < 2; + if !must_redraw { + app.tick(); + std::thread::sleep(tick_rate); + continue; + } + // Render frame_count += 1; if frame_count <= 3 || frame_count % 300 == 0 { @@ -529,10 +693,18 @@ fn main() -> Result<()> { // a previous FullHD session. Harmless no-op if there are none. let cleanup = render::kitty_png::KittyPngImage::cleanup_escape(); execute!(terminal.backend_mut(), crossterm::style::Print(&cleanup))?; + // Drop any shared memory object the terminal never got round to + // reading, so switching modes cannot leave objects behind. + render::kitty_shm::unlink_all(); terminal.clear()?; app.needs_clear = false; } + // ...and again after input, because the key that opened the panel or + // resized it arrived after the first sync: the frame about to be drawn + // must not be the one frame with a stale layout. + sync_sequence_viewport(&mut app, (term_cols, term_rows)); + let draw_start = Instant::now(); terminal.draw(|frame| { // If interface is active, split horizontally: sidebar | main @@ -562,20 +734,25 @@ fn main() -> Result<()> { frame.area() }; + let sequence_height = ui::sequence_panel::height_for(&app, main_area.height); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(1), // Header - Constraint::Min(3), // Viewport - Constraint::Length(2), // Status bar - Constraint::Length(1), // Help bar + Constraint::Length(1), // Header + Constraint::Min(3), // Viewport + Constraint::Length(sequence_height), // Sequence panel + Constraint::Length(2), // Status bar + Constraint::Length(1), // Help bar ]) .split(main_area); ui::header::render_header(frame, chunks[0], &app.protein.name); ui::viewport::render_viewport(frame, chunks[1], &app); - ui::statusbar::render_statusbar(frame, chunks[2], &app); - ui::helpbar::render_helpbar(frame, chunks[3]); + if sequence_height > 0 { + ui::sequence_panel::render_sequence_panel(frame, chunks[2], &app); + } + ui::statusbar::render_statusbar(frame, chunks[3], &app); + ui::helpbar::render_helpbar(frame, chunks[4], &app); if app.show_help { ui::help_overlay::render_help_overlay(frame, frame.area()); @@ -607,6 +784,7 @@ fn main() -> Result<()> { quit_flag.store(true, Ordering::Relaxed); // Restore terminal + render::kitty_shm::unlink_all(); disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor()?; diff --git a/src/model/mod.rs b/src/model/mod.rs index f461126..1c3627a 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,4 +1,6 @@ pub mod interface; pub mod protein; +pub mod residue_selection; pub mod secondary; pub mod selection; +pub mod sequence; diff --git a/src/model/residue_selection.rs b/src/model/residue_selection.rs new file mode 100644 index 0000000..48c4baa --- /dev/null +++ b/src/model/residue_selection.rs @@ -0,0 +1,291 @@ +//! The set of residues the user has picked in the sequence panel. +//! +//! The renderer asks "is this residue selected?" once per residue per frame, +//! so membership is a flat `[chain][residue]` bitmap rather than a hash set: +//! lookup is an index, and the whole thing costs one byte per residue. + +use crate::model::protein::Protein; +use crate::model::selection::format_residue_id; + +/// How a selection should appear in the 3D view. +/// +/// Bundled into one borrow so the renderers take a single extra argument, and +/// so a caller with no selection (the snapshot and panel-server paths) simply +/// passes `None`. +#[derive(Debug, Clone, Copy)] +pub struct SelectionView<'a> { + pub selection: &'a ResidueSelection, + /// Draw every atom of a picked residue, rather than one marker sphere. + pub ball_and_stick: bool, +} + +/// A per-residue selection over one parsed structure. +#[derive(Debug, Clone, Default)] +pub struct ResidueSelection { + flags: Vec>, + count: usize, +} + +impl ResidueSelection { + /// An empty selection shaped to `protein`. + pub fn new(protein: &Protein) -> Self { + Self { + flags: protein + .chains + .iter() + .map(|chain| vec![false; chain.residues.len()]) + .collect(), + count: 0, + } + } + + #[inline] + pub fn contains(&self, chain: usize, residue: usize) -> bool { + self.flags + .get(chain) + .and_then(|c| c.get(residue)) + .copied() + .unwrap_or(false) + } + + /// Whether any residue of `chain` is selected. + pub fn chain_has_any(&self, chain: usize) -> bool { + self.flags + .get(chain) + .is_some_and(|c| c.iter().any(|selected| *selected)) + } + + pub fn set(&mut self, chain: usize, residue: usize, selected: bool) { + let Some(slot) = self.flags.get_mut(chain).and_then(|c| c.get_mut(residue)) else { + return; + }; + if *slot == selected { + return; + } + *slot = selected; + if selected { + self.count += 1; + } else { + self.count -= 1; + } + } + + pub fn toggle(&mut self, chain: usize, residue: usize) { + let selected = self.contains(chain, residue); + self.set(chain, residue, !selected); + } + + /// Set an inclusive residue range within one chain. The endpoints may be + /// given in either order. + pub fn set_range(&mut self, chain: usize, from: usize, to: usize, selected: bool) { + let (lo, hi) = if from <= to { (from, to) } else { (to, from) }; + for residue in lo..=hi { + self.set(chain, residue, selected); + } + } + + /// Select or deselect an entire chain. + pub fn set_chain(&mut self, chain: usize, selected: bool) { + let Some(len) = self.flags.get(chain).map(Vec::len) else { + return; + }; + if len > 0 { + self.set_range(chain, 0, len - 1, selected); + } + } + + pub fn clear(&mut self) { + for chain in &mut self.flags { + chain.fill(false); + } + self.count = 0; + } + + pub fn count(&self) -> usize { + self.count + } + + pub fn is_empty(&self) -> bool { + self.count == 0 + } + + /// Number of chains contributing at least one residue. + pub fn chain_count(&self) -> usize { + (0..self.flags.len()) + .filter(|chain| self.chain_has_any(*chain)) + .count() + } + + /// Mean position of every atom in the selection, for centring the camera. + pub fn centroid(&self, protein: &Protein) -> Option<[f64; 3]> { + let mut sum = [0.0f64; 3]; + let mut n = 0usize; + for (chain_index, chain) in protein.chains.iter().enumerate() { + for (residue_index, residue) in chain.residues.iter().enumerate() { + if !self.contains(chain_index, residue_index) { + continue; + } + for atom in &residue.atoms { + sum[0] += atom.x; + sum[1] += atom.y; + sum[2] += atom.z; + n += 1; + } + } + } + (n > 0).then(|| [sum[0] / n as f64, sum[1] / n as f64, sum[2] / n as f64]) + } + + /// Compact human-readable form: `Lb:12-18,40 LC:7`. + /// + /// Truncated after `max_chains` chains so it always fits on one line. + pub fn describe(&self, protein: &Protein, max_chains: usize) -> String { + let mut parts: Vec = Vec::new(); + let mut skipped = 0usize; + + for (chain_index, chain) in protein.chains.iter().enumerate() { + let ranges = self.chain_ranges(chain_index, chain.residues.len()); + if ranges.is_empty() { + continue; + } + if parts.len() == max_chains { + skipped += 1; + continue; + } + let spans: Vec = ranges + .iter() + .map(|(from, to)| { + let first = &chain.residues[*from]; + let last = &chain.residues[*to]; + let first_id = + format_residue_id(first.seq_num, first.insertion_code.as_deref()); + if from == to { + first_id + } else { + let last_id = + format_residue_id(last.seq_num, last.insertion_code.as_deref()); + format!("{first_id}-{last_id}") + } + }) + .collect(); + parts.push(format!("{}:{}", chain.id, spans.join(","))); + } + + if skipped > 0 { + parts.push(format!("+{skipped} more")); + } + parts.join(" ") + } + + /// Contiguous selected runs within one chain, as residue index pairs. + fn chain_ranges(&self, chain: usize, len: usize) -> Vec<(usize, usize)> { + let mut ranges = Vec::new(); + let mut start: Option = None; + for residue in 0..len { + match (self.contains(chain, residue), start) { + (true, None) => start = Some(residue), + (false, Some(from)) => { + ranges.push((from, residue - 1)); + start = None; + } + _ => {} + } + } + if let Some(from) = start { + ranges.push((from, len - 1)); + } + ranges + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::protein::{Atom, Chain, MoleculeType, Residue, SecondaryStructure}; + + fn protein() -> Protein { + let chain = |id: &str, count: usize, x0: f64| Chain { + id: id.to_string(), + molecule_type: MoleculeType::Protein, + residues: (0..count) + .map(|i| Residue { + name: "ALA".to_string(), + seq_num: i as i32 + 1, + insertion_code: None, + atoms: vec![Atom { + name: "CA".to_string(), + element: "C".to_string(), + x: x0 + i as f64, + y: 0.0, + z: 0.0, + b_factor: 0.0, + is_backbone: true, + is_hetero: false, + }], + secondary_structure: SecondaryStructure::Coil, + }) + .collect(), + }; + Protein { + name: "sel".to_string(), + chains: vec![chain("A", 10, 0.0), chain("B", 5, 100.0)], + ligands: Vec::new(), + } + } + + #[test] + fn count_tracks_set_toggle_and_clear() { + let protein = protein(); + let mut selection = ResidueSelection::new(&protein); + assert!(selection.is_empty()); + + selection.set_range(0, 2, 5, true); + assert_eq!(selection.count(), 4); + // Re-selecting an already selected residue must not double count. + selection.set(0, 3, true); + assert_eq!(selection.count(), 4); + + selection.toggle(0, 3); + assert_eq!(selection.count(), 3); + assert!(!selection.contains(0, 3)); + + selection.set_chain(1, true); + assert_eq!(selection.count(), 8); + assert_eq!(selection.chain_count(), 2); + + selection.clear(); + assert!(selection.is_empty()); + assert_eq!(selection.chain_count(), 0); + } + + #[test] + fn out_of_range_indices_are_ignored() { + let protein = protein(); + let mut selection = ResidueSelection::new(&protein); + selection.set(9, 0, true); + selection.set(0, 999, true); + assert!(selection.is_empty()); + assert!(!selection.contains(9, 0)); + } + + #[test] + fn describe_collapses_runs_and_reports_residue_numbers() { + let protein = protein(); + let mut selection = ResidueSelection::new(&protein); + selection.set_range(0, 1, 3, true); + selection.set(0, 7, true); + selection.set(1, 0, true); + assert_eq!(selection.describe(&protein, 8), "A:2-4,8 B:1"); + assert_eq!(selection.describe(&protein, 1), "A:2-4,8 +1 more"); + } + + #[test] + fn centroid_averages_selected_atoms_only() { + let protein = protein(); + let mut selection = ResidueSelection::new(&protein); + assert!(selection.centroid(&protein).is_none()); + selection.set_range(0, 0, 2, true); + let centroid = selection.centroid(&protein).unwrap(); + assert!((centroid[0] - 1.0).abs() < 1e-9); + } +} diff --git a/src/model/sequence.rs b/src/model/sequence.rs new file mode 100644 index 0000000..93a1fbf --- /dev/null +++ b/src/model/sequence.rs @@ -0,0 +1,279 @@ +//! One-letter sequence codes and the wrapped layout of the sequence panel. +//! +//! The layout is computed once per panel width and then used by both the +//! renderer and the cursor navigation, so what the user sees and what the +//! arrow keys move through can never disagree. + +use crate::model::protein::{MoleculeType, Protein}; + +/// Residues per group, separated by a single space in the panel. +pub const GROUP: usize = 10; + +/// One row of the sequence panel. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SeqRow { + /// Chain title row: `> Lb Protein 138 res 1-138`. + Header(usize), + /// A wrapped run of residues from one chain. + Residues { + chain: usize, + /// Index of the first residue of the run within the chain. + start: usize, + /// Number of residues on this row (`<= wrap`). + len: usize, + }, +} + +/// The full scrollable row list for one panel width. +#[derive(Debug, Clone, Default)] +pub struct SequenceLayout { + /// Residues per full row. + pub wrap: usize, + /// Panel width this layout was built for. + pub width: u16, + pub rows: Vec, + /// Row index of each chain's header, so a cursor position maps to a row + /// by arithmetic instead of a search. + chain_header_row: Vec, + /// Residue count per chain, cached for clamping. + chain_len: Vec, +} + +impl SequenceLayout { + /// Build the layout for `protein` at a given residues-per-row. + /// + /// Empty chains still get a header row: a chain that parsed to zero + /// polymer residues is information, not something to hide. + pub fn build(protein: &Protein, wrap: usize, width: u16) -> Self { + let wrap = wrap.max(1); + let mut rows = Vec::new(); + let mut chain_header_row = Vec::with_capacity(protein.chains.len()); + let mut chain_len = Vec::with_capacity(protein.chains.len()); + + for (chain_index, chain) in protein.chains.iter().enumerate() { + chain_header_row.push(rows.len()); + chain_len.push(chain.residues.len()); + rows.push(SeqRow::Header(chain_index)); + let mut start = 0; + while start < chain.residues.len() { + let len = wrap.min(chain.residues.len() - start); + rows.push(SeqRow::Residues { + chain: chain_index, + start, + len, + }); + start += len; + } + } + + Self { + wrap, + width, + rows, + chain_header_row, + chain_len, + } + } + + pub fn row_count(&self) -> usize { + self.rows.len() + } + + /// Row index holding `residue` of `chain`, and the column within it. + pub fn locate(&self, chain: usize, residue: usize) -> Option<(usize, usize)> { + let header = *self.chain_header_row.get(chain)?; + if residue >= *self.chain_len.get(chain)? { + return None; + } + Some((header + 1 + residue / self.wrap, residue % self.wrap)) + } + + /// Row index of a chain's header row. + pub fn header_row(&self, chain: usize) -> Option { + self.chain_header_row.get(chain).copied() + } + + /// The residue at `column` of `row`, clamped to the row's last residue. + /// + /// Header rows have no residue, so vertical navigation skips them. + pub fn residue_at(&self, row: usize, column: usize) -> Option<(usize, usize)> { + match self.rows.get(row)? { + SeqRow::Header(_) => None, + SeqRow::Residues { chain, start, len } => { + Some((*chain, start + column.min(len.saturating_sub(1)))) + } + } + } +} + +/// Screen column offset of a residue column, accounting for group spacing. +#[inline] +pub fn column_offset(column: usize) -> usize { + column + column / GROUP +} + +/// Residues that fit in `avail` columns when every group of [`GROUP`] is +/// followed by a space. +pub fn wrap_for_width(avail: usize) -> usize { + // A group costs GROUP characters plus one separator, except the last one + // which needs no trailing space. + let groups = (avail + 1) / (GROUP + 1); + (groups * GROUP).max(GROUP) +} + +/// One-letter code for a residue, given its chain's polymer type. +/// +/// Modified residues map to their parent letter (`PSU` -> `U`, `MSE` -> `M`) +/// so a modified base never breaks the reading frame of the sequence; the +/// panel's cursor line always shows the true three-letter name. Anything +/// unrecognized is `X`. +pub fn one_letter(name: &str, molecule_type: MoleculeType) -> char { + let name = name.trim(); + match molecule_type { + MoleculeType::RNA | MoleculeType::DNA => nucleotide_letter(name), + MoleculeType::Protein | MoleculeType::SmallMolecule => amino_acid_letter(name) + // A chain classified as protein can still carry stray nucleotides + // (hybrid or mis-classified chains); fall back before giving up. + .or_else(|| { + let letter = nucleotide_letter(name); + (letter != 'X').then_some(letter) + }) + .unwrap_or('X'), + } +} + +fn amino_acid_letter(name: &str) -> Option { + Some(match name { + "ALA" => 'A', + "ARG" => 'R', + "ASN" => 'N', + "ASP" => 'D', + "CYS" | "CYX" | "CYM" => 'C', + "GLN" => 'Q', + "GLU" => 'E', + "GLY" => 'G', + "HIS" | "HID" | "HIE" | "HIP" | "HSD" | "HSE" | "HSP" => 'H', + "ILE" => 'I', + "LEU" => 'L', + "LYS" | "LYN" => 'K', + "MET" | "MSE" | "FME" => 'M', + "PHE" => 'F', + "PRO" | "HYP" => 'P', + "SER" | "SEP" => 'S', + "THR" | "TPO" => 'T', + "TRP" => 'W', + "TYR" | "PTR" => 'Y', + "VAL" => 'V', + "SEC" => 'U', + "PYL" => 'O', + "ASX" => 'B', + "GLX" => 'Z', + "UNK" => 'X', + _ => return None, + }) +} + +fn nucleotide_letter(name: &str) -> char { + match name { + "A" | "DA" | "AMP" | "ADE" | "1MA" | "6MA" | "MA6" | "2MA" => 'A', + "C" | "DC" | "CMP" | "CYT" | "5MC" | "OMC" | "4OC" | "3MC" => 'C', + "G" | "DG" | "GMP" | "GUA" | "2MG" | "7MG" | "M2G" | "OMG" | "1MG" | "YG" => 'G', + "U" | "UMP" | "URA" | "URI" | "PSU" | "4SU" | "H2U" | "5MU" | "UR3" | "OMU" | "3MU" => 'U', + "T" | "DT" | "THY" | "5MT" => 'T', + "I" | "DI" => 'I', + "N" | "DN" => 'N', + _ => 'X', + } +} + +/// Short label for a chain's polymer type, for the panel header. +pub fn molecule_label(molecule_type: MoleculeType) -> &'static str { + match molecule_type { + MoleculeType::Protein => "Protein", + MoleculeType::RNA => "RNA", + MoleculeType::DNA => "DNA", + MoleculeType::SmallMolecule => "Other", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::protein::{Chain, Residue, SecondaryStructure}; + + fn chain(id: &str, count: usize, molecule_type: MoleculeType) -> Chain { + Chain { + id: id.to_string(), + molecule_type, + residues: (0..count) + .map(|i| Residue { + name: "ALA".to_string(), + seq_num: i as i32 + 1, + insertion_code: None, + atoms: Vec::new(), + secondary_structure: SecondaryStructure::Coil, + }) + .collect(), + } + } + + fn protein(counts: &[usize]) -> Protein { + Protein { + name: "seq".to_string(), + chains: counts + .iter() + .enumerate() + .map(|(i, n)| chain(&format!("C{i}"), *n, MoleculeType::Protein)) + .collect(), + ligands: Vec::new(), + } + } + + #[test] + fn locate_and_residue_at_are_inverses() { + let protein = protein(&[25, 3]); + let layout = SequenceLayout::build(&protein, 10, 80); + for chain in 0..2 { + let len = protein.chains[chain].residues.len(); + for residue in 0..len { + let (row, column) = layout.locate(chain, residue).unwrap(); + assert_eq!(layout.residue_at(row, column), Some((chain, residue))); + } + } + } + + #[test] + fn empty_chains_still_get_a_header() { + let protein = protein(&[0, 4]); + let layout = SequenceLayout::build(&protein, 10, 80); + assert_eq!(layout.rows[0], SeqRow::Header(0)); + assert_eq!(layout.rows[1], SeqRow::Header(1)); + assert_eq!(layout.locate(0, 0), None); + } + + #[test] + fn short_rows_clamp_the_column() { + let protein = protein(&[13]); + let layout = SequenceLayout::build(&protein, 10, 80); + // Second residue row holds 3 residues; column 7 clamps to the last. + assert_eq!(layout.residue_at(2, 7), Some((0, 12))); + } + + #[test] + fn wrap_accounts_for_group_separators() { + // Three groups need 10+1+10+1+10 = 32 columns; one fewer fits only two. + assert_eq!(wrap_for_width(32), 30); + assert_eq!(wrap_for_width(31), 20); + // Never returns zero, however narrow the panel is. + assert_eq!(wrap_for_width(0), GROUP); + } + + #[test] + fn modified_residues_keep_the_reading_frame() { + assert_eq!(one_letter("PSU", MoleculeType::RNA), 'U'); + assert_eq!(one_letter("MSE", MoleculeType::Protein), 'M'); + assert_eq!(one_letter("T1C", MoleculeType::Protein), 'X'); + // A nucleotide inside a chain classified as protein still reads. + assert_eq!(one_letter("G", MoleculeType::Protein), 'G'); + } +} diff --git a/src/panel_server.rs b/src/panel_server.rs index aa080a6..86b300a 100644 --- a/src/panel_server.rs +++ b/src/panel_server.rs @@ -160,6 +160,7 @@ impl PanelSession { &self.mesh_cache, self.settings.show_ligands, interactions, + None, ); let image = DynamicImage::ImageRgba8(framebuffer.to_rgba_image()); write_png_atomically(&image, &self.output_path)?; diff --git a/src/render/braille.rs b/src/render/braille.rs index 30d7e64..e142541 100644 --- a/src/render/braille.rs +++ b/src/render/braille.rs @@ -4,9 +4,12 @@ use ratatui::widgets::canvas::{Canvas, Context, Line}; use crate::app::VizMode; use crate::model::interface::{Interaction, InteractionType}; use crate::model::protein::{LigandType, MoleculeType, Protein}; +use crate::model::residue_selection::SelectionView; use crate::render::bond::atoms_bonded; use crate::render::camera::Camera; use crate::render::color::ColorScheme; +use crate::render::hd::{linking_atoms, selection_atom_color}; +use crate::render::palette::palette; /// Draw a thick line by rendering parallel offset lines along the perpendicular direction. fn draw_thick_line( @@ -56,6 +59,7 @@ pub fn render_protein<'a>( height: f64, show_ligands: bool, interactions: &'a [Interaction], + selection: Option>, ) -> Canvas<'a, impl Fn(&mut Context<'_>) + 'a> { Canvas::default() .marker(Marker::Braille) @@ -75,6 +79,10 @@ pub fn render_protein<'a>( render_ligands(ctx, protein, camera, color_scheme); } + if let Some(view) = selection.filter(|view| !view.selection.is_empty()) { + render_selection(ctx, protein, view, camera); + } + if !interactions.is_empty() { render_interactions(ctx, interactions, camera); } @@ -249,6 +257,109 @@ fn render_ligands( } } +/// A small plus sign, the canvas stand-in for a dot. +fn draw_cross(ctx: &mut Context<'_>, x: f64, y: f64, size: f64, color: ratatui::style::Color) { + ctx.draw(&Line { + x1: x - size, + y1: y, + x2: x + size, + y2: y, + color, + }); + ctx.draw(&Line { + x1: x, + y1: y - size, + x2: x, + y2: y + size, + color, + }); +} + +/// Draw residues picked in the sequence panel over the braille canvas. +/// +/// The canvas has no depth buffer, so this is drawn last and always wins: in +/// braille mode the selection is a marker, not a shaded overlay. +fn render_selection( + ctx: &mut Context<'_>, + protein: &Protein, + view: SelectionView<'_>, + camera: &Camera, +) { + let carbon = palette().selection.carbon.0; + let marker = palette().selection.marker.0; + let marker_color = ratatui::style::Color::Rgb(marker[0], marker[1], marker[2]); + let offsets: [f64; 3] = [0.0, 0.4, -0.4]; + + for (chain_index, chain) in protein.chains.iter().enumerate() { + for (residue_index, residue) in chain.residues.iter().enumerate() { + if !view.selection.contains(chain_index, residue_index) { + continue; + } + + if !view.ball_and_stick { + if let Some(atom) = residue + .atoms + .iter() + .find(|atom| atom.is_backbone) + .or_else(|| residue.atoms.first()) + { + let proj = camera.project(atom.x, atom.y, atom.z); + draw_cross(ctx, proj.x, proj.y, 1.2, marker_color); + } + continue; + } + + let projected: Vec<_> = residue + .atoms + .iter() + .map(|atom| { + let proj = camera.project(atom.x, atom.y, atom.z); + let [r, g, b] = selection_atom_color(atom, carbon); + (atom, proj, ratatui::style::Color::Rgb(r, g, b)) + }) + .collect(); + + // A dot per atom: the canvas has no filled circles, and without + // this a residue modelled as a lone C-alpha would draw nothing at + // all, since there is no bond to draw. + for (_, proj, color) in &projected { + draw_cross(ctx, proj.x, proj.y, 0.6, *color); + } + + for i in 0..projected.len() { + for j in (i + 1)..projected.len() { + let (a1, p1, color) = &projected[i]; + let (a2, p2, _) = &projected[j]; + if atoms_bonded(&a1.element, a1.x, a1.y, a1.z, &a2.element, a2.x, a2.y, a2.z) { + draw_thick_line(ctx, p1.x, p1.y, p2.x, p2.y, *color, &offsets); + } + } + } + + if !view.selection.contains(chain_index, residue_index + 1) { + continue; + } + let Some(next) = chain.residues.get(residue_index + 1) else { + continue; + }; + if let Some((from, to)) = linking_atoms(chain.molecule_type, residue, next) { + let p1 = camera.project(from.x, from.y, from.z); + let p2 = camera.project(to.x, to.y, to.z); + let [r, g, b] = selection_atom_color(from, carbon); + draw_thick_line( + ctx, + p1.x, + p1.y, + p2.x, + p2.y, + ratatui::style::Color::Rgb(r, g, b), + &offsets, + ); + } + } + } +} + /// Map interaction type to a ratatui color for braille rendering. fn braille_interaction_color(t: InteractionType) -> ratatui::style::Color { match t { diff --git a/src/render/color.rs b/src/render/color.rs index ff1631a..eac07d0 100644 --- a/src/render/color.rs +++ b/src/render/color.rs @@ -3,8 +3,25 @@ use std::collections::HashSet; use crate::model::interface::InterfaceAnalysis; use crate::model::protein::{Atom, Chain, Ligand, LigandType, Residue, SecondaryStructure}; use crate::model::selection::ResidueColorOverrides; +use crate::render::palette::{Rgb, palette}; use ratatui::style::Color; +/// Convert a palette entry to a ratatui color. +#[inline] +fn rgb(color: Rgb) -> Color { + Color::Rgb(color.0[0], color.0[1], color.0[2]) +} + +/// Linear blend between two palette entries. +/// +/// Written as `low * (1 - t) + high * t` so that the default blue-to-red +/// endpoints reproduce the previous hardcoded arithmetic bit for bit. +#[inline] +fn lerp_rgb(low: Rgb, high: Rgb, t: f64) -> Color { + let channel = |i: usize| (low.0[i] as f64 * (1.0 - t) + high.0[i] as f64 * t) as u8; + Color::Rgb(channel(0), channel(1), channel(2)) +} + /// Available color schemes #[derive(Debug, Clone, Copy, PartialEq)] pub enum ColorSchemeType { @@ -107,7 +124,8 @@ impl ColorScheme { match self.scheme_type { ColorSchemeType::Structure => self.structure_color(residue), ColorSchemeType::Chain => self.chain_color(chain), - ColorSchemeType::Element => Color::Rgb(144, 144, 144), + // Residue-level stand-in; the Element scheme colors per atom. + ColorSchemeType::Element => rgb(palette().element.get("C")), ColorSchemeType::BFactor => self.bfactor_color(residue), ColorSchemeType::Rainbow => self.rainbow_color(residue), ColorSchemeType::Interface => self.interface_color(residue, chain), @@ -141,79 +159,50 @@ impl ColorScheme { )); let is_focus = chain.id == self.focus_chain_id; + let p = &palette().interface; match (is_focus, is_contact) { - (true, true) => Color::Rgb(0, 255, 100), // Bright green — antibody interface - (true, false) => Color::Rgb(40, 100, 60), // Dim green — antibody non-interface - (false, true) => Color::Rgb(255, 165, 0), // Bright orange — antigen interface - (false, false) => Color::Rgb(100, 80, 60), // Dim brown — antigen non-interface + (true, true) => rgb(p.focus_contact), + (true, false) => rgb(p.focus_other), + (false, true) => rgb(p.partner_contact), + (false, false) => rgb(p.partner_other), } } /// CPK-style element coloring pub fn element_color(atom: &Atom) -> Color { - match atom.element.trim() { - "C" => Color::Rgb(144, 144, 144), - "N" => Color::Rgb(48, 80, 248), - "O" => Color::Rgb(255, 13, 13), - "S" => Color::Rgb(255, 255, 48), - "H" => Color::Rgb(255, 255, 255), - "P" => Color::Rgb(255, 128, 0), - "FE" | "Fe" => Color::Rgb(224, 102, 51), - "MG" | "Mg" => Color::Rgb(0, 180, 0), // Magnesium — green - "ZN" | "Zn" => Color::Rgb(125, 128, 176), // Zinc — blue-gray - "CA" | "Ca" => Color::Rgb(61, 255, 0), // Calcium — green - "MN" | "Mn" => Color::Rgb(156, 122, 199), // Manganese — purple - "CO" | "Co" => Color::Rgb(240, 144, 160), // Cobalt — pink - "CU" | "Cu" => Color::Rgb(200, 128, 51), // Copper — brown-orange - "NI" | "Ni" => Color::Rgb(80, 208, 80), // Nickel — green - "CL" | "Cl" => Color::Rgb(31, 240, 31), // Chlorine — green - "BR" | "Br" => Color::Rgb(166, 41, 41), // Bromine — dark red - _ => Color::Rgb(200, 200, 200), - } + rgb(palette().element.get(&atom.element)) } /// Get base color for a ligand based on current scheme. pub fn ligand_color(&self, ligand: &Ligand) -> Color { match self.scheme_type { - ColorSchemeType::Structure => match ligand.ligand_type { - LigandType::Ligand => Color::Rgb(255, 0, 255), // magenta for ligands - LigandType::Ion => Color::Rgb(0, 255, 255), // cyan for ions - }, - ColorSchemeType::Element => Color::Rgb(144, 144, 144), // overridden per-atom + ColorSchemeType::Structure => Self::ligand_base_color(ligand), + // Overridden per-atom; carbon grey stands in for the whole molecule. + ColorSchemeType::Element => rgb(palette().element.get("C")), ColorSchemeType::BFactor => { let avg_b = if ligand.atoms.is_empty() { 0.0 } else { ligand.atoms.iter().map(|a| a.b_factor).sum::() / ligand.atoms.len() as f64 }; - let t = ((avg_b - 5.0) / 75.0).clamp(0.0, 1.0); - let r = (t * 255.0) as u8; - let b = ((1.0 - t) * 255.0) as u8; - Color::Rgb(r, 0, b) - } - ColorSchemeType::Chain => { - // Match parent chain's color using chain_id - let chain_colors = [ - Color::Rgb(0, 180, 255), - Color::Rgb(255, 100, 0), - Color::Rgb(0, 220, 100), - Color::Rgb(255, 50, 150), - Color::Rgb(180, 100, 255), - Color::Rgb(255, 220, 0), - Color::Rgb(0, 200, 200), - Color::Rgb(255, 150, 150), - ]; - let idx = - ligand.chain_id.bytes().next().unwrap_or(b'A') as usize % chain_colors.len(); - chain_colors[idx] + bfactor_gradient(avg_b) } - ColorSchemeType::Rainbow => Color::Rgb(255, 0, 255), - ColorSchemeType::Interface => Color::Rgb(255, 255, 255), // bright white to stand out + // Match parent chain's color using chain_id + ColorSchemeType::Chain => rgb(palette().chain(&ligand.chain_id)), + ColorSchemeType::Rainbow => rgb(palette().ligand.rainbow), + // Drawn bright so it stands out against the interface coloring. + ColorSchemeType::Interface => rgb(palette().interface.ligand), // pLDDT mode: fall back to Structure-mode colors for ligands - ColorSchemeType::Plddt => match ligand.ligand_type { - LigandType::Ligand => Color::Rgb(255, 0, 255), // magenta for ligands - LigandType::Ion => Color::Rgb(0, 255, 255), // cyan for ions - }, + ColorSchemeType::Plddt => Self::ligand_base_color(ligand), + } + } + + /// Palette color for a ligand by kind, shared by the Structure and pLDDT schemes. + fn ligand_base_color(ligand: &Ligand) -> Color { + let p = &palette().ligand; + match ligand.ligand_type { + LigandType::Ligand => rgb(p.ligand), + LigandType::Ion => rgb(p.ion), } } @@ -233,27 +222,17 @@ impl ColorScheme { return color; } + let p = &palette().structure; match residue.secondary_structure { - SecondaryStructure::Helix => Color::Rgb(255, 0, 128), - SecondaryStructure::Sheet => Color::Rgb(255, 200, 0), - SecondaryStructure::Turn => Color::Rgb(96, 128, 255), - SecondaryStructure::Coil => Color::Rgb(0, 204, 0), + SecondaryStructure::Helix => rgb(p.helix), + SecondaryStructure::Sheet => rgb(p.sheet), + SecondaryStructure::Turn => rgb(p.turn), + SecondaryStructure::Coil => rgb(p.coil), } } fn chain_color(&self, chain: &Chain) -> Color { - let chain_colors = [ - Color::Rgb(0, 180, 255), - Color::Rgb(255, 100, 0), - Color::Rgb(0, 220, 100), - Color::Rgb(255, 50, 150), - Color::Rgb(180, 100, 255), - Color::Rgb(255, 220, 0), - Color::Rgb(0, 200, 200), - Color::Rgb(255, 150, 150), - ]; - let idx = chain.id.bytes().next().unwrap_or(b'A') as usize % chain_colors.len(); - chain_colors[idx] + rgb(palette().chain(&chain.id)) } fn bfactor_color(&self, residue: &Residue) -> Color { @@ -262,10 +241,7 @@ impl ColorScheme { } else { residue.atoms.iter().map(|a| a.b_factor).sum::() / residue.atoms.len() as f64 }; - let t = ((avg_b - 5.0) / 75.0).clamp(0.0, 1.0); - let r = (t * 255.0) as u8; - let b = ((1.0 - t) * 255.0) as u8; - Color::Rgb(r, 0, b) + bfactor_gradient(avg_b) } fn rainbow_color(&self, residue: &Residue) -> Color { @@ -286,14 +262,15 @@ impl ColorScheme { /// - >= 50: yellow (low confidence) /// - < 50: orange (very low confidence) pub fn plddt_color(b_factor: f64) -> Color { + let p = &palette().plddt; if b_factor >= 90.0 { - Color::Rgb(0, 83, 214) + rgb(p.very_high) } else if b_factor >= 70.0 { - Color::Rgb(101, 203, 243) + rgb(p.high) } else if b_factor >= 50.0 { - Color::Rgb(255, 219, 19) + rgb(p.low) } else { - Color::Rgb(255, 125, 69) + rgb(p.very_low) } } @@ -308,15 +285,26 @@ impl ColorScheme { } } +/// Map a raw B-factor onto the configured cold-to-hot gradient. +/// +/// The 5..80 domain matches the range these files typically occupy; values +/// outside it clamp to the endpoints. +fn bfactor_gradient(b_factor: f64) -> Color { + let p = &palette().bfactor; + let t = ((b_factor - 5.0) / 75.0).clamp(0.0, 1.0); + lerp_rgb(p.low, p.high, t) +} + /// Returns a base-type color for nucleotide residues, or `None` for non-nucleotides. fn nucleotide_base_color(name: &str) -> Option { + let p = &palette().nucleotide; match name { - "A" | "DA" | "AMP" => Some(Color::Rgb(220, 60, 60)), // Adenine — red - "U" | "UMP" => Some(Color::Rgb(60, 60, 220)), // Uracil — blue - "T" | "DT" => Some(Color::Rgb(60, 60, 220)), // Thymine — blue - "G" | "DG" | "GMP" => Some(Color::Rgb(60, 180, 60)), // Guanine — green - "C" | "DC" | "CMP" => Some(Color::Rgb(220, 200, 40)), // Cytosine — yellow - "I" | "DI" => Some(Color::Rgb(150, 100, 180)), // Inosine — purple + "A" | "DA" | "AMP" => Some(rgb(p.adenine)), + "U" | "UMP" => Some(rgb(p.uracil)), + "T" | "DT" => Some(rgb(p.thymine)), + "G" | "DG" | "GMP" => Some(rgb(p.guanine)), + "C" | "DC" | "CMP" => Some(rgb(p.cytosine)), + "I" | "DI" => Some(rgb(p.inosine)), _ => None, } } diff --git a/src/render/framebuffer.rs b/src/render/framebuffer.rs index 0d39339..3660d65 100644 --- a/src/render/framebuffer.rs +++ b/src/render/framebuffer.rs @@ -2,6 +2,18 @@ use image::{RgbImage, RgbaImage}; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; +use rayon::prelude::*; + +/// Depth span, in angstroms, at which the caller's fog strength applies as +/// given. Roughly the depth of a small single-domain protein, so structures of +/// that size and below keep exactly the appearance they always had. +const FOG_REFERENCE_DEPTH: f64 = 55.0; + +/// Ceiling on the depth-scaled fog strength. +/// +/// At this blend a distant feature keeps a quarter of its own color: clearly +/// receded, still identifiable. Reached at roughly twice the reference depth. +const FOG_MAX_STRENGTH: f64 = 0.75; /// RGB pixel framebuffer with z-buffer for software rasterization. /// @@ -175,23 +187,36 @@ impl Framebuffer { /// /// - Nearest pixels (z == z_min) keep their original color /// - Farthest pixels (z == z_max) are blended most toward `fog_color` - /// - The `fog_strength` parameter (0.0..=1.0) controls the maximum blend + /// - The `fog_strength` parameter (0.0..=1.0) sets the blend at the far + /// plane for a structure no deeper than [`FOG_REFERENCE_DEPTH`] + /// + /// # Why the strength scales with depth /// - /// Background pixels (depth == INFINITY) remain unchanged (black). + /// The ramp is normalized across the structure's own depth range, so the + /// contrast between two features a fixed distance apart is inversely + /// proportional to how deep the whole structure is. At a fixed strength a + /// 10 A separation is a clear 0.065 of blend in a small protein but 0.015 + /// in a ribosome -- invisible, which is exactly when depth cues matter + /// most, because that is when everything overlaps. + /// + /// Scaling the strength with the depth span restores roughly constant + /// discrimination per angstrom, up to [`FOG_MAX_STRENGTH`], which bounds + /// how much of a distant feature's own color is allowed to wash out. + /// Structures at or below the reference depth are unaffected. pub fn apply_depth_tint(&mut self, fog_color: [u8; 3], fog_strength: f64) { // Find z_min and z_max across all valid (non-background) pixels. - let mut z_min = f32::INFINITY; - let mut z_max = f32::NEG_INFINITY; - for &d in &self.depth { - if d < f32::INFINITY { - if d < z_min { - z_min = d; - } - if d > z_max { - z_max = d; - } - } - } + let (z_min, z_max) = self + .depth + .par_iter() + .filter(|d| **d < f32::INFINITY) + .fold( + || (f32::INFINITY, f32::NEG_INFINITY), + |(lo, hi), &d| (lo.min(d), hi.max(d)), + ) + .reduce( + || (f32::INFINITY, f32::NEG_INFINITY), + |a, b| (a.0.min(b.0), a.1.max(b.1)), + ); // No valid pixels, or all at the same depth — nothing to tint. let z_range = z_max - z_min; @@ -199,23 +224,31 @@ impl Framebuffer { return; } + // Depth is in world units (angstroms): the projection applies zoom only + // to x and y, so this scaling is independent of how far the user has + // zoomed in. + let strength = (fog_strength * f64::from(z_range) / FOG_REFERENCE_DEPTH) + .clamp(fog_strength, FOG_MAX_STRENGTH); + let inv_range = 1.0 / z_range; - for i in 0..self.depth.len() { - let d = self.depth[i]; - if d >= f32::INFINITY { - continue; // background pixel — leave black - } - let t = ((d - z_min) * inv_range).clamp(0.0, 1.0); - let blend = t as f64 * fog_strength; - let c = &mut self.color[i]; - c[0] = - (c[0] as f64 + (fog_color[0] as f64 - c[0] as f64) * blend).clamp(0.0, 255.0) as u8; - c[1] = - (c[1] as f64 + (fog_color[1] as f64 - c[1] as f64) * blend).clamp(0.0, 255.0) as u8; - c[2] = - (c[2] as f64 + (fog_color[2] as f64 - c[2] as f64) * blend).clamp(0.0, 255.0) as u8; - } + // Per-pixel and independent -- parallelize over rows. + self.color + .par_iter_mut() + .zip(self.depth.par_iter()) + .for_each(|(c, &d)| { + if d >= f32::INFINITY { + return; // background pixel — leave black + } + let t = ((d - z_min) * inv_range).clamp(0.0, 1.0); + let blend = t as f64 * strength; + c[0] = (c[0] as f64 + (fog_color[0] as f64 - c[0] as f64) * blend).clamp(0.0, 255.0) + as u8; + c[1] = (c[1] as f64 + (fog_color[1] as f64 - c[1] as f64) * blend).clamp(0.0, 255.0) + as u8; + c[2] = (c[2] as f64 + (fog_color[2] as f64 - c[2] as f64) * blend).clamp(0.0, 255.0) + as u8; + }); } /// Cohen-Sutherland line clipping against framebuffer bounds [0, width) x [0, height). @@ -534,14 +567,43 @@ impl Framebuffer { /// ratatui-image integration to send the framebuffer to the terminal via /// Sixel, Kitty, or other graphics protocols. pub fn to_rgb_image(&self) -> RgbImage { - let mut img = RgbImage::new(self.width as u32, self.height as u32); - for y in 0..self.height { - for x in 0..self.width { - let c = self.color[y * self.width + x]; - img.put_pixel(x as u32, y as u32, image::Rgb(c)); - } - } - img + let mut buf = vec![0u8; self.color.len() * 3]; + buf.par_chunks_mut(3) + .zip(self.color.par_iter()) + .for_each(|(out, c)| out.copy_from_slice(c)); + RgbImage::from_raw(self.width as u32, self.height as u32, buf) + .expect("buffer is exactly width * height * 3 bytes") + } + + /// Write this framebuffer into `dst` as RGBA8, one byte per channel. + /// + /// `dst` must be exactly `width * height * 4` bytes; the caller owns the + /// allocation, which lets the interactive path write straight into a shared + /// memory mapping instead of building an intermediate image. + /// + /// Background pixels (depth == INFINITY, colour == black) get alpha = 0 so + /// the terminal background shows through. Drawn pixels get alpha = 255. + /// + /// # Panics + /// + /// If `dst` is not exactly `width * height * 4` bytes long. + pub fn write_rgba(&self, dst: &mut [u8]) { + assert_eq!( + dst.len(), + self.color.len() * 4, + "destination must be exactly width * height * 4 bytes" + ); + // Per-pixel and independent; a full frame at Retina resolution is tens + // of megabytes, enough for the copy to be worth spreading over cores. + dst.par_chunks_mut(4) + .zip(self.color.par_iter()) + .zip(self.depth.par_iter()) + .for_each(|((out, c), d)| { + out[0] = c[0]; + out[1] = c[1]; + out[2] = c[2]; + out[3] = if *d >= f32::INFINITY { 0 } else { 255 }; + }); } /// Convert this framebuffer into an `image::RgbaImage` with transparency. @@ -549,20 +611,10 @@ impl Framebuffer { /// Background pixels (depth == INFINITY, color == black) get alpha = 0 so /// the terminal background shows through. Drawn pixels get alpha = 255. pub fn to_rgba_image(&self) -> RgbaImage { - let mut img = RgbaImage::new(self.width as u32, self.height as u32); - for y in 0..self.height { - for x in 0..self.width { - let idx = y * self.width + x; - let c = self.color[idx]; - let alpha = if self.depth[idx] >= f32::INFINITY { - 0 - } else { - 255 - }; - img.put_pixel(x as u32, y as u32, image::Rgba([c[0], c[1], c[2], alpha])); - } - } - img + let mut buf = vec![0u8; self.color.len() * 4]; + self.write_rgba(&mut buf); + RgbaImage::from_raw(self.width as u32, self.height as u32, buf) + .expect("buffer is exactly width * height * 4 bytes") } /// Draw a filled circle with a specific z-depth for z-buffer testing. @@ -586,6 +638,8 @@ impl Framebuffer { } let r_sq = radius * radius; + let inv_r = if radius > 0.0 { 1.0 / radius } else { 0.0 }; + let light = default_light_dir(); for py in iy_min..=iy_max { let dy = py as f64 + 0.5 - cy; @@ -595,8 +649,10 @@ impl Framebuffer { } for px in ix_min..=ix_max { let dx = px as f64 + 0.5 - cx; - if dx * dx + dy_sq <= r_sq { - self.set_pixel(px, py, z as f32, color); + let d_sq = dx * dx + dy_sq; + if d_sq <= r_sq { + let shaded = shade_sphere(dx * inv_r, dy * inv_r, d_sq / r_sq, light, color); + self.set_pixel(px, py, z as f32, shaded); } } } @@ -769,15 +825,37 @@ pub fn framebuffer_to_widget(fb: &Framebuffer) -> Paragraph<'static> { /// per-cell (rather than per-pixel) coloring. /// /// Consecutive cells with the same foreground color are merged into a single -/// [`Span`] for performance (run-length encoding). Color quantization is -/// available via `quant_step` but currently disabled (`quant_step = 1`, a -/// no-op). Set it to e.g. 4 or 8 to reduce distinct colors and increase -/// run-length merging at the expense of color precision. -#[allow(clippy::needless_range_loop)] +/// [`Span`] for performance (run-length encoding). pub fn framebuffer_to_braille_widget(fb: &Framebuffer) -> Paragraph<'static> { + framebuffer_to_braille_widget_ssaa(fb, 1, 1) +} + +/// Supersampled variant of [`framebuffer_to_braille_widget`]. +/// +/// The framebuffer is expected to have dimensions `(cols * 2 * ssaa, rows * 4 * +/// ssaa)`, so that each braille dot is backed by an `ssaa x ssaa` block of +/// samples rather than a single pixel. Each block is box-filtered: the dot is +/// lit once the block is at least half covered, and the cell's foreground color +/// averages every covered sample of its lit dots. This anti-aliases silhouettes +/// and keeps colors stable as the camera rotates, at no cost in emitted bytes -- +/// the widget is still exactly `cols x rows` braille characters. +/// +/// `quant_step` rounds each channel to a multiple of `step` before run-length +/// merging; `1` disables it. Larger values merge many more cells into a single +/// [`Span`], which cuts the number of SGR color escapes written to the terminal +/// -- the dominant cost of this render path over SSH -- at a small loss of color +/// precision. +#[allow(clippy::needless_range_loop)] +pub fn framebuffer_to_braille_widget_ssaa( + fb: &Framebuffer, + ssaa: usize, + quant_step: u8, +) -> Paragraph<'static> { + let ssaa = ssaa.max(1); + // Terminal cell grid dimensions derived from the framebuffer. - let term_cols = fb.width.div_ceil(2); - let term_rows = fb.height.div_ceil(4); + let term_cols = fb.width.div_ceil(2 * ssaa); + let term_rows = fb.height.div_ceil(4 * ssaa); if term_cols == 0 || term_rows == 0 { return Paragraph::new(""); @@ -795,7 +873,11 @@ pub fn framebuffer_to_braille_widget(fb: &Framebuffer) -> Paragraph<'static> { [0x08, 0x10, 0x20, 0x80], // column 1: rows 0-3 ]; - let quant_step: u8 = 1; + // Samples backing one braille dot, and the coverage needed to light it. + // At `ssaa == 1` the threshold is 1, i.e. "any non-black pixel lights the + // dot" -- identical to the non-supersampled behaviour. + let samples_per_dot = ssaa * ssaa; + let coverage_threshold = samples_per_dot.div_ceil(2) as u32; let mut lines: Vec> = Vec::with_capacity(term_rows); @@ -828,24 +910,60 @@ pub fn framebuffer_to_braille_widget(fb: &Framebuffer) -> Paragraph<'static> { let mut g_sum: u32 = 0; let mut b_sum: u32 = 0; let mut on_count: u32 = 0; + // Color of the frontmost covered sample in the cell. A braille cell + // carries a single foreground color, but its eight dots can straddle + // several structures at different depths; averaging them blends a pink + // helix and a green coil into brown. Taking the nearest sample instead + // shows what is actually in front, and keeps colors saturated. + let mut cell_near_z = f32::INFINITY; + let mut cell_near_c = [0u8; 3]; for (dx, col_bits) in BRAILLE_BITS.iter().enumerate() { - let px = px_base + dx; - if px >= fb.width { + let sx0 = (px_base + dx) * ssaa; + if sx0 >= fb.width { continue; } for (dy, &bit) in col_bits.iter().enumerate() { - let py = py_base + dy; - if py >= fb.height { + let sy0 = (py_base + dy) * ssaa; + if sy0 >= fb.height { continue; } - let c = fb.color[py * fb.width + px]; - if c != [0, 0, 0] { + + // Box-filter the sample block backing this dot. + let mut covered: u32 = 0; + let mut r: u32 = 0; + let mut g: u32 = 0; + let mut b: u32 = 0; + let mut dot_near_z = f32::INFINITY; + let mut dot_near_c = [0u8; 3]; + for sy in sy0..(sy0 + ssaa).min(fb.height) { + let row = sy * fb.width; + for sx in sx0..(sx0 + ssaa).min(fb.width) { + let c = fb.color[row + sx]; + if c != [0, 0, 0] { + covered += 1; + r += c[0] as u32; + g += c[1] as u32; + b += c[2] as u32; + let z = fb.depth[row + sx]; + if z < dot_near_z { + dot_near_z = z; + dot_near_c = c; + } + } + } + } + + if covered >= coverage_threshold { bits |= bit; - r_sum += c[0] as u32; - g_sum += c[1] as u32; - b_sum += c[2] as u32; - on_count += 1; + r_sum += r; + g_sum += g; + b_sum += b; + on_count += covered; + if dot_near_z < cell_near_z { + cell_near_z = dot_near_z; + cell_near_c = dot_near_c; + } } } } @@ -866,14 +984,18 @@ pub fn framebuffer_to_braille_widget(fb: &Framebuffer) -> Paragraph<'static> { } } else { // Compute average color of "on" pixels. - let avg = quantize_color( + // Fall back to the mean when no covered sample carried a finite + // depth -- a framebuffer whose colors were written without z. + let raw = if cell_near_z.is_finite() { + cell_near_c + } else { [ (r_sum / on_count) as u8, (g_sum / on_count) as u8, (b_sum / on_count) as u8, - ], - quant_step, - ); + ] + }; + let avg = quantize_color(raw, quant_step); let cell_color = Some(avg); let braille_char = char::from_u32(0x2800u32 + bits as u32).unwrap_or(' '); @@ -918,6 +1040,51 @@ pub fn normalize(v: [f64; 3]) -> [f64; 3] { /// The X component is negative to compensate for the camera's X-negation /// (which corrects chirality so L-amino acids render correctly). In screen /// space this still appears as upper-right lighting. +/// Ambient floor for sphere shading, and with it the contrast across an atom. +/// +/// Kept a little above the ribbon's own floor: an atom is often only a few +/// pixels across, where the full ribbon contrast turns into high-frequency +/// speckle rather than form. A one-sided Lambert term would average barely +/// half the colour, which read as markedly darker than the flat discs it +/// replaces once depth fog was applied on top. +const SPHERE_AMBIENT: f64 = 0.50; + +/// Shade one pixel of an atom as a point on a sphere rather than a flat disc. +/// +/// Backbone and ligand atoms are drawn as filled circles of one flat colour. +/// On a small structure that is fine, but a large one is tens of thousands of +/// them overlapping, and a field of flat discs has no form at all -- the eye +/// gets no cue for which atom is in front of which, and the render reads as +/// confetti. Treating each disc as the silhouette of a sphere costs one +/// square root per pixel and gives every atom a highlight and a shaded limb, +/// which is what makes a dense structure legible. +/// +/// `nx`/`ny` are the pixel's offset from the centre in radii, and `d_sq_norm` +/// is that offset's squared length, so the surface normal follows from the +/// sphere equation. Screen y runs downward while the light is specified in a +/// y-up view space, hence the flip. +#[inline] +fn shade_sphere(nx: f64, ny: f64, d_sq_norm: f64, light: [f64; 3], color: [u8; 3]) -> [u8; 3] { + // The z axis here is the light's own, not the depth buffer's: `light` has a + // positive z and is meant to sit in front of the scene, so the face of the + // sphere pointing at the viewer takes +z and catches the highlight. The + // triangle rasterizer never had to pick a side because it shades with + // `abs(dot)`; a sphere does, or the highlight lands on the wrong limb. + let nz = (1.0 - d_sq_norm).max(0.0).sqrt(); + // Screen y runs downward while the light is given in a y-up view space. + let dot = nx * light[0] - ny * light[1] + nz * light[2]; + // Half-Lambert wrap, as the triangle rasterizer uses: it keeps the unlit + // limb from going flat black, which matters when atoms are only a few + // pixels across and a hard terminator would just read as noise. + let wrapped = dot.mul_add(0.5, 0.5); + let intensity = SPHERE_AMBIENT + (1.0 - SPHERE_AMBIENT) * wrapped; + [ + (f64::from(color[0]) * intensity).min(255.0) as u8, + (f64::from(color[1]) * intensity).min(255.0) as u8, + (f64::from(color[2]) * intensity).min(255.0) as u8, + ] +} + pub fn default_light_dir() -> [f64; 3] { normalize([-0.3, 0.8, 0.5]) } @@ -1003,12 +1170,43 @@ mod tests { fn test_draw_circle() { let mut fb = Framebuffer::new(20, 20); fb.draw_circle(10.0, 10.0, 3.0, [128, 64, 32]); - // Center pixel should be filled - assert_eq!(fb.color[10 * 20 + 10], [128, 64, 32]); + // Center pixel is filled, shaded rather than the flat input colour. + let center = fb.color[10 * 20 + 10]; + assert!( + center != [0, 0, 0], + "centre should be drawn, got {center:?}" + ); + assert!( + center[0] <= 128 && center[0] > 64, + "centre should be lit but not brighter than the base colour, got {center:?}" + ); // Far corner should not be assert_eq!(fb.color[0], [0, 0, 0]); } + /// Atoms are shaded as spheres, so a disc is not a flat patch of colour: + /// it has a highlight toward the light and a darker limb away from it. + #[test] + fn draw_circle_shades_like_a_sphere() { + let mut fb = Framebuffer::new(40, 40); + fb.draw_circle(20.0, 20.0, 10.0, [200, 200, 200]); + + let at = |x: usize, y: usize| fb.color[y * 40 + x][0]; + let centre = at(20, 20); + // The default light comes from the upper left. + let toward_light = at(15, 15); + let away_from_light = at(25, 25); + + assert!( + toward_light > away_from_light, + "lit limb {toward_light} should be brighter than the far limb {away_from_light}" + ); + assert!( + toward_light >= centre && centre >= away_from_light, + "brightness should fall off across the sphere: {toward_light} / {centre} / {away_from_light}" + ); + } + #[test] fn test_framebuffer_to_widget_basic() { let mut fb = Framebuffer::new(2, 4); @@ -1267,4 +1465,184 @@ mod tests { drawn ); } + + // --------------------------------------------------------------------- + // Supersampled braille conversion + // --------------------------------------------------------------------- + + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use ratatui::widgets::Widget; + + /// Render a widget into a cell buffer so the emitted glyphs and colors can + /// be inspected directly. + fn render_cells(widget: Paragraph<'static>, w: u16, h: u16) -> Buffer { + let area = Rect::new(0, 0, w, h); + let mut buf = Buffer::empty(area); + widget.render(area, &mut buf); + buf + } + + /// Count foreground-color changes along each row. This is the quantity that + /// drives how many SGR escape sequences reach the terminal, i.e. the + /// bandwidth cost of a frame. + fn color_runs(buf: &Buffer, w: u16, h: u16) -> usize { + let mut runs = 0; + for y in 0..h { + let mut prev: Option = None; + for x in 0..w { + let fg = buf[(x, y)].fg; + if prev != Some(fg) { + runs += 1; + prev = Some(fg); + } + } + } + runs + } + + #[test] + fn ssaa_one_lights_a_dot_from_any_non_black_pixel() { + // At ssaa == 1 the coverage threshold is 1, preserving the original + // "any non-black pixel lights the dot" behaviour exactly. + let mut fb = Framebuffer::new(2, 4); + fb.color[0] = [255, 0, 0]; // dot (dx=0, dy=0) -> bit 0x01 + + let buf = render_cells(framebuffer_to_braille_widget(&fb), 1, 1); + let expected = char::from_u32(0x2800 + 0x01).unwrap().to_string(); + assert_eq!(buf[(0, 0)].symbol(), expected); + } + + #[test] + fn ssaa_dot_needs_half_coverage_to_light() { + // 4x8 framebuffer at ssaa = 2 is exactly one terminal cell: each of the + // 2x4 braille dots is backed by a 2x2 block of samples. + let mut fb = Framebuffer::new(4, 8); + + // Dot (dx=0, dy=0) covers samples x in [0,2), y in [0,2). + // One covered sample out of four is 25% -- below threshold, stays dark. + fb.color[0] = [255, 0, 0]; + + // Dot (dx=1, dy=0) covers samples x in [2,4), y in [0,2). + // Two covered samples out of four is 50% -- lights up (bit 0x08). + fb.color[2] = [0, 255, 0]; + fb.color[3] = [0, 255, 0]; + + let buf = render_cells(framebuffer_to_braille_widget_ssaa(&fb, 2, 1), 1, 1); + let expected = char::from_u32(0x2800 + 0x08).unwrap().to_string(); + assert_eq!( + buf[(0, 0)].symbol(), + expected, + "only the half-covered dot should light" + ); + } + + #[test] + fn ssaa_grid_maps_to_the_same_cell_dimensions() { + // A supersampled framebuffer must still produce cols x rows cells -- + // supersampling buys quality, never extra characters on the wire. + let (cols, rows, ssaa) = (7usize, 3usize, 2usize); + let fb = Framebuffer::new(cols * 2 * ssaa, rows * 4 * ssaa); + + let plain = Framebuffer::new(cols * 2, rows * 4); + let a = render_cells( + framebuffer_to_braille_widget_ssaa(&fb, ssaa, 1), + cols as u16, + rows as u16, + ); + let b = render_cells( + framebuffer_to_braille_widget(&plain), + cols as u16, + rows as u16, + ); + assert_eq!(a, b, "ssaa must not change the emitted cell grid"); + } + + #[test] + fn quantization_merges_color_runs() { + // Eight cells whose colors differ by only 2 per channel -- the kind of + // near-identical neighbours supersampled shading produces. + let cols = 8usize; + let mut fb = Framebuffer::new(cols * 2, 4); + for cell in 0..cols { + for dx in 0..2 { + for dy in 0..4 { + let idx = dy * fb.width + cell * 2 + dx; + fb.color[idx] = [100 + 2 * cell as u8, 150, 200]; + } + } + } + + let unquantized = color_runs( + &render_cells( + framebuffer_to_braille_widget_ssaa(&fb, 1, 1), + cols as u16, + 1, + ), + cols as u16, + 1, + ); + let quantized = color_runs( + &render_cells( + framebuffer_to_braille_widget_ssaa(&fb, 1, 8), + cols as u16, + 1, + ), + cols as u16, + 1, + ); + + assert_eq!( + unquantized, cols, + "every cell should differ without quantization" + ); + assert!( + quantized < unquantized, + "quantization should merge runs (got {quantized}, unquantized {unquantized})" + ); + } + + #[test] + fn quantization_never_darkens_a_lit_cell_to_black() { + // A very dark but non-black cell must not quantize to black, which + // would make lit geometry invisible. + let mut fb = Framebuffer::new(2, 4); + for i in 0..fb.color.len() { + fb.color[i] = [1, 1, 1]; + } + let buf = render_cells(framebuffer_to_braille_widget_ssaa(&fb, 1, 8), 1, 1); + assert_ne!(buf[(0, 0)].fg, Color::Rgb(0, 0, 0)); + } + + #[test] + fn cell_color_takes_the_frontmost_sample_not_the_mean() { + // One cell whose dots straddle two structures at different depths. The + // cell carries a single foreground color, so averaging a red front and a + // green back yields a muddy olive; it must show the front color instead. + let mut fb = Framebuffer::new(2, 4); + fb.color[0] = [255, 0, 0]; + fb.depth[0] = 10.0; // far + fb.color[1] = [0, 255, 0]; + fb.depth[1] = 1.0; // near + + let buf = render_cells(framebuffer_to_braille_widget(&fb), 1, 1); + assert_eq!( + buf[(0, 0)].fg, + Color::Rgb(0, 255, 0), + "cell should take the nearer sample's color, not the blend" + ); + } + + #[test] + fn cell_color_falls_back_to_the_mean_without_depth() { + // A framebuffer whose colors were written without z has no frontmost + // sample to pick, so the mean is the only sensible answer. + let mut fb = Framebuffer::new(2, 4); + fb.color[0] = [255, 0, 0]; + fb.color[1] = [0, 255, 0]; + // depths left at INFINITY + + let buf = render_cells(framebuffer_to_braille_widget(&fb), 1, 1); + assert_eq!(buf[(0, 0)].fg, Color::Rgb(127, 127, 0)); + } } diff --git a/src/render/hd.rs b/src/render/hd.rs index db6ec11..6321e1d 100644 --- a/src/render/hd.rs +++ b/src/render/hd.rs @@ -1,10 +1,12 @@ use crate::app::VizMode; use crate::model::interface::{Interaction, InteractionType}; -use crate::model::protein::{LigandType, MoleculeType, Protein}; +use crate::model::protein::{Atom, LigandType, MoleculeType, Protein, Residue}; +use crate::model::residue_selection::SelectionView; use crate::render::bond::atoms_bonded; use crate::render::camera::Camera; use crate::render::color::{ColorScheme, color_to_rgb}; use crate::render::framebuffer::{Framebuffer, default_light_dir}; +use crate::render::palette::palette; use crate::render::ribbon::RibbonTriangle; use rayon::prelude::*; @@ -24,6 +26,44 @@ pub fn render_hd_framebuffer( mesh: &[RibbonTriangle], show_ligands: bool, interactions: &[Interaction], + selection: Option>, +) -> Framebuffer { + render_hd_framebuffer_ssaa( + protein, + camera, + color_scheme, + viz_mode, + width, + height, + mesh, + show_ligands, + interactions, + selection, + 1.0, + ) +} + +/// Like [`render_hd_framebuffer`], but aware that the caller intends to +/// downsample the result by a factor of `ssaa` before display. +/// +/// `width` / `height` are the *supersampled* framebuffer dimensions, i.e. the +/// output resolution already multiplied by `ssaa`. The factor is needed +/// separately because line thickness and circle radii must be derived from the +/// **output** resolution and then scaled up, so that features downsample to the +/// same apparent size rather than becoming proportionally thinner. +#[allow(clippy::too_many_arguments)] +pub fn render_hd_framebuffer_ssaa( + protein: &Protein, + camera: &Camera, + color_scheme: &ColorScheme, + viz_mode: VizMode, + width: f64, + height: f64, + mesh: &[RibbonTriangle], + show_ligands: bool, + interactions: &[Interaction], + selection: Option>, + ssaa: f64, ) -> Framebuffer { let px_w = width as usize; let px_h = height as usize; @@ -36,12 +76,23 @@ pub fn render_hd_framebuffer( let half_w = px_w as f64 / 2.0; let half_h = px_h as f64 / 2.0; - // Scale line thickness and circle radii relative to framebuffer size. + // Scale line thickness and circle radii relative to the *output* size. // Values were tuned at ~160px wide (braille resolution) where 1.5px // lines and circles look correct. At FullHD (~640px+) we scale up // proportionally. Floor of 1.0 preserves the original look at low // resolutions; ceiling of 3.0 caps growth on 4K terminals. - let ts = (px_w as f64 / 500.0).clamp(1.0, 3.0); + // + // When supersampling, the clamp must be evaluated against the resolution + // the user actually sees and only then multiplied by `ssaa`. Deriving it + // from the supersampled width instead would let the clamp floor absorb the + // factor and render features too thin once downsampled. + let ssaa = if ssaa.is_finite() && ssaa >= 1.0 { + ssaa + } else { + 1.0 + }; + let output_px_w = px_w as f64 / ssaa; + let ts = (output_px_w / 500.0).clamp(1.0, 3.0) * ssaa; // Pre-compute sin/cos once for the entire frame instead of per-vertex. let cache = camera.projection_cache(); @@ -70,6 +121,13 @@ pub fn render_hd_framebuffer( render_ligands_fb(&mut fb, protein, camera, color_scheme, half_w, half_h, ts); } + // Residues picked in the sequence panel, drawn over whatever mode is + // active but still z-buffered against it, so a picked side chain is hidden + // when the structure genuinely occludes it. + if let Some(view) = selection.filter(|view| !view.selection.is_empty()) { + render_selection_fb(&mut fb, protein, view, camera, half_w, half_h, ts); + } + // Post-pass: blend all rasterized pixels toward a cool blue-gray fog color // based on their z-buffer depth. This gives uniform depth cues across all // rendering modes (triangles, lines, circles). @@ -90,14 +148,30 @@ fn to_pixel(proj_x: f64, proj_y: f64, proj_z: f64, half_w: f64, half_h: f64) -> } // --------------------------------------------------------------------------- -// Tile-based parallel cartoon rasterization +// Band-based parallel cartoon rasterization // --------------------------------------------------------------------------- -/// Tile size in pixels. 64x64 is a good balance between parallelism (many -/// tiles) and per-tile overhead (triangle binning, allocation). -const TILE_SIZE: usize = 64; +/// Inside-test epsilon, shared by the scanline span solver and the exact +/// per-pixel test so the two always agree. +const EDGE_EPS: f64 = 1e-6; + +/// Target number of horizontal bands per worker thread. +/// +/// Bands are the unit of parallelism *and* of triangle binning. More bands per +/// thread balances uneven triangle distribution (a protein rarely fills the +/// viewport evenly) at the cost of re-visiting triangles that straddle a band +/// boundary. Four is enough to keep every worker busy without meaningfully +/// inflating the bin lists. +const BANDS_PER_THREAD: usize = 4; + +/// Smallest band height in pixels. Below this the per-band bookkeeping starts +/// to cost more than the parallelism buys. +const MIN_BAND_HEIGHT: usize = 16; -/// A projected, shaded triangle ready for rasterization into tiles. +/// A projected, shaded triangle ready for rasterization. +/// +/// The barycentric setup is computed once here, in the parallel projection +/// pass, rather than once per band the triangle touches. struct ProjectedTriangle { /// Screen-space vertices `[x, y, z]`. verts: [[f64; 3]; 3], @@ -108,9 +182,37 @@ struct ProjectedTriangle { max_x: usize, min_y: usize, max_y: usize, + /// Barycentric coefficients: `u = u_x * dx + u_yc * dy`, likewise for `v`, + /// where `dx`/`dy` are offsets from vertex 2. + u_x: f64, + v_x: f64, + u_yc: f64, + v_yc: f64, +} + +impl ProjectedTriangle { + /// Placeholder for a triangle that is off-screen or degenerate. An empty + /// bounding box (`min_x > max_x`) is the marker. + const CULLED: Self = Self { + verts: [[0.0; 3]; 3], + shaded: [0; 3], + min_x: 1, + max_x: 0, + min_y: 1, + max_y: 0, + u_x: 0.0, + v_x: 0.0, + u_yc: 0.0, + v_yc: 0.0, + }; + + #[inline] + fn is_culled(&self) -> bool { + self.min_x > self.max_x + } } -/// Context for tile-based cartoon rasterization, reducing parameter count. +/// Context for band-based cartoon rasterization, reducing parameter count. struct TiledRenderCtx { half_w: f64, half_h: f64, @@ -119,30 +221,30 @@ struct TiledRenderCtx { light_dir: [f64; 3], } -/// A rasterized tile with its position, dimensions, and pixel data. -struct RenderedTile { - x: usize, - y: usize, - w: usize, - h: usize, - color: Vec<[u8; 3]>, - depth: Vec, -} - -/// Render the cartoon mesh using tile-based parallel rasterization. +/// Render the cartoon mesh using band-based parallel rasterization. /// -/// 1. Project all triangles serially (Lambert shade). -/// 2. Bin projected triangles into screen-space tiles. -/// 3. Rasterize each tile in parallel via rayon -- each tile owns its own -/// color/depth arrays so no synchronization is needed. -/// 4. Merge tile results back into the main framebuffer. +/// 1. Project, shade and set up all triangles (parallel). +/// 2. Bin them into horizontal screen bands via a flat CSR index. +/// 3. Rasterize each band in parallel, writing **straight into** the +/// framebuffer rows that band owns -- bands are disjoint row ranges, so no +/// synchronization, no per-band scratch buffers, and no merge pass. fn render_cartoon_tiled( fb: &mut Framebuffer, mesh: &[RibbonTriangle], cache: &crate::render::camera::ProjectionCache, ctx: &TiledRenderCtx, ) { - const AMBIENT: f64 = 0.55; + // Ambient floor, and with it the shading contrast: intensity runs from + // AMBIENT on the unlit side to 1.0 facing the light. + // + // This used to be 0.55 applied to `abs(dot) * 0.4 + 0.6`, which spans only + // 0.82..1.00 -- a barely visible 18% -- so cartoons came out looking flat + // and washed out. That formula was compensating for normals whose sign was + // arbitrary: rings inherited their winding from a frame that flips along + // the spline, so `abs` was the only way to shade them consistently. Now + // that the mesh is wound outward throughout, the true normal can be used + // and the range opened up to something that actually reads as form. + const AMBIENT: f64 = 0.35; let half_w = ctx.half_w; let half_h = ctx.half_h; let px_w = ctx.px_w; @@ -150,196 +252,328 @@ fn render_cartoon_tiled( let light_dir = ctx.light_dir; // ------------------------------------------------------------------ - // Step 1: Project and shade all triangles (serial). + // Step 1: Project, shade and set up all triangles (parallel). // ------------------------------------------------------------------ - let projected: Vec = mesh - .iter() - .filter_map(|tri| { - let v0 = cache.project(tri.verts[0][0], tri.verts[0][1], tri.verts[0][2]); - let v1 = cache.project(tri.verts[1][0], tri.verts[1][1], tri.verts[1][2]); - let v2 = cache.project(tri.verts[2][0], tri.verts[2][1], tri.verts[2][2]); - - let sv0 = to_pixel(v0.x, v0.y, v0.z, half_w, half_h); - let sv1 = to_pixel(v1.x, v1.y, v1.z, half_w, half_h); - let sv2 = to_pixel(v2.x, v2.y, v2.z, half_w, half_h); - - // Screen-space bounding box clamped to framebuffer. - let fmin_x = sv0[0].min(sv1[0]).min(sv2[0]).floor() as isize; - let fmax_x = sv0[0].max(sv1[0]).max(sv2[0]).ceil() as isize; - let fmin_y = sv0[1].min(sv1[1]).min(sv2[1]).floor() as isize; - let fmax_y = sv0[1].max(sv1[1]).max(sv2[1]).ceil() as isize; - - let min_x = fmin_x.max(0) as usize; - let max_x = (fmax_x.max(0) as usize).min(px_w.saturating_sub(1)); - let min_y = fmin_y.max(0) as usize; - let max_y = (fmax_y.max(0) as usize).min(px_h.saturating_sub(1)); - - if min_x > max_x || min_y > max_y { - return None; - } + // Reuse the projected-triangle buffer across frames. At interactive + // resolutions this array is several megabytes; reallocating and re-faulting + // it every frame showed up as the single largest allocator cost in a + // profile of the render loop. + SCRATCH.with(|cell| { + let mut scratch = cell.borrow_mut(); + let projected: &mut Vec = &mut scratch; + mesh.par_iter() + .map(|tri| { + let v0 = cache.project(tri.verts[0][0], tri.verts[0][1], tri.verts[0][2]); + let v1 = cache.project(tri.verts[1][0], tri.verts[1][1], tri.verts[1][2]); + let v2 = cache.project(tri.verts[2][0], tri.verts[2][1], tri.verts[2][2]); + + let sv0 = to_pixel(v0.x, v0.y, v0.z, half_w, half_h); + let sv1 = to_pixel(v1.x, v1.y, v1.z, half_w, half_h); + let sv2 = to_pixel(v2.x, v2.y, v2.z, half_w, half_h); + + // Screen-space bounding box clamped to framebuffer. + let fmin_x = sv0[0].min(sv1[0]).min(sv2[0]).floor() as isize; + let fmax_x = sv0[0].max(sv1[0]).max(sv2[0]).ceil() as isize; + let fmin_y = sv0[1].min(sv1[1]).min(sv2[1]).floor() as isize; + let fmax_y = sv0[1].max(sv1[1]).max(sv2[1]).ceil() as isize; + + let min_x = fmin_x.max(0) as usize; + let max_x = (fmax_x.max(0) as usize).min(px_w.saturating_sub(1)); + let min_y = fmin_y.max(0) as usize; + let max_y = (fmax_y.max(0) as usize).min(px_h.saturating_sub(1)); - // Two-sided half-Lambert shading (identical to `rasterize_triangle_depth`). - let rn = cache.rotate_normal(tri.normal[0], tri.normal[1], tri.normal[2]); - let dot = rn[0] * light_dir[0] + rn[1] * light_dir[1] + rn[2] * light_dir[2]; - let half_lambert = dot.abs() * 0.4 + 0.6; - let intensity = AMBIENT + (1.0 - AMBIENT) * half_lambert; - let shaded: [u8; 3] = [ - (tri.color[0] as f64 * intensity).min(255.0) as u8, - (tri.color[1] as f64 * intensity).min(255.0) as u8, - (tri.color[2] as f64 * intensity).min(255.0) as u8, - ]; - - Some(ProjectedTriangle { - verts: [sv0, sv1, sv2], - shaded, - min_x, - max_x, - min_y, - max_y, + // Barycentric denominator (twice the signed screen-space area). + let denom = + (sv1[1] - sv2[1]) * (sv0[0] - sv2[0]) + (sv2[0] - sv1[0]) * (sv0[1] - sv2[1]); + + // Off-screen or degenerate: emit a culled entry rather than + // filtering, so the parallel map stays indexed and can write + // straight into the reused buffer without any reallocation. + // + // Back faces are *not* culled here, though the winding is now + // consistent enough to do it (see `ribbon::push_wound`). The + // ribbon surface is not watertight: a sheet's arrowhead and the + // coil after it meet at a T-junction, where a vertex lands + // mid-edge on its neighbour. Edge counts can be balanced by + // sealing the gaps, but the T-junctions still crack open into + // hairline slivers once the back faces behind them stop being + // drawn. Culling saves a few milliseconds on a frame that + // already fits its budget several times over, which does not + // justify shipping those artifacts; it needs the arrowhead + // seam re-tessellated first. + if min_x > max_x || min_y > max_y || denom.abs() < 1e-12 { + return ProjectedTriangle::CULLED; + } + let inv_denom = 1.0 / denom; + + // Two-sided half-Lambert shading (identical to `rasterize_triangle_depth`). + let rn = cache.rotate_normal(tri.normal[0], tri.normal[1], tri.normal[2]); + // Depth increases away from the viewer, so a normal facing the + // camera has negative z; the light is specified in front of the + // scene. Flip z to put the two in the same frame. + let dot = rn[0] * light_dir[0] + rn[1] * light_dir[1] - rn[2] * light_dir[2]; + // Half-Lambert wrap: the terminator is soft, so a ribbon + // turning away from the light shades off gradually instead of + // hitting a hard edge, and nothing goes black. + let wrapped = dot.mul_add(0.5, 0.5); + let intensity = AMBIENT + (1.0 - AMBIENT) * wrapped; + let shaded: [u8; 3] = [ + (tri.color[0] as f64 * intensity).min(255.0) as u8, + (tri.color[1] as f64 * intensity).min(255.0) as u8, + (tri.color[2] as f64 * intensity).min(255.0) as u8, + ]; + + ProjectedTriangle { + verts: [sv0, sv1, sv2], + shaded, + min_x, + max_x, + min_y, + max_y, + u_x: (sv1[1] - sv2[1]) * inv_denom, + v_x: (sv2[1] - sv0[1]) * inv_denom, + u_yc: (sv2[0] - sv1[0]) * inv_denom, + v_yc: (sv0[0] - sv2[0]) * inv_denom, + } }) - }) - .collect(); + .collect_into_vec(projected); - if projected.is_empty() { - return; - } + if projected.is_empty() { + return; + } - // ------------------------------------------------------------------ - // Step 2: Create tile grid and bin triangles into tiles. - // ------------------------------------------------------------------ - let cols = px_w.div_ceil(TILE_SIZE); - let rows = px_h.div_ceil(TILE_SIZE); - let num_tiles = cols * rows; - let mut tile_triangles: Vec> = vec![Vec::new(); num_tiles]; - - for (tri_idx, tri) in projected.iter().enumerate() { - let tc0 = tri.min_x / TILE_SIZE; - let tc1 = tri.max_x / TILE_SIZE; - let tr0 = tri.min_y / TILE_SIZE; - let tr1 = tri.max_y / TILE_SIZE; - for tr in tr0..=tr1 { - for tc in tc0..=tc1 { - tile_triangles[tr * cols + tc].push(tri_idx); + // ------------------------------------------------------------------ + // Step 2: Bin triangles into horizontal bands (flat CSR index). + // ------------------------------------------------------------------ + // A `Vec>` would allocate and grow one heap buffer per band + // every frame; counting first and scattering into a single flat array + // costs two linear passes and one allocation. + let threads = rayon::current_num_threads().max(1); + let band_h = (px_h.div_ceil(threads * BANDS_PER_THREAD)).max(MIN_BAND_HEIGHT); + let num_bands = px_h.div_ceil(band_h); + + let mut offsets = vec![0u32; num_bands + 1]; + for tri in projected.iter().filter(|t| !t.is_culled()) { + let b0 = tri.min_y / band_h; + let b1 = tri.max_y / band_h; + for slot in &mut offsets[b0 + 1..=b1 + 1] { + *slot += 1; } } - } - - // ------------------------------------------------------------------ - // Step 3: Rasterize tiles in parallel. - // ------------------------------------------------------------------ - // Each tile produces its own local color and depth buffers. - let tiles: Vec = tile_triangles - .into_par_iter() - .enumerate() - .map(|(tile_idx, tri_indices)| { - let tx = (tile_idx % cols) * TILE_SIZE; - let ty = (tile_idx / cols) * TILE_SIZE; - let tw = TILE_SIZE.min(px_w.saturating_sub(tx)); - let th = TILE_SIZE.min(px_h.saturating_sub(ty)); - - let mut color = vec![[0u8; 3]; tw * th]; - let mut depth = vec![f32::INFINITY; tw * th]; - - for &tri_idx in &tri_indices { - let tri = &projected[tri_idx]; - rasterize_into_tile(&mut color, &mut depth, tw, th, tx, ty, tri); + for i in 0..num_bands { + offsets[i + 1] += offsets[i]; + } + let mut items = vec![0u32; offsets[num_bands] as usize]; + let mut cursor = offsets.clone(); + for (tri_idx, tri) in projected.iter().enumerate() { + if tri.is_culled() { + continue; } - - RenderedTile { - x: tx, - y: ty, - w: tw, - h: th, - color, - depth, + let b0 = tri.min_y / band_h; + let b1 = tri.max_y / band_h; + for b in b0..=b1 { + items[cursor[b] as usize] = tri_idx as u32; + cursor[b] += 1; } - }) - .collect(); + } - // ------------------------------------------------------------------ - // Step 4: Merge tiles back into the main framebuffer. - // ------------------------------------------------------------------ - for tile in &tiles { - for ly in 0..tile.h { - for lx in 0..tile.w { - let ti = ly * tile.w + lx; - let fi = (tile.y + ly) * px_w + (tile.x + lx); - if tile.depth[ti] < fb.depth[fi] { - fb.color[fi] = tile.color[ti]; - fb.depth[fi] = tile.depth[ti]; + // ------------------------------------------------------------------ + // Step 3: Rasterize bands in parallel, straight into the framebuffer. + // ------------------------------------------------------------------ + let projected: &[ProjectedTriangle] = projected; + let items = &items; + let offsets = &offsets; + fb.color + .par_chunks_mut(band_h * px_w) + .zip(fb.depth.par_chunks_mut(band_h * px_w)) + .enumerate() + .for_each(|(band, (color, depth))| { + let y0 = band * band_h; + let rows = color.len() / px_w; + let lo = offsets[band] as usize; + let hi = offsets[band + 1] as usize; + for &tri_idx in &items[lo..hi] { + rasterize_band(color, depth, px_w, y0, rows, &projected[tri_idx as usize]); } - } - } - } + }); + }); +} + +thread_local! { + static SCRATCH: std::cell::RefCell> = + const { std::cell::RefCell::new(Vec::new()) }; +} + +/// A triangle edge as a moving x-bound while walking down scanlines. +/// +/// Each barycentric constraint has the form `a * dx + c * dy >= t`, so the `dx` +/// at which it flips is `t/a - (c/a) * dy` — **linear in `dy`**. Solving that +/// once per triangle and then stepping by the slope each scanline replaces +/// three floating-point divisions per scanline with one addition. +#[derive(Clone, Copy)] +struct EdgeBound { + /// Current bound on `dx`, valid for the scanline being processed. + at: f64, + /// How the bound moves per scanline. + step: f64, +} + +/// A constraint whose `dx` coefficient is zero: it does not bound `x` at all, +/// it just switches whole scanlines on or off. +#[derive(Clone, Copy)] +struct FlatBound { + c: f64, + t: f64, } -/// Rasterize a single projected triangle into a tile's local buffers. +/// Rasterize one projected triangle into the framebuffer rows owned by a band. +/// +/// `y0` is the framebuffer row the band starts at and `rows` is how many rows +/// it owns; `color`/`depth` are that band's slices, so row `py` lives at +/// `(py - y0) * px_w`. /// -/// The algorithm is identical to `Framebuffer::rasterize_triangle_depth` but -/// operates on tile-local coordinate arrays. `tx`/`ty` are the pixel -/// coordinates of the tile's top-left corner in the full framebuffer. +/// Rather than scanning the triangle's full bounding box and rejecting most of +/// it — ribbon triangles are thin slivers whose bounding box is several times +/// their area — each scanline is reduced to the x-range where all three +/// barycentric half-planes can hold, walked incrementally down the triangle. +/// The exact same inside test then runs over just that range, widened by a +/// pixel at each end so floating-point error can never clip a covered pixel. #[inline] -fn rasterize_into_tile( +fn rasterize_band( color: &mut [[u8; 3]], depth: &mut [f32], - tw: usize, - th: usize, - tx: usize, - ty: usize, + px_w: usize, + y0: usize, + rows: usize, tri: &ProjectedTriangle, ) { let [v0, v1, v2] = tri.verts; - // Clamp the triangle's bounding box to this tile. - let min_x = tri.min_x.max(tx); - let max_x = tri.max_x.min((tx + tw).saturating_sub(1)); - let min_y = tri.min_y.max(ty); - let max_y = tri.max_y.min((ty + th).saturating_sub(1)); - - if min_x > max_x || min_y > max_y { + let y_start = tri.min_y.max(y0); + let y_end = tri.max_y.min(y0 + rows - 1); + if y_start > y_end || tri.min_x > tri.max_x { return; } - // Barycentric denominator (same math as `rasterize_triangle_depth`). - let denom = (v1[1] - v2[1]) * (v0[0] - v2[0]) + (v2[0] - v1[0]) * (v0[1] - v2[1]); - if denom.abs() < 1e-12 { - return; // degenerate triangle - } - let inv_denom = 1.0 / denom; - - let u_x_step = (v1[1] - v2[1]) * inv_denom; - let v_x_step = (v2[1] - v0[1]) * inv_denom; - let u_y_coeff = (v2[0] - v1[0]) * inv_denom; - let v_y_coeff = (v0[0] - v2[0]) * inv_denom; - - for py in min_y..=max_y { - let pf_y = py as f64 + 0.5; - let dy = pf_y - v2[1]; - let u_y = u_y_coeff * dy; - let v_y = v_y_coeff * dy; - - for px in min_x..=max_x { - let pf_x = px as f64 + 0.5; - let dx = pf_x - v2[0]; - - let u = u_x_step * dx + u_y; - let v = v_x_step * dx + v_y; - let w = 1.0 - u - v; - - if u >= -1e-6 && v >= -1e-6 && w >= -1e-6 { - let z = (u * v0[2] + v * v1[2] + w * v2[2]) as f32; - let lx = px - tx; - let ly = py - ty; - let ti = ly * tw + lx; - if z < depth[ti] { - depth[ti] = z; - color[ti] = tri.shaded; + let (u_x, v_x, u_yc, v_yc) = (tri.u_x, tri.v_x, tri.u_yc, tri.v_yc); + // w = 1 - u - v, so `w >= -EPS` is `-(u_x+v_x) dx - (u_yc+v_yc) dy >= -1-EPS`. + let dy_start = y_start as f64 + 0.5 - v2[1]; + + let mut lower: [EdgeBound; 3] = [EdgeBound { at: 0.0, step: 0.0 }; 3]; + let mut upper: [EdgeBound; 3] = [EdgeBound { at: 0.0, step: 0.0 }; 3]; + let mut flat: [FlatBound; 3] = [FlatBound { c: 0.0, t: 0.0 }; 3]; + let (mut n_lower, mut n_upper, mut n_flat) = (0usize, 0usize, 0usize); + + for (a, c, t) in [ + (u_x, u_yc, -EDGE_EPS), + (v_x, v_yc, -EDGE_EPS), + (-(u_x + v_x), -(u_yc + v_yc), -1.0 - EDGE_EPS), + ] { + if a == 0.0 { + flat[n_flat] = FlatBound { c, t }; + n_flat += 1; + continue; + } + let inv_a = 1.0 / a; + let step = -c * inv_a; + let bound = EdgeBound { + at: t * inv_a + step * dy_start, + step, + }; + if a > 0.0 { + lower[n_lower] = bound; + n_lower += 1; + } else { + upper[n_upper] = bound; + n_upper += 1; + } + } + + for py in y_start..=y_end { + let dy = py as f64 + 0.5 - v2[1]; + + // Constraints independent of x either admit the whole scanline or none + // of it. + let scanline_live = flat[..n_flat].iter().all(|f| f.c * dy >= f.t); + + if scanline_live { + let mut lo = f64::NEG_INFINITY; + for b in &lower[..n_lower] { + if b.at > lo { + lo = b.at; } } + let mut hi = f64::INFINITY; + for b in &upper[..n_upper] { + if b.at < hi { + hi = b.at; + } + } + + if lo <= hi { + // dx is measured from v2[0] at pixel centres, so + // px = dx + v2[0] - 0.5. Widen by one pixel each way; the + // exact test below still decides. + let x_start = span_start(lo + v2[0] - 0.5, tri.min_x); + let x_end = span_end(hi + v2[0] - 0.5, tri.max_x); + + if x_start <= x_end { + let u_y = u_yc * dy; + let v_y = v_yc * dy; + let base = (py - y0) * px_w; + for px in x_start..=x_end { + let dx = px as f64 + 0.5 - v2[0]; + let u = u_x * dx + u_y; + let v = v_x * dx + v_y; + let w = 1.0 - u - v; + + if u >= -EDGE_EPS && v >= -EDGE_EPS && w >= -EDGE_EPS { + let z = (u * v0[2] + v * v1[2] + w * v2[2]) as f32; + let i = base + px; + if z < depth[i] { + depth[i] = z; + color[i] = tri.shaded; + } + } + } + } + } + } + + for b in &mut lower[..n_lower] { + b.at += b.step; + } + for b in &mut upper[..n_upper] { + b.at += b.step; } } } +/// First pixel column to test, never above `floor` and never below 0. +/// A NaN or infinite bound falls back to the triangle's bounding box. +#[inline] +fn span_start(x: f64, floor_x: usize) -> usize { + if x.is_nan() { + return floor_x; + } + // Rust saturates out-of-range float-to-int casts, so this is safe for +-inf. + let i = x.floor() as isize; + i.max(floor_x as isize) as usize +} + +/// Last pixel column to test, never beyond `ceil_x`. +#[inline] +fn span_end(x: f64, ceil_x: usize) -> usize { + if x.is_nan() { + return ceil_x; + } + let i = x.ceil() as isize; + if i < 0 { + return 0; + } + (i as usize).min(ceil_x) +} + /// Render backbone CA trace to framebuffer. fn render_backbone_fb( fb: &mut Framebuffer, @@ -516,6 +750,138 @@ fn render_ligands_fb( } } +/// Draw the residues picked in the sequence panel. +/// +/// With ball-and-stick on, every atom of a picked residue becomes a sphere and +/// every bond a stick, including the peptide or phosphodiester bond into the +/// next picked residue so a selected stretch reads as one connected fragment. +/// With it off, a single marker sphere per residue says where the selection is +/// without hiding the cartoon underneath. +fn render_selection_fb( + fb: &mut Framebuffer, + protein: &Protein, + view: SelectionView<'_>, + camera: &Camera, + half_w: f64, + half_h: f64, + ts: f64, +) { + let marker = palette().selection.marker.0; + let carbon = palette().selection.carbon.0; + + for (chain_index, chain) in protein.chains.iter().enumerate() { + for (residue_index, residue) in chain.residues.iter().enumerate() { + if !view.selection.contains(chain_index, residue_index) { + continue; + } + + if !view.ball_and_stick { + // One sphere on the backbone atom (CA / C4'), or on the first + // atom of a residue that has no backbone atom at all. + if let Some(atom) = residue + .atoms + .iter() + .find(|atom| atom.is_backbone) + .or_else(|| residue.atoms.first()) + { + let projected = camera.project(atom.x, atom.y, atom.z); + let px = to_pixel(projected.x, projected.y, projected.z, half_w, half_h); + // The backbone atom sits *inside* the ribbon, so an + // unbiased marker would be hidden by the very residue it + // marks. Pulling it forward by a ribbon half-width makes + // it visible while anything genuinely in front still wins. + fb.draw_circle_z(px[0], px[1], px[2] - MARKER_DEPTH_BIAS, 4.0 * ts, marker); + } + continue; + } + + let atoms: Vec<([f64; 3], [u8; 3], &Atom)> = residue + .atoms + .iter() + .map(|atom| { + let projected = camera.project(atom.x, atom.y, atom.z); + let px = to_pixel(projected.x, projected.y, projected.z, half_w, half_h); + (px, selection_atom_color(atom, carbon), atom) + }) + .collect(); + + for (px, color, atom) in &atoms { + fb.draw_circle_z(px[0], px[1], px[2], atom_radius(atom) * ts, *color); + } + + for i in 0..atoms.len() { + for j in (i + 1)..atoms.len() { + let (p1, c1, a1) = &atoms[i]; + let (p2, _, a2) = &atoms[j]; + if atoms_bonded(&a1.element, a1.x, a1.y, a1.z, &a2.element, a2.x, a2.y, a2.z) { + fb.draw_thick_line_3d(*p1, *p2, *c1, 2.0 * ts); + } + } + } + + // Link to the next residue when it is picked too. + let next_index = residue_index + 1; + if !view.selection.contains(chain_index, next_index) { + continue; + } + let Some(next) = chain.residues.get(next_index) else { + continue; + }; + if let Some((from, to)) = linking_atoms(chain.molecule_type, residue, next) { + let p1 = camera.project(from.x, from.y, from.z); + let p2 = camera.project(to.x, to.y, to.z); + fb.draw_thick_line_3d( + to_pixel(p1.x, p1.y, p1.z, half_w, half_h), + to_pixel(p2.x, p2.y, p2.z, half_w, half_h), + selection_atom_color(from, carbon), + 2.0 * ts, + ); + } + } + } +} + +/// CPK color, except that carbon takes the selection color: the standard way +/// to make one fragment of a structure legible without recoloring chemistry. +pub(crate) fn selection_atom_color(atom: &Atom, carbon: [u8; 3]) -> [u8; 3] { + if atom.element.trim().eq_ignore_ascii_case("C") { + carbon + } else { + color_to_rgb(ColorScheme::element_color(atom)) + } +} + +/// Sphere radius in framebuffer units before thickness scaling. +pub(crate) fn atom_radius(atom: &Atom) -> f64 { + match atom.element.trim() { + "H" => 1.4, + "C" => 2.6, + "N" | "O" => 2.8, + "S" | "P" => 3.2, + _ => 3.0, + } +} + +/// How far forward, in angstroms, a selection marker is pushed so it clears +/// the ribbon drawn around its own backbone atom. +const MARKER_DEPTH_BIAS: f64 = 3.5; + +/// The covalent bond joining consecutive polymer residues. +pub(crate) fn linking_atoms<'a>( + molecule_type: MoleculeType, + current: &'a Residue, + next: &'a Residue, +) -> Option<(&'a Atom, &'a Atom)> { + let (from, to) = match molecule_type { + MoleculeType::RNA | MoleculeType::DNA => ("O3'", "P"), + MoleculeType::Protein => ("C", "N"), + MoleculeType::SmallMolecule => return None, + }; + let a = current.atoms.iter().find(|atom| atom.name.trim() == from)?; + let b = next.atoms.iter().find(|atom| atom.name.trim() == to)?; + Some((a, b)) +} + /// Render non-covalent interaction lines as dashed segments in the framebuffer. fn render_interactions_fb( fb: &mut Framebuffer, @@ -551,3 +917,302 @@ fn interaction_color(t: InteractionType) -> [u8; 3] { InteractionType::Other => [160, 160, 160], // gray } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::protein::{Atom, Chain, MoleculeType, Residue, SecondaryStructure}; + use crate::render::color::ColorSchemeType; + + /// A short CA trace running diagonally across the view. + fn trace_protein() -> Protein { + let atom = |x: f64, y: f64| Atom { + name: "CA".to_string(), + element: "C".to_string(), + x, + y, + z: 0.0, + b_factor: 20.0, + is_backbone: true, + is_hetero: false, + }; + let residues = (0..12) + .map(|i| Residue { + name: "ALA".to_string(), + seq_num: i + 1, + insertion_code: None, + atoms: vec![atom(i as f64 * 6.0 - 33.0, i as f64 * 3.0 - 16.0)], + secondary_structure: SecondaryStructure::Coil, + }) + .collect(); + Protein { + name: "trace".to_string(), + chains: vec![Chain { + id: "A".to_string(), + residues, + molecule_type: MoleculeType::Protein, + }], + ligands: Vec::new(), + } + } + + /// Fraction of framebuffer pixels carrying geometry. + fn ink_fraction(fb: &Framebuffer) -> f64 { + let lit = fb.color.iter().filter(|c| **c != [0, 0, 0]).count(); + lit as f64 / fb.color.len() as f64 + } + + /// Bounding box of lit pixels, expressed as fractions of the framebuffer + /// dimensions. This is the protein's *apparent* size: what the viewer sees + /// once the buffer is downsampled onto the terminal cell grid. + fn normalized_bbox(fb: &Framebuffer) -> (f64, f64) { + let (mut min_x, mut max_x) = (usize::MAX, 0usize); + let (mut min_y, mut max_y) = (usize::MAX, 0usize); + for y in 0..fb.height { + for x in 0..fb.width { + if fb.color[y * fb.width + x] != [0, 0, 0] { + min_x = min_x.min(x); + max_x = max_x.max(x); + min_y = min_y.min(y); + max_y = max_y.max(y); + } + } + } + assert!(min_x != usize::MAX, "fixture should draw something"); + ( + (max_x - min_x) as f64 / fb.width as f64, + (max_y - min_y) as f64 / fb.height as f64, + ) + } + + /// Render the fixture the way the viewport does: the framebuffer is scaled + /// by `ssaa`, and the camera is scaled to match because zoom and pan are + /// both in framebuffer pixel units. + fn render_trace(out_w: f64, out_h: f64, ssaa: f64) -> Framebuffer { + let protein = trace_protein(); + let mut camera = Camera::default(); + camera.zoom = 4.0 * ssaa; + let scheme = ColorScheme::new(ColorSchemeType::Structure, 12); + render_hd_framebuffer_ssaa( + &protein, + &camera, + &scheme, + VizMode::Backbone, + out_w * ssaa, + out_h * ssaa, + &[], + false, + &[], + None, + ssaa, + ) + } + + #[test] + fn supersampling_preserves_apparent_size() { + // HDplus must render the protein at the same apparent size as HD. + // Scaling the framebuffer by `ssaa` without scaling the camera would + // leave the protein covering the same pixel count in a buffer twice as + // wide, i.e. half the size once downsampled. + let (bw, bh) = normalized_bbox(&render_trace(400.0, 184.0, 1.0)); + let (pw, ph) = normalized_bbox(&render_trace(400.0, 184.0, 2.0)); + + assert!( + (pw - bw).abs() < 0.02 && (ph - bh).abs() < 0.02, + "supersampled extent ({pw:.3}, {ph:.3}) should match base ({bw:.3}, {bh:.3})" + ); + } + + #[test] + fn supersampling_preserves_apparent_line_thickness() { + // Line thickness must be derived from the *output* resolution and then + // scaled by the supersampling factor, so strokes occupy the same + // fraction of the frame. Deriving it from the supersampled width + // instead lets the clamp floor in `ts` absorb the factor and renders + // strokes too thin once downsampled. + let base = ink_fraction(&render_trace(400.0, 184.0, 1.0)); + let supersampled = ink_fraction(&render_trace(400.0, 184.0, 2.0)); + + let ratio = supersampled / base; + assert!( + (0.9..=1.1).contains(&ratio), + "supersampled ink fraction {supersampled:.4} should match base {base:.4} within 10% (ratio {ratio:.3})" + ); + } + + #[test] + fn ssaa_factor_is_ignored_when_invalid() { + // Guards against a NaN or sub-1 factor silently collapsing thickness. + let protein = trace_protein(); + let mut camera = Camera::default(); + camera.zoom = 4.0; + let scheme = ColorScheme::new(ColorSchemeType::Structure, 12); + let render = |ssaa: f64| { + render_hd_framebuffer_ssaa( + &protein, + &camera, + &scheme, + VizMode::Backbone, + 400.0, + 184.0, + &[], + false, + &[], + None, + ssaa, + ) + }; + let expected = ink_fraction(&render(1.0)); + for bad in [f64::NAN, 0.0, -3.0] { + assert!( + (ink_fraction(&render(bad)) - expected).abs() < 1e-9, + "ssaa {bad} should fall back to 1.0" + ); + } + } +} + +#[cfg(test)] +mod selection_tests { + use super::*; + use crate::model::protein::{Chain, Residue, SecondaryStructure}; + use crate::model::residue_selection::ResidueSelection; + use crate::render::color::ColorSchemeType; + + /// Six residues with a two-atom side chain each, spread across the view. + fn side_chain_protein() -> Protein { + let atom = |name: &str, element: &str, x: f64, y: f64, backbone: bool| Atom { + name: name.to_string(), + element: element.to_string(), + x, + y, + z: 0.0, + b_factor: 20.0, + is_backbone: backbone, + is_hetero: false, + }; + let residues = (0..6) + .map(|i| { + let x = i as f64 * 6.0 - 15.0; + Residue { + name: "LEU".to_string(), + seq_num: i + 1, + insertion_code: None, + atoms: vec![ + atom("CA", "C", x, 0.0, true), + atom("CB", "C", x + 1.5, 1.5, false), + atom("CG", "O", x + 2.6, 3.0, false), + ], + secondary_structure: SecondaryStructure::Coil, + } + }) + .collect(); + Protein { + name: "sidechains".to_string(), + chains: vec![Chain { + id: "A".to_string(), + residues, + molecule_type: MoleculeType::Protein, + }], + ligands: Vec::new(), + } + } + + fn lit_pixels(fb: &Framebuffer) -> usize { + fb.color.iter().filter(|color| **color != [0, 0, 0]).count() + } + + fn render(protein: &Protein, view: Option>) -> Framebuffer { + let mut camera = Camera::default(); + camera.zoom = 8.0; + let scheme = ColorScheme::new(ColorSchemeType::Structure, protein.residue_count()); + render_hd_framebuffer( + protein, + &camera, + &scheme, + VizMode::Backbone, + 400.0, + 300.0, + &[], + false, + &[], + view, + ) + } + + #[test] + fn selection_adds_ink_and_ball_and_stick_adds_more_than_markers() { + let protein = side_chain_protein(); + let mut selection = ResidueSelection::new(&protein); + selection.set_range(0, 0, 2, true); + + let plain = lit_pixels(&render(&protein, None)); + let marked = lit_pixels(&render( + &protein, + Some(SelectionView { + selection: &selection, + ball_and_stick: false, + }), + )); + let ball_and_stick = lit_pixels(&render( + &protein, + Some(SelectionView { + selection: &selection, + ball_and_stick: true, + }), + )); + + assert!( + plain < marked, + "markers should add ink: {plain} -> {marked}" + ); + assert!( + marked < ball_and_stick, + "side chains should add more than markers: {marked} -> {ball_and_stick}" + ); + } + + #[test] + fn an_empty_selection_renders_identically_to_no_selection() { + // The overlay must be free when nothing is picked -- this is what lets + // the panel stay open with no selection and cost nothing. + let protein = side_chain_protein(); + let empty = ResidueSelection::new(&protein); + let plain = render(&protein, None); + let with_empty = render( + &protein, + Some(SelectionView { + selection: &empty, + ball_and_stick: true, + }), + ); + assert_eq!(plain.color, with_empty.color); + } + + #[test] + fn only_picked_residues_gain_side_chains() { + // The projection mirrors x, so residue 0 lands right of centre; the + // far half of the frame must be untouched by picking it. + let protein = side_chain_protein(); + let mut selection = ResidueSelection::new(&protein); + selection.set(0, 0, true); + + let left_half = |fb: &Framebuffer| { + (0..fb.height) + .flat_map(|y| (0..fb.width / 2).map(move |x| y * fb.width + x)) + .filter(|i| fb.color[*i] != [0, 0, 0]) + .count() + }; + + let plain = render(&protein, None); + let picked = render( + &protein, + Some(SelectionView { + selection: &selection, + ball_and_stick: true, + }), + ); + assert_eq!(left_half(&plain), left_half(&picked)); + assert!(lit_pixels(&plain) < lit_pixels(&picked)); + } +} diff --git a/src/render/kitty_png.rs b/src/render/kitty_png.rs index 777f87c..903fa7c 100644 --- a/src/render/kitty_png.rs +++ b/src/render/kitty_png.rs @@ -1,15 +1,18 @@ -//! Compressed Kitty graphics protocol transmitter using raw RGBA + zlib. +//! Kitty graphics protocol transmitter. //! -//! ratatui-image sends Kitty images as raw RGBA32 base64-encoded (`f=32`), -//! which is ~1.3MB per frame for a 640x384 render. This module compresses -//! the raw RGBA pixel data with zlib (level 1 / fastest) and sends it via -//! the Kitty graphics protocol with `f=32,o=z`. This avoids PNG's -//! filtering, CRC checksums, and chunk framing overhead while still -//! achieving good compression on protein renders (mostly black/transparent -//! background). +//! Two transports produce the same widget: +//! +//! * **Shared memory** (`t=s`), used whenever the terminal is local and +//! [`super::kitty_shm::probe`] confirmed support. The pixels never enter the +//! escape sequence at all -- see that module. This is the fast path. +//! * **Escape codes** (`f=32,o=z`), for SSH sessions and terminals without +//! shared-memory support. ratatui-image would send raw base64 RGBA here; +//! compressing with zlib first is 10-20x smaller on protein renders, which +//! are mostly transparent background, and that is what makes FullHD usable +//! over a network at all. //! //! Note: the file is still named `kitty_png.rs` for historical reasons and -//! to minimize import churn. The actual encoding is raw RGBA + zlib, not PNG. +//! to minimize import churn. Neither transport involves PNG. //! //! Unlike the original implementation which dumped the escape sequence into //! cell (0,0) and skipped everything else, this version uses Kitty's @@ -30,7 +33,6 @@ use std::io::Write as IoWrite; use base64::Engine; use flate2::Compression; use flate2::write::ZlibEncoder; -use image::DynamicImage; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::widgets::Widget; @@ -40,12 +42,15 @@ use ratatui::widgets::Widget; /// image data — no flicker, no delete-before-draw gap. const IMAGE_ID: u32 = 1; -/// A ratatui `Widget` that renders a `DynamicImage` via the Kitty graphics -/// protocol using raw RGBA data with zlib compression (`f=32,o=z`) and +/// A ratatui `Widget` that places a Kitty image over the render area using /// unicode placeholders. /// -/// Named `KittyPngImage` for historical reasons; the actual encoding is -/// zlib-compressed raw RGBA, not PNG. +/// Construct it with [`KittyPngImage::from_shm`] to hand the terminal a shared +/// memory object, or [`KittyPngImage::from_rgba`] to carry the pixels inline as +/// zlib-compressed base64. Only the transmit sequence differs; placement is +/// identical either way. +/// +/// Named `KittyPngImage` for historical reasons; neither transport uses PNG. pub struct KittyPngImage { transmit: String, unique_id: u32, @@ -63,34 +68,48 @@ impl KittyPngImage { format!("\x1b_Gq=2,a=d,d=I,i={IMAGE_ID}\x1b\\") } - /// Create a new compressed Kitty image widget. + /// Point the terminal at pixels already written into a shared memory + /// object. The payload is just the object's name, so the escape sequence + /// is a fixed ~60 bytes regardless of image size. /// - /// The image is immediately converted to raw RGBA, zlib-compressed - /// (level 1 / fastest), and base64-chunked into the Kitty escape - /// sequence. Call this outside the draw closure if you want to time - /// encoding separately. + /// `byte_len` is the object's exact size, sent as `S=` so the terminal + /// reads the pixel data and nothing beyond it. + pub fn from_shm(shm_name: &str, w: u32, h: u32, byte_len: usize, area: Rect) -> Self { + let payload = base64::engine::general_purpose::STANDARD.encode(shm_name.as_bytes()); + let (cols, rows) = (area.width, area.height); + // Reusing IMAGE_ID lets Kitty swap the image atomically, so there is no + // blank gap between frames; c=/r= make it span the whole placeholder + // grid, which also stretches a reduced-resolution frame back to size. + // See `from_rgba` for the full rationale. + let transmit = format!( + "\x1b_Gq=2,i={IMAGE_ID},a=T,U=1,f=32,t=s,s={w},v={h},c={cols},r={rows},S={byte_len};{payload}\x1b\\" + ); + Self { + transmit, + unique_id: IMAGE_ID, + area, + } + } + + /// Create a Kitty image widget that carries its pixels inline, zlib + /// compressed and base64 chunked. + /// + /// `rgba` must be exactly `w * h * 4` bytes. Returns `None` if + /// compression fails, so the caller can fall back to braille rather than + /// crash the TUI. /// /// Uses a single fixed image ID (`IMAGE_ID`). Transmitting with /// `a=T,U=1` for the same ID causes Kitty to atomically replace the /// old image data, so there is never a visible gap between frames. /// No delete commands are emitted during normal rendering. - pub fn new(img: &DynamicImage, area: Rect) -> Option { - let (w, h) = (img.width(), img.height()); - - // Get raw RGBA bytes from the image. - let rgba = img.to_rgba8(); - let raw_bytes = rgba.as_raw(); - + pub fn from_rgba(rgba: &[u8], w: u32, h: u32, area: Rect) -> Option { // Compress with zlib level 1 (fastest). Returns None on failure so // the caller can fall back to braille instead of crashing the TUI. let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast()); - if encoder.write_all(raw_bytes).is_err() { + if encoder.write_all(rgba).is_err() { return None; } - let compressed = match encoder.finish() { - Ok(bytes) => bytes, - Err(_) => return None, - }; + let compressed = encoder.finish().ok()?; // Base64. let b64 = base64::engine::general_purpose::STANDARD.encode(&compressed); diff --git a/src/render/kitty_shm.rs b/src/render/kitty_shm.rs new file mode 100644 index 0000000..99d0ead --- /dev/null +++ b/src/render/kitty_shm.rs @@ -0,0 +1,391 @@ +//! Shared-memory transport for the Kitty graphics protocol. +//! +//! The escape-code transport has to base64 every pixel of every frame and push +//! it down the PTY, and because the protocol has no delta mechanism that is the +//! *whole* frame, every frame. At a Retina-resolution viewport that is tens of +//! megabytes of pixel data per frame; compressing it first trades a large chunk +//! of the frame budget for a smaller — but still substantial — write, and the +//! terminal then has to decompress it again. +//! +//! Kitty's `t=s` transmission medium removes the transfer entirely. The client +//! puts the pixels in a POSIX shared memory object and sends only its *name*; +//! the terminal maps the same pages and reads them directly. The escape +//! sequence shrinks from hundreds of kilobytes to about sixty bytes, no +//! compression happens on either side, and the pixels are written exactly once +//! — straight into the shared mapping. +//! +//! This obviously only works when the terminal is on the same machine, so the +//! escape-code path in [`super::kitty_png`] remains for SSH sessions and for +//! terminals that speak the Kitty protocol without supporting `t=s`. Support is +//! established once at startup by [`probe`] rather than assumed. +//! +//! # Object lifetime +//! +//! Per the protocol, *the terminal* unlinks and closes the object once it has +//! read it, so a fresh object is needed for every frame. Names are therefore +//! cycled through a small ring of slots, and each slot is unlinked before being +//! recreated: a frame the terminal never consumed leaks one object until its +//! slot comes round again, and [`unlink_all`] clears whatever is left at exit. +//! Our own mapping can be dropped as soon as the pixels are written — the +//! object outlives it, and that is what the terminal opens. + +use std::ffi::CString; + +/// Number of shared-memory names cycled through, so the terminal is never +/// still reading the object we are about to replace. At 30 fps this leaves +/// over a tenth of a second of slack, orders of magnitude more than a terminal +/// needs to drain an escape sequence. +pub const SLOTS: u32 = 4; + +/// Slot reserved for the startup capability probe, outside the display ring. +const PROBE_SLOT: u32 = SLOTS; + +/// Image id used for the probe. Distinct from the display image id so a +/// query can never disturb the image currently on screen. +const PROBE_IMAGE_ID: u32 = 7317; + +/// POSIX shared memory object names are limited to 31 characters on macOS, so +/// this stays deliberately terse: `/pv-`. +fn slot_name(slot: u32) -> String { + format!("/pv{}-{}", std::process::id(), slot) +} + +/// A freshly created shared memory object, mapped writable. +/// +/// Dropping this unmaps our view but deliberately leaves the object in place: +/// the terminal opens it by name after it reads the escape sequence. +pub struct ShmFrame { + name: String, + ptr: *mut u8, + len: usize, +} + +impl ShmFrame { + /// Create slot `slot`'s object at `len` bytes and map it writable. + /// + /// Any object left in the slot by an unconsumed earlier frame is unlinked + /// first. Returns `None` if the platform refuses at any step, which the + /// caller should treat as "fall back to the escape-code transport". + pub fn create(slot: u32, len: usize) -> Option { + if len == 0 { + return None; + } + let name = slot_name(slot); + let cname = CString::new(name.as_str()).ok()?; + + // SAFETY: `cname` is a valid NUL-terminated C string that outlives all + // of these calls, `len` is non-zero, and every failure path releases + // whatever the previous step acquired. + unsafe { + // Drop a stale object from a frame the terminal never read. + libc::shm_unlink(cname.as_ptr()); + + let fd = libc::shm_open( + cname.as_ptr(), + libc::O_CREAT | libc::O_EXCL | libc::O_RDWR, + 0o600 as libc::c_uint, + ); + if fd < 0 { + return None; + } + + if libc::ftruncate(fd, len as libc::off_t) != 0 { + libc::close(fd); + libc::shm_unlink(cname.as_ptr()); + return None; + } + + let ptr = libc::mmap( + std::ptr::null_mut(), + len, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_SHARED, + fd, + 0, + ); + // The mapping keeps the object alive; the descriptor is not needed. + libc::close(fd); + + if ptr == libc::MAP_FAILED { + libc::shm_unlink(cname.as_ptr()); + return None; + } + + Some(Self { + name, + ptr: ptr.cast::(), + len, + }) + } + } + + /// The object's name, to be sent as the escape sequence's payload. + pub fn name(&self) -> &str { + &self.name + } + + /// The mapped bytes, for the caller to write pixels into. + pub fn as_mut_slice(&mut self) -> &mut [u8] { + // SAFETY: `ptr` came from a successful `mmap` of exactly `len` writable + // bytes and is unmapped only in `Drop`, so the slice cannot outlive it. + unsafe { std::slice::from_raw_parts_mut(self.ptr, self.len) } + } +} + +impl Drop for ShmFrame { + fn drop(&mut self) { + // SAFETY: `ptr`/`len` are exactly what `mmap` returned, and this runs + // once because `Drop` runs once. The object itself is intentionally + // left linked for the terminal to open. + unsafe { + libc::munmap(self.ptr.cast::(), self.len); + } + } +} + +/// Unlink every name this process may have created. +/// +/// The terminal normally does this itself, so in the common case every call +/// here is a no-op; it exists so a frame that was dropped mid-flight — on +/// quit, or on a switch out of FullHD — cannot outlive the process. +pub fn unlink_all() { + for slot in 0..=PROBE_SLOT { + if let Ok(cname) = CString::new(slot_name(slot)) { + // SAFETY: valid NUL-terminated string; unlinking an absent name + // just returns an error we do not care about. + unsafe { + libc::shm_unlink(cname.as_ptr()); + } + } + } +} + +/// Ask the terminal whether it can actually read pixels out of shared memory. +/// +/// Uses the handshake the protocol documents for exactly this: a *query* action +/// — which validates without storing or displaying anything — followed by a +/// primary device attributes request. A terminal that supports the graphics +/// protocol must answer the query before it answers the DA request, so a DA +/// response arriving alone is a definitive "no". The DA response also bounds +/// the wait, with `timeout` as a backstop for terminals that answer neither. +/// +/// Both responses are consumed here so they cannot surface later as spurious +/// key events. +pub fn probe(timeout: std::time::Duration) -> bool { + let Some(mut frame) = ShmFrame::create(PROBE_SLOT, 4) else { + return false; + }; + frame.as_mut_slice().copy_from_slice(&[0, 0, 0, 255]); + let name = frame.name().to_string(); + drop(frame); + + let supported = query_terminal(&name, timeout); + + // The terminal unlinks the object only if it read it; on the "no" path it + // is still there. + if let Ok(cname) = CString::new(name) { + // SAFETY: valid NUL-terminated string. + unsafe { + libc::shm_unlink(cname.as_ptr()); + } + } + + supported +} + +/// Send the query + DA pair and classify the replies. +fn query_terminal(shm_name: &str, timeout: std::time::Duration) -> bool { + use std::io::Write; + + let payload = base64_encode(shm_name.as_bytes()); + let query = format!("\x1b_Gi={PROBE_IMAGE_ID},a=q,f=32,t=s,s=1,v=1,S=4;{payload}\x1b\\\x1b[c"); + + let mut stdout = std::io::stdout(); + if stdout.write_all(query.as_bytes()).is_err() || stdout.flush().is_err() { + return false; + } + + let deadline = std::time::Instant::now() + timeout; + let mut buf = Vec::with_capacity(256); + let mut chunk = [0u8; 128]; + + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + return false; + } + match read_stdin_timeout(&mut chunk, remaining) { + Some(0) | None => return false, + Some(n) => buf.extend_from_slice(&chunk[..n]), + } + + if let Some(ok) = classify(&buf) { + return ok; + } + // Guard against a terminal that streams unrelated input at us. + if buf.len() > 4096 { + return false; + } + } +} + +/// `Some(true)` once a graphics `OK` has arrived, `Some(false)` once the device +/// attributes reply has arrived without one, `None` while still undecided. +fn classify(buf: &[u8]) -> Option { + if let Some(start) = find(buf, b"\x1b_G") { + if let Some(end) = find(&buf[start..], b"\x1b\\") { + let body = &buf[start..start + end]; + return Some(find(body, b";OK").is_some()); + } + // Graphics reply started but has not finished; keep reading. + return None; + } + // A DA reply is `ESC [ ? ... c`. + let da = find(buf, b"\x1b[?")?; + buf[da..].iter().position(|b| *b == b'c').map(|_| false) +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +/// Read from stdin, waiting at most `timeout`. `None` on error or timeout. +fn read_stdin_timeout(buf: &mut [u8], timeout: std::time::Duration) -> Option { + let mut fds = libc::pollfd { + fd: libc::STDIN_FILENO, + events: libc::POLLIN, + revents: 0, + }; + let millis = timeout.as_millis().min(i32::MAX as u128) as libc::c_int; + + // SAFETY: a single well-formed `pollfd` describing stdin. + let ready = unsafe { libc::poll(&mut fds, 1, millis) }; + if ready <= 0 { + return None; + } + + // SAFETY: `buf` is a valid writable slice of the length passed in. + let n = unsafe { + libc::read( + libc::STDIN_FILENO, + buf.as_mut_ptr().cast::(), + buf.len(), + ) + }; + if n < 0 { None } else { Some(n as usize) } +} + +/// Standard base64, for the handful of bytes in a shared memory object name. +fn base64_encode(data: &[u8]) -> String { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(data) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slot_names_fit_the_posix_limit() { + // macOS caps shared memory object names at 31 characters. + for slot in 0..=PROBE_SLOT { + let name = slot_name(slot); + assert!(name.starts_with('/'), "{name} must be an absolute name"); + assert!(name.len() <= 31, "{name} is {} chars", name.len()); + } + } + + #[test] + fn create_maps_writable_memory_and_reads_back() { + let len = 64 * 1024; + let mut frame = ShmFrame::create(0, len).expect("shm unavailable"); + assert_eq!(frame.as_mut_slice().len(), len); + frame.as_mut_slice()[0] = 0xAB; + frame.as_mut_slice()[len - 1] = 0xCD; + assert_eq!(frame.as_mut_slice()[0], 0xAB); + assert_eq!(frame.as_mut_slice()[len - 1], 0xCD); + drop(frame); + unlink_all(); + } + + #[test] + fn recreating_a_slot_replaces_a_stale_object() { + // Simulates a frame the terminal never consumed: the slot is still + // linked when the ring comes back round to it. + let first = ShmFrame::create(1, 4096).expect("shm unavailable"); + let name = first.name().to_string(); + drop(first); + let second = ShmFrame::create(1, 8192).expect("stale slot blocked reuse"); + assert_eq!(second.name(), name); + drop(second); + unlink_all(); + } + + /// The whole transport rests on this: after we unmap, a *separate* opener + /// -- the terminal -- can still find the object by name and read the + /// pixels we wrote. + #[test] + fn another_opener_reads_the_pixels_after_we_unmap() { + let len = 4096; + let mut frame = ShmFrame::create(3, len).expect("shm unavailable"); + let name = frame.name().to_string(); + for (i, byte) in frame.as_mut_slice().iter_mut().enumerate() { + *byte = (i % 256) as u8; + } + drop(frame); // exactly what the render path does before the terminal reads + + let cname = CString::new(name.as_str()).unwrap(); + // SAFETY: mirrors what the terminal does -- open the named object + // read-only, map it, read it, then unlink and unmap. + unsafe { + let fd = libc::shm_open(cname.as_ptr(), libc::O_RDONLY, 0 as libc::c_uint); + assert!(fd >= 0, "reopening {name} failed"); + let ptr = libc::mmap( + std::ptr::null_mut(), + len, + libc::PROT_READ, + libc::MAP_SHARED, + fd, + 0, + ); + libc::close(fd); + assert_ne!(ptr, libc::MAP_FAILED, "mapping {name} failed"); + + let seen = std::slice::from_raw_parts(ptr.cast::(), len); + let expected: Vec = (0..len).map(|i| (i % 256) as u8).collect(); + assert_eq!( + seen, + expected.as_slice(), + "pixels did not survive unmapping" + ); + + libc::munmap(ptr, len); + libc::shm_unlink(cname.as_ptr()); + } + } + + #[test] + fn zero_length_is_rejected() { + assert!(ShmFrame::create(2, 0).is_none()); + } + + #[test] + fn classify_detects_ok_error_and_da() { + assert_eq!(classify(b"\x1b_Gi=7317;OK\x1b\\"), Some(true)); + assert_eq!(classify(b"\x1b_Gi=7317;EBADF:no shm\x1b\\"), Some(false)); + // Graphics reply still arriving. + assert_eq!(classify(b"\x1b_Gi=7317;O"), None); + // DA alone means the graphics protocol went unanswered. + assert_eq!(classify(b"\x1b[?62;c"), Some(false)); + // DA still arriving. + assert_eq!(classify(b"\x1b[?62;"), None); + assert_eq!(classify(b""), None); + } + + #[test] + fn classify_prefers_graphics_reply_over_da() { + assert_eq!(classify(b"\x1b_Gi=7317;OK\x1b\\\x1b[?62;c"), Some(true)); + } +} diff --git a/src/render/mod.rs b/src/render/mod.rs index 3175305..fb40104 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -5,5 +5,7 @@ pub mod color; pub mod framebuffer; pub mod hd; pub mod kitty_png; +pub mod kitty_shm; +pub mod palette; pub mod ribbon; pub mod snapshot; diff --git a/src/render/palette.rs b/src/render/palette.rs new file mode 100644 index 0000000..9113a89 --- /dev/null +++ b/src/render/palette.rs @@ -0,0 +1,557 @@ +//! User-configurable color palette. +//! +//! Every fixed color ProteinView draws comes from a [`Palette`]. The built-in +//! defaults reproduce the previously hardcoded colors exactly, so a user with no +//! config file sees no change. A TOML file may override any subset of them: +//! anything omitted keeps its default. +//! +//! The palette is resolved once at startup and read-only thereafter, so it lives +//! in a process-wide [`OnceLock`] rather than being threaded through every +//! renderer. Call [`init`] once from `main`; everything else reads [`palette`]. +//! +//! Procedural schemes (Rainbow's HSV sweep) and the depth fog are not covered +//! here yet. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use anyhow::{Context, Result}; +use serde::Deserialize; + +/// An RGB triple, deserialized from a hex string such as `"FF0080"` or `"#ff0080"`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Rgb(pub [u8; 3]); + +impl Rgb { + pub const fn new(r: u8, g: u8, b: u8) -> Self { + Self([r, g, b]) + } +} + +impl<'de> Deserialize<'de> for Rgb { + fn deserialize>(deserializer: D) -> Result { + use serde::de::Error; + let raw = String::deserialize(deserializer)?; + let hex = raw.strip_prefix('#').unwrap_or(&raw); + if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(D::Error::custom(format!( + "expected six hexadecimal digits such as \"FF0080\", got {raw:?}" + ))); + } + let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).map_err(D::Error::custom); + Ok(Rgb([byte(0)?, byte(2)?, byte(4)?])) + } +} + +// --------------------------------------------------------------------------- +// Sections +// --------------------------------------------------------------------------- + +/// Secondary-structure colors for the Structure scheme. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct StructurePalette { + pub helix: Rgb, + pub sheet: Rgb, + pub turn: Rgb, + pub coil: Rgb, +} + +impl Default for StructurePalette { + fn default() -> Self { + Self { + helix: Rgb::new(255, 0, 128), + sheet: Rgb::new(255, 200, 0), + turn: Rgb::new(96, 128, 255), + coil: Rgb::new(0, 204, 0), + } + } +} + +/// Per-base colors for nucleic acid residues. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct NucleotidePalette { + pub adenine: Rgb, + pub uracil: Rgb, + pub thymine: Rgb, + pub guanine: Rgb, + pub cytosine: Rgb, + pub inosine: Rgb, +} + +impl Default for NucleotidePalette { + fn default() -> Self { + Self { + adenine: Rgb::new(220, 60, 60), + uracil: Rgb::new(60, 60, 220), + thymine: Rgb::new(60, 60, 220), + guanine: Rgb::new(60, 180, 60), + cytosine: Rgb::new(220, 200, 40), + inosine: Rgb::new(150, 100, 180), + } + } +} + +/// CPK-style element colors. `symbols` is keyed by uppercase element symbol. +#[derive(Debug, Clone)] +pub struct ElementPalette { + pub fallback: Rgb, + pub symbols: HashMap, +} + +impl ElementPalette { + /// Color for an element symbol, case-insensitively. + pub fn get(&self, symbol: &str) -> Rgb { + self.symbols + .get(&symbol.trim().to_ascii_uppercase()) + .copied() + .unwrap_or(self.fallback) + } +} + +impl Default for ElementPalette { + fn default() -> Self { + const CPK: &[(&str, [u8; 3])] = &[ + ("C", [144, 144, 144]), + ("N", [48, 80, 248]), + ("O", [255, 13, 13]), + ("S", [255, 255, 48]), + ("H", [255, 255, 255]), + ("P", [255, 128, 0]), + ("FE", [224, 102, 51]), + ("MG", [0, 180, 0]), + ("ZN", [125, 128, 176]), + ("CA", [61, 255, 0]), + ("MN", [156, 122, 199]), + ("CO", [240, 144, 160]), + ("CU", [200, 128, 51]), + ("NI", [80, 208, 80]), + ("CL", [31, 240, 31]), + ("BR", [166, 41, 41]), + ]; + Self { + fallback: Rgb::new(200, 200, 200), + symbols: CPK + .iter() + .map(|(s, c)| ((*s).to_string(), Rgb(*c))) + .collect(), + } + } +} + +/// AlphaFold pLDDT confidence bands. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct PlddtPalette { + /// pLDDT >= 90 + pub very_high: Rgb, + /// pLDDT >= 70 + pub high: Rgb, + /// pLDDT >= 50 + pub low: Rgb, + /// pLDDT < 50 + pub very_low: Rgb, +} + +impl Default for PlddtPalette { + fn default() -> Self { + Self { + very_high: Rgb::new(0, 83, 214), + high: Rgb::new(101, 203, 243), + low: Rgb::new(255, 219, 19), + very_low: Rgb::new(255, 125, 69), + } + } +} + +/// Endpoints of the B-factor gradient. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct BFactorPalette { + /// Color at the cold end of the range. + pub low: Rgb, + /// Color at the hot end of the range. + pub high: Rgb, +} + +impl Default for BFactorPalette { + fn default() -> Self { + Self { + low: Rgb::new(0, 0, 255), + high: Rgb::new(255, 0, 0), + } + } +} + +/// Interface-highlighting colors. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct InterfacePalette { + /// Focus chain, in contact with a partner. + pub focus_contact: Rgb, + /// Focus chain, away from the interface. + pub focus_other: Rgb, + /// Partner chain, in contact with the focus chain. + pub partner_contact: Rgb, + /// Partner chain, away from the interface. + pub partner_other: Rgb, + /// Ligands, drawn bright so they stand out against the interface coloring. + pub ligand: Rgb, +} + +impl Default for InterfacePalette { + fn default() -> Self { + Self { + focus_contact: Rgb::new(0, 255, 100), + focus_other: Rgb::new(40, 100, 60), + partner_contact: Rgb::new(255, 165, 0), + partner_other: Rgb::new(100, 80, 60), + ligand: Rgb::new(255, 255, 255), + } + } +} + +/// Small-molecule colors. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct LigandPalette { + pub ligand: Rgb, + pub ion: Rgb, + /// Ligands under the Rainbow scheme, which has no per-residue value for them. + pub rainbow: Rgb, +} + +impl Default for LigandPalette { + fn default() -> Self { + Self { + ligand: Rgb::new(255, 0, 255), + ion: Rgb::new(0, 255, 255), + rainbow: Rgb::new(255, 0, 255), + } + } +} + +/// Colors for residues picked in the sequence panel. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct SelectionPalette { + /// Carbon atoms of the ball-and-stick overlay. Other elements keep their + /// CPK colors, so a picked residue reads as itself while still standing + /// out from the ribbon behind it. + pub carbon: Rgb, + /// Marker sphere drawn on a picked residue when ball-and-stick is off. + pub marker: Rgb, + /// Background of the sequence panel's cursor cell. + pub cursor: Rgb, +} + +impl Default for SelectionPalette { + fn default() -> Self { + Self { + carbon: Rgb::new(0, 230, 140), + marker: Rgb::new(0, 230, 140), + cursor: Rgb::new(255, 200, 0), + } + } +} + +// --------------------------------------------------------------------------- +// Palette +// --------------------------------------------------------------------------- + +/// The resolved palette: built-in defaults with any configured overrides applied. +#[derive(Debug, Clone)] +pub struct Palette { + pub structure: StructurePalette, + pub nucleotide: NucleotidePalette, + /// Cycled by chain in order; always at least one entry. + pub chains: Vec, + pub element: ElementPalette, + pub plddt: PlddtPalette, + pub bfactor: BFactorPalette, + pub interface: InterfacePalette, + pub ligand: LigandPalette, + pub selection: SelectionPalette, +} + +impl Default for Palette { + fn default() -> Self { + Self { + structure: StructurePalette::default(), + nucleotide: NucleotidePalette::default(), + chains: vec![ + Rgb::new(0, 180, 255), + Rgb::new(255, 100, 0), + Rgb::new(0, 220, 100), + Rgb::new(255, 50, 150), + Rgb::new(180, 100, 255), + Rgb::new(255, 220, 0), + Rgb::new(0, 200, 200), + Rgb::new(255, 150, 150), + ], + element: ElementPalette::default(), + plddt: PlddtPalette::default(), + bfactor: BFactorPalette::default(), + interface: InterfacePalette::default(), + ligand: LigandPalette::default(), + selection: SelectionPalette::default(), + } + } +} + +impl Palette { + /// Color for the chain whose id starts with `id`, cycling through `chains`. + pub fn chain(&self, id: &str) -> Rgb { + let idx = id.bytes().next().unwrap_or(b'A') as usize % self.chains.len(); + self.chains[idx] + } +} + +// --------------------------------------------------------------------------- +// Config file +// --------------------------------------------------------------------------- + +/// Element section as written in the file. `symbols` is *merged* onto the +/// built-in CPK table rather than replacing it, so overriding carbon does not +/// silently drop every other element. +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct ElementFile { + /// Color for elements not listed in `symbols`. + fallback: Option, + symbols: HashMap, +} + +/// Chain section. A section rather than a bare top-level `chains` key, because +/// in TOML a bare key written after any `[table]` header silently binds to that +/// table instead of the document root. +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct ChainFile { + /// Replaces the default cycle outright; order is significant. + colors: Option>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct PaletteFile { + structure: StructurePalette, + nucleotide: NucleotidePalette, + chain: ChainFile, + element: ElementFile, + plddt: PlddtPalette, + bfactor: BFactorPalette, + interface: InterfacePalette, + ligand: LigandPalette, + selection: SelectionPalette, +} + +impl PaletteFile { + fn resolve(self) -> Result { + let mut palette = Palette { + structure: self.structure, + nucleotide: self.nucleotide, + chains: Palette::default().chains, + element: ElementPalette::default(), + plddt: self.plddt, + bfactor: self.bfactor, + interface: self.interface, + ligand: self.ligand, + selection: self.selection, + }; + + if let Some(colors) = self.chain.colors { + if colors.is_empty() { + anyhow::bail!("`chain.colors` must list at least one color"); + } + palette.chains = colors; + } + if let Some(fallback) = self.element.fallback { + palette.element.fallback = fallback; + } + for (symbol, color) in self.element.symbols { + palette + .element + .symbols + .insert(symbol.trim().to_ascii_uppercase(), color); + } + + Ok(palette) + } +} + +/// Parse a palette from TOML text, filling anything omitted from the defaults. +pub fn parse(text: &str) -> Result { + toml::from_str::(text)?.resolve() +} + +/// Read and parse a palette file. +pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("cannot read palette file {}", path.display()))?; + parse(&text).with_context(|| format!("invalid palette file {}", path.display())) +} + +/// The default palette file location: `$XDG_CONFIG_HOME/proteinview/palette.toml`, +/// falling back to `~/.config/proteinview/palette.toml`. +pub fn default_config_path() -> Option { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?; + Some(base.join("proteinview").join("palette.toml")) +} + +static PALETTE: OnceLock = OnceLock::new(); + +/// The active palette. Defaults are used if [`init`] was never called. +pub fn palette() -> &'static Palette { + PALETTE.get_or_init(Palette::default) +} + +/// Resolve the palette once, from `explicit` if given, else from the default +/// config path if it exists, else from the built-in defaults. +/// +/// An explicit path that cannot be read is an error; so is a malformed file in +/// either location, since silently ignoring a palette the user wrote is worse +/// than refusing to start. Returns the path that was loaded, if any. +pub fn init(explicit: Option<&Path>) -> Result> { + let chosen = match explicit { + Some(path) => Some(path.to_path_buf()), + None => default_config_path().filter(|p| p.is_file()), + }; + + let (resolved, loaded) = match &chosen { + Some(path) => (load(path)?, Some(path.clone())), + None => (Palette::default(), None), + }; + + let _ = PALETTE.set(resolved); + Ok(loaded) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_config_is_the_default_palette() { + let p = parse("").unwrap(); + let d = Palette::default(); + assert_eq!(p.structure.helix, d.structure.helix); + assert_eq!(p.chains, d.chains); + assert_eq!(p.element.get("ZN"), d.element.get("ZN")); + assert_eq!(p.plddt.very_high, d.plddt.very_high); + } + + #[test] + fn overrides_apply_and_the_rest_stays_default() { + let p = parse( + r##" + [structure] + helix = "#112233" + "##, + ) + .unwrap(); + assert_eq!(p.structure.helix, Rgb::new(0x11, 0x22, 0x33)); + // Untouched fields keep their defaults. + assert_eq!(p.structure.sheet, StructurePalette::default().sheet); + assert_eq!(p.structure.coil, StructurePalette::default().coil); + } + + #[test] + fn element_symbols_merge_rather_than_replace() { + // Overriding carbon must not drop the rest of the CPK table. + let p = parse( + r#" + [element.symbols] + C = "010203" + "#, + ) + .unwrap(); + assert_eq!(p.element.get("C"), Rgb::new(1, 2, 3)); + assert_eq!(p.element.get("ZN"), Rgb::new(125, 128, 176)); + assert_eq!(p.element.get("FE"), Rgb::new(224, 102, 51)); + } + + #[test] + fn element_lookup_is_case_insensitive_both_ways() { + let p = parse( + r#" + [element.symbols] + se = "0A0B0C" + "#, + ) + .unwrap(); + assert_eq!(p.element.get("SE"), Rgb::new(10, 11, 12)); + assert_eq!(p.element.get("Se"), Rgb::new(10, 11, 12)); + assert_eq!(p.element.get(" fe "), Rgb::new(224, 102, 51)); + } + + #[test] + fn chains_are_replaced_wholesale_and_cycle() { + let p = parse("[chain]\ncolors = [\"FF0000\", \"00FF00\"]").unwrap(); + assert_eq!(p.chains.len(), 2); + // b'A' = 65, so 65 % 2 = 1 picks the second entry. + assert_eq!(p.chain("A"), Rgb::new(0, 255, 0)); + assert_eq!(p.chain("B"), Rgb::new(255, 0, 0)); + assert_eq!(p.chain("A"), p.chain("C")); + } + + #[test] + fn empty_chain_list_is_rejected() { + let err = parse("[chain]\ncolors = []").unwrap_err().to_string(); + assert!(err.contains("at least one"), "unhelpful error: {err}"); + } + + #[test] + fn chain_colors_survive_being_written_after_other_sections() { + // A bare top-level `chains = [...]` would bind to whichever [table] + // preceded it. Keeping it in its own section makes order irrelevant. + let p = parse("[structure]\nhelix = \"FF0000\"\n\n[chain]\ncolors = [\"00FF00\"]").unwrap(); + assert_eq!(p.chains, vec![Rgb::new(0, 255, 0)]); + assert_eq!(p.structure.helix, Rgb::new(255, 0, 0)); + } + + #[test] + fn hex_accepts_optional_hash_and_any_case() { + let p = parse( + r##" + [structure] + helix = "#aabbcc" + sheet = "AABBCC" + "##, + ) + .unwrap(); + assert_eq!(p.structure.helix, Rgb::new(0xAA, 0xBB, 0xCC)); + assert_eq!(p.structure.sheet, p.structure.helix); + } + + #[test] + fn malformed_color_is_rejected_with_the_offending_value() { + let err = parse( + r#" + [structure] + helix = "nope" + "#, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("six hexadecimal"), "unhelpful error: {err}"); + assert!(err.contains("nope"), "error should quote the value: {err}"); + } + + #[test] + fn unknown_keys_are_rejected_so_typos_are_not_silent() { + let err = parse( + r#" + [structure] + helics = "FF0000" + "#, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("helics"), "error should name the key: {err}"); + } +} diff --git a/src/render/ribbon.rs b/src/render/ribbon.rs index 804aeb1..ddfd1ff 100644 --- a/src/render/ribbon.rs +++ b/src/render/ribbon.rs @@ -16,11 +16,25 @@ use crate::render::color::{ColorScheme, color_to_rgb}; // Constants & LOD configuration // --------------------------------------------------------------------------- -/// Default number of spline subdivisions between each pair of C-alpha atoms. -const DEFAULT_SPLINE_SUBDIVISIONS: usize = 14; +/// Spline subdivisions between each pair of C-alpha atoms, for small structures. +/// +/// Small structures cost little to tessellate whatever the setting, and are the +/// ones most likely to be zoomed in on, so they keep the finer mesh. +const SMALL_SPLINE_SUBDIVISIONS: usize = 12; + +/// Vertices around the coil/turn tube cross-section, for small structures. +const SMALL_COIL_SEGMENTS: usize = 10; + +/// Default spline subdivisions. +/// +/// Measured against a 14x12 mesh at 1600x736 (the largest framebuffer a FullHD +/// terminal viewport produces), 8x8 renders 2.1x faster for a mean per-channel +/// error of 0.40/255, with differences confined to silhouette edges. At braille +/// and HD resolutions the difference is smaller still. +const DEFAULT_SPLINE_SUBDIVISIONS: usize = 8; /// Default number of vertices around the coil/turn tube cross-section. -const DEFAULT_COIL_SEGMENTS: usize = 12; +const DEFAULT_COIL_SEGMENTS: usize = 8; /// Reduced spline subdivisions for large structures (>5000 residues). const LARGE_SPLINE_SUBDIVISIONS: usize = 4; @@ -28,6 +42,9 @@ const LARGE_SPLINE_SUBDIVISIONS: usize = 4; /// Reduced coil segments for large structures (>5000 residues). const LARGE_COIL_SEGMENTS: usize = 6; +/// Residue count below which the finer small-structure mesh is used. +const SMALL_STRUCTURE_THRESHOLD: usize = 300; + /// Level-of-detail configuration for ribbon mesh generation. /// Large structures use reduced subdivision counts to cut triangle count /// with zero visible difference at terminal resolution. @@ -38,6 +55,13 @@ struct LodConfig { } impl LodConfig { + fn small() -> Self { + Self { + spline_subdivisions: SMALL_SPLINE_SUBDIVISIONS, + coil_segments: SMALL_COIL_SEGMENTS, + } + } + fn normal() -> Self { Self { spline_subdivisions: DEFAULT_SPLINE_SUBDIVISIONS, @@ -56,6 +80,8 @@ impl LodConfig { fn for_residue_count(residue_count: usize) -> Self { if residue_count > crate::app::LARGE_STRUCTURE_THRESHOLD { Self::large() + } else if residue_count <= SMALL_STRUCTURE_THRESHOLD { + Self::small() } else { Self::normal() } @@ -371,6 +397,68 @@ fn triangle_normal(v0: V3, v1: V3, v2: V3) -> V3 { v3_normalize(v3_cross(e1, e2)) } +// --------------------------------------------------------------------------- +// Consistently wound triangle emission +// --------------------------------------------------------------------------- + +/// Mean of a set of points. +fn centroid(points: &[V3]) -> V3 { + if points.is_empty() { + return [0.0; 3]; + } + let sum = points.iter().fold([0.0; 3], |acc: V3, p| v3_add(acc, *p)); + v3_scale(sum, 1.0 / points.len() as f64) +} + +/// Push a triangle, forcing its winding to agree with `outward`. +/// +/// Every cross-section ring is built from a reference frame carried along the +/// spline, and that frame can flip sign partway down a chain. When it does, +/// the ring's vertex order reverses, and so does the winding of every triangle +/// built from it -- which is why the mesh used to contain a mix of clockwise +/// and counter-clockwise faces and could not be back-face culled. +/// +/// Orienting each triangle against a direction known to point out of the +/// surface makes the winding globally consistent, and makes the stored normal a +/// genuine outward normal rather than whatever the vertex order implied. +fn push_oriented( + v0: V3, + v1: V3, + v2: V3, + outward: V3, + color: [u8; 3], + out: &mut Vec, +) { + let normal = triangle_normal(v0, v1, v2); + if v3_dot(normal, outward) < 0.0 { + // Swapping two vertices reverses the winding, and with it the normal. + out.push(RibbonTriangle { + verts: [v0, v2, v1], + color, + normal: v3_scale(normal, -1.0), + }); + } else { + out.push(RibbonTriangle { + verts: [v0, v1, v2], + color, + normal, + }); + } +} + +/// Push a triangle, reversing its winding when `flip` is set. +/// +/// Used where the correct orientation is known for a whole run of triangles at +/// once, which is more reliable than testing each one on its own. +fn push_wound(v0: V3, v1: V3, v2: V3, flip: bool, color: [u8; 3], out: &mut Vec) { + let verts = if flip { [v0, v2, v1] } else { [v0, v1, v2] }; + out.push(RibbonTriangle { + verts, + color, + normal: triangle_normal(verts[0], verts[1], verts[2]), + }); +} + // --------------------------------------------------------------------------- // Emit triangle strip between two cross-sections // --------------------------------------------------------------------------- @@ -384,6 +472,36 @@ fn emit_strip(ring_a: &[V3], ring_b: &[V3], color: [u8; 3], out: &mut Vec two triangles. - // Triangle 1: a0, a1, b0 - let n1 = triangle_normal(a0, a1, b0); - out.push(RibbonTriangle { - verts: [a0, a1, b0], - color, - normal: n1, - }); + push_wound(a0, a1, b0, flip, color, out); + push_wound(a1, b1, b0, flip, color, out); + } +} - // Triangle 2: a1, b1, b0 - let n2 = triangle_normal(a1, b1, b0); - out.push(RibbonTriangle { - verts: [a1, b1, b0], - color, - normal: n2, - }); +/// Outward axis directions for the two end caps of a spline. +/// +/// The cap at each end faces along the tube axis, away from the body, which is +/// the direction from the neighbouring spline point toward the end point. +fn end_axes(spline_points: &[SplinePoint]) -> (V3, V3) { + let n = spline_points.len(); + if n < 2 { + // Degenerate run: any consistent pair will do, nothing is visible. + return ([0.0, 0.0, -1.0], [0.0, 0.0, 1.0]); } + let front = v3_normalize(v3_sub(spline_points[0].pos, spline_points[1].pos)); + let back = v3_normalize(v3_sub(spline_points[n - 1].pos, spline_points[n - 2].pos)); + (front, back) } /// Emit a cap (disc) to close the end of a tube or ribbon. -/// `ring` is the cross-section ring, `center` is the spline point center, -/// and `facing_forward` controls the winding direction. -fn emit_cap( - ring: &[V3], - center: V3, - color: [u8; 3], - facing_forward: bool, - out: &mut Vec, -) { +/// +/// `ring` is the cross-section ring and `center` its spline point. `outward` +/// is the tube axis direction pointing away from the body of the tube, which is +/// the cap's true surface normal; it decides the winding, so the caps are +/// consistent with the strips around them. +fn emit_cap(ring: &[V3], center: V3, color: [u8; 3], outward: V3, out: &mut Vec) { let n = ring.len(); if n < 3 { return; } for i in 0..n { let j = (i + 1) % n; - let (v0, v1) = if facing_forward { - (ring[i], ring[j]) - } else { - (ring[j], ring[i]) - }; - let norm = triangle_normal(center, v0, v1); - out.push(RibbonTriangle { - verts: [center, v0, v1], - color, - normal: norm, - }); + push_oriented(center, ring[i], ring[j], outward, color, out); } } @@ -483,8 +590,16 @@ fn resample_ring(ring: &[V3], target_count: usize) -> Vec { /// The returned triangles are in world space. The caller should project each /// vertex through the camera and then rasterize. pub fn generate_ribbon_mesh(protein: &Protein, color_scheme: &ColorScheme) -> Vec { - let mut triangles: Vec = Vec::new(); let lod = LodConfig::for_residue_count(protein.residue_count()); + generate_ribbon_mesh_with_lod(protein, color_scheme, lod) +} + +fn generate_ribbon_mesh_with_lod( + protein: &Protein, + color_scheme: &ColorScheme, + lod: LodConfig, +) -> Vec { + let mut triangles: Vec = Vec::new(); for chain in &protein.chains { match chain.molecule_type { @@ -658,12 +773,14 @@ fn build_spline_tube(records: &[CaRecord], lod: &LodConfig, out: &mut Vec) { - let n1 = triangle_normal(v0, v1, v2); - out.push(RibbonTriangle { - verts: [v0, v1, v2], - color, - normal: n1, - }); - let n2 = triangle_normal(v0, v2, v3); - out.push(RibbonTriangle { - verts: [v0, v2, v3], - color, - normal: n2, - }); +/// Emit two triangles for a quad face (v0, v1, v2, v3). +/// +/// `interior` is a point inside the solid the face belongs to, which orients +/// both triangles outward. +fn emit_quad( + v0: V3, + v1: V3, + v2: V3, + v3: V3, + interior: V3, + color: [u8; 3], + out: &mut Vec, +) { + let third = 1.0 / 3.0; + let c1 = v3_scale(v3_add(v3_add(v0, v1), v2), third); + push_oriented(v0, v1, v2, v3_sub(c1, interior), color, out); + let c2 = v3_scale(v3_add(v3_add(v0, v2), v3), third); + push_oriented(v0, v2, v3, v3_sub(c2, interior), color, out); } // --------------------------------------------------------------------------- @@ -1264,3 +1391,70 @@ mod tests { } } } + +#[cfg(test)] +mod winding_tests { + use super::*; + use crate::render::color::{ColorScheme, ColorSchemeType}; + use std::collections::HashMap; + + /// Every triangle in the ribbon mesh must be wound the same way round. + /// + /// Each shared edge should appear once in each direction. An edge used + /// twice in the *same* direction means two neighbouring triangles disagree + /// about which side is out, which leaves the stored normals arbitrarily + /// signed and rules out back-face culling. + fn assert_consistent_winding(path: &str) { + let mut protein = crate::parser::pdb::load_structure(path).unwrap(); + protein.center(); + let scheme = ColorScheme::new(ColorSchemeType::Structure, protein.residue_count()); + let mesh = generate_ribbon_mesh(&protein, &scheme); + assert!(!mesh.is_empty(), "{path} produced no ribbon geometry"); + + let key = |v: V3| { + const SCALE: f64 = 4096.0; + ( + (v[0] * SCALE).round() as i64, + (v[1] * SCALE).round() as i64, + (v[2] * SCALE).round() as i64, + ) + }; + let mut directed: HashMap<((i64, i64, i64), (i64, i64, i64)), i32> = HashMap::new(); + for tri in &mesh { + let k = [key(tri.verts[0]), key(tri.verts[1]), key(tri.verts[2])]; + for i in 0..3 { + *directed.entry((k[i], k[(i + 1) % 3])).or_insert(0) += 1; + } + } + + let mismatched: i32 = directed + .iter() + .filter(|&(&(u, v), &n)| { + let back = directed.get(&(v, u)).copied().unwrap_or(0); + back != 0 && back != n + }) + .map(|(_, &n)| n) + .sum(); + assert_eq!( + mismatched, 0, + "{path}: {mismatched} inconsistently wound edges" + ); + } + + #[test] + fn protein_ribbons_are_consistently_wound() { + assert_consistent_winding("examples/1UBQ.pdb"); + assert_consistent_winding("examples/4HHB.pdb"); + } + + #[test] + fn nucleic_acid_ribbons_are_consistently_wound() { + assert_consistent_winding("examples/1BNA.pdb"); + assert_consistent_winding("examples/1RNA.pdb"); + } + + #[test] + fn mixed_structures_are_consistently_wound() { + assert_consistent_winding("examples/1AOI.pdb"); + } +} diff --git a/src/render/snapshot.rs b/src/render/snapshot.rs index 0db6fe9..2ba6239 100644 --- a/src/render/snapshot.rs +++ b/src/render/snapshot.rs @@ -120,6 +120,7 @@ pub fn save_png(mut protein: Protein, output_path: &Path, options: SnapshotOptio &mesh, options.show_ligands, interactions, + None, ); let image = DynamicImage::ImageRgba8(framebuffer.to_rgba_image()); write_png_atomically(&image, output_path) diff --git a/src/ui/help_overlay.rs b/src/ui/help_overlay.rs index fcc05da..7afcaa0 100644 --- a/src/ui/help_overlay.rs +++ b/src/ui/help_overlay.rs @@ -11,7 +11,7 @@ pub fn render_help_overlay(frame: &mut Frame, area: Rect) { return; } let popup_width = 60u16.min(area.width.saturating_sub(4)); - let popup_height = 23u16.min(area.height.saturating_sub(4)); + let popup_height = 36u16.min(area.height.saturating_sub(4)); let x = (area.width - popup_width) / 2; let y = (area.height - popup_height) / 2; let popup_area = Rect::new(x, y, popup_width, popup_height); @@ -60,7 +60,7 @@ pub fn render_help_overlay(frame: &mut Frame, area: Rect) { ]), Line::from(vec![ Span::styled(" m ", Style::default().fg(Color::Yellow)), - Span::raw("Toggle Braille / HD"), + Span::raw("Cycle Braille / HD / HDplus"), ]), Line::from(vec![ Span::styled(" M ", Style::default().fg(Color::Yellow)), @@ -95,6 +95,56 @@ pub fn render_help_overlay(frame: &mut Frame, area: Rect) { Span::raw("Quit"), ]), Line::from(""), + Line::from(Span::styled( + " Sequence panel", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )), + Line::from(vec![ + Span::styled(" S ", Style::default().fg(Color::Yellow)), + Span::raw("Open / close the sequence panel"), + ]), + Line::from(vec![ + Span::styled( + " \u{2190}\u{2192}\u{2191}\u{2193} ", + Style::default().fg(Color::Yellow), + ), + Span::raw("Move the residue cursor"), + ]), + Line::from(vec![ + Span::styled(" Shift+arrow", Style::default().fg(Color::Yellow)), + Span::raw(" Extend the selection"), + ]), + Line::from(vec![ + Span::styled(" Enter ", Style::default().fg(Color::Yellow)), + Span::raw("Select / deselect residue"), + ]), + Line::from(vec![ + Span::styled(" A ", Style::default().fg(Color::Yellow)), + Span::raw("Select / deselect whole chain"), + ]), + Line::from(vec![ + Span::styled(" x ", Style::default().fg(Color::Yellow)), + Span::raw("Clear the selection"), + ]), + Line::from(vec![ + Span::styled(" b ", Style::default().fg(Color::Yellow)), + Span::raw("Ball-and-stick for the selection"), + ]), + Line::from(vec![ + Span::styled(" z ", Style::default().fg(Color::Yellow)), + Span::raw("Centre the view on the selection"), + ]), + Line::from(vec![ + Span::styled(" Home / End ", Style::default().fg(Color::Yellow)), + Span::raw("Start / end of chain"), + ]), + Line::from(vec![ + Span::styled(" < / > ", Style::default().fg(Color::Yellow)), + Span::raw("Shrink / grow the panel"), + ]), + Line::from(""), Line::from(Span::styled( " Press ? or Esc to close", Style::default().fg(Color::DarkGray), diff --git a/src/ui/helpbar.rs b/src/ui/helpbar.rs index c57ec74..305f296 100644 --- a/src/ui/helpbar.rs +++ b/src/ui/helpbar.rs @@ -4,8 +4,18 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; -/// Render the keybinding hints bar at the bottom -pub fn render_helpbar(frame: &mut Frame, area: Rect) { +use crate::app::App; + +/// Render the keybinding hints bar at the bottom. +/// +/// The hints change with the sequence panel: while it is open the arrow keys +/// drive the cursor rather than the camera, and saying so here is cheaper than +/// making the user open the help overlay to find out. +pub fn render_helpbar(frame: &mut Frame, area: Rect, app: &App) { + if app.show_sequence { + frame.render_widget(Paragraph::new(sequence_hints()), area); + return; + } let help = Paragraph::new(Line::from(vec![ Span::styled("╰── ", Style::default().fg(Color::DarkGray)), Span::styled( @@ -78,6 +88,13 @@ pub fn render_helpbar(frame: &mut Frame, area: Rect) { .add_modifier(Modifier::BOLD), ), Span::styled(": ligands ", Style::default().fg(Color::Gray)), + Span::styled( + "S", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled(": sequence ", Style::default().fg(Color::Gray)), Span::styled( "q", Style::default() @@ -89,3 +106,43 @@ pub fn render_helpbar(frame: &mut Frame, area: Rect) { ])); frame.render_widget(help, area); } + +/// Hints shown while the sequence panel has the arrow keys. +fn sequence_hints() -> Line<'static> { + let key = |text: &'static str| { + Span::styled( + text, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + }; + let label = |text: &'static str| Span::styled(text, Style::default().fg(Color::Gray)); + + Line::from(vec![ + Span::styled("╰── ", Style::default().fg(Color::DarkGray)), + key("←→↑↓"), + label(": cursor "), + key("shift+←→"), + label(": range "), + key("↵"), + label(": pick "), + key("A"), + label(": chain "), + key("x"), + label(": clear "), + key("b"), + label(": ball&stick "), + key("z"), + label(": centre "), + key("[ ]"), + label(": chain "), + key("hjkl"), + label(": rotate "), + key("<>"), + label(": size "), + key("S/esc"), + label(": close "), + Span::styled("──╯", Style::default().fg(Color::DarkGray)), + ]) +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 289fad5..2dc24c1 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2,5 +2,6 @@ pub mod header; pub mod help_overlay; pub mod helpbar; pub mod interface_panel; +pub mod sequence_panel; pub mod statusbar; pub mod viewport; diff --git a/src/ui/sequence_panel.rs b/src/ui/sequence_panel.rs new file mode 100644 index 0000000..a55ea37 --- /dev/null +++ b/src/ui/sequence_panel.rs @@ -0,0 +1,430 @@ +//! The scrollable chain-sequence panel. +//! +//! Every chain in the structure is laid out as one-letter codes, wrapped to the +//! panel width and scrolled as a single list. The cursor and the selection are +//! drawn straight onto the letters, so picking residues here is what drives the +//! ball-and-stick overlay in the 3D view. + +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::app::App; +use crate::model::protein::{Chain, MoleculeType, SecondaryStructure}; +use crate::model::sequence::{GROUP, SeqRow, column_offset, molecule_label, one_letter}; +use crate::render::palette::palette; + +/// Width of the left gutter: a right-aligned residue number plus a space. +pub const GUTTER: u16 = 7; + +/// Color of a picked residue, from the palette's `[selection]` section. +fn selection_color() -> Color { + let [red, green, blue] = palette().selection.marker.0; + Color::Rgb(red, green, blue) +} + +/// Color of the cursor cell, from the palette's `[selection]` section. +fn cursor_color() -> Color { + let [red, green, blue] = palette().selection.cursor.0; + Color::Rgb(red, green, blue) +} + +/// Height the panel takes in a layout `total_rows` tall, or 0 when closed. +/// +/// The rest of the interface needs seven rows (header, a minimum viewport, +/// status bar, help bar), so the panel never squeezes the 3D view out of +/// existence however far `>` is held down. +pub fn height_for(app: &App, total_rows: u16) -> u16 { + if !app.show_sequence { + return 0; + } + const CHROME_ROWS: u16 = 7; + app.seq_panel_height + .min(total_rows.saturating_sub(CHROME_ROWS)) +} + +/// Render the sequence panel into `area`. +pub fn render_sequence_panel(frame: &mut Frame, area: Rect, app: &App) { + if area.height < 2 { + return; + } + + let block = Block::default() + .borders(Borders::TOP) + .border_style(Style::default().fg(Color::DarkGray)) + .title(" Sequence ") + .title_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.height == 0 { + return; + } + + // Cursor line: what is under the cursor, and what the selection holds. + frame.render_widget( + Paragraph::new(cursor_line(app)), + Rect::new(inner.x, inner.y, inner.width, 1), + ); + + let rows_area = Rect::new( + inner.x, + inner.y + 1, + inner.width, + inner.height.saturating_sub(1), + ); + if rows_area.height == 0 { + return; + } + + let layout = app.sequence_layout(); + let mut lines: Vec = Vec::with_capacity(rows_area.height as usize); + for offset in 0..rows_area.height as usize { + let row = app.seq_scroll + offset; + match layout.rows.get(row) { + Some(SeqRow::Header(chain_index)) => { + lines.push(header_line(app, *chain_index)); + } + Some(SeqRow::Residues { chain, start, len }) => { + lines.push(residue_line(app, *chain, *start, *len)); + } + None => lines.push(Line::from("")), + } + } + + frame.render_widget(Paragraph::new(lines), rows_area); +} + +/// `Lb 245 ARG helix │ 12 residues in 3 chains │ ball&stick on` +fn cursor_line(app: &App) -> Line<'static> { + let mut spans = vec![Span::styled(" ", Style::default())]; + + match app.seq_cursor_residue() { + Some((chain, residue)) => { + let letter = one_letter(&residue.name, chain.molecule_type); + let insertion = residue.insertion_code.as_deref().unwrap_or(""); + spans.push(Span::styled( + format!("{} ", chain.id), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled( + format!("{}{} ", residue.seq_num, insertion), + Style::default().fg(Color::White), + )); + spans.push(Span::styled( + format!("{} ({letter}) ", residue.name), + Style::default().fg(Color::Gray), + )); + if chain.molecule_type == MoleculeType::Protein { + spans.push(Span::styled( + format!("{} ", secondary_label(residue.secondary_structure)), + Style::default().fg(Color::DarkGray), + )); + } + if app.selection.contains(app.seq_cursor.0, app.seq_cursor.1) { + spans.push(Span::styled( + "[selected] ", + Style::default().fg(selection_color()), + )); + } + } + None => spans.push(Span::styled( + "no residues ", + Style::default().fg(Color::DarkGray), + )), + } + + spans.push(Span::styled("│ ", Style::default().fg(Color::DarkGray))); + if app.selection.is_empty() { + spans.push(Span::styled( + "nothing selected ", + Style::default().fg(Color::DarkGray), + )); + } else { + spans.push(Span::styled( + format!( + "{} res in {} chain{} ", + app.selection.count(), + app.selection.chain_count(), + if app.selection.chain_count() == 1 { + "" + } else { + "s" + } + ), + Style::default().fg(selection_color()), + )); + spans.push(Span::styled("│ ", Style::default().fg(Color::DarkGray))); + spans.push(Span::styled( + format!("{} ", app.selection.describe(&app.protein, 4)), + Style::default().fg(Color::White), + )); + spans.push(Span::styled("│ ", Style::default().fg(Color::DarkGray))); + spans.push(Span::styled( + if app.show_ball_stick { + "ball&stick on " + } else { + "ball&stick off " + }, + Style::default().fg(if app.show_ball_stick { + Color::Rgb(255, 170, 0) + } else { + Color::DarkGray + }), + )); + } + + Line::from(spans) +} + +fn secondary_label(secondary: SecondaryStructure) -> &'static str { + match secondary { + SecondaryStructure::Helix => "helix", + SecondaryStructure::Sheet => "sheet", + SecondaryStructure::Turn => "turn", + SecondaryStructure::Coil => "coil", + } +} + +/// `> Lb Protein 138 res 1-138` +fn header_line(app: &App, chain_index: usize) -> Line<'static> { + let Some(chain) = app.protein.chains.get(chain_index) else { + return Line::from(""); + }; + let range = match (chain.residues.first(), chain.residues.last()) { + (Some(first), Some(last)) => format!("{}-{}", first.seq_num, last.seq_num), + _ => "empty".to_string(), + }; + let marker_style = if chain_index == app.current_chain { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + let mut spans = vec![ + Span::styled( + if chain_index == app.current_chain { + "▸ " + } else { + " " + }, + marker_style, + ), + Span::styled( + chain.id.clone(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!( + " {} {} res {range}", + molecule_label(chain.molecule_type), + chain.residues.len() + ), + Style::default().fg(Color::DarkGray), + ), + ]; + if app.selection.chain_has_any(chain_index) { + spans.push(Span::styled(" ●", Style::default().fg(selection_color()))); + } + Line::from(spans) +} + +/// One wrapped run of residues, with its starting residue number in the gutter. +fn residue_line(app: &App, chain_index: usize, start: usize, len: usize) -> Line<'static> { + let Some(chain) = app.protein.chains.get(chain_index) else { + return Line::from(""); + }; + + let number = chain + .residues + .get(start) + .map(|residue| residue.seq_num.to_string()) + .unwrap_or_default(); + let mut spans = vec![Span::styled( + format!("{number:>width$} ", width = GUTTER as usize - 1), + Style::default().fg(Color::DarkGray), + )]; + + // Consecutive residues that share a style are emitted as one span: a + // 200-column row of one-letter codes would otherwise cost 200 spans a + // frame, all with identical styling. + let mut run = String::new(); + let mut run_style: Option