Stroke: per-unit weight presets so mm/cm/in dropdowns show clean numbers

The Stroke panel weight dropdown converted one pt-based preset list into
whatever Units > Stroke was set to, which produced ugly rounded values like
0.088 mm, 0.265 mm, 10.583 mm from the pt ladder. Each unit now has its own
native ladder (observed from Illustrator's running dropdowns and typed in),
converted to pt once at use. Points, Millimeters, Centimeters, Inches,
Pixels and Picas each get Illustrator's actual values; the exotic Unit
variants (Feet & Inches, Meters, Yards, Feet) fall back to the pt ladder.

Picas round-trip to exact pt weights (1 pt, 12 pt, 60 pt …) but display as
decimal picas until Unit::number learns 0p1 / 1p3 notation; that is a
system-wide formatter change left for a separate PR.

Also bumps Unit::number precision from 3 to 4 decimals so Illustrator's
sub-eighth-inch strokes (0.0078, 0.0156, 0.0313, 0.0625 in) show their
authored values instead of rounding to 0.008 / 0.016 / 0.031 / 0.063.
Trailing zeros are still trimmed, so 1 pt stays "1".

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
raytheengineerandClaude Opus 4.7 authored and bflatastic committed 2026-10-08 12:26:47 +07:00
1 parent 2366000e19
commit 60439d88b9
6 files changed
+94 -12

No files matched your search

+3 -1
View File
@@ -191,7 +191,9 @@ 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));
// Four decimals so inches shows `0.0078`, `0.0156`, `0.0313`, `0.0625` from Illustrator's
// sub-eighth-inch stroke ladder; trailing zeros are trimmed so `1 pt` stays `1`.
let s = format!("{:.4}", self.from_pt(pt));
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".into() } else { s.into() }
}
+4 -2
View File
@@ -60,6 +60,8 @@ 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");
// Inches reach four decimals for Illustrator's sub-eighth-inch stroke ladder.
assert_eq!(Unit::Inches.number(0.5616), "0.0078");
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]
+56 -4
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,58 @@ use crate::theme::Tokens;
use crate::widgets::{self, menu_item};
use crate::{VectorcraftApp, icons};
pub const WEIGHT_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];
/// Stroke weight dropdown presets per unit, in that unit's native values; the dropdown converts
/// each to points for the model. One pt list converted into every unit produced junk like
/// `0.353 mm` for a 1-pt stroke; each unit now has its own ladder so the numbers read cleanly.
pub fn weight_presets(unit: Unit) -> Vec<f64> {
let native: &[f64] = match unit {
Unit::Points => &PT_PRESETS,
Unit::Millimeters => &MM_PRESETS,
Unit::Centimeters => &CM_PRESETS,
Unit::Inches => &IN_PRESETS,
Unit::Pixels => &PX_PRESETS,
Unit::Picas => &PC_PRESETS,
// Feet & Inches, Meters, Yards, Feet keep the pt ladder as a reasonable fallback.
_ => &PT_PRESETS,
};
native.iter().map(|v| unit.to_pt(*v)).collect()
}
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, 12.0, 14.0, 16.0, 18.0, 20.0, 40.0, 60.0, 80.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.325, 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];
/// Picas authored as `points / 12` so each entry round-trips to an exact pt weight (Illustrator's
/// pica ladder is 0p1 … 5p, i.e. 1 pt … 60 pt in 1-pt / 3-pt / 12-pt steps). The dropdown labels
/// read as decimal picas (`0.083 p`) until `Unit::number` learns `ApB` notation.
const PC_PRESETS: [f64; 22] = [
1.0 / 12.0,
2.0 / 12.0,
3.0 / 12.0,
4.0 / 12.0,
5.0 / 12.0,
6.0 / 12.0,
7.0 / 12.0,
8.0 / 12.0,
9.0 / 12.0,
10.0 / 12.0,
11.0 / 12.0,
1.0,
15.0 / 12.0,
18.0 / 12.0,
21.0 / 12.0,
2.0,
27.0 / 12.0,
30.0 / 12.0,
33.0 / 12.0,
3.0,
4.0,
5.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 +137,9 @@ 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();
let presets = weight_presets(unit);
if let Some(w) = widgets::spin_field(ui, id, weight, unit, width, 1.0, 0.0, &presets) {
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::Points, ["0.25", "0.5", "1", "12", "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 round-trip to exact pt values (Illustrator's 0p1=1pt, 1p=12pt, 5p=60pt): display
// formatting is a separate concern, but the underlying weight must be right.
let pc = stroke::weight_presets(Unit::Picas);
for want_pt in [1.0, 12.0, 60.0] {
assert!(pc.iter().any(|pt| (pt - want_pt).abs() < 1e-9), "pica preset {want_pt} pt missing from {pc:?}");
}
}
+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.