Files
gridcraft/crates/ui-egui/src/chartview.rs
T

537 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Floating objects on the sheet: charts (rendered by `gridcraft-chart` into primitives that
//! we paint with egui), pictures and shapes.
use egui::epaint::{PathShape, PathStroke};
use egui::{Align2, Color32, FontId, Painter, Pos2, Rect, Stroke, StrokeKind, pos2, vec2};
use gridcraft_chart::{HAlign, Prim, VAlign};
use gridcraft_engine::model::{Anchor, ShapeKind, Sheet, Workbook};
use crate::SheetApp;
use crate::grid::Geo;
use crate::theme::{self, Tokens};
fn rgba(c: [u8; 4]) -> Color32 {
Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3])
}
struct EguiMeasure<'a> {
painter: &'a Painter,
z: f32,
}
impl gridcraft_chart::Measure for EguiMeasure<'_> {
fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 {
let font = if bold { theme::ui_bold(size * self.z) } else { theme::ui_font(size * self.z) };
self.painter.layout_no_wrap(text.to_string(), font, Color32::BLACK).size().x / self.z.max(0.01)
}
}
/// Paints `chart` (not necessarily on the sheet: the Recommended Charts preview) scaled to fit
/// `rect`.
pub fn paint_chart(p: &Painter, rect: Rect, wb: &Workbook, sheet: usize, chart: &gridcraft_engine::model::Chart) {
let (w, h) = (chart.anchor.width.max(1.0), chart.anchor.height.max(1.0));
let z = (rect.width() / w).min(rect.height() / h);
if !(z.is_finite() && z > 0.0) {
return;
}
let origin = rect.center() - vec2(w * z, h * z) / 2.0;
let data = gridcraft_chart::resolve(wb, sheet, chart);
let m = EguiMeasure { painter: p, z };
let prims = gridcraft_chart::render(chart, &data, w, h, &m);
paint_prims(&p.with_clip_rect(rect.intersect(p.clip_rect())), origin, &prims, z);
}
/// Paints chart primitives at `origin`, scaled by `z`.
pub fn paint_prims(p: &Painter, origin: Pos2, prims: &[Prim], z: f32) {
let pt = |x: f32, y: f32| pos2(origin.x + x * z, origin.y + y * z);
for prim in prims {
match prim {
Prim::Rect { x, y, w, h, fill, stroke, radius } => {
let r = Rect::from_min_size(pt(*x, *y), vec2(w * z, h * z));
if let Some(f) = fill {
p.rect_filled(r, radius * z, rgba(*f));
}
if let Some((c, wd)) = stroke {
p.rect_stroke(r, radius * z, Stroke::new(wd * z.max(0.5), rgba(*c)), StrokeKind::Inside);
}
}
Prim::Line { pts, color, width, dash } => {
let v: Vec<Pos2> = pts.iter().map(|q| pt(q[0], q[1])).collect();
if v.len() < 2 {
continue;
}
if *dash {
p.extend(egui::Shape::dashed_line(&v, Stroke::new(width * z.max(0.5), rgba(*color)), 4.0 * z, 3.0 * z));
} else {
p.add(PathShape::line(v, PathStroke::new(width * z.max(0.5), rgba(*color))));
}
}
Prim::Polygon { pts, fill, stroke } => {
let v: Vec<Pos2> = pts.iter().map(|q| pt(q[0], q[1])).collect();
if v.len() < 3 {
continue;
}
// Polygons may be concave (areas): triangulate as a fan from the first point
// when convex, else draw through a mesh of the closed path.
let shape = PathShape { points: v.clone(), closed: true, fill: rgba(*fill), stroke: PathStroke::NONE };
if is_convex(&v) {
p.add(shape);
} else {
p.add(egui::Shape::mesh(area_mesh(&v, rgba(*fill))));
}
if let Some((c, w)) = stroke {
p.add(PathShape::closed_line(v, PathStroke::new(w * z.max(0.5), rgba(*c))));
}
}
Prim::Wedge { cx, cy, r_outer, r_inner, a0, a1, fill, stroke } => {
let c = pt(*cx, *cy);
let steps = (((a1 - a0).abs() / 0.05).ceil() as usize).clamp(2, 400);
let at = |a: f32, r: f32| pos2(c.x + r * z * a.sin(), c.y - r * z * a.cos());
let mut mesh = egui::Mesh::default();
let col = rgba(*fill);
for i in 0..=steps {
let a = a0 + (a1 - a0) * i as f32 / steps as f32;
mesh.colored_vertex(at(a, *r_outer), col);
mesh.colored_vertex(if *r_inner > 0.0 { at(a, *r_inner) } else { c }, col);
}
for i in 0..steps as u32 {
let b = i * 2;
mesh.add_triangle(b, b + 1, b + 2);
mesh.add_triangle(b + 1, b + 3, b + 2);
}
p.add(egui::Shape::mesh(mesh));
if let Some((sc, w)) = stroke {
let mut outline: Vec<Pos2> = (0..=steps).map(|i| at(a0 + (a1 - a0) * i as f32 / steps as f32, *r_outer)).collect();
if *r_inner > 0.0 {
outline.extend((0..=steps).rev().map(|i| at(a0 + (a1 - a0) * i as f32 / steps as f32, *r_inner)));
} else {
outline.push(c);
}
p.add(PathShape::closed_line(outline, PathStroke::new(w * z.max(0.5), rgba(*sc))));
}
}
Prim::Circle { cx, cy, r, fill, stroke } => {
if let Some(f) = fill {
p.circle_filled(pt(*cx, *cy), r * z, rgba(*f));
}
if let Some((c, w)) = stroke {
p.circle_stroke(pt(*cx, *cy), r * z, Stroke::new(w * z.max(0.5), rgba(*c)));
}
}
Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => {
let font: FontId = if *bold { theme::ui_bold(size * z) } else { theme::ui_font(size * z) };
let galley = p.layout_no_wrap(text.clone(), font, rgba(*color));
let sz = galley.size();
let ax = match align {
HAlign::Left => 0.0,
HAlign::Center => 0.5,
HAlign::Right => 1.0,
};
let ay = match valign {
VAlign::Top => 0.0,
VAlign::Middle => 0.5,
VAlign::Bottom => 1.0,
};
let anchor = pt(*x, *y);
if *rotation == 0.0 {
p.galley(pos2(anchor.x - sz.x * ax, anchor.y - sz.y * ay), galley, rgba(*color));
} else {
// Rotate about the anchor: offset the galley origin by the rotated alignment.
let (s, c) = rotation.sin_cos();
let off = vec2(-sz.x * ax, -sz.y * ay);
let rot = vec2(off.x * c - off.y * s, off.x * s + off.y * c);
let mut shape = egui::epaint::TextShape::new(anchor + rot, galley, rgba(*color));
shape.angle = *rotation;
p.add(shape);
}
}
}
}
}
fn is_convex(v: &[Pos2]) -> bool {
let n = v.len();
let mut sign = 0.0f32;
for i in 0..n {
let a = v[i];
let b = v[(i + 1) % n];
let c = v[(i + 2) % n];
let cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x);
if cross.abs() > 1e-3 {
if sign == 0.0 {
sign = cross.signum();
} else if cross.signum() != sign {
return false;
}
}
}
true
}
/// Area charts produce "mountain" polygons whose bottom edge is a straight baseline: fill them
/// as vertical strips (exact for such shapes, acceptable for others).
fn area_mesh(v: &[Pos2], col: Color32) -> egui::Mesh {
let mut mesh = egui::Mesh::default();
let n = v.len();
if n < 3 {
return mesh;
}
let half = n / 2;
for i in 0..half.saturating_sub(1) {
let a = v[i];
let b = v[i + 1];
let c = v[n - 2 - i];
let d = v[n - 1 - i];
let base = mesh.vertices.len() as u32;
for q in [a, b, c, d] {
mesh.colored_vertex(q, col);
}
mesh.add_triangle(base, base + 1, base + 2);
mesh.add_triangle(base, base + 2, base + 3);
}
mesh
}
/// A shape's text with its formatting: size, bold/italic, typeface, colour, outline, horizontal
/// alignment and vertical anchor. Shapes other than text boxes default to white text centred in
/// the shape; text boxes to black text at the top left.
fn paint_shape_text(p: &Painter, wb: &Workbook, sp: &gridcraft_engine::model::Shape, r: Rect, z: f32) {
use gridcraft_engine::model::{ShapeTextAlign, ShapeTextAnchor};
let f = &sp.format;
let text_box = sp.kind == ShapeKind::TextBox;
let align = f.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center });
let anchor = f.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle });
let default_color = if text_box { Color32::BLACK } else { Color32::WHITE };
let color = f.text_color.and_then(|c| c.resolve(&wb.theme)).map(theme::color32).unwrap_or(default_color);
// Default 14 px (as before); a set size is in points.
let size = f.font_size.map_or(14.0, |pt| pt * 96.0 / 72.0).clamp(1.0, 2000.0) * z;
let family = theme::cell_family(f.font.as_deref().unwrap_or(""), f.bold, f.italic);
let inset = 4.0;
let width = (r.width() - 2.0 * inset).max(1.0);
let mut job = egui::text::LayoutJob::simple(sp.text.clone(), FontId::new(size, family), color, width);
job.halign = match align {
ShapeTextAlign::Left => egui::Align::LEFT,
ShapeTextAlign::Center => egui::Align::Center,
ShapeTextAlign::Right => egui::Align::RIGHT,
};
let g = p.layout_job(job);
let x = match align {
ShapeTextAlign::Left => r.left() + inset,
ShapeTextAlign::Center => r.center().x,
ShapeTextAlign::Right => r.right() - inset,
};
let y = match anchor {
ShapeTextAnchor::Top => r.top() + inset,
ShapeTextAnchor::Middle => r.center().y - g.size().y / 2.0,
ShapeTextAnchor::Bottom => r.bottom() - inset - g.size().y,
};
let pos = pos2(x, y);
let clip = p.with_clip_rect(r.intersect(p.clip_rect()));
// egui has no stroked text: the outline is the text drawn in the outline colour, offset in
// eight directions by the outline width, under the filled text.
if let Some((c, w)) = f.text_outline
&& let Some(oc) = c.resolve(&wb.theme).map(theme::color32)
{
let d = (w * 96.0 / 72.0 * z / 2.0).clamp(0.5, 20.0);
for (dx, dy) in [(-1.0, -1.0), (0.0, -1.0), (1.0, -1.0), (-1.0, 0.0), (1.0, 0.0), (-1.0, 1.0), (0.0, 1.0), (1.0, 1.0)] {
clip.galley_with_override_text_color(pos + vec2(dx * d, dy * d), g.clone(), oc);
}
}
clip.galley(pos, g, color);
}
pub(crate) fn anchor_rect(geo: &Geo, sh: &Sheet, a: &Anchor) -> Rect {
let x = geo.x(sh, a.cell.col) + a.dx * geo.z;
let y = geo.y(sh, a.cell.row) + a.dy * geo.z;
Rect::from_min_size(pos2(x, y), vec2(a.width * geo.z, a.height.max(1.0) * geo.z))
}
/// The shapes one floating object painted (indices into the painter's layer) and its logical
/// rectangle, so a right-to-left sheet can move it as a whole instead of mirroring it.
pub(crate) struct PaintedObject {
pub shapes: std::ops::Range<usize>,
pub rect: Rect,
}
/// Ctrl+6 (Options › Advanced › "For objects, show: Nothing"): objects are neither drawn nor
/// clickable. Placeholders, which Excel no longer offers, show the objects.
pub(crate) fn objects_hidden(wb: &Workbook) -> bool {
wb.show_objects == gridcraft_engine::model::ShowObjects::None
}
pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) -> Vec<PaintedObject> {
let mut painted = Vec::new();
if objects_hidden(wb) {
return painted;
}
// Source outlines stay in cell space beneath every floating object.
if let Some(ch) = sh.charts.iter().find(|ch| app.selected_chart == Some(ch.id))
&& anchor_rect(geo, sh, &ch.anchor).intersects(p.clip_rect())
{
for s in &ch.series {
if let Ok(gridcraft_engine::formula::Expr::Ref(rf)) = gridcraft_engine::formula::parse(&s.values)
&& rf.sheet_name().is_none_or(|n| n.eq_ignore_ascii_case(&sh.name))
{
let rr = geo.range_rect(sh, rf.range());
let color = Tokens::get(p.ctx()).ref_color(0, true);
for pane in geo.range_panes(rf.range()) {
let pp = p.with_clip_rect(pane.intersect(p.clip_rect()));
pp.rect_stroke(rr, 0.0, Stroke::new(1.5, color), StrokeKind::Inside);
}
}
}
}
let mut done = |start: egui::layers::ShapeIdx, rect: Rect| painted.push(PaintedObject { shapes: start.0..p.add(egui::Shape::Noop).0, rect });
// The grid paints the active workbook: its pictures are cached under it.
let doc = app.session.active().map_or(0, |d| d.uid);
for sp in &sh.shapes {
let r = anchor_rect(geo, sh, &sp.anchor);
if !r.intersects(p.clip_rect()) {
continue;
}
let start = p.add(egui::Shape::Noop);
let mut label = true;
let fill = sp.fill.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::TRANSPARENT);
let line =
if sp.format.no_line { Color32::TRANSPARENT } else { sp.line.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::DARK_GRAY) };
match sp.kind {
ShapeKind::Rectangle | ShapeKind::TextBox => {
p.rect_filled(r, 0.0, fill);
p.rect_stroke(r, 0.0, Stroke::new(1.0, line), StrokeKind::Inside);
}
ShapeKind::RoundedRectangle => {
p.rect_filled(r, 10.0 * geo.z, fill);
p.rect_stroke(r, 10.0 * geo.z, Stroke::new(1.0, line), StrokeKind::Inside);
}
ShapeKind::Ellipse => {
let pts: Vec<Pos2> = (0..64)
.map(|i| {
let a = i as f32 / 64.0 * std::f32::consts::TAU;
pos2(r.center().x + r.width() / 2.0 * a.cos(), r.center().y + r.height() / 2.0 * a.sin())
})
.collect();
p.add(PathShape::convex_polygon(pts, fill, Stroke::new(1.0, line)));
}
ShapeKind::Triangle => {
p.add(PathShape::convex_polygon(vec![pos2(r.center().x, r.top()), r.right_bottom(), r.left_bottom()], fill, Stroke::new(1.0, line)));
}
ShapeKind::Ink => {
let w = gridcraft_engine::cmd::draw::ink_width(sp) * geo.z;
let pts: Vec<Pos2> =
gridcraft_engine::cmd::draw::ink_points(sp).iter().map(|q| pos2(r.left() + q[0] * geo.z, r.top() + q[1] * geo.z)).collect();
if pts.len() >= 2 {
let col = if w > 8.0 { line.gamma_multiply(0.45) } else { line };
p.add(PathShape::line(pts, PathStroke::new(w, col)));
}
label = false;
}
ShapeKind::Icon => {
if let Some(icon) = crate::icons::from_name(&sp.text) {
let t = Tokens::get(p.ctx());
crate::icons::paint_on_sheet(p, r, icon, if fill == Color32::TRANSPARENT { t.cell_text } else { fill });
}
label = false;
}
ShapeKind::Line | ShapeKind::Arrow => {
p.line_segment([r.left_top(), r.right_bottom()], Stroke::new(2.0, line));
if sp.kind == ShapeKind::Arrow {
let d = (r.right_bottom() - r.left_top()).normalized();
let n = vec2(-d.y, d.x);
let tip = r.right_bottom();
p.add(PathShape::convex_polygon(vec![tip, tip - d * 10.0 + n * 5.0, tip - d * 10.0 - n * 5.0], line, Stroke::NONE));
}
}
}
if label && !sp.text.is_empty() && !app.editing_text_box(sp.id) {
paint_shape_text(p, wb, sp, r, geo.z);
}
if label && sp.kind == ShapeKind::TextBox && app.selected_chart == Some(sp.id) {
selection_frame(p, r);
}
done(start, r);
}
for im in &sh.images {
let r = anchor_rect(geo, sh, &im.anchor);
if !r.intersects(p.clip_rect()) {
continue;
}
let start = p.add(egui::Shape::Noop);
if let Some(tex) = crate::floating_pictures::texture(p.ctx(), doc, im, r) {
p.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
} else {
p.rect_filled(r, 0.0, Tokens::get(p.ctx()).sheet_track);
}
if app.selected_chart == Some(im.id) {
selection_frame(p, r);
}
done(start, r);
}
for ch in &sh.charts {
let r = anchor_rect(geo, sh, &ch.anchor);
if !r.intersects(p.clip_rect()) {
continue;
}
let start = p.add(egui::Shape::Noop);
// Soft shadow + white card.
p.rect_filled(r.translate(vec2(0.0, 1.5)), 2.0, Color32::from_black_alpha(18));
p.rect_filled(r, 0.0, Tokens::get(p.ctx()).grid_bg);
let data = gridcraft_chart::resolve(wb, si, ch);
let m = EguiMeasure { painter: p, z: geo.z };
let prims = gridcraft_chart::render(ch, &data, ch.anchor.width, ch.anchor.height, &m);
paint_prims(&p.with_clip_rect(r.intersect(p.clip_rect())), r.min, &prims, geo.z);
if app.selected_chart == Some(ch.id) {
selection_frame(p, r);
}
done(start, r);
}
paint_slicers(app, p, geo, wb, si, sh);
painted
}
/// Geometry of a slicer's parts within its screen rect.
pub struct SlicerLayout {
pub header: Rect,
/// Clear-filter button in the header.
pub clear: Rect,
/// One rect per value tile, in order.
pub tiles: Vec<Rect>,
}
pub fn slicer_layout(rect: Rect, z: f32, n: usize, columns: u32) -> SlicerLayout {
let header_h = 26.0 * z;
let pad = 6.0 * z;
let gap = 3.0 * z;
let tile_h = 22.0 * z;
let header = Rect::from_min_size(rect.min, vec2(rect.width(), header_h));
let cb = 16.0 * z;
let clear = Rect::from_min_size(pos2(header.right() - pad - cb, header.top() + (header_h - cb) / 2.0), vec2(cb, cb));
let cols = columns.max(1) as f32;
let inner_left = rect.left() + pad;
let inner_top = header.bottom() + pad;
let inner_w = (rect.width() - 2.0 * pad).max(1.0);
let tile_w = ((inner_w - (cols - 1.0) * gap) / cols).max(1.0);
let mut tiles = Vec::with_capacity(n);
for i in 0..n {
let col = (i as f32) % cols;
let row = (i as f32 / cols).floor();
let x = inner_left + col * (tile_w + gap);
let y = inner_top + row * (tile_h + gap);
tiles.push(Rect::from_min_size(pos2(x, y), vec2(tile_w, tile_h)));
}
SlicerLayout { header, clear, tiles }
}
fn paint_slicers(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) {
let tk = theme::Tokens::get(p.ctx());
for sl in &sh.slicers {
let r = anchor_rect(geo, sh, &sl.anchor);
if !r.intersects(p.clip_rect()) {
continue;
}
let items = gridcraft_engine::cmd::slicer::slicer_items(wb, si, sl);
let lay = slicer_layout(r, geo.z, items.len(), sl.columns);
let radius = 4.0 * geo.z;
// Card + soft shadow.
p.rect_filled(r.translate(vec2(0.0, 1.5)), radius, Color32::from_black_alpha(18));
p.rect_filled(r, radius, tk.window);
p.rect_stroke(r, radius, Stroke::new(1.0, tk.separator), StrokeKind::Inside);
// Header.
p.rect_filled(lay.header, radius, tk.accent_soft);
p.rect_filled(Rect::from_min_max(pos2(lay.header.left(), lay.header.bottom() - radius), lay.header.max), 0.0, tk.accent_soft);
let cap = if sl.caption.is_empty() { sl.column.clone() } else { sl.caption.clone() };
p.text(lay.header.left_center() + vec2(8.0 * geo.z, 0.0), Align2::LEFT_CENTER, cap, theme::ui_bold(12.5 * geo.z), tk.accent_dark);
// Clear-filter button (tinted when a filter is active).
let filtered = sl.selected.is_some();
if let Some(icon) = crate::icons::from_name("filter") {
crate::icons::paint(p, lay.clear, icon, if filtered { tk.accent } else { tk.text_dim });
}
// Value tiles.
for ((val, on), tr) in items.iter().zip(&lay.tiles) {
if !r.contains(tr.left_top()) {
continue; // overflowed the card; skip (no scroll yet)
}
let (fill, fg) = if *on { (tk.accent, tk.on_accent) } else { (tk.hover, tk.text_dim) };
p.rect_filled(*tr, 3.0 * geo.z, fill);
let cp = p.with_clip_rect(tr.intersect(p.clip_rect()));
cp.text(tr.left_center() + vec2(6.0 * geo.z, 0.0), Align2::LEFT_CENTER, val, theme::ui_font(12.0 * geo.z), fg);
}
if app.selected_chart == Some(sl.id) {
selection_frame(p, r);
}
}
}
/// What a click inside a slicer hit.
pub enum SlicerAction {
Toggle(String),
Clear,
}
/// A click on a slicer's value tile or clear button, if any. A click elsewhere in a slicer returns
/// `None` so the caller can treat it as a select/drag on the object.
pub fn slicer_click(geo: &Geo, sh: &Sheet, wb: &Workbook, si: usize, p: Pos2) -> Option<(u32, SlicerAction)> {
if !geo.cells.contains(p) || objects_hidden(wb) {
return None;
}
for sl in sh.slicers.iter().rev() {
let r = anchor_rect(geo, sh, &sl.anchor);
if !r.contains(p) {
continue;
}
let items = gridcraft_engine::cmd::slicer::slicer_items(wb, si, sl);
let lay = slicer_layout(r, geo.z, items.len(), sl.columns);
if lay.clear.contains(p) {
return Some((sl.id, SlicerAction::Clear));
}
for ((val, _on), tr) in items.iter().zip(&lay.tiles) {
if r.contains(tr.left_top()) && tr.contains(p) {
return Some((sl.id, SlicerAction::Toggle(val.clone())));
}
}
return None; // inside this (topmost) slicer, but on its header/background → select/drag
}
None
}
fn selection_frame(p: &Painter, r: Rect) {
let t = Tokens::get(p.ctx());
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Outside);
for c in [r.left_top(), r.center_top(), r.right_top(), r.left_center(), r.right_center(), r.left_bottom(), r.center_bottom(), r.right_bottom()] {
p.circle_filled(c, 4.0, t.sheet_control_bg);
p.circle_stroke(c, 4.0, Stroke::new(1.0, t.sheet_control_border));
}
let _ = Align2::CENTER_CENTER;
}
/// The topmost object under `p`: (kind, id, rect).
pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(&'static str, u32, Rect)> {
if app.session.active().is_some_and(|d| objects_hidden(&d.wb)) {
return None;
}
for sl in sh.slicers.iter().rev() {
let r = anchor_rect(geo, sh, &sl.anchor);
if r.contains(p) && geo.cells.contains(p) {
return Some(("slicer", sl.id, r));
}
}
for ch in sh.charts.iter().rev() {
let r = anchor_rect(geo, sh, &ch.anchor);
if r.expand(4.0).contains(p) && geo.cells.contains(p) {
return Some(("chart", ch.id, r));
}
}
for im in sh.images.iter().rev() {
let r = anchor_rect(geo, sh, &im.anchor);
if r.contains(p) && geo.cells.contains(p) {
return Some(("image", im.id, r));
}
}
for s in sh.shapes.iter().rev() {
let r = anchor_rect(geo, sh, &s.anchor);
if r.expand(3.0).contains(p) && geo.cells.contains(p) {
return Some(("shape", s.id, r));
}
}
None
}