mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 21:51:36 +00:00
Merge main into feat/plugin-call-commands
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -953,6 +953,40 @@ fn polygon_path(pts: &[Point]) -> PathData {
|
||||
PathData::single(vectorcraft_geom::SubPath::new(pts.iter().map(|p| Anchor::corner(*p)).collect(), true))
|
||||
}
|
||||
|
||||
/// The part of an image under `r` (in document space) copied out of its pixels, as PNG bytes, with its size and
|
||||
/// placement. `None` when the image can't be cut that way (turned or skewed, CMYK, pixels that can't be read, or
|
||||
/// `r` not on the pixel grid), so the caller draws it instead.
|
||||
fn crop_pixels(doc: &vectorcraft_doc::Document, im: &ImageObject, r: Rect) -> Option<(Vec<u8>, u32, u32, Affine)> {
|
||||
let blob = doc.images.get(&im.key)?;
|
||||
let [a, b, c, d, _, _] = im.xf.as_coeffs();
|
||||
if blob.is_cmyk() || b.abs() > 1e-9 || c.abs() > 1e-9 || a.abs() < 1e-9 || d.abs() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let inv = im.xf.inverse();
|
||||
let (p0, p1) = (inv * Point::new(r.x0, r.y0), inv * Point::new(r.x1, r.y1));
|
||||
// Only a rect on the pixel grid is cut from the pixels; one between pixels keeps its exact bounds by being drawn.
|
||||
// Checked before decoding, so an off-grid crop of a large image doesn't decode it for nothing.
|
||||
let on_grid = |v: f64| (v - v.round()).abs() < 1e-3;
|
||||
if ![p0.x, p0.y, p1.x, p1.y].into_iter().all(on_grid) {
|
||||
return None;
|
||||
}
|
||||
let px = decode_rgba("object.cropImage", blob).ok()?;
|
||||
let (w, h) = px.dimensions();
|
||||
if w == 0 || h == 0 || (w, h) != (im.width, im.height) {
|
||||
return None;
|
||||
}
|
||||
let lo = |v: f64, max: u32| v.round().clamp(0.0, f64::from(max)) as u32;
|
||||
let (x0, x1) = (lo(p0.x.min(p1.x), w), lo(p0.x.max(p1.x), w));
|
||||
let (y0, y1) = (lo(p0.y.min(p1.y), h), lo(p0.y.max(p1.y), h));
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return None;
|
||||
}
|
||||
let cut = image::imageops::crop_imm(&px, x0, y0, x1 - x0, y1 - y0).to_image();
|
||||
let mut png = Vec::new();
|
||||
cut.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
|
||||
Some((png, x1 - x0, y1 - y0, im.xf * Affine::translate((f64::from(x0), f64::from(y0)))))
|
||||
}
|
||||
|
||||
fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.cropImage";
|
||||
let st = s.doc()?;
|
||||
@@ -993,6 +1027,11 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if (r.x0 - ib.x0).abs() < 1e-6 && (r.y0 - ib.y0).abs() < 1e-6 && (r.x1 - ib.x1).abs() < 1e-6 && (r.y1 - ib.y1).abs() < 1e-6 {
|
||||
return Err(bad(C, "nothing to crop"));
|
||||
}
|
||||
// A plain, axis-aligned raster is cut straight from its pixels, so nothing is resampled.
|
||||
if let Some((png, cw, ch, xf)) = crop_pixels(&st.doc, im, r) {
|
||||
put_pixels(s, id, png, cw, ch, xf)?;
|
||||
return Ok(json!({ "id": id.0, "width": cw, "height": ch }));
|
||||
}
|
||||
// Keep the image's pixel density.
|
||||
let scale = (im.width as f64 / ib.width().max(1e-9)).max(im.height as f64 / ib.height().max(1e-9));
|
||||
let mut bare = node.clone();
|
||||
@@ -1027,6 +1066,27 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The image's pixels as RGBA, held to the allowance opening a raster has.
|
||||
fn decode_rgba(c: &str, blob: &vectorcraft_doc::ImageBlob) -> Result<image::RgbaImage> {
|
||||
// Print rasters run to gigabytes decoded: the same allowance opening them has.
|
||||
let reader = || {
|
||||
image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
|
||||
.with_guessed_format()
|
||||
.map_err(|_| bad(c, "the image's pixels can't be read"))
|
||||
};
|
||||
// The image's size as RGBA, read from its header, is held to that allowance before anything
|
||||
// is decoded (#1717).
|
||||
let (w, h) = reader()?.into_dimensions().map_err(|e| bad(c, format!("the image's pixels can't be read: {e}")))?;
|
||||
if u64::from(w) * u64::from(h) > crate::cmd::fileio::MAX_RASTER_ALLOC / 4 {
|
||||
return Err(bad(c, format!("an image of {w} × {h} pixels is too large to draw")));
|
||||
}
|
||||
let mut reader = reader()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
Ok(reader.decode().map_err(|e| bad(c, format!("the image's pixels can't be read: {e}")))?.to_rgba8())
|
||||
}
|
||||
|
||||
/// Crop Image's trim: the image cut to the box of its pixels that aren't fully transparent, the
|
||||
/// pixels copied as they are (no resampling) and placed where they were.
|
||||
fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
|
||||
@@ -1037,23 +1097,7 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
|
||||
if blob.is_cmyk() {
|
||||
return Ok(json!({ "id": id.0, "width": im.width, "height": im.height, "trimmed": false }));
|
||||
}
|
||||
// Print rasters run to gigabytes decoded: the same allowance opening them has.
|
||||
let reader = || {
|
||||
image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
|
||||
.with_guessed_format()
|
||||
.map_err(|_| bad(C, "the image's pixels can't be read"))
|
||||
};
|
||||
// The image's size as RGBA, read from its header, is held to that allowance before anything
|
||||
// is decoded (#1717).
|
||||
let (w, h) = reader()?.into_dimensions().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?;
|
||||
if u64::from(w) * u64::from(h) > crate::cmd::fileio::MAX_RASTER_ALLOC / 4 {
|
||||
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw")));
|
||||
}
|
||||
let mut reader = reader()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
let px = reader.decode().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?.to_rgba8();
|
||||
let px = decode_rgba(C, blob)?;
|
||||
let (w, h) = px.dimensions();
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
|
||||
for (x, y, p) in px.enumerate_pixels() {
|
||||
@@ -1072,23 +1116,22 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
|
||||
let mut png = Vec::new();
|
||||
cut.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| bad(C, e.to_string()))?;
|
||||
let (cw, ch) = (x1 - x0, y1 - y0);
|
||||
put_pixels(s, id, png, cw, ch, im.xf * Affine::translate((f64::from(x0), f64::from(y0))))?;
|
||||
Ok(json!({ "id": id.0, "width": cw, "height": ch, "trimmed": true }))
|
||||
}
|
||||
|
||||
/// Crop Image's edit for a cut made from the image's own pixels: `png` (`w` × `h` px) takes the
|
||||
/// image's place, mapped into the document by `xf`.
|
||||
fn put_pixels(s: &mut Session, id: NodeId, png: Vec<u8>, w: u32, h: u32, xf: Affine) -> Result<()> {
|
||||
s.edit("Crop Image", |d, _| {
|
||||
let key = unique_key(d, "crop");
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
|
||||
if let Some(n) = d.node_mut(id) {
|
||||
n.kind = NodeKind::Image(Box::new(ImageObject {
|
||||
key,
|
||||
width: cw,
|
||||
height: ch,
|
||||
xf: im.xf * Affine::translate((f64::from(x0), f64::from(y0))),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
}));
|
||||
n.kind = NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "id": id.0, "width": cw, "height": ch, "trimmed": true }))
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The Control bar's Mask for an image: a clip group of the image and a clipping path on its
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1514,6 +1514,44 @@ fn crop_image_to_rect() {
|
||||
assert!(s.execute("object.cropImage", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// 100 × 80 px of a busy pattern, opened as an image and scaled to 2 pt a pixel; returns the session, the image and its pixels.
|
||||
fn busy_image_at_two_points_a_pixel() -> (Session, NodeId, image::RgbaImage) {
|
||||
let mut px = image::RgbaImage::new(100, 80);
|
||||
for (x, y, p) in px.enumerate_pixels_mut() {
|
||||
*p = image::Rgba([(x * 37 % 256) as u8, (y * 91 % 256) as u8, ((x ^ y) * 13 % 256) as u8, 255]);
|
||||
}
|
||||
let mut png = Vec::new();
|
||||
px.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let img = selected(&s)[0];
|
||||
s.execute("object.scale", &json!({"sx": 200, "origin": [0, 0]})).unwrap();
|
||||
(s, img, px)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_image_to_rect_copies_the_pixels_without_resampling() {
|
||||
let (mut s, img, px) = busy_image_at_two_points_a_pixel();
|
||||
// Pixels (10, 5) to (40, 25), as points: on the pixel grid, so the cut copies them as they are.
|
||||
let r = s.execute("object.cropImage", &json!({"rect": [20, 10, 60, 40]})).unwrap();
|
||||
assert_eq!((r["width"].as_u64().unwrap(), r["height"].as_u64().unwrap()), (30, 20), "{r}");
|
||||
let b = bounds(&s, img);
|
||||
assert!(close(b.x0, 20.0) && close(b.y0, 10.0) && close(b.width(), 60.0) && close(b.height(), 40.0), "{b:?}");
|
||||
let NodeKind::Image(im) = &node(&s, img).kind else { panic!("an image") };
|
||||
let out = image::load_from_memory(&s.doc().unwrap().doc.images[&im.key].bytes).unwrap().to_rgba8();
|
||||
assert_eq!(out, image::imageops::crop_imm(&px, 10, 5, 30, 20).to_image());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_image_to_a_rect_between_pixels_keeps_the_rect() {
|
||||
let (mut s, img, _) = busy_image_at_two_points_a_pixel();
|
||||
// Pixels (10.5, 5.5) to (40.5, 25.5): off the pixel grid, so the cut is drawn and lands on the rect, not on whole pixels.
|
||||
s.execute("object.cropImage", &json!({"rect": [21, 11, 60, 40]})).unwrap();
|
||||
let b = bounds(&s, img);
|
||||
assert!(close(b.x0, 21.0) && close(b.y0, 11.0) && close(b.width(), 60.0) && close(b.height(), 40.0), "{b:?}");
|
||||
}
|
||||
|
||||
/// Crop Image refuses a `rect` that is not an array, such as text, and the image stays as it was; a
|
||||
/// null `rect` crops to the artboard as a missing one does.
|
||||
#[test]
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -6453,3 +6453,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.
|
@@ -6504,3 +6504,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.
|
@@ -6701,3 +6701,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.
|
@@ -6720,3 +6720,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.
|
@@ -6467,3 +6467,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.
|
@@ -6307,3 +6307,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.
|
@@ -6672,3 +6672,5 @@ breakText Glyphs Γλύφοι
|
||||
Ignore All Παράβλεψη όλων
|
||||
Add to Dictionary Προσθήκη στο λεξικό
|
||||
No Suggestions Καμία πρόταση
|
||||
Edit Custom Dictionary Επεξεργασία προσαρμοσμένου λεξικού
|
||||
Entry: Λήμμα:
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -6663,3 +6663,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.
|
@@ -6755,3 +6755,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.
|
@@ -6715,3 +6715,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.
|
@@ -8161,3 +8161,5 @@ theme Appearance अपीयरेंस
|
||||
Ignore All सभी को अनदेखा करें
|
||||
Add to Dictionary शब्दकोश में जोड़ें
|
||||
No Suggestions कोई सुझाव नहीं
|
||||
Edit Custom Dictionary कस्टम शब्दकोश संपादित करें
|
||||
Entry: प्रविष्टि:
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -6704,3 +6704,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.
|
@@ -6467,3 +6467,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.
|
@@ -6341,3 +6341,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.
|
@@ -6406,3 +6406,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.
|
@@ -6781,3 +6781,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.
|
@@ -6302,3 +6302,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.
|
@@ -6518,3 +6518,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.
|
@@ -6576,3 +6576,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.
|
@@ -6793,3 +6793,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.
|
@@ -6809,3 +6809,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.
|
@@ -8205,3 +8205,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.
|
@@ -6676,3 +6676,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.
|
@@ -6321,3 +6321,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.
|
@@ -6322,3 +6322,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.
|
@@ -6558,3 +6558,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.
|
@@ -6356,3 +6356,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.
|
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -243,3 +243,72 @@ fn a_spinner_step_from_a_weight_shown_as_a_preset_moves_past_it() {
|
||||
}
|
||||
assert_eq!(app.session.shown_stroke().unwrap().width, 1.0);
|
||||
}
|
||||
|
||||
/// The screen rect of the Weight spinner in the Stroke panel, found by its accessibility label, so
|
||||
/// the tests do not depend on where the panel puts the field.
|
||||
fn weight_spinner_rect(ctx: &egui::Context, app: &mut VectorcraftApp) -> egui::Rect {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| stroke::show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
let nodes = &out.platform_output.accesskit_update.as_ref().expect("accessibility is on").nodes;
|
||||
let b = nodes.iter().find(|(_, n)| n.label() == Some("Step up or down")).expect("the weight spinner").1.bounds().expect("its bounds");
|
||||
egui::Rect::from_min_max(egui::pos2(b.x0 as f32, b.y0 as f32), egui::pos2(b.x1 as f32, b.y1 as f32))
|
||||
}
|
||||
|
||||
/// With objects of different weights selected the Weight field is blank, and its spinner steps
|
||||
/// from 0.25 pt (#5349): up gives 0.5 pt and down gives 0 pt, for all of them, whatever the
|
||||
/// Units > Stroke.
|
||||
#[test]
|
||||
fn the_spinner_of_a_blank_weight_steps_from_a_quarter_point() {
|
||||
use super::tests_appearance::frame_raw;
|
||||
|
||||
for (unit, up, after) in [("points", true, 0.5), ("points", false, 0.0), ("millimeters", true, 0.5), ("millimeters", false, 0.0)] {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.enable_accesskit();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut app = app_with_rect();
|
||||
app.session.prefs.units_stroke = unit.into();
|
||||
let a = app.session.active().unwrap().selection.objects[0];
|
||||
let b = run(&mut app, "shape.rectangle", json!({"x": 120, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap();
|
||||
run(&mut app, "stroke.set", json!({"ids": [b], "weight": 3}));
|
||||
run(&mut app, "select.set", json!({ "ids": [a.0, b] }));
|
||||
texts(&ctx, &mut app, stroke::show);
|
||||
let sr = weight_spinner_rect(&ctx, &mut app);
|
||||
// The up arrow is the upper half of the spinner, the down arrow the lower half.
|
||||
let pos = egui::pos2(sr.center().x, sr.center().y + if up { -sr.height() / 4.0 } else { sr.height() / 4.0 });
|
||||
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
for event in [egui::Event::PointerMoved(pos), button(true), button(false)] {
|
||||
frame_raw(&ctx, &mut app, egui::RawInput { events: vec![event], ..Default::default() }, stroke::show);
|
||||
}
|
||||
for id in [a.0, b] {
|
||||
run(&mut app, "select.set", json!({ "ids": [id] }));
|
||||
assert_eq!(app.session.shown_stroke().unwrap().width, after, "object {id}, up: {up}, {unit}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ↑/↓ in the focused blank Weight field step from 0.25 pt too, for every selected object.
|
||||
#[test]
|
||||
fn the_arrow_keys_in_a_blank_weight_step_from_a_quarter_point() {
|
||||
for (key, after) in [(egui::Key::ArrowUp, 0.5), (egui::Key::ArrowDown, 0.0)] {
|
||||
// A new context each time: the field keeps its text in the context, and the 0.5 pt left by ArrowUp
|
||||
// would make the blank field of the next run start from 0.5 pt.
|
||||
let ctx = egui::Context::default();
|
||||
ctx.enable_accesskit();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut app = app_with_rect();
|
||||
let a = app.session.active().unwrap().selection.objects[0];
|
||||
let b = run(&mut app, "shape.rectangle", json!({"x": 120, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap();
|
||||
run(&mut app, "stroke.set", json!({"ids": [b], "weight": 3}));
|
||||
run(&mut app, "select.set", json!({ "ids": [a.0, b] }));
|
||||
texts(&ctx, &mut app, stroke::show);
|
||||
// The blank field, right of the spinner.
|
||||
let sr = weight_spinner_rect(&ctx, &mut app);
|
||||
click(&ctx, &mut app, egui::pos2(sr.right() + 30.0, sr.center().y), stroke::show);
|
||||
let press = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
|
||||
frame_events(&ctx, &mut app, vec![press], stroke::show);
|
||||
for id in [a.0, b] {
|
||||
run(&mut app, "select.set", json!({ "ids": [id] }));
|
||||
assert_eq!(app.session.shown_stroke().unwrap().width, after, "object {id}, {key:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,27 @@ fn unit_field(
|
||||
stops: &[f64],
|
||||
live: bool,
|
||||
empty: &str,
|
||||
) -> Option<f64> {
|
||||
unit_field_from(ui, id, label, name, value, unit, width, share_of_value, bounds, stops, live, None, empty)
|
||||
}
|
||||
|
||||
/// [`unit_field`] whose blank field (`value` is `None`) steps from `blank_from` (points) with
|
||||
/// ↑/↓ and the wheel instead of doing nothing.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn unit_field_from(
|
||||
ui: &mut Ui,
|
||||
id: impl std::hash::Hash,
|
||||
label: Option<PreviousLabel>,
|
||||
name: Option<&str>,
|
||||
value: Option<f64>,
|
||||
unit: Unit,
|
||||
width: f32,
|
||||
share_of_value: bool,
|
||||
bounds: RangeInclusive<f64>,
|
||||
stops: &[f64],
|
||||
live: bool,
|
||||
blank_from: Option<f64>,
|
||||
empty: &str,
|
||||
) -> Option<f64> {
|
||||
let id = id_with(ui.scope_id(), id);
|
||||
let shown = value.map(|v| unit.format(v)).unwrap_or_else(|| empty.to_string());
|
||||
@@ -132,8 +153,13 @@ fn unit_field(
|
||||
let read = |b: &str| typed.parse(&decimal_point(b));
|
||||
let bounds_unit = unit.from_pt(*bounds.start())..=unit.from_pt(*bounds.end());
|
||||
let stops: Vec<f64> = stops.iter().map(|&s| unit.from_pt(s)).collect();
|
||||
let stepped =
|
||||
step_value(ui, &resp, &mut buf, &bounds_unit, (|b: &str| Some(unit.from_pt(read(b)?)), |v| unit.format(unit.to_pt(v))), None, &stops);
|
||||
let blank_from = blank_from.filter(|_| value.is_none());
|
||||
let read_unit = |b: &str| match read(b) {
|
||||
Some(v) => Some(unit.from_pt(v)),
|
||||
None if b.trim().is_empty() => blank_from.map(|v| unit.from_pt(v)),
|
||||
None => None,
|
||||
};
|
||||
let stepped = step_value(ui, &resp, &mut buf, &bounds_unit, (read_unit, |v| unit.format(unit.to_pt(v))), None, &stops);
|
||||
let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS, &bounds_unit);
|
||||
let commit = commit && buf != shown;
|
||||
if commit {
|
||||
@@ -821,12 +847,24 @@ pub fn spin_field_stops_named(
|
||||
presets: &[f64],
|
||||
) -> Option<f64> {
|
||||
let label = take_previous_label(ui);
|
||||
spin_generic(ui, label, value, None, None, None, width, step, min, presets, presets, &|v| unit.format(v), &mut |ui, fw, label| {
|
||||
unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, presets, false, "")
|
||||
let blank_from = BLANK_WEIGHT_STEPS_FROM;
|
||||
// A blank field steps in points from `blank_from`, whatever the unit's preset ladder (#5349).
|
||||
let stops = if value.is_none() { &BLANK_WEIGHT_STOPS[..] } else { presets };
|
||||
let fmt = |v: f64| unit.format(v);
|
||||
spin_generic(ui, label, value, Some(blank_from), None, None, None, width, step, min, presets, stops, &fmt, &mut |ui, fw, label| {
|
||||
unit_field_from(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, stops, false, Some(blank_from), "")
|
||||
})
|
||||
.and_then(SpinPick::value)
|
||||
}
|
||||
|
||||
/// Where a blank Stroke Weight (the selected objects differ) steps from, in points: up goes to
|
||||
/// 0.5 pt and down to 0 pt. This is the behaviour #5349 asks for.
|
||||
const BLANK_WEIGHT_STEPS_FROM: f64 = 0.25;
|
||||
|
||||
/// The stops a blank Stroke Weight steps through, in points, so that up gives 0.5 pt in every
|
||||
/// Units > Stroke, not the first preset of the unit's own ladder above 0.25 pt.
|
||||
const BLANK_WEIGHT_STOPS: [f64; 2] = [BLANK_WEIGHT_STEPS_FROM, 0.5];
|
||||
|
||||
/// A distance field with spinner arrows kept within `bounds` (points), as the Drop Shadow and
|
||||
/// Outer Glow dialogs show their offsets and blur: the spinner steps one `unit` at a time
|
||||
/// (Shift ×10, Ctrl/Cmd ÷10), and it, ↑/↓, the wheel, a scrub and a typed value stop at both
|
||||
@@ -842,7 +880,7 @@ pub fn spin_length_within(
|
||||
let label = take_previous_label(ui);
|
||||
let step = Some(unit.to_pt(1.0)).filter(|s| s.is_finite() && *s > 0.0).unwrap_or(1.0);
|
||||
let field = bounds.clone();
|
||||
spin_generic(ui, label, value, None, None, None, width, step, *bounds.start(), &[], &[], &|v| unit.format(v), &mut |ui, fw, label| {
|
||||
spin_generic(ui, label, value, None, None, None, None, width, step, *bounds.start(), &[], &[], &|v| unit.format(v), &mut |ui, fw, label| {
|
||||
unit_field(ui, id, label, None, value, unit, fw, false, field.clone(), &[], false, "")
|
||||
})
|
||||
.and_then(SpinPick::value)
|
||||
@@ -867,7 +905,7 @@ pub fn spin_range(
|
||||
let places = decimals.min(6) as i32;
|
||||
let value = value.map(|v| within(v, &range));
|
||||
let fmt = |v: f64| format!("{v}{suffix}");
|
||||
spin_generic(ui, label, value, None, None, None, width, step, *range.start(), &[], &[], &fmt, &mut |ui, fw, label| {
|
||||
spin_generic(ui, label, value, None, None, None, None, width, step, *range.start(), &[], &[], &fmt, &mut |ui, fw, label| {
|
||||
bounded_field(ui, id, label, None, value, suffix, decimals, fw, &range, None, false, false)
|
||||
})
|
||||
.and_then(SpinPick::value)
|
||||
@@ -925,7 +963,7 @@ pub fn spin_field_auto_named(
|
||||
presets: &[f64],
|
||||
) -> Option<SpinPick> {
|
||||
let label = take_previous_label(ui);
|
||||
spin_generic(ui, label, value, auto, None, None, width, step, min, presets, &[], &|v| unit.format(v), &mut |ui, fw, label| {
|
||||
spin_generic(ui, label, value, None, auto, None, None, width, step, min, presets, &[], &|v| unit.format(v), &mut |ui, fw, label| {
|
||||
unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, &[], false, "")
|
||||
})
|
||||
}
|
||||
@@ -964,7 +1002,7 @@ pub fn spin_plain_named(
|
||||
let label = take_previous_label(ui);
|
||||
let value = value.into();
|
||||
let fmt = |v: f64| format!("{v}{suffix}");
|
||||
spin_generic(ui, label, value, None, None, None, width, step, min, presets, &[], &fmt, &mut |ui, fw, label| {
|
||||
spin_generic(ui, label, value, None, None, None, None, width, step, min, presets, &[], &fmt, &mut |ui, fw, label| {
|
||||
bounded_field(ui, id, label, name, value, suffix, decimals, fw, &(min..=f64::INFINITY), None, false, false)
|
||||
})
|
||||
.and_then(SpinPick::value)
|
||||
@@ -990,6 +1028,7 @@ pub fn spin_kerning(
|
||||
ui,
|
||||
label,
|
||||
value,
|
||||
None,
|
||||
automatic,
|
||||
optical,
|
||||
Some(optical_label),
|
||||
@@ -1010,6 +1049,7 @@ fn spin_generic(
|
||||
ui: &mut Ui,
|
||||
label: Option<PreviousLabel>,
|
||||
value: Option<f64>,
|
||||
blank_from: Option<f64>,
|
||||
auto: Option<bool>,
|
||||
optical: Option<bool>,
|
||||
optical_label: Option<&str>,
|
||||
@@ -1049,14 +1089,14 @@ fn spin_generic(
|
||||
ui.painter().line_segment([m + vec2(0.0, d), m + vec2(3.0, -d)], Stroke::new(1.2, c));
|
||||
}
|
||||
sresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tl!("Step up or down")));
|
||||
// A blank field (values that differ) has nothing to step from. A click steps once; an
|
||||
// A blank field (values that differ) steps from `blank_from`, if any. A click steps once; an
|
||||
// arrow held down steps again and again after a pause, as a spin button does (#4267).
|
||||
let is_up = match held_repeat(ui, &sresp) {
|
||||
Some(true) => Some(ui.rect_contains_pointer(up)),
|
||||
None if sresp.clicked() => sresp.interact_pointer_pos().map(|p| up.contains(p)),
|
||||
_ => None,
|
||||
};
|
||||
if let (Some(is_up), Some(v)) = (is_up, value) {
|
||||
if let (Some(is_up), Some(v)) = (is_up, value.or(blank_from)) {
|
||||
let modifiers = ui.input(|i| i.modifiers);
|
||||
out = Some(SpinPick::Value(spinner_step(v, step, min, is_up, modifiers, stops, fmt)));
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** 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 (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
|
||||
|
||||
@@ -441,7 +441,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.
|
||||
@@ -850,6 +850,7 @@ 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. |
|
||||
|
||||
+11
@@ -2578,6 +2578,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)
|
||||
|
||||
@@ -6134,6 +6140,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
@@ -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
@@ -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
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user