mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-19 05:34:23 -04:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b12fff215 | ||
|
|
e428f38767 | ||
|
|
da735914b6 | ||
|
|
2adf4465d6 | ||
|
|
d3c997329d | ||
|
|
1637165600 | ||
|
|
1f5073e4d1 | ||
|
|
288e93c11f | ||
|
|
a809f270eb | ||
|
|
22ff29167e | ||
|
|
171c382618 | ||
|
|
e3e6869043 |
No files matched your search
@@ -170,12 +170,13 @@ next one. To pick one when starting the app, set `WGPU_POWER_PREF=high` (or `low
|
||||
its name, such as `WGPU_ADAPTER_NAME=nvidia` (see [`docs/development.md`](docs/development.md#desktop-graphics-processor)).
|
||||
|
||||
On native Wayland, winit 0.30 does not deliver dropped files. To open an SVG, use File › Open;
|
||||
to place artwork in the current document, copy and paste the file in a file manager; or run
|
||||
under XWayland (`WAYLAND_DISPLAY= vectorcraft`) for file drag-and-drop
|
||||
to place artwork in the current document, copy and paste the file in a file manager. For file
|
||||
drag-and-drop, set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart, or start
|
||||
the app with `WAYLAND_DISPLAY= vectorcraft`: the window then opens through XWayland, where drops work
|
||||
(see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
|
||||
|
||||
On Linux under KDE Plasma 6.3 or later with Wayland, a drawing tablet's pen moves the cursor but VectorCraft doesn't
|
||||
respond to it yet (#491). Start the app under XWayland instead: `WAYLAND_DISPLAY= vectorcraft` (see
|
||||
respond to it yet (#491). Open the window through XWayland instead, with the same preference or `WAYLAND_DISPLAY= vectorcraft` (see
|
||||
[`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
|
||||
|
||||
### Use it from Claude Code and other agents
|
||||
|
||||
+1
-1
@@ -127,7 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
|
||||
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
|
||||
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
|
||||
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
|
||||
|
||||
@@ -39,6 +39,7 @@ mod printing;
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
mod system_fonts;
|
||||
mod window;
|
||||
mod xwayland;
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
@@ -674,6 +675,25 @@ fn main() -> std::process::ExitCode {
|
||||
let startup = std::sync::Arc::new(gpu::Startup::default());
|
||||
let custom_titlebar = custom_titlebar(saved.as_ref());
|
||||
let options = native_options(custom_titlebar);
|
||||
// winit picks Wayland whenever `WAYLAND_DISPLAY` is set; Preferences › Performance › Linux
|
||||
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151).
|
||||
#[cfg(target_os = "linux")]
|
||||
let options = {
|
||||
let mut options = options;
|
||||
let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
|
||||
if xwayland::wanted(pref, |name| std::env::var_os(name)) {
|
||||
if xwayland::x11_libraries_found() {
|
||||
log::info!("opening the window through XWayland (Preferences › Performance › Linux Display Server)");
|
||||
use winit::platform::x11::EventLoopBuilderExtX11 as _;
|
||||
options.event_loop_builder = Some(Box::new(|builder| {
|
||||
builder.with_x11();
|
||||
}));
|
||||
} else {
|
||||
log::warn!("Linux Display Server is X11, but the X11 libraries aren't all installed: opening the window on Wayland");
|
||||
}
|
||||
}
|
||||
options
|
||||
};
|
||||
#[cfg(feature = "wgpu")]
|
||||
let options = {
|
||||
let mut options = options;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Linux: Preferences › Performance › Linux Display Server › X11 opens the window through XWayland
|
||||
//! on a Wayland session, where dropped files arrive (winit 0.30 has no Wayland drag and drop,
|
||||
//! #1151). Only when XWayland can run the window: an X display is set and the X11 libraries winit
|
||||
//! loads at runtime are installed. Otherwise the window opens on Wayland, so a missing library
|
||||
//! never keeps the app from starting.
|
||||
//!
|
||||
//! The library check comes from PhotoCraft's `linux_libs.rs`: no `dlopen` (that needs `unsafe`),
|
||||
//! just the dynamic linker's cache (`ldconfig -p`) and the usual library directories.
|
||||
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// What winit 0.30 `dlopen`s for an X11 window: x11-dl opens Xlib, Xlib-xcb, Xcursor and XInput2,
|
||||
/// x11rb opens libxcb, and xkbcommon-dl opens xkbcommon and xkbcommon-x11.
|
||||
const X11_LIBS: &[&str] =
|
||||
&["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0"];
|
||||
|
||||
/// Whether the saved `linuxDisplayServer` and the environment ask for XWayland: `x11` chosen, a
|
||||
/// Wayland session (winit picks Wayland whenever `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set)
|
||||
/// and an X display to open.
|
||||
pub fn wanted(pref: Option<&str>, var: impl Fn(&str) -> Option<OsString>) -> bool {
|
||||
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
|
||||
pref == Some("x11") && (set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) && set("DISPLAY")
|
||||
}
|
||||
|
||||
/// Are all the libraries an X11 window needs found on this system? Libraries the check can't find
|
||||
/// count as missing.
|
||||
pub fn x11_libraries_found() -> bool {
|
||||
let cache = read_ldconfig();
|
||||
let dirs = search_dirs(std::env::var("LD_LIBRARY_PATH").ok().as_deref());
|
||||
X11_LIBS
|
||||
.iter()
|
||||
.all(|lib| cache.as_deref().is_some_and(|out| ldconfig_has(out, lib, ldconfig_arch_tag())) || dirs.iter().any(|d| d.join(lib).exists()))
|
||||
}
|
||||
|
||||
/// The `ldconfig -p` architecture tag for this build, when it's one we can tell apart from a
|
||||
/// 32-bit library of the same name (`libc6,x86-64` vs plain `libc6` for i386).
|
||||
fn ldconfig_arch_tag() -> Option<&'static str> {
|
||||
if cfg!(target_arch = "x86_64") {
|
||||
Some("x86-64")
|
||||
} else if cfg!(target_arch = "aarch64") {
|
||||
Some("AArch64")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `ldconfig -p` output list `soname` for our architecture? Lines look like
|
||||
/// `\tlibxkbcommon-x11.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxkbcommon-x11.so.0`.
|
||||
fn ldconfig_has(output: &str, soname: &str, arch_tag: Option<&str>) -> bool {
|
||||
output.lines().any(|line| {
|
||||
let Some((name, rest)) = line.trim_start().split_once(' ') else { return false };
|
||||
if name != soname {
|
||||
return false;
|
||||
}
|
||||
match arch_tag {
|
||||
None => true,
|
||||
Some(tag) => {
|
||||
let flags = rest.split_once('(').and_then(|(_, r)| r.split_once(')')).map_or("", |(f, _)| f);
|
||||
flags.split(',').any(|f| f.trim() == tag)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `ldconfig -p`, from the usual locations (`/sbin` isn't on a normal user's PATH on Debian). A
|
||||
/// real listing starts with "N libs found in cache".
|
||||
fn read_ldconfig() -> Option<String> {
|
||||
["/sbin/ldconfig", "/usr/sbin/ldconfig", "ldconfig"].into_iter().find_map(|cmd| {
|
||||
let out = std::process::Command::new(cmd).arg("-p").output().ok().filter(|out| out.status.success())?;
|
||||
let text = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
text.lines().next().is_some_and(|l| l.contains("libs found in cache")).then_some(text)
|
||||
})
|
||||
}
|
||||
|
||||
/// Library directories to search: `LD_LIBRARY_PATH`, then the usual system and Flatpak paths.
|
||||
fn search_dirs(ld_library_path: Option<&str>) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = ld_library_path.unwrap_or("").split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect();
|
||||
let triplet = if cfg!(target_arch = "aarch64") {
|
||||
"aarch64-linux-gnu"
|
||||
} else if cfg!(target_arch = "riscv64") {
|
||||
"riscv64-linux-gnu"
|
||||
} else {
|
||||
"x86_64-linux-gnu"
|
||||
};
|
||||
dirs.extend(["/app/lib", "/usr/local/lib", "/usr/lib64", "/lib64", "/usr/lib", "/lib"].map(PathBuf::from));
|
||||
dirs.push(Path::new("/usr/lib").join(triplet));
|
||||
dirs.push(Path::new("/lib").join(triplet));
|
||||
dirs
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn env(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<OsString> {
|
||||
move |k| pairs.iter().find(|(name, _)| *name == k).map(|(_, v)| OsString::from(v))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xwayland_only_when_chosen_on_wayland_with_an_x_display() {
|
||||
let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]);
|
||||
assert!(wanted(Some("x11"), &wayland));
|
||||
assert!(wanted(Some("x11"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")])));
|
||||
assert!(!wanted(Some("automatic"), &wayland));
|
||||
assert!(!wanted(None, &wayland));
|
||||
assert!(!wanted(Some("wayland"), &wayland));
|
||||
// An X11 session already opens on X11; without an X display there is no XWayland to use.
|
||||
assert!(!wanted(Some("x11"), env(&[("DISPLAY", ":0")])));
|
||||
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")])));
|
||||
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0")])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ldconfig_parsing_matches_name_and_architecture() {
|
||||
let cache = "12 libs found in cache `/etc/ld.so.cache'\n\
|
||||
\tlibX11.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libX11.so.6\n\
|
||||
\tlibxkbcommon-x11.so.0 (libc6) => /lib/i386-linux-gnu/libxkbcommon-x11.so.0\n";
|
||||
assert!(ldconfig_has(cache, "libX11.so.6", Some("x86-64")));
|
||||
assert!(!ldconfig_has(cache, "libX11.so.6", Some("AArch64")));
|
||||
// Only the 32-bit build is installed: doesn't count for a 64-bit app.
|
||||
assert!(!ldconfig_has(cache, "libxkbcommon-x11.so.0", Some("x86-64")));
|
||||
assert!(ldconfig_has(cache, "libxkbcommon-x11.so.0", None));
|
||||
assert!(!ldconfig_has(cache, "libX11.so", None));
|
||||
}
|
||||
}
|
||||
@@ -375,6 +375,20 @@ mod tests {
|
||||
assert!(hit_test(&d, Point::new(50.5, 25.0), HitOptions::default()).is_some());
|
||||
}
|
||||
|
||||
/// #1103: a marquee drawn over a background picks what is on it, not the background, whose
|
||||
/// outline it doesn't touch.
|
||||
#[test]
|
||||
fn a_marquee_inside_a_background_leaves_the_background() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let (back, small) = (d.alloc_id(), d.alloc_id());
|
||||
d.insert(Some(l), 0, Node::path(back, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art())).unwrap();
|
||||
d.insert(Some(l), 1, Node::path(small, shapes::rectangle(Rect::new(50.0, 50.0, 60.0, 60.0)), Appearance::default_art())).unwrap();
|
||||
assert_eq!(marquee(&d, Rect::new(40.0, 40.0, 70.0, 70.0), None, false), vec![small]);
|
||||
// Reaching the background's edge takes it too.
|
||||
assert_eq!(marquee(&d, Rect::new(-5.0, 40.0, 70.0, 70.0), None, false), vec![back, small]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_top_object() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
|
||||
@@ -825,20 +825,9 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Reshape", |d, _| {
|
||||
let path = path_mut(d, id)?;
|
||||
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
if seg >= sp.segment_count() {
|
||||
if !sp.bend_segment(seg, t, dv) {
|
||||
return Err(EngineError::Other("no such segment".into()));
|
||||
}
|
||||
let n = sp.anchors.len();
|
||||
// Moving both inner control points by v moves B(t) by 3t(1-t)·v. A smooth anchor stays
|
||||
// smooth: its other handle turns with the moved one.
|
||||
let v = dv / (3.0 * t * (1.0 - t));
|
||||
let (i0, i1) = (seg % n, (seg + 1) % n);
|
||||
if let Some(a) = sp.anchors.get_mut(i0) {
|
||||
a.set_handle(true, a.h_out + v, false);
|
||||
}
|
||||
if let Some(b) = sp.anchors.get_mut(i1) {
|
||||
b.set_handle(false, b.h_in + v, false);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
|
||||
@@ -82,7 +82,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Hide Others",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} hide every top-level layer but those holding the highlighted rows (else the current layer). One undo step → {count}",
|
||||
"{ids?} hide every top-level layer but those holding the highlighted rows (else the current layer), which show. One undo step → {count}",
|
||||
has_doc,
|
||||
|s, p| others(s, p, Flag::Visible, false)
|
||||
),
|
||||
@@ -96,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Outline Others",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} turn Preview off (outline view) for every top-level layer but those holding the highlighted rows (else the current layer) → {count}",
|
||||
"{ids?} turn Preview off (outline view) for every top-level layer but those holding the highlighted rows (else the current layer), which preview → {count}",
|
||||
has_doc,
|
||||
|s, p| others(s, p, Flag::Preview, false)
|
||||
),
|
||||
@@ -114,7 +114,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Lock Others",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} lock every top-level layer but those holding the highlighted rows (else the current layer) → {count}",
|
||||
"{ids?} lock every top-level layer but those holding the highlighted rows (else the current layer), which unlock → {count}",
|
||||
has_doc,
|
||||
|s, p| others(s, p, Flag::Locked, true)
|
||||
),
|
||||
@@ -176,23 +176,23 @@ impl Flag {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set `flag` to `v` on `ids` (layers that differ) in one undo step labelled `label` → how many
|
||||
/// Set `flag` on each of `changes` (id, value; layers that differ) in one undo step labelled `label` → how many
|
||||
/// changed.
|
||||
fn set_flag(s: &mut Session, ids: Vec<NodeId>, flag: Flag, v: bool, label: &str) -> Result<Value> {
|
||||
fn set_flag(s: &mut Session, changes: Vec<(NodeId, bool)>, flag: Flag, label: &str) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let ids: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| flag.get(n) != v)).collect();
|
||||
if !ids.is_empty() {
|
||||
let changes: Vec<(NodeId, bool)> = changes.into_iter().filter(|(id, v)| d.node(*id).is_some_and(|n| flag.get(n) != *v)).collect();
|
||||
if !changes.is_empty() {
|
||||
s.edit(label, |d, sel| {
|
||||
for id in &ids {
|
||||
for (id, v) in &changes {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
flag.set(n, v);
|
||||
flag.set(n, *v);
|
||||
}
|
||||
}
|
||||
sel.deselect_uneditable(d);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "count": ids.len() }))
|
||||
Ok(json!({ "count": changes.len() }))
|
||||
}
|
||||
|
||||
/// The top-level layers holding the rows a command acts on.
|
||||
@@ -202,20 +202,22 @@ fn kept_layers(s: &Session, p: &Value) -> Result<HashSet<NodeId>> {
|
||||
Ok(ids.iter().filter_map(|id| d.layer_of(*id)).collect())
|
||||
}
|
||||
|
||||
/// Every top-level layer but the kept ones set to `v`, and the kept ones to the opposite: Alt-click
|
||||
/// on a hidden layer's eye shows just that layer (#1158).
|
||||
fn others(s: &mut Session, p: &Value, flag: Flag, v: bool) -> Result<Value> {
|
||||
let keep = kept_layers(s, p)?;
|
||||
let ids: Vec<NodeId> = s.doc()?.doc.layers.iter().map(|l| l.id).filter(|l| !keep.contains(l)).collect();
|
||||
set_flag(s, ids, flag, v, flag.label(v))
|
||||
let changes = s.doc()?.doc.layers.iter().map(|l| (l.id, !keep.contains(&l.id) == v)).collect();
|
||||
set_flag(s, changes, flag, flag.label(v))
|
||||
}
|
||||
|
||||
fn all(s: &mut Session, flag: Flag, v: bool) -> Result<Value> {
|
||||
let mut ids = vec![];
|
||||
s.doc()?.doc.walk(|n| {
|
||||
if n.is_layer() {
|
||||
ids.push(n.id);
|
||||
ids.push((n.id, v));
|
||||
}
|
||||
});
|
||||
set_flag(s, ids, flag, v, flag.label(v))
|
||||
set_flag(s, ids, flag, flag.label(v))
|
||||
}
|
||||
|
||||
fn template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -14,7 +14,7 @@ use vectorcraft_doc::live::{
|
||||
PreserveShape, Spine,
|
||||
};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{Affine, PathData, Point};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::expand::gradient_sampler;
|
||||
@@ -95,6 +95,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
spine_move_anchor
|
||||
),
|
||||
cmd!(
|
||||
"object.blend.spine.reshapeSegment",
|
||||
"Reshape Spine Segment",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the selected blend), segment: index, t: 0..1, dx, dy} bend a segment of the blend's spine so its point at t moves by (dx, dy), as path.reshapeSegment bends a path's: a straight spine turns into a curve (#1111)",
|
||||
has_doc,
|
||||
spine_reshape_segment
|
||||
),
|
||||
cmd!(
|
||||
"object.blend.spine.addAnchor",
|
||||
"Add Spine Point",
|
||||
@@ -640,6 +649,18 @@ fn spine_move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.blend.spine.reshapeSegment";
|
||||
let seg = index_param(p, "segment", C)?;
|
||||
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
|
||||
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
if !(t.is_finite() && dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < 1e9) {
|
||||
return Err(bad(C, "t, dx and dy must be finite"));
|
||||
}
|
||||
edit_spine(s, p, C, "Reshape Spine", |sp, _, _| if sp.bend_segment(seg, t, dv) { Ok(()) } else { Err(bad(C, "no such spine segment")) })?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn spine_add_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.blend.spine.addAnchor";
|
||||
let q = finite_point(p, C)?;
|
||||
|
||||
@@ -9,7 +9,7 @@ use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::corners::set_corners;
|
||||
use vectorcraft_doc::{Appearance, Document, Knockout, LiveCorners, LiveShape, Node, NodeId, NodeKind, OrientedBox, Scaling};
|
||||
use vectorcraft_geom::shapes::CornerKind;
|
||||
use vectorcraft_geom::{Affine, FillRule, Point, Rect, Vec2};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, Vec2};
|
||||
|
||||
use super::edit::{duplicate_in, selected_roots};
|
||||
use super::opacitymask::percent;
|
||||
@@ -106,7 +106,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("object.hide", "Selection", ["Object", "Hide"], Some("Cmd+3"), "{}", has_selection, hide),
|
||||
cmd!("object.showAll", "Show All", ["Object"], Some("Cmd+Alt+3"), "{}", has_doc, show_all),
|
||||
cmd!("object.compoundPath.make", "Make", ["Object", "Compound Path"], Some("Cmd+8"), "{}", has_selection, compound_make),
|
||||
cmd!("object.compoundPath.release", "Release", ["Object", "Compound Path"], Some("Cmd+Alt+Shift+8"), "{}", has_selection, compound_release),
|
||||
cmd!(
|
||||
"object.compoundPath.release",
|
||||
"Release",
|
||||
["Object", "Compound Path"],
|
||||
Some("Cmd+Alt+Shift+8"),
|
||||
"{} release the selected compound paths into their paths, each with the compound path's paint; a path of several subpaths (an SVG `d` with several `M`s) splits into one path per subpath",
|
||||
has_selection,
|
||||
compound_release
|
||||
),
|
||||
cmd!(
|
||||
"object.clippingMask.make",
|
||||
"Make",
|
||||
@@ -658,26 +666,52 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
/// Is `n` a compound path to release: a compound path, or a path of several subpaths (an SVG `d`
|
||||
/// with several `M`s, a PDF path), which is one in Illustrator too (#1163)?
|
||||
fn releases(n: &Node) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Compound { .. } => true,
|
||||
NodeKind::Path { path, .. } => path.subpaths.len() > 1,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn compound_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
// With no compound path selected, the document, the selection and the undo history stay as
|
||||
// they are.
|
||||
let d = &s.doc()?.doc;
|
||||
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))) {
|
||||
if !ids.iter().any(|id| d.node(*id).is_some_and(releases)) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Release Compound Path", |d, sel| {
|
||||
let mut out = vec![];
|
||||
for id in &ids {
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let NodeKind::Compound { children, .. } = &n.kind else { continue };
|
||||
let Some(n) = d.node(*id).cloned().filter(releases) else { continue };
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
d.remove(*id)?;
|
||||
for (k, c) in children.iter().enumerate() {
|
||||
let mut c = (**c).clone();
|
||||
c.appearance = n.appearance.clone();
|
||||
out.push(c.id);
|
||||
d.insert(par, idx + k, c)?;
|
||||
match &n.kind {
|
||||
NodeKind::Compound { children, .. } => {
|
||||
for (k, c) in children.iter().enumerate() {
|
||||
let mut c = (**c).clone();
|
||||
c.appearance = n.appearance.clone();
|
||||
out.push(c.id);
|
||||
d.insert(par, idx + k, c)?;
|
||||
}
|
||||
}
|
||||
// Each subpath becomes a path of its own with the path's paint, in the same
|
||||
// stacking order; the first keeps the path's id.
|
||||
NodeKind::Path { path, .. } => {
|
||||
for (k, sp) in path.subpaths.iter().enumerate() {
|
||||
let mut c = if k == 0 { n.clone() } else { d.reid(&n) };
|
||||
if let NodeKind::Path { path, live, .. } = &mut c.kind {
|
||||
(*path, *live) = (PathData::single(sp.clone()), None);
|
||||
}
|
||||
out.push(c.id);
|
||||
d.insert(par, idx + k, c)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
sel.set(out);
|
||||
|
||||
@@ -87,6 +87,10 @@ const LINE_STYLE: &[(&str, &str)] = &[("lines", "Lines"), ("dots", "Dots")];
|
||||
/// `automatic` (#502).
|
||||
pub const GPU_PREFERENCES: &[(&str, &str)] =
|
||||
&[("automatic", "Automatic"), ("lowPower", "Power Saving (integrated)"), ("highPerformance", "High Performance (discrete)")];
|
||||
/// Performance › Linux Display Server (`linuxDisplayServer`), read by the desktop app at startup:
|
||||
/// `x11` opens the window through XWayland on a Wayland session, where dropped files arrive
|
||||
/// (winit 0.30 has no Wayland drag and drop, #1151).
|
||||
const LINUX_DISPLAY_SERVERS: &[(&str, &str)] = &[("automatic", "Automatic"), ("x11", "X11 (XWayland)")];
|
||||
const BLACK: &[(&str, &str)] = &[("accurate", "Display All Blacks Accurately"), ("rich", "Display All Blacks as Rich Black")];
|
||||
const BLACK_OUT: &[(&str, &str)] = &[("accurate", "Output All Blacks Accurately"), ("rich", "Output All Blacks as Rich Black")];
|
||||
|
||||
@@ -290,6 +294,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("historyStates", "Performance", "", "History States", int(5, 1000)),
|
||||
p!("realTimeDrawing", "Performance", "", "Real-time Drawing and Editing", bool),
|
||||
p!("renderThreads", "Performance", "", "Render Threads (-1 = Automatic)", int(-1, 64)),
|
||||
p!("linuxDisplayServer", "Performance", "", "Linux Display Server", choice(LINUX_DISPLAY_SERVERS)),
|
||||
// File Handling
|
||||
p!("backgroundSave", "File Handling", "", "Enable Background Save", bool),
|
||||
p!("backgroundExport", "File Handling", "", "Enable Background Export", bool),
|
||||
|
||||
@@ -564,6 +564,9 @@ pub struct Prefs {
|
||||
pub real_time_drawing: bool,
|
||||
/// Rasterizer worker threads; -1 = automatic.
|
||||
pub render_threads: i32,
|
||||
/// Linux: `automatic` opens the window on the session's display server; `x11` opens it through
|
||||
/// XWayland on a Wayland session, where dropped files arrive (#1151). Read when the app starts.
|
||||
pub linux_display_server: String,
|
||||
// File Handling
|
||||
pub background_save: bool,
|
||||
pub background_export: bool,
|
||||
@@ -769,6 +772,7 @@ impl Default for Prefs {
|
||||
history_states: 500,
|
||||
real_time_drawing: true,
|
||||
render_threads: -1,
|
||||
linux_display_server: s("automatic"),
|
||||
background_save: true,
|
||||
background_export: true,
|
||||
autosave_recovery: true,
|
||||
|
||||
@@ -381,6 +381,31 @@ fn clipping_and_compound() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![b]);
|
||||
}
|
||||
|
||||
/// #1163: a path of several subpaths (an SVG `d` with several `M`s) is a compound path to Release:
|
||||
/// one path per subpath, each with the path's paint, in its stacking order, in one undo step.
|
||||
#[test]
|
||||
fn release_splits_a_path_of_several_subpaths() {
|
||||
let mut s = session();
|
||||
let id = NodeId(
|
||||
s.execute("path.create", &json!({"d": "M0 0 L10 0 L10 10 Z M20 0 L30 0 L30 10 Z M40 0 L50 0 L50 10 Z"})).unwrap()["id"].as_u64().unwrap(),
|
||||
);
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("object.compoundPath.release", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.history.undo.len(), undo + 1);
|
||||
let sel = st.selection.objects.clone();
|
||||
assert_eq!((sel.len(), sel[0]), (3, id), "the first keeps its id");
|
||||
let layer = st.doc.layers[0].children().unwrap();
|
||||
for (k, n) in sel.iter().map(|i| st.doc.node(*i).unwrap()).enumerate() {
|
||||
let NodeKind::Path { path, .. } = &n.kind else { panic!("a path") };
|
||||
assert_eq!(path.subpaths.len(), 1);
|
||||
assert_eq!(path.bounds().unwrap().x0, 20.0 * k as f64, "in order");
|
||||
assert_eq!(n.appearance.fill_paint().color().unwrap().to_hex(), "#ff0000", "the path's paint");
|
||||
assert_eq!(layer[k].id, n.id, "stacked bottom to top");
|
||||
}
|
||||
}
|
||||
|
||||
/// `object.align` without `horizontal` or `vertical` (an unknown key such as `align` counts as
|
||||
/// neither) returns an error: nothing moves, and the undo and redo stacks stay as they were.
|
||||
#[test]
|
||||
|
||||
@@ -187,3 +187,20 @@ fn key_anchors_round_trip() {
|
||||
let old: BlendSpec = serde_json::from_value(json!({"spine": {"subpaths": []}})).unwrap();
|
||||
assert!(old.key_anchors.is_empty());
|
||||
}
|
||||
|
||||
/// #1111: a straight spine's segment bends into a curve, its middle moving where it's dragged, and
|
||||
/// the steps follow it; junk segments and deltas are refused.
|
||||
#[test]
|
||||
fn a_straight_spine_segment_bends_into_a_curve() {
|
||||
let mut s = session();
|
||||
let (_, _, g) = blend(&mut s);
|
||||
s.execute("object.blend.spine.reshapeSegment", &json!({"segment": 0, "t": 0.5, "dx": 0, "dy": 40})).unwrap();
|
||||
let spine = spec(&s, g).spine.unwrap();
|
||||
let seg = spine.subpaths[0].segment(0);
|
||||
use vectorcraft_geom::kurbo::ParamCurve;
|
||||
assert!(seg.eval(0.5).distance(Point::new(55.0, 45.0)) < 1e-9, "{seg:?}");
|
||||
assert!(center(&steps(&s, g)[1]).y > 20.0, "the step follows the curve");
|
||||
for bad in [json!({"segment": 1, "dy": 10}), json!({"segment": 0, "dx": f64::MAX, "dy": f64::MAX}), json!({"segment": -1})] {
|
||||
assert!(s.execute("object.blend.spine.reshapeSegment", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
@@ -543,6 +543,23 @@ fn hide_lock_and_outline_others_and_their_all_counterparts() {
|
||||
assert_eq!(run(&mut s, "layer.previewAll", json!({}))["count"], 0, "nothing to do: no undo step");
|
||||
}
|
||||
|
||||
/// #1158: Alt-click on a hidden (locked) layer's eye (lock) isolates it: it shows (unlocks) as the
|
||||
/// others hide (lock), in one undo step.
|
||||
#[test]
|
||||
fn hide_and_lock_others_show_and_unlock_the_layer_kept() {
|
||||
let mut s = session();
|
||||
let l1 = s.doc().unwrap().doc.layers[0].id;
|
||||
let l2 = id(&run(&mut s, "layer.new", json!({})));
|
||||
let flags = |s: &Session, f: fn(&Node) -> bool| [l1, l2].map(|l| f(&node(s, l)));
|
||||
run(&mut s, "layer.setProps", json!({"ids": [l2.0], "visible": false, "locked": true}));
|
||||
assert_eq!(run(&mut s, "layer.hideOthers", json!({"ids": [l2.0]}))["count"], 2);
|
||||
assert_eq!(flags(&s, |n| n.visible), [false, true]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(flags(&s, |n| n.visible), [true, false], "one undo step");
|
||||
run(&mut s, "layer.lockOthers", json!({"ids": [l2.0]}));
|
||||
assert_eq!(flags(&s, |n| n.locked), [true, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locate_object_highlights_its_row() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -156,6 +156,23 @@ fn gpu_preference_defaults_to_automatic_and_validates() {
|
||||
assert_eq!(s.prefs.gpu_preference, "automatic");
|
||||
}
|
||||
|
||||
/// Performance › Linux Display Server (#1151): automatic by default, X11 by value or label, and
|
||||
/// anything else is an error that changes nothing.
|
||||
#[test]
|
||||
fn linux_display_server_defaults_to_automatic_and_validates() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.prefs.linux_display_server, "automatic");
|
||||
s.execute("prefs.set", &json!({"key": "linuxDisplayServer", "value": "X11 (XWayland)"})).unwrap();
|
||||
assert_eq!(s.prefs.linux_display_server, "x11");
|
||||
for bad in [json!("wayland"), json!(""), json!(11), json!(null)] {
|
||||
assert!(s.execute("prefs.set", &json!({"key": "linuxDisplayServer", "value": bad})).is_err(), "{bad}");
|
||||
assert_eq!(s.prefs.linux_display_server, "x11");
|
||||
}
|
||||
assert_eq!(s.prefs.to_json()["linuxDisplayServer"], "x11");
|
||||
s.execute("prefs.reset", &json!({"category": "Performance"})).unwrap();
|
||||
assert_eq!(s.prefs.linux_display_server, "automatic");
|
||||
}
|
||||
|
||||
/// Selection & Anchor Display › Tolerance, Object Selection by Path Only and Command Click to
|
||||
/// Select Objects Behind, and General › Double Click To Isolate, as the Selection tool sees them
|
||||
/// (#394).
|
||||
|
||||
@@ -367,6 +367,7 @@ fn structured_junk() {
|
||||
("object.blend.options", json!({"spacing": "distance", "value": -1e308, "orientation": 5})),
|
||||
("object.blend.spine.moveAnchor", json!({"id": 2, "anchor": u64::MAX, "x": 1e308, "y": -1e308, "handle": "sideways"})),
|
||||
("object.blend.spine.addAnchor", json!({"id": u64::MAX, "x": 0, "y": 0})),
|
||||
("object.blend.spine.reshapeSegment", json!({"id": 2, "segment": u64::MAX, "t": f64::NAN, "dx": 1e308, "dy": -1e308})),
|
||||
("object.blend.spine.removeAnchor", json!({"anchor": -1})),
|
||||
("object.puppetWarp", json!({"rest": true, "pins": [], "moved": []})),
|
||||
("object.puppetWarp", json!({"rest": true, "pins": [[1e308, 0]], "moved": [[0, 0]], "expand": -5})),
|
||||
|
||||
+9
-13
@@ -66,7 +66,10 @@ pub fn stroke_contains(path: &BezPath, width: f64, tol: f64, p: Point) -> bool {
|
||||
quick.contains(p) && distance_to_outline(path, p) <= width / 2.0 + tol
|
||||
}
|
||||
|
||||
/// Does the path intersect (or lie inside) the rect? Used for marquee selection.
|
||||
/// Does the path touch the rect: an anchor inside it, or a segment crossing its edges? Used for
|
||||
/// marquee selection, which (as in Illustrator) needs the path itself: a rect wholly inside a
|
||||
/// filled shape doesn't touch it, so a marquee drawn over a background picks only what is on it
|
||||
/// (#1103).
|
||||
pub fn intersects_rect(path: &PathData, r: Rect) -> bool {
|
||||
let Some(b) = path.bounds() else { return false };
|
||||
if b.intersect(r).area() <= 0.0 && !(b.width() == 0.0 || b.height() == 0.0) {
|
||||
@@ -83,16 +86,7 @@ pub fn intersects_rect(path: &PathData, r: Rect) -> bool {
|
||||
kurbo::Line::new((r.x1, r.y1), (r.x0, r.y1)),
|
||||
kurbo::Line::new((r.x0, r.y1), (r.x0, r.y0)),
|
||||
];
|
||||
let bp = path.to_bezpath();
|
||||
for seg in bp.segments() {
|
||||
for e in &edges {
|
||||
if !seg.intersect_line(*e).is_empty() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rect entirely inside a closed shape counts as touching it.
|
||||
path.is_closed() && fill_contains(&bp, FillRule::NonZero, r.center())
|
||||
path.to_bezpath().segments().any(|seg| edges.iter().any(|e| !seg.intersect_line(*e).is_empty()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -152,8 +146,10 @@ mod tests {
|
||||
assert!(!intersects_rect(&p, Rect::new(20.0, 20.0, 30.0, 30.0)));
|
||||
// corner region of the bbox that misses the ellipse outline
|
||||
assert!(!intersects_rect(&p, Rect::new(0.0, 0.0, 0.5, 0.5)));
|
||||
// fully inside
|
||||
assert!(intersects_rect(&p, Rect::new(4.0, 4.0, 6.0, 6.0)));
|
||||
// Wholly inside the shape, touching neither its anchors nor its outline (#1103).
|
||||
assert!(!intersects_rect(&p, Rect::new(4.0, 4.0, 6.0, 6.0)));
|
||||
// Around the whole shape.
|
||||
assert!(intersects_rect(&p, Rect::new(-1.0, -1.0, 11.0, 11.0)));
|
||||
// crossing a line
|
||||
let l = shapes::line(Point::new(0.0, 0.0), Point::new(10.0, 10.0));
|
||||
assert!(intersects_rect(&l, Rect::new(4.0, 0.0, 6.0, 10.0)));
|
||||
|
||||
@@ -252,6 +252,24 @@ impl SubPath {
|
||||
_ => a,
|
||||
}
|
||||
}
|
||||
/// Bend segment `seg` so its point at `t` moves by `dv`: both inner control points move by
|
||||
/// dv / 3t(1-t), since B(t) moves by 3t(1-t) times that. A smooth anchor stays smooth: its other
|
||||
/// handle turns with the moved one. False when there is no such segment.
|
||||
pub fn bend_segment(&mut self, seg: usize, t: f64, dv: kurbo::Vec2) -> bool {
|
||||
let n = self.anchors.len();
|
||||
if seg >= self.segment_count() || n == 0 {
|
||||
return false;
|
||||
}
|
||||
let v = dv / (3.0 * t * (1.0 - t));
|
||||
if let Some(a) = self.anchors.get_mut(seg % n) {
|
||||
a.set_handle(true, a.h_out + v, false);
|
||||
}
|
||||
if let Some(b) = self.anchors.get_mut((seg + 1) % n) {
|
||||
b.set_handle(false, b.h_in + v, false);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Split segment `seg` at parameter `t`, inserting a new anchor. Returns the new anchor index.
|
||||
pub fn insert_anchor(&mut self, seg: usize, t: f64) -> usize {
|
||||
let n = self.anchors.len();
|
||||
|
||||
@@ -90,6 +90,15 @@ enum State {
|
||||
start: Point,
|
||||
began: bool,
|
||||
},
|
||||
/// Dragging a curved segment of a selected blend's spine: it follows the pointer
|
||||
/// (`object.blend.spine.reshapeSegment`, #1111).
|
||||
SpineSegment {
|
||||
id: NodeId,
|
||||
seg: usize,
|
||||
t: f64,
|
||||
start: Point,
|
||||
began: bool,
|
||||
},
|
||||
/// Dragging a handle of the clicked spine point.
|
||||
SpineHandle {
|
||||
id: NodeId,
|
||||
@@ -170,6 +179,15 @@ fn hit_spine_point(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usiz
|
||||
})
|
||||
}
|
||||
|
||||
/// The segment of a selected blend's spine passing within `tol` of `p`: (blend, segment, t, whether
|
||||
/// it is straight).
|
||||
pub(crate) fn hit_spine_segment(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, f64, bool)> {
|
||||
spines(cx).into_iter().filter(|(id, _)| cx.selection.contains(*id)).find_map(|(id, path)| {
|
||||
let (si, seg, t, _, dist) = path.nearest(p)?;
|
||||
(dist <= tol).then(|| (id, seg, t, path.subpaths.get(si).is_some_and(|sp| sp.segment_is_line(seg))))
|
||||
})
|
||||
}
|
||||
|
||||
/// The spine point `sel` and whether `p` is on its outgoing handle's end (`Some(true)`) or its
|
||||
/// incoming one's.
|
||||
fn hit_spine_handle(cx: &ToolContext, sel: (NodeId, usize), p: Point, tol: f64) -> Option<bool> {
|
||||
@@ -402,6 +420,11 @@ impl Tool for DirectSelectionTool {
|
||||
if let Some((id, anchors)) = hit_frame_edge(cx, p, tol) {
|
||||
return self.press_type_area(cx, id, anchors, p, ev.mods.shift);
|
||||
}
|
||||
// A curved segment of a selected blend's spine reshapes, as a path's does.
|
||||
if let Some((id, seg, t, false)) = hit_spine_segment(cx, p, tol) {
|
||||
self.state = State::SpineSegment { id, seg, t, start: p, began: false };
|
||||
return vec![];
|
||||
}
|
||||
if let Some(out) = self.guide.press(cx, ev) {
|
||||
self.state = State::Idle;
|
||||
return out;
|
||||
@@ -546,6 +569,22 @@ impl Tool for DirectSelectionTool {
|
||||
out.push(Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": id.0, "anchor": anchor, "x": q.x, "y": q.y})));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::SpineSegment { id, seg, t, start, began }) => {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
if p.distance(start) < cx.tol(3.0) {
|
||||
return out;
|
||||
}
|
||||
out.push(Action::Begin("Reshape Spine".into()));
|
||||
self.state = State::SpineSegment { id, seg, t, start, began: true };
|
||||
}
|
||||
let d = p - start;
|
||||
out.push(Action::Preview(
|
||||
"object.blend.spine.reshapeSegment".into(),
|
||||
json!({"id": id.0, "segment": seg, "t": t, "dx": d.x, "dy": d.y}),
|
||||
));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::SpineHandle { id, anchor, out }) => vec![Action::Preview(
|
||||
"object.blend.spine.moveAnchor".into(),
|
||||
json!({"id": id.0, "anchor": anchor, "handle": if out {"out"} else {"in"}, "x": p.x, "y": p.y, "independent": ev.mods.alt}),
|
||||
@@ -583,6 +622,7 @@ impl Tool for DirectSelectionTool {
|
||||
| State::MoveObject { began, .. }
|
||||
| State::Segment { began, .. }
|
||||
| State::SpinePoint { began, .. }
|
||||
| State::SpineSegment { began, .. }
|
||||
| State::TypeArea { began, .. },
|
||||
) => {
|
||||
self.state = State::Idle;
|
||||
@@ -814,6 +854,45 @@ mod tests {
|
||||
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Anchor { filled: true, .. })), "the clicked point shows");
|
||||
}
|
||||
|
||||
/// #1111: the Anchor Point tool bends a selected blend's straight spine; Direct Selection
|
||||
/// reshapes a curved spine segment and leaves a straight one alone, as it does a path's.
|
||||
#[test]
|
||||
fn spine_segments_bend_with_the_anchor_point_tool_and_direct_selection() {
|
||||
let (mut d, g, _) = blend_doc();
|
||||
let mut s = Selection::default();
|
||||
s.set([g]);
|
||||
let p = paint();
|
||||
let drag = |t: &mut dyn crate::Tool, cx: &ToolContext| {
|
||||
t.pointer(cx, &PointerEvent::new(PointerKind::Down, 160.0, 311.0));
|
||||
let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, 160.0, 351.0));
|
||||
t.pointer(cx, &PointerEvent::new(PointerKind::Up, 160.0, 351.0));
|
||||
a
|
||||
};
|
||||
let straight = cx(&d, &s, &p);
|
||||
let a = drag(crate::create("anchorPoint").as_mut(), &straight);
|
||||
assert!(
|
||||
matches!(a.last(), Some(Action::Preview(c, v)) if c == "object.blend.spine.reshapeSegment" && v["id"] == g.0 && v["segment"] == 0 && v["dy"] == 40.0),
|
||||
"{a:?}"
|
||||
);
|
||||
let a = drag(&mut DirectSelectionTool::new(false), &straight);
|
||||
assert!(!a.iter().any(|x| matches!(x, Action::Preview(c, _) if c == "object.blend.spine.reshapeSegment")), "straight: {a:?}");
|
||||
// The spine bent upward: Direct Selection takes it from where it passes.
|
||||
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(110.0, 310.0), Point::new(210.0, 310.0)], false);
|
||||
sp.bend_segment(0, 0.5, vectorcraft_geom::Vec2::new(0.0, 20.0));
|
||||
let Some(NodeKind::Blend { spec, .. }) = d.node_mut(g).map(|n| &mut n.kind) else { panic!("blend") };
|
||||
(spec.spine, spec.key_anchors) = (Some(PathData::single(sp)), vec![0, 1]);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
let curved = cx(&d, &s, &p);
|
||||
t.pointer(&curved, &PointerEvent::new(PointerKind::Down, 160.0, 330.0));
|
||||
let a = t.pointer(&curved, &PointerEvent::new(PointerKind::Drag, 160.0, 345.0));
|
||||
assert_eq!(a[0], Action::Begin("Reshape Spine".into()));
|
||||
assert!(
|
||||
matches!(&a[1], Action::Preview(c, v) if c == "object.blend.spine.reshapeSegment" && v["segment"] == 0 && v["dx"] == 0.0 && v["dy"] == 15.0),
|
||||
"{a:?}"
|
||||
);
|
||||
assert_eq!(t.pointer(&curved, &PointerEvent::new(PointerKind::Up, 160.0, 345.0)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_and_group_selection_pick_blend_keys() {
|
||||
let (d, g, k1) = blend_doc();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Delete: click an anchor to remove it, re-fitting the neighbouring curve (Alt = add).
|
||||
//! Anchor Point: click a smooth anchor → corner; drag an anchor → pull out smooth handles; drag a
|
||||
//! handle → move it independently (Shift: at 45° steps round its anchor); drag a segment → reshape
|
||||
//! the curve.
|
||||
//! the curve, a selected blend's spine's too, straight or curved (#1111).
|
||||
//! Scissors: click a path to split it there.
|
||||
|
||||
use serde_json::json;
|
||||
@@ -19,9 +19,30 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum State {
|
||||
Idle,
|
||||
Convert { id: NodeId, si: usize, ai: usize, start: Point, began: bool },
|
||||
Handle { id: NodeId, si: usize, ai: usize, out: bool, began: bool },
|
||||
Reshape { id: NodeId, si: usize, seg: usize, t: f64, start: Point, began: bool },
|
||||
Convert {
|
||||
id: NodeId,
|
||||
si: usize,
|
||||
ai: usize,
|
||||
start: Point,
|
||||
began: bool,
|
||||
},
|
||||
Handle {
|
||||
id: NodeId,
|
||||
si: usize,
|
||||
ai: usize,
|
||||
out: bool,
|
||||
began: bool,
|
||||
},
|
||||
/// Bending a segment: a path's, or (`spine`) a selected blend's spine's.
|
||||
Reshape {
|
||||
id: NodeId,
|
||||
si: usize,
|
||||
seg: usize,
|
||||
t: f64,
|
||||
start: Point,
|
||||
began: bool,
|
||||
spine: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct AnchorTool {
|
||||
@@ -94,7 +115,9 @@ impl Tool for AnchorTool {
|
||||
} else if let Some((id, si, ai)) = hit_anchor(cx, p, point) {
|
||||
self.state = State::Convert { id, si, ai, start: p, began: false };
|
||||
} else if let Some((id, si, seg, t)) = hit_segment(cx, p, tol) {
|
||||
self.state = State::Reshape { id, si, seg, t, start: p, began: false };
|
||||
self.state = State::Reshape { id, si, seg, t, start: p, began: false, spine: false };
|
||||
} else if let Some((id, seg, t, _)) = crate::direct::hit_spine_segment(cx, p, tol) {
|
||||
self.state = State::Reshape { id, si: 0, seg, t, start: p, began: false, spine: true };
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
@@ -128,6 +151,10 @@ impl Tool for AnchorTool {
|
||||
json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out { "out" } else { "in" }, "x": q.x, "y": q.y, "independent": true}),
|
||||
)
|
||||
}
|
||||
State::Reshape { id, seg, t, start, spine: true, .. } => Action::Preview(
|
||||
"object.blend.spine.reshapeSegment".into(),
|
||||
json!({"id": id.0, "segment": seg, "t": t, "dx": p.x - start.x, "dy": p.y - start.y}),
|
||||
),
|
||||
State::Reshape { id, si, seg, t, start, .. } => Action::Preview(
|
||||
"path.reshapeSegment".into(),
|
||||
json!({"id": id.0, "subpath": si, "segment": seg, "t": t, "dx": p.x - start.x, "dy": p.y - start.y}),
|
||||
|
||||
@@ -1238,6 +1238,8 @@ fn node_outline(n: &Node) -> BezPath {
|
||||
[b, c, d].into_iter().for_each(|q| bp.line_to(q));
|
||||
bp.close_path();
|
||||
}
|
||||
// A gradient mesh shows its mesh lines (#1111).
|
||||
NodeKind::Mesh(m) => bp.extend(m.lines().to_bezpath()),
|
||||
// An envelope shows its mesh (or top object), not its content.
|
||||
NodeKind::Envelope { .. } => {
|
||||
if let Some((lines, _)) = vectorcraft_doc::live::envelope_overlay(c) {
|
||||
@@ -1632,9 +1634,15 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let partial = st.selection.partial(*id);
|
||||
// Path outlines.
|
||||
stroke_path(p, &node_outline(n), xf, Stroke::new(1.0, color));
|
||||
// Anchors (and handles for selected anchors in direct mode); a mesh envelope's points.
|
||||
// Anchors (and handles for selected anchors in direct mode); a gradient mesh's or a mesh
|
||||
// envelope's points.
|
||||
walk_drawn(n, &mut |c| {
|
||||
if let Some((_, Some(grid))) = vectorcraft_doc::live::envelope_overlay(c) {
|
||||
let envelope = vectorcraft_doc::live::envelope_overlay(c).and_then(|(_, grid)| grid);
|
||||
let grid = match &c.kind {
|
||||
NodeKind::Mesh(m) => Some(m),
|
||||
_ => envelope.as_ref(),
|
||||
};
|
||||
if let Some(grid) = grid {
|
||||
for q in &grid.points {
|
||||
anchor_square(p, xf.to_screen(q.p), color, false, anchor(direct));
|
||||
}
|
||||
@@ -2548,6 +2556,34 @@ mod tests {
|
||||
assert!(points > 200 && squares > 200, "{points} outline points, {squares} squares");
|
||||
}
|
||||
|
||||
/// #1111: a selected gradient mesh shows its mesh lines and points to the selection tools, not
|
||||
/// only to the Mesh tool.
|
||||
#[test]
|
||||
fn a_selected_gradient_mesh_shows_its_lines_and_points() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 200, "height": 100})).unwrap();
|
||||
app.session.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let drawn = |app: &mut VectorcraftApp| {
|
||||
frame(app, &ctx, vec![]);
|
||||
let shapes = shapes(app, &ctx);
|
||||
let lines = shapes.iter().filter(|s| matches!(s, Shape::Path(ps) if ps.points.len() > 1)).count();
|
||||
(lines, shapes.iter().filter(|s| matches!(s, Shape::Rect(_))).count())
|
||||
};
|
||||
let (none_lines, none_points) = {
|
||||
app.session.execute("select.none", &json!({})).unwrap();
|
||||
drawn(&mut app)
|
||||
};
|
||||
for tool in ["selection", "directSelection"] {
|
||||
app.select_tool(tool);
|
||||
app.session.execute("select.all", &json!({})).unwrap();
|
||||
let (lines, points) = drawn(&mut app);
|
||||
// The outline, two inner lines each way; the 3 × 3 points.
|
||||
assert!(lines >= none_lines + 3 && points >= none_points + 9, "{tool}: {lines} lines, {points} points");
|
||||
}
|
||||
}
|
||||
|
||||
/// #541: the key object's outline is drawn thicker than the rest of the selection's.
|
||||
#[test]
|
||||
fn the_key_object_has_a_thicker_outline() {
|
||||
|
||||
@@ -1545,6 +1545,7 @@
|
||||
Move Rows Zeilen verschieben
|
||||
Move Slices Slices verschieben
|
||||
Move Spine Point Achsenpunkt verschieben
|
||||
Reshape Spine Segment Achsensegment umformen
|
||||
Move Swatch Farbfeld verschieben
|
||||
Move Up Nach oben verschieben
|
||||
Move… Verschieben…
|
||||
@@ -4097,3 +4098,5 @@ theme Appearance Erscheinungsbild
|
||||
@msg invalid native panel set Ungültiger nativer Bedienfeldsatz
|
||||
@msg missing panel layout Bedienfeldanordnung fehlt
|
||||
@msg unsupported native docking operation Nicht unterstützte native Docking-Operation
|
||||
Linux Display Server Linux-Anzeigeserver
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1563,6 +1563,7 @@
|
||||
Move Rows Mover filas
|
||||
Move Slices Mover sectores
|
||||
Move Spine Point Mover punto del eje
|
||||
Reshape Spine Segment Remodelar segmento del eje
|
||||
Move Swatch Mover muestra
|
||||
Move Up Subir
|
||||
Move… Mover…
|
||||
@@ -4141,3 +4142,5 @@ lighting Light Luz
|
||||
@msg invalid native panel set Conjunto nativo de paneles no válido
|
||||
@msg missing panel layout Falta la distribución de paneles
|
||||
@msg unsupported native docking operation Operación nativa de anclaje no admitida
|
||||
Linux Display Server Servidor de pantalla de Linux
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1562,6 +1562,7 @@
|
||||
Move Rows Déplacer les lignes
|
||||
Move Slices Déplacer les tranches
|
||||
Move Spine Point Déplacer le point de la dorsale
|
||||
Reshape Spine Segment Modeler le segment de la dorsale
|
||||
Move Swatch Déplacer la nuance
|
||||
Move Up Monter
|
||||
Move… Déplacer…
|
||||
@@ -4151,3 +4152,5 @@ lighting Light Lumière
|
||||
@msg invalid native panel set Ensemble natif de panneaux invalide
|
||||
@msg missing panel layout Disposition des panneaux manquante
|
||||
@msg unsupported native docking operation Opération native d’ancrage non prise en charge
|
||||
Linux Display Server Serveur d’affichage Linux
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1563,6 +1563,7 @@
|
||||
Move Rows Sposta righe
|
||||
Move Slices Sposta sezioni
|
||||
Move Spine Point Sposta punto dorsale
|
||||
Reshape Spine Segment Rimodella segmento della dorsale
|
||||
Move Swatch Sposta campione
|
||||
Move Up Sposta su
|
||||
Move… Sposta…
|
||||
@@ -4143,3 +4144,5 @@ lighting Light Luce
|
||||
@msg invalid native panel set Gruppo nativo di pannelli non valido
|
||||
@msg missing panel layout Disposizione dei pannelli mancante
|
||||
@msg unsupported native docking operation Operazione nativa di ancoraggio non supportata
|
||||
Linux Display Server Server di visualizzazione Linux
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1697,6 +1697,7 @@
|
||||
Move Rows 行を移動
|
||||
Move Slices スライスを移動
|
||||
Move Spine Point スパインポイントを移動
|
||||
Reshape Spine Segment スパインセグメントをシェイプ変形
|
||||
Move Swatch スウォッチを移動
|
||||
Move Up 上へ移動
|
||||
Move in Perspective 遠近で移動
|
||||
@@ -4137,3 +4138,5 @@ lighting Light ライト
|
||||
@msg invalid native panel set ネイティブパネルセットが無効です
|
||||
@msg missing panel layout パネル配置がありません
|
||||
@msg unsupported native docking operation ネイティブドッキング操作は未対応です
|
||||
Linux Display Server Linux ディスプレイサーバー
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1334,6 +1334,7 @@
|
||||
Move Plane to Match Object Mover Plano para Corresponder ao Objeto
|
||||
Move Slices Mover Fatias
|
||||
Move Spine Point Mover Ponto de Coluna
|
||||
Reshape Spine Segment Remodelar Segmento da Coluna
|
||||
Move Swatch Mover Amostra
|
||||
Move Up Mover para Cima
|
||||
Move in Perspective Mover em Perspectiva
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1561,6 +1561,7 @@
|
||||
Move Rows Переместить строки
|
||||
Move Slices Переместить фрагменты
|
||||
Move Spine Point Переместить точку остова
|
||||
Reshape Spine Segment Изменить форму сегмента остова
|
||||
Move Swatch Переместить образец
|
||||
Move Up Переместить вверх
|
||||
Move… Переместить…
|
||||
@@ -4139,3 +4140,5 @@ lighting Light Свет
|
||||
@msg invalid native panel set Недопустимый собственный набор панелей
|
||||
@msg missing panel layout Отсутствует расположение панелей
|
||||
@msg unsupported native docking operation Неподдерживаемая собственная операция прикрепления
|
||||
Linux Display Server Дисплейный сервер Linux
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1557,6 +1557,7 @@
|
||||
Move Rows Перемістити рядки
|
||||
Move Slices Перемістити фрагменти
|
||||
Move Spine Point Перемістити точку осі
|
||||
Reshape Spine Segment Змінити форму сегмента осі
|
||||
Move Swatch Перемістити зразок
|
||||
Move Up Перемістити вгору
|
||||
Move… Перемістити…
|
||||
@@ -4123,3 +4124,5 @@ lighting Light Світло
|
||||
@msg invalid native panel set Неприпустимий власний набір панелей
|
||||
@msg missing panel layout Відсутнє розташування панелей
|
||||
@msg unsupported native docking operation Непідтримувана власна операція прикріплення
|
||||
Linux Display Server Дисплейний сервер Linux
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
|
Can't render this file because it contains an unexpected character in line 659 and column 40.
|
@@ -1494,6 +1494,7 @@
|
||||
Move Plane to Match Object 移动平面以符合对象
|
||||
Move Slices 移动切片
|
||||
Move Spine Point 移动脊线点
|
||||
Reshape Spine Segment 整形脊线线段
|
||||
Move Swatch 移动色板
|
||||
Move Up 上移
|
||||
Move… 移动…
|
||||
@@ -3742,3 +3743,5 @@ lighting Light 光源
|
||||
Vertical Guide 垂直参考线
|
||||
Horizontal Guide 水平参考线
|
||||
Move Guide to Layer 将参考线移至图层
|
||||
Linux Display Server Linux 显示服务器
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1538,6 +1538,7 @@
|
||||
Move Rows 移動列
|
||||
Move Slices 移動切片
|
||||
Move Spine Point 移動脊線點
|
||||
Reshape Spine Segment 改變脊線線段外框
|
||||
Move Swatch 移動色票
|
||||
Move Up 上移
|
||||
Move… 移動…
|
||||
@@ -3523,3 +3524,5 @@ lighting Light 光源
|
||||
Vertical Guide 垂直參考線
|
||||
Horizontal Guide 水平參考線
|
||||
Move Guide to Layer 將參考線移至圖層
|
||||
Linux Display Server Linux 顯示伺服器
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -306,6 +306,8 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) {
|
||||
}
|
||||
});
|
||||
}
|
||||
// Linux only: the other platforms have a single display server.
|
||||
PrefKind::Choice(_) if sp.key == "linuxDisplayServer" && !cfg!(target_os = "linux") => {}
|
||||
PrefKind::Choice(opts) => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
let cur = v.as_str().unwrap_or("");
|
||||
@@ -315,8 +317,8 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) {
|
||||
d.fields.insert(sp.key.into(), json!(opts[i].0));
|
||||
}
|
||||
});
|
||||
if sp.key == "gpuPreference" {
|
||||
// The window's graphics device is chosen once, when the app starts.
|
||||
if sp.key == "gpuPreference" || sp.key == "linuxDisplayServer" {
|
||||
// The window's graphics device and display server are chosen once, when the app starts.
|
||||
ui.label(egui::RichText::new(tl!("Applies the next time VectorCraft starts.")).color(t.text_dim).size(11.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -692,7 +692,9 @@ new blends start with. The Blend tool (`ui.tool.select {tool: "blend"}`): `ui.po
|
||||
another, blends them; down on an anchor point blends from that point; each further object clicked joins the blend.
|
||||
A selected blend shows its spine. With Direct Selection (`directSelection`) a `ui.pointer` drag from a spine point
|
||||
moves it (`object.blend.spine.moveAnchor`, one undo step) and, once a point is clicked, from one of its handles
|
||||
reshapes the curve; Direct and Group Selection pick a blend's key objects. With the Pen tool a click on a selected
|
||||
reshapes the curve, and a drag on a curved spine segment bends it (`object.blend.spine.reshapeSegment`); the Anchor
|
||||
Point tool (`anchorPoint`) bends any segment of a selected blend's spine, straight ones too. Direct and Group Selection
|
||||
pick a blend's key objects. With the Pen tool a click on a selected
|
||||
blend's spine adds a point (on a point no key sits on, deletes it).
|
||||
|
||||
Perspective grid view options: View → Perspective Grid lists Show/Hide Grid, Show/Hide Rulers, Snap to Grid
|
||||
|
||||
+2
-2
@@ -102,8 +102,8 @@ The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On
|
||||
- **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. egui's own text paste into fields goes through smithay-clipboard and works either way.
|
||||
- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. A paste itself still reads on the UI thread, so pasting from such an owner waits for it once. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server.
|
||||
- **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EMF/WMF or a bitmap), before the clipboard's other formats (which include the files' paths as text).
|
||||
- **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.36 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or run the app under XWayland, where drops work (`WAYLAND_DISPLAY= vectorcraft`).
|
||||
- **Pens and pen displays under Wayland (#491):** winit 0.30 doesn't bind the Wayland tablet protocol (`zwp_tablet_v2`; tablet input arrives in winit 0.31, [winit#4318](https://github.com/rust-windowing/winit/pull/4318), [egui#7731](https://github.com/emilk/egui/pull/7731)). Since Plasma 6.3, KWin no longer turns tablet input into pointer input for such apps ([kwin!6336](https://invent.kde.org/plasma/kwin/-/merge_requests/6336)): the pen moves the compositor's cursor over the window, but the app gets no motion or presses and shows none of its own cursors. Until eframe moves to winit 0.31, either run the app under XWayland, which supports the tablet protocol and hands the pen to X11 apps as a pointer (`WAYLAND_DISPLAY= vectorcraft`, or `env -u WAYLAND_DISPLAY vectorcraft`; for a desktop launcher, put `env WAYLAND_DISPLAY=` before the command in its `Exec=` line), or turn KWin's deprecated emulation back on for the whole session by setting `KWIN_WAYLAND_EMULATE_TABLET=1` in KWin's environment (for example in a file under `~/.config/environment.d/` or in `/etc/environment`) and logging in again. The pen then works as a mouse, without pressure: winit 0.30 reads pen pressure on Windows only (Windows Ink, which reaches the Liquify tools' Use Pressure Pen). Other compositors that don't emulate a pointer for tablets behave the same way.
|
||||
- **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.36 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or open the window through XWayland, where drops work: set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart (#1151), or start the app with `WAYLAND_DISPLAY= vectorcraft`. The preference (`linuxDisplayServer`, read at startup in `apps/vectorcraft/src/main.rs`, shown on Linux only) has winit open the window on X11 (`EventLoopBuilderExtX11::with_x11`) when the session is Wayland, `DISPLAY` is set and the X11 libraries winit loads are installed (`apps/vectorcraft/src/xwayland.rs` reads `ldconfig -p` and the usual library directories); otherwise the window opens on Wayland and the log says why. Unlike clearing `WAYLAND_DISPLAY`, it leaves the clipboard on the compositor's own.
|
||||
- **Pens and pen displays under Wayland (#491):** winit 0.30 doesn't bind the Wayland tablet protocol (`zwp_tablet_v2`; tablet input arrives in winit 0.31, [winit#4318](https://github.com/rust-windowing/winit/pull/4318), [egui#7731](https://github.com/emilk/egui/pull/7731)). Since Plasma 6.3, KWin no longer turns tablet input into pointer input for such apps ([kwin!6336](https://invent.kde.org/plasma/kwin/-/merge_requests/6336)): the pen moves the compositor's cursor over the window, but the app gets no motion or presses and shows none of its own cursors. Until eframe moves to winit 0.31, either run the app under XWayland, which supports the tablet protocol and hands the pen to X11 apps as a pointer (Preferences › Performance › Linux Display Server › X11 (XWayland), `WAYLAND_DISPLAY= vectorcraft`, or `env -u WAYLAND_DISPLAY vectorcraft`; for a desktop launcher, put `env WAYLAND_DISPLAY=` before the command in its `Exec=` line), or turn KWin's deprecated emulation back on for the whole session by setting `KWIN_WAYLAND_EMULATE_TABLET=1` in KWin's environment (for example in a file under `~/.config/environment.d/` or in `/etc/environment`) and logging in again. The pen then works as a mouse, without pressure: winit 0.30 reads pen pressure on Windows only (Windows Ink, which reaches the Liquify tools' Use Pressure Pen). Other compositors that don't emulate a pointer for tablets behave the same way.
|
||||
|
||||
## Fonts: craft-fonts (optional build input)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hardware parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Linux Display Server preference opens the window through XWayland, #1151) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
What hardware Illustrator uses, per platform, against VectorCraft. Illustrator's side is from its public
|
||||
documentation (GPU Performance, touch workspace, pen pressure pages); ours from the code
|
||||
@@ -19,7 +19,7 @@ on huge documents. Pen input is pressure-only and Windows-first.
|
||||
| Multithreaded rendering off the UI thread | partial | yes | yes | yes | single-threaded | beyond Illustrator |
|
||||
| Hybrid-graphics GPU choice | automatic | power-saving GPU by default, Preferences › Graphics Processor | same; DX12 then OpenGL; no Vulkan unless asked (#545, #806) | the desktop's GPU | browser | done |
|
||||
| Restart on another GPU when the first fails to present | — | yes (#502, #964) | yes | yes | restart on lost device (#369) | done |
|
||||
| Pen pressure | yes (Wacom, Windows Ink, macOS tablet events) | **no** (winit reports none, #852) | yes (pointer force via `WM_POINTER`) | X11 only via XWayland; none on Wayland (#491, #764) | browser pointer events | partial: 4–8 h for macOS and Wayland (winit 0.31) |
|
||||
| Pen pressure | yes (Wacom, Windows Ink, macOS tablet events) | **no** (winit reports none, #852) | yes (pointer force via `WM_POINTER`) | X11 only via XWayland (Preferences › Performance › Linux Display Server, #1151); none on Wayland (#491, #764) | browser pointer events | partial: 4–8 h for macOS and Wayland (winit 0.31) |
|
||||
| Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | no | no | no | no | missing: 4–8 h after the input layer (#372) |
|
||||
| Pen eraser end | yes | no | no | no | no | missing |
|
||||
| Touch gestures (pinch zoom, two-finger pan, rotate view, Touch workspace) | yes (Windows touch, Surface) | trackpad pinch and scroll | pinch, pan; two-finger tap undo, three-finger redo (#585) | same where winit reports touch | same | partial: touch workspace and rotate gesture missing, 3–5 h |
|
||||
@@ -33,4 +33,5 @@ on huge documents. Pen input is pressure-only and Windows-first.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland on a Wayland session, where pens and dropped files reach the app (#1151); score unchanged (estimated) |
|
||||
| 2026-10-10 | major | First checklist from the canvas, touch and graphics code and the open hardware issues |
|
||||
+3
-2
@@ -3077,8 +3077,9 @@ among the objects takes the others in as more key objects, keeping its options,
|
||||
A blend's spine is the straight lines between its key centres until it is edited. `object.blend.info` lists it
|
||||
(`spine.anchors`, `keyAnchors`: the point each key sits on). `object.blend.spine.addAnchor {x, y}` adds a point where
|
||||
the spine passes nearest, `object.blend.spine.moveAnchor {anchor, x, y}` moves a point (a key on it moves along; with
|
||||
`handle: "in" | "out"` it places that handle) and `object.blend.spine.removeAnchor {anchor}` deletes a point no key
|
||||
sits on; the first edit turns the spine into a path. Moving a key moves its end of the spine. Release
|
||||
`handle: "in" | "out"` it places that handle), `object.blend.spine.reshapeSegment {segment, t, dx, dy}` bends a segment
|
||||
so its point at `t` moves by (dx, dy), turning a straight spine into a curve, and `object.blend.spine.removeAnchor
|
||||
{anchor}` deletes a point no key sits on; the first edit turns the spine into a path. Moving a key moves its end of the spine. Release
|
||||
(`object.blend.release`) leaves the spine as a path that paints nothing (`spines` in the result):
|
||||
|
||||
```json
|
||||
|
||||
@@ -353,7 +353,7 @@ since 2026-10-01.
|
||||
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
|
||||
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
|
||||
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
|
||||
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel; what an agent reads and writes on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root`, as in PhotoCraft, #832) |
|
||||
| **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) |
|
||||
|
||||
@@ -460,6 +460,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Platforms: Preferences › Performance › Linux Display Server opens the window through XWayland, where file drops work (#1151); score unchanged (estimated) |
|
||||
| 2026-10-11 | minor | Drawing tools: Live Corners on corners between curved sides (#1144) leaves the missing list; score and hours unchanged at this rounding (estimated) |
|
||||
| 2026-10-11 | minor | Type advanced: Proportional Metrics in vertical type (`vpal`, #966) leaves the missing list; score and hours unchanged at this rounding (estimated) |
|
||||
| 2026-10-11 | minor | Shape Builder #893 fixed (planar-map faces share their corners exactly, so merges leave no seams); Path operations stays 80% and Shape Builder 70% while #937 is open (estimated) |
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Live Corners round corners between curved sides, #1144; earlier: several artboards selected at once, #1139) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's
|
||||
trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to,
|
||||
@@ -62,6 +62,7 @@ tools, never checked side by side) isn't captured row by row.
|
||||
| Behaviour | Status | Notes |
|
||||
|---|---|---|
|
||||
| Click/Shift-click anchors; marquee toggles with Shift | done | #483 |
|
||||
| A marquee selects the objects whose paths it touches (anchors or segments): drawn inside a filled shape, over a background, it leaves that shape (Preview and Outline) | done | #1103 |
|
||||
| Drag a curved segment to bend it, a straight one to move its anchors | done | |
|
||||
| Drag by an anchor of a path selected whole moves the whole path; Alt-drag by an anchor or a segment copies it, Alt toggles the copy during the drag, and the copy snaps by the anchor pressed, onto the original too | done | #1104 |
|
||||
| Handles shown are the handles that drag; tolerance from Selection & Anchor Display | done | #494 |
|
||||
@@ -169,6 +170,7 @@ re-measure of the panels added since.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | A marquee inside a filled shape no longer selects it: only touching its path does (#1103); score unchanged (estimated) |
|
||||
| 2026-10-11 | minor | Live Corners: corners between curved sides round too, the curve trimmed to what the cut leaves (#1144); score unchanged (estimated) |
|
||||
| 2026-10-11 | minor | Artboards: Shift-click selects several, which move, copy and delete together (#1139); score unchanged (estimated) |
|
||||
| 2026-10-11 | minor | Edit Toolbar opens the All Tools drawer beside the toolbar instead of a centred dialog, dims the tools the toolbar holds, and edits the toolbar by drag and drop while it is open (#1018); score unchanged (estimated) |
|
||||
|
||||
Reference in new issue
Block a user