From 5723db18e39ecd7901cb15277d1b777864ef0a97 Mon Sep 17 00:00:00 2001 From: kevinepope Date: Fri, 16 Oct 2026 17:47:12 -0700 Subject: [PATCH 1/2] Add Bevel shading: raised tiles with a light top-left and dark bottom-right rim A third map style beside Cushion and Flat. Each file is an even fill with a two-pixel rim lightened along the top and left edges and darkened along the bottom and right, so it reads as lit from the upper left like the cushions but with hard edges. Small rectangles get a one-pixel rim, under 3 px none. The toolbar's Cushions checkbox becomes a Cushion / Flat / Bevel switch and View > Cushion Shading becomes a View > Shading submenu with radio items on both the in-window and macOS menu bars. Settings store the style as a `shading` key and keep writing `cushions` so 0.1.1 still reads the file. Pixel-invariant tests cover the rims and the thin-rim fallback; menu, toolbar and settings tests follow the new switch. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 5 ++ README.md | 3 +- docs/PLAN.md | 6 +- hdtreemap-core/src/render/mod.rs | 7 +- hdtreemap-core/src/render/raster.rs | 96 ++++++++++++++++++++++++++-- hdtreemap-core/src/render/tests.rs | 82 +++++++++++++++++++++++- hdtreemap-core/src/settings.rs | 49 ++++++++++++-- hdtreemap-core/src/state/view.rs | 2 +- hdtreemap-core/src/ui/commands.rs | 21 +++--- hdtreemap-core/src/ui/main_view.rs | 29 +++++---- hdtreemap-core/src/ui/menus.rs | 20 ++++-- hdtreemap-core/tests/menus.rs | 19 ++++-- hdtreemap-core/tests/treemap_view.rs | 13 +++- hdtreemap-native/src/menu_bar.rs | 18 ++++-- 14 files changed, 310 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eca4d0..bbcf1d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to HDTreeMap are documented here. ## Unreleased ### Added +- A third shading style, **Bevel**: each file is a raised tile with an even fill, a light + rim along its top and left edges and a dark rim along the bottom and right, lit from the + upper left like the cushions but with hard edges. The toolbar's Cushions checkbox is now + a Cushion / Flat / Bevel switch, and View ▸ Cushion Shading is a View ▸ Shading submenu. + Settings store the style as `shading` (keeping `cushions` so 0.1.1 still reads them). - `run-windows.cmd`: the Windows counterpart of `run-mac.command`, a double-click launcher that builds a release binary and starts the app, passing its arguments through (`[PATH | --demo ID]`). diff --git a/README.md b/README.md index 8483d49..ea550ca 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ server). It never reads anything on your computer. - **Correct totals.** Stays on one filesystem, doesn't follow symlinks, and counts hard links (and, on macOS, APFS clones) once. Unreadable folders are counted, not fatal. "Size on disk" or apparent size, switchable without rescanning. -- **Squarified cushion treemap**, coloured by file type or by top-level folder; folders +- **Squarified cushion treemap** (or flat blocks, or bevelled tiles), coloured by file type + or by top-level folder; folders too small to open and leftover small files show as grey blocks, so all space is shown. - **Three linked views.** A folder tree beside the map, sorted by size with each folder's share, and a legend of file types with sizes, shares and file counts. Select or hover in diff --git a/docs/PLAN.md b/docs/PLAN.md index 94f2460..db2b405 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -57,7 +57,7 @@ Algorithms: - **Move to Trash…** (confirmed first; uses the `trash` crate, never a permanent delete). The node's size is then subtracted from the tree and the map re-laid out. - Menu bar (native on macOS through agg-gui-shell): - File: Open Folder, Rescan, Quit - - View: Size Mode, Cushion Shading on/off, Colour By (Extension / Top Folder), Zoom Out + - View: Size Mode, Shading (Cushion / Flat / Bevel), Colour By (Extension / Top Folder), Zoom Out - Help: About **Web demo (wasm, GitHub Pages):** the full app running in the browser against a **simulated drive**: a deterministic, realistic synthetic file tree (OS folders, apps, photos, videos, `node_modules`, caches…) generated by `hdtreemap-core::demo_fs`. The "scan" streams that tree at a believable pace, so the progress screen and the live map during the scan are shown too. Every menu, view and action works, with platform actions simulated: Move to Trash removes the node from the model, and Reveal/Open show a toast saying it's a demo. A banner makes clear it is a demo. The start screen offers several descriptive simulated drives, each with its own tree profile: @@ -190,7 +190,9 @@ Scanning is behind a `ScanSource` abstraction: `FsScanner` (native only, `cfg(no - Performance target: under 10 ms per frame when nothing has changed (agg-gui rule), and under about 150 ms to re-rasterise a 4K map with a million files. Both are measured with a stats env var, following the `CRITTERS_STATS` pattern. - *As built (M2):* `I = Ia + max(0, Is·n·L)` with `Ia = 0.15`, `Is = 1 − Ia`, `L` ∝ (−1, −1, 10), times a 1.2 brightness gain, clamped. Flat mode darkens each rectangle's right and bottom - pixel line. Rows go in 16-row bands (rayon natively, serial on wasm). `HDTREEMAP_STATS=1` + pixel line. Bevel mode fills like flat and adds a two-pixel rim (one pixel on small + rectangles, none under 3 px): the top and left moved 45% towards white, the bottom and + right at half brightness. Rows go in 16-row bands (rayon natively, serial on wasm). `HDTREEMAP_STATS=1` prints each layout and raster pass and, once a second, the CPU layout+paint time per frame; `examples/bench_treemap` times them headless. Measured on a loaded 10-core Apple Silicon machine, archive-server demo (1.56 M files) at 3840×2160: layout 45–90 ms, raster diff --git a/hdtreemap-core/src/render/mod.rs b/hdtreemap-core/src/render/mod.rs index fe3d5f0..00f8723 100644 --- a/hdtreemap-core/src/render/mod.rs +++ b/hdtreemap-core/src/render/mod.rs @@ -1,7 +1,8 @@ //! Treemap rendering (`docs/PLAN.md` §3.4): colours and the per-pixel cushion rasteriser. //! //! - [`palette`] — a hue per top extension (or per top-level folder), greys for the rest. -//! - [`raster`] — paints a `layout::TreemapLayout` into an RGBA buffer, cushioned or flat, +//! - [`raster`] — paints a `layout::TreemapLayout` into an RGBA buffer, cushioned, flat or +//! bevelled, //! in parallel row bands natively. //! //! The `HDTREEMAP_STATS` environment variable ([`stats_enabled`]) makes the treemap view @@ -12,7 +13,9 @@ pub mod palette; pub mod raster; pub use palette::{ColorBy, Palette, Rgb, FOLDER_BLOCK, HUES, OTHER_FILES}; -pub use raster::{cushion_intensity, rasterise, Shading, AMBIENT, BACKGROUND, BRIGHTNESS, LIGHT}; +pub use raster::{ + cushion_intensity, rasterise, Shading, AMBIENT, BACKGROUND, BEVEL_WIDTH, BRIGHTNESS, LIGHT, +}; /// Where stats lines go when stats are on: stderr natively, or a sink a shell installed /// ([`enable_stats`]; the web shell logs to the browser console). diff --git a/hdtreemap-core/src/render/raster.rs b/hdtreemap-core/src/render/raster.rs index 170a620..f26358a 100644 --- a/hdtreemap-core/src/render/raster.rs +++ b/hdtreemap-core/src/render/raster.rs @@ -9,7 +9,10 @@ //! QDirStat's cushion renderers; the rectangle's base colour is scaled by //! `I · BRIGHTNESS`. With //! [`Shading::Flat`] rectangles are filled evenly and their right and bottom pixel lines are -//! darkened so neighbours stay apart. +//! darkened so neighbours stay apart. With [`Shading::Bevel`] each rectangle is a raised +//! tile: an even fill with a rim of [`BEVEL_WIDTH`] pixels lightened along the top and left +//! edges and darkened along the bottom and right, as if lit from the upper left like the +//! cushions but with hard edges; small rectangles get a one-pixel rim or none. //! //! The rows are split into bands of [`BAND_ROWS`]; each band paints only the rectangles that //! reach into it ([`crate::layout::bins::Bins`]). Natively the bands run in parallel on @@ -34,6 +37,35 @@ pub enum Shading { Cushion, /// Even fills with a darker line on each rectangle's right and bottom edge. Flat, + /// Raised tiles: even fills with a light top-left rim and a dark bottom-right rim. + Bevel, +} + +impl Shading { + pub const ALL: [Shading; 3] = [Shading::Cushion, Shading::Flat, Shading::Bevel]; + + /// The settings key. + pub fn key(self) -> &'static str { + match self { + Shading::Cushion => "cushion", + Shading::Flat => "flat", + Shading::Bevel => "bevel", + } + } + + /// The shading with settings key `key`. + pub fn from_key(key: &str) -> Option { + Self::ALL.into_iter().find(|s| s.key() == key) + } + + /// The menu and toolbar label. + pub fn label(self) -> &'static str { + match self { + Shading::Cushion => "Cushion", + Shading::Flat => "Flat", + Shading::Bevel => "Bevel", + } + } } /// Ambient light `Ia`; the diffuse share is `Is = 1 - Ia`. @@ -52,11 +84,21 @@ pub const LIGHT: [f32; 3] = [-1.0 / LIGHT_LEN, -1.0 / LIGHT_LEN, 10.0 / LIGHT_LE const LIGHT_LEN: f32 = 10.099_505; /// Intensity of an evenly lit (flat) rectangle. -const FLAT_INTENSITY: f32 = 0.92; +pub(super) const FLAT_INTENSITY: f32 = 0.92; /// Edge lines of flat rectangles are this much darker. const FLAT_EDGE: f32 = 0.6; /// Flat rectangles narrower than this get no edge line (it would swallow them). -const FLAT_EDGE_MIN: u32 = 3; +pub(super) const FLAT_EDGE_MIN: u32 = 3; + +/// Width in pixels of a bevelled rectangle's rim on each side. +pub const BEVEL_WIDTH: u32 = 2; +/// Bevelled rectangles narrower than this on either side get a one-pixel rim instead, and +/// narrower than [`FLAT_EDGE_MIN`] none at all. +const BEVEL_WIDE_MIN: u32 = 4 * BEVEL_WIDTH; +/// How far the top and left rim moves the fill colour towards white. +const BEVEL_LIGHT: f32 = 0.45; +/// The bottom and right rim is this much darker than the fill. +const BEVEL_DARK: f32 = 0.5; /// The default colour of pixels no rectangle covers (an empty zoom root); a palette made /// for a theme carries its own ([`Palette::background`]). @@ -119,6 +161,7 @@ pub fn rasterise( match shading { Shading::Cushion => paint_cushion(target, rect, *color), Shading::Flat => paint_flat(target, rect, *color), + Shading::Bevel => paint_bevel(target, rect, *color), } } }; @@ -162,7 +205,7 @@ impl Band<'_> { } } -fn to_byte(v: f32) -> u8 { +pub(super) fn to_byte(v: f32) -> u8 { (v * 255.0 + 0.5).clamp(0.0, 255.0) as u8 } @@ -215,3 +258,48 @@ fn paint_flat(mut band: Band<'_>, rect: &LayoutRect, color: Rgb) { } } } + +/// The rim width of a bevelled rectangle `w × h` pixels across: full, one pixel, or none. +fn bevel_rim(w: u32, h: u32) -> u32 { + let side = w.min(h); + if side >= BEVEL_WIDE_MIN { + BEVEL_WIDTH + } else { + u32::from(side >= FLAT_EDGE_MIN) + } +} + +fn paint_bevel(mut band: Band<'_>, rect: &LayoutRect, color: Rgb) { + let Some((x0, x1, y0, y1)) = band.span(rect) else { + return; + }; + let (full_x0, full_x1) = rect.rect.pixel_cols(band.width); + let (full_y0, full_y1) = rect.rect.pixel_rows(band.height); + let rim = bevel_rim(full_x1 - full_x0, full_y1 - full_y0); + let fill = color.map(|c| to_byte(c * FLAT_INTENSITY)); + let light = + color.map(|c| to_byte(c * FLAT_INTENSITY + (1.0 - c * FLAT_INTENSITY) * BEVEL_LIGHT)); + let dark = color.map(|c| to_byte(c * FLAT_INTENSITY * BEVEL_DARK)); + for y in y0..y1 { + // Distance of this row into the rectangle from its top and from its bottom. + let (from_top, from_bottom) = (y - full_y0, full_y1 - 1 - y); + let row = band.row(y, x0, x1); + for (i, px) in row.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let x = x0 + i as u32; + let (from_left, from_right) = (x - full_x0, full_x1 - 1 - x); + // The lit rim owns the top and left bands; along the diagonal where it meets the + // shaded rim, the pixel goes to whichever edge it is nearer, top-left winning ties. + let lit = from_top.min(from_left); + let shaded = from_bottom.min(from_right); + let rgb = if lit < rim && lit <= shaded { + light + } else if shaded < rim { + dark + } else { + fill + }; + px[..3].copy_from_slice(&rgb); + px[3] = 255; + } + } +} diff --git a/hdtreemap-core/src/render/tests.rs b/hdtreemap-core/src/render/tests.rs index 1efe494..c2ce20c 100644 --- a/hdtreemap-core/src/render/tests.rs +++ b/hdtreemap-core/src/render/tests.rs @@ -1,8 +1,10 @@ //! Golden-invariant tests for the rasteriser on a small fixed tree at 64×48: every //! rectangle shows its palette colour, cushions are brighter toward the upper left, flat mode -//! fills evenly with darker edges, and the palette's hue assignment. Pixel invariants rather -//! than a stored image, so harmless tuning does not break them. +//! fills evenly with darker edges, bevel mode rims each tile light and dark, and the +//! palette's hue assignment. Pixel invariants rather than a stored image, so harmless +//! tuning does not break them. +use super::raster::{to_byte, FLAT_EDGE_MIN, FLAT_INTENSITY}; use super::*; use crate::layout::{layout, LayoutParams, LayoutRect, PxRect, RectKind, TreemapLayout}; use crate::model::{ExtTable, NodeFlags, SizeMode, Sizes, Tree, TreeBuilder}; @@ -140,6 +142,82 @@ fn flat_mode_fills_evenly_with_darker_edges() { } } +#[test] +fn bevel_mode_has_a_light_top_left_rim_and_a_dark_bottom_right_rim() { + let (tree, palette, map) = fixture(); + let buf = rasterise(&map, &tree, &palette, Shading::Bevel, W, H); + let flat = rasterise(&map, &tree, &palette, Shading::Flat, W, H); + let mut checked = 0; + for r in painted(&map) { + let (x0, x1) = r.rect.pixel_cols(W); + let (y0, y1) = r.rect.pixel_rows(H); + if x1 - x0 < 4 * BEVEL_WIDTH + 2 || y1 - y0 < 4 * BEVEL_WIDTH + 2 { + continue; + } + checked += 1; + let (cx, cy) = ((x0 + x1) / 2, (y0 + y1) / 2); + let centre = pixel(&buf, cx, cy); + assert_eq!(centre, pixel(&flat, cx, cy), "the fill matches flat mode"); + assert_eq!(centre, pixel(&buf, cx + 1, cy + 1), "the fill is even"); + let color = palette.rect_color(&tree, r); + // The rim is BEVEL_WIDTH deep: the pixel just inside it is fill. + for d in 0..BEVEL_WIDTH { + let top = pixel(&buf, cx, y0 + d); + let left = pixel(&buf, x0 + d, cy); + let bottom = pixel(&buf, cx, y1 - 1 - d); + let right = pixel(&buf, x1 - 1 - d, cy); + assert!(luma(top) > luma(centre), "top rim is lighter"); + assert_eq!(top, left, "top and left rims match"); + assert!(luma(bottom) < luma(centre), "bottom rim is darker"); + assert_eq!(bottom, right, "bottom and right rims match"); + // Lightening moves every channel towards white, so a dark channel still rises. + for c in 0..3 { + assert!( + top[c] >= centre[c], + "channel {c} of {color:?} is not darker" + ); + } + } + assert_eq!(pixel(&buf, cx, y0 + BEVEL_WIDTH), centre); + assert_eq!(pixel(&buf, cx, y1 - 1 - BEVEL_WIDTH), centre); + // The top-left corner belongs to the light rim, the bottom-right to the dark one. + assert_eq!(pixel(&buf, x0, y0), pixel(&buf, cx, y0)); + assert_eq!(pixel(&buf, x1 - 1, y1 - 1), pixel(&buf, cx, y1 - 1)); + } + assert!(checked >= 2, "fixture has {checked} bevelled rectangles"); +} + +#[test] +fn tiny_bevelled_rectangles_get_a_thin_rim_or_none() { + let (tree, palette, _) = fixture(); + // Squeezed to a strip, so every rectangle is a few pixels tall at most. + let bounds = PxRect::new(0.0, 0.0, f64::from(W), 5.0); + let strip = layout(&tree, tree.root(), bounds, &LayoutParams::default()); + let buf = rasterise(&strip, &tree, &palette, Shading::Bevel, W, 5); + for r in strip.rects().iter().filter(|r| r.kind.is_painted()) { + let (x0, x1) = r.rect.pixel_cols(W); + let (y0, y1) = r.rect.pixel_rows(5); + if x0 >= x1 || y0 >= y1 { + continue; + } + let colour = palette + .rect_color(&tree, r) + .map(|c| to_byte(c * FLAT_INTENSITY)); + let (w, h) = (x1 - x0, y1 - y0); + let top_left = pixel(&buf, x0, y0); + if w.min(h) < FLAT_EDGE_MIN { + // Too small for a rim: plain fill. + assert_eq!(&top_left[..3], &colour[..]); + } else { + // A one-pixel rim: the next pixel in is already fill. + assert!(luma(top_left) > luma([colour[0], colour[1], colour[2], 255])); + if w >= 3 && h >= 3 { + assert_eq!(&pixel(&buf, x0 + 1, y0 + 1)[..3], &colour[..]); + } + } + } +} + #[test] fn folders_and_aggregates_are_dark_grey() { let mut b = TreeBuilder::new("/r"); diff --git a/hdtreemap-core/src/settings.rs b/hdtreemap-core/src/settings.rs index b989407..f27a9fb 100644 --- a/hdtreemap-core/src/settings.rs +++ b/hdtreemap-core/src/settings.rs @@ -1,4 +1,4 @@ -//! Remembered settings: the size mode, cushion shading, colour scheme, the theme (light, +//! Remembered settings: the size mode, shading style, colour scheme, the theme (light, //! dark or match the system; light when missing, as in blobs from before it existed), the //! two splitter positions and the recently scanned folders and drives, as one small JSON blob. //! @@ -89,6 +89,8 @@ impl Default for Settings { #[serde(default)] struct Blob { size_mode: Option, + /// `cushion`, `flat` or `bevel`; files from before 0.1.2 have only `cushions`. + shading: Option, cushions: Option, color_by: Option, theme: Option, @@ -122,10 +124,14 @@ impl Settings { Some("allocated") => SizeMode::Allocated, _ => defaults.size_mode, }, - shading: match blob.cushions { - Some(true) => Shading::Cushion, - Some(false) => Shading::Flat, - None => defaults.shading, + shading: match ( + blob.shading.as_deref().and_then(Shading::from_key), + blob.cushions, + ) { + (Some(shading), _) => shading, + (None, Some(true)) => Shading::Cushion, + (None, Some(false)) => Shading::Flat, + (None, None) => defaults.shading, }, color_by: match blob.color_by.as_deref() { Some("folder") => ColorBy::TopFolder, @@ -153,6 +159,8 @@ impl Settings { } .to_string(), ), + shading: Some(self.shading.key().to_string()), + // Kept for 0.1.1 and earlier, which know only cushions on or off. cushions: Some(self.shading == Shading::Cushion), color_by: Some( match self.color_by { @@ -199,7 +207,7 @@ mod tests { fn settings_round_trip_through_json() { let mut s = Settings { size_mode: SizeMode::Apparent, - shading: Shading::Flat, + shading: Shading::Bevel, color_by: ColorBy::TopFolder, theme: Theme::Dark, side_split: 0.333_333, @@ -214,7 +222,7 @@ mod tests { s.remember(folder("/Users/me/Code")); let back = Settings::from_json(&s.to_json()); assert_eq!(back.size_mode, SizeMode::Apparent); - assert_eq!(back.shading, Shading::Flat); + assert_eq!(back.shading, Shading::Bevel); assert_eq!(back.color_by, ColorBy::TopFolder); assert_eq!(back.theme, Theme::Dark); assert!((back.side_split - 0.333).abs() < 1e-9); @@ -241,6 +249,33 @@ mod tests { assert_eq!(s.recent, vec![folder("/a")]); } + #[test] + fn the_shading_key_wins_over_the_old_cushions_flag() { + // A file from 0.1.1 has only the flag. + assert_eq!( + Settings::from_json(r#"{"cushions": false}"#).shading, + Shading::Flat + ); + // A newer file carries both, written for either version to read. + assert_eq!( + Settings::from_json(r#"{"shading": "bevel", "cushions": false}"#).shading, + Shading::Bevel + ); + assert_eq!( + Settings::from_json(r#"{"shading": "velvet", "cushions": true}"#).shading, + Shading::Cushion + ); + for shading in Shading::ALL { + let s = Settings { + shading, + ..Settings::default() + }; + let json = s.to_json(); + assert!(json.contains(&format!("\"shading\": \"{}\"", shading.key()))); + assert_eq!(Settings::from_json(&json).shading, shading); + } + } + #[test] fn the_theme_is_light_unless_a_known_one_is_stored() { assert_eq!(Settings::default().theme, Theme::Light); diff --git a/hdtreemap-core/src/state/view.rs b/hdtreemap-core/src/state/view.rs index 568fa6e..4db290d 100644 --- a/hdtreemap-core/src/state/view.rs +++ b/hdtreemap-core/src/state/view.rs @@ -272,7 +272,7 @@ impl AppState { self.view().shading.get() } - /// Cushion or flat shading (kept across scans). + /// Cushion, flat or bevel shading (kept across scans). pub fn set_shading(&self, shading: Shading) { if self.shading() != shading { self.view().shading.set(shading); diff --git a/hdtreemap-core/src/ui/commands.rs b/hdtreemap-core/src/ui/commands.rs index 486f3c2..a193c0c 100644 --- a/hdtreemap-core/src/ui/commands.rs +++ b/hdtreemap-core/src/ui/commands.rs @@ -39,8 +39,8 @@ pub enum Command { Trash, /// View ▸ Size Mode. SizeMode(SizeMode), - /// View ▸ Cushion Shading. - ToggleCushions, + /// View ▸ Shading ▸ Cushion, Flat or Bevel. + Shading(Shading), /// View ▸ Colour By. ColorBy(ColorBy), /// View ▸ Zoom In: to the selected folder (or the selected file's folder). @@ -72,7 +72,7 @@ impl Command { Command::Trash => "edit.trash".into(), Command::SizeMode(SizeMode::Allocated) => "view.size.allocated".into(), Command::SizeMode(SizeMode::Apparent) => "view.size.apparent".into(), - Command::ToggleCushions => "view.cushions".into(), + Command::Shading(shading) => format!("view.shading.{}", shading.key()), Command::ColorBy(ColorBy::Extension) => "view.color.type".into(), Command::ColorBy(ColorBy::TopFolder) => "view.color.folder".into(), Command::ZoomIn => "view.zoom-in".into(), @@ -93,6 +93,9 @@ impl Command { if let Some(key) = id.strip_prefix("view.theme.") { return Theme::from_key(key).map(Command::Theme); } + if let Some(key) = id.strip_prefix("view.shading.") { + return Shading::from_key(key).map(Command::Shading); + } [ Command::OpenFolder, Command::Rescan, @@ -102,7 +105,6 @@ impl Command { Command::Trash, Command::SizeMode(SizeMode::Allocated), Command::SizeMode(SizeMode::Apparent), - Command::ToggleCushions, Command::ColorBy(ColorBy::Extension), Command::ColorBy(ColorBy::TopFolder), Command::ZoomIn, @@ -200,7 +202,7 @@ impl Commands { Command::Trash => self .selection() .is_some_and(|n| self.nodes.enabled(n, NodeAction::Trash)), - Command::SizeMode(_) | Command::ToggleCushions | Command::ColorBy(_) => self.has_map(), + Command::SizeMode(_) | Command::Shading(_) | Command::ColorBy(_) => self.has_map(), Command::ZoomIn => self.zoom_in_target().is_some(), Command::ZoomOut | Command::ZoomRoot => self.zoomed_in(), Command::ClearHighlight => self.has_map() && self.state.highlight_ext().is_some(), @@ -209,11 +211,11 @@ impl Commands { } /// The check or radio mark of `command`: `Some(on)` for the size modes, the colour - /// schemes, the themes and cushion shading, `None` for plain commands. + /// schemes, the shadings and the themes, `None` for plain commands. pub fn checked(&self, command: Command) -> Option { match command { Command::SizeMode(mode) => Some(self.state.size_mode() == mode), - Command::ToggleCushions => Some(self.state.shading() == Shading::Cushion), + Command::Shading(shading) => Some(self.state.shading() == shading), Command::ColorBy(by) => Some(self.state.color_by() == by), Command::Theme(theme) => Some(self.state.theme() == theme), _ => None, @@ -248,10 +250,7 @@ impl Commands { Command::Trash => self.on_selection(NodeAction::Trash), Command::SizeMode(mode) => state.set_size_mode(mode), Command::ColorBy(by) => state.set_color_by(by), - Command::ToggleCushions => state.set_shading(match state.shading() { - Shading::Cushion => Shading::Flat, - Shading::Flat => Shading::Cushion, - }), + Command::Shading(shading) => state.set_shading(shading), Command::ZoomIn => { if let Some(folder) = self.zoom_in_target() { state.set_zoom_root(folder); diff --git a/hdtreemap-core/src/ui/main_view.rs b/hdtreemap-core/src/ui/main_view.rs index c479b32..43eb695 100644 --- a/hdtreemap-core/src/ui/main_view.rs +++ b/hdtreemap-core/src/ui/main_view.rs @@ -1,5 +1,5 @@ //! The main results view: a toolbar (back to the drive list, the zoom breadcrumb, size mode, -//! cushion shading, Rescan, the light / dark button), the three linked views in the middle, and a status bar +//! shading, Rescan, the light / dark button), the three linked views in the middle, and a status bar //! (hovered or selected item, highlighted extension, totals, unreadable items, free space). //! //! It is shown from the moment a scan starts: while the scan runs, the [`scan_strip`] @@ -27,8 +27,8 @@ use std::rc::Rc; use std::sync::Arc; use agg_gui::{ - current_visuals, Button, Checkbox, Container, EllipsisMode, FlexColumn, FlexRow, HAnchor, - Insets, Label, Rebuilder, SegmentedControl, Splitter, VAnchor, Widget, + current_visuals, Button, Container, EllipsisMode, FlexColumn, FlexRow, HAnchor, Insets, Label, + Rebuilder, SegmentedControl, Splitter, VAnchor, Widget, }; use super::breadcrumb; @@ -58,7 +58,7 @@ pub const RESCAN_ID: &str = "main-rescan"; pub const SIZE_MODE_ID: &str = "main-size-mode"; /// Widget id of the colour-scheme switch (file type / top-level folder). pub const COLOR_BY_ID: &str = "main-color-by"; -/// Widget id of the cushion-shading checkbox. +/// Widget id of the shading switch (cushion / flat / bevel). pub const SHADING_ID: &str = "main-shading"; /// Widget id of the status bar's hovered / selected item line. pub const STATUS_ITEM_ID: &str = "status-item"; @@ -217,7 +217,7 @@ fn toolbar( ) } -/// The size-mode and colour-scheme switches and the cushion checkbox, showing the state as +/// The size-mode, colour-scheme and shading switches, showing the state as /// it is now. fn view_toggles(state: &AppState, fonts: &Fonts) -> Box { let modes = [SizeMode::Allocated, SizeMode::Apparent]; @@ -248,15 +248,22 @@ fn view_toggles(state: &AppState, fonts: &Fonts) -> Box { .with_v_anchor(VAnchor::CENTER) .with_name(COLOR_BY_ID); + let shadings = Shading::ALL; + let shading_index = Rc::new(Cell::new( + shadings + .iter() + .position(|&s| s == state.shading()) + .unwrap_or(0), + )); let shading_state = state.clone(); - let shading = Checkbox::new( - "Cushions", + let shading = SegmentedControl::new( + shadings.iter().map(|s| s.label()).collect(), + shading_index, Arc::clone(&fonts.ui), - state.shading() == Shading::Cushion, ) - .on_change(move |on| { - shading_state.set_shading(if on { Shading::Cushion } else { Shading::Flat }) - }) + .with_fit_width(true) + .on_change(move |i| shading_state.set_shading(shadings[i.min(shadings.len() - 1)])) + .with_tooltip("Shade the map as cushions, flat blocks or bevelled tiles") .with_v_anchor(VAnchor::CENTER) .with_name(SHADING_ID); Box::new( diff --git a/hdtreemap-core/src/ui/menus.rs b/hdtreemap-core/src/ui/menus.rs index 5f27922..47d42cc 100644 --- a/hdtreemap-core/src/ui/menus.rs +++ b/hdtreemap-core/src/ui/menus.rs @@ -22,7 +22,7 @@ use super::commands::{Command, Commands}; use super::node_actions::{label, NodeAction, NodeActions}; use crate::fonts::{fa, Fonts}; use crate::model::{NodeId, SizeMode}; -use crate::render::ColorBy; +use crate::render::{ColorBy, Shading}; use crate::theme::Theme; /// Widget id of the in-window menu bar. @@ -203,7 +203,13 @@ pub fn menu_bar_spec(commands: &Commands, native_mac: bool) -> Vec .map(|by| item(by.label(), Command::ColorBy(by), None, None)) .collect(), }, - item("Cushion Shading", Command::ToggleCushions, None, None), + MenuSpec::Submenu { + label: "Shading".into(), + items: Shading::ALL + .into_iter() + .map(|shading| item(shading.label(), Command::Shading(shading), None, None)) + .collect(), + }, MenuSpec::Separator, item( "Zoom In", @@ -290,9 +296,13 @@ pub fn entries(commands: &Commands, items: &[MenuSpec]) -> Vec { row = row.shortcut(shortcut); } row = match (spec.command, commands.checked(spec.command)) { - (Command::SizeMode(_) | Command::ColorBy(_) | Command::Theme(_), Some(on)) => { - row.radio(on) - } + ( + Command::SizeMode(_) + | Command::ColorBy(_) + | Command::Shading(_) + | Command::Theme(_), + Some(on), + ) => row.radio(on), (_, Some(on)) => row.checked(on), (_, None) => row, }; diff --git a/hdtreemap-core/tests/menus.rs b/hdtreemap-core/tests/menus.rs index 49205c0..90b2b44 100644 --- a/hdtreemap-core/tests/menus.rs +++ b/hdtreemap-core/tests/menus.rs @@ -89,7 +89,7 @@ fn the_bar_lists_the_menus_and_their_items() { vec![ "Size Mode >", "Colour By >", - "Cushion Shading", + "Shading >", "-", "Zoom In", "Zoom Out", @@ -136,9 +136,20 @@ fn menu_commands_run_and_report_their_state() { Some(true) ); - assert_eq!(commands.checked(Command::ToggleCushions), Some(true)); - commands.run(Command::ToggleCushions); - assert_eq!(state.shading(), Shading::Flat); + assert_eq!( + commands.checked(Command::Shading(Shading::Cushion)), + Some(true) + ); + commands.run(Command::Shading(Shading::Bevel)); + assert_eq!(state.shading(), Shading::Bevel); + assert_eq!( + commands.checked(Command::Shading(Shading::Cushion)), + Some(false) + ); + assert_eq!( + commands.checked(Command::Shading(Shading::Bevel)), + Some(true) + ); // Zoom In needs a selection below the zoom root. assert!(!commands.enabled(Command::ZoomIn)); diff --git a/hdtreemap-core/tests/treemap_view.rs b/hdtreemap-core/tests/treemap_view.rs index a4791fd..e00bb97 100644 --- a/hdtreemap-core/tests/treemap_view.rs +++ b/hdtreemap-core/tests/treemap_view.rs @@ -206,16 +206,23 @@ fn the_size_mode_switch_relays_the_map_out() { } #[test] -fn the_cushion_toggle_rerasterises_without_relayout() { +fn the_shading_switch_rerasterises_without_relayout() { let mut h = usb_map(); assert_eq!(h.state.shading(), Shading::Cushion); let before = h.state.map_counters(); - h.click(SHADING_ID); + // Three segments: Cushion, Flat, Bevel. + let (x, y) = h.point_in(SHADING_ID, 0.5, 0.5); + h.click_at(x, y); assert_eq!(h.state.shading(), Shading::Flat); let after = h.state.map_counters(); assert_eq!(after.layouts, before.layouts); assert_eq!(after.rasters, before.rasters + 1); - h.click(SHADING_ID); + let (x, y) = h.point_in(SHADING_ID, 0.85, 0.5); + h.click_at(x, y); + assert_eq!(h.state.shading(), Shading::Bevel); + assert_eq!(h.state.map_counters().rasters, before.rasters + 2); + let (x, y) = h.point_in(SHADING_ID, 0.15, 0.5); + h.click_at(x, y); assert_eq!(h.state.shading(), Shading::Cushion); } diff --git a/hdtreemap-native/src/menu_bar.rs b/hdtreemap-native/src/menu_bar.rs index be4a7da..f2478c6 100644 --- a/hdtreemap-native/src/menu_bar.rs +++ b/hdtreemap-native/src/menu_bar.rs @@ -120,14 +120,18 @@ mod tests { action(); } assert!(state.quit_requested()); - // View's Cushion Shading carries a check mark; on the start screen it is disabled. + // View ▸ Shading's rows carry radio marks; on the start screen they are disabled. let view = native::children_of(&model.menus[3]); - let cushions = view - .iter() - .find(|i| i.text == "Cushion Shading") - .expect("item"); - assert!(cushions.is_checked.is_some()); - assert!(!native::is_enabled(cushions)); + let shading = view.iter().find(|i| i.text == "Shading").expect("submenu"); + let rows = native::children_of(shading); + assert_eq!( + rows.iter().map(|i| i.text.as_str()).collect::>(), + vec!["Cushion", "Flat", "Bevel"] + ); + for row in &rows { + assert!(row.is_checked.is_some()); + assert!(!native::is_enabled(row)); + } } #[test] From 87ce7822fc91a2c89bec1cfe01efe452d0112f8e Mon Sep 17 00:00:00 2001 From: kevinepope Date: Fri, 16 Oct 2026 18:04:50 -0700 Subject: [PATCH 2/2] Add City shading: the map as a cityscape with buildings rising by size A fourth style beside Cushion, Flat and Bevel. Every painted rectangle is a building: its footprint is the rectangle less a one-pixel street on the right and bottom, widened by 1.5 px per enclosing folder that ends on that edge, so folders read as city blocks between avenues; its height is the cube root of its size relative to the biggest item in view, up to a tenth of the raster's shorter side (8-56 px), and flat under 2 px. Buildings are drawn in an oblique projection (roof moved up one pixel and left half a pixel per pixel of height, right wall at 0.7, bottom wall at 0.5 brightness) with a per-band depth buffer, so the parallel bands stay independent of paint order. Only the pixels are 3D: hit testing, hover and the outlines still use the footprint. The toolbar switch and View > Shading gain a City entry; settings store it as "cityscape". Tests: a one-file raster checks the roof offset, both walls and the street; the fixture checks heights by cube root, wider streets on folder edges, visible ground and that nothing is lit beyond a roof. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 6 + CLAUDE.md | 5 +- README.md | 4 +- docs/PLAN.md | 12 +- hdtreemap-core/src/layout/mod.rs | 2 +- hdtreemap-core/src/render/city.rs | 194 +++++++++++++++++++++++++++ hdtreemap-core/src/render/mod.rs | 7 +- hdtreemap-core/src/render/raster.rs | 65 +++++++-- hdtreemap-core/src/render/tests.rs | 140 +++++++++++++++++++ hdtreemap-core/src/settings.rs | 2 +- hdtreemap-core/src/state/view.rs | 2 +- hdtreemap-core/src/ui/commands.rs | 2 +- hdtreemap-core/src/ui/main_view.rs | 4 +- hdtreemap-core/tests/treemap_view.rs | 13 +- hdtreemap-native/src/menu_bar.rs | 2 +- 15 files changed, 428 insertions(+), 32 deletions(-) create mode 100644 hdtreemap-core/src/render/city.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index bbcf1d4..21b31a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ All notable changes to HDTreeMap are documented here. upper left like the cushions but with hard edges. The toolbar's Cushions checkbox is now a Cushion / Flat / Bevel switch, and View ▸ Cushion Shading is a View ▸ Shading submenu. Settings store the style as `shading` (keeping `cushions` so 0.1.1 still reads them). +- A fourth style, **City**: the map as a cityscape seen from above and a little to the + lower right. Every file is a building whose footprint is its rectangle less a street on + the right and bottom (wider along folder boundaries, so folders read as city blocks), + and whose height grows with the cube root of its size, so the space hogs stand up as + towers. Drawn in an oblique projection with shaded walls; hover, selection and hit + testing still use the footprint. - `run-windows.cmd`: the Windows counterpart of `run-mac.command`, a double-click launcher that builds a release binary and starts the app, passing its arguments through (`[PATH | --demo ID]`). diff --git a/CLAUDE.md b/CLAUDE.md index 1e10ac9..623cc03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,8 +50,9 @@ hdtreemap-core/ rlib: everything testable, no platform code src/settings.rs remembered settings + recent scans as JSON; SettingsStore trait src/demo_fs/ DemoScanner + per-drive recipes for the simulated drives (all targets) src/layout/ squarified cushion layout (Y-down physical pixels), aggregates, hit grid - src/render/ palette (by type or top folder) + cushion rasteriser (rayon row bands - natively), HDTREEMAP_STATS (stats_line; a shell can redirect it) + src/render/ palette (by type or top folder) + rasteriser (cushion, flat, bevel; + rayon row bands natively), city.rs (the cityscape: buildings, streets, + oblique projection), HDTREEMAP_STATS (stats_line; a shell can redirect it) src/state.rs AppState (screen, running scan, finished result, with_result over a live or finished ScanView); state/pump.rs polls scans and folder rescans once per frame (an agg-gui ui_thread drain hook); live.rs: diff --git a/README.md b/README.md index ea550ca..bf66505 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ server). It never reads anything on your computer. - **Correct totals.** Stays on one filesystem, doesn't follow symlinks, and counts hard links (and, on macOS, APFS clones) once. Unreadable folders are counted, not fatal. "Size on disk" or apparent size, switchable without rescanning. -- **Squarified cushion treemap** (or flat blocks, or bevelled tiles), coloured by file type - or by top-level folder; folders +- **Squarified cushion treemap** (or flat blocks, bevelled tiles, or a cityscape whose + buildings rise with file size), coloured by file type or by top-level folder; folders too small to open and leftover small files show as grey blocks, so all space is shown. - **Three linked views.** A folder tree beside the map, sorted by size with each folder's share, and a legend of file types with sizes, shares and file counts. Select or hover in diff --git a/docs/PLAN.md b/docs/PLAN.md index db2b405..f241c24 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -57,7 +57,7 @@ Algorithms: - **Move to Trash…** (confirmed first; uses the `trash` crate, never a permanent delete). The node's size is then subtracted from the tree and the map re-laid out. - Menu bar (native on macOS through agg-gui-shell): - File: Open Folder, Rescan, Quit - - View: Size Mode, Shading (Cushion / Flat / Bevel), Colour By (Extension / Top Folder), Zoom Out + - View: Size Mode, Shading (Cushion / Flat / Bevel / City), Colour By (Extension / Top Folder), Zoom Out - Help: About **Web demo (wasm, GitHub Pages):** the full app running in the browser against a **simulated drive**: a deterministic, realistic synthetic file tree (OS folders, apps, photos, videos, `node_modules`, caches…) generated by `hdtreemap-core::demo_fs`. The "scan" streams that tree at a believable pace, so the progress screen and the live map during the scan are shown too. Every menu, view and action works, with platform actions simulated: Move to Trash removes the node from the model, and Reveal/Open show a toast saying it's a demo. A banner makes clear it is a demo. The start screen offers several descriptive simulated drives, each with its own tree profile: @@ -88,7 +88,7 @@ HDTreeMap/ src/scan/ walker (jwalk), progress, cancel, inode dedup, fs boundaries src/model/ arena tree, ext table, size modes, subtract-on-delete src/layout/ squarify + cushion coefficients (pure, no drawing) - src/render/ cushion rasteriser → RGBA buffer, palette + src/render/ cushion rasteriser → RGBA buffer, palette; city.rs: the cityscape painter src/ui/ widgets: TreemapView, FolderTree, ExtLegend, Breadcrumb, StatusBar, StartScreen src/platform.rs trait PlatformServices { pick_folder, reveal, open, trash } hdtreemap-native/ tiny bin: ShellConfig + agg_gui_shell::run, implements PlatformServices @@ -192,7 +192,13 @@ Scanning is behind a `ScanSource` abstraction: `FsScanner` (native only, `cfg(no times a 1.2 brightness gain, clamped. Flat mode darkens each rectangle's right and bottom pixel line. Bevel mode fills like flat and adds a two-pixel rim (one pixel on small rectangles, none under 3 px): the top and left moved 45% towards white, the bottom and - right at half brightness. Rows go in 16-row bands (rayon natively, serial on wasm). `HDTREEMAP_STATS=1` + right at half brightness. Cityscape mode (`render/city.rs`) makes each rectangle a + building: a street of 1 px on its right and bottom, +1.5 px per enclosing folder that + ends on that edge (at most 6 px, or 40% of the side); height = tallest × ∛(size / biggest + in view), the tallest being a tenth of the raster's shorter side within 8–56 px, and flat + under 2 px; drawn in an oblique projection (roof moved by (−½, −1) per pixel of height, + right wall at 0.7, bottom wall at 0.5) with a per-band depth buffer, so the bands stay + independent. Rows go in 16-row bands (rayon natively, serial on wasm). `HDTREEMAP_STATS=1` prints each layout and raster pass and, once a second, the CPU layout+paint time per frame; `examples/bench_treemap` times them headless. Measured on a loaded 10-core Apple Silicon machine, archive-server demo (1.56 M files) at 3840×2160: layout 45–90 ms, raster diff --git a/hdtreemap-core/src/layout/mod.rs b/hdtreemap-core/src/layout/mod.rs index 010d635..4e2de34 100644 --- a/hdtreemap-core/src/layout/mod.rs +++ b/hdtreemap-core/src/layout/mod.rs @@ -80,7 +80,7 @@ impl PxRect { /// Pixels whose centre `i + 0.5` lies in `[from, to)`. Adjacent spans never overlap and leave /// no gap, which is what makes the painted rectangles tile the raster exactly. -fn pixel_span(from: f64, to: f64, limit: u32) -> (u32, u32) { +pub(crate) fn pixel_span(from: f64, to: f64, limit: u32) -> (u32, u32) { let first = (from - 0.5).ceil().clamp(0.0, f64::from(limit)); let end = (to - 0.5).ceil().clamp(0.0, f64::from(limit)); (first as u32, (end as u32).max(first as u32)) diff --git a/hdtreemap-core/src/render/city.rs b/hdtreemap-core/src/render/city.rs new file mode 100644 index 0000000..686b4f4 --- /dev/null +++ b/hdtreemap-core/src/render/city.rs @@ -0,0 +1,194 @@ +//! The cityscape painter ([`Shading::Cityscape`](super::Shading::Cityscape)): the treemap +//! as a city seen from above and a little to the lower right. Every painted rectangle is a +//! building. Its footprint is the rectangle less a street along its right and bottom edges, +//! wider where a folder boundary runs (one step per enclosing folder that ends on that +//! edge), so folders read as city blocks between avenues. Its height grows with the cube +//! root of its size, up to [`max_height`] for the biggest item in view, so the space hogs +//! stand up as towers and the small fry stay low. Buildings are drawn in an oblique +//! projection: the roof is the footprint moved by [`RISE`] per pixel of height (up and a +//! little to the left), and the right and bottom walls fill in below it, shaded darker as +//! they face away from the light. A per-band depth buffer settles which building shows at +//! a pixel, so the bands stay independent and the paint order does not matter. +//! +//! Only the pixels are 3D: hit testing, hover and the outlines still use the footprint. + +use super::palette::Rgb; +use super::raster::{to_byte, Band, FLAT_INTENSITY}; +use crate::layout::{pixel_span, TreemapLayout, NO_PARENT}; +use crate::model::Tree; + +/// Where a roof moves per pixel of height: half a pixel left and a full pixel up. +pub const RISE: [f32; 2] = [-0.5, -1.0]; +/// The tallest building is this share of the raster's shorter side, within the limits. +const HEIGHT_SHARE: f32 = 0.1; +/// The tallest building is never shorter than this (a small window still shows relief)… +pub const MIN_MAX_HEIGHT: f32 = 8.0; +/// …nor taller than this (a 4K map does not become a forest of spires). +pub const MAX_MAX_HEIGHT: f32 = 56.0; +/// Footprints narrower than this on either side stay flat: ground tiles, not needles. +const FLAT_BELOW: f32 = 2.0; +/// A plain street between neighbours, in pixels. +pub const STREET: f32 = 1.0; +/// Extra street width per folder boundary that runs along an edge. +pub const STREET_PER_FOLDER: f32 = 1.5; +/// A street never gets wider than this… +const STREET_MAX: f32 = 6.0; +/// …nor takes more than this share of a rectangle's side. +const STREET_SHARE: f32 = 0.4; +/// Wall intensities relative to the roof: the right wall is lit a little by the ambient +/// light, the bottom wall is in shadow. +pub const RIGHT_WALL: f32 = 0.7; +pub const BOTTOM_WALL: f32 = 0.5; + +/// One building: its footprint in raster pixels (the rectangle less its streets) and its +/// height in pixels of rise. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Building { + pub x0: f32, + pub y0: f32, + pub x1: f32, + pub y1: f32, + pub height: f32, +} + +impl Building { + /// The pixel rows the building's projection covers, clamped to `0..limit`. + pub fn pixel_rows(&self, limit: u32) -> (u32, u32) { + let top = self.y0 + self.height * RISE[1]; + pixel_span(f64::from(top), f64::from(self.y1), limit) + } + + /// The pixel columns the building's projection covers, clamped to `0..limit`. + pub fn pixel_cols(&self, limit: u32) -> (u32, u32) { + let left = self.x0 + self.height * RISE[0]; + pixel_span(f64::from(left), f64::from(self.x1), limit) + } +} + +/// The height of the biggest item in a raster of this size. +pub fn max_height(width: u32, height: u32) -> f32 { + (width.min(height) as f32 * HEIGHT_SHARE).clamp(MIN_MAX_HEIGHT, MAX_MAX_HEIGHT) +} + +/// A building for each of `leaves` (indices of painted rectangles in `layout.rects()`), in +/// the same order. +pub fn plan( + layout: &TreemapLayout, + tree: &Tree, + leaves: &[usize], + width: u32, + height: u32, +) -> Vec { + let rects = layout.rects(); + let size_of = |i: usize| match layout.aggregate(i) { + Some(merged) => merged.bytes, + None => rects.get(i).map_or(0, |r| tree.size(r.node)), + }; + let biggest = leaves.iter().map(|&i| size_of(i)).max().unwrap_or(0).max(1) as f64; + let tallest = max_height(width, height); + leaves + .iter() + .filter_map(|&i| rects.get(i).map(|r| (i, r))) + .map(|(i, r)| { + // Every enclosing folder that ends on an edge widens that edge's street; the + // zoom root's edges are the map's and get none. + let (mut right, mut bottom) = (STREET, STREET); + for a in layout.ancestors(i) { + let Some(folder) = rects.get(a) else { break }; + if folder.parent == NO_PARENT { + break; + } + if (folder.rect.right() - r.rect.right()).abs() < 1e-6 { + right += STREET_PER_FOLDER; + } + if (folder.rect.bottom() - r.rect.bottom()).abs() < 1e-6 { + bottom += STREET_PER_FOLDER; + } + } + let (w, h) = (r.rect.w as f32, r.rect.h as f32); + let right = right.min(STREET_MAX).min(w * STREET_SHARE); + let bottom = bottom.min(STREET_MAX).min(h * STREET_SHARE); + let (x0, y0) = (r.rect.x as f32, r.rect.y as f32); + let (x1, y1) = (x0 + w - right, y0 + h - bottom); + let height = if (x1 - x0).min(y1 - y0) < FLAT_BELOW { + 0.0 + } else { + tallest * (size_of(i) as f64 / biggest).cbrt() as f32 + }; + Building { + x0, + y0, + x1, + y1, + height, + } + }) + .collect() +} + +/// The depth of a point `height` above ground point `(x, y)`: larger is nearer the viewer. +/// Points that land on the same pixel lie on one line of sight, along which this grows. +fn depth_key(x: f32, y: f32, height: f32) -> f32 { + -RISE[0] * x - RISE[1] * y + height +} + +/// Paint `building` in `color` into the band's rows, keeping whatever is nearer in `depth` +/// (one entry per band pixel, `NEG_INFINITY` where nothing has been painted). +pub(super) fn paint(mut band: Band<'_>, building: &Building, color: Rgb, depth: &mut [f32]) { + let (cx0, cx1) = building.pixel_cols(band.width); + let (ry0, ry1) = building.pixel_rows(band.height); + let y0 = ry0.max(band.first_row); + let y1 = ry1.min(band.first_row + band.rows); + if cx0 >= cx1 || y0 >= y1 { + return; + } + let roof = color.map(|c| to_byte(c * FLAT_INTENSITY)); + let right_wall = color.map(|c| to_byte(c * FLAT_INTENSITY * RIGHT_WALL)); + let bottom_wall = color.map(|c| to_byte(c * FLAT_INTENSITY * BOTTOM_WALL)); + let (ox, oy) = (building.height * RISE[0], building.height * RISE[1]); + let b = building; + for y in y0..y1 { + let py = y as f32 + 0.5; + let from = ((y - band.first_row) * band.width + cx0) as usize; + let to = ((y - band.first_row) * band.width + cx1) as usize; + let row = band.row(y, cx0, cx1); + let depths = &mut depth[from..to]; + for (i, (px, d)) in row + .as_chunks_mut::<4>() + .0 + .iter_mut() + .zip(depths) + .enumerate() + { + let sx = (cx0 + i as u32) as f32 + 0.5; + let on_roof = sx >= b.x0 + ox && sx < b.x1 + ox && py >= b.y0 + oy && py < b.y1 + oy; + let (gx, gy, z, rgb) = if on_roof { + (sx - ox, py - oy, b.height, roof) + } else if b.height <= 0.0 { + continue; + } else { + // The right wall sweeps the footprint's right edge up along the rise; a + // pixel on it is `s` of the way up. The bottom wall likewise. + let s = (sx - b.x1) / ox; + let gy = py - s * oy; + if (0.0..1.0).contains(&s) && gy >= b.y0 && gy < b.y1 { + (b.x1, gy, s * b.height, right_wall) + } else { + let s = (py - b.y1) / oy; + let gx = sx - s * ox; + if (0.0..1.0).contains(&s) && gx >= b.x0 && gx < b.x1 { + (gx, b.y1, s * b.height, bottom_wall) + } else { + continue; + } + } + }; + let key = depth_key(gx, gy, z); + if key > *d { + *d = key; + px[..3].copy_from_slice(&rgb); + px[3] = 255; + } + } + } +} diff --git a/hdtreemap-core/src/render/mod.rs b/hdtreemap-core/src/render/mod.rs index 00f8723..1df6c83 100644 --- a/hdtreemap-core/src/render/mod.rs +++ b/hdtreemap-core/src/render/mod.rs @@ -1,14 +1,15 @@ //! Treemap rendering (`docs/PLAN.md` §3.4): colours and the per-pixel cushion rasteriser. //! //! - [`palette`] — a hue per top extension (or per top-level folder), greys for the rest. -//! - [`raster`] — paints a `layout::TreemapLayout` into an RGBA buffer, cushioned, flat or -//! bevelled, -//! in parallel row bands natively. +//! - [`raster`] — paints a `layout::TreemapLayout` into an RGBA buffer, cushioned, flat, +//! bevelled or as a cityscape, in parallel row bands natively. +//! - [`city`] — the cityscape: buildings with streets between, in an oblique projection. //! //! The `HDTREEMAP_STATS` environment variable ([`stats_enabled`]) makes the treemap view //! print how long each layout and raster pass took ([`stats_line`]; the web demo turns it //! on with `?stats` and logs to the console). +pub mod city; pub mod palette; pub mod raster; diff --git a/hdtreemap-core/src/render/raster.rs b/hdtreemap-core/src/render/raster.rs index f26358a..4172e9f 100644 --- a/hdtreemap-core/src/render/raster.rs +++ b/hdtreemap-core/src/render/raster.rs @@ -12,7 +12,9 @@ //! darkened so neighbours stay apart. With [`Shading::Bevel`] each rectangle is a raised //! tile: an even fill with a rim of [`BEVEL_WIDTH`] pixels lightened along the top and left //! edges and darkened along the bottom and right, as if lit from the upper left like the -//! cushions but with hard edges; small rectangles get a one-pixel rim or none. +//! cushions but with hard edges; small rectangles get a one-pixel rim or none. With +//! [`Shading::Cityscape`] the map is a city: each rectangle a building with streets on its +//! right and bottom, raised by its size and drawn in an oblique projection ([`super::city`]). //! //! The rows are split into bands of [`BAND_ROWS`]; each band paints only the rectangles that //! reach into it ([`crate::layout::bins::Bins`]). Natively the bands run in parallel on @@ -24,6 +26,7 @@ #[cfg(not(target_arch = "wasm32"))] use rayon::prelude::*; +use super::city; use super::palette::{Palette, Rgb}; use crate::layout::bins::Bins; use crate::layout::{LayoutRect, TreemapLayout}; @@ -39,10 +42,17 @@ pub enum Shading { Flat, /// Raised tiles: even fills with a light top-left rim and a dark bottom-right rim. Bevel, + /// A city: buildings as tall as their size warrants, with streets between. + Cityscape, } impl Shading { - pub const ALL: [Shading; 3] = [Shading::Cushion, Shading::Flat, Shading::Bevel]; + pub const ALL: [Shading; 4] = [ + Shading::Cushion, + Shading::Flat, + Shading::Bevel, + Shading::Cityscape, + ]; /// The settings key. pub fn key(self) -> &'static str { @@ -50,6 +60,7 @@ impl Shading { Shading::Cushion => "cushion", Shading::Flat => "flat", Shading::Bevel => "bevel", + Shading::Cityscape => "cityscape", } } @@ -64,6 +75,7 @@ impl Shading { Shading::Cushion => "Cushion", Shading::Flat => "Flat", Shading::Bevel => "Bevel", + Shading::Cityscape => "City", } } } @@ -131,10 +143,32 @@ pub fn rasterise( .zip(colors) .filter(|(r, _)| r.kind.is_painted()) .collect(); + // The cityscape's buildings, one per leaf in the same order; their projections reach + // above and left of their rectangles, so the bins go by them. + let city = (shading == Shading::Cityscape).then(|| { + let indices: Vec = layout + .rects() + .iter() + .enumerate() + .filter(|(_, r)| r.kind.is_painted()) + .map(|(i, _)| i) + .collect(); + city::plan(layout, tree, &indices, width, height) + }); let bands = height.div_ceil(BAND_ROWS) as usize; let bins = Bins::build(leaves.len(), bands, |i| { - let (y0, y1) = leaves[i].0.rect.pixel_rows(height); - let (x0, x1) = leaves[i].0.rect.pixel_cols(width); + let (y0, y1, x0, x1) = match &city { + Some(buildings) => { + let (y0, y1) = buildings[i].pixel_rows(height); + let (x0, x1) = buildings[i].pixel_cols(width); + (y0, y1, x0, x1) + } + None => { + let (y0, y1) = leaves[i].0.rect.pixel_rows(height); + let (x0, x1) = leaves[i].0.rect.pixel_cols(width); + (y0, y1, x0, x1) + } + }; if y0 >= y1 || x0 >= x1 { 0..0 } else { @@ -149,6 +183,10 @@ pub fn rasterise( for px in chunk.as_chunks_mut::<4>().0 { *px = background; } + let mut depth = match &city { + Some(_) => vec![f32::NEG_INFINITY; (rows * width) as usize], + None => Vec::new(), + }; for &leaf in bins.cell(band) { let (rect, color) = &leaves[leaf as usize]; let target = Band { @@ -162,6 +200,11 @@ pub fn rasterise( Shading::Cushion => paint_cushion(target, rect, *color), Shading::Flat => paint_flat(target, rect, *color), Shading::Bevel => paint_bevel(target, rect, *color), + Shading::Cityscape => { + if let Some(buildings) = &city { + city::paint(target, &buildings[leaf as usize], *color, &mut depth); + } + } } } }; @@ -180,12 +223,12 @@ pub fn rasterise( } /// The rows of the raster one band owns. -struct Band<'a> { - pixels: &'a mut [u8], - first_row: u32, - rows: u32, - width: u32, - height: u32, +pub(super) struct Band<'a> { + pub(super) pixels: &'a mut [u8], + pub(super) first_row: u32, + pub(super) rows: u32, + pub(super) width: u32, + pub(super) height: u32, } impl Band<'_> { @@ -198,7 +241,7 @@ impl Band<'_> { (x0 < x1 && y0b < y1b).then_some((x0, x1, y0b, y1b)) } - fn row(&mut self, y: u32, x0: u32, x1: u32) -> &mut [u8] { + pub(super) fn row(&mut self, y: u32, x0: u32, x1: u32) -> &mut [u8] { let start = ((y - self.first_row) * self.width + x0) as usize * 4; let end = ((y - self.first_row) * self.width + x1) as usize * 4; &mut self.pixels[start..end] diff --git a/hdtreemap-core/src/render/tests.rs b/hdtreemap-core/src/render/tests.rs index c2ce20c..29e85d7 100644 --- a/hdtreemap-core/src/render/tests.rs +++ b/hdtreemap-core/src/render/tests.rs @@ -4,6 +4,7 @@ //! palette's hue assignment. Pixel invariants rather than a stored image, so harmless //! tuning does not break them. +use super::city; use super::raster::{to_byte, FLAT_EDGE_MIN, FLAT_INTENSITY}; use super::*; use crate::layout::{layout, LayoutParams, LayoutRect, PxRect, RectKind, TreemapLayout}; @@ -218,6 +219,145 @@ fn tiny_bevelled_rectangles_get_a_thin_rim_or_none() { } } +/// `/r` with one file, so the building fills the raster less its streets. +fn one_file() -> (Tree, Palette, TreemapLayout) { + let mut b = TreeBuilder::new("/r"); + let root = b.root(); + b.add_file(root, "big.mp4", Sizes::new(1000, 1000), NodeFlags::empty()); + let tree = b.finish(SizeMode::Allocated); + let table = ExtTable::from_tree(&tree); + let palette = Palette::from_extensions(&table, SizeMode::Allocated, tree.ext_count()); + let bounds = PxRect::new(0.0, 0.0, f64::from(W), f64::from(H)); + let map = layout(&tree, tree.root(), bounds, &LayoutParams::default()); + (tree, palette, map) +} + +#[test] +fn a_cityscape_building_has_a_raised_roof_two_walls_and_a_street() { + let (tree, palette, map) = one_file(); + let buf = rasterise(&map, &tree, &palette, Shading::Cityscape, W, H); + let r = painted(&map)[0]; + let color = palette.rect_color(&tree, r); + let roof = color.map(|c| to_byte(c * FLAT_INTENSITY)); + let right = color.map(|c| to_byte(c * FLAT_INTENSITY * city::RIGHT_WALL)); + let bottom = color.map(|c| to_byte(c * FLAT_INTENSITY * city::BOTTOM_WALL)); + let background = palette.background(); + let h = city::max_height(W, H); + assert_eq!(h, city::MIN_MAX_HEIGHT, "a 64×48 raster uses the minimum"); + // The footprint is the rectangle less a one-pixel street right and bottom; the zoom + // root's own edges add no street. + let (x1, y1) = (W as f32 - city::STREET, H as f32 - city::STREET); + // The bottom-right corner is street (ground). + assert_eq!(pixel(&buf, W - 1, H - 1), background); + // The roof is the footprint moved up and left by the rise. + let (ox, oy) = (h * city::RISE[0], h * city::RISE[1]); + let roof_at = |fx: f32, fy: f32| pixel(&buf, (fx + ox) as u32, (fy + oy) as u32); + assert_eq!(&roof_at(x1 / 2.0, y1 / 2.0)[..3], &roof[..]); + assert_eq!( + &roof_at(x1 - 1.0, y1 - 1.0)[..3], + &roof[..], + "roof's bottom-right" + ); + // Top-left of the raster: the roof moved off the edge, so only roof remains there. + assert_eq!(&pixel(&buf, 0, 0)[..3], &roof[..]); + // Straight below the roof's bottom edge lies the bottom wall, then the street. + let under_roof = (y1 + oy) as u32 + 1; + assert_eq!(&pixel(&buf, W / 2, under_roof)[..3], &bottom[..]); + assert_eq!(&pixel(&buf, W / 2, H - 2)[..3], &bottom[..]); + // Right of the roof's right edge lies the right wall (where it is not under the + // bottom wall's parallelogram). + let beside_roof = (x1 + ox) as u32 + 1; + assert_eq!(&pixel(&buf, beside_roof, H / 2)[..3], &right[..]); + // The walls are darker than the roof, the bottom darkest. + assert!(luma([roof[0], roof[1], roof[2], 255]) > luma([right[0], right[1], right[2], 255])); + assert!( + luma([right[0], right[1], right[2], 255]) > luma([bottom[0], bottom[1], bottom[2], 255]) + ); +} + +#[test] +fn bigger_files_make_taller_buildings_and_streets_widen_at_folder_edges() { + let (tree, palette, map) = fixture(); + let buf = rasterise(&map, &tree, &palette, Shading::Cityscape, W, H); + let background = palette.background(); + let indices: Vec = map + .rects() + .iter() + .enumerate() + .filter(|(_, r)| r.kind.is_painted()) + .map(|(i, _)| i) + .collect(); + let buildings = city::plan(&map, &tree, &indices, W, H); + // movie.mp4 (5000) is the biggest and reaches the full height; a.jpg (1500) is lower + // by the cube root of the ratio; nothing is taller than the biggest. + let by_name = |name: &str| { + indices + .iter() + .zip(&buildings) + .find(|(&i, _)| tree.name(map.rects()[i].node) == name) + .map(|(_, b)| *b) + .expect(name) + }; + let movie = by_name("movie.mp4"); + let a = by_name("a.jpg"); + assert_eq!(movie.height, city::max_height(W, H)); + let expected = movie.height * (1500.0f64 / 5000.0).cbrt() as f32; + assert!( + (a.height - expected).abs() < 1e-3, + "{} vs {expected}", + a.height + ); + assert!(buildings.iter().all(|b| b.height <= movie.height)); + // a.jpg sits inside photos/: where its edge is also the folder's, the street is wider + // than a plain one, and inside the folder it is a plain street. + let photos = map + .rects() + .iter() + .find(|r| r.kind == RectKind::Dir && r.depth == 1) + .expect("photos/ rect"); + let a_rect = map.rects()[map.index_of(tree_node(&tree, "a.jpg")).expect("a.jpg")].rect; + let right_street = a_rect.right() as f32 - a.x1; + let bottom_street = a_rect.bottom() as f32 - a.y1; + let on_folder_right = (photos.rect.right() - a_rect.right()).abs() < 1e-6; + let on_folder_bottom = (photos.rect.bottom() - a_rect.bottom()).abs() < 1e-6; + let plain = city::STREET; + let wide = city::STREET + city::STREET_PER_FOLDER; + assert_eq!(right_street, if on_folder_right { wide } else { plain }); + assert_eq!(bottom_street, if on_folder_bottom { wide } else { plain }); + // Somewhere in the raster the ground shows between buildings. + let ground = (0..H) + .flat_map(|y| (0..W).map(move |x| (x, y))) + .filter(|&(x, y)| pixel(&buf, x, y) == background) + .count(); + assert!(ground > 0, "streets show"); + // Nothing is lit beyond a roof: every pixel is ground or no brighter than the + // brightest roof. + let brightest = indices + .iter() + .map(|&i| { + let [r, g, b] = palette + .rect_color(&tree, &map.rects()[i]) + .map(|v| to_byte(v * FLAT_INTENSITY)); + luma([r, g, b, 255]) + }) + .max() + .expect("buildings"); + for y in 0..H { + for x in 0..W { + let p = pixel(&buf, x, y); + assert!(p == background || luma(p) <= brightest); + } + } +} + +/// The node called `name` (unique in the fixtures). +fn tree_node(tree: &Tree, name: &str) -> crate::model::NodeId { + (0..tree.arena_len()) + .map(|i| crate::model::NodeId(i as u32)) + .find(|&id| tree.node(id).is_some() && tree.name(id) == name) + .expect(name) +} + #[test] fn folders_and_aggregates_are_dark_grey() { let mut b = TreeBuilder::new("/r"); diff --git a/hdtreemap-core/src/settings.rs b/hdtreemap-core/src/settings.rs index f27a9fb..a982ef6 100644 --- a/hdtreemap-core/src/settings.rs +++ b/hdtreemap-core/src/settings.rs @@ -89,7 +89,7 @@ impl Default for Settings { #[serde(default)] struct Blob { size_mode: Option, - /// `cushion`, `flat` or `bevel`; files from before 0.1.2 have only `cushions`. + /// `cushion`, `flat`, `bevel` or `cityscape`; files from before 0.1.2 have only `cushions`. shading: Option, cushions: Option, color_by: Option, diff --git a/hdtreemap-core/src/state/view.rs b/hdtreemap-core/src/state/view.rs index 4db290d..238356c 100644 --- a/hdtreemap-core/src/state/view.rs +++ b/hdtreemap-core/src/state/view.rs @@ -272,7 +272,7 @@ impl AppState { self.view().shading.get() } - /// Cushion, flat or bevel shading (kept across scans). + /// Cushion, flat, bevel or cityscape shading (kept across scans). pub fn set_shading(&self, shading: Shading) { if self.shading() != shading { self.view().shading.set(shading); diff --git a/hdtreemap-core/src/ui/commands.rs b/hdtreemap-core/src/ui/commands.rs index a193c0c..3432766 100644 --- a/hdtreemap-core/src/ui/commands.rs +++ b/hdtreemap-core/src/ui/commands.rs @@ -39,7 +39,7 @@ pub enum Command { Trash, /// View ▸ Size Mode. SizeMode(SizeMode), - /// View ▸ Shading ▸ Cushion, Flat or Bevel. + /// View ▸ Shading ▸ Cushion, Flat, Bevel or City. Shading(Shading), /// View ▸ Colour By. ColorBy(ColorBy), diff --git a/hdtreemap-core/src/ui/main_view.rs b/hdtreemap-core/src/ui/main_view.rs index 43eb695..e8ab6c7 100644 --- a/hdtreemap-core/src/ui/main_view.rs +++ b/hdtreemap-core/src/ui/main_view.rs @@ -58,7 +58,7 @@ pub const RESCAN_ID: &str = "main-rescan"; pub const SIZE_MODE_ID: &str = "main-size-mode"; /// Widget id of the colour-scheme switch (file type / top-level folder). pub const COLOR_BY_ID: &str = "main-color-by"; -/// Widget id of the shading switch (cushion / flat / bevel). +/// Widget id of the shading switch (cushion / flat / bevel / city). pub const SHADING_ID: &str = "main-shading"; /// Widget id of the status bar's hovered / selected item line. pub const STATUS_ITEM_ID: &str = "status-item"; @@ -263,7 +263,7 @@ fn view_toggles(state: &AppState, fonts: &Fonts) -> Box { ) .with_fit_width(true) .on_change(move |i| shading_state.set_shading(shadings[i.min(shadings.len() - 1)])) - .with_tooltip("Shade the map as cushions, flat blocks or bevelled tiles") + .with_tooltip("Draw the map as cushions, flat blocks, bevelled tiles or a city") .with_v_anchor(VAnchor::CENTER) .with_name(SHADING_ID); Box::new( diff --git a/hdtreemap-core/tests/treemap_view.rs b/hdtreemap-core/tests/treemap_view.rs index e00bb97..908db8b 100644 --- a/hdtreemap-core/tests/treemap_view.rs +++ b/hdtreemap-core/tests/treemap_view.rs @@ -210,18 +210,23 @@ fn the_shading_switch_rerasterises_without_relayout() { let mut h = usb_map(); assert_eq!(h.state.shading(), Shading::Cushion); let before = h.state.map_counters(); - // Three segments: Cushion, Flat, Bevel. - let (x, y) = h.point_in(SHADING_ID, 0.5, 0.5); + // Four segments: Cushion, Flat, Bevel, City. + let (x, y) = h.point_in(SHADING_ID, 0.375, 0.5); h.click_at(x, y); assert_eq!(h.state.shading(), Shading::Flat); let after = h.state.map_counters(); assert_eq!(after.layouts, before.layouts); assert_eq!(after.rasters, before.rasters + 1); - let (x, y) = h.point_in(SHADING_ID, 0.85, 0.5); + let (x, y) = h.point_in(SHADING_ID, 0.625, 0.5); h.click_at(x, y); assert_eq!(h.state.shading(), Shading::Bevel); assert_eq!(h.state.map_counters().rasters, before.rasters + 2); - let (x, y) = h.point_in(SHADING_ID, 0.15, 0.5); + let (x, y) = h.point_in(SHADING_ID, 0.875, 0.5); + h.click_at(x, y); + assert_eq!(h.state.shading(), Shading::Cityscape); + assert_eq!(h.state.map_counters().layouts, before.layouts); + assert_eq!(h.state.map_counters().rasters, before.rasters + 3); + let (x, y) = h.point_in(SHADING_ID, 0.125, 0.5); h.click_at(x, y); assert_eq!(h.state.shading(), Shading::Cushion); } diff --git a/hdtreemap-native/src/menu_bar.rs b/hdtreemap-native/src/menu_bar.rs index f2478c6..14c602f 100644 --- a/hdtreemap-native/src/menu_bar.rs +++ b/hdtreemap-native/src/menu_bar.rs @@ -126,7 +126,7 @@ mod tests { let rows = native::children_of(shading); assert_eq!( rows.iter().map(|i| i.text.as_str()).collect::>(), - vec!["Cushion", "Flat", "Bevel"] + vec!["Cushion", "Flat", "Bevel", "City"] ); for row in &rows { assert!(row.is_checked.is_some());