M1: Crop image from the Object menu, the right-click menu and the image fill row (#1464) (#1501)

* M1: Crop image from the Object menu, the right-click menu and the image fill row (#1464)

* M1: crop mode shows the image dimmed outside the layer and heavier crop marks (#1464)

* M1: the handle overlay draws gradients and crops in their own helpers (#1464)
This commit is contained in:
BFlat
2026-10-29 15:07:21 +07:00
committed by GitHub
parent 7c9184b023
commit 7ba22fb2b6
22 changed files with 573 additions and 81 deletions
+1 -8
View File
@@ -546,15 +546,8 @@ impl Session {
}
let Some(data) = doc.images.get(&img.image) else { continue };
let (iw, ih) = (f64::from(data.width), f64::from(data.height));
let crop = if img.scale_mode == ImageScaleMode::Crop {
img.effective_crop(iw, ih, w, h)
} else {
let (rw, rh) = img.rotated_size(iw.max(1.0), ih.max(1.0));
let s = if img.scale_mode == ImageScaleMode::Fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) };
[w / (rw * s), 0.0, 0.0, h / (rh * s), (rw * s - w) / (2.0 * rw * s), (rh * s - h) / (2.0 * rh * s)]
};
// A degenerate (zero-scale) layer has no inverse: its image keeps its placement.
let crop = (Affine::new(crop) * to_old).as_coeffs();
let crop = (Affine::new(img.placement_crop(iw, ih, w, h)) * to_old).as_coeffs();
if !crop.iter().all(|c| c.is_finite()) {
continue;
}
+3 -1
View File
@@ -151,6 +151,8 @@ pub static MENU: &[MenuItem] = &[
m!("Object", "Use as mask", "object.mask"),
m!("Object", "Release mask", "object.releaseMask"),
m!("Object", "Set as thumbnail", "object.setThumbnail"),
// FigCraft's own (in `OWN`): the way into crop mode that the Design panel also offers.
m!("Object", "Crop image", "image.crop"),
m!("Object", SEP, ""),
m!("Object", "Bring to front", "arrange.front"),
m!("Object", "Bring forward", "arrange.forward"),
@@ -290,7 +292,7 @@ pub fn items() -> impl Iterator<Item = &'static MenuItem> {
}
/// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones).
const OWN: [&str; 4] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts"];
const OWN: [&str; 5] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts", "image.crop"];
/// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer.
pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
+46 -4
View File
@@ -1,12 +1,12 @@
//! Image paints: adjustment presets, built in and saved in the file.
//! Image paints: adjustment presets, built in and saved in the file, and Crop image.
use figcraft_color::{ImageFilters, PaintKind};
use figcraft_doc::{Document, ImagePreset};
use figcraft_color::{ImageFilters, ImageScaleMode, PaintKind};
use figcraft_doc::{Document, ImagePreset, Node};
use serde_json::{Value, json};
use super::{CommandSpec, ok};
use crate::params::{f64_of, get, num, req_index, req_string, req_targets, string, targets};
use crate::{Result, Session, bad, spec};
use crate::{EngineError, Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -34,6 +34,13 @@ pub fn specs() -> Vec<CommandSpec> {
}
ok()
}),
spec!(
"image.crop",
"Crop image",
"{ids? (one layer), index? (an image fill; default its top visible one)} → {id, index}: the image fill switches to Crop, keeping the picture where it was, and the canvas enters crop mode on it",
true,
crop
),
spec!("image.presets", "Image presets", "{} → {builtIn: [{id, label, filters}], saved: [{name, filters}]}", false, |s, _| {
let built_in: Vec<Value> = ImageFilters::PRESETS.iter().map(|(id, label, f)| json!({"id": id, "label": label, "filters": f})).collect();
Ok(json!({"builtIn": built_in, "saved": s.doc()?.image_presets}))
@@ -135,6 +142,41 @@ fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"changed": changed}))
}
/// The index of a layer's top visible image fill (fills paint bottom to top).
pub fn top_image_fill(n: &Node) -> Option<usize> {
n.fills.iter().rposition(|f| f.visible && matches!(f.kind, PaintKind::Image(_)))
}
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "image.crop";
let ids = req_targets(cmd, s, p)?;
// A selection with nothing to crop is refused as unavailable, not as bad input.
let refuse = |why: &str| EngineError::Disabled(cmd.into(), why.into());
let [id] = ids[..] else { return Err(refuse("select one layer to crop its image")) };
let doc = s.doc()?;
let n = doc.get(id)?;
let index = match get(p, "index") {
None | Some(Value::Null) => top_image_fill(n).ok_or_else(|| refuse("the layer has no image fill to crop"))?,
Some(_) => req_index(cmd, p, "index")?,
};
let mut paint = n.fills.get(index).cloned().ok_or_else(|| bad(cmd, "no fill at that index"))?;
let PaintKind::Image(img) = &mut paint.kind else { return Err(bad(cmd, "that fill is not an image")) };
// An image already in Crop mode only enters crop mode: no edit, no undo step.
if img.scale_mode != ImageScaleMode::Crop {
let data = doc.images.get(&img.image).ok_or_else(|| bad(cmd, "the image's data is missing"))?;
img.crop = Some(img.placement_crop(f64::from(data.width), f64::from(data.height), n.width, n.height));
img.scale_mode = ImageScaleMode::Crop;
let n = s.doc_mut()?.get_mut(id)?;
if let Some(f) = n.fills.get_mut(index) {
*f = paint;
}
// Like any paint edit, the layer leaves its paint style.
n.styles.fill = None;
}
s.request_ui(cmd, json!({"id": id, "index": index}));
Ok(json!({"id": id, "index": index}))
}
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "image.savePreset";
let name: String = req_string(cmd, p, "name")?.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect();
+110
View File
@@ -0,0 +1,110 @@
//! `image.crop` (Object ▸ Crop image, the right-click menu and the image fill row): the layer's
//! top visible image fill (or the one `index` names) switches to Crop with the picture left where
//! its old mode drew it, as one undo step, and the canvas is asked to enter crop mode. A layer
//! without an image fill, or several layers, are refused with a reason and nothing changes.
use base64::Engine as _;
use figcraft_color::{ImageScaleMode, Paint, PaintKind};
use figcraft_doc::NodeId;
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
/// A 200×100 synthetic image layer (every pixel differs), resized to 200×150 so Fill and Fit
/// place the picture differently.
fn image_layer(s: &mut Session) -> NodeId {
let image = image::RgbaImage::from_fn(200, 100, |x, y| image::Rgba([x as u8, y as u8, 120, 255]));
let mut bytes = std::io::Cursor::new(Vec::new());
image.write_to(&mut bytes, image::ImageFormat::Png).unwrap();
let data = base64::engine::general_purpose::STANDARD.encode(bytes.into_inner());
let id = id_of(&ex(s, "file.import", json!({"data": data, "name": "crop.png", "x": 0, "y": 0}))["id"]).unwrap();
ex(s, "node.set", json!({"ids": [id], "props": {"height": 150}}));
id
}
fn fills(s: &Session, id: NodeId) -> Vec<Paint> {
s.doc().unwrap().node(id).unwrap().fills.clone()
}
fn set_mode(s: &mut Session, id: NodeId, mode: ImageScaleMode) {
let mut f = fills(s, id);
let PaintKind::Image(img) = &mut f[0].kind else { panic!("image fill") };
img.scale_mode = mode;
ex(s, "node.set", json!({"ids": [id], "props": {"fills": f}}));
}
fn pixels(s: &Session, id: NodeId) -> Vec<u8> {
figcraft_render::render_node(s.doc().unwrap(), id, 1.0).unwrap().data().to_vec()
}
fn undo_steps(s: &Session) -> usize {
s.ds().unwrap().history.undo.len()
}
#[test]
fn crop_image_switches_to_crop_keeping_the_picture_as_one_undo_step() {
for mode in [ImageScaleMode::Fill, ImageScaleMode::Fit, ImageScaleMode::Crop] {
let mut s = Session::new();
let layer = image_layer(&mut s);
set_mode(&mut s, layer, mode);
let (before, doc_before, steps) = (pixels(&s, layer), s.doc().unwrap().clone(), undo_steps(&s));
s.ui_requests.clear();
let r = ex(&mut s, "image.crop", json!({}));
assert_eq!(r, json!({"id": layer, "index": 0}), "{mode:?}");
let PaintKind::Image(img) = &fills(&s, layer)[0].kind else { panic!("image fill") };
assert_eq!(img.scale_mode, ImageScaleMode::Crop, "{mode:?}");
assert_eq!(pixels(&s, layer), before, "{mode:?}: the picture stays where it was");
let asked: Vec<_> = s.ui_requests.iter().filter(|u| u.kind == "image.crop").map(|u| u.data.clone()).collect();
assert_eq!(asked, vec![json!({"id": layer, "index": 0})], "{mode:?}: the canvas enters crop mode");
if mode == ImageScaleMode::Crop {
assert_eq!(undo_steps(&s), steps, "already cropped: nothing to undo");
continue;
}
assert_eq!(undo_steps(&s), steps + 1, "{mode:?}: one undo step");
ex(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap(), &doc_before, "{mode:?}: undo restores the fill");
}
}
#[test]
fn crop_image_takes_the_top_visible_image_fill_or_the_one_named() {
let mut s = Session::new();
let layer = image_layer(&mut s);
let mut f = fills(&s, layer);
let image = f[0].clone();
let mut hidden = image.clone();
hidden.visible = false;
// Bottom to top: image, image, solid, hidden image.
f = vec![image.clone(), image, Paint::solid(figcraft_color::Color::BLACK), hidden];
ex(&mut s, "node.set", json!({"ids": [layer], "props": {"fills": f}}));
assert_eq!(ex(&mut s, "image.crop", json!({"ids": [layer]}))["index"], 1, "the top visible image fill");
let modes =
|s: &Session| fills(s, layer).iter().map(|p| if let PaintKind::Image(i) = &p.kind { Some(i.scale_mode) } else { None }).collect::<Vec<_>>();
assert_eq!(modes(&s), [Some(ImageScaleMode::Fill), Some(ImageScaleMode::Crop), None, Some(ImageScaleMode::Fill)]);
assert_eq!(ex(&mut s, "image.crop", json!({"ids": [layer], "index": 0}))["index"], 0, "an image fill by index");
assert_eq!(modes(&s)[0], Some(ImageScaleMode::Crop));
let err = s.execute("image.crop", &json!({"ids": [layer], "index": 2})).unwrap_err().to_string();
assert!(err.contains("not an image"), "{err}");
let err = s.execute("image.crop", &json!({"ids": [layer], "index": 9})).unwrap_err().to_string();
assert!(err.contains("no fill at that index"), "{err}");
}
#[test]
fn crop_image_is_refused_without_one_image_layer() {
let mut s = Session::new();
let rect = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 300, "y": 0, "width": 50, "height": 50}))["id"]).unwrap();
let layer = image_layer(&mut s);
let doc = s.doc().unwrap().clone();
s.ui_requests.clear();
for (ids, why) in [(json!([rect]), "no image fill"), (json!([rect, layer]), "select one layer")] {
let err = s.execute("image.crop", &json!({"ids": ids})).unwrap_err();
assert!(matches!(&err, crate::EngineError::Disabled(..)) && err.to_string().contains(why), "{err}");
}
assert_eq!(s.doc().unwrap(), &doc, "nothing changes");
assert!(s.ui_requests.is_empty(), "no crop mode");
}
+2
View File
@@ -307,6 +307,8 @@ mod huge_stroke_tests;
#[cfg(test)]
mod id_params_behaviour_tests;
#[cfg(test)]
mod image_crop_tests;
#[cfg(test)]
mod images_left_in_file_tests;
#[cfg(test)]
mod import_export_tests;