Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
683b1e9845 | ||
|
|
a0438f73f9 | ||
|
|
b23a402df8 | ||
|
|
fd52ca32c7 |
No files matched your search
+1
-1
@@ -123,7 +123,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). |
|
||||
| 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). |
|
||||
| 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 |
|
||||
|
||||
@@ -129,7 +129,7 @@ fn default_ratio(h: &reader::Header) -> (Unit, f64) {
|
||||
|
||||
/// The name of `$INSUNITS` code `code`.
|
||||
fn units_name(code: i64) -> &'static str {
|
||||
const NAMES: [&str; 22] = [
|
||||
const NAMES: [&str; 25] = [
|
||||
"Unitless",
|
||||
"Inches",
|
||||
"Feet",
|
||||
@@ -152,6 +152,9 @@ fn units_name(code: i64) -> &'static str {
|
||||
"Light years",
|
||||
"Parsecs",
|
||||
"US survey feet",
|
||||
"US survey inches",
|
||||
"US survey yards",
|
||||
"US survey miles",
|
||||
];
|
||||
usize::try_from(code).ok().and_then(|i| NAMES.get(i)).copied().unwrap_or("Unitless")
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ pub(crate) struct Build<'d, 'a> {
|
||||
/// The unit of `$INSUNITS` code `code` in points (0, unitless: none).
|
||||
pub(crate) fn insunits_points(code: i64) -> Option<f64> {
|
||||
let m = 72.0 / 0.0254;
|
||||
// The US survey foot: exactly 1200/3937 m.
|
||||
let survey_ft = m * 1200.0 / 3937.0;
|
||||
Some(match code {
|
||||
1 => 72.0,
|
||||
2 => 864.0,
|
||||
@@ -87,7 +89,10 @@ pub(crate) fn insunits_points(code: i64) -> Option<f64> {
|
||||
18 => m * 1.495_978_707e11,
|
||||
19 => m * 9.460_730_472_580_8e15,
|
||||
20 => m * 3.085_677_581e16,
|
||||
21 => m * 1200.0 / 3937.0,
|
||||
21 => survey_ft,
|
||||
22 => survey_ft / 12.0,
|
||||
23 => survey_ft * 3.0,
|
||||
24 => survey_ft * 5280.0,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -128,6 +128,20 @@ fn units_choose_the_default_scale() {
|
||||
let um = info(&drawing(&[(9, "$INSUNITS"), (70, "13")], &[], &[], LINE)).unwrap();
|
||||
assert_eq!((um.unit, um.units.as_str()), (Unit::Millimeters, "Microns"));
|
||||
assert!((um.scale - 1000.0).abs() < 1e-6);
|
||||
// #1011: US survey inches, yards and miles (from the survey foot, 1200/3937 m), not unitless.
|
||||
let survey_ft = 72.0 / 0.0254 * 1200.0 / 3937.0;
|
||||
for (code, name, pt) in [
|
||||
("21", "US survey feet", survey_ft),
|
||||
("22", "US survey inches", survey_ft / 12.0),
|
||||
("23", "US survey yards", survey_ft * 3.0),
|
||||
("24", "US survey miles", survey_ft * 5280.0),
|
||||
] {
|
||||
let i = info(&drawing(&[(9, "$INSUNITS"), (70, code)], &[], &[], LINE)).unwrap();
|
||||
assert_eq!((i.unit, i.units.as_str()), (Unit::Millimeters, name));
|
||||
assert!((i.scale * pt - 72.0 / 25.4).abs() < 1e-9, "{name}: 1 mm is {} units", i.scale);
|
||||
}
|
||||
let survey_in = open(&drawing(&[(9, "$INSUNITS"), (70, "22")], &[], &[], LINE), &ImportOptions::default()).document;
|
||||
assert!((survey_in.artboards[0].rect.width() - 7200.0144000288).abs() < 1e-6, "{:?}", survey_in.artboards[0].rect);
|
||||
let imperial = info(&drawing(&[(9, "$MEASUREMENT"), (70, "0")], &[], &[], LINE)).unwrap();
|
||||
assert_eq!((imperial.unit, imperial.scale), (Unit::Inches, 1.0));
|
||||
// A ratio: 1 cm = 10 units is 1:1 in millimetres again.
|
||||
|
||||
@@ -870,6 +870,10 @@ fn the_pen_continues_any_open_path_and_joins_another() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
assert_eq!(s.cursor(Point::new(300.0, 200.0), none, v), Cursor::PenJoin);
|
||||
assert_eq!(s.cursor(Point::new(350.0, 200.0), none, v), Cursor::Pen, "the middle of the other path");
|
||||
// #1009: Use Precise Cursors makes the join pointer a crosshair too.
|
||||
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": true})).unwrap();
|
||||
assert_eq!(s.cursor(Point::new(300.0, 200.0), none, v), Cursor::Crosshair);
|
||||
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": false})).unwrap();
|
||||
assert_eq!(gesture(&mut s, &[(300.0, 200.0)], none), 1);
|
||||
let pts = |s: &Session, id| path(s, id).subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|x| (x.p.x, x.p.y))).collect::<Vec<_>>();
|
||||
assert_eq!(pts(&s, a), [(100.0, 100.0), (200.0, 100.0), (300.0, 200.0), (400.0, 200.0)]);
|
||||
|
||||
@@ -235,8 +235,9 @@ pub fn polar_grid(r: Rect, concentric: u32, radial: u32) -> Vec<PathData> {
|
||||
let f = i as f64 / (concentric + 1) as f64;
|
||||
out.push(ellipse(Rect::from_center_size(c, (r.width() * f, r.height() * f))));
|
||||
}
|
||||
for i in 0..radial.max(1) {
|
||||
let a = -PI / 2.0 + TAU * i as f64 / radial.max(1) as f64;
|
||||
// No dividers at 0 (the loop never divides by it).
|
||||
for i in 0..radial {
|
||||
let a = -PI / 2.0 + TAU * i as f64 / radial as f64;
|
||||
out.push(line(c, Point::new(c.x + r.width() / 2.0 * a.cos(), c.y + r.height() / 2.0 * a.sin())));
|
||||
}
|
||||
out
|
||||
@@ -402,6 +403,10 @@ mod tests {
|
||||
fn grids() {
|
||||
assert_eq!(rectangular_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5, true).len(), 11);
|
||||
assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5).len(), 11);
|
||||
// #1008: no radial dividers is none, only the outer ellipse without concentric ones.
|
||||
for (radial, len) in [(0, 1), (1, 2), (2, 3)] {
|
||||
assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 0, radial).len(), len, "{radial}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+11
-10
@@ -47,8 +47,8 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
usvg::Tree::from_str(&src, &opt).map_err(|e| SvgError::Parse(e.to_string()))?
|
||||
};
|
||||
let size = tree.size();
|
||||
let (kx, ky) = units.k;
|
||||
let mut doc = Document::new(size.width() as f64 * kx, size.height() as f64 * ky);
|
||||
let k = units.k;
|
||||
let mut doc = Document::new(size.width() as f64 * k, size.height() as f64 * k);
|
||||
doc.units = units.unit;
|
||||
let mut im = Importer {
|
||||
doc,
|
||||
@@ -68,7 +68,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
data: found.data,
|
||||
sublayers: found.sublayers,
|
||||
sublayer_groups: HashSet::new(),
|
||||
screen: (kx * ky).sqrt(),
|
||||
screen: k,
|
||||
};
|
||||
if im.files.nested_text() {
|
||||
im.warn("text inside an SVG image isn't imported".into());
|
||||
@@ -76,7 +76,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
|
||||
// usvg wraps everything in an id-less group carrying the viewBox transform when needed.
|
||||
let mut top = tree.root();
|
||||
let mut base = Affine::scale_non_uniform(kx, ky) * aff(top.transform());
|
||||
let mut base = Affine::scale(k) * aff(top.transform());
|
||||
if let Some(g) = im.wrapper(top) {
|
||||
base *= aff(g.transform());
|
||||
top = g;
|
||||
@@ -212,12 +212,14 @@ pub(super) const DEFAULT_FONT_SIZE: f64 = 12.0;
|
||||
/// A pixel (and a unitless user unit) is a point, as we export, and absolute lengths keep their
|
||||
/// physical size at 72 pt per inch: `font-size="12pt"` is 12 pt, `1in` is 72 pt. A root size in
|
||||
/// absolute units (`width="210mm"`) is that physical size, and its user units are CSS pixels of it
|
||||
/// (96 per inch, 0.75 pt each), so a drawing without a `viewBox` keeps its proportions.
|
||||
/// (96 per inch, 0.75 pt each), so a drawing without a `viewBox` keeps its proportions. With one
|
||||
/// side absolute and the other not (`width="96pt" height="96"`), both are: the unitless side is
|
||||
/// CSS pixels too, so the drawing isn't stretched (#1010).
|
||||
struct RootUnits {
|
||||
/// Pixels per inch usvg (and the text pass) convert absolute lengths at.
|
||||
dpi: f64,
|
||||
/// Points per user unit of the root along x and y.
|
||||
k: (f64, f64),
|
||||
/// Points per user unit of the root, along both axes.
|
||||
k: f64,
|
||||
/// Document units: those of the root `width` (pixels when it has none).
|
||||
unit: Unit,
|
||||
}
|
||||
@@ -228,8 +230,7 @@ impl RootUnits {
|
||||
use svgtypes::LengthUnit as U;
|
||||
let physical = |u: Option<U>| matches!(u, Some(U::In | U::Cm | U::Mm | U::Pt | U::Pc));
|
||||
let (w, h) = (unit("width"), unit("height"));
|
||||
let k = |u| if physical(u) { PT_PER_IN / CSS_PX_PER_IN } else { 1.0 };
|
||||
let dpi = if physical(w) || physical(h) { CSS_PX_PER_IN } else { PT_PER_IN };
|
||||
let (dpi, k) = if physical(w) || physical(h) { (CSS_PX_PER_IN, PT_PER_IN / CSS_PX_PER_IN) } else { (PT_PER_IN, 1.0) };
|
||||
let unit = match w {
|
||||
Some(U::Mm) => Unit::Millimeters,
|
||||
Some(U::Cm) => Unit::Centimeters,
|
||||
@@ -238,7 +239,7 @@ impl RootUnits {
|
||||
Some(U::Pc) => Unit::Picas,
|
||||
_ => Unit::Pixels,
|
||||
};
|
||||
Self { dpi, k: (k(w), k(h)), unit }
|
||||
Self { dpi, k, unit }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,8 +41,9 @@
|
||||
//! * One CSS pixel (user unit) is one point, as on export, and absolute lengths (`in`, `cm`, `mm`,
|
||||
//! `pt`, `pc`) keep their physical size (72 pt per inch): `font-size="12pt"` is 12 pt. A root
|
||||
//! `width`/`height` in absolute units keeps its physical size, so a 210 mm SVG opens on a 210 mm
|
||||
//! artboard, its user units being CSS pixels of it (96 per inch). The document's units follow the
|
||||
//! unit of the root `width` (pixels when it has none).
|
||||
//! artboard, its user units being CSS pixels of it (96 per inch), the other side's too when only
|
||||
//! one is absolute. The document's units follow the unit of the root `width` (pixels when it has
|
||||
//! none).
|
||||
//! * `<mask>` imports as a luminance opacity mask; a mask we exported keeps its options and art
|
||||
//! (its `data-vectorcraft-mask="noclip invert"` lists the options that differ from clipping and
|
||||
//! not inverted). Nested clip paths (`<clipPath clip-path>`) are clip groups in clip groups.
|
||||
|
||||
@@ -811,6 +811,31 @@ fn import_absolute_units_keep_their_physical_size() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1010: a root with one absolute side and one unitless or px side opens as if both were
|
||||
/// absolute (CSS pixels at 96 per inch), so nothing is stretched and the viewport keeps its size.
|
||||
#[test]
|
||||
fn import_mixed_root_units_scale_both_sides_alike() {
|
||||
let rect = |d: &Document| art(d)[0].geometric_bounds().unwrap();
|
||||
for (size, board) in [
|
||||
(r#"width="96pt" height="96""#, Rect::new(0.0, 0.0, 96.0, 72.0)),
|
||||
(r#"width="96pt" height="96px""#, Rect::new(0.0, 0.0, 96.0, 72.0)),
|
||||
(r#"width="96pt" height="72pt""#, Rect::new(0.0, 0.0, 96.0, 72.0)),
|
||||
(r#"width="128" height="72pt""#, Rect::new(0.0, 0.0, 96.0, 72.0)),
|
||||
] {
|
||||
let svg =
|
||||
|extra: &str| format!(r##"<svg xmlns="http://www.w3.org/2000/svg" {size} {extra}><rect width="32" height="32" fill="#f00"/></svg>"##);
|
||||
let d = import(&svg("")).unwrap();
|
||||
assert!(close_rect(d.artboards[0].rect, board, 1e-6), "{size}: {:?}", d.artboards[0].rect);
|
||||
assert!(close_rect(rect(&d), Rect::new(0.0, 0.0, 24.0, 24.0), 1e-6), "{size}: {:?}", rect(&d));
|
||||
// With a viewBox: fitted uniformly (xMidYMid meet), unless preserveAspectRatio is none.
|
||||
let d = import(&svg(r#"viewBox="0 0 32 32""#)).unwrap();
|
||||
assert!(close_rect(d.artboards[0].rect, board, 1e-6), "{size}: {:?}", d.artboards[0].rect);
|
||||
assert!(close_rect(rect(&d), Rect::new(12.0, 0.0, 84.0, 72.0), 1e-6), "{size} viewBox: {:?}", rect(&d));
|
||||
let d = import(&svg(r#"viewBox="0 0 32 32" preserveAspectRatio="none""#)).unwrap();
|
||||
assert!(close_rect(rect(&d), board, 1e-6), "{size} none: {:?}", rect(&d));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_document_roundtrip_is_stable() {
|
||||
// export → import → export produces identical SVG.
|
||||
|
||||
@@ -485,6 +485,7 @@ impl Cursor {
|
||||
| Cursor::PenDelete
|
||||
| Cursor::PenClose
|
||||
| Cursor::PenContinue
|
||||
| Cursor::PenJoin
|
||||
| Cursor::PenConvert
|
||||
| Cursor::Eyedropper
|
||||
| Cursor::Slice
|
||||
|
||||
Reference in new issue
Block a user