Merge remote-tracking branch 'origin/main' into fix/6388-ai-variable-font-instance

# Conflicts:
#	docs/gaps.md
This commit is contained in:
ruchirguitar
2026-11-11 01:35:57 +05:30
64 changed files with 1714 additions and 162 deletions
+5 -5
View File
@@ -205,12 +205,12 @@ fn a_stroked_open_path_builds_a_ribbon() {
/// still draws.
#[test]
fn a_stroke_past_the_limits_is_left_out() {
// 1,500 corners: within the 2,048 points of the fill's solid, but not the stroke's outline.
let points: Vec<Point> = (0..1500)
// 40,000 corners: within the 65,536 points of the fill's solid, but not the stroke's outline.
let points: Vec<Point> = (0..40_000)
.map(|k| {
let (s, c) = (k as f64 * std::f64::consts::TAU / 1500.0).sin_cos();
let r = if k % 2 == 0 { 100.0 } else { 96.0 };
Point::new(200.0 + r * c, 200.0 + r * s)
let (s, c) = (k as f64 * std::f64::consts::TAU / 40_000.0).sin_cos();
let r = if k % 2 == 0 { 10_000.0 } else { 9_999.0 };
Point::new(20_000.0 + r * c, 20_000.0 + r * s)
})
.collect();
let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
+40 -2
View File
@@ -165,13 +165,13 @@ pub struct BuiltinBrushLibrary {
make: fn() -> BrushLibrary,
}
/// The built-in brush libraries, in menu order: original brushes generated in code, one library
/// per kind of brush.
/// The built-in brush libraries, in menu order: original brushes generated in code.
pub const BUILTIN: &[BuiltinBrushLibrary] = &[
BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons },
BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs },
BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders },
BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes },
BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails },
];
/// Built-in library `id`, made once.
@@ -378,6 +378,44 @@ fn scatter_shapes() -> BrushLibrary {
built("Scatter Shapes", doc, brushes)
}
/// Game Trails: art brushes for motion lines and impact streaks (original game HUD / VFX art).
fn game_trails() -> BrushLibrary {
let speed = {
let n = 32;
let half = |x: f64| 2.2 * (1.0 - x / 100.0).powf(1.2).max(0.08);
let xs = |i: usize| 100.0 * i as f64 / n as f64;
let mut pts: Vec<Point> = (0..=n).map(xs).map(|x| Point::new(x, -half(x))).collect();
pts.extend((0..n).rev().map(xs).map(|x| Point::new(x, half(x))));
PathData::single(SubPath::polyline(&pts, true))
};
let dash = group(vec![
black(shapes::rounded_rectangle(Rect::new(0.0, -1.0, 14.0, 1.0), 1.0)),
black(shapes::rounded_rectangle(Rect::new(22.0, -1.0, 36.0, 1.0), 1.0)),
black(shapes::rounded_rectangle(Rect::new(44.0, -1.0, 58.0, 1.0), 1.0)),
black(shapes::rounded_rectangle(Rect::new(66.0, -1.0, 80.0, 1.0), 1.0)),
black(shapes::rounded_rectangle(Rect::new(88.0, -1.0, 100.0, 1.0), 1.0)),
]);
let burst = {
// A wedge that flares from a tip at x=0 to a wide end at x=100.
let tip = Point::new(0.0, 0.0);
let top = Point::new(100.0, -6.0);
let bot = Point::new(100.0, 6.0);
PathData::single(SubPath::polyline(&[tip, top, bot], true))
};
let zigzag = band(100.0, 1.8, 40, |x| {
let t = (x / 12.5).floor();
let local = (x / 12.5) - t;
if (t as i32) % 2 == 0 { -3.0 + 6.0 * local } else { 3.0 - 6.0 * local }
});
let brushes = vec![
art("Speed Line", black(speed), Colorization::Tints),
art("Dash Trail", dash, Colorization::Tints),
art("Burst Ray", black(burst), Colorization::Tints),
art("Zigzag", black(zigzag), Colorization::Tints),
];
built("Game Trails", Document::new(100.0, 100.0), brushes)
}
// ---------- commands ----------
fn builtin_info(b: &BuiltinBrushLibrary) -> LibraryInfo {
+71
View File
@@ -85,6 +85,15 @@ pub fn specs() -> Vec<CommandSpec> {
always,
change_word
),
cmd!(
query "text.spelling.editDictionary",
"Edit Custom Dictionary",
[],
None,
"{remove?: [word…], add?: [word…] (each one word, at most 64 characters; at most 10000 of each)} deletes the words of remove that are there and adds those of add, then saves once: all of it, or none of it when the save fails (Edit Custom Dictionary's Done) → {removed: [word…], added: [word…]}",
always,
edit_dictionary
),
cmd!(
query "text.spelling.ignoreAll",
"Ignore All",
@@ -223,6 +232,12 @@ fn suggest(_s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "suggestions": suggestions }))
}
/// `w` as the custom dictionary keeps it (trimmed, lower case), or None when it isn't one word of at
/// most `craft_proof::MAX_CUSTOM_WORD_CHARS` characters: what `text.spelling.addWord` takes.
pub fn custom_word(w: &str) -> Option<String> {
craft_proof::custom_word(w)
}
/// `p`'s `key` as a custom-dictionary word (trimmed, lower case), or an error naming what it must be.
fn word_param(p: &Value, key: &str, command: &str) -> Result<String> {
let w = str_param(p, key).ok_or_else(|| bad(command, format!("missing `{key}`")))?;
@@ -342,6 +357,62 @@ fn change_word(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "word": to }))
}
/// Most words `text.spelling.editDictionary` takes in each of its lists.
const MAX_EDIT_WORDS: usize = 10_000;
/// `p`'s `key`, a list of custom-dictionary words (none when it is missing), deduplicated.
fn word_list(p: &Value, key: &str, command: &str) -> Result<BTreeSet<String>> {
let Some(v) = p.get(key) else { return Ok(BTreeSet::new()) };
let words = v.as_array().ok_or_else(|| bad(command, format!("`{key}` must be a list of words")))?;
if words.len() > MAX_EDIT_WORDS {
return Err(bad(command, format!("`{key}` holds more than {MAX_EDIT_WORDS} words")));
}
words
.iter()
.map(|w| {
w.as_str()
.and_then(craft_proof::custom_word)
.ok_or_else(|| bad(command, format!("`{key}` must hold single words of at most {} characters", craft_proof::MAX_CUSTOM_WORD_CHARS)))
})
.collect()
}
/// `text.spelling.editDictionary`: Edit Custom Dictionary's Done. Every delete and add is made,
/// then saved once; when the save fails, all of them are taken back, so a failed Done leaves the
/// dictionary as it was rather than half changed.
fn edit_dictionary(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.spelling.editDictionary";
let (remove, add) = (word_list(p, "remove", C)?, word_list(p, "add", C)?);
let before: BTreeSet<String> = craft_proof::user_dictionary().into_iter().collect();
// A word in both lists stays; a word to delete that another window deleted already is skipped.
let removed: Vec<String> = remove.iter().filter(|w| !add.contains(*w) && before.contains(*w)).cloned().collect();
let added: Vec<String> = add.iter().filter(|w| !before.contains(*w)).cloned().collect();
if removed.is_empty() && added.is_empty() {
return Ok(json!({ "removed": removed, "added": added }));
}
for w in &removed {
craft_proof::remove_word(w);
}
for w in &added {
craft_proof::add_word(w);
}
let saved = save_dictionary(s, &removed, C);
dictionary_changed();
saved.inspect_err(|_| {
for w in &added {
craft_proof::remove_word(w);
}
for w in &removed {
craft_proof::add_word(w);
}
// Words the save took in from the file stay, as for one word.
if !added.is_empty() {
retake_file_words(s);
}
})?;
Ok(json!({ "removed": removed, "added": added }))
}
/// `text.spelling.ignoreAll`: Ignore All, for the active document.
fn ignore_all(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.spelling.ignoreAll";
+15 -8
View File
@@ -502,6 +502,8 @@ struct Job {
/// The documents open when the run began, by [`DocState::uid`].
start_uids: Vec<u64>,
name: String,
/// The file-access scope `script.run` came in, which every slice runs in.
scope: crate::file_access::ScopeHandle,
}
/// The `Data` for a JSON value, within `limits`. When it does not fit, the error is the place in
@@ -986,7 +988,8 @@ impl Session {
self.script_report = Some(report);
return Err(EngineError::Other(message));
}
let job = Job { realm, host, world: World::working(self), start_uids: open, name: request.name };
let scope = crate::file_access::ScopeHandle::capture();
let job = Job { realm, host, world: World::working(self), start_uids: open, name: request.name, scope };
let id = NEXT_JOB.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
JOBS.with(|jobs| jobs.borrow_mut().insert(id, job));
self.script_key = Some(JobKey(id));
@@ -1170,20 +1173,24 @@ fn failure_text(report: &Value) -> String {
}
/// A run's slice: the working copy in, the real session lent to the host functions, one measured
/// slice run, and everything back. A panic in the interpreter is the outer `Err`.
/// slice run in the file-access scope the run began in, and everything back. A panic in the
/// interpreter is the outer `Err`.
fn run_slice(s: &mut Session, job: &mut Job) -> std::result::Result<std::result::Result<Progress, RunError>, String> {
let depth = std::mem::replace(&mut s.depth, 0);
job.world.swap(s);
s.script_in_run = true;
let cell = job.host.session.clone();
std::mem::swap(s, &mut *cell.borrow_mut());
let scope = job.scope.clone();
let realm = &mut job.realm;
let r = guard::catch_panic(|| {
#[cfg(test)]
if PANIC_IN_SLICE.with(Cell::get) {
panic!("a slice failed");
}
realm.advance(SLICE_FUEL)
let r = scope.run(|| {
guard::catch_panic(|| {
#[cfg(test)]
if PANIC_IN_SLICE.with(Cell::get) {
panic!("a slice failed");
}
realm.advance(SLICE_FUEL)
})
});
std::mem::swap(s, &mut *cell.borrow_mut());
s.script_in_run = false;
+14 -2
View File
@@ -104,7 +104,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
let list = run(&mut s, "brush.libraries", json!({}));
let libs = list["libraries"].as_array().unwrap();
let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect();
assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes"]);
assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails"]);
assert!(libs.iter().all(|l| l["category"] == "builtIn"));
let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"}));
assert_eq!(
@@ -112,8 +112,20 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
["Notched Ribbon", "Double Line", "Wave", "Teardrop", "Striped Ribbon"]
);
assert_eq!(libs[0]["count"].as_u64(), Some(5));
let trails = run(&mut s, "brush.library", json!({"library": "game-trails"}));
assert_eq!(
trails["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::<Vec<_>>(),
["Speed Line", "Dash Trail", "Burst Ray", "Zigzag"]
);
assert_eq!(libs[4]["count"].as_u64(), Some(4));
// One kind of brush per library, every brush drawing something.
for (id, kind) in [("art-ribbons", "art"), ("calligraphic-nibs", "calligraphic"), ("pattern-borders", "pattern"), ("scatter-shapes", "scatter")] {
for (id, kind) in [
("art-ribbons", "art"),
("calligraphic-nibs", "calligraphic"),
("pattern-borders", "pattern"),
("scatter-shapes", "scatter"),
("game-trails", "art"),
] {
let (_, lib) = cmd::brushlib::library(&s, id).unwrap();
assert!(lib.len() >= 4, "{id}");
for b in &lib.brushes {
+38
View File
@@ -862,6 +862,44 @@ fn a_file_saved_before_a_failure_stays_written_and_is_named() {
let _ = std::fs::remove_file(&path);
}
/// A run in the app's frames keeps the file-access scope its `script.run` came in, whatever scope
/// the frames that advance it run in.
#[test]
fn a_framed_run_keeps_the_file_access_scope_it_started_in() {
let dir = std::env::temp_dir().join(format!("vc-script-scope-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (allowed, outside) = (dir.join("allowed"), dir.join("outside"));
std::fs::create_dir_all(&allowed).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let roots = crate::file_access::AutomationRoots::new(&[&allowed], &[&allowed]).unwrap().unwrap();
let export = |target: &std::path::Path| {
let p = target.to_string_lossy().into_owned();
format!("try {{ vc.run('document.export', {{path: {p:?}, format: 'svg'}}); vc.log('ok'); }} catch (e) {{ vc.log(e.name); }}")
};
let finish = |s: &mut Session| {
loop {
if let Some(r) = s.script_pump() {
return r;
}
}
};
let mut s = two_docs();
s.script_frames = true;
let confined_target = outside.join("confined.svg");
let started = crate::file_access::confine(Some(&roots), || run(&mut s, &export(&confined_target))).unwrap();
assert_eq!(started["running"], true);
let r = crate::file_access::unconfined(|| finish(&mut s)).unwrap();
assert_eq!(r["log"], json!(["Error"]), "a confined caller's run stays confined: {r}");
assert!(!confined_target.exists());
let user_target = outside.join("user.svg");
let started = crate::file_access::unconfined(|| run(&mut s, &export(&user_target))).unwrap();
assert_eq!(started["running"], true);
let r = crate::file_access::confine(Some(&roots), || finish(&mut s)).unwrap();
assert_eq!(r["log"], json!(["ok"]), "a run the person started is not confined by a later caller: {r}");
assert!(user_target.exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_committed_run_exports_what_it_drew() {
let path = std::env::temp_dir().join(format!("vc-script-export-{}.svg", std::process::id()));
+37
View File
@@ -260,6 +260,43 @@ fn a_change_that_cant_be_saved_is_taken_back() {
run(&mut s, "text.spelling.removeWord", json!({"word": "wkqzvx"}));
}
/// Edit Custom Dictionary's Done: its deletes and adds are saved together, or, when the save
/// fails, none of them is kept (a delete saved before an add that failed would stay otherwise).
#[test]
fn a_dictionary_edit_is_saved_whole_or_not_at_all() {
let _one_at_a_time = dictionary_lock();
const EDIT: &str = "text.spelling.editDictionary";
let dir = vectorcraft_testkit::temp_dir("spelling-edit");
let file = dir.join("Custom Dictionary.txt");
std::fs::write(&file, "pqzvka\npqzvkb\n").unwrap();
let mut s = session();
s.attach_custom_dictionary(file.clone());
// A word already gone and a word already there are skipped; added words are kept in lower case.
let r = run(&mut s, EDIT, json!({"remove": ["pqzvka", "pqzvkz"], "add": ["PqzvkC", "pqzvkb"]}));
assert_eq!(r, json!({"removed": ["pqzvka"], "added": ["pqzvkc"]}));
let saved = std::fs::read_to_string(&file).unwrap();
let lines: Vec<&str> = saved.lines().collect();
assert!(lines.contains(&"pqzvkb") && lines.contains(&"pqzvkc") && !lines.contains(&"pqzvka"), "{saved}");
// A save that fails takes back every delete and add.
let folder = dir.join("folder");
std::fs::create_dir_all(&folder).unwrap();
let mut failing = session();
failing.attach_custom_dictionary(folder);
assert!(failing.execute(EDIT, &json!({"remove": ["pqzvkb", "pqzvkc"], "add": ["pqzvkd"]})).is_err());
assert!(in_dictionary(&mut s, "pqzvkb") && in_dictionary(&mut s, "pqzvkc"), "the deletes are taken back");
assert!(!in_dictionary(&mut s, "pqzvkd"), "the add is taken back");
// A list that isn't one of words is refused before anything changes.
let many: Vec<String> = (0..10_001).map(|i| format!("pqzv{i}")).collect();
for p in [json!({"remove": "pqzvkb"}), json!({"add": ["a b"]}), json!({"add": [5]}), json!({"remove": ["pqzvkb"], "add": many})] {
assert!(s.execute(EDIT, &p).is_err(), "{EDIT} refuses a bad list");
assert!(in_dictionary(&mut s, "pqzvkb"), "{EDIT} changed nothing");
}
let e = s.execute(EDIT, &json!({"add": ["a b"]})).unwrap_err().to_string();
assert!(e.contains("must hold single words of at most 64 characters"), "{e}");
run(&mut s, EDIT, json!({"remove": ["pqzvkb", "pqzvkc"]}));
assert!(!in_dictionary(&mut s, "pqzvkb") && !in_dictionary(&mut s, "pqzvkc"));
}
#[cfg(unix)]
#[test]
fn a_word_another_window_saved_stays_when_this_save_fails() {
@@ -127,6 +127,30 @@ fn set_bounds_moves_and_sizes_the_ink_box() {
assert!((ink(&s, t).height() - before.height()).abs() < 1e-6, "{:?}", ink(&s, t));
}
/// The font size the panels show follows the vertical scale: a typed width alone changes the
/// horizontal scale and keeps the font size, and a typed height alone changes the font size and
/// keeps the width, so the horizontal scale changes the other way.
#[test]
fn a_typed_width_changes_the_horizontal_scale_and_a_typed_height_the_font_size() {
let shown = |s: &Session, t: NodeId| {
let o = text(s, t);
let st = o.effective_char_style(&o.runs[0].style).unwrap();
(st.size, st.h_scale)
};
let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"}));
glyphs(&mut s, true);
let before = ink(&s, t);
run(&mut s, "object.setBounds", json!({"width": before.width() * 2.0}));
let (size, h_scale) = shown(&s, t);
assert!((size - 100.0).abs() < 1e-6 && (h_scale - 200.0).abs() < 1e-6, "{size} pt at {h_scale}%");
let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"}));
glyphs(&mut s, true);
run(&mut s, "object.setBounds", json!({"height": before.height() * 2.0}));
let (size, h_scale) = shown(&s, t);
assert!((size - 200.0).abs() < 1e-6 && (h_scale - 50.0).abs() < 1e-6, "{size} pt at {h_scale}%");
assert!((ink(&s, t).width() - before.width()).abs() < 1e-6, "{:?}", ink(&s, t));
}
#[test]
fn set_bounds_with_the_current_values_records_no_undo_step() {
let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"}));
+79 -14
View File
@@ -1,5 +1,8 @@
//! Character classes of ECMA-262 5.1 section 7: white space, line terminators and the characters
//! identifiers are made of.
//! identifiers are made of. Identifiers follow the Unicode `ID_Start` and `ID_Continue` rule of
//! later editions.
use std::cmp::Ordering;
use unicode_general_category::{GeneralCategory as G, get_general_category};
@@ -14,35 +17,97 @@ pub fn is_whitespace(c: u32) -> bool {
matches!(c, 0x09 | 0x0B | 0x0C | 0x20 | 0xA0 | 0xFEFF | 0x1680 | 0x2000..=0x200A | 0x202F | 0x205F | 0x3000)
}
/// A character an identifier may start with: a letter (categories Lu, Ll, Lt, Lm, Lo and Nl),
/// `$` or `_` (section 7.6). `\u` escapes are decoded before this check.
/// A character an identifier may start with: a Unicode `ID_Start` character, `$` or `_`
/// (the Names and Keywords clause of current ECMA-262 editions). `ID_Start` is the letters (categories Lu, Ll, Lt, Lm, Lo and Nl)
/// and `Other_ID_Start`, less `Pattern_Syntax` and `Pattern_White_Space` (Unicode Standard Annex
/// #31). The categories come from Unicode 16.0. `\u` escapes are decoded before this check.
pub fn is_id_start(c: u32) -> bool {
match c {
0x24 | 0x5F => true,
0x41..=0x5A | 0x61..=0x7A => true,
0..=0x7F => false,
// U+2E2F VERTICAL TILDE is a letter (Lm) that Unicode leaves out of identifiers.
0x2E2F => false,
_ => category(c).is_some_and(|g| {
matches!(g, G::UppercaseLetter | G::LowercaseLetter | G::TitlecaseLetter | G::ModifierLetter | G::OtherLetter | G::LetterNumber)
}),
_ if is_other_id_start(c) => true,
_ => {
category(c).is_some_and(|g| {
matches!(g, G::UppercaseLetter | G::LowercaseLetter | G::TitlecaseLetter | G::ModifierLetter | G::OtherLetter | G::LetterNumber)
}) && !is_pattern_syntax(c)
}
}
}
/// A character an identifier may hold after its first: those it may start with, decimal digits
/// (Nd), combining marks (Mn, Mc), connector punctuation (Pc), and the zero width non-joiner and
/// joiner.
/// A character an identifier may hold after its first: a Unicode `ID_Continue` character or `$`.
/// `ID_Continue` adds to `ID_Start` the decimal digits (Nd), combining marks (Mn, Mc), connector
/// punctuation (Pc, which holds `_`) and `Other_ID_Continue` (which holds the zero width
/// non-joiner and joiner), less `Pattern_Syntax` and `Pattern_White_Space`.
pub fn is_id_part(c: u32) -> bool {
match c {
0x30..=0x39 => true,
0x200C | 0x200D => true,
0x2E2F => false,
_ if is_id_start(c) => true,
0..=0x7F => false,
_ => category(c).is_some_and(|g| matches!(g, G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation)),
_ if is_other_id_continue(c) => true,
_ => {
category(c).is_some_and(|g| matches!(g, G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation))
&& !is_pattern_syntax(c)
}
}
}
/// `Other_ID_Start` (Unicode PropList): characters kept in `ID_Start` though their category is
/// not a letter.
fn is_other_id_start(c: u32) -> bool {
matches!(c, 0x1885 | 0x1886 | 0x2118 | 0x212E | 0x309B | 0x309C)
}
/// `Other_ID_Continue` (Unicode PropList 16.0): characters kept in `ID_Continue` though their
/// category is not a letter, mark, digit or connector.
fn is_other_id_continue(c: u32) -> bool {
matches!(c, 0xB7 | 0x387 | 0x1369..=0x1371 | 0x19DA | 0x200C | 0x200D | 0x30FB | 0xFF65)
}
/// Non-ASCII `Pattern_Syntax` ranges (Unicode PropList). Unicode does not change this set, and no
/// identifier character belongs to it. `Pattern_White_Space` outside ASCII (U+0085, U+200E, U+200F,
/// U+2028 and U+2029) holds no letter, mark, digit or connector, so the category checks leave it out.
const PATTERN_SYNTAX: [(u32, u32); 23] = [
(0x00A1, 0x00A7),
(0x00A9, 0x00A9),
(0x00AB, 0x00AC),
(0x00AE, 0x00AE),
(0x00B0, 0x00B1),
(0x00B6, 0x00B6),
(0x00BB, 0x00BB),
(0x00BF, 0x00BF),
(0x00D7, 0x00D7),
(0x00F7, 0x00F7),
(0x2010, 0x2027),
(0x2030, 0x203E),
(0x2041, 0x2053),
(0x2055, 0x205E),
(0x2190, 0x245F),
(0x2500, 0x2775),
(0x2794, 0x2BFF),
(0x2E00, 0x2E7F),
(0x3001, 0x3003),
(0x3008, 0x3020),
(0x3030, 0x3030),
(0xFD3E, 0xFD3F),
(0xFE45, 0xFE46),
];
/// Whether a non-ASCII code point is `Pattern_Syntax`.
fn is_pattern_syntax(c: u32) -> bool {
PATTERN_SYNTAX
.binary_search_by(|&(lo, hi)| {
if hi < c {
Ordering::Less
} else if lo > c {
Ordering::Greater
} else {
Ordering::Equal
}
})
.is_ok()
}
/// The Unicode general category of a code point; `None` for a lone surrogate.
fn category(c: u32) -> Option<G> {
char::from_u32(c).map(get_general_category)
+1 -1
View File
@@ -341,7 +341,7 @@ impl<'s> Lexer<'s> {
} else {
self.decimal()
};
if self.peek().is_some_and(|c| c == BACKSLASH || is_id_start(c) || is_decimal_digit(c)) {
if self.code_point(self.pos).is_some_and(|(c, _)| c == BACKSLASH || is_id_start(c) || is_decimal_digit(c)) {
return Err(self.error_at(self.pos, "identifier starts immediately after numeric literal"));
}
let mut token = self.token(TokenKind::Number(value), start, line, column, false);
+112
View File
@@ -401,6 +401,118 @@ fn identifier_characters_follow_their_unicode_categories() {
assert!(parse("var a\\u00B2 = 1;", &Options::default()).is_err());
}
#[test]
fn identifiers_take_the_other_id_start_and_continue_characters() {
// Other_ID_Start: not letters by category, but ID_Start, so they start and continue a name.
for c in [0x1885, 0x1886, 0x2118, 0x212E, 0x309B, 0x309C] {
let ch = char::from_u32(c).unwrap_or_default();
for src in [format!("var {ch} = 1;"), format!("var a{ch} = 1;"), format!("var \\u{c:04X}\\u{{{c:X}}} = 1;")] {
let p = ok(&src);
let Stmt::Var(decls) = stmt(&p, p.body[0]) else { panic!("{src:?}") };
assert!(decls[0].name.name.contains(ch), "{src:?}");
}
}
// Other_ID_Continue: may follow the first character, but cannot start a name.
for c in [0xB7, 0x387].into_iter().chain(0x1369..=0x1371).chain([0x19DA, 0x200C, 0x200D, 0x30FB, 0xFF65]) {
let ch = char::from_u32(c).unwrap_or_default();
for src in [format!("var a{ch} = 1;"), format!("var a\\u{c:04X} = 1;"), format!("var a\\u{{{c:X}}} = 1;")] {
ok(&src);
}
assert!(err(&format!("var {ch} = 1;")).message.contains("unexpected character"), "{c:X}");
assert!(err(&format!("var \\u{c:04X} = 1;")).message.contains("identifier escape"), "{c:X}");
assert!(err(&format!("var \\u{{{c:X}}} = 1;")).message.contains("identifier escape"), "{c:X}");
}
}
#[test]
fn identifiers_refuse_pattern_syntax_surrogates_and_bad_escapes() {
// U+2E2F is a letter by category but Pattern_Syntax; U+2010, U+00B6 and U+2190 are
// punctuation and symbols in Pattern_Syntax.
for c in [0x2E2F, 0x2010, 0xB6, 0x2190, 0x3001] {
let ch = char::from_u32(c).unwrap_or_default();
assert!(err(&format!("var {ch} = 1;")).message.contains("unexpected character"), "{c:X}");
assert!(err(&format!("var a{ch} = 1;")).message.contains("unexpected character"), "{c:X}");
assert!(err(&format!("var a\\u{c:04X} = 1;")).message.contains("identifier escape"), "{c:X}");
assert!(err(&format!("var \\u{{{c:X}}} = 1;")).message.contains("identifier escape"), "{c:X}");
}
for bad in [r"var \uD800 = 1;", r"var a\uDC00 = 1;", r"var a\u{D800} = 1;", r"var a\u{110000} = 1;", r"var a\u{} = 1;"] {
assert!(parse(bad, &Options::default()).is_err(), "{bad}");
}
// A supplementary letter is one identifier character, at the start and after a number.
let p = ok("var \u{10000}\u{1D7CE} = 1;");
let Stmt::Var(decls) = stmt(&p, p.body[0]) else { panic!() };
assert_eq!(decls[0].name.name, "\u{10000}\u{1D7CE}");
assert!(err("1\u{10000};").message.contains("immediately after numeric literal"));
}
/// The code point ranges of `Pattern_Syntax` and `Pattern_White_Space` as Unicode's PropList lists
/// them; Unicode does not change either set.
fn pattern_syntax_and_white_space() -> Vec<std::ops::RangeInclusive<u32>> {
let pattern = "0009..000D 0020 0021..002F 003A..0040 005B..005E 0060 007B..007E 0085 00A1..00A7 00A9 00AB..00AC 00AE \
00B0..00B1 00B6 00BB 00BF 00D7 00F7 200E..200F 2010..2027 2028..2029 2030..203E 2041..2053 2055..205E \
2190..245F 2500..2775 2794..2BFF 2E00..2E7F 3001..3003 3008..3020 3030 FD3E..FD3F FE45..FE46";
pattern
.split_whitespace()
.map(|range| {
let (lo, hi) = range.split_once("..").unwrap_or((range, range));
let (Ok(lo), Ok(hi)) = (u32::from_str_radix(lo, 16), u32::from_str_radix(hi, 16)) else { panic!("{range}") };
lo..=hi
})
.collect()
}
#[test]
fn no_pattern_syntax_or_white_space_character_is_an_identifier_character() {
use crate::chars::{is_id_part, is_id_start};
// `$` is Pattern_Syntax but an identifier character in scripts.
let mut checked = 0;
for range in pattern_syntax_and_white_space() {
for c in range.filter(|&c| c != 0x24) {
assert!(!is_id_start(c) && !is_id_part(c), "U+{c:04X}");
checked += 1;
}
}
assert_eq!(checked, 2760 + 11 - 1);
for c in 0xD800..=0xDFFF {
assert!(!is_id_start(c) && !is_id_part(c), "U+{c:04X}");
}
assert!(!is_id_start(0x110000) && !is_id_part(0x110000));
}
#[test]
fn identifier_characters_are_unicode_16_id_start_and_id_continue() {
use crate::chars::{is_id_part, is_id_start};
use unicode_general_category::{GeneralCategory as G, get_general_category};
// Unicode Standard Annex #31: ID_Start is the categories Lu, Ll, Lt, Lm, Lo and Nl plus
// Other_ID_Start; ID_Continue adds Mn, Mc, Nd, Pc and Other_ID_Continue; both leave out
// Pattern_Syntax and Pattern_White_Space. Other_ID_Start and Other_ID_Continue as PropList 16.0
// lists them.
let other_start = [0x1885..=0x1886, 0x2118..=0x2118, 0x212E..=0x212E, 0x309B..=0x309C];
let other_continue = [0xB7..=0xB7, 0x387..=0x387, 0x1369..=0x1371, 0x19DA..=0x19DA, 0x200C..=0x200D, 0x30FB..=0x30FB, 0xFF65..=0xFF65];
let mut excluded = vec![false; 0x11_0000];
for range in pattern_syntax_and_white_space() {
for c in range {
excluded[c as usize] = true;
}
}
let (mut id_starts, mut id_continues) = (0, 0);
for c in (0..=0x10_FFFF_u32).filter(|c| !(0xD800..=0xDFFF).contains(c)) {
let Some(ch) = char::from_u32(c) else { panic!("U+{c:04X}") };
let g = get_general_category(ch);
let letter = matches!(g, G::UppercaseLetter | G::LowercaseLetter | G::TitlecaseLetter | G::ModifierLetter | G::OtherLetter | G::LetterNumber);
let id_start = (letter || other_start.iter().any(|r| r.contains(&c))) && !excluded[c as usize];
let continuing = matches!(g, G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation);
let id_continue = (id_start || continuing || other_continue.iter().any(|r| r.contains(&c))) && !excluded[c as usize];
id_starts += usize::from(id_start);
id_continues += usize::from(id_continue);
// Scripts add `$` and `_` at the start and `$` later (ECMA-262, Names and Keywords).
assert_eq!(is_id_start(c), id_start || c == 0x24 || c == 0x5F, "start U+{c:04X}");
assert_eq!(is_id_part(c), id_continue || c == 0x24, "part U+{c:04X}");
}
// The totals of ID_Start and ID_Continue in DerivedCoreProperties 16.0.
assert_eq!((id_starts, id_continues), (141_269, 144_541));
}
#[test]
fn comments_and_line_terminators() {
let p = ok("a /* one\ntwo */ b");
+2
View File
@@ -25,6 +25,8 @@ const MAX_PROFILE_POINTS: usize = 16_384;
const MAX_FACES: usize = 32768;
/// Most faces Revolve builds for one object, its members together: about 460 MB of drawn art.
const MAX_REVOLVE_FACES: usize = 163_840;
/// Most faces Extrude & Bevel and Inflate build for one solid, as many as Revolve's.
const MAX_SOLID_FACES: usize = MAX_REVOLVE_FACES;
/// Samples a curved profile segment takes, and the fewest it takes on a surface over its budget.
const CURVE_SAMPLES: usize = 16;
const MIN_CURVE_SAMPLES: usize = 4;
+7 -2
View File
@@ -11,7 +11,7 @@
use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Vec2};
use crate::{MAX_FACES, Revolve, V3, eye_distance, perspective_scale};
use crate::{MAX_FACES, MAX_SOLID_FACES, Revolve, V3, eye_distance, perspective_scale};
mod build;
mod inflate;
@@ -23,11 +23,13 @@ use build::{Camera, cuts, edge_distance, newell, section};
pub(crate) use build::{Face3, extrude_faces, plane_faces};
pub use build::{extrude, plane};
#[cfg(test)]
use inflate::GRID_WORK;
#[cfg(test)]
pub(crate) use inflate::INFLATED;
pub(crate) use inflate::inflate_faces;
pub use inflate::{inflate, inflate_together};
#[cfg(test)]
use membrane::{Edges, Membrane, cell_triangles};
use membrane::{Edges, GRID_BUDGET, MAX_EDGE_ENTRIES, MAX_GRID_WORK, Membrane, cell_triangles};
use rings::signed_area;
#[cfg(test)]
use rings::{MAX_SOLID_POINTS, rings};
@@ -319,3 +321,6 @@ mod tests;
#[cfg(test)]
mod tests_inflate_shading;
#[cfg(test)]
mod tests_limits;
+22 -10
View File
@@ -1,5 +1,7 @@
use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, signed_area, solid_rings};
use super::{Bevel, Extrude, Facet, FillRule, MAX_FACES, PathData, Point, Rect, Revolve, V3, View, eye_distance, perspective_scale};
use super::rings::{
CURVE_PIECES, MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, TOO_COMPLEX_SURFACE, curve_pieces, inset, left, signed_area, solid_rings,
};
use super::{Bevel, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, PathData, Point, Rect, Revolve, V3, View, eye_distance, perspective_scale};
/// Turns 3D points into document points and shades faces for one view.
pub(super) struct Camera {
@@ -87,8 +89,8 @@ impl Builder<'_> {
}
if let Some(mesh) = &mut self.mesh {
mesh.push((contours, n.unit()));
if mesh.len() > MAX_FACES {
return Err("The 3D surface is too complex; simplify the shape".into());
if mesh.len() > MAX_SOLID_FACES {
return Err(TOO_COMPLEX_SURFACE.into());
}
return Ok(());
}
@@ -135,8 +137,8 @@ impl Builder<'_> {
#[cfg(test)]
view,
});
if self.facets.len() > MAX_FACES {
return Err("The 3D surface is too complex; simplify the shape".into());
if self.facets.len() > MAX_SOLID_FACES {
return Err(TOO_COMPLEX_SURFACE.into());
}
Ok(())
}
@@ -407,15 +409,25 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio
if o.depth <= 0.0 {
return plane_faces(path, rule, bounds, view, mesh);
}
let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
let steps = steps(&o);
// One face for each edge of the outline in each band between two steps, and the two caps:
// counted before any point is sampled, and again on the rings, which can have more points
// once contours that meet are merged.
let (bands, caps) = (steps.len().saturating_sub(1), if o.cap { 2 } else { 0 });
let size = bounds.width().max(bounds.height());
let most = curve_pieces(path, size, bands, caps)?;
let rings = solid_rings(path, rule, size, MAX_SOLID_POINTS, most)?;
let edges = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add);
if edges.saturating_mul(bands).saturating_add(caps) > MAX_SOLID_FACES {
return Err(TOO_COMPLEX_SURFACE.into());
}
let camera = Camera::new(view, bounds, o.depth);
let mut b = Builder { camera: &camera, closed: o.cap, hidden: o.hidden, facets: vec![], mesh: mesh.then(Vec::new) };
let steps = steps(&o);
let z = |t: f64| o.depth / 2.0 - o.depth * t;
let ring_at =
|ring: &[Point], s: &Step| -> Vec<V3> { section(&inset(ring, s.inset), bounds, &o, s.t).iter().map(|p| camera.place(*p, z(s.t))).collect() };
// Every band of a twisted outline, bevels included, is cut as its walls' first slice is (with
// bevels and no walls, as its shallowest band is), within the facet limit.
// bevels and no walls, as its shallowest band is), within [`MAX_FACES`] facets.
let depth_of = |s: &[Step]| s.get(1).zip(s.first()).map_or(0.0, |(b, a)| (b.t - a.t).abs());
let wall = steps
.windows(2)
@@ -465,7 +477,7 @@ pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Resul
/// [`plane`], or with `mesh` its face before projection instead of its facet.
pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, mesh: bool) -> Result<Built, String> {
check_bounds(bounds)?;
let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?;
let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS, CURVE_PIECES)?;
let camera = Camera::new(view, bounds, 0.0);
let mut b = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) };
let contours: Vec<Vec<V3>> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect();
+65 -10
View File
@@ -1,7 +1,12 @@
use super::build::{Builder, Built, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward};
use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, filled, material};
use super::rings::{MAX_SOLID_POINTS, signed_area, solid_rings};
use super::{Facet, FillRule, Inflate, MAX_FACES, PathData, Point, Rect, V3, Vec2, View};
use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, crossings, filled, material};
use super::rings::{MAX_SOLID_POINTS, TOO_COMPLEX_SURFACE, curve_pieces, signed_area, solid_rings};
use super::{Facet, FillRule, Inflate, MAX_FACES, MAX_SOLID_FACES, PathData, Point, Rect, V3, Vec2, View};
/// The slices the sides are cut into: more to follow a twist or taper.
fn slices(o: &Inflate) -> usize {
if o.twist != 0.0 || o.taper != 1.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 }
}
/// A shape to inflate: its rings and the membrane under them on the grid of the bounds.
struct Puff {
@@ -18,11 +23,14 @@ struct Puff {
/// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area.
fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result<Option<Puff>, String> {
let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
// The sides take one face for each edge of the outline in each slice; the grid's faces are
// counted as they are built.
let size = bounds.width().max(bounds.height());
let most = curve_pieces(path, size, if o.depth > 0.0 { slices(o) } else { 0 }, 0)?;
let rings = solid_rings(path, rule, size, MAX_SOLID_POINTS, most)?;
if rings.is_empty() {
return Ok(None);
}
let size = bounds.width().max(bounds.height());
if size <= 0.0 {
return Ok(None);
}
@@ -35,8 +43,11 @@ fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result<Op
}
let side = |len: f64| ((len / cell).ceil() as usize).clamp(1, CELLS + 1);
let (nx, ny) = (side(bounds.width()), side(bounds.height()));
let edges = Edges::new(&rings);
let edges = Edges::new(&rings)?;
let m = Membrane::solve(&rings, &edges, bounds.origin(), cell, nx + 1, ny + 1);
if edges.spent() {
return Err(TOO_COMPLEX_SURFACE.into());
}
// A circle of radius r puffs into a half sphere of radius r at 50 % (its membrane peaks at r²/4).
let height = 2.0 * m.peak.sqrt() * o.volume / 50.0;
Ok(Some(Puff { rings, edges, cell, nx, ny, m, height }))
@@ -79,6 +90,8 @@ pub(crate) fn inflate_faces(
thread_local! {
/// How many inflated solids this thread has built, for the tests.
pub(crate) static INFLATED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// The grid work of the last solid this thread inflated in full, for the tests.
pub(super) static GRID_WORK: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The facets of one inflated shape, for a camera that allows for a puff `reach` high (or the
@@ -143,14 +156,41 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo
// builds the topology of every outline ring near its cell, so a self-crossing outline cost its
// crossings again in every cell it spans. Past it, cells take their clipped pieces.
let material_left = std::cell::Cell::new(MAX_MATERIAL_SEGMENTS);
// The shape's part of a rectangle, as rings.
// Every ring's bounds, and the crossings of the last few rows the cells' centres lie on (a
// row of cells has at most seven, at its three levels).
let ring_bounds: Vec<Option<Rect>> =
rings.iter().map(|r| Some(r.iter().skip(1).fold(Rect::from_points(*r.first()?, *r.first()?), |b, p| b.union_pt(*p)))).collect();
let points = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add);
let rows: std::cell::RefCell<Vec<(u64, Vec<f64>)>> = std::cell::RefCell::new(vec![]);
let filled_at = |p: Point| -> bool {
let mut rows = rows.borrow_mut();
if let Some((_, xs)) = rows.iter().find(|(y, _)| *y == p.y.to_bits()) {
return filled(xs, p.x);
}
let xs = if edges.charge(points) { crossings(&rings, p.y) } else { vec![] };
let inside = filled(&xs, p.x);
if rows.len() >= 8 {
rows.remove(0);
}
rows.push((p.y.to_bits(), xs));
inside
};
// The shape's part of a rectangle, as rings: only the rings whose bounds meet it are clipped,
// each point counted as work.
let cut = |r: Rect| -> Vec<Vec<Point>> {
// A cell no edge crosses is wholly inside the shape or wholly outside it.
if !edges.touch(r) {
let corners = vec![Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)];
return if filled(&rings, r.center()) { vec![corners] } else { vec![] };
return if filled_at(r.center()) { vec![corners] } else { vec![] };
}
let pieces: Vec<Vec<Point>> = rings.iter().map(|ring| clip(ring, r)).filter(|c| !c.is_empty()).collect();
let pieces: Vec<Vec<Point>> = rings
.iter()
.zip(&ring_bounds)
.filter(|(_, b)| b.is_some_and(|b| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1))
.filter(|(ring, _)| edges.charge(ring.len()))
.map(|(ring, _)| clip(ring, r))
.filter(|c| !c.is_empty())
.collect();
if pieces.iter().map(|p| signed_area(p)).sum::<f64>() <= 1e-9 * r.area() {
return vec![];
}
@@ -185,6 +225,9 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo
}
})
.collect();
if edges.spent() {
return Err(TOO_COMPLEX_SURFACE.into());
}
let level =
|i: isize, j: isize| -> u32 { if i < 0 || j < 0 || i as usize >= nx || j as usize >= ny { 0 } else { levels[j as usize * nx + i as usize] } };
for j in 0..ny {
@@ -201,6 +244,9 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo
for sx in (bx0..bx1).step_by(step) {
let r = Rect::from_points(at(sx, sy), at(sx + step, sy + step));
let pieces = cut(r);
if edges.spent() {
return Err(TOO_COMPLEX_SURFACE.into());
}
if pieces.is_empty() {
continue;
}
@@ -312,7 +358,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo
}
if o.depth > 0.0 {
// The sides, cut into slices that follow a twist or taper.
let slices = if o.twist != 0.0 || o.taper != 1.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 };
let slices = slices(&o);
let at = |ring: &[Point], k: usize| -> Vec<V3> {
let t = k as f64 / slices as f64;
let turn = Turn::new(bounds, o.twist, o.taper, t);
@@ -320,11 +366,20 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo
};
let mut pieces: Vec<Vec<usize>> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect();
fit(&mut pieces, slices, MAX_FACES.saturating_sub(b.len()));
let edges = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add);
if b.len().saturating_add(edges.saturating_mul(slices)) > MAX_SOLID_FACES {
return Err(TOO_COMPLEX_SURFACE.into());
}
for (ring, pieces) in rings.iter().zip(&pieces) {
for k in 0..slices {
b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?;
}
}
}
#[cfg(test)]
GRID_WORK.with(|w| w.set(edges.work()));
if edges.spent() {
return Err(TOO_COMPLEX_SURFACE.into());
}
Ok(b.finish())
}
+105 -27
View File
@@ -1,9 +1,35 @@
use std::cell::Cell;
use super::build::segment_distance;
use super::rings::{MAX_PLANE_POINTS, contains, left, rings, signed_area};
use super::rings::{MAX_PLANE_POINTS, TOO_COMPLEX_SURFACE, left, rings, signed_area};
use super::{FillRule, PathData, Point, Rect, Vec2};
/// Most entries the edge index of one inflated shape holds: one for each bucket an edge's bounds
/// cover (64 MiB).
pub(super) const MAX_EDGE_ENTRIES: usize = 1 << 24;
/// Most work the grid of one inflated shape takes: the index's entries, the edges its queries
/// look at, the crossings of the rows it fills and the ring points its cells clip, together.
pub(super) const MAX_GRID_WORK: usize = 1 << 28;
#[cfg(test)]
thread_local! {
/// The grid work the next edge indexes of this thread may take, for the tests.
pub(super) static GRID_BUDGET: Cell<usize> = const { Cell::new(MAX_GRID_WORK) };
}
/// The grid work a new edge index may take.
fn grid_budget() -> usize {
#[cfg(test)]
return GRID_BUDGET.with(Cell::get);
#[cfg(not(test))]
MAX_GRID_WORK
}
/// The edges of `rings` filed in a grid of square buckets, so the nearest edge to a point is
/// found among the buckets around it rather than among all the edges.
/// found among the buckets around it rather than among all the edges. It also counts the work
/// done with it (see [`MAX_GRID_WORK`]): once that is spent, its buckets read as empty, and the
/// caller refuses the shape.
pub(super) struct Edges {
edges: Vec<(Point, Point)>,
normals: Vec<(Vec2, Vec2)>,
@@ -11,10 +37,14 @@ pub(super) struct Edges {
size: f64,
n: usize,
buckets: Vec<Vec<u32>>,
work: Cell<usize>,
budget: usize,
}
impl Edges {
pub(super) fn new(rings: &[Vec<Point>]) -> Self {
/// The index of `rings`' edges; an error when it would hold more than [`MAX_EDGE_ENTRIES`]
/// entries, counted before any is filed.
pub(super) fn new(rings: &[Vec<Point>]) -> Result<Self, String> {
const N: usize = 64;
let edges: Vec<(Point, Point)> =
rings.iter().flat_map(|r| r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(a, b)| (*a, *b))).collect();
@@ -37,16 +67,48 @@ impl Edges {
let size = (bounds.width().max(bounds.height()) / N as f64).max(1e-9);
let mut buckets = vec![vec![]; N * N];
let index = |v: f64, o: f64| (((v - o) / size).floor().max(0.0) as usize).min(N - 1);
for (k, (a, b)) in edges.iter().enumerate() {
let (i0, i1) = (index(a.x.min(b.x), bounds.x0), index(a.x.max(b.x), bounds.x0));
let (j0, j1) = (index(a.y.min(b.y), bounds.y0), index(a.y.max(b.y), bounds.y0));
let spans = |(a, b): &(Point, Point)| {
((index(a.x.min(b.x), bounds.x0), index(a.x.max(b.x), bounds.x0)), (index(a.y.min(b.y), bounds.y0), index(a.y.max(b.y), bounds.y0)))
};
let entries = edges.iter().map(spans).fold(0usize, |sum, ((i0, i1), (j0, j1))| sum.saturating_add((i1 - i0 + 1) * (j1 - j0 + 1)));
if entries > MAX_EDGE_ENTRIES {
return Err(TOO_COMPLEX_SURFACE.into());
}
for (k, e) in edges.iter().enumerate() {
let ((i0, i1), (j0, j1)) = spans(e);
for j in j0..=j1 {
for i in i0..=i1 {
buckets[j * N + i].push(k as u32);
}
}
}
Self { edges, normals, origin: bounds.origin(), size, n: N, buckets }
Ok(Self { edges, normals, origin: bounds.origin(), size, n: N, buckets, work: Cell::new(entries), budget: grid_budget() })
}
/// Has the work done with the index gone past its budget?
pub(super) fn spent(&self) -> bool {
self.work.get() > self.budget
}
/// The work done with the index so far.
#[cfg(test)]
pub(super) fn work(&self) -> usize {
self.work.get()
}
/// Count `amount` more work; `false` once the budget is spent, when the work is not to be done.
pub(super) fn charge(&self, amount: usize) -> bool {
if self.spent() {
return false;
}
self.work.set(self.work.get().saturating_add(amount));
!self.spent()
}
/// The edges filed in bucket (`i`, `j`), counted as work; none once the budget is spent.
fn bucket(&self, i: isize, j: isize) -> &[u32] {
let bucket = self.buckets.get((j * self.n as isize + i) as usize).map_or(&[][..], Vec::as_slice);
if self.charge(bucket.len()) { bucket } else { &[] }
}
/// The bucket row or column of coordinate `v`, from the grid's origin `o` on that axis.
@@ -58,15 +120,11 @@ impl Edges {
/// looked at.
pub(super) fn near(&self, p: Point, tolerance: f64) -> bool {
self.touch(Rect::new(p.x - tolerance, p.y - tolerance, p.x + tolerance, p.y + tolerance)) && {
let n = self.n as isize;
let (i0, i1) = (self.cell(p.x - tolerance, self.origin.x), self.cell(p.x + tolerance, self.origin.x));
let (j0, j1) = (self.cell(p.y - tolerance, self.origin.y), self.cell(p.y + tolerance, self.origin.y));
(j0..=j1).any(|j| {
(i0..=i1).any(|i| {
self.buckets[(j * n + i) as usize]
.iter()
.filter_map(|k| self.edges.get(*k as usize))
.any(|(a, b)| segment_distance(p, *a, *b) <= tolerance)
self.bucket(i, j).iter().filter_map(|k| self.edges.get(*k as usize)).any(|(a, b)| segment_distance(p, *a, *b) <= tolerance)
})
})
}
@@ -75,10 +133,9 @@ impl Edges {
/// Call `f` with each edge whose bounding box overlaps `r` (an edge in several of the
/// buckets `r` covers comes more than once).
fn each(&self, r: Rect, mut f: impl FnMut(Point, Point)) {
let n = self.n as isize;
for j in self.cell(r.y0, self.origin.y)..=self.cell(r.y1, self.origin.y) {
for i in self.cell(r.x0, self.origin.x)..=self.cell(r.x1, self.origin.x) {
for (a, b) in self.buckets[(j * n + i) as usize].iter().filter_map(|k| self.edges.get(*k as usize)) {
for (a, b) in self.bucket(i, j).iter().filter_map(|k| self.edges.get(*k as usize)) {
if a.x.min(b.x) <= r.x1 && a.x.max(b.x) >= r.x0 && a.y.min(b.y) <= r.y1 && a.y.max(b.y) >= r.y0 {
f(*a, *b);
}
@@ -89,12 +146,11 @@ impl Edges {
/// Does any edge pass through `r` (its bounding box, at least, overlapping it)?
pub(super) fn touch(&self, r: Rect) -> bool {
let n = self.n as isize;
let (i0, i1) = (self.cell(r.x0, self.origin.x), self.cell(r.x1, self.origin.x));
let (j0, j1) = (self.cell(r.y0, self.origin.y), self.cell(r.y1, self.origin.y));
(j0..=j1).any(|j| {
(i0..=i1).any(|i| {
self.buckets[(j * n + i) as usize]
self.bucket(i, j)
.iter()
.filter_map(|k| self.edges.get(*k as usize))
.any(|(a, b)| a.x.min(b.x) <= r.x1 && a.x.max(b.x) >= r.x0 && a.y.min(b.y) <= r.y1 && a.y.max(b.y) >= r.y0)
@@ -122,12 +178,15 @@ impl Edges {
let mut best = f64::INFINITY;
let mut nearest = (p, Vec2::ZERO);
for r in 0..n {
if self.spent() {
break;
}
for j in (cj - r)..=(cj + r) {
for i in (ci - r)..=(ci + r) {
if (j - cj).abs() != r && (i - ci).abs() != r || i < 0 || j < 0 || i >= n || j >= n {
continue;
}
for k in &self.buckets[(j * n + i) as usize] {
for k in self.bucket(i, j) {
if let Some((a, b)) = self.edges.get(*k as usize) {
let d = *b - *a;
let t = if d.hypot2() < 1e-18 { 0.0 } else { ((p - *a).dot(d) / d.hypot2()).clamp(0.0, 1.0) };
@@ -152,9 +211,24 @@ impl Edges {
}
}
/// Is `p` filled by `rings` (nonzero winding, as they are oriented)?
pub(super) fn filled(rings: &[Vec<Point>], p: Point) -> bool {
rings.iter().filter(|r| contains(r, p)).count() % 2 == 1
/// Where the edges of `rings` cross the line through `y`, in order: the points of the line
/// filled by `rings` (nonzero winding, as they are oriented) are those with an odd number of
/// crossings to their right ([`filled`]). An edge crosses where one end is above the line and
/// the other is not, as [`super::rings::contains`] counts it.
pub(super) fn crossings(rings: &[Vec<Point>], y: f64) -> Vec<f64> {
let mut xs: Vec<f64> = rings
.iter()
.flat_map(|r| r.iter().zip(r.iter().cycle().skip(1)).take(r.len()))
.filter(|(a, b)| (a.y > y) != (b.y > y))
.map(|(a, b)| a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x))
.collect();
xs.sort_by(f64::total_cmp);
xs
}
/// Is `x` filled on the line whose [`crossings`] are `xs`?
pub(super) fn filled(xs: &[f64], x: f64) -> bool {
(xs.len() - xs.partition_point(|v| *v <= x)) % 2 == 1
}
/// `ring` cut to the rectangle `cell` (Sutherland–Hodgman: the rectangle is convex, the ring
@@ -203,13 +277,17 @@ impl Membrane {
pub(super) fn solve(rings: &[Vec<Point>], edges: &Edges, origin: Point, step: f64, nx: usize, ny: usize) -> Self {
let node = |i: usize, j: usize| Point::new(origin.x + i as f64 * step, origin.y + j as f64 * step);
let idx = |i: usize, j: usize| j * nx + i;
// A node on the outline itself is held at zero with the outside.
let inside: Vec<bool> = (0..nx * ny)
.map(|k| {
let p = node(k % nx, k / nx);
filled(rings, p) && !edges.near(p, 1e-6 * step)
})
.collect();
// A node on the outline itself is held at zero with the outside. Each row of nodes is
// tested against the crossings of its line.
let points = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add);
let mut inside = Vec::with_capacity(nx * ny);
for j in 0..ny {
let xs = if edges.charge(points) { crossings(rings, node(0, j).y) } else { vec![] };
inside.extend((0..nx).map(|i| {
let p = node(i, j);
filled(&xs, p.x) && !edges.near(p, 1e-6 * step)
}));
}
// The nearest crossing of the outline from `p` along (dx, dy), within one step.
let crossing = |p: Point, dx: f64, dy: f64| -> f64 {
let mut best = step;
+79 -16
View File
@@ -1,8 +1,7 @@
use super::{FillRule, ParamCurve, PathData, Point, Rect};
use super::{FillRule, MAX_SOLID_FACES, ParamCurve, PathData, Point, Rect};
/// Outline points Extrude & Bevel and Inflate sample at most: every point costs walls, bevel
/// bands and distance tests.
pub(super) const MAX_SOLID_POINTS: usize = 2048;
/// Outline points Extrude & Bevel and Inflate sample at most, and contours they take at most.
pub(super) const MAX_SOLID_POINTS: usize = 65536;
/// Outline points Rotate samples at most: it only turns the outline, so an outlined paragraph fits.
pub(super) const MAX_PLANE_POINTS: usize = 65536;
@@ -10,13 +9,76 @@ pub(super) const MAX_PLANE_POINTS: usize = 65536;
/// Pieces a twisted wall's strip is cut into along one edge at most.
pub(super) const MAX_PIECES: usize = 64;
/// Pieces a curve takes at most, where its tolerance asks for them.
pub(super) const CURVE_PIECES: u32 = 32;
/// The fewest pieces a curve of a solid over its budget is held to: a curve whose tolerance asks
/// for fewer takes fewer.
pub(super) const MIN_CURVE_PIECES: u32 = 4;
const TOO_COMPLEX_SHAPE: &str = "The shape is too complex for a 3D effect";
pub(super) const TOO_COMPLEX_SURFACE: &str = "The 3D surface is too complex; simplify the shape";
/// How many straight pieces follow `c` within `tolerance` (Wang's bound on a cubic's distance from
/// its chords), 1 to 32.
/// its chords), 1 to [`CURVE_PIECES`].
fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 {
let d = |a: Point, b: Point, c: Point| (a.to_vec2() - 2.0 * b.to_vec2() + c.to_vec2()).hypot();
let bend = d(c.p0, c.p1, c.p2).max(d(c.p1, c.p2, c.p3));
let n = (0.75 * bend / tolerance).sqrt().ceil();
if n.is_finite() { (n as u32).clamp(1, 32) } else { 32 }
if n.is_finite() { (n as u32).clamp(1, CURVE_PIECES) } else { CURVE_PIECES }
}
/// The most pieces each curve of a solid's outline takes, counted on `path` before any point is
/// sampled: [`CURVE_PIECES`] when the outline fits within [`MAX_SOLID_POINTS`] points and
/// [`MAX_SOLID_FACES`] faces, else the largest number down to [`MIN_CURVE_PIECES`] that fits, the
/// same for every curve. A curve whose tolerance (see [`sample`]) asks for fewer pieces takes
/// fewer. The faces are counted as `per_edge` for each edge of the outline and `fixed` more.
///
/// An error when the outline takes more than [`MAX_SOLID_POINTS`] points, or contours, with
/// every curve held to [`MIN_CURVE_PIECES`], and when its faces do not fit with that many.
pub(super) fn curve_pieces(path: &PathData, size: f64, per_edge: usize, fixed: usize) -> Result<u32, String> {
let tolerance = (size / 2000.0).max(0.05);
// How many curves ask for each number of pieces.
let mut curves = [0usize; CURVE_PIECES as usize + 1];
let (mut lines, mut segments, mut open) = (0usize, 0usize, 0usize);
for (contours, sp) in path.subpaths.iter().enumerate() {
if contours >= MAX_SOLID_POINTS {
return Err(TOO_COMPLEX_SHAPE.into());
}
// A subpath of one anchor draws nothing, as in [`sample`].
if sp.anchors.len() < 2 {
continue;
}
if sp.anchors.iter().any(|a| [a.p, a.h_in, a.h_out].iter().any(|q| !q.x.is_finite() || !q.y.is_finite())) {
return Err("The shape contains invalid coordinates".into());
}
let count = if sp.closed { sp.anchors.len() } else { sp.anchors.len() - 1 };
// Every segment takes a point at least.
segments = segments.saturating_add(count);
if segments > MAX_SOLID_POINTS {
return Err(TOO_COMPLEX_SHAPE.into());
}
open += usize::from(!sp.closed);
for i in 0..count {
if sp.segment_is_line(i) {
lines += 1;
} else if let Some(n) = curves.get_mut(pieces(sp.segment(i), tolerance) as usize) {
*n += 1;
}
}
}
let points = |most: u32| -> usize {
curves.iter().enumerate().fold(lines, |sum, (asked, n)| sum.saturating_add(n.saturating_mul(asked.min(most as usize))))
};
if points(MIN_CURVE_PIECES) > MAX_SOLID_POINTS {
return Err(TOO_COMPLEX_SHAPE.into());
}
// An open contour is closed by one more edge.
let faces = |most: u32| points(most).saturating_add(open).saturating_mul(per_edge).saturating_add(fixed);
(MIN_CURVE_PIECES..=CURVE_PIECES)
.rev()
.find(|most| points(*most) <= MAX_SOLID_POINTS && faces(*most) <= MAX_SOLID_FACES)
.ok_or_else(|| TOO_COMPLEX_SURFACE.into())
}
/// Contours thinner than this on average (twice their area over their perimeter) are dropped when
@@ -37,14 +99,15 @@ pub(super) const DEBRIS: f64 = 1e-6;
/// are. A profile the boolean refuses is built from its contours as drawn, as it was before
/// contours were merged. An empty answer is an answer: contours that cancel (a square drawn twice
/// under even-odd), or that are all thinner than [`DEBRIS`], fill nothing, so nothing is built.
pub(super) fn solid_rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
let sampled = sample(path, size, limit)?;
/// Each curve takes at most `most` pieces, on the profile as drawn and on the merged outline.
pub(super) fn solid_rings(path: &PathData, rule: FillRule, size: f64, limit: usize, most: u32) -> Result<Vec<Vec<Point>>, String> {
let sampled = sample(path, size, limit, most)?;
let closed = closed_straight(path);
if !meets(&sampled, &closed) {
return orient(sampled, rule);
}
match vectorcraft_pathops::try_unite_all_with(&[(&closed, rule)], DEBRIS) {
Ok(merged) => sample(&merged, size, limit).map(wound),
Ok(merged) => sample(&merged, size, limit, most).map(wound),
Err(_) => orient(sampled, rule),
}
}
@@ -197,12 +260,12 @@ fn segments_meet(a: Point, b: Point, c: Point, d: Point, touch: f64) -> bool {
/// is left out: a contour inside another that runs the same way doesn't cut a hole, as it would
/// if nesting alone decided (#1724).
pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
orient(sample(path, size, limit)?, rule)
orient(sample(path, size, limit, CURVE_PIECES)?, rule)
}
/// The closed polygons that follow `path`'s subpaths, as [`rings`] samples them, each running as
/// its subpath does.
pub(super) fn sample(path: &PathData, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
/// its subpath does, each curve in at most `most` pieces.
pub(super) fn sample(path: &PathData, size: f64, limit: usize, most: u32) -> Result<Vec<Vec<Point>>, String> {
let tolerance = (size / 2000.0).max(0.05);
let mut count = 0;
let mut out = vec![];
@@ -210,7 +273,7 @@ pub(super) fn sample(path: &PathData, size: f64, limit: usize) -> Result<Vec<Vec
for sp in path.subpaths.iter().filter(|sp| sp.anchors.len() >= 2) {
let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len() - 1 };
if segments > limit {
return Err("The shape is too complex for a 3D effect".into());
return Err(TOO_COMPLEX_SHAPE.into());
}
let mut ring: Vec<Point> = vec![];
if let Some(first) = sp.anchors.first() {
@@ -218,10 +281,10 @@ pub(super) fn sample(path: &PathData, size: f64, limit: usize) -> Result<Vec<Vec
}
for i in 0..segments {
let curve = sp.segment(i);
let samples = if sp.segment_is_line(i) { 1 } else { pieces(curve, tolerance) };
let samples = if sp.segment_is_line(i) { 1 } else { pieces(curve, tolerance).min(most.max(1)) };
count += samples as usize;
if count > limit {
return Err("The shape is too complex for a 3D effect".into());
return Err(TOO_COMPLEX_SHAPE.into());
}
for k in 1..=samples {
ring.push(curve.eval(f64::from(k) / f64::from(samples)));
@@ -252,7 +315,7 @@ fn orient(mut out: Vec<Vec<Point>>, rule: FillRule) -> Result<Vec<Vec<Point>>, S
// Which rings hold which: each ring against the others, its edges walked only where its
// bounds hold the point, and within [`MAX_CONTAINMENT_EDGES`] edges and pairs in all (a pair
// whose bounds miss the point costs a step too: many small rings are a square of pairs).
let too_complex = || String::from("The shape is too complex for a 3D effect");
let too_complex = || String::from(TOO_COMPLEX_SHAPE);
let mut work = 0usize;
let mut within = |out: &[Vec<Point>], bounds: &[Rect], i: usize| -> Result<Vec<usize>, String> {
let p = out.get(i).and_then(|r| r.first()).copied().unwrap_or_default();
+31 -18
View File
@@ -277,11 +277,14 @@ fn malformed_and_degenerate_input_is_bounded() {
// Too many anchors: refused, not truncated.
let mut many = BezPath::new();
many.move_to((0.0, 0.0));
for i in 0..3000 {
for i in 0..70_000 {
many.line_to((f64::from(i), f64::from(i % 7)));
}
many.close_path();
assert!(extrude(&PathData::from_bezpath(&many), FillRule::EvenOdd, b, Extrude::default(), front()).is_err());
assert_eq!(
extrude(&PathData::from_bezpath(&many), FillRule::EvenOdd, b, Extrude::default(), front()).unwrap_err(),
"The shape is too complex for a 3D effect"
);
}
#[test]
@@ -321,11 +324,11 @@ fn the_inflate_membrane_matches_known_solutions() {
c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 50.0), 0.01));
let (p, b) = shape(&c);
let r = rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap();
let disc = Membrane::solve(&r, &Edges::new(&r), b.origin(), b.width() / 48.0, 49, 49);
let disc = Membrane::solve(&r, &Edges::new(&r).unwrap(), b.origin(), b.width() / 48.0, 49, 49);
assert!(close(disc.peak, 50.0 * 50.0 / 4.0, 625.0 * 0.02), "{}", disc.peak);
let (p, b) = shape(&square(0.0, 0.0, 100.0));
let r = rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap();
let sq = Membrane::solve(&r, &Edges::new(&r), b.origin(), 100.0 / 48.0, 49, 49);
let sq = Membrane::solve(&r, &Edges::new(&r).unwrap(), b.origin(), 100.0 / 48.0, 49, 49);
assert!(close(sq.peak, 0.07367 * 100.0 * 100.0, 736.7 * 0.02), "{}", sq.peak);
// Smooth across the middle: no crease where a distance-to-the-edge height would have one
// (the diagonals). The second difference along a diagonal stays small and even.
@@ -374,8 +377,7 @@ fn bumps(count: usize) -> (PathData, Rect) {
}
/// Curves are followed within a tolerance rather than by sixteen points each: small curves take a
/// few points, so shapes of many curves (outlined type) fit; Rotate, which only turns the outline,
/// takes far more.
/// few points, so shapes of many curves (outlined type) fit, in a solid and in Rotate.
#[test]
fn small_curves_take_few_points_and_rotate_takes_large_outlines() {
// 300 curves were 4,800 points at sixteen each, over the old limit of 1,024.
@@ -383,10 +385,13 @@ fn small_curves_take_few_points_and_rotate_takes_large_outlines() {
assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).is_ok());
assert!(inflate(&p, FillRule::EvenOdd, r, Inflate { volume: 20.0, ..Inflate::default() }, front()).is_ok());
assert!(plane(&p, FillRule::EvenOdd, r, front()).is_ok());
// 4,000 curves: too many for a solid, not for Rotate.
// 4,000 curves fit a solid and Rotate alike; 70,000 fit neither.
let (p, r) = bumps(4000);
assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).unwrap_err().contains("too complex"));
assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).is_ok());
assert_eq!(plane(&p, FillRule::EvenOdd, r, front()).unwrap().len(), 1);
let (p, r) = bumps(70_000);
assert_eq!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect");
assert_eq!(plane(&p, FillRule::EvenOdd, r, front()).unwrap_err(), "The shape is too complex for a 3D effect");
// Each piece stays within the tolerance of the curve: a circle's chords sag at most 0.05 pt
// here (1/2000 of its 100 pt size is less).
let mut c = BezPath::new();
@@ -421,7 +426,7 @@ fn twisted_pieces_stay_within_the_facet_limit() {
fn the_edge_index_finds_the_nearest_edge() {
let (p, r) = bumps(200);
let rings = rings(&p, FillRule::EvenOdd, r.width(), MAX_SOLID_POINTS).unwrap();
let edges = Edges::new(&rings);
let edges = Edges::new(&rings).unwrap();
for k in 0..400 {
let t = f64::from(k);
let q = Point::new(-300.0 + (t * 37.0) % 1400.0, -50.0 + (t * 13.0) % 130.0);
@@ -437,7 +442,7 @@ fn the_edge_index_looks_along_the_side_from_outside() {
let ring = |b: BezPath| rings(&shape(&b).0, FillRule::EvenOdd, 64.0, MAX_SOLID_POINTS).unwrap().remove(0);
// Straight ahead of the point, a little further up its side, and the far corner (a 64 pt grid).
let rings = vec![ring(square(2.5, 0.0, 1.0)), ring(square(0.0, 4.2, 0.8)), ring(square(63.0, 63.0, 1.0))];
let edges = Edges::new(&rings);
let edges = Edges::new(&rings).unwrap();
let q = Point::new(-10.0, 0.5);
let (fast, slow) = (edges.distance(q), edge_distance(&rings, q));
assert!((fast - slow).abs() < 1e-9, "{fast} against {slow}");
@@ -1019,7 +1024,7 @@ fn flat_far_and_too_large_merges() {
let squares: Vec<_> = (0..12).map(|i| rect(i as f64 * 20.0, 0.0, i as f64 * 20.0 + 10.0, 10.0)).collect();
let line = vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 300.0), Point::new(50.0, 300.0), Point::new(100.0, 300.0)], false);
let with_line = PathData::new(squares.iter().cloned().chain([line]).collect());
assert!(!rings::meets(&rings::sample(&with_line, 300.0, usize::MAX).unwrap(), &rings::closed_straight(&with_line)));
assert!(!rings::meets(&rings::sample(&with_line, 300.0, usize::MAX, rings::CURVE_PIECES).unwrap(), &rings::closed_straight(&with_line)));
assert!(close(area(&plane(&with_line, FillRule::NonZero, with_line.bounds().unwrap(), front()).unwrap()), 1200.0, 1e-6));
// Far from the origin the boolean snaps wider than 1e-6 (1.4e-6 at 9.9e7), and so does the
// test for meeting: squares 1.35e-6 apart there are one outline, one cap of both areas.
@@ -1049,9 +1054,8 @@ fn flat_far_and_too_large_merges() {
let b = wide.bounds().unwrap();
let joined = plane(&wide, FillRule::NonZero, b, front()).unwrap();
assert_eq!(joined.iter().map(|f| f.contours.len()).sum::<usize>(), 2, "the squares joined, the lobe apart");
// 24 bars each way: 192 points as drawn, some 2,400 once every hole between them is traced:
// past Extrude's and Inflate's 2,048 the shape is too complex (its contours as drawn cross, so
// they can't be built as they are), and Rotate's 65,536 takes it.
// 24 bars each way: 192 points as drawn, some 2,400 once every hole between them is traced,
// within the 65,536 points Extrude & Bevel, Inflate and Rotate take.
let mut bars = vec![];
for i in 0..24 {
let x = i as f64 * 10.0;
@@ -1061,9 +1065,18 @@ fn flat_far_and_too_large_merges() {
let grid = PathData::new(bars);
let b = grid.bounds().unwrap();
for rule in [FillRule::NonZero, FillRule::EvenOdd] {
assert_eq!(extrude(&grid, rule, b, Extrude::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect", "{rule:?}");
assert_eq!(inflate(&grid, rule, b, Inflate::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect", "{rule:?}");
assert!(close(area(&plane(&grid, rule, b, front()).unwrap()), vectorcraft_pathops::area(&grid, rule), 1e-6), "{rule:?}: Rotate");
// With a limit of 2,048 points the grid fits as drawn and its merged outline does not: its
// contours as drawn cross, so they can't be built as they are.
assert!(rings::sample(&grid, 240.0, 2048, rings::CURVE_PIECES).is_ok());
assert_eq!(
rings::solid_rings(&grid, rule, 240.0, 2048, rings::CURVE_PIECES),
Err("The shape is too complex for a 3D effect".into()),
"{rule:?}"
);
let expected = vectorcraft_pathops::area(&grid, rule);
assert!(close(area(&extrude(&grid, rule, b, Extrude::default(), front()).unwrap()), expected, 1e-6), "{rule:?}: Extrude & Bevel");
assert!(!inflate(&grid, rule, b, Inflate::default(), front()).unwrap().is_empty(), "{rule:?}: Inflate");
assert!(close(area(&plane(&grid, rule, b, front()).unwrap()), expected, 1e-6), "{rule:?}: Rotate");
}
}
@@ -1076,7 +1089,7 @@ fn meets_within_twice_the_boolean_tolerance() {
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| {
vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)
};
let meets = |p: &PathData| rings::meets(&rings::sample(p, 300.0, usize::MAX).unwrap(), &rings::closed_straight(p));
let meets = |p: &PathData| rings::meets(&rings::sample(p, 300.0, usize::MAX, rings::CURVE_PIECES).unwrap(), &rings::closed_straight(p));
// Two triangles whose facing 45° edges are 1.4e-6 apart, the second's corners 0.99e-6 lower on
// the page (greater y) than the first's: snapped onto the first's heights, they are joined.
let d = 0.99e-6;
+197
View File
@@ -0,0 +1,197 @@
use vectorcraft_geom::{Anchor, SubPath};
use super::*;
const SHAPE: &str = "The shape is too complex for a 3D effect";
const SURFACE: &str = "The 3D surface is too complex; simplify the shape";
/// A closed ring of `count` curved petals around a circle of `radius`: each curve leaves its anchor
/// outward, `lift` points out, and comes back to the next anchor, so a larger lift asks for more
/// pieces per curve.
fn petals(count: usize, radius: f64, lift: f64) -> PathData {
let chord = std::f64::consts::TAU * radius / count as f64;
let anchors = (0..count)
.map(|i| {
let (s, c) = (std::f64::consts::TAU * i as f64 / count as f64).sin_cos();
let p = Point::new(radius * 2.0 + radius * c, radius * 2.0 + radius * s);
let (out, along) = (Vec2::new(c, s) * lift, Vec2::new(-s, c) * (chord / 3.0));
Anchor::with_handles(p, p + out - along, p + out + along)
})
.collect();
PathData::new(vec![SubPath::new(anchors, true)])
}
/// A closed polygon of `count` straight edges around a circle.
fn polygon(count: usize, radius: f64) -> PathData {
let anchors = (0..count)
.map(|i| {
let (s, c) = (std::f64::consts::TAU * i as f64 / count as f64).sin_cos();
Anchor::corner(Point::new(radius * 2.0 + radius * c, radius * 2.0 + radius * s))
})
.collect();
PathData::new(vec![SubPath::new(anchors, true)])
}
/// Turned so that the walls and bevels show.
fn turned() -> View {
View { rotation_x: -18.0, rotation_y: -26.0, rotation_z: 8.0, ..View::default() }
}
fn size(path: &PathData) -> f64 {
let b = path.bounds().unwrap();
b.width().max(b.height())
}
/// How many points the outline samples to, each curve in at most `most` pieces.
fn points(path: &PathData, most: u32) -> usize {
rings::sample(path, size(path), usize::MAX, most).unwrap().iter().map(Vec::len).sum()
}
fn built(facets: &[Facet]) -> bool {
!facets.is_empty()
&& facets.len() <= MAX_SOLID_FACES
&& facets.iter().flat_map(|f| f.contours.iter().flatten()).all(|p| p.x.is_finite() && p.y.is_finite())
}
#[test]
fn two_thousand_curves_extrude_with_a_bevel_and_inflate() {
let path = petals(2000, 500.0, 4.0);
let b = path.bounds().unwrap();
// Every curve takes several pieces: far more points than 2,048, all of them kept.
let sampled = points(&path, rings::CURVE_PIECES);
assert!(sampled >= 3 * 2000, "{sampled}");
assert_eq!(rings::curve_pieces(&path, size(&path), 2, 2), Ok(rings::CURVE_PIECES));
let bevel = Extrude { bevel: Some(Bevel::default()), ..Extrude::default() };
let extruded = extrude(&path, FillRule::NonZero, b, bevel, turned()).unwrap();
assert!(built(&extruded) && extruded.len() > sampled, "{}", extruded.len());
let inflated = inflate(&path, FillRule::NonZero, b, Inflate::default(), turned()).unwrap();
assert!(built(&inflated), "{}", inflated.len());
}
#[test]
fn an_outline_at_the_point_limit_is_taken_and_one_past_it_is_refused_before_sampling() {
let at = polygon(MAX_SOLID_POINTS, 20_000.0);
assert_eq!(rings::curve_pieces(&at, size(&at), 1, 2), Ok(rings::CURVE_PIECES));
assert!(built(&extrude(&at, FillRule::NonZero, at.bounds().unwrap(), Extrude::default(), turned()).unwrap()));
let past = polygon(MAX_SOLID_POINTS + 1, 20_000.0);
assert_eq!(rings::curve_pieces(&past, size(&past), 1, 2), Err(SHAPE.to_string()));
assert_eq!(extrude(&past, FillRule::NonZero, past.bounds().unwrap(), Extrude::default(), turned()), Err(SHAPE.to_string()));
assert_eq!(inflate(&past, FillRule::NonZero, past.bounds().unwrap(), Inflate::default(), turned()), Err(SHAPE.to_string()));
}
#[test]
fn faces_at_the_limit_are_taken_and_one_past_it_is_refused_before_sampling() {
// 40,960 straight edges in four bands are exactly the face limit.
let path = polygon(MAX_SOLID_FACES / 4, 20_000.0);
assert_eq!(rings::curve_pieces(&path, size(&path), 4, 0), Ok(rings::CURVE_PIECES));
assert_eq!(rings::curve_pieces(&path, size(&path), 4, 1), Err(SURFACE.to_string()));
// A rounded bevel gives 20,000 edges nine bands each, past the limit.
let path = polygon(20_000, 20_000.0);
let rounded = Extrude { bevel: Some(Bevel { shape: BevelShape::Rounded, ..Bevel::default() }), ..Extrude::default() };
assert_eq!(extrude(&path, FillRule::NonZero, path.bounds().unwrap(), rounded, turned()), Err(SURFACE.to_string()));
}
#[test]
fn curves_over_the_budget_take_fewer_pieces_down_to_four() {
let path = petals(2000, 500.0, 20.0);
let s = size(&path);
assert_eq!(points(&path, rings::CURVE_PIECES), 6 * 2000, "six pieces a curve within the tolerance");
// 16 faces an edge: 12,000 points are too many, 10,000 at five pieces a curve fit.
assert_eq!(rings::curve_pieces(&path, s, 16, 0), Ok(5));
assert_eq!(points(&path, 5), 5 * 2000);
// At four pieces a curve, 21 faces an edge are still too many.
assert_eq!(rings::curve_pieces(&path, s, 21, 0), Err(SURFACE.to_string()));
// Twisted 96°, the walls are 16 slices: built with five pieces a curve.
let b = path.bounds().unwrap();
let twisted = extrude(&path, FillRule::NonZero, b, Extrude { twist: 96.0, hidden: true, ..Extrude::default() }, turned()).unwrap();
assert!(built(&twisted) && twisted.len() > MAX_FACES, "{}", twisted.len());
// Twisted 360°, 60 slices are too many even at four pieces a curve.
assert_eq!(extrude(&path, FillRule::NonZero, b, Extrude { twist: 360.0, ..Extrude::default() }, turned()), Err(SURFACE.to_string()));
}
#[test]
fn five_and_ten_thousand_curves_build_at_the_defaults() {
let five = petals(5000, 1250.0, 2.0);
assert!(built(&inflate(&five, FillRule::NonZero, five.bounds().unwrap(), Inflate::default(), turned()).unwrap()));
assert!(built(&extrude(&five, FillRule::NonZero, five.bounds().unwrap(), Extrude::default(), turned()).unwrap()));
let ten = petals(10_000, 2500.0, 4.0);
let b = ten.bounds().unwrap();
assert!(points(&ten, rings::CURVE_PIECES) > 10_000);
assert!(built(&extrude(&ten, FillRule::NonZero, b, Extrude::default(), turned()).unwrap()));
let deep = Extrude { twist: 360.0, bevel: Some(Bevel { shape: BevelShape::Rounded, repeat: 4, ..Bevel::default() }), ..Extrude::default() };
assert_eq!(extrude(&ten, FillRule::NonZero, b, deep, turned()), Err(SURFACE.to_string()));
}
#[test]
fn many_empty_contours_and_invalid_handles_are_refused_before_sampling() {
// Contours of one anchor and of none draw nothing, yet each is counted.
let lone = |i: usize| SubPath::new(if i.is_multiple_of(2) { vec![] } else { vec![Anchor::corner(Point::new(1.0, i as f64))] }, false);
let square = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true);
let at = PathData::new((0..MAX_SOLID_POINTS - 1).map(lone).chain([square.clone()]).collect());
assert_eq!(rings::curve_pieces(&at, 10.0, 1, 2), Ok(rings::CURVE_PIECES));
let past = PathData::new((0..MAX_SOLID_POINTS).map(lone).chain([square.clone()]).collect());
assert_eq!(rings::curve_pieces(&past, 10.0, 1, 2), Err(SHAPE.to_string()));
let b = Rect::new(0.0, 0.0, 10.0, 10.0);
assert_eq!(extrude(&past, FillRule::NonZero, b, Extrude::default(), turned()), Err(SHAPE.to_string()));
let mut bad = square;
bad.anchors[1].h_out = Point::new(f64::NAN, 0.0);
let bad = PathData::new(vec![bad]);
assert_eq!(rings::curve_pieces(&bad, 10.0, 1, 2), Err("The shape contains invalid coordinates".to_string()));
assert_eq!(inflate(&bad, FillRule::NonZero, b, Inflate::default(), turned()), Err("The shape contains invalid coordinates".to_string()));
}
/// A comb of `teeth` teeth, 100 wide and 100 tall, on a strip along its foot: its outline
/// crosses every cell of the grid Inflate fills.
fn comb(teeth: usize) -> PathData {
let w = 100.0 / teeth as f64;
let mut points: Vec<Point> = (0..teeth)
.flat_map(|k| {
let x = k as f64 * w;
[Point::new(x, 0.0), Point::new(x, 100.0), Point::new(x + w / 2.0, 100.0), Point::new(x + w / 2.0, 0.0)]
})
.collect();
points.extend([Point::new(100.0, -1.0), Point::new(0.0, -1.0)]);
PathData::new(vec![SubPath::polyline(&points, true)])
}
/// Inflate `path` with the grid work its edge index may take set to `budget`.
fn inflated_within(path: &PathData, budget: usize) -> Result<Vec<Facet>, String> {
GRID_BUDGET.with(|b| b.set(budget));
let out = inflate(path, FillRule::NonZero, path.bounds().unwrap(), Inflate::default(), turned());
GRID_BUDGET.with(|b| b.set(MAX_GRID_WORK));
out
}
#[test]
fn inflate_at_its_grid_work_budget_builds_and_one_past_it_is_refused() {
let path = comb(200);
assert!(built(&inflated_within(&path, MAX_GRID_WORK).unwrap()));
let work = GRID_WORK.with(std::cell::Cell::get);
assert!(work > 0);
assert!(built(&inflated_within(&path, work).unwrap()));
assert_eq!(GRID_WORK.with(std::cell::Cell::get), work, "the same work at its budget");
assert_eq!(inflated_within(&path, work - 1), Err(SURFACE.to_string()));
}
#[test]
fn the_edge_index_at_its_entry_limit_is_built_and_one_past_it_is_refused() {
// A diagonal across the whole grid of 64 by 64 buckets is filed in every bucket, each way.
let diagonal = vec![Point::new(0.0, 0.0), Point::new(64.0, 64.0)];
assert_eq!(MAX_EDGE_ENTRIES, 2048 * 2 * 64 * 64);
let mut rings = vec![diagonal; 2048];
assert!(Edges::new(&rings).is_ok_and(|e| e.work() == MAX_EDGE_ENTRIES));
// A small triangle in one bucket: three entries more.
rings.push(vec![Point::new(0.0, 0.0), Point::new(0.5, 0.0), Point::new(0.0, 0.5)]);
assert_eq!(Edges::new(&rings).err(), Some(SURFACE.to_string()));
}
#[test]
fn a_comb_that_crosses_every_cell_is_refused_once_its_grid_work_is_spent() {
// Two thousand curves take a small share of the budget.
let curves = petals(2000, 500.0, 4.0);
assert!(built(&inflate(&curves, FillRule::NonZero, curves.bounds().unwrap(), Inflate::default(), turned()).unwrap()));
assert!(GRID_WORK.with(std::cell::Cell::get) < MAX_GRID_WORK / 16);
// 16,002 points, each clipped in every cell its ring meets.
let path = comb(4000);
assert_eq!(inflate(&path, FillRule::NonZero, path.bounds().unwrap(), Inflate::default(), turned()), Err(SURFACE.to_string()));
}
+2
View File
@@ -26,6 +26,7 @@ mod command;
pub mod composite_fonts;
pub mod confirm;
pub mod corners;
pub mod custom_dictionary;
pub mod dimension;
mod document_setup;
mod duotone;
@@ -432,6 +433,7 @@ registry! {
NewBrush: [new_brush::KIND] => new_brush::SPEC,
EditSelection: [edit_selection::KIND] => edit_selection::SPEC,
EditViews: [edit_views::KIND] => edit_views::SPEC,
EditCustomDictionary: [custom_dictionary::KIND] => custom_dictionary::SPEC,
PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC,
LayerOptions: [layer_options::KIND] => layer_options::SPEC,
LayersPanelOptions: [layers_panel_options::KIND] => layers_panel_options::SPEC,
@@ -0,0 +1,334 @@
//! Edit › Edit Custom Dictionary…: the custom dictionary's words in a list, with an Entry field and
//! Add, Delete and Change, as the public
//! [Check and correct spelling](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/check-and-correct-spelling.html)
//! page describes it: enter a word and Add it, select a word and Delete it, or select a word, edit it
//! in the Entry field and Change it. Nothing changes until Done, which deletes and adds the words
//! that differ in one `text.spelling.editDictionary`, all or none; Cancel leaves the dictionary as
//! it was.
//!
//! Fields: `orig` (the words when the dialog opened), `words` (as edited, sorted), `entry` (the
//! Entry field) and `selected` (the word picked, or null).
use serde_json::{Value, json};
use vectorcraft_engine::cmd::spelling::custom_word;
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Edit Custom Dictionary.
pub const KIND: &str = "editCustomDictionary";
/// The command its menu item runs: with no params, it opens this dialog.
pub const COMMAND: &str = "text.spelling.dictionary";
/// The command Done runs: every delete and add, saved once.
const EDIT: &str = "text.spelling.editDictionary";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| tl!("Edit Custom Dictionary").into(), body, confirm, ok: Some("Done"), min_width: 420.0, ..DialogSpec::FORM };
/// Open Edit Custom Dictionary on the custom dictionary's words.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let words = app.run(COMMAND, json!({})).map_err(|e| e.to_string())?.get("words").cloned().unwrap_or_else(|| json!([]));
app.ui.dialog = Some(Dialog::new(KIND, json!({ "orig": words, "words": words, "entry": "", "selected": null })));
Ok(Value::Null)
}
fn list(d: &Dialog, key: &str) -> Vec<String> {
d.fields.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(String::from).collect()).unwrap_or_default()
}
/// The word picked in the list, while it is still there.
fn selected(d: &Dialog, words: &[String]) -> Option<String> {
d.fields.get("selected").and_then(Value::as_str).filter(|w| words.iter().any(|x| x == w)).map(String::from)
}
/// What the buttons can do now: the Entry field as a dictionary word that isn't in the list yet
/// (for Add and Change), and the word picked (for Delete and Change).
fn actions(d: &Dialog) -> (Option<String>, Option<String>) {
let words = list(d, "words");
let entry = custom_word(&d.str("entry")).filter(|w| !words.contains(w));
(entry, selected(d, &words))
}
fn set_words(d: &mut Dialog, mut words: Vec<String>, selected: Option<&str>) {
words.sort();
words.dedup();
d.fields.insert("words".into(), json!(words));
d.fields.insert("selected".into(), json!(selected));
}
/// Add: the Entry field's word joins the list, picked.
fn add(d: &mut Dialog) {
let (Some(w), _) = actions(d) else { return };
let mut words = list(d, "words");
words.push(w.clone());
set_words(d, words, Some(&w));
}
/// Delete: the word picked leaves the list; the one after it (or before, for the last) is picked.
fn delete(d: &mut Dialog) {
let (_, Some(w)) = actions(d) else { return };
let mut words = list(d, "words");
let Some(i) = words.iter().position(|x| *x == w) else { return };
words.remove(i);
let next = words.get(i).or_else(|| words.last()).cloned();
if let Some(n) = &next {
d.fields.insert("entry".into(), json!(n));
}
set_words(d, words, next.as_deref());
}
/// Change: the word picked becomes the Entry field's word.
fn change(d: &mut Dialog) {
let (Some(to), Some(w)) = actions(d) else { return };
let mut words: Vec<String> = list(d, "words").into_iter().filter(|x| *x != w).collect();
words.push(to.clone());
set_words(d, words, Some(&to));
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let words = list(d, "words");
let current = selected(d, &words);
let (entry, picked) = actions(d);
let mut pick = None;
let (mut added, mut deleted, mut changed) = (false, false, false);
widgets::dim_label(ui, tl!("Entry:"));
form::text(ui, d, "entry", 380.0);
ui.add_space(8.0);
ui.horizontal_top(|ui| {
widgets::list_box(ui, |ui| {
ui.set_width(260.0);
let row = ui.spacing().interact_size.y;
egui::ScrollArea::vertical().id_salt("custom-dictionary").min_scrolled_height(200.0).max_height(200.0).show_rows(
ui,
row,
words.len(),
|ui, range| {
ui.set_width(ui.available_width());
for w in words.get(range).unwrap_or_default() {
if ui.selectable_label(current.as_ref() == Some(w), w.as_str()).clicked() {
pick = Some(w.clone());
}
}
},
);
});
ui.add_space(16.0);
ui.vertical(|ui| {
added = ui.add_enabled_ui(entry.is_some(), |ui| widgets::flat_button(ui, tl!("Add"), 90.0).clicked()).inner;
ui.add_space(6.0);
deleted = ui.add_enabled_ui(picked.is_some(), |ui| widgets::flat_button(ui, tl!("Delete"), 90.0).clicked()).inner;
ui.add_space(6.0);
changed = ui.add_enabled_ui(entry.is_some() && picked.is_some(), |ui| widgets::flat_button(ui, tl!("Change"), 90.0).clicked()).inner;
});
});
// Picking a word puts it in the Entry field, to edit for Change.
if let Some(w) = pick {
d.fields.insert("entry".into(), json!(w));
d.fields.insert("selected".into(), json!(w));
}
if added {
add(d);
} else if deleted {
delete(d);
} else if changed {
change(d);
}
false
}
/// Done: deletes the words no longer in the list and adds the new ones in one
/// `text.spelling.editDictionary`, saved once. When it can't be saved, none of it is kept: the
/// dialog stays open with the error, its list as edited, and Cancel leaves the dictionary as it was.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (orig, words) = (list(d, "orig"), list(d, "words"));
let remove: Vec<&String> = orig.iter().filter(|w| !words.contains(w)).collect();
let add: Vec<&String> = words.iter().filter(|w| !orig.contains(w)).collect();
app.run(EDIT, json!({ "remove": remove, "add": add })).map_err(|e| e.to_string())?;
app.ui.dialog = None;
Ok(Value::Null)
}
#[cfg(test)]
mod tests {
use egui::{Event, PointerButton, Pos2, Rect, vec2};
use serde_json::{Value, json};
use crate::VectorcraftApp;
// The custom dictionary is the whole process's: each test here uses words no other test does.
const A: &str = "zqvdicta";
const B: &str = "zqvdictb";
const M: &str = "zqvdictm";
const X: &str = "zqvdictx";
const Y: &str = "zqvdicty";
fn app() -> VectorcraftApp {
VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default())
}
fn has(app: &mut VectorcraftApp, w: &str) -> bool {
app.run("text.spelling.dictionary", json!({})).unwrap()["words"].as_array().unwrap().contains(&json!(w))
}
fn forget(app: &mut VectorcraftApp, words: &[&str]) {
for w in words {
let _ = app.run("text.spelling.removeWord", json!({ "word": w }));
}
}
fn set(app: &mut VectorcraftApp, k: &str, v: Value) {
app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v);
}
/// The dialog's list, of `mine` (other tests may have their words in the dictionary meanwhile).
fn words(app: &VectorcraftApp, mine: &[&str]) -> Vec<String> {
super::list(app.ui.dialog.as_ref().unwrap(), "words").into_iter().filter(|w| mine.contains(&w.as_str())).collect()
}
/// One frame of the dialog on an 800 × 600 window → the text painted, with its rect.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>) -> Vec<(String, Rect)> {
// A window takes a sizing frame and fades in: each frame is a quarter second on.
let time = ctx.input(|i| i.time) + 0.25;
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)));
let raw = egui::RawInput { screen_rect, events, time: Some(time), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx()));
out.textures_delta.clear();
fn walk(s: &egui::Shape, v: &mut Vec<(String, Rect)>) {
match s {
egui::Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))),
egui::Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)),
_ => {}
}
}
let mut v = vec![];
out.shapes.iter().for_each(|c| walk(&c.shape, &mut v));
v
}
fn context() -> egui::Context {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
ctx
}
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) -> Vec<(String, Rect)> {
frame(app, ctx, vec![]);
let texts = frame(app, ctx, vec![]);
let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{label}` in {texts:?}"));
let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(app, ctx, vec![Event::PointerMoved(at)]);
frame(app, ctx, vec![press(true)]);
frame(app, ctx, vec![press(false)]);
frame(app, ctx, vec![])
}
/// Edit › Edit Custom Dictionary… (beside Spelling, as the public page has it) opens the
/// dialog on the custom dictionary's words.
#[test]
fn the_edit_menu_opens_it_on_the_custom_dictionary() {
let mut app = app();
forget(&mut app, &[M]);
app.run("text.spelling.addWord", json!({ "word": M })).unwrap();
let e = crate::menus::menu_entries(&app).into_iter().find(|e| e.label == "Edit Custom Dictionary…").expect("in the Edit menu");
assert_eq!((e.path.as_slice(), e.command.as_deref(), e.enabled), (&["Edit".to_string()][..], Some(super::COMMAND), true));
crate::menus::invoke(&mut app, super::COMMAND, json!({}));
let d = app.ui.dialog.as_ref().expect("Edit Custom Dictionary opens");
assert_eq!(d.kind, super::KIND);
assert_eq!(words(&app, &[M]), [M]);
forget(&mut app, &[M]);
}
/// Add, Delete and Change edit the list; nothing changes in the dictionary until Done, which
/// applies them; Cancel leaves it as it was.
#[test]
fn add_delete_and_change_apply_on_done() {
let mut app = app();
forget(&mut app, &[A, B, "zqvdictc"]);
app.run("text.spelling.addWord", json!({ "word": A })).unwrap();
super::open(&mut app).unwrap();
let ctx = context();
// Add: the Entry field's word (kept in lower case) joins the list.
set(&mut app, "entry", json!(" ZqvDictB "));
click(&mut app, &ctx, "Add");
assert_eq!(words(&app, &[A, B, "zqvdictc"]), [A, B]);
assert!(!has(&mut app, B), "not until Done");
// Change: pick A, edit it in the Entry field.
set(&mut app, "selected", json!(A));
set(&mut app, "entry", json!("zqvdictc"));
click(&mut app, &ctx, "Change");
assert_eq!(words(&app, &[A, B, "zqvdictc"]), [B, "zqvdictc"]);
// Delete the word picked.
set(&mut app, "selected", json!(B));
click(&mut app, &ctx, "Delete");
assert_eq!(words(&app, &[A, B, "zqvdictc"]), ["zqvdictc"]);
// Cancel: the dictionary is as it was.
crate::dialogs::cancel(&mut app);
assert!(has(&mut app, A) && !has(&mut app, B) && !has(&mut app, "zqvdictc"));
// Again, then Done.
super::open(&mut app).unwrap();
set(&mut app, "selected", json!(A));
set(&mut app, "entry", json!("zqvdictc"));
click(&mut app, &ctx, "Change");
set(&mut app, "entry", json!(B));
click(&mut app, &ctx, "Add");
click(&mut app, &ctx, "Done");
assert!(app.ui.dialog.is_none(), "Done closes it");
assert!(!has(&mut app, A) && has(&mut app, B) && has(&mut app, "zqvdictc"));
forget(&mut app, &[A, B, "zqvdictc"]);
}
/// Done that can't be saved keeps none of the edits: the dialog stays open with the error and
/// Cancel leaves the dictionary as it was.
#[test]
fn a_done_that_cant_be_saved_changes_nothing() {
const P: &str = "zqvdictp";
const Q: &str = "zqvdictq";
let mut app = app();
forget(&mut app, &[P, Q]);
app.run("text.spelling.addWord", json!({ "word": P })).unwrap();
let folder = vectorcraft_testkit::temp_dir("custom-dictionary-done").join("Custom Dictionary.txt");
std::fs::create_dir_all(&folder).unwrap();
let mut failing = self::app();
failing.session.attach_custom_dictionary(folder);
super::open(&mut failing).unwrap();
let ctx = context();
set(&mut failing, "selected", json!(P));
click(&mut failing, &ctx, "Delete");
set(&mut failing, "entry", json!(Q));
click(&mut failing, &ctx, "Add");
assert_eq!(words(&failing, &[P, Q]), [Q]);
click(&mut failing, &ctx, "Done");
assert!(failing.ui.dialog.is_some(), "the dialog stays open with the error");
assert!(has(&mut app, P) && !has(&mut app, Q), "neither the delete nor the add is kept");
crate::dialogs::cancel(&mut failing);
assert!(has(&mut app, P) && !has(&mut app, Q));
forget(&mut app, &[P, Q]);
}
/// Add and Change need one word that isn't in the list yet; Delete and Change need a word
/// picked. A button that can't act does nothing.
#[test]
fn the_buttons_act_only_on_a_word_they_can_take() {
let mut app = app();
forget(&mut app, &[X, Y]);
app.run("text.spelling.addWord", json!({ "word": X })).unwrap();
super::open(&mut app).unwrap();
let ctx = context();
for entry in ["", "two words", X, &"z".repeat(65)] {
set(&mut app, "entry", json!(entry));
assert_eq!(super::actions(app.ui.dialog.as_ref().unwrap()).0, None, "{entry:?}");
click(&mut app, &ctx, "Add");
assert_eq!(words(&app, &[X, Y]), [X], "{entry:?} isn't added");
}
// Nothing picked: Delete and Change do nothing.
set(&mut app, "entry", json!(Y));
click(&mut app, &ctx, "Delete");
click(&mut app, &ctx, "Change");
assert_eq!(words(&app, &[X, Y]), [X]);
crate::dialogs::cancel(&mut app);
forget(&mut app, &[X]);
}
}
+7
View File
@@ -5904,6 +5904,11 @@ axis Y Axis Y ос
User Brush Library 9 Потребителска библиотека с четки 9
User Brush Library 10 Потребителска библиотека с четки 10
Art Ribbons Арт ленти
Zigzag Зигзаг
Burst Ray Лъч на удар
Dash Trail Пунктирна следа
Speed Line Линия на скорост
Game Trails Игрови следи
Calligraphic Nibs Калиграфски писци
Pattern Borders Бордюри с мостри
Scatter Shapes Разсеяни фигури
@@ -6452,3 +6457,5 @@ breakText Glyphs Глифи
# Align to Pixel Grid
Align to Pixel Grid Подравняване към пикселната мрежа
@msg side is left or right, not `{s}` side е left или right, а не `{s}`
Edit Custom Dictionary Редактиране на потребителски речник
Entry: Запис:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -5954,6 +5954,11 @@ axis Y Axis Y osa
User Brush Library 9 Korisnička biblioteka četkica 9
User Brush Library 10 Korisnička biblioteka četkica 10
Art Ribbons Umjetničke trake
Zigzag Cik-cak
Burst Ray Zraka udara
Dash Trail Isprekidani trag
Speed Line Linija brzine
Game Trails Tragovi igre
Calligraphic Nibs Kaligrafski vrhovi
Pattern Borders Ivice od uzoraka
Scatter Shapes Raspršeni oblici
@@ -6503,3 +6508,5 @@ shape Open Otvoren
# Align to Pixel Grid
Align to Pixel Grid Poravnaj s mrežom piksela
@msg side is left or right, not `{s}` side je left ili right, a ne `{s}`
Edit Custom Dictionary Uredi prilagođeni rječnik
Entry: Unos:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -362,6 +362,11 @@ antiAlias Sharp Nítid
Art Brush Options Opcions del pinzell artístic
Art Optimized Optimitzat per a l’art
Art Ribbons Cintes d’art
Zigzag Zigzag
Burst Ray Raig d’impacte
Dash Trail Estela discontínua
Speed Line Línia de velocitat
Game Trails Esteles de joc
Art using a deleted global swatch keeps its colour. L’art que utilitza una mostra global suprimida conserva el seu color.
Artboard Tauler
Artboard Options Opcions del tauler
@@ -6687,3 +6692,5 @@ breakText Glyphs Glifs
Ignore All Ignora-ho tot
Add to Dictionary Afegeix al diccionari
No Suggestions No hi ha suggeriments
Edit Custom Dictionary Edita el diccionari personalitzat
Entry: Entrada:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -6118,6 +6118,11 @@ axis Y Axis osa Y
User Brush Library 9 Uživatelská knihovna štětců 9
User Brush Library 10 Uživatelská knihovna štětců 10
Art Ribbons Obrazové stuhy
Zigzag Cikcak
Burst Ray Paprsek nárazu
Dash Trail Čárkovaná stopa
Speed Line Rychlostní čára
Game Trails Herní stopy
Calligraphic Nibs Kaligrafická pera
Pattern Borders Vzorkové okraje
Scatter Shapes Rozptýlené tvary
@@ -6706,3 +6711,5 @@ breakText Glyphs Glyfy
Ignore All Ignorovat vše
Add to Dictionary Přidat do slovníku
No Suggestions Žádné návrhy
Edit Custom Dictionary Upravit vlastní slovník
Entry: Položka:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -5919,6 +5919,11 @@ axis Y Axis Y-akse
User Brush Library 9 Brugerpenselbibliotek 9
User Brush Library 10 Brugerpenselbibliotek 10
Art Ribbons Kunstbånd
Zigzag Zigzag
Burst Ray Burststråle
Dash Trail Streget spor
Speed Line Hastighedslinje
Game Trails Spilspor
Calligraphic Nibs Kalligrafiske penne
Pattern Borders Mønsterborter
Scatter Shapes Spredte former
@@ -6466,3 +6471,5 @@ breakText Glyphs Glyffer
# Align to Pixel Grid
Align to Pixel Grid Juster efter pixelgitter
@msg side is left or right, not `{s}` side er left eller right, ikke `{s}`
Edit Custom Dictionary Redigér egen ordliste
Entry: Opslag:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -5701,6 +5701,11 @@ axis Y Axis Y-Achse
User Brush Library 9 Benutzer-Pinsel-Bibliothek 9
User Brush Library 10 Benutzer-Pinsel-Bibliothek 10
Art Ribbons Bildbänder
Zigzag Zickzack
Burst Ray Stoßstrahl
Dash Trail Strichelspur
Speed Line Geschwindigkeitslinie
Game Trails Spielspuren
Calligraphic Nibs Kalligrafie-Federn
Pattern Borders Musterbordüren
Scatter Shapes Streuformen
@@ -6293,3 +6298,5 @@ breakText Glyphs Glyphen
Ignore All Alle ignorieren
Add to Dictionary Zum Wörterbuch hinzufügen
No Suggestions Keine Vorschläge
Edit Custom Dictionary Benutzerwörterbuch bearbeiten
Entry: Eintrag:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -6069,6 +6069,11 @@ axis Y Axis Άξονας Y
User Brush Library 9 Βιβλιοθήκη πινέλων χρήστη 9
User Brush Library 10 Βιβλιοθήκη πινέλων χρήστη 10
Art Ribbons Γραφικές κορδέλες
Zigzag Ζιγκζάγκ
Burst Ray Ακτίνα κρούσης
Dash Trail Διακεκομμένο ίχνος
Speed Line Γραμμή ταχύτητας
Game Trails Ίχνη παιχνιδιού
Calligraphic Nibs Καλλιγραφικές πένες
Pattern Borders Περιθώρια μοτίβου
Scatter Shapes Σχήματα διασποράς
@@ -6658,3 +6663,5 @@ breakText Glyphs Γλύφοι
Ignore All Παράβλεψη όλων
Add to Dictionary Προσθήκη στο λεξικό
No Suggestions Καμία πρόταση
Edit Custom Dictionary Επεξεργασία προσαρμοσμένου λεξικού
Entry: Λήμμα:
Can't render this file because it is too large.
+7
View File
@@ -6060,6 +6060,11 @@ axis Y Axis Eje Y
User Brush Library 9 Biblioteca de pinceles del usuario 9
User Brush Library 10 Biblioteca de pinceles del usuario 10
Art Ribbons Cintas de ilustración
Zigzag Zigzag
Burst Ray Rayo de impacto
Dash Trail Estela discontinua
Speed Line Línea de velocidad
Game Trails Estelas de juego
Calligraphic Nibs Plumillas caligráficas
Pattern Borders Bordes de motivo
Scatter Shapes Formas de dispersión
@@ -6649,3 +6654,5 @@ breakText Glyphs Glifos
Ignore All Omitir todo
Add to Dictionary Añadir al diccionario
No Suggestions No hay sugerencias
Edit Custom Dictionary Editar diccionario personalizado
Entry: Entrada:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -6148,6 +6148,11 @@ axis Y Axis Y-akseli
User Brush Library 9 Käyttäjän sivellinkirjasto 9
User Brush Library 10 Käyttäjän sivellinkirjasto 10
Art Ribbons Kuvitusnauhat
Zigzag Siksak
Burst Ray Iskusäde
Dash Trail Katkoviiva
Speed Line Nopeusviiva
Game Trails Pelijäljet
Calligraphic Nibs Kalligrafiakärjet
Pattern Borders Kuvioreunukset
Scatter Shapes Sirontamuodot
@@ -6741,3 +6746,5 @@ breakText Glyphs Merkit
Ignore All Ohita kaikki
Add to Dictionary Lisää sanastoon
No Suggestions Ei ehdotuksia
Edit Custom Dictionary Muokkaa omaa sanastoa
Entry: Merkintä:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -6113,6 +6113,11 @@ axis Y Axis Axe Y
User Brush Library 9 Bibliothèque de formes utilisateur 9
User Brush Library 10 Bibliothèque de formes utilisateur 10
Art Ribbons Rubans d'illustration
Zigzag Zigzag
Burst Ray Rayon d’impact
Dash Trail Traînée tiretée
Speed Line Ligne de vitesse
Game Trails Traînées de jeu
Calligraphic Nibs Plumes calligraphiques
Pattern Borders Bordures de motif
Scatter Shapes Formes de dispersion
@@ -6701,3 +6706,5 @@ breakText Glyphs Glyphes
Ignore All Tout ignorer
Add to Dictionary Ajouter au dictionnaire
No Suggestions Aucune suggestion
Edit Custom Dictionary Modifier le dictionnaire personnalisé
Entry: Entrée :
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -421,6 +421,11 @@ antiAlias Sharp शार्प
Art Brush Options आर्ट ब्रश ऑप्शन्स
Art Optimized आर्टवर्क के लिए अनुकूलित
Art Ribbons आर्ट रिबन
Zigzag ज़िगज़ैग
Burst Ray बर्स्ट रे
Dash Trail डैश ट्रेल
Speed Line स्पीड लाइन
Game Trails गेम ट्रेल्स
Art using a deleted global swatch keeps its colour. हटाए गए ग्लोबल स्वॉच वाला आर्टवर्क अपना रंग बनाए रखता है।
Artboard आर्टबोर्ड
Artboard {n} of {total} {total} में से आर्टबोर्ड {n}
@@ -8147,3 +8152,5 @@ theme Appearance अपीयरेंस
Ignore All सभी को अनदेखा करें
Add to Dictionary शब्दकोश में जोड़ें
No Suggestions कोई सुझाव नहीं
Edit Custom Dictionary कस्टम शब्दकोश संपादित करें
Entry: प्रविष्टि:
Can't render this file because it is too large.
+7
View File
@@ -6101,6 +6101,11 @@ axis Y Axis Y tengely
User Brush Library 9 Felhasználói ecsetkönyvtár 9
User Brush Library 10 Felhasználói ecsetkönyvtár 10
Art Ribbons Grafikus szalagok
Zigzag Cikkcakk
Burst Ray Ütéssugár
Dash Trail Szaggatott nyom
Speed Line Sebességvonal
Game Trails Játéknyomok
Calligraphic Nibs Kalligrafikus tollhegyek
Pattern Borders Mintázatszegélyek
Scatter Shapes Szórt alakzatok
@@ -6690,3 +6695,5 @@ breakText Glyphs Glifák
Ignore All Az összes kihagyása
Add to Dictionary Hozzáadás a szótárhoz
No Suggestions Nincs javaslat
Edit Custom Dictionary Egyéni szótár szerkesztése
Entry: Bejegyzés:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -5919,6 +5919,11 @@ axis Y Axis Sumbu Y
User Brush Library 9 Pustaka kuas pengguna 9
User Brush Library 10 Pustaka kuas pengguna 10
Art Ribbons Pita seni
Zigzag Zigzag
Burst Ray Sinar ledakan
Dash Trail Jejak putus-putus
Speed Line Garis kecepatan
Game Trails Jejak game
Calligraphic Nibs Pena kaligrafi
Pattern Borders Bingkai pola
Scatter Shapes Bentuk sebar
@@ -6466,3 +6471,5 @@ breakText Glyphs Glif
# Align to Pixel Grid
Align to Pixel Grid Sejajarkan ke grid piksel
@msg side is left or right, not `{s}` side adalah left atau right, bukan `{s}`
Edit Custom Dictionary Edit kamus kustom
Entry: Entri:
Can't render this file because it contains an unexpected character in line 2315 and column 5.
+7
View File
@@ -5735,6 +5735,11 @@ axis Y Axis Asse Y
User Brush Library 9 Libreria pennelli utente 9
User Brush Library 10 Libreria pennelli utente 10
Art Ribbons Nastri grafici
Zigzag Zigzag
Burst Ray Raggio d’impatto
Dash Trail Scia tratteggiata
Speed Line Linea di velocità
Game Trails Scia di gioco
Calligraphic Nibs Pennini calligrafici
Pattern Borders Bordi a motivo
Scatter Shapes Forme a dispersione
@@ -6327,3 +6332,5 @@ breakText Glyphs Glifi
Ignore All Ignora tutto
Add to Dictionary Aggiungi al dizionario
No Suggestions Nessun suggerimento
Edit Custom Dictionary Modifica dizionario personalizzato
Entry: Voce:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -5800,6 +5800,11 @@ axis Y Axis Y 軸
User Brush Library 9 ユーザーブラシライブラリ 9
User Brush Library 10 ユーザーブラシライブラリ 10
Art Ribbons アートリボン
Zigzag ジグザグ
Burst Ray バーストレイ
Dash Trail ダッシュトレイル
Speed Line スピードライン
Game Trails ゲームトレイル
Calligraphic Nibs カリグラフィペン先
Pattern Borders パターンボーダー
Scatter Shapes 散布シェイプ
@@ -6392,3 +6397,5 @@ breakText Glyphs 字形
Ignore All すべて無視
Add to Dictionary 辞書に追加
No Suggestions 候補なし
Edit Custom Dictionary カスタム辞書を編集
Entry: エントリ:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -6041,6 +6041,11 @@ axis Y Axis Y ღერძი
User Brush Library 9 მომხმარებლის ფუნჯების ბიბლიოთეკა 9
User Brush Library 10 მომხმარებლის ფუნჯების ბიბლიოთეკა 10
Art Ribbons ნახატის ლენტები
Zigzag ზიგზაგი
Burst Ray დარტყმის სხივი
Dash Trail წყვეტილი კვალი
Speed Line სიჩქარის ხაზი
Game Trails თამაშის კვალი
Calligraphic Nibs კალიგრაფიული წვერები
Pattern Borders პატერნის ბორდიურები
Scatter Shapes გაბნეული ფიგურები
@@ -6767,3 +6772,5 @@ font-script Japanese იაპონური
Ignore All ყველას იგნორირება
Add to Dictionary ლექსიკონში დამატება
No Suggestions შემოთავაზებები არ არის
Edit Custom Dictionary მომხმარებლის ლექსიკონის რედაქტირება
Entry: ჩანაწერი:
Can't render this file because it is too large.
+7
View File
@@ -5696,6 +5696,11 @@ axis Y Axis Y축
User Brush Library 9 사용자 브러시 라이브러리 9
User Brush Library 10 사용자 브러시 라이브러리 10
Art Ribbons 아트 리본
Zigzag 지그재그
Burst Ray 버스트 레이
Dash Trail 대시 트레일
Speed Line 스피드 라인
Game Trails 게임 트레일
Calligraphic Nibs 서예 펜촉
Pattern Borders 패턴 테두리
Scatter Shapes 흩뿌리기 모양
@@ -6288,3 +6293,5 @@ breakText Glyphs 글리프
Ignore All 모두 무시
Add to Dictionary 사전에 추가
No Suggestions 제안 없음
Edit Custom Dictionary 사용자 사전 편집
Entry: 항목:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -5970,6 +5970,11 @@ axis Y Axis Y-as
User Brush Library 9 Penseelbibliotheek van gebruiker 9
User Brush Library 10 Penseelbibliotheek van gebruiker 10
Art Ribbons Illustratielinten
Zigzag Zigzag
Burst Ray Inslagstraal
Dash Trail Streepspoor
Speed Line Snelheidslijn
Game Trails Spelsporen
Calligraphic Nibs Kalligrafische penpunten
Pattern Borders Patroonranden
Scatter Shapes Strooivormen
@@ -6517,3 +6522,5 @@ breakText Glyphs Glyphs
# Align to Pixel Grid
Align to Pixel Grid Uitlijnen op pixelraster
@msg side is left or right, not `{s}` side is left of right, niet `{s}`
Edit Custom Dictionary Aangepast woordenboek bewerken
Entry: Item:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -5972,6 +5972,11 @@ axis Y Axis Oś Y
User Brush Library 9 Biblioteka pędzli użytkownika 9
User Brush Library 10 Biblioteka pędzli użytkownika 10
Art Ribbons Wstęgi artystyczne
Zigzag Zygzak
Burst Ray Promień uderzenia
Dash Trail Ślad kreskowany
Speed Line Linia prędkości
Game Trails Ślady gry
Calligraphic Nibs Stalówki kaligraficzne
Pattern Borders Obramowania wzorków
Scatter Shapes Kształty rozproszone
@@ -6562,3 +6567,5 @@ breakText Glyphs Glify
Ignore All Ignoruj wszystko
Add to Dictionary Dodaj do słownika
No Suggestions Brak sugestii
Edit Custom Dictionary Edytuj słownik użytkownika
Entry: Wpis:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -6033,6 +6033,11 @@ axis Y Axis Eixo Y
User Brush Library 9 Biblioteca de Pincéis do Usuário 9
User Brush Library 10 Biblioteca de Pincéis do Usuário 10
Art Ribbons Fitas de Arte
Zigzag Zigue-zague
Burst Ray Raio de impacto
Dash Trail Rastro tracejado
Speed Line Linha de velocidade
Game Trails Rastros de jogo
Calligraphic Nibs Penas Caligráficas
Pattern Borders Bordas de Padrão
Scatter Shapes Formas de Dispersão
@@ -6779,3 +6784,5 @@ swatch Yellow Green Verde-amarelado
Ignore All Ignorar Tudo
Add to Dictionary Adicionar ao Dicionário
No Suggestions Nenhuma Sugestão
Edit Custom Dictionary Editar Dicionário Personalizado
Entry: Entrada:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -5729,6 +5729,11 @@ axis Y Axis Ось Y
User Brush Library 9 Пользовательская библиотека кистей 9
User Brush Library 10 Пользовательская библиотека кистей 10
Art Ribbons Художественные ленты
Zigzag Зигзаг
Burst Ray Ударный луч
Dash Trail Пунктирный след
Speed Line Линия скорости
Game Trails Игровые следы
Calligraphic Nibs Каллиграфические перья
Pattern Borders Узорные каймы
Scatter Shapes Рассеянные фигуры
@@ -6795,3 +6800,5 @@ shape Open Открытый
Ignore All Пропустить все
Add to Dictionary Добавить в словарь
No Suggestions Нет вариантов
Edit Custom Dictionary Редактировать пользовательский словарь
Entry: Запись:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -6141,6 +6141,11 @@ axis Y Axis Y-axel
User Brush Library 9 Användarbibliotek för penslar 9
User Brush Library 10 Användarbibliotek för penslar 10
Art Ribbons Konstband
Zigzag Sicksack
Burst Ray Stötstråle
Dash Trail Streckat spår
Speed Line Hastighetslinje
Game Trails Spår från spel
Calligraphic Nibs Kalligrafiska pennspetsar
Pattern Borders Mönsterbårder
Scatter Shapes Spridningsformer
@@ -8191,3 +8196,5 @@ digits Hindi Hindi
Ignore All Ignorera alla
Add to Dictionary Lägg till i ordlistan
No Suggestions Inga förslag
Edit Custom Dictionary Redigera egen ordlista
Entry: Post:
Can't render this file because it contains an unexpected character in line 5 and column 25.
+7
View File
@@ -6074,6 +6074,11 @@ axis Y Axis Y ekseni
User Brush Library 9 Kullanıcı Fırça Kitaplığı 9
User Brush Library 10 Kullanıcı Fırça Kitaplığı 10
Art Ribbons Sanat Şeritleri
Zigzag Zigzag
Burst Ray Darbe ışını
Dash Trail Kesik iz
Speed Line Hız çizgisi
Game Trails Oyun izleri
Calligraphic Nibs Kaligrafi Uçları
Pattern Borders Desen Kenarlıkları
Scatter Shapes Dağılım Şekilleri
@@ -6662,3 +6667,5 @@ breakText Glyphs Glifler
Ignore All Tümünü Yoksay
Add to Dictionary Sözlüğe Ekle
No Suggestions Öneri Yok
Edit Custom Dictionary Özel Sözlüğü Düzenle
Entry: Giriş:
Can't render this file because it contains an unexpected character in line 5 and column 25.
+7
View File
@@ -5715,6 +5715,11 @@ axis Y Axis Вісь Y
User Brush Library 9 Користувацька бібліотека пензлів 9
User Brush Library 10 Користувацька бібліотека пензлів 10
Art Ribbons Художні стрічки
Zigzag Зигзаг
Burst Ray Ударний промінь
Dash Trail Пунктирний слід
Speed Line Лінія швидкості
Game Trails Ігрові сліди
Calligraphic Nibs Каліграфічні пера
Pattern Borders Візерункові облямівки
Scatter Shapes Розсіяні фігури
@@ -6307,3 +6312,5 @@ breakText Glyphs Гліфи
Ignore All Пропустити все
Add to Dictionary Додати до словника
No Suggestions Немає варіантів
Edit Custom Dictionary Редагувати словник користувача
Entry: Запис:
Can't render this file because it contains an unexpected character in line 182 and column 5.
+7
View File
@@ -5716,6 +5716,11 @@ axis Y Axis Trục Y
User Brush Library 9 Thư viện cọ người dùng 9
User Brush Library 10 Thư viện cọ người dùng 10
Art Ribbons Dải băng nghệ thuật
Zigzag Zigzag
Burst Ray Tia va chạm
Dash Trail Vệt đứt đoạn
Speed Line Đường tốc độ
Game Trails Vệt game
Calligraphic Nibs Ngòi bút thư pháp
Pattern Borders Viền họa tiết
Scatter Shapes Hình phân tán
@@ -6308,3 +6313,5 @@ breakText Glyphs Glyph
Ignore All Bỏ qua tất cả
Add to Dictionary Thêm vào từ điển
No Suggestions Không có gợi ý
Edit Custom Dictionary Chỉnh sửa từ điển tùy chỉnh
Entry: Mục nhập:
Can't render this file because it contains an unexpected character in line 204 and column 5.
+7
View File
@@ -5956,6 +5956,11 @@ axis Y Axis Y 轴
User Brush Library 9 用户画笔库 9
User Brush Library 10 用户画笔库 10
Art Ribbons 艺术丝带
Zigzag 锯齿
Burst Ray 冲击射线
Dash Trail 虚线轨迹
Speed Line 速度线
Game Trails 游戏轨迹
Calligraphic Nibs 书法笔尖
Pattern Borders 图案边框
Scatter Shapes 散布形状
@@ -6544,3 +6549,5 @@ breakText Glyphs 字形
Align to Pixel Grid 对齐像素网格
@msg only paths and compound paths can be aligned to the pixel grid 只有路径和复合路径可以对齐像素网格
@msg side is left or right, not `{s}` side 应为 left 或 right,而不是 `{s}`
Edit Custom Dictionary 编辑自定义词典
Entry: 条目:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -5753,6 +5753,11 @@ axis Y Axis Y 軸
User Brush Library 9 使用者筆刷資料庫 9
User Brush Library 10 使用者筆刷資料庫 10
Art Ribbons 藝術緞帶
Zigzag 鋸齒
Burst Ray 衝擊射線
Dash Trail 虛線軌跡
Speed Line 速度線
Game Trails 遊戲軌跡
Calligraphic Nibs 書法筆尖
Pattern Borders 圖樣邊框
Scatter Shapes 散佈形狀
@@ -6342,3 +6347,5 @@ breakText Glyphs 字符
Align to Pixel Grid 對齊像素格點
@msg only paths and compound paths can be aligned to the pixel grid 只有路徑和複合路徑可以對齊像素格點
@msg side is left or right, not `{s}` side 應為 left 或 right,而不是 `{s}`
Edit Custom Dictionary 編輯自訂字典
Entry: 項目:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -287,6 +287,13 @@ fn open_view_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool {
}
return true;
}
// Edit Custom Dictionary…: the custom dictionary's words in a list, to add, delete or change.
if id == crate::dialogs::custom_dictionary::COMMAND && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = crate::dialogs::custom_dictionary::open(app) {
app.status(e);
}
return true;
}
// Edit Views…: the saved views in a list, to rename or delete.
if id == "view.saved.edit" && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = crate::dialogs::edit_views::open(app) {
+6 -2
View File
@@ -206,9 +206,13 @@ fn run_ui_part2(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
}
// Edit → Color Settings… / Assign Profile… (no params): show the colour-management panel
// wherever it is, as Window › Separations Preview does (it isn't in an icon column, so a
// flyout for it never opened: #4542).
// flyout for it never opened: #4542), scrolled to its Color Settings section, which a short
// window shows only after scrolling past the plates and Proof Setup (#6419).
"edit.colorSettings" | "edit.assignProfile" if p.as_object().is_none_or(|o| o.is_empty()) => {
crate::panel_docking::reveal_panel(app, "separations").and_then(|_| app.session.execute(id, p).map_err(|e| e.to_string()))
crate::panel_docking::reveal_panel(app, "separations").and_then(|_| {
app.ui.color_settings_scroll = true;
app.session.execute(id, p).map_err(|e| e.to_string())
})
}
// Outline or Preview is a setting of the active document's view.
"view.outline" => match app.view_mut() {
+5 -5
View File
@@ -1,6 +1,6 @@
use super::slots::{PERSPECTIVE_SLOTS, RECENT_FONT_IDS, RECENT_IDS, SAVED_SELECTION_IDS, SAVED_VIEW_IDS};
use super::submenus::{INSERT_BREAK, INSERT_SPECIAL, INSERT_WHITESPACE, PATH_EFFECTS, TYPE_SIZES, effect_menu, font_items, insert_items};
use super::{Item, Sep, c, cp, json, sub, todo, todos};
use super::{Item, Sep, c, cp, json, sub, todos};
/// The in-window and native menu tree. The in-window bar skips the VectorCraft menu (see
/// `menu_bar`); the native Mac bar is composed from the whole tree (see `native_menu`).
@@ -97,10 +97,10 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
Sep,
cp("Find and Replace…", "edit.findReplace", json!({"find": "", "replace": "", "matchCase": false, "wholeWord": false})),
cp("Find Next", "edit.findNext", json!({"find": ""})),
sub(
"Spelling",
vec![c("Auto Spell Check", "edit.autoSpellCheck"), todos("Check Spelling…", "Cmd+I"), todo("Edit Custom Dictionary…")],
),
sub("Spelling", vec![c("Auto Spell Check", "edit.autoSpellCheck"), todos("Check Spelling…", "Cmd+I")]),
// Edit › Edit Custom Dictionary…, beside Spelling, on the public Check and correct
// spelling page.
c("Edit Custom Dictionary…", crate::dialogs::custom_dictionary::COMMAND),
Sep,
sub(
"Edit Colors",
+35 -1
View File
@@ -385,10 +385,22 @@ pub(crate) fn row_popup(app: &mut VectorcraftApp, ctx: &egui::Context, panel: Re
let area_id = egui::Id::unique("ap-row-popup");
let shown = ctx.memory(|m| m.area_rect(area_id));
// A press outside it (not one that closes a dropdown the popup opened) or Escape puts it away.
// With a field in it that has the keyboard, a press draws it once more first, so the field lets
// go of the keyboard to that press and commits what was typed, as in a panel (#6429). Escape
// drops what was typed.
let pressed_outside = ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| shown.is_some_and(|r| !r.contains(p))));
if pressed_outside && !egui::Popup::is_any_open(ctx) || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
let mut last = false;
if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
return close(app);
} else if pressed_outside && !egui::Popup::is_any_open(ctx) {
if !widgets::field_focused_in(ctx, egui::LayerId::new(egui::Order::Foreground, area_id)) {
return close(app);
}
last = true;
}
// Fields let go of the keyboard on a click, which this press isn't yet. The app-wide option
// goes back however this function ends, a panic in the panel included.
let _surrender = last.then(|| SurrenderOnPress::set(ctx));
let screen = ctx.content_rect();
let left = panel.left() - POPUP_GAP - POPUP_W;
let x = if left >= screen.left() + POPUP_GAP { left } else { (panel.right() + POPUP_GAP).min(screen.right() - POPUP_W - POPUP_GAP) };
@@ -415,6 +427,28 @@ pub(crate) fn row_popup(app: &mut VectorcraftApp, ctx: &egui::Context, panel: Re
}
});
});
if last {
close(app);
}
}
/// While it lives, a press anywhere makes a field let go of the keyboard, as a click does
/// otherwise; dropped, egui's previous `surrender_focus_on` is back ([`row_popup`]'s last frame).
struct SurrenderOnPress(egui::Context, egui::SurrenderFocusOn);
impl SurrenderOnPress {
fn set(ctx: &egui::Context) -> Self {
let before = ctx.options(|o| o.input_options.surrender_focus_on);
ctx.options_mut(|o| o.input_options.surrender_focus_on = egui::SurrenderFocusOn::Presses);
Self(ctx.clone(), before)
}
}
impl Drop for SurrenderOnPress {
fn drop(&mut self) {
let before = self.1;
self.0.options_mut(|o| o.input_options.surrender_focus_on = before);
}
}
/// A row's name that edits the row when clicked ([`link`]), with the tooltip "Click to edit".
+70
View File
@@ -83,7 +83,26 @@ fn profile_dropdown(ui: &mut Ui, id: &str, current: &str, kind: ProfileKind, ext
widgets::dropdown_names(ui, id, super::label_or_name(current, extra == Some(current)), &refs, 210.0).and_then(|i| names.get(i).cloned())
}
/// The Color Settings section (Edit › Color Settings… / Assign Profile…), scrolled into view once
/// when one of those commands opened the panel ([`crate::state::UiState::color_settings_scroll`]).
fn settings_section(app: &mut VectorcraftApp, ui: &mut Ui) {
let top = ui.cursor().top();
settings_rows(app, ui);
// Edit › Color Settings… / Assign Profile… opened the panel for this section: scroll it into
// view once, as the plates and Proof Setup above it fill a short window (#6419). At once, not
// animated: the panel has just opened. egui moves the scroll area on a later frame, so ask for
// one rather than wait for the pointer to move.
if app.ui.color_settings_scroll && !ui.is_sizing_pass() {
let rect = egui::Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=ui.min_rect().bottom().max(top));
ui.scroll_to_rect_animation(rect, Some(egui::Align::TOP), egui::style::ScrollAnimation::none());
ui.ctx().request_repaint();
app.ui.color_settings_scroll = false;
}
}
/// The section's rows: the RGB and CMYK working spaces, intent, black point compensation, Load
/// Profile… and Gamut Check, and the profile the active document is assigned.
fn settings_rows(app: &mut VectorcraftApp, ui: &mut Ui) {
let st = cms::active_settings();
widgets::section_header(ui, tl!("Color Settings"));
ui.horizontal(|ui| {
@@ -215,4 +234,55 @@ mod tests {
assert!(panel.iter().any(|(id, _)| id == "tritanopia"));
assert!(panel.iter().all(|(id, _)| cms::ProofTarget::parse(id).is_some_and(|t| t.id() == *id)));
}
/// #6419: Edit › Assign Profile… and Color Settings… show the panel scrolled to its Color
/// Settings section, which a window shorter than the panel (a floated panel stops at 320
/// points) otherwise shows only after scrolling past the plates and Proof Setup. Once: a
/// scroll away afterwards stays.
#[test]
fn color_settings_and_assign_profile_scroll_the_color_settings_into_view() {
const VIEW: f32 = 120.0;
fn find(s: &egui::Shape, text: &str) -> Option<f32> {
match s {
egui::Shape::Text(t) if t.galley.text() == text => Some(t.pos.y),
egui::Shape::Vec(v) => v.iter().find_map(|s| find(s, text)),
_ => None,
}
}
// One frame of the panel in a scroll area VIEW points high, scrolled to `offset` when
// given: where the section's Black Point Compensation box shows in the view.
fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, offset: Option<f32>) -> f32 {
let mut top = 0.0;
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
top = ui.cursor().top();
let mut area = egui::ScrollArea::vertical().id_salt("separations").max_height(VIEW);
if let Some(y) = offset {
area = area.vertical_scroll_offset(y);
}
area.show(ui, |ui| show(app, ui));
});
out.textures_delta.clear();
out.shapes.iter().find_map(|c| find(&c.shape, "Use Black Point Compensation")).unwrap() - top
}
let visible = |y: f32| (0.0..VIEW).contains(&y);
for id in ["edit.assignProfile", "edit.colorSettings"] {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({})).unwrap();
let ctx = egui::Context::default();
let before = frame(&ctx, &mut app, None);
assert!(!visible(before), "the section starts below the view: {before}");
app.run(id, json!({})).unwrap();
frame(&ctx, &mut app, None);
assert!(!app.ui.color_settings_scroll);
// egui moves a scroll area on the frame after the request and paints it moved on the
// one after that; both ask for a repaint.
assert!(ctx.has_requested_repaint());
frame(&ctx, &mut app, None);
assert!(ctx.has_requested_repaint());
let shown = frame(&ctx, &mut app, None);
assert!(visible(shown), "{id} scrolls the section into the view: {shown}");
frame(&ctx, &mut app, Some(0.0));
assert_eq!(frame(&ctx, &mut app, None), before, "scrolled back to the top, it stays there");
}
}
}
@@ -1994,3 +1994,33 @@ fn the_drop_buttons_light_up_while_a_row_is_dragged() {
assert_eq!(drop_buttons_on(&ctx, &mut app, vec![]), [false, false], "dark again once it is released");
assert_eq!(stack_items(&app).len(), 2, "released elsewhere: nothing removed");
}
/// #6429: a dash length typed into the Stroke panel that the Appearance panel's "Stroke:" row or
/// the Properties panel's Stroke link opens sticks when a click outside closes the popup, as it
/// does when Enter ends the edit. The popup used to close without drawing again, so the field
/// never saw itself lose the keyboard and the value went back.
#[test]
fn a_dash_typed_in_a_stroke_popup_sticks_when_a_click_outside_closes_it() {
type Panel = fn(&mut VectorcraftApp, &mut egui::Ui);
let hosts: [(&str, Panel); 2] = [("Stroke:", appearance::show), ("Stroke", super::properties::show)];
for (link, show) in hosts {
let mut app = app_with_rect();
run(&mut app, "stroke.set", json!({"dash": [10.0]}));
let ctx = egui::Context::default();
let texts = frame_events(&ctx, &mut app, vec![], show);
click(&ctx, &mut app, text_rect(&texts, link).center(), show);
// The popup's first dash field: a click gives it the keyboard with its text selected.
let texts = frame_events(&ctx, &mut app, vec![], show);
click(&ctx, &mut app, text_rect(&texts, "10").center(), show);
frame_events(&ctx, &mut app, vec![egui::Event::Text("4".into())], show);
let dash = |app: &VectorcraftApp| current_stroke(app).and_then(|s| s.dash).map(|d| d.pattern);
assert_eq!(dash(&app), Some(vec![10.0]), "{link}: nothing set while typing");
click(&ctx, &mut app, egui::pos2(5.0, 3000.0), show);
let surrender = ctx.options(|o| o.input_options.surrender_focus_on);
assert_eq!(surrender, egui::SurrenderFocusOn::Clicks, "{link}: fields let go of the keyboard on a click again");
frame_events(&ctx, &mut app, vec![], show);
assert_eq!(dash(&app).and_then(|p| p.first().copied()), Some(4.0), "{link}: the typed dash stuck: {:?}", dash(&app));
let texts = frame_events(&ctx, &mut app, vec![], show);
assert!(!texts.iter().any(|(t, _)| t == "Dashed Line"), "{link}: the popup closed");
}
}
@@ -127,7 +127,7 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() {
let under = |e: &crate::menus::MenuEntry| e.path == ["Window", "Brush Libraries"];
let opened: Vec<_> =
entries.iter().filter(|e| under(e) && e.command.as_deref() == Some("window.brushLibrary")).map(|e| e.params["library"].clone()).collect();
assert_eq!(opened, [json!("art-ribbons"), json!("calligraphic-nibs"), json!("pattern-borders"), json!("scatter-shapes")]);
assert_eq!(opened, [json!("art-ribbons"), json!("calligraphic-nibs"), json!("pattern-borders"), json!("scatter-shapes"), json!("game-trails")]);
let enabled = |cmd: &str| entries.iter().find(|e| under(e) && e.command.as_deref() == Some(cmd)).map(|e| e.enabled);
assert_eq!(enabled("window.brushLibrary.other"), Some(true));
assert_eq!(enabled("ui.saveBrushLibrary"), Some(true), "a new document has the default brushes");
+6
View File
@@ -392,6 +392,11 @@ pub struct UiState {
/// That popup's first text field is still to take the keyboard.
#[serde(skip)]
pub transient_focus: bool,
/// The Separations Preview panel is still to scroll its Color Settings section into view:
/// Edit › Color Settings… and Assign Profile… show the panel for it, and the section sits below
/// the plates and Proof Setup, out of sight in a short window (#6419).
#[serde(skip)]
pub color_settings_scroll: bool,
/// Type › Find Font…'s last Find In choice (`find_font::FIND_IN`), kept while the app runs.
#[serde(skip)]
pub find_font_in: String,
@@ -1009,6 +1014,7 @@ impl Default for UiState {
open_panel: None,
transient_panel: None,
transient_focus: false,
color_settings_scroll: false,
find_font_in: String::new(),
control_bar: false,
control_bar_bottom: false,
+1 -1
View File
@@ -47,7 +47,7 @@ pub use drag::{
use dropdowns::combo_button_id;
#[cfg(test)]
pub(crate) use dropdowns::menu_floor;
pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field, combo_named, popover_is_open};
pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field, combo_named, field_focused_in, popover_is_open};
pub use dropdowns::{
dropdown, dropdown_named, dropdown_names, dropdown_names_named, dropdown_with, dropdown_with_named, list_scroll_style, menu_room, menu_scroll,
panel_scroll, popover, set_menu_floor, strip_scroll,
+13 -1
View File
@@ -234,8 +234,13 @@ pub fn popover<R>(resp: &Response, toggle: bool, content: impl FnOnce(&mut Ui) -
&& click_pos.is_some_and(|pos| ctx.layer_id_at(pos).is_none_or(|l| l.order != egui::Order::Foreground));
was_open && !clicked_elsewhere
};
// Closing with a field in it that has the keyboard: it draws once more, so the field lets go of
// the keyboard to the click and commits what was typed, as in a panel (#6429).
let last = was_open && !open && field_focused_in(ctx, egui::LayerId::new(egui::Order::Foreground, id));
let mut shown = open || last;
let inner =
egui::Popup::menu(resp).id(id).open_bool(&mut open).close_behavior(egui::PopupCloseBehavior::IgnoreClicks).show(content).map(|r| r.inner);
egui::Popup::menu(resp).id(id).open_bool(&mut shown).close_behavior(egui::PopupCloseBehavior::IgnoreClicks).show(content).map(|r| r.inner);
open = shown && !last;
ctx.data_mut(|d| {
if open {
d.insert_temp(id, frame);
@@ -246,6 +251,13 @@ pub fn popover<R>(resp: &Response, toggle: bool, content: impl FnOnce(&mut Ui) -
inner
}
/// Whether a field in the popup on `layer` has the keyboard. A popup closing with one draws once
/// more before it goes: the field lets go of the keyboard to the click away and commits what was
/// typed, as it does in a panel. A popup that closed without drawing again dropped it (#6429).
pub(crate) fn field_focused_in(ctx: &egui::Context, layer: egui::LayerId) -> bool {
ctx.memory(|m| m.focused()).and_then(|id| ctx.read_response(id)).is_some_and(|r| r.layer_id == layer)
}
/// Whether the popover for this anchor was open in the latest completed frame.
pub(crate) fn popover_is_open(ctx: &egui::Context, anchor: egui::Id) -> bool {
let frame = ctx.cumulative_frame_nr();
+9 -6
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -443,7 +443,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
- **Missing:** CJK vertical
composition (mojikumi sets and dialog, kinsoku settings #633, manual tate-chu-yoko), Middle Eastern
features beyond bidi, digit types, Character Direction and Kashidas (Middle Eastern composers, a split caret), spell check (its engine is done, #4936: `text.spelling.check` and
`text.spelling.suggest` on craft-proof; the custom dictionary and Ignore All commands; Auto Spell Check, `edit.autoSpellCheck`, with a right-click on an underlined word offering its suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type; the Check Spelling and Edit Custom Dictionary dialogs remain),
`text.spelling.suggest` on craft-proof; the custom dictionary and Ignore All commands; Auto Spell Check, `edit.autoSpellCheck`, with a right-click on an underlined word offering its suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type; the Edit Custom Dictionary dialog; the Check Spelling dialog remains),
Touch Type, Retype, Snap to Glyph; optional preservation of font sizes during group scaling (#1034 enhancement).
Itemized in [type-parity.md](type-parity.md).
- **Impact:** professional typography and Japanese/Chinese publishing.
@@ -633,8 +633,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
libraries ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html),
[import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html)):
Window › Brush Libraries and the Brushes panel's Brush Libraries Menu open each library in a library panel of
its own (four original libraries generated in code: Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter
Shapes; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a click adds a
its own (five original libraries generated in code: Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter
Shapes and Game Trails; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a
click adds a
brush to the document's Brushes with the patterns, symbols and other resources its art uses, as one undo
step (applied to the selected paths in the same step), dragging one onto a path adds and applies it and
dragging one onto the Brushes panel adds it; Save Brushes / Save Brush Library writes the document's brushes
@@ -650,11 +651,11 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
graphic style libraries (Window › Graphic Style Libraries; seven original libraries generated in code, including
Game HUD with Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine and Boss Alert; User Defined; Other
Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`).
- **Missing:** more built-in brush libraries (and further graphic-style libraries beyond the seven shipped, and further symbol libraries beyond Basic
- **Missing:** more built-in brush libraries beyond the five shipped (and further graphic-style libraries beyond the seven shipped, and further symbol libraries beyond Basic
Shapes, UI Frames and Map Markers; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write;
Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the
start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge).
- **Estimate:** 9–19 h.
- **Estimate:** 8–18 h.
## G3 3D and Materials
@@ -852,12 +853,14 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | minor | G4 type: Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, #4936; the Check Spelling dialog remains; scores and estimates unchanged. |
| 2026-11-10 | minor | Tidy-up, no content change: in Feature submission evidence, a stray table row and two "PR #NNNN revision" notes move into Feature revisions, the three "Feature evidence last updated" lines become one, the duplicate Flyout update note goes (its row stays), and eight reconciliation rows become two. Scores and estimates unchanged. |
| 2026-11-10 | minor | G5: built-in Game HUD graphic style library (Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine, Boss Alert); Missing trimmed; estimate 10–20 → 9–19 h. |
| 2026-11-10 | minor | G5: built-in Map Markers symbol library (Pin, Flag, Crosshair, Checkpoint; no default 9-slice); Missing trimmed; estimate 11–21 → 10–20 h. |
| 2026-11-10 | minor | G4 type: a right-click on type has Spelling › Auto Spell Check, checked while it is on, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. |
| 2026-11-10 | minor | G4 type: a right-click on a word Auto Spell Check underlined lists its suggestions, Ignore All and Add to Dictionary, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. |
| 2026-11-10 | minor | G2: the Effect Gallery shows a thumbnail of each filter and a preview area inside the dialog (an original landscape drawing passed through the filter). Estimate 1–2 h → 0. The canvas preview and one-undo OK are unchanged. |
| 2026-11-09 | minor | G5: built-in Game Trails brush library (Speed Line, Dash Trail, Burst Ray, Zigzag); Missing trimmed; estimate 11–21 → 10–20 h. |
| 2026-11-09 | minor | G4 type: Auto Spell Check (`edit.autoSpellCheck`) underlines the unknown words of the visible type, #4936; the Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu remain; scores and estimates unchanged. |
| 2026-11-09 | minor | G5: built-in UI Frames symbol library (Panel, Button, Bar with default 9-slice); Missing trimmed; estimate 12–22 → 11–21 h. |
| 2026-11-09 | minor | G5: Direct Selection of a dynamic instance part takes opacity, blend, gradient and pattern fill/stroke; Art brush Overlap and Pattern auto corners documented Done; estimate 15–25 → 12–22 h. |
+21 -7
View File
@@ -1525,7 +1525,9 @@ File › Scripts › Other Script… (Cmd+F12) picks a `.js` or `.jsx` file and
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html
describes that menu item. Pass exactly one of `path` and `source`. A `path` must end in `.js` or `.jsx`, in any
case. A `.jsxbin` file or text that starts with `@JSXBIN@` is compiled and ends with an error, as does text that is
not UTF-8. The language is ES5.1. The script reaches the app through one global object:
not UTF-8. The language is ES5.1, except that identifiers take the characters Unicode's `ID_Start` and
`ID_Continue` properties allow, as later editions define them, and may use `\u{...}` escapes. The script reaches the
app through one global object:
- `vc.run(id, params)` runs a command and returns its result. A command that fails throws an error the script can
catch. The documents and the session state the command changed, preferences included, go back, and any file it
@@ -2578,6 +2580,12 @@ solid, false open behind), `volume` (0..100%, default 50), `bothSides`.
All three take `rotationX/Y/Z` (degrees, -180..180, default the Off-Axis Front position -21.88/-23.4/9.06),
`perspectiveAngle` and the lighting parameters as Revolve. See [three-d.md](three-d.md) for the Position presets,
the scope and what differs from the public documentation.
`threeD.extrude` and `threeD.inflate` take up to 65,536 outline points and build up to 163,840 faces for each solid
(VectorCraft's own limits); with more faces than that, each curve takes fewer pieces, down to 4. A shape past the
points returns "The shape is too complex for a 3D effect", and one whose faces do not fit at 4 pieces a curve returns
"The 3D surface is too complex; simplify the shape". `threeD.inflate` also returns the surface error when its outline
has so many points across so many of its grid's cells that filling the grid would pass its work limit. `threeD.rotate`
takes up to 65,536 points.
## Extrude & Bevel (Classic)
@@ -2839,10 +2847,10 @@ error.
## Brush libraries
Brush libraries are read-only sets of brushes: four generated in code (Art Ribbons, Calligraphic Nibs, Pattern
Borders, Scatter Shapes), the `.vectorcraft` files in the user library folder of the desktop app (User Defined) and
the brushes of any document loaded with `brush.loadLibrary` (a document without brushes of its own has the default
ones). `brush.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and
Brush libraries are read-only sets of brushes: five generated in code (Art Ribbons, Calligraphic Nibs, Pattern
Borders, Scatter Shapes, Game Trails), the `.vectorcraft` files in the user library folder of the desktop app (User
Defined) and the brushes of any document loaded with `brush.loadLibrary` (a document without brushes of its own has
the default ones). `brush.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and
`brush.library {library}` returns one's brushes (`name`, `type`; `library` is an id or a name).
`brush.addFromLibrary {library, name? | names?}` copies brushes into the document's Brushes as one undo step (no
names: the whole library), with the resources their art uses: patterns, symbols, swatches (`swatchConflict` as for
@@ -4083,8 +4091,9 @@ outlines and its underline and strikethrough bars. This applies to each point ty
default.
A width or height typed with the option on scales the type until its ink has that size. The type stays live type: its
font size changes by the scale, so `object.setBounds {height: 72}` on "H" gives the cap height 72 pt, not a 72 pt
font. With Use Preview Bounds on, the ink takes in the characters' strokes, and type with geometry effects is
font size changes by the vertical scale, so `object.setBounds {height: 72}` on "H" gives the cap height 72 pt, not a
72 pt font. A width typed alone, out of proportion, changes the horizontal scale and keeps the font size, and a height
typed alone keeps the width, so the horizontal scale changes the other way. With Use Preview Bounds on, the ink takes in the characters' strokes, and type with geometry effects is
measured as drawn. Rotated type keeps its own angle, and skewed type is measured by its glyph outlines. Type that
draws nothing (no text, or only spaces without underline or strikethrough), area type, type on a path and type inside
a selected group are measured as before. Arrange in Grid and Group Overlapping Objects measure visual bounds as
@@ -6134,6 +6143,11 @@ preferences, one word per line; without preferences (`VECTORCRAFT_NO_PREFS`, the
closes. `text.spelling.ignoreAll {word}` is Ignore All: the active document's checks skip the word, in
any case, while the document is open. It is not saved and not an undo step.
In the app, Edit › Edit Custom Dictionary… (`text.spelling.dictionary` with no params, from the menus) opens a dialog of
the words, with an Entry field and Add, Delete and Change; Done runs `text.spelling.editDictionary {remove, add}` for
the words that differ, which deletes and adds them and saves once, all or none (a save that fails keeps none of them),
and Cancel changes nothing.
```json
{"name":"run_command","arguments":{"command":"text.spelling.addWord","params":{"word":"VectorCraft"}}}
{"name":"run_command","arguments":{"command":"text.spelling.ignoreAll","params":{"word":"Zqvwkx"}}}
+5 -4
View File
@@ -1,6 +1,6 @@
# Roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
>
> Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369).
>
@@ -146,9 +146,9 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default | 4–8 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); 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 | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type (#4936; the dialogs remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 |
| 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, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/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); symbol libraries done (Window › Symbol Libraries, three generated libraries (Basic Shapes, UI Frames, Map Markers), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 |
@@ -199,6 +199,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | minor | M7: the Edit Custom Dictionary dialog (#4936); the Check Spelling dialog remains; scores and estimates unchanged. |
| 2026-11-10 | minor | M10/G5: Game HUD built-in graphic style library; estimates unchanged |
| 2026-11-10 | minor | M10/G5: Map Markers built-in symbol library (Pin, Flag, Crosshair, Checkpoint); estimates unchanged |
| 2026-11-10 | minor | M7: Spelling › Auto Spell Check in the context menu of type (#4936); the dialogs remain; scores and estimates unchanged. |
File diff suppressed because one or more lines are too long
+15 -5
View File
@@ -105,7 +105,8 @@ The rest is our reading:
- The solids turn, twist and taper about the center of the bounds of all their shapes, Inflate's grid covers
those bounds, and with Perspective one eye sees every solid. A stroke too narrow to hold a point of Inflate's
grid stays flat.
- A stroke whose outline takes more than 2,048 points is left out, and the fill's solid still draws.
- A stroke whose outline takes more than 65,536 points, or whose solid takes more than 163,840 faces, is left out,
and the fill's solid still draws.
## Groups
@@ -235,10 +236,19 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used.
clipping groups and other live objects have to be converted first. Revolve on a group sweeps its members about
one axis ([revolve.md](revolve.md)). One 3D effect per object.
- Curves are followed within 1/2000 of the shape's size (at least 0.05 pt), so a small curve takes a few points
and a large one up to 32. Extrude & Bevel and Inflate take up to 2,048 outline points (a stroke's outline is
counted on its own), Rotate (which only turns the outline) 65,536. The limit is counted on the shape as drawn and,
when its contours meet, again on the merged outline, which can have more points (bars crossing in a grid trace
every hole between them); past it, the shape is too complex for the effect.
and a large one up to 32. Extrude & Bevel and Inflate take up to 65,536 outline points and 163,840 faces for each
solid (a stroke's outline is counted on its own), and Rotate (which only turns the outline) takes 65,536 points.
These limits are VectorCraft's own values. Before any point is sampled, Extrude & Bevel counts the faces its walls,
bevels and caps need, and Inflate counts the faces of its sides; when they do not fit, every curve of the shape
takes fewer pieces, down to 4 (a curve that needs fewer takes fewer). A shape that does not fit at 4 pieces a
curve is refused because the 3D surface is too complex. Inflate counts the faces of its front and back as it
builds them, against the same 163,840. The point limit is counted on the shape as drawn and, when its contours
meet, again on the merged outline, which can have more points (bars crossing in a grid trace every hole between
them); past it, the shape is too complex for the effect.
- Inflate also limits the work of filling its grid, which grows with the outline's points times the grid cells its
outline crosses. An outline of tens of thousands of points that crosses nearly every cell (a comb of 4,000 teeth
across the shape) is refused because the 3D surface is too complex; a closed path of 65,536 points round a
circle builds.
- **3D (Classic)**: Extrude & Bevel (Classic) is built, with its surface types and blend steps. Revolve (Classic),
Rotate (Classic), the track cube, map art, Turntable and 3D export are separate, unbuilt items.
+3 -2
View File
@@ -1,6 +1,6 @@
# Type parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x)
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
@@ -47,7 +47,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Hyphenation… and Justification… options | done | Eleven Indian languages use attributed patterns and preserve syllables in both composers (#4947). Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Auto Leading also sets the Leading the Character panel shows for Auto type, the leading Alt+↑/↓ (`type.step`) steps from, per paragraph, and the lines Fill with Placeholder Text counts. Evidence: `crates/text/src/tests/justify.rs`, `dialogs::justification::tests`, `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading` |
| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877); Ctrl-dragging a stop (Command on macOS) moves all the stops together; in right-aligned area type the text before a line's last tab sits at its stops, from the left edge, and the text after it against the right edge, as Illustrator's own layout in a user's `.ai` sets a price list (evidence: `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`; centred and justified lines are still aligned as a whole) |
| Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` |
| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | partial | the engine (#4936): `text.spelling.check` lists the unknown words of the document's type, each run in its Language, at the bytes `text.editRange` changes, and `text.spelling.suggest` gives suggestions, both from craft-proof, each paragraph on its own (English from the first dictionary it finds: a Hunspell dictionary in the user's dictionaries folder, the SCOWL dictionary built in from craft-i18n, a system Hunspell dictionary, and last the public-domain Moby word list built into it; other languages from the dictionaries craft-proof finds for them). The custom dictionary commands are done: `text.spelling.addWord`, `removeWord` and `changeWord` (Add, and Edit Custom Dictionary's Add, Delete and Change), and `text.spelling.dictionary` lists the words, saved to `Custom Dictionary.txt` beside the preferences; Ignore All: `text.spelling.ignoreAll` skips a word in the active document while it is open. Auto Spell Check is done: Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`, off at first, saved with the view settings) draws a red wavy line under the unknown words of the visible type, leaves out type with effects, and follows edits, the custom dictionary and Ignore All. A right-click on an underlined word lists the words its dictionary suggests (each replaces it in one undo step, `text.editRange`), or No Suggestions, then Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check, checked while it is on. The Check Spelling and Edit Custom Dictionary dialogs remain. Evidence: `crates/engine/src/tests/spelling.rs`, `crates/ui-egui/src/canvas/tests.rs` (`auto_spell_check_*`), `crates/ui-egui/src/tests/contextmenu.rs` (the right-click). |
| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | partial | the engine (#4936): `text.spelling.check` lists the unknown words of the document's type, each run in its Language, at the bytes `text.editRange` changes, and `text.spelling.suggest` gives suggestions, both from craft-proof, each paragraph on its own (English from the first dictionary it finds: a Hunspell dictionary in the user's dictionaries folder, the SCOWL dictionary built in from craft-i18n, a system Hunspell dictionary, and last the public-domain Moby word list built into it; other languages from the dictionaries craft-proof finds for them). The custom dictionary commands are done: `text.spelling.addWord`, `removeWord` and `changeWord` (Add, and Edit Custom Dictionary's Add, Delete and Change), and `text.spelling.dictionary` lists the words, saved to `Custom Dictionary.txt` beside the preferences; Ignore All: `text.spelling.ignoreAll` skips a word in the active document while it is open. Auto Spell Check is done: Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`, off at first, saved with the view settings) draws a red wavy line under the unknown words of the visible type, leaves out type with effects, and follows edits, the custom dictionary and Ignore All. A right-click on an underlined word lists the words its dictionary suggests (each replaces it in one undo step, `text.editRange`), or No Suggestions, then Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check, checked while it is on. Edit › Edit Custom Dictionary… lists the custom dictionary's words, with an Entry field and Add, Delete and Change, applied on Done (Cancel leaves the dictionary as it was). The Check Spelling dialog remains. Evidence: `crates/engine/src/tests/spelling.rs`, `crates/ui-egui/src/canvas/tests.rs` (`auto_spell_check_*`), `crates/ui-egui/src/tests/contextmenu.rs` (the right-click), `crates/ui-egui/src/dialogs/custom_dictionary.rs` (the dialog). |
| Smart Punctuation, Change Case, Find & Replace | done | |
| Alt + numeric-keypad codes while typing (Windows: `Alt+0176` → °) | partial | #5108: on Windows the character Windows posts when Alt comes up after keypad digits is typed at the caret like any other, in the typing's undo step, and the keypad digits typed with Alt run no shortcut. With NumLock off the keypad keys (Insert, End, arrows, Clear, Home, Page Up/Down without the extended-key flag) count as digits too and don't move the caret (`windows_alt_codes` unit tests; neither NumLock on nor off has been tried on a Windows machine yet). `type.insert {code}` (Windows Alt+0 code, Windows-1252 for 128–159) and `{unicode}` (hex code point) insert a character by its code (`menucmds::insert_a_character_by_its_code`). macOS and Linux have no such system convention and egui reports a keypad digit as the top-row digit, so there it does nothing; a floating panel window doesn't receive the character |
| Caret: the arrows and Delete step over whole clusters (Thai consonant with its marks, combining accents, emoji); Backspace removes one character | done | in text with right-to-left characters the arrows move in visual order and stop on both sides of a character drawn with no width, such as a zero width non-joiner |
@@ -81,6 +81,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | minor | Edit Custom Dictionary (#4936): Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, applied on Done; the Check Spelling dialog remains. Scores and estimates unchanged. |
| 2026-11-10 | minor | Type on a path (#3565): Direct Selection shows the direction handles of selected type on a path's path, as of a path selected whole, and drags them (`text.setPathHandle`; Alt: one side, Shift: 45° steps); the text reflows along the new curve. Scores and estimates unchanged. |
| 2026-11-10 | minor | Spelling in the canvas context menu (#4936): a right-click on type has Spelling › Auto Spell Check, checked while it is on; the Check Spelling and Edit Custom Dictionary dialogs remain. Scores and estimates unchanged. |
| 2026-11-10 | minor | Spelling in the canvas context menu (#4936): a right-click on a word Auto Spell Check underlined lists its suggestions, Ignore All and Add to Dictionary; the Check Spelling and Edit Custom Dictionary dialogs remain. Scores and estimates unchanged. |