Merge pull request #426 from raytheengineer/stroke-weight-presets-per-unit

Stroke: per-unit weight presets so mm/cm/in dropdowns show clean numbers
This commit is contained in:
BFlat authored and GitHub committed 2026-10-08 12:49:39 +07:00
commit 3966b0cce5
7 files changed
+72 -13

No files matched your search

+2 -2
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400); see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426); see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -140,7 +140,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved and loaded as `.vcswatches`, `.gpl` or CSS. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers, panels have their shortcuts, and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units, shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups; Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel.
+4 -1
View File
@@ -191,7 +191,10 @@ impl Unit {
}
/// [`Unit::format`] without the suffix (`12.5`), for narrow fields.
pub fn number(self, pt: f64) -> String {
let s = format!("{:.3}", self.from_pt(pt));
// Three decimals for the small units (`595.276 pt`), four for the large ones (`8.2677 in`,
// `35.2778 mm`, the `0.0078 in` stroke preset); trailing zeros are trimmed (`1 pt` is `1`).
let decimals = if matches!(self, Unit::Points | Unit::Pixels | Unit::Picas) { 3 } else { 4 };
let s = format!("{:.*}", decimals, self.from_pt(pt));
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".into() } else { s.into() }
}
+5 -2
View File
@@ -60,6 +60,9 @@ fn readouts_show_two_decimals() {
assert_eq!(Unit::Points.readout(15.0), "15.00 pt");
assert_eq!(Unit::Millimeters.readout(72.0), "25.40 mm");
assert_eq!(Unit::Inches.readout(-36.0), "-0.50 in");
assert_eq!(Unit::Millimeters.number(1.0), "0.353");
assert_eq!(Unit::Points.number(-0.0001), "0");
assert_eq!(Unit::Millimeters.number(1.0), "0.3528");
// Four decimals for the large units, three for the small ones.
assert_eq!(Unit::Inches.number(0.5616), "0.0078");
assert_eq!((Unit::Inches.number(595.2756), Unit::Points.number(595.2756)), ("8.2677".into(), "595.276".into()));
assert_eq!(Unit::Points.number(-0.00001), "0");
}
+1 -1
View File
@@ -73,7 +73,7 @@ fn stroke_unit_follows_the_preference() {
assert_eq!(s.stroke_unit(), Unit::Points);
s.prefs.units_stroke = "millimeters".into();
assert_eq!(s.stroke_unit(), Unit::Millimeters);
assert_eq!(s.stroke_unit().format(1.0), "0.353 mm");
assert_eq!(s.stroke_unit().format(1.0), "0.3528 mm");
}
#[test]
+30 -3
View File
@@ -4,7 +4,7 @@
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, ArrowAlign, Arrowhead, Dash, Document, LineCap, LineJoin, Node, StrokeAlign, StrokeLayer, WidthProfile};
use vectorcraft_doc::{Appearance, ArrowAlign, Arrowhead, Dash, Document, LineCap, LineJoin, Node, StrokeAlign, StrokeLayer, Unit, WidthProfile};
use vectorcraft_engine::inspect::StrokeMixed;
use super::{character, current_stroke, pstate, set_pstate, stroke_mixed};
@@ -12,8 +12,34 @@ use crate::theme::Tokens;
use crate::widgets::{self, menu_item};
use crate::{VectorcraftApp, icons};
pub const WEIGHT_PRESETS: [f64; 22] =
/// Stroke weight dropdown presets in points, from the ladder of `unit` (Units > Stroke): each
/// unit has its own ladder of round values in that unit, so the dropdown reads `0.25 mm`, not the
/// `0.088 mm` a converted pt ladder gives. Feet, yards and meters, which no stroke is measured
/// in, keep the pt ladder.
pub fn weight_presets(unit: Unit) -> [f64; 22] {
let native = match unit {
Unit::Millimeters => MM_PRESETS,
Unit::Centimeters => CM_PRESETS,
Unit::Inches => IN_PRESETS,
Unit::Pixels => PX_PRESETS,
Unit::Picas => return PC_PRESETS,
Unit::Points | Unit::FeetInches | Unit::Feet | Unit::Meters | Unit::Yards => return PT_PRESETS,
};
native.map(|v| unit.to_pt(v))
}
const PT_PRESETS: [f64; 22] =
[0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0];
const MM_PRESETS: [f64; 22] = [0.1, 0.25, 0.35, 0.5, 0.75, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 15.0, 20.0, 25.0, 30.0];
const CM_PRESETS: [f64; 22] = [0.01, 0.02, 0.03, 0.05, 0.06, 0.07, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0];
const IN_PRESETS: [f64; 22] =
[0.0078, 0.0156, 0.0313, 0.0625, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0];
const PX_PRESETS: [f64; 22] =
[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 30.0, 40.0];
/// The pica ladder (0p1 … 5p) in points, so every entry is an exact pt weight. The dropdown shows
/// them as decimal picas (`0.083 p`) until [`Unit::number`] writes `0p1` notation.
const PC_PRESETS: [f64; 22] =
[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 15.0, 18.0, 21.0, 24.0, 27.0, 30.0, 33.0, 36.0, 48.0, 60.0];
/// A profile's points as its silhouette: none (the plain bar) for the uniform stroke.
fn silhouette(points: &[(f64, f64, f64)]) -> Option<&[(f64, f64, f64)]> {
@@ -87,7 +113,8 @@ pub(crate) fn shown_weight(app: &VectorcraftApp, st: Option<&StrokeLayer>, mixed
/// The weight spinner (Stroke panel, Control bar): Units > Stroke, presets, blank when mixed.
pub(crate) fn weight_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, weight: Option<f64>, width: f32) {
if let Some(w) = widgets::spin_field(ui, id, weight, app.session.stroke_unit(), width, 1.0, 0.0, &WEIGHT_PRESETS) {
let unit = app.session.stroke_unit();
if let Some(w) = widgets::spin_field(ui, id, weight, unit, width, 1.0, 0.0, &weight_presets(unit)) {
set(app, json!({"weight": w}));
}
}
+29 -3
View File
@@ -32,10 +32,10 @@ fn weights_and_dashes_show_in_the_stroke_unit_everywhere() {
app.session.prefs.units_stroke = "millimeters".into();
run(&mut app, "stroke.set", json!({"dash": [12, 6]}));
let mm = vectorcraft_doc::Unit::Millimeters.format(1.0);
assert_eq!(mm, "0.353 mm");
assert_eq!(mm, "0.3528 mm");
let t = texts(&ctx, &mut app, stroke::show);
assert!(shows(&t, &mm), "Stroke panel: {t:?}");
assert!(shows(&t, "4.233") && shows(&t, "2.117"), "dash and gap in mm: {t:?}");
assert!(shows(&t, "4.2333") && shows(&t, "2.1167"), "dash and gap in mm: {t:?}");
assert!(shows(&texts(&ctx, &mut app, properties::show), &mm), "Properties panel");
assert!(shows(&texts(&ctx, &mut app, crate::chrome::control_bar), &mm), "Control bar");
}
@@ -75,7 +75,8 @@ fn inside_and_outside_are_off_for_open_paths_and_type() {
#[test]
fn the_weight_presets_run_from_a_quarter_point_to_a_hundred() {
assert!(stroke::WEIGHT_PRESETS.contains(&0.25) && stroke::WEIGHT_PRESETS.contains(&100.0));
let pt = stroke::weight_presets(vectorcraft_doc::Unit::Points);
assert!(pt.contains(&0.25) && pt.contains(&100.0));
let ctx = egui::Context::default();
let mut app = app_with_rect();
// The chevron at the right end of the 120 px weight spinner opens them.
@@ -86,3 +87,28 @@ fn the_weight_presets_run_from_a_quarter_point_to_a_hundred() {
let t = texts(&ctx, &mut app, stroke::show);
assert!(shows(&t, "0.25 pt") && shows(&t, "100 pt"), "{t:?}");
}
#[test]
fn weight_presets_display_cleanly_in_their_native_unit() {
use vectorcraft_doc::Unit;
// Round-tripping a native-unit value through to_pt → from_pt → number() must give the same
// string the ladder was authored with: no "0.353 mm" surprises in the dropdown.
for (unit, want) in [
(Unit::Millimeters, ["0.1", "0.25", "0.35", "0.5", "0.75", "1", "30"].as_slice()),
(Unit::Centimeters, ["0.01", "0.05", "0.1", "0.5", "1", "5"].as_slice()),
(Unit::Pixels, ["1", "10", "20", "40"].as_slice()),
(Unit::Inches, ["0.0078", "0.0313", "0.375", "1", "5"].as_slice()),
(Unit::Points, ["0.25", "0.5", "1", "50", "100"].as_slice()),
] {
let presets = stroke::weight_presets(unit);
let shown: Vec<String> = presets.iter().map(|pt| unit.number(*pt)).collect();
for w in want {
assert!(shown.iter().any(|s| s == w), "{unit:?} preset {w:?} missing from {shown:?}");
}
}
// Picas are exact pt weights (0p1 = 1 pt, 1p = 12 pt, 5p = 60 pt).
let pc = stroke::weight_presets(Unit::Picas);
assert!([1.0, 12.0, 60.0].iter().all(|w| pc.contains(w)), "{pc:?}");
// Units no stroke is measured in keep the pt ladder, in points.
assert_eq!(stroke::weight_presets(Unit::Meters), stroke::weight_presets(Unit::Points));
}
+1 -1
View File
@@ -37,7 +37,7 @@ fn general_mm(app: &mut VectorcraftApp) {
#[test]
fn panels_show_millimetres_after_the_preference_changes() {
let mut app = app_with_rect();
assert_eq!(side_mm(), "35.278 mm");
assert_eq!(side_mm(), "35.2778 mm");
assert!(shows(&texts(&mut app, transform::show), "100 pt"), "points first");
general_mm(&mut app);
// The next frame: no restart, nothing cached.