Compare commits

..
Author SHA1 Message Date
bflatastic 683b1e9845 #1011: DXF US survey inches, yards and miles import at their size
$INSUNITS 22, 23 and 24 (US survey inch, yard and mile, as Autodesk's INSUNITS reference
lists them) had no size or name, so the drawing opened as unitless (millimetres, or inches
with $MEASUREMENT 0) and document.dxfInfo said Unitless.

They now derive from the US survey foot (1200/3937 m) like code 21, with their names.

The test reads each survey code's unit name and default scale and opens a survey-inch line at
7200.0144 pt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:47 +07:00
bflatastic a0438f73f9 #1010: SVGs with one absolute and one unitless root side import unstretched
A root such as width="96pt" height="96" took CSS pixels (0.75 pt) along x and points along y,
so a square came in 24 x 32 pt and the artboard 96 x 96 instead of 96 x 72; with a viewBox the
art was stretched too.

When either root side is absolute, both axes now take CSS pixels at 96 per inch (usvg already
parses at that resolution): the unitless or px side is the same CSS length it would be in a
browser. The root scale is one number.

The test opens width="96pt" with height 96, 96px and 72pt, and width 128 with height 72pt: a
96 x 72 artboard and a 24 x 24 square; with viewBox 0 0 32 32 a 72 x 72 square at x 12 (meet),
and with preserveAspectRatio="none" the whole artboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:43 +07:00
bflatastic b23a402df8 #1009: Use Precise Cursors makes the Pen's join pointer a crosshair
Cursor::precise listed every Pen state but PenJoin (added later, #794), so with Use Precise
Cursors on the Pen still showed its join badge over another open path's end.

PenJoin is a crosshair too under the preference.

The Pen join test now also checks the pointer over the other path's end is the crosshair with
the preference on (and PenJoin with it off, as before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:39 +07:00
bflatastic fd52ca32c7 #1008: a polar grid with no radial dividers draws none
shapes::polar_grid drew at least one radial divider (`radial.max(1)`), so radial 0, which
the command and the Polar Grid tool (Left arrow while dragging) accept, still drew a spoke.

Zero now draws none; the loop never divides by it.

The test checks 0, 1 and 2 radial dividers with no concentric ones give 1, 2 and 3 paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:35 +07:00
10 changed files with 76 additions and 17 deletions

No files matched your search

+1 -1
View File
@@ -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 |
+4 -1
View File
@@ -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")
}
+6 -1
View File
@@ -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,
})
}
+14
View File
@@ -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.
+4
View File
@@ -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)]);
+7 -2
View File
@@ -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
View File
@@ -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 }
}
}
+3 -2
View File
@@ -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.
+25
View File
@@ -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.
+1
View File
@@ -485,6 +485,7 @@ impl Cursor {
| Cursor::PenDelete
| Cursor::PenClose
| Cursor::PenContinue
| Cursor::PenJoin
| Cursor::PenConvert
| Cursor::Eyedropper
| Cursor::Slice