Add shared presets and artwork fitting to Artboard Options

Squashed from #3277 onto main.
This commit is contained in:
bflatastic committed 2026-10-22 10:40:37 +07:00
1 parent 56b7dc300d
commit 438c6194a6
7 files changed
+651 -22

No files matched your search

+96 -6
View File
@@ -1,7 +1,8 @@
//! Artboard Options (double-click with the Artboard tool, double-click the tool, or the Artboards
//! panel's options icon or menu): the artboard's fields, sizes parsed with units, its position
//! from a reference point, and its background color (`color`: a color as the paint commands take
//! it, or null for none). A new size also goes to the other selected artboards.
//! it, or null for none). Sizing choices also go to the other selected artboards; fitting
//! changes only the active artboard around stationary artwork.
use serde_json::{Value, json};
use vectorcraft_color::Color;
@@ -12,6 +13,7 @@ use vectorcraft_geom::{Point, Rect};
use super::{DialogSpec, form, run_and_close};
use crate::VectorcraftApp;
use crate::panels::artboards::{FILLS, fill_colors, fill_name};
use crate::panels::properties::ArtboardSizeChoice;
use crate::panels::transform::{artboard_rect, reference_index, reference_locator};
use crate::state::Dialog;
@@ -34,8 +36,24 @@ fn placed(d: &Dialog) -> Rect {
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let (unit, origin, refi) = (app.session.general_unit(), app.ruler_origin(), reference_index(ui));
let (was, w0, h0) = (placed(d), d.f64("width", 0.0), d.f64("height", 0.0));
let bounds = [fit_bounds(app, ui.ctx(), false), fit_bounds(app, ui.ctx(), true)];
let (choice, fit) = crate::panels::properties::artboard_size_controls_with_fit(app, ui, w0, h0, bounds.map(|b| b.is_some()));
let picked = choice.is_some();
if let Some(choice) = choice {
if let Some((w, h)) = choice.size_for(w0, h0) {
set_rect(d, artboard_rect(was, refi, None, None, Some(w), Some(h), false));
}
remember_size_choice(d, choice);
d.fields.insert("__refx".into(), json!(refi % 3));
d.fields.insert("__refy".into(), json!(refi / 3));
d.fields.remove("__fit");
}
ui.add_space(8.0);
form::grid_then(ui, d, unit, |ui, d| {
let (w, h) = (d.f64("width", 0.0), d.f64("height", 0.0));
if !picked && (w != w0 || h != h0) {
d.fields.remove("__sizeChoice");
}
let n = artboard_rect(was, refi, None, None, Some(w), Some(h), false);
// Only the side that changed moves, so an untouched coordinate stays exactly as it was. The
// reference point's column or row is kept for the other selected artboards (`confirm`).
@@ -47,6 +65,15 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
position_rows(ui, d, refi, unit, origin);
});
// Apply a Fit after the ordinary size/reference-point grid: that grid must not replace
// the chosen artwork's position with one derived from the previous artboard rectangle.
if let Some(selection) = fit
&& let Some(bounds) = bounds[usize::from(selection)]
{
set_rect(d, bounds);
d.fields.insert("__fit".into(), json!(true));
d.fields.remove("__sizeChoice");
}
ui.add_space(8.0);
let color = d.fields.get("color").and_then(color_value);
let fills = fill_colors(app);
@@ -68,6 +95,59 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
false
}
/// Store document-coordinate draft bounds, retaining an editable minimum for zero-area art.
fn set_rect(d: &mut Dialog, r: Rect) {
for (key, value) in [("x", r.x0), ("y", r.y0), ("width", r.width().max(1.0)), ("height", r.height().max(1.0))] {
d.fields.insert(key.into(), json!(value));
}
}
/// Preserve preset/orientation intent separately from typed dimensions for the selected set.
fn remember_size_choice(d: &mut Dialog, choice: ArtboardSizeChoice) {
let value = match choice {
ArtboardSizeChoice::Preset(w, h) => json!({"preset": [w, h]}),
ArtboardSizeChoice::Orientation(landscape) => json!({"landscape": landscape}),
};
d.fields.insert("__sizeChoice".into(), value);
}
/// Validate stored sizing intent before deriving the other selected artboards' dimensions.
fn size_choice(d: &Dialog) -> Option<ArtboardSizeChoice> {
let value = d.fields.get("__sizeChoice")?;
if let Some(landscape) = value.get("landscape").and_then(Value::as_bool) {
return Some(ArtboardSizeChoice::Orientation(landscape));
}
let preset = value.get("preset")?.as_array()?;
let (w, h) = (preset.first()?.as_f64()?, preset.get(1)?.as_f64()?);
(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0).then_some(ArtboardSizeChoice::Preset(w, h))
}
#[derive(Clone)]
struct ArtworkFitBounds {
key: (u64, u64),
bounds: Option<Rect>,
}
/// Artwork fitting includes all drawn artwork, even outside every artboard; selection uses
/// the shared selection cache. Draft/target geometry never restricts membership. Global artwork
/// bounds are cached only for the active document and its revision.
fn fit_bounds(app: &mut VectorcraftApp, ctx: &egui::Context, selection: bool) -> Option<Rect> {
if selection {
return app.selection_bounds();
}
let st = app.session.active()?;
let key = (st.uid, st.revision);
let id = egui::Id::new("artboard-options-artwork-fit-bounds");
if let Some(cached) = ctx.data_mut(|data| data.get_temp::<ArtworkFitBounds>(id))
&& cached.key == key
{
return cached.bounds;
}
let bounds = vectorcraft_render::encode::art_bounds(&st.doc);
ctx.data_mut(|data| data.insert_temp(id, ArtworkFitBounds { key, bounds }));
bounds
}
/// The X and Y rows: where reference point `refi` lies, counted from the ruler origin `origin`,
/// with the locator before X's label. A typed X or Y moves the artboard so that its reference point
/// lands there.
@@ -119,10 +199,12 @@ pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
Ok(json!({ "dialog": KIND }))
}
/// OK sets the artboard's fields. A Width or Height that differs from the artboard's also goes to
/// the other selected artboards (when this one is among them), each resized about the reference
/// OK sets the artboard's fields. A preset or orientation choice applies separately to each
/// selected artboard; a typed Width or Height that differs also goes to the others (when this
/// one is among them), each resized about the reference
/// point the form's last Width or Height change used (fields `__refx` and `__refy`, its column and
/// row; the top-left corner when the form set none), all in one undo step.
/// row; the top-left corner when the form set none), all in one undo step. Fitting applies only
/// to this artboard and never moves its artwork.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
if d.bool("new") {
return confirm_new(app, d);
@@ -142,17 +224,25 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (w, h) = (changed("width", was.map(|r| r.width())), changed("height", was.map(|r| r.height())));
let part = |k: &str| d.fields.get(k).and_then(Value::as_u64).map_or(0, |v| v.min(2) as usize);
let refi = part("__refy") * 3 + part("__refx");
let choice = size_choice(d);
let mut entries = vec![];
if w.is_some() || h.is_some() {
if !d.bool("__fit") && (choice.is_some() || w.is_some() || h.is_some()) {
for i in crate::panels::artboards::resized_with(app, index).into_iter().filter(|i| *i != index) {
if let Some(r) = app.session.active().and_then(|st| st.doc.artboards.get(i)).map(|a| a.rect) {
let (w, h) = if let Some(choice) = choice {
let Some((w, h)) = choice.size_for(r.width(), r.height()) else { continue };
(Some(w), Some(h))
} else {
(w, h)
};
let n = artboard_rect(r, refi, None, None, w, h, false);
entries.push(json!({"index": i, "x": n.x0, "y": n.y0, "width": n.width(), "height": n.height()}));
}
}
}
entries.insert(0, p);
let common = json!({"moveArt": crate::panels::artboards::move_art(app)});
// Fitting changes the artboard around existing art; it must never move that art.
let common = json!({"moveArt": !d.bool("__fit") && crate::panels::artboards::move_art(app)});
let p = crate::panels::artboards::set_props_params(common, entries).ok_or("no artboard")?;
run_and_close(app, "artboard.setProps", p)
}
+397
View File
@@ -1792,3 +1792,400 @@ fn polar_grid_shared_cancel_reopen_zero_counts_and_failed_creation() {
assert_eq!(app.session.tool_options_of("polarGrid"), options);
assert_eq!(app.session.doc().unwrap().doc, before);
}
/// Open the real Preset menu before choosing a Fit; these actions are no longer body buttons.
fn artboard_fit_preset_at(frames: &mut Frames, app: &mut VectorcraftApp, label: &str) -> egui::Pos2 {
let texts = frames.texts(app);
assert!(texts.iter().all(|(t, _)| !matches!(t.as_str(), "Fit to Artwork Bounds" | "Fit to Selected Art")), "Fit lives only in Preset");
let row = texts.iter().find(|(t, _)| t == "Preset:").unwrap().1;
let combo = frames.ctx.viewport(|vp| {
vp.prev_pass
.widgets
.layers()
.flat_map(|(_, widgets)| widgets.iter())
.filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right())
.map(|w| w.rect)
.min_by(|a, b| a.left().total_cmp(&b.left()))
.unwrap()
.center()
});
frames.press(app, combo, true);
frames.press(app, combo, false);
frames.texts(app).into_iter().find(|(text, _)| text.trim() == label).unwrap().1.center()
}
#[test]
fn artboard_options_fit_drafts_cancel_and_apply_without_moving_art() {
for selected in [false, true] {
let mut app = app();
app.run("shape.rectangle", json!({"x":30,"y":40,"width":80,"height":60})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
let before = app.session.doc().unwrap().doc.clone();
for apply in [false, true] {
artboard_options::open(&mut app).unwrap();
let mut frames = Frames::new();
let label = if selected { "Fit to Selected Art" } else { "Fit to Artwork Bounds" };
let at = artboard_fit_preset_at(&mut frames, &mut app, label);
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
assert_eq!(app.session.doc().unwrap().doc, before, "draft only");
if !apply {
cancel(&mut app);
continue;
}
confirm(&mut app).unwrap();
assert_eq!(app.session.doc().unwrap().doc.layers, before.layers, "art stays in place");
assert_eq!(app.session.doc().unwrap().doc.artboards[0].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 110.0, 100.0));
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
}
}
}
#[test]
fn artboard_options_shared_preset_drafts_and_resizes_selected_boards_in_their_orientations() {
let mut app = app();
app.run("file.new", json!({"width":200,"height":100,"artboards":3})).unwrap();
app.run("artboard.setProps", json!({"index":2,"width":150,"height":300})).unwrap();
app.select_tool("artboard");
crate::panels::artboards::select(&mut app, 0);
app.session.set_tool_option("selected", &json!([0, 2]));
let before = app.session.doc().unwrap().doc.clone();
for apply in [false, true] {
artboard_options::open(&mut app).unwrap();
let mut frames = Frames::new();
let at = |texts: &[(String, egui::Rect)], label: &str| {
texts.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label:?} in {texts:?}"))
};
let custom = at(&frames.texts(&mut app), "Custom");
frames.press(&mut app, custom, true);
frames.press(&mut app, custom, false);
let print = at(&frames.texts(&mut app), "Print");
frames.run(&mut app, vec![egui::Event::PointerMoved(print)]);
let letter = at(&frames.texts(&mut app), "Letter");
frames.press(&mut app, letter, true);
frames.press(&mut app, letter, false);
assert_eq!(app.session.doc().unwrap().doc, before, "preset is a draft until OK");
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.f64("width", 0.0), d.f64("height", 0.0)), (792.0, 612.0));
if !apply {
cancel(&mut app);
assert_eq!(app.session.doc().unwrap().doc, before);
continue;
}
let steps = app.session.doc().unwrap().history.undo.len();
confirm(&mut app).unwrap();
let doc = &app.session.doc().unwrap().doc;
assert_eq!((doc.artboards[0].rect.width(), doc.artboards[0].rect.height()), (792.0, 612.0));
assert_eq!((doc.artboards[2].rect.width(), doc.artboards[2].rect.height()), (612.0, 792.0));
assert_eq!(doc.artboards[1], before.artboards[1], "unselected board stays unchanged");
assert_eq!(app.session.doc().unwrap().history.undo.len(), steps + 1);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
}
}
#[test]
fn artboard_options_orientation_applies_to_other_selected_boards_when_active_is_already_portrait() {
let mut app = app();
app.run("file.new", json!({"width":100,"height":200,"artboards":3})).unwrap();
app.run("artboard.setProps", json!({"index":2,"width":300,"height":150})).unwrap();
app.select_tool("artboard");
crate::panels::artboards::select(&mut app, 0);
app.session.set_tool_option("selected", &json!([0, 2]));
let before = app.session.doc().unwrap().doc.clone();
artboard_options::open(&mut app).unwrap();
let mut frames = Frames::new();
let texts = frames.texts(&mut app);
let row = texts.iter().find(|(t, _)| t == "Preset:").unwrap().1;
let mut buttons: Vec<egui::Rect> = frames.ctx.viewport(|vp| {
vp.prev_pass
.widgets
.layers()
.flat_map(|(_, w)| w.iter())
.filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right())
.map(|w| w.rect)
.collect()
});
buttons.sort_by(|a, b| a.left().total_cmp(&b.left()));
let portrait = buttons[buttons.len() - 2].center();
frames.press(&mut app, portrait, true);
frames.press(&mut app, portrait, false);
assert_eq!(app.session.doc().unwrap().doc, before, "orientation remains a draft");
confirm(&mut app).unwrap();
let doc = &app.session.doc().unwrap().doc;
assert_eq!(doc.artboards[0], before.artboards[0], "active board was already portrait");
assert_eq!((doc.artboards[2].rect.width(), doc.artboards[2].rect.height()), (150.0, 300.0));
assert_eq!(doc.artboards[1], before.artboards[1]);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
}
#[test]
fn artboard_options_fit_changes_only_active_board_even_with_an_artboard_multiselection() {
let mut app = app();
app.run("file.new", json!({"width":200,"height":200,"artboards":3})).unwrap();
app.run("shape.rectangle", json!({"x":30,"y":40,"width":80,"height":60})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
app.select_tool("artboard");
crate::panels::artboards::select(&mut app, 0);
app.session.set_tool_option("selected", &json!([0, 2]));
let before = app.session.doc().unwrap().doc.clone();
artboard_options::open(&mut app).unwrap();
let mut frames = Frames::new();
let fit = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds");
frames.press(&mut app, fit, true);
frames.press(&mut app, fit, false);
assert_eq!(app.session.doc().unwrap().doc, before, "fit remains a draft");
confirm(&mut app).unwrap();
let doc = &app.session.doc().unwrap().doc;
assert_eq!(doc.artboards[0].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 110.0, 100.0));
assert_eq!(&doc.artboards[1..], &before.artboards[1..]);
assert_eq!(doc.layers, before.layers, "fitting never moves artwork");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
}
#[test]
fn artboard_options_fit_artwork_ignores_draft_membership_and_invalidates_on_edits() {
let mut app = app();
app.run("file.new", json!({"width":400,"height":320})).unwrap();
app.run("shape.rectangle", json!({"x":30,"y":40,"width":80,"height":60})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
let boards = app.session.doc().unwrap().doc.artboards.clone();
artboard_options::open(&mut app).unwrap();
let dialog = app.ui.dialog.as_mut().unwrap();
dialog.fields.insert("width".into(), json!(1));
dialog.fields.insert("height".into(), json!(1));
let mut frames = Frames::new();
let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds");
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
let dialog = app.ui.dialog.as_ref().unwrap();
assert_eq!(
(dialog.f64("x", 0.0), dialog.f64("y", 0.0), dialog.f64("width", 0.0), dialog.f64("height", 0.0)),
(30.0, 40.0, 80.0, 60.0),
"Fit queries all artwork, not the shrunken draft"
);
assert_eq!(app.session.doc().unwrap().doc.artboards, boards);
// A remote command can edit the document while the modal remains open.
app.run("shape.rectangle", json!({"x":600,"y":120,"width":40,"height":30})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
let layers = app.session.doc().unwrap().doc.layers.clone();
let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds");
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
let dialog = app.ui.dialog.as_ref().unwrap();
assert_eq!(
(dialog.f64("x", 0.0), dialog.f64("y", 0.0), dialog.f64("width", 0.0), dialog.f64("height", 0.0)),
(30.0, 40.0, 610.0, 110.0),
"A document revision must invalidate cached fit bounds"
);
confirm(&mut app).unwrap();
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
assert_eq!(app.session.doc().unwrap().doc.artboards[0].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 640.0, 150.0));
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.artboards, boards);
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.artboards[0].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 640.0, 150.0));
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
}
#[test]
fn artboard_options_fit_artwork_includes_all_boards_and_is_independent_of_target() {
let mut app = app();
app.run("file.new", json!({"width":400,"height":320,"artboards":2})).unwrap();
let boards = app.session.doc().unwrap().doc.artboards.clone();
for (index, width, height) in [(0, 80.0, 60.0), (1, 40.0, 30.0)] {
let rect = boards[index].rect;
app.run("shape.rectangle", json!({"x":rect.x0+30.0,"y":rect.y0+40.0,"width":width,"height":height})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
}
let layers = app.session.doc().unwrap().doc.layers.clone();
let expected =
vectorcraft_geom::Rect::new(boards[0].rect.x0 + 30.0, boards[0].rect.y0 + 40.0, boards[0].rect.x0 + 110.0, boards[0].rect.y0 + 100.0).union(
vectorcraft_geom::Rect::new(boards[1].rect.x0 + 30.0, boards[1].rect.y0 + 40.0, boards[1].rect.x0 + 70.0, boards[1].rect.y0 + 70.0),
);
app.select_tool("artboard");
let mut frames = Frames::new();
for index in [0, 1, 0] {
crate::panels::artboards::select(&mut app, index);
artboard_options::open(&mut app).unwrap();
let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds");
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
let dialog = app.ui.dialog.as_ref().unwrap();
assert_eq!(
(dialog.f64("x", 0.0), dialog.f64("y", 0.0), dialog.f64("width", 0.0), dialog.f64("height", 0.0)),
(expected.x0, expected.y0, expected.width(), expected.height())
);
cancel(&mut app);
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
assert_eq!(app.session.doc().unwrap().doc.artboards, boards);
}
}
#[test]
fn artboard_options_fit_artwork_new_board_includes_art_outside_its_draft() {
let mut app = app();
app.run("file.new", json!({"width":400,"height":320})).unwrap();
app.run("shape.rectangle", json!({"x":450,"y":40,"width":80,"height":60})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
app.run("shape.rectangle", json!({"x":30,"y":170,"width":20,"height":20})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
let boards = app.session.doc().unwrap().doc.artboards.clone();
let layers = app.session.doc().unwrap().doc.layers.clone();
app.run("ui.newArtboard", json!({})).unwrap();
assert!(app.ui.dialog.as_ref().unwrap().bool("new"));
let mut frames = Frames::new();
let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds");
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
let dialog = app.ui.dialog.as_ref().unwrap();
assert_eq!((dialog.f64("x", 0.0), dialog.f64("y", 0.0), dialog.f64("width", 0.0), dialog.f64("height", 0.0)), (30.0, 40.0, 500.0, 150.0));
confirm(&mut app).unwrap();
let after = app.session.doc().unwrap().doc.artboards.clone();
assert_eq!(after.len(), 2);
assert_eq!(after[0], boards[0]);
assert_eq!(after[1].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 530.0, 190.0));
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.artboards, boards);
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.artboards, after);
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
}
/// Fit and background color share one draft and one undo step for existing and new boards.
#[test]
fn artboard_options_fit_and_background_color_coexist_through_cancel_undo_and_redo() {
for new in [false, true] {
for apply in [false, true] {
let mut app = app();
app.run("file.new", json!({"width":400,"height":320})).unwrap();
app.run("artboard.setProps", json!({"index":0,"color":"#336699"})).unwrap();
let x = if new { 450.0 } else { 30.0 };
app.run("shape.rectangle", json!({"x":x,"y":40,"width":80,"height":60})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
let before = app.session.doc().unwrap().doc.clone();
let steps = app.session.doc().unwrap().history.undo.len();
if new {
app.run("ui.newArtboard", json!({})).unwrap();
} else {
artboard_options::open(&mut app).unwrap();
}
let labels = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx()));
assert!(labels.contains("Background Color:") && labels.contains("Preset:") && !labels.contains("Fit to Artwork Bounds"), "{labels}");
app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!("#c05030"));
let mut frames = Frames::new();
let fit = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds");
frames.press(&mut app, fit, true);
frames.press(&mut app, fit, false);
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["color"], json!("#c05030"));
assert_eq!(app.session.doc().unwrap().doc, before, "both changes remain a draft");
if !apply {
cancel(&mut app);
assert_eq!(app.session.doc().unwrap().doc, before);
assert_eq!(app.session.doc().unwrap().history.undo.len(), steps);
continue;
}
confirm(&mut app).unwrap();
let after = app.session.doc().unwrap().doc.clone();
let board = &after.artboards[usize::from(new)];
assert_eq!(board.rect, vectorcraft_geom::Rect::new(x, 40.0, x + 80.0, 100.0));
assert_eq!(board.color.map(|c| c.to_hex()).as_deref(), Some("#c05030"));
assert_eq!(after.layers, before.layers, "fitting never moves the art");
if new {
assert_eq!(after.artboards[0], before.artboards[0]);
}
assert_eq!(app.session.doc().unwrap().history.undo.len(), steps + 1);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, after);
}
}
}
/// Moving only the board, with no resize, must not translate its art even when Move Artwork is on.
#[test]
fn artboard_options_position_only_fit_keeps_art_with_move_art_enabled() {
let mut app = app();
app.run("file.new", json!({"width":80,"height":60})).unwrap();
app.run("shape.rectangle", json!({"x":10,"y":10,"width":20,"height":20})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
app.run("shape.rectangle", json!({"x":70,"y":50,"width":20,"height":20})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
app.run("tool.setOption", json!({"tool":"artboard", "values":{"moveArt":true}})).unwrap();
assert!(crate::panels::artboards::move_art(&app));
let before = app.session.doc().unwrap().doc.clone();
artboard_options::open(&mut app).unwrap();
let mut frames = Frames::new();
let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds");
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
confirm(&mut app).unwrap();
let after = app.session.doc().unwrap().doc.clone();
assert_eq!(after.artboards[0].rect, vectorcraft_geom::Rect::new(10.0, 10.0, 90.0, 70.0));
assert_eq!(after.layers, before.layers, "position-only fitting never moves art");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, after);
}
#[test]
fn artboard_options_fit_preset_disabled_without_art_or_selection() {
for has_art in [false, true] {
for label in ["Fit to Artwork Bounds", "Fit to Selected Art"] {
if has_art && label == "Fit to Artwork Bounds" {
continue;
}
let mut app = app();
if has_art {
app.run("shape.rectangle", json!({"x":600,"y":40,"width":80,"height":60})).unwrap();
app.run("select.none", json!({})).unwrap();
}
let before = app.session.doc().unwrap().doc.clone();
artboard_options::open(&mut app).unwrap();
let mut frames = Frames::new();
let at = artboard_fit_preset_at(&mut frames, &mut app, label);
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
let draft = app.ui.dialog.as_ref().unwrap();
assert_ne!(draft.fields.get("__fit"), Some(&json!(true)), "disabled {label} must not become a fit draft");
assert_eq!(app.session.doc().unwrap().doc, before);
cancel(&mut app);
assert_eq!(app.session.doc().unwrap().doc, before);
}
}
}
#[test]
fn artboard_options_selected_fit_spans_other_boards_and_off_board_art() {
let mut app = app();
app.run("file.new", json!({"width":200,"height":200,"artboards":3})).unwrap();
for (x, y, w, h) in [(30, 40, 80, 60), (1000, 500, 50, 40)] {
app.run("shape.rectangle", json!({"x":x,"y":y,"width":w,"height":h})).unwrap();
app.run("paint.setStroke", json!({"none":true})).unwrap();
}
app.run("select.all", json!({})).unwrap();
let before = app.session.doc().unwrap().doc.clone();
artboard_options::open(&mut app).unwrap();
let mut frames = Frames::new();
let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Selected Art");
frames.press(&mut app, at, true);
frames.press(&mut app, at, false);
assert_eq!(app.session.doc().unwrap().doc, before, "selection fit is a draft");
confirm(&mut app).unwrap();
let after = app.session.doc().unwrap().doc.clone();
assert_eq!(after.artboards[0].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 1050.0, 540.0));
assert_eq!(after.artboards[1..], before.artboards[1..]);
assert_eq!(after.layers, before.layers);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, after);
}
+62 -15
View File
@@ -323,6 +323,58 @@ fn turned(landscape: bool, pw: f64, ph: f64) -> (f64, f64) {
/// width and height of each selected artboard that is not square and not already in it. Each
/// top-left corner stays put.
pub(crate) fn artboard_size_row(app: &mut VectorcraftApp, ui: &mut Ui, i: usize, w: f64, h: f64) {
let Some(choice) = artboard_size_controls(app, ui, w, h) else { return };
let scale_art = super::artboards::scale_art(app);
let indices = super::artboards::resized_with(app, i);
super::artboards::set_props(app, &indices, json!({"scaleArt": scale_art}), |j, r| {
let (width, height) = choice.size_for(r.width(), r.height())?;
Some(json!({"index": j, "width": width, "height": height}))
})
.ok();
}
/// A shared picker choice, applied separately to each artboard's orientation.
#[derive(Clone, Copy)]
pub(crate) enum ArtboardSizeChoice {
Preset(f64, f64),
Orientation(bool),
}
impl ArtboardSizeChoice {
pub(crate) fn size_for(self, w: f64, h: f64) -> Option<(f64, f64)> {
match self {
Self::Preset(pw, ph) => Some(turned(w > h, pw, ph)),
Self::Orientation(landscape) if (w > h) != landscape && w != h => Some((h, w)),
Self::Orientation(_) => None,
}
}
}
/// Shared existing presets/orientation UI. Dialogs keep the chosen size in their draft until OK.
pub(crate) fn artboard_size_controls(app: &VectorcraftApp, ui: &mut Ui, w: f64, h: f64) -> Option<ArtboardSizeChoice> {
artboard_size_controls_inner(app, ui, w, h, None).0
}
/// The dialog's shared preset/orientation picker with optional artwork and selection fits.
/// The second return value is a chosen Fit: false for all artwork, true for selected artwork.
pub(crate) fn artboard_size_controls_with_fit(
app: &VectorcraftApp,
ui: &mut Ui,
w: f64,
h: f64,
enabled: [bool; 2],
) -> (Option<ArtboardSizeChoice>, Option<bool>) {
artboard_size_controls_inner(app, ui, w, h, Some(enabled))
}
/// Properties and Control keep their ordinary picker; Artboard Options adds two Fit entries.
fn artboard_size_controls_inner(
app: &VectorcraftApp,
ui: &mut Ui,
w: f64,
h: f64,
fits: Option<[bool; 2]>,
) -> (Option<ArtboardSizeChoice>, Option<bool>) {
let landscape = w > h;
let same = |s: &newdoc::DocSettings| {
let (pw, ph) = turned(landscape, s.width, s.height);
@@ -338,10 +390,19 @@ pub(crate) fn artboard_size_row(app: &mut VectorcraftApp, ui: &mut Ui, i: usize,
// The preset's width and height as listed, or the orientation clicked.
let mut preset = None;
let mut orientation = None;
let mut fit = None;
ui.horizontal(|ui| {
dim_label(ui, tl!("Preset:"));
preset = widgets::combo(ui, "ab-preset", &current, 140.0, false, |ui| {
let mut chosen = None;
if let Some(enabled) = fits {
for (selection, label) in [(false, tl!("Fit to Artwork Bounds")), (true, tl!("Fit to Selected Art"))] {
if widgets::menu_item_name(ui, label, enabled[usize::from(selection)], false) {
fit = Some(selection);
}
}
ui.separator();
}
for (cat, list) in &cats {
ui.menu_button(tl!(cat), |ui| {
widgets::menu_scroll(ui, |ui| {
@@ -362,21 +423,7 @@ pub(crate) fn artboard_size_row(app: &mut VectorcraftApp, ui: &mut Ui, i: usize,
}
}
});
if preset.is_none() && orientation.is_none() {
return;
}
let scale_art = super::artboards::scale_art(app);
let indices = super::artboards::resized_with(app, i);
super::artboards::set_props(app, &indices, json!({"scaleArt": scale_art}), |j, r| {
let (w, h) = (r.width(), r.height());
let (width, height) = match (preset, orientation) {
(Some((pw, ph)), _) => turned(w > h, pw, ph),
(None, Some(l)) if (w > h) != l && w != h => (h, w),
_ => return None,
};
Some(json!({"index": j, "width": width, "height": height}))
})
.ok();
(preset.map(|(w, h)| ArtboardSizeChoice::Preset(w, h)).or_else(|| orientation.map(ArtboardSizeChoice::Orientation)), fit)
}
/// Artboard `i`'s Fill, its background `color`: the chip shows it (the None paint when the artboard
+24
View File
@@ -2,6 +2,28 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradientLine truncated
## Feature submission evidence
**Feature evidence last updated:** 2026-10-21.
The shared assessment status line and revision table retain the feature-base snapshot; merging retains upstream's current shared assessment and history. This dated section records the submission's feature evidence and does not rescore upstream or claim a new combined build or runtime measurement.
- Current-main conflict reconciliation retains upstream and feature behavior; scores and estimates unchanged
- Superseded 2026-10-20 behavior, retained as history: Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting uses the original target artboard and revision-scoped bounds caches
- Artboard Options puts Fit to Artwork Bounds and Fit to Selected Art in the Preset menu. Artwork bounds cover all drawn document art, including other artboards and off-board art; selection bounds may span artboards. Fit changes only the active artboard and keeps artwork stationary, including position-only fitting with Move Artwork enabled. Existing Background Color and presets remain available; scores and estimates unchanged.
### Feature revisions
| Date | Change | Summary |
|---|---|---|
| 2026-10-21 | minor | Correct current global-all-art and document/revision cache descriptions; mark target-scoped fitting as superseded 2026-10-20 history. Runtime, tests, scores and estimates unchanged. |
| 2026-10-21 | minor | Artboard Options puts Fit to Artwork Bounds and Fit to Selected Art in the Preset menu. Artwork bounds cover all drawn document art, including other artboards and off-board art; selection bounds may span artboards. Fit changes only the active artboard and keeps artwork stationary, including position-only fitting with Move Artwork enabled. Existing Background Color and presets remain available; scores and estimates unchanged. |
| 2026-10-21 | minor | Reconcile feature status and revision evidence in a dated section; retain shared baseline metadata for independent upstream updates. Scores and estimates are not reassessed. |
| 2026-10-21 | minor | Reconcile this feature with current main, preserving both sets of behavior and progress evidence; scores and estimates unchanged. |
| 2026-10-20 | minor | Review follow-up: artwork fitting uses the original target artboard and revision-scoped bounds caches; focused regression evidence and behavior documentation updated; scores and hours unchanged. |
| 2026-10-20 | minor | Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; scores and hours unchanged. |
Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from
switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make
progress on, write a plan, and update the entry (and [target-app-parity.md](target-app-parity.md)) in the same PR.
@@ -45,6 +67,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
- **Polygon/Star count persistence:** Exact-size placement saves the chosen count for later drags and reopening the existing canvas dialog. Regression coverage includes both tools, repeated drags, switching tools and Cancel. The remaining G1 scope and estimates are unchanged.
- **Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards:** Shared existing preset definitions are reused; no external preset assets are added. Presets preserve each selected artboard's orientation, and orientation choices apply even when the active board already matches. Fit operations draft bounds until OK, affect only the active artboard and leave artwork in place. Regression coverage checks draft/Cancel, selected-set application, active-only fitting and Undo. Broader scope and estimates unchanged.
- **Implemented, regression-tested and contributor-confirmed:** status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates. Original-file acceptance confirmed 2026-10-19; broader G1 scope and estimates unchanged. Sample widths are cached by language, document unit and pixels per point rather than remeasured every frame.
- **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. G1 scope and estimates are unchanged.
+23 -1
View File
@@ -2,6 +2,28 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation; M14: panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); estimates unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; M12 duplicate milestone rows consolidated without losing distinct details; scores and estimates unchanged; live-trace bounds note placed under Image Trace rather than Graphs; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; M3.57: CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; DXF circular cubic paths export as native CIRCLE/ARC with R12 weighted strokes kept as POLYLINE and closing-chord safeguards (#1299); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 graph pruning and junction merging measured; M12 exact crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: M12's Image Trace quick presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgLine truncated
## Feature submission evidence
**Feature evidence last updated:** 2026-10-21.
The shared assessment status line and revision table retain the feature-base snapshot; merging retains upstream's current shared assessment and history. This dated section records the submission's feature evidence and does not rescore upstream or claim a new combined build or runtime measurement.
- Current-main conflict reconciliation retains upstream and feature behavior; scores and estimates unchanged
- Superseded 2026-10-20 behavior, retained as history: Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting uses the original target artboard and revision-scoped bounds caches
- Artboard Options puts Fit to Artwork Bounds and Fit to Selected Art in the Preset menu. Artwork bounds cover all drawn document art, including other artboards and off-board art; selection bounds may span artboards. Fit changes only the active artboard and keeps artwork stationary, including position-only fitting with Move Artwork enabled. Existing Background Color and presets remain available; scores and estimates unchanged.
### Feature revisions
| Date | Change | Summary |
|---|---|---|
| 2026-10-21 | minor | Correct current global-all-art and document/revision cache descriptions; mark target-scoped fitting as superseded 2026-10-20 history. Runtime, tests, scores and estimates unchanged. |
| 2026-10-21 | minor | Artboard Options puts Fit to Artwork Bounds and Fit to Selected Art in the Preset menu. Artwork bounds cover all drawn document art, including other artboards and off-board art; selection bounds may span artboards. Fit changes only the active artboard and keeps artwork stationary, including position-only fitting with Move Artwork enabled. Existing Background Color and presets remain available; scores and estimates unchanged. |
| 2026-10-21 | minor | Reconcile feature status and revision evidence in a dated section; retain shared baseline metadata for independent upstream updates. Scores and estimates are not reassessed. |
| 2026-10-21 | minor | Reconcile this feature with current main, preserving both sets of behavior and progress evidence; scores and estimates unchanged. |
| 2026-10-20 | minor | Review follow-up: artwork fitting uses the original target artboard and revision-scoped bounds caches; focused regression evidence and behavior documentation updated; scores and hours unchanged. |
| 2026-10-20 | minor | Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; scores and hours unchanged. |
Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and
[target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5
agent wall-clock hours, one agent. There is no `cargo xtask scorecard` or `cargo xtask parity` in this repo yet
@@ -62,7 +84,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Photocopy, Stamp, Torn Edges and Water Paper done (54/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged) | 🟡 | 15–25 |
| M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged) | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; broader scripting and batch work remains | 18–27 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
+26
View File
@@ -2,6 +2,28 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation; UI chrome: panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); scores and hours unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: manual Tate-chu-yoko; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours uncLine truncated
## Feature submission evidence
**Feature evidence last updated:** 2026-10-21.
The shared assessment status line and revision table retain the feature-base snapshot; merging retains upstream's current shared assessment and history. This dated section records the submission's feature evidence and does not rescore upstream or claim a new combined build or runtime measurement.
- Current-main conflict reconciliation retains upstream and feature behavior; scores and estimates unchanged
- Superseded 2026-10-20 behavior, retained as history: Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting uses the original target artboard and revision-scoped bounds caches (scores/hours unchanged)
- Artboard Options puts Fit to Artwork Bounds and Fit to Selected Art in the Preset menu. Artwork bounds cover all drawn document art, including other artboards and off-board art; selection bounds may span artboards. Fit changes only the active artboard and keeps artwork stationary, including position-only fitting with Move Artwork enabled. Existing Background Color and presets remain available; scores and estimates unchanged.
### Feature revisions
| Date | Change | Summary |
|---|---|---|
| 2026-10-21 | minor | Correct current global-all-art and document/revision cache descriptions; mark target-scoped fitting as superseded 2026-10-20 history. Runtime, tests, scores and estimates unchanged. |
| 2026-10-21 | minor | Artboard Options puts Fit to Artwork Bounds and Fit to Selected Art in the Preset menu. Artwork bounds cover all drawn document art, including other artboards and off-board art; selection bounds may span artboards. Fit changes only the active artboard and keeps artwork stationary, including position-only fitting with Move Artwork enabled. Existing Background Color and presets remain available; scores and estimates unchanged. |
| 2026-10-21 | minor | Reconcile feature status and revision evidence in a dated section; retain shared baseline metadata for independent upstream updates. Scores and estimates are not reassessed. |
| 2026-10-21 | minor | Reconcile this feature with current main, preserving both sets of behavior and progress evidence; scores and estimates unchanged. |
| 2026-10-20 | minor | Review follow-up: artwork fitting uses the original target artboard and revision-scoped bounds caches; focused regression evidence and behavior documentation updated; scores and hours unchanged. |
| 2026-10-20 | minor | Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; scores and hours unchanged. |
The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is
the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md),
[file formats](file-format-parity.md), [hardware](hardware-parity.md), [languages](localization-parity.md),
@@ -398,6 +420,10 @@ since 2026-10-01.
**Drawing tools — Polygon and Star counts:** The existing canvas placement dialog reads the stored count and saves it only after successful placement. `polygon_and_star_canvas_counts_persist_without_new_toolbar_dialogs` covers both tools, repeated drags, tool switching, reopening and Cancel. Counts remain session tool options. Existing click and toolbar gestures are retained; no score or hour change.
**Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views:** `live_trace_result_bounds_exclude_the_retained_bitmap_at_any_angle` checks every TraceView, geometric and visual bounds and rotated result bounds. `live_trace_selection_highlight_excludes_the_retained_source_rectangle` checks result highlights and the existing ordinary-group policy. Shared editing-child traversal also preserves Shaper handling.
**Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards:** Shared existing preset definitions are reused; no external preset assets are added. Presets preserve each selected artboard's orientation, and orientation choices apply even when the active board already matches. Fit operations draft bounds until OK, affect only the active artboard and leave artwork in place. Regression coverage checks draft/Cancel, selected-set application, active-only fitting and Undo.
**Review follow-up:** The fit-query follow-up covers fitting after draft dimensions change, updated artwork after a document revision, cache scope and new-artboard fitting/history. Selection uses the existing application bounds cache; global artwork queries include all drawn document art and are cached by active document and revision; draft geometry and target artboards do not limit membership. This is not a native timing claim.
- **Stable status-bar readouts:** The status message keeps its left anchor and clipping allocation as live values, canvas hover and background-job state change. Coordinate and spinner slots stay reserved; narrow windows hide optional diagnostics, then coordinates, to preserve message space. Widths are measured from the active font, translated labels and fixed numeric samples, never current readings. Coordinates through +/-9999.99 selected units, timings through 9999.9 ms and FPS through 9999 remain readable. Extreme values are elided with full tooltip/accessibility text; only elided fields show a tooltip. Coordinate inspection retains the last point per document and clears on PointerGone. Regressions cover message origin/clip/bounds/elision invariance, all registered languages and ten units, responsive layouts, coordinate inspection and document switching, and accessible busy transitions. The contributor rebuilt and confirmed the original status-bar issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. Sample widths use a bounded single-entry UI cache keyed by language, unit and pixels per point; two regressions verify reuse during live updates and invalidation after each display-setting change.
- **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged.
+23
View File
@@ -2,6 +2,28 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; Panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); scores unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top, G1; Distribute Objects and Distribute Spacing space from a key guide (#3165) with the Distribute Spacing value; Distribute spaces selected ruler guides with the objects, #1597 in part; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; a ruler guide can be the key object Align aligns to, and selected guides align to a key object, #2143; Pencil: Alt key toggles to Smooth Tool, #2001; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; Background Export progress dialog with Cancel, #2564; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; scores unchanged; library panels: one per library, dockable and tabbable, with Persistent, #2603; the Selection tool threads text from the out port, #2572 in part; scores unchanged; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; scores unchanged; transparency grid visibility saved in native documents, #1968; Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; WidthLine truncated
## Feature submission evidence
**Feature evidence last updated:** 2026-10-21.
The shared assessment status line and revision table retain the feature-base snapshot; merging retains upstream's current shared assessment and history. This dated section records the submission's feature evidence and does not rescore upstream or claim a new combined build or runtime measurement.
- Current-main conflict reconciliation retains upstream and feature behavior; scores and estimates unchanged
- Superseded 2026-10-20 behavior, retained as history: Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting uses the original target artboard and revision-scoped bounds caches
- Artboard Options puts Fit to Artwork Bounds and Fit to Selected Art in the Preset menu. Artwork bounds cover all drawn document art, including other artboards and off-board art; selection bounds may span artboards. Fit changes only the active artboard and keeps artwork stationary, including position-only fitting with Move Artwork enabled. Existing Background Color and presets remain available; scores and estimates unchanged.
### Feature revisions
| Date | Change | Summary |
|---|---|---|
| 2026-10-21 | minor | Correct current global-all-art and document/revision cache descriptions; mark target-scoped fitting as superseded 2026-10-20 history. Runtime, tests, scores and estimates unchanged. |
| 2026-10-21 | minor | Artboard Options puts Fit to Artwork Bounds and Fit to Selected Art in the Preset menu. Artwork bounds cover all drawn document art, including other artboards and off-board art; selection bounds may span artboards. Fit changes only the active artboard and keeps artwork stationary, including position-only fitting with Move Artwork enabled. Existing Background Color and presets remain available; scores and estimates unchanged. |
| 2026-10-21 | minor | Reconcile feature status and revision evidence in a dated section; retain shared baseline metadata for independent upstream updates. Scores and estimates are not reassessed. |
| 2026-10-21 | minor | Reconcile this feature with current main, preserving both sets of behavior and progress evidence; scores and estimates unchanged. |
| 2026-10-20 | minor | Review follow-up: artwork fitting uses the original target artboard and revision-scoped bounds caches; focused regression evidence and behavior documentation updated; scores and hours unchanged. |
| 2026-10-20 | minor | Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; scores and hours unchanged. |
How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's
trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to,
how far an arrow key nudges. This checklist is the work list for the **interaction-fidelity pass**
@@ -185,6 +207,7 @@ tools, never checked side by side) isn't captured row by row.
| Artboard history prunes removed indices while preserving valid multiselection and tool options | tested | Headless pointer/key tests in `panels/properties.rs` cover panel New → Undo → Delete/Backspace/Properties, Duplicate → Undo → Delete, partial pruning and valid multiselection. They check surviving board records, artwork, object selection, Move/Scale Artwork options, saved state and Undo/Redo. Full workspace CI passes. |
| Click an object with the Artboard tool to create an artboard fitted to its visible bounds | done | Topmost editable object or group; drag and modified clicks retain their meaning; one undo step (`artboard_click.rs`, #1227) |
| Artboard Options: X and Y beside a reference point locator, counted from the ruler origin; a typed X or Y moves the artboard's reference point there, a new Width or Height keeps it in place | done | #1876; as the Transform panel's and the Control bar's artboard X and Y, with the locator they and Properties share; the shape dialogs' X and Y (the click position) stay hidden. Pointer and key tests in `dialogs/tests.rs` |
| Artboard Options shares existing presets/orientation and drafts artwork or selected-art fitting until OK | done | Fit changes only the active artboard and preserves artwork positions; Cancel leaves the document unchanged; newer multi-artboard behavior retained; fitting after draft resizing uses all drawn document artwork and document/revision-scoped bounds; selected art may span boards |
| Shape tools: Shift constrains, Alt from centre, Space moves while dragging, arrows change sides/points/rows | done | |
| Polygon/Star exact-size placement remembers the chosen sides/points for later drags and dialog openings; Cancel preserves the previous count | done | Session tool options; existing dialog-opening gestures retained; regression in `toolbar.rs` |
| Tool double-click opens its options | done | |