feat(ui): developer tile grid overlay (ui.set tileGrid) (#4217)
Draw the active layer's storage tiles over the canvas: a faint
TILE_SIZE grid over the document, every stored tile filled and
labelled tx,ty, tiles with identical pixels in one colour per group
and unique tiles in blue. It shows how sparse a layer is, which tiles
copy-on-write shares, and which ones .pcraft saves once.
It has no menu item and isn't a View > Show item. The control channel
and MCP turn it on with ui.set {"tileGrid": true}; ui.inspect reports
it as view.tile_grid, and View > Extras doesn't hide it.
Grouping hashes each distinct tile allocation once, in parallel (or
serially while jobs hold the pool, #1554), cached per document
revision and active layer. A 36 MP layer of all-different tiles groups
in 3.9 ms (8-bit) and 7.5 ms (16-bit) on 24 cores in release; the
ignored tile_grouping_36mp test reproduces this.
Also documents tileGrid in docs/control-protocol.md and
docs/development.md, and adds my contributors/people.toml entry.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
8157d1a851
commit
465a663540
|
|
@ -140,3 +140,6 @@ sync_github_name = true
|
|||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
[people.deadmeu]
|
||||
sync_github_name = true
|
||||
|
|
|
|||
|
|
@ -323,7 +323,8 @@ pub struct UiSetParams {
|
|||
/// auto_adjust, classic_mode and auto_center_preview), shapeStroke (an object patching width, align, cap, join, miterLimit,
|
||||
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
|
||||
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
|
||||
/// the Content-Aware Move tool's Transform On Drop). Other fields are an error.
|
||||
/// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the
|
||||
/// active layer's storage tiles). Other fields are an error.
|
||||
pub fields: Value,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2823,6 +2823,12 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
|||
crate::pixel_grid::paint(app, &painter, &xf, idx, output, visible_doc_rect(&xf));
|
||||
}
|
||||
|
||||
// Developer tile grid (`ui.set {"tileGrid": true}`): the active layer's storage tiles.
|
||||
// Not tied to View › Extras (⌘H), which hides only the View › Show overlays.
|
||||
if app.ui.view.tile_grid {
|
||||
crate::tile_overlay::paint(app, &ctx, &painter, &xf, idx, visible_doc_rect(&xf));
|
||||
}
|
||||
|
||||
// View › Show › Layer Edges: drawn at this depth, but only once this frame's tool events are
|
||||
// in (`layer_edges_rect`), so a press that Auto-Selects a layer outlines that layer.
|
||||
let layer_edges = painter.add(egui::Shape::Noop);
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ pub enum Outcome {
|
|||
/// field's value is validated before the first one is applied, so a typo, an unknown field, a
|
||||
/// bad value or a bad nested key can't reply with success while nothing — or only half of it —
|
||||
/// changed (#412).
|
||||
pub const UI_SET_FIELDS: [&str; 34] = [
|
||||
pub const UI_SET_FIELDS: [&str; 35] = [
|
||||
"tool",
|
||||
"panels",
|
||||
"dock",
|
||||
|
|
@ -166,6 +166,7 @@ pub const UI_SET_FIELDS: [&str; 34] = [
|
|||
"cropShield",
|
||||
"shapeStroke",
|
||||
"camTransformOnDrop",
|
||||
"tileGrid",
|
||||
];
|
||||
|
||||
/// Most clicks one `ui.click` may queue (#982). Each click is a press and a release that the app
|
||||
|
|
@ -492,6 +493,8 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
|||
let eyedropper_ring = bool_field(p, "eyedropperRing")?;
|
||||
// Content-Aware Move's Transform On Drop checkbox.
|
||||
let cam_transform_on_drop = bool_field(p, "camTransformOnDrop")?;
|
||||
// Developer overlay of the active layer's storage tiles (tile_overlay.rs).
|
||||
let tile_grid = bool_field(p, "tileGrid")?;
|
||||
// The Crop tool's overlay menu (#1919).
|
||||
let crop_overlay = match p.get("cropOverlay") {
|
||||
Some(v) => Some(v.as_str().and_then(crate::crop_overlay::CropOverlay::from_id).ok_or_else(|| {
|
||||
|
|
@ -650,6 +653,9 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
|||
if let Some(on) = cam_transform_on_drop {
|
||||
app.ui.tool_options.cam_transform_on_drop = on;
|
||||
}
|
||||
if let Some(on) = tile_grid {
|
||||
app.ui.view.tile_grid = on;
|
||||
}
|
||||
if let Some(k) = crop_overlay {
|
||||
app.ui.tool_options.crop_overlay = k;
|
||||
}
|
||||
|
|
@ -1858,6 +1864,25 @@ mod tests {
|
|||
assert!(app.ui.tool_options.gradient_classic, "invalid mode must not change options");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_set_toggles_the_developer_tile_grid() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
assert!(!app.ui.view.tile_grid, "off by default");
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"tileGrid": true}))["ok"], true);
|
||||
assert!(app.ui.view.tile_grid);
|
||||
assert_eq!(call(&mut app, &ctx, "ui.inspect", json!({}))["result"]["view"]["tile_grid"], true);
|
||||
// Not a View › Show item: Extras doesn't hide it, and Show Extras Options doesn't list it.
|
||||
crate::menus::invoke(&mut app, &ctx, "view.extras", json!({})).unwrap();
|
||||
assert!(!app.ui.view.extras && app.ui.view.tile_grid);
|
||||
assert!(serde_json::to_value(app.ui.view.show).unwrap().get("tile_grid").is_none());
|
||||
let bad = call(&mut app, &ctx, "ui.set", json!({"tileGrid": "yes"}));
|
||||
assert_eq!(bad["ok"], false, "{bad}");
|
||||
assert!(app.ui.view.tile_grid, "a rejected value changes nothing");
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"tileGrid": false}))["ok"], true);
|
||||
assert!(!app.ui.view.tile_grid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_set_drives_the_eyedropper_options() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ pub mod targa_options_ui;
|
|||
pub mod text_field_menu;
|
||||
pub mod theme;
|
||||
pub mod tiff_options_ui;
|
||||
mod tile_overlay;
|
||||
mod timeline_ui;
|
||||
mod titlebar;
|
||||
pub mod tone;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,293 @@
|
|||
//! The developer tile grid: the active layer's storage tiles over the canvas, turned on with the
|
||||
//! control channel's `ui.set {"tileGrid": true}` ([`crate::view_cmds::ViewOptions::tile_grid`]).
|
||||
//! It shows how sparse a layer is, which tiles copy-on-write shares, and which ones `.pcraft`
|
||||
//! saves once.
|
||||
//!
|
||||
//! A layer's pixels live in a sparse [`Surface`] of [`TILE_SIZE`] tiles; cells with no stored
|
||||
//! tile read as the surface's default pixel and cost nothing. The overlay draws the tile grid
|
||||
//! faintly over the document, fills every stored tile, and labels it `tx,ty` once it is large
|
||||
//! enough on screen. Tiles with identical pixels share a hue; a tile whose pixels appear nowhere
|
||||
//! else is plain blue.
|
||||
//!
|
||||
//! Grouping reads every stored tile, so it is cached per document revision and active layer,
|
||||
//! never redone per frame (AGENTS.md rule 8). Tile identity can't stand in for the revision: a
|
||||
//! tile nothing else shares is written in place. Each allocation is hashed once, and tiles are
|
||||
//! grouped by that 64-bit hash of their bytes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Align2, Color32, FontId, Painter, Shape, Stroke};
|
||||
use photocraft_geom::{Rect as DRect, TILE_SIZE, TileCoord};
|
||||
use photocraft_raster::{Surface, Tile};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::canvas::ViewXform;
|
||||
|
||||
/// Outline and fill of a tile whose pixels appear nowhere else in the layer.
|
||||
const UNIQUE: Color32 = Color32::from_rgb(0x2d, 0x8c, 0xeb);
|
||||
/// Alpha of a stored tile's fill; low so the image stays readable underneath.
|
||||
const FILL_ALPHA: u8 = 40;
|
||||
/// The grid over cells with no stored tile: a translucent mid grey, seen on light and dark pixels.
|
||||
const EMPTY_LINE: Color32 = Color32::from_rgba_premultiplied(32, 32, 32, 64);
|
||||
/// Tile labels show once a tile is at least this many points across.
|
||||
const LABEL_MIN: f32 = 56.0;
|
||||
|
||||
/// One stored tile and the group of tiles it shares its pixels with (`None` = unique).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct TileInfo {
|
||||
pub coord: TileCoord,
|
||||
pub group: Option<usize>,
|
||||
}
|
||||
|
||||
/// The tiles of one layer, valid for one document revision.
|
||||
#[derive(Clone)]
|
||||
struct Cached {
|
||||
revision: u64,
|
||||
tiles: Arc<Vec<TileInfo>>,
|
||||
}
|
||||
|
||||
/// A tile's allocation as a plain address, so it can cross to the Rayon pool.
|
||||
fn ptr(t: &Arc<Tile>) -> usize {
|
||||
Arc::as_ptr(t) as usize
|
||||
}
|
||||
|
||||
fn content_hash(t: &Tile) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
t.bytes().hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Every stored tile of `s` with its shared-pixels group. Groups are numbered in tile order, so
|
||||
/// the same surface always gets the same colours. `parallel` hashes on the Rayon pool; pass
|
||||
/// false while jobs hold it, so the UI thread never waits on them (#1554).
|
||||
pub fn tile_infos(s: &Surface, parallel: bool) -> Vec<TileInfo> {
|
||||
// Hash each distinct allocation once; copy-on-write clones share one.
|
||||
let mut by_ptr: HashMap<usize, &Arc<Tile>> = HashMap::new();
|
||||
for (_, t) in s.tiles() {
|
||||
by_ptr.entry(ptr(t)).or_insert(t);
|
||||
}
|
||||
let distinct: Vec<(usize, &Arc<Tile>)> = by_ptr.into_iter().collect();
|
||||
let hashed: Vec<(usize, u64)> = if parallel {
|
||||
distinct.par_iter().map(|(p, t)| (*p, content_hash(t))).collect()
|
||||
} else {
|
||||
distinct.iter().map(|(p, t)| (*p, content_hash(t))).collect()
|
||||
};
|
||||
let hash_of: HashMap<usize, u64> = hashed.into_iter().collect();
|
||||
let mut count: HashMap<u64, usize> = HashMap::new();
|
||||
for (_, t) in s.tiles() {
|
||||
if let Some(h) = hash_of.get(&ptr(t)) {
|
||||
*count.entry(*h).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let mut group_of: HashMap<u64, usize> = HashMap::new();
|
||||
s.tiles()
|
||||
.map(|(c, t)| {
|
||||
let group = hash_of.get(&ptr(t)).filter(|h| count.get(h).is_some_and(|&n| n > 1)).map(|h| {
|
||||
let next = group_of.len();
|
||||
*group_of.entry(*h).or_insert(next)
|
||||
});
|
||||
TileInfo { coord: *c, group }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Hues (0..1) left to unique tiles: shared groups never land in the blues, nor in the
|
||||
/// blue-violets that read as blue next to them ([`UNIQUE`] is about 0.58).
|
||||
const BLUE_HUES: (f32, f32) = (0.45, 0.75);
|
||||
/// Gap kept from either end of the allowed range, as a share of it.
|
||||
const HUE_MARGIN: f32 = 0.05;
|
||||
|
||||
/// The hue of shared group `g`: golden-ratio steps keep neighbouring group numbers far apart,
|
||||
/// spread over the wheel outside [`BLUE_HUES`] and clear of its edges.
|
||||
fn group_hue(g: usize) -> f32 {
|
||||
let step = (g as f32 * 0.618_034).fract();
|
||||
let span = 1.0 - (BLUE_HUES.1 - BLUE_HUES.0);
|
||||
(BLUE_HUES.1 + (HUE_MARGIN + step * (1.0 - 2.0 * HUE_MARGIN)) * span).fract()
|
||||
}
|
||||
|
||||
/// The colour of a tile group: blue for unique tiles, an evenly spread hue per shared group.
|
||||
pub fn group_color(group: Option<usize>) -> Color32 {
|
||||
let Some(g) = group else { return UNIQUE };
|
||||
egui::ecolor::Hsva::new(group_hue(g), 0.85, 0.95, 1.0).into()
|
||||
}
|
||||
|
||||
/// The tile cells of the grid that touch `r`, as an inclusive (tx0, ty0, tx1, ty1) range.
|
||||
pub fn cell_range(r: DRect) -> Option<(i32, i32, i32, i32)> {
|
||||
if r.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = (TileCoord::containing(r.x0, r.y0), TileCoord::containing(r.x1 - 1, r.y1 - 1));
|
||||
Some((a.tx, a.ty, b.tx, b.ty))
|
||||
}
|
||||
|
||||
fn quad(xf: &ViewXform, r: DRect) -> [egui::Pos2; 4] {
|
||||
let (x0, y0, x1, y1) = (r.x0 as f32, r.y0 as f32, r.x1 as f32, r.y1 as f32);
|
||||
[xf.to_screen(x0, y0), xf.to_screen(x1, y0), xf.to_screen(x1, y1), xf.to_screen(x0, y1)]
|
||||
}
|
||||
|
||||
/// Draw the active layer's tiles of document `idx`. `visible` is the document area in view.
|
||||
pub fn paint(app: &PhotocraftApp, ctx: &egui::Context, painter: &Painter, xf: &ViewXform, idx: usize, visible: DRect) {
|
||||
let Some(st) = app.session.documents().get(idx) else { return };
|
||||
let Some(surface) = st.active_layer.and_then(|id| st.doc.layer(id)).and_then(|l| l.surface()) else { return };
|
||||
let revision = st.revision;
|
||||
let mem = egui::Id::new(("pc-tile-overlay", st.doc.id.0, st.active_layer.map(|l| l.0)));
|
||||
let cached = ctx.data(|d| d.get_temp::<Cached>(mem)).filter(|c| c.revision == revision);
|
||||
let tiles = match cached {
|
||||
Some(c) => c.tiles,
|
||||
None => {
|
||||
let tiles = Arc::new(tile_infos(surface, !app.session.has_jobs()));
|
||||
ctx.data_mut(|d| d.insert_temp(mem, Cached { revision, tiles: tiles.clone() }));
|
||||
tiles
|
||||
}
|
||||
};
|
||||
|
||||
// The grid over the document, so the cells without a stored tile show too.
|
||||
let bounds = st.doc.bounds();
|
||||
if let Some((tx0, ty0, tx1, ty1)) = cell_range(visible.intersect(&bounds)) {
|
||||
let line = Stroke::new(1.0, EMPTY_LINE);
|
||||
let edge = |t: i32| t.saturating_mul(TILE_SIZE);
|
||||
let (top, bottom) = (bounds.y0.max(edge(ty0)) as f32, bounds.y1.min(edge(ty1.saturating_add(1))) as f32);
|
||||
let (left, right) = (bounds.x0.max(edge(tx0)) as f32, bounds.x1.min(edge(tx1.saturating_add(1))) as f32);
|
||||
for tx in tx0..=tx1.saturating_add(1) {
|
||||
let x = edge(tx).clamp(bounds.x0, bounds.x1) as f32;
|
||||
painter.line_segment([xf.to_screen(x, top), xf.to_screen(x, bottom)], line);
|
||||
}
|
||||
for ty in ty0..=ty1.saturating_add(1) {
|
||||
let y = edge(ty).clamp(bounds.y0, bounds.y1) as f32;
|
||||
painter.line_segment([xf.to_screen(left, y), xf.to_screen(right, y)], line);
|
||||
}
|
||||
}
|
||||
|
||||
// Stored tiles, which may lie past the canvas edge.
|
||||
let size = TILE_SIZE as f32 * xf.zoom;
|
||||
for t in tiles.iter() {
|
||||
let r = t.coord.rect();
|
||||
if r.intersect(&visible).is_empty() {
|
||||
continue;
|
||||
}
|
||||
let c = group_color(t.group);
|
||||
let pts = quad(xf, r).to_vec();
|
||||
let fill = Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), FILL_ALPHA);
|
||||
painter.add(Shape::convex_polygon(pts.clone(), fill, Stroke::NONE));
|
||||
painter.add(Shape::closed_line(pts, Stroke::new(1.0, c)));
|
||||
if size >= LABEL_MIN {
|
||||
let mid = xf.to_screen(r.x0 as f32 + TILE_SIZE as f32 / 2.0, r.y0 as f32 + TILE_SIZE as f32 / 2.0);
|
||||
let label = format!("{},{}", t.coord.tx, t.coord.ty);
|
||||
let font = FontId::monospace((size / 8.0).clamp(10.0, 16.0));
|
||||
// A dark drop shadow keeps the label readable over light pixels.
|
||||
painter.text(mid + egui::vec2(1.0, 1.0), Align2::CENTER_CENTER, &label, font.clone(), Color32::from_black_alpha(180));
|
||||
painter.text(mid, Align2::CENTER_CENTER, label, font, Color32::WHITE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_color::PixelFormat;
|
||||
|
||||
/// The document rect of tile cell (tx, ty).
|
||||
fn cell(tx: i32, ty: i32) -> DRect {
|
||||
TileCoord::new(tx, ty).rect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_surface_has_no_tiles() {
|
||||
assert!(tile_infos(&Surface::new(PixelFormat::RGBA8), true).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_pixels_share_a_group_and_unique_tiles_have_none() {
|
||||
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
|
||||
identical_pixels_share_a_group_in(fmt);
|
||||
}
|
||||
}
|
||||
|
||||
fn identical_pixels_share_a_group_in(fmt: PixelFormat) {
|
||||
let mut s = Surface::new(fmt);
|
||||
// Two separately allocated tiles with the same red pixels, as after loading a .pcraft.
|
||||
s.fill_rect(cell(0, 0), &[1.0, 0.0, 0.0, 1.0]);
|
||||
s.fill_rect(cell(2, 0), &[1.0, 0.0, 0.0, 1.0]);
|
||||
// One shared allocation in two cells (copy-on-write), and a lone green tile.
|
||||
let blue = s.solid_tile(&[0.0, 0.0, 1.0, 1.0]);
|
||||
s.put_tiles([(TileCoord::new(0, 1), blue.clone()), (TileCoord::new(1, 1), blue)]);
|
||||
s.fill_rect(cell(1, 0), &[0.0, 1.0, 0.0, 1.0]);
|
||||
for parallel in [true, false] {
|
||||
let infos = tile_infos(&s, parallel);
|
||||
let group = |tx, ty| infos.iter().find(|i| i.coord == TileCoord::new(tx, ty)).map(|i| i.group);
|
||||
assert_eq!(infos.len(), 5);
|
||||
assert_eq!(group(1, 0), Some(None), "green is unique");
|
||||
let (red, blue) = (group(0, 0).flatten(), group(0, 1).flatten());
|
||||
assert!(red.is_some() && blue.is_some() && red != blue);
|
||||
assert_eq!(group(2, 0).flatten(), red);
|
||||
assert_eq!(group(1, 1).flatten(), blue);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_get_distinct_colours_and_unique_tiles_stay_blue() {
|
||||
assert_eq!(group_color(None), UNIQUE);
|
||||
let colours: Vec<Color32> = (0..6).map(|g| group_color(Some(g))).collect();
|
||||
for (i, a) in colours.iter().enumerate() {
|
||||
assert_ne!(*a, UNIQUE);
|
||||
assert!(colours.iter().skip(i + 1).all(|b| b != a));
|
||||
}
|
||||
// Huge group numbers still map to a colour.
|
||||
let _ = group_color(Some(usize::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_groups_never_take_the_blue_of_unique_tiles() {
|
||||
for g in (0..10_000).chain([usize::MAX]) {
|
||||
let h = group_hue(g);
|
||||
assert!((0.0..1.0).contains(&h), "group {g}: hue {h}");
|
||||
assert!(!(BLUE_HUES.0..=BLUE_HUES.1).contains(&h), "group {g}: hue {h} is blue");
|
||||
// Clear of the range's edges too, where hues still read as blue.
|
||||
assert!((h - BLUE_HUES.1).abs() > 0.01 && (h - BLUE_HUES.0).abs() > 0.01, "group {g}: hue {h} is at the edge");
|
||||
}
|
||||
}
|
||||
|
||||
/// `cargo test --release -p photocraft-ui-egui --lib tile_grouping_36mp -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore = "benchmark"]
|
||||
fn tile_grouping_36mp() {
|
||||
let (w, h) = (7360, 4912);
|
||||
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16] {
|
||||
// Every tile different, the worst case: each one is hashed and none share.
|
||||
let mut s = Surface::new(fmt);
|
||||
let Some((tx1, ty1)) = cell_range(DRect::from_xywh(0, 0, w, h)).map(|(_, _, tx1, ty1)| (tx1, ty1)) else { return };
|
||||
for ty in 0..=ty1 {
|
||||
for tx in 0..=tx1 {
|
||||
let seed = (tx * 31 + ty * 17) as usize;
|
||||
for (i, b) in s.tile_mut(TileCoord::new(tx, ty)).bytes_mut().iter_mut().enumerate() {
|
||||
*b = (i ^ seed) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
for parallel in [true, false] {
|
||||
let mut best = f64::MAX;
|
||||
for _ in 0..8 {
|
||||
let t = std::time::Instant::now();
|
||||
std::hint::black_box(tile_infos(&s, parallel));
|
||||
best = best.min(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
let mode = if parallel { "parallel" } else { "serial" };
|
||||
println!("36 MP {fmt:?}, {} tiles, {mode}: {best:.1} ms", s.tile_count());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_range_covers_the_cells_a_rect_touches() {
|
||||
let t = TILE_SIZE;
|
||||
// Exactly one cell, then a partial cell past whole ones (a canvas not a multiple of it).
|
||||
assert_eq!(cell_range(cell(0, 0)), Some((0, 0, 0, 0)));
|
||||
assert_eq!(cell_range(DRect::new(0, 0, 7 * t + t / 2, 4 * t + 1)), Some((0, 0, 7, 4)));
|
||||
// One pixel either side of a cell edge, including left of the origin.
|
||||
assert_eq!(cell_range(DRect::new(-1, t - 1, t + 1, t + 4)), Some((-1, 0, 1, 1)));
|
||||
assert_eq!(cell_range(DRect::EMPTY), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -96,6 +96,9 @@ pub struct ViewOptions {
|
|||
pub middle_eastern_composer: bool,
|
||||
/// Parameters from the last successfully applied New Guide Layout dialog.
|
||||
pub guide_layout: Value,
|
||||
/// Developer overlay of the active layer's storage tiles (tile_overlay.rs), set with the
|
||||
/// control channel's `ui.set {"tileGrid": true}`.
|
||||
pub tile_grid: bool,
|
||||
}
|
||||
|
||||
/// Factory New Guide Layout values (Alt+Reset and a fresh ViewOptions).
|
||||
|
|
@ -121,6 +124,7 @@ impl Default for ViewOptions {
|
|||
language_features: "defaultFeatures".into(),
|
||||
middle_eastern_composer: false,
|
||||
guide_layout: default_guide_layout(),
|
||||
tile_grid: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
|||
- `ui.menu.invoke {id, params?, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once. Window ends with the open documents as `window.document.<index>` (tab order, the active one checked, #2991); invoking one brings that document to the front and replies `{document: index}`
|
||||
- `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar.
|
||||
- `ui.set` also accepts `camTransformOnDrop` (boolean), the Content-Aware Move tool's Transform On Drop checkbox: a drop then puts a scale/rotate box on the patch (`ui.inspect` › `camTransform`: its document/revision, drag `offset` and `frame`), driven with `ui.pointer` (handles as Free Transform's; outside the box rotates), committed with `ui.key Enter` (one `paint.contentAwareMove` with `scaleX`, `scaleY`, `rotate`) or dropped with `Escape`.
|
||||
- `ui.set` also accepts `tileGrid` (boolean), a developer overlay of the active layer's storage tiles: the tile grid over the document, every stored tile filled and labelled `tx,ty`, tiles with identical pixels in a shared colour and unique ones in blue. It has no menu item and View › Extras doesn't hide it. `ui.inspect` reports it as `view.tile_grid`.
|
||||
- Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back.
|
||||
- `ui.set` also accepts the Eyedropper options bar (#1649): `eyedropperSampleSize` (`"point"` or 1, 3, 5, 11, 31, 51, 101: the side of the averaged square, shared by the Eyedropper, a painting tool's Alt-click eyedropper, the Curves/Color Picker eyedroppers and the Info panel), `eyedropperSample` (`current`, `currentAndBelow`, `all`, `allNoAdjustments`, `currentAndBelowNoAdjustments`) and `eyedropperRing` (boolean, Show Sampling Ring). `ui.inspect` reports them under `toolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`). The engine command `document.sampleColor {x, y, size, sampleLayer}` returns the colour such a sample picks.
|
||||
- `ui.set` also accepts the Crop tool's overlay menu (#1919): `cropOverlay` (`thirds`, `grid`, `diagonal`, `triangle`, `goldenRatio`, `goldenSpiral`), `cropOverlayShow` (`auto`: only while the crop box is dragged, Photoshop's default; `always`; `never`) and `cropOverlayOrientation` (0..3; Triangle has two orientations and Golden Spiral four, the others ignore it). `ui.inspect` reports them under `toolOptions` (`crop_overlay`, `crop_overlay_show`, `crop_overlay_orientation`). In the app, O cycles the overlay and ⇧O its orientation while the Crop tool shows a box.
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ See `docs/control-protocol.md` for every method. Tips:
|
|||
- **macOS does not render occluded windows.** `ui.screenshot` raises the window first (`focus: true` by default). Use `ui.focus` before other visual checks.
|
||||
- **Capture only our own window.** For native captures (to see the real title bar and traffic lights), capture by window id (`screencapture -l <CGWindowID>`), never a screen region, which can grab other apps.
|
||||
- **Pointer gestures:** `ui.pointer` takes events in *document* coordinates and runs them through the same tool state machine as the mouse.
|
||||
- **Seeing tile storage:** `ui.set {"tileGrid": true}` overlays the active layer's stored tiles, which shows how sparse a layer is and which tiles share pixels (`ui-egui/src/tile_overlay.rs`).
|
||||
|
||||
## Headless CLI
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue