mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 18:10:01 -06:00
.ai import: 6D Art Pen brushes open live, and Windows reads pen tilt and rotation (#372)
Squashed from #5815 onto main.
This commit is contained in:
@@ -16,6 +16,14 @@
|
||||
//! mouse message that Windows did not make from a pen or a touch
|
||||
//! (<https://learn.microsoft.com/en-us/windows/win32/tablet/system-events-and-mouse-messages>)
|
||||
//! clears the feed.
|
||||
//!
|
||||
//! The same `POINTER_PEN_INFO` gives the pen's tilt and barrel rotation (#372: a 6D Art Pen turns a
|
||||
//! Calligraphic brush's nib with its barrel), each only where its `PEN_MASK_*` bit says the pen
|
||||
//! reports it (<https://learn.microsoft.com/en-us/windows/win32/inputmsg/pen-mask-constants>):
|
||||
//! `tiltX` and `tiltY` in degrees −90..90, positive to the right and toward the user, and
|
||||
//! `rotation` in degrees 0..359 clockwise
|
||||
//! (<https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-pointer_pen_info>).
|
||||
//! Toward the user is down the screen, so they are [`PenPose`]'s axes and direction as they come.
|
||||
#![cfg_attr(not(windows), allow(dead_code))]
|
||||
|
||||
use std::cell::RefCell;
|
||||
@@ -37,6 +45,11 @@ const PEN_FLAG_INVERTED: u32 = 0x0002;
|
||||
const PEN_FLAG_ERASER: u32 = 0x0004;
|
||||
/// `PEN_MASK_PRESSURE` (`winuser.h`), in `POINTER_PEN_INFO::penMask`: the pen reports pressure.
|
||||
const PEN_MASK_PRESSURE: u32 = 0x0001;
|
||||
/// `PEN_MASK_ROTATION`, `PEN_MASK_TILT_X` and `PEN_MASK_TILT_Y` (`winuser.h`): the pen reports its
|
||||
/// barrel rotation, its tilt along x and its tilt along y.
|
||||
const PEN_MASK_ROTATION: u32 = 0x0002;
|
||||
const PEN_MASK_TILT_X: u32 = 0x0004;
|
||||
const PEN_MASK_TILT_Y: u32 = 0x0008;
|
||||
/// `MI_WP_SIGNATURE` and `SIGNATURE_MASK`: the extra information of a mouse message that Windows
|
||||
/// made from a pen or a touch.
|
||||
const MI_WP_SIGNATURE: u32 = 0xFF51_5700;
|
||||
@@ -94,7 +107,8 @@ pub fn msg_hook(msg: *const std::ffi::c_void) -> bool {
|
||||
// pointer that is not a pen.
|
||||
unsafe { GetPointerPenInfo((msg.wParam.0 & 0xffff) as u32, &mut info) }.ok()?;
|
||||
let in_contact = info.pointerInfo.pointerFlags.0 & POINTER_FLAG_INCONTACT.0 != 0;
|
||||
Some(pen_sample(info.pressure, info.penMask, info.penFlags, in_contact))
|
||||
let pose = pen_pose(info.penMask, info.tiltX, info.tiltY, info.rotation);
|
||||
Some(pen_sample(info.pressure, info.penMask, info.penFlags, in_contact, pose))
|
||||
};
|
||||
// SAFETY: `GetMessageExtraInfo` reads the extra information of the thread's last message.
|
||||
let from_mouse = || is_mouse(unsafe { GetMessageExtraInfo() }.0 as u32);
|
||||
@@ -106,15 +120,25 @@ pub fn msg_hook(msg: *const std::ffi::c_void) -> bool {
|
||||
|
||||
/// What a pen whose `POINTER_PEN_INFO` holds `pressure` (0..1024), `pen_mask` and `pen_flags`
|
||||
/// reports: the pressure as winit gives it to a touch, 0 while it hovers, a full press for a pen
|
||||
/// in contact that reports none; and the eraser end while it is inverted or its eraser button is
|
||||
/// pressed.
|
||||
fn pen_sample(pressure: u32, pen_mask: u32, pen_flags: u32, in_contact: bool) -> PenSample {
|
||||
/// in contact that reports none; the eraser end while it is inverted or its eraser button is
|
||||
/// pressed; and how it is held, `pose` ([`pen_pose`]).
|
||||
fn pen_sample(pressure: u32, pen_mask: u32, pen_flags: u32, in_contact: bool, pose: PenPose) -> PenSample {
|
||||
let pressure = match pressure {
|
||||
1..=1024 if pen_mask & PEN_MASK_PRESSURE != 0 => pressure as f32 / 1024.0,
|
||||
_ if in_contact => 1.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
PenSample { pressure, eraser: pen_flags & (PEN_FLAG_INVERTED | PEN_FLAG_ERASER) != 0, pose: PenPose::default() }
|
||||
PenSample { pressure, eraser: pen_flags & (PEN_FLAG_INVERTED | PEN_FLAG_ERASER) != 0, pose }
|
||||
}
|
||||
|
||||
/// How a pen whose `POINTER_PEN_INFO` holds `pen_mask`, `tilt_x`, `tilt_y` and `rotation` is
|
||||
/// held: the tilt when the pen reports it along either axis (0 along an axis it doesn't), the
|
||||
/// rotation when it reports one, each kept within [`PenPose`]'s ranges.
|
||||
fn pen_pose(pen_mask: u32, tilt_x: i32, tilt_y: i32, rotation: u32) -> PenPose {
|
||||
let along = |mask: u32, t: i32| if pen_mask & mask != 0 { t as f32 } else { 0.0 };
|
||||
let tilt = (pen_mask & (PEN_MASK_TILT_X | PEN_MASK_TILT_Y) != 0).then(|| [along(PEN_MASK_TILT_X, tilt_x), along(PEN_MASK_TILT_Y, tilt_y)]);
|
||||
let rotation = (pen_mask & PEN_MASK_ROTATION != 0).then_some(rotation as f32);
|
||||
PenPose { tilt, rotation }.sanitized()
|
||||
}
|
||||
|
||||
/// Whether a mouse message with this extra information (`GetMessageExtraInfo`) comes from a mouse,
|
||||
@@ -390,7 +414,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn an_inverted_pen_or_its_eraser_button_is_the_eraser_end() {
|
||||
let eraser = |flags| pen_sample(512, PEN_MASK_PRESSURE, flags, true).eraser;
|
||||
let eraser = |flags| pen_sample(512, PEN_MASK_PRESSURE, flags, true, PenPose::default()).eraser;
|
||||
assert!(!eraser(0), "the tip");
|
||||
assert!(!eraser(PEN_FLAG_BARREL), "the barrel button");
|
||||
assert!(eraser(PEN_FLAG_INVERTED), "turned over");
|
||||
@@ -399,19 +423,32 @@ mod tests {
|
||||
assert!(eraser(PEN_FLAG_INVERTED | PEN_FLAG_BARREL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pens_tilt_and_rotation_come_where_it_reports_them() {
|
||||
let all = PEN_MASK_TILT_X | PEN_MASK_TILT_Y | PEN_MASK_ROTATION;
|
||||
assert_eq!(pen_pose(all, 30, -45, 90), PenPose { tilt: Some([30.0, -45.0]), rotation: Some(90.0) });
|
||||
assert_eq!(pen_pose(PEN_MASK_PRESSURE, 30, -45, 90), PenPose::default(), "a pen that reports neither");
|
||||
assert_eq!(pen_pose(PEN_MASK_TILT_X, 30, -45, 90), PenPose { tilt: Some([30.0, 0.0]), rotation: None }, "one axis");
|
||||
assert_eq!(pen_pose(PEN_MASK_ROTATION, 0, 0, 359), PenPose { tilt: None, rotation: Some(359.0) }, "a 6D Art Pen's barrel");
|
||||
let wild = pen_pose(all, 400, i32::MIN, 720);
|
||||
assert_eq!(wild, PenPose { tilt: Some([90.0, -90.0]), rotation: Some(0.0) }, "kept within the ranges");
|
||||
let pose = pen_pose(all, 10, 20, 180);
|
||||
assert_eq!(pen_sample(512, PEN_MASK_PRESSURE | all, 0, true, pose).pose, pose);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pens_pressure_is_the_one_winit_gives_its_touch() {
|
||||
assert_eq!(pen_sample(512, PEN_MASK_PRESSURE, 0, true).pressure, 0.5);
|
||||
assert_eq!(pen_sample(1024, PEN_MASK_PRESSURE, 0, true).pressure, 1.0);
|
||||
assert_eq!(pen_sample(512, PEN_MASK_PRESSURE, 0, true, PenPose::default()).pressure, 0.5);
|
||||
assert_eq!(pen_sample(1024, PEN_MASK_PRESSURE, 0, true, PenPose::default()).pressure, 1.0);
|
||||
assert_eq!(
|
||||
pen_sample(0, PEN_MASK_PRESSURE, PEN_FLAG_INVERTED, false),
|
||||
pen_sample(0, PEN_MASK_PRESSURE, PEN_FLAG_INVERTED, false, PenPose::default()),
|
||||
PenSample { pressure: 0.0, eraser: true, pose: PenPose::default() },
|
||||
"hovering"
|
||||
);
|
||||
assert_eq!(pen_sample(0, 0, 0, true).pressure, 1.0, "a pen in contact that reports no pressure presses fully");
|
||||
assert_eq!(pen_sample(300, 0, 0, true).pressure, 1.0, "a pressure without the pressure mask is not one");
|
||||
assert_eq!(pen_sample(0, PEN_MASK_PRESSURE, 0, true).pressure, 1.0, "as winit, which gives no force for 0");
|
||||
assert_eq!(pen_sample(2000, PEN_MASK_PRESSURE, 0, false).pressure, 0.0);
|
||||
assert_eq!(pen_sample(0, 0, 0, true, PenPose::default()).pressure, 1.0, "a pen in contact that reports no pressure presses fully");
|
||||
assert_eq!(pen_sample(300, 0, 0, true, PenPose::default()).pressure, 1.0, "a pressure without the pressure mask is not one");
|
||||
assert_eq!(pen_sample(0, PEN_MASK_PRESSURE, 0, true, PenPose::default()).pressure, 1.0, "as winit, which gives no force for 0");
|
||||
assert_eq!(pen_sample(2000, PEN_MASK_PRESSURE, 0, false, PenPose::default()).pressure, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -7,8 +7,18 @@
|
||||
//! - One object, the brush manager's order, has the order of the Brushes panel: its parameters
|
||||
//! are the plug-in name and brush name of each brush, each one after a `/` and a space.
|
||||
//! - A Calligraphic brush's parameters are 11 numbers: the first is 1; the diameter, roundness and
|
||||
//! angle as (low, high) pairs; how the diameter, roundness and angle vary (0 Fixed, 1 Random, 2
|
||||
//! Pressure, other codes the pen's other channels); a last number.
|
||||
//! angle as (low, high) pairs; how the diameter, roundness and angle vary; a last number. The
|
||||
//! codes follow the order of the Brush Options menu: 0 Fixed, 1 Random, 2 Pressure, 3 Stylus
|
||||
//! Wheel, 4 Tilt, 5 Bearing, 6 Rotation. A user's 6D Art Pen brushes (#372) vary the diameter
|
||||
//! with 2, the roundness with 5 and the angle with 6, the angle over a whole turn, as the barrel
|
||||
//! rotation turns the nib. VectorCraft reads no stylus wheel: 3 is read as Random when the value
|
||||
//! varies, as an unknown code is.
|
||||
//! - A path with a Calligraphic brush is a stroke in its art style ([`super::style`]): the filter
|
||||
//! `(Adobe Calligraphic Brush Tool)`, its stroke's paint and weight (the weight scales the brush:
|
||||
//! 1 pt draws it at its own size), the brush by name (`/GObjRef : (name) (plug-in)
|
||||
//! /PluginObject`), and the brush's settings for this path as entries: `Diameter`, `Roundness`,
|
||||
//! `Angle`, their `…Variation` either side and `…DependsOn` codes, and `AngleRelativeTo` (0 the
|
||||
//! page).
|
||||
//! - A Scatter, Art or Pattern brush's parameters are fields split by `/`: a number, the art's
|
||||
//! section names (one for Scatter, five for Art and Pattern, empty where there is none, each
|
||||
//! after a space for Art and Pattern), more fields, and last 15 numbers.
|
||||
@@ -168,12 +178,15 @@ fn numbers(s: &str) -> Option<Vec<f64>> {
|
||||
s.split_whitespace().map(|w| w.parse::<f64>().ok().filter(|v| v.is_finite())).collect()
|
||||
}
|
||||
|
||||
/// How a value varies, by its code: `None` for a code that isn't Fixed, Random or Pressure.
|
||||
/// How a value varies, by its code: `None` for the stylus wheel and codes that aren't known.
|
||||
fn variation(code: f64) -> Option<Variation> {
|
||||
match code {
|
||||
0.0 => Some(Variation::Fixed),
|
||||
1.0 => Some(Variation::Random),
|
||||
2.0 => Some(Variation::Pressure),
|
||||
4.0 => Some(Variation::Tilt),
|
||||
5.0 => Some(Variation::Bearing),
|
||||
6.0 => Some(Variation::Rotation),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -183,6 +196,27 @@ fn mode(code: f64, varies: bool) -> Variation {
|
||||
variation(code).unwrap_or(if varies { Variation::Random } else { Variation::Fixed })
|
||||
}
|
||||
|
||||
/// A Calligraphic brush of angle, roundness and size `values`, each varying `variation` either side
|
||||
/// as its code in `codes` says.
|
||||
pub(super) fn calligraphic(values: [f64; 3], variation: [f64; 3], codes: [f64; 3]) -> Calligraphic {
|
||||
let [angle, roundness, size] = values;
|
||||
let modes = [0, 1, 2].map(|i| mode(codes[i], variation[i] != 0.0));
|
||||
Calligraphic { angle, roundness, size, variation, modes: Some(modes) }
|
||||
}
|
||||
|
||||
/// Does the brush `draft` draw as `c`, the settings a path's art style gives it? A path whose
|
||||
/// settings differ (Options of Selected Object) keeps its drawn look: a stroke names a brush, and
|
||||
/// has no settings of its own.
|
||||
pub(super) fn draws_as(draft: &BrushDraft, c: &Calligraphic) -> bool {
|
||||
let DraftKind::Calligraphic(d) = &draft.kind else { return false };
|
||||
let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0);
|
||||
close(d.angle, c.angle)
|
||||
&& close(d.roundness, c.roundness)
|
||||
&& close(d.size, c.size)
|
||||
&& d.variation.iter().zip(&c.variation).all(|(a, b)| close(*a, *b))
|
||||
&& d.modes() == c.modes()
|
||||
}
|
||||
|
||||
/// The brush named `name` of kind `kind` from its parameters `params`.
|
||||
pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result<BrushDraft, BrushError> {
|
||||
let kind = match kind {
|
||||
@@ -191,15 +225,11 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result<
|
||||
let [_, d0, d1, r0, r1, a0, a1, dm, rm, am, ..] = n.as_slice() else { return Err(BrushError::Values) };
|
||||
let mid = |a: f64, b: f64| (a + b) / 2.0;
|
||||
let half = |a: f64, b: f64| (b - a).abs() / 2.0;
|
||||
let variation = [half(*a0, *a1), half(*r0, *r1), half(*d0, *d1)];
|
||||
let modes = [mode(*am, variation[0] != 0.0), mode(*rm, variation[1] != 0.0), mode(*dm, variation[2] != 0.0)];
|
||||
DraftKind::Calligraphic(Calligraphic {
|
||||
angle: mid(*a0, *a1),
|
||||
roundness: mid(*r0, *r1),
|
||||
size: mid(*d0, *d1),
|
||||
variation,
|
||||
modes: Some(modes),
|
||||
})
|
||||
DraftKind::Calligraphic(calligraphic(
|
||||
[mid(*a0, *a1), mid(*r0, *r1), mid(*d0, *d1)],
|
||||
[half(*a0, *a1), half(*r0, *r1), half(*d0, *d1)],
|
||||
[*am, *rm, *dm],
|
||||
))
|
||||
}
|
||||
Tool::Scatter | Tool::Art | Tool::Pattern => {
|
||||
let fields: Vec<&str> = params.split('/').collect();
|
||||
|
||||
@@ -712,6 +712,7 @@ impl<'a> Reader<'a> {
|
||||
patterns: &self.pattern_tiles,
|
||||
names: &self.global_names,
|
||||
v7_patterns: &self.v7_patterns,
|
||||
brushes: &self.brush_drafts,
|
||||
space,
|
||||
},
|
||||
)
|
||||
@@ -854,6 +855,8 @@ struct StylePaints<'a> {
|
||||
patterns: &'a HashMap<String, Rect>,
|
||||
names: &'a ShownNames,
|
||||
v7_patterns: &'a HashSet<String>,
|
||||
/// The brushes the file defines, which a brushed stroke names.
|
||||
brushes: &'a [super::super::brush::BrushDraft],
|
||||
space: Affine,
|
||||
}
|
||||
|
||||
@@ -950,7 +953,17 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p
|
||||
AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: paints.names.get(Global::Pattern, name).to_string(), xf }))
|
||||
}
|
||||
},
|
||||
StyleItem::Stroke { .. } => stroke_layer(item)?,
|
||||
StyleItem::Stroke { brush, .. } => {
|
||||
// A brushed stroke draws as its look only with the brush the file defines (the
|
||||
// first of its name, as the Brushes panel keeps it) at the object's settings.
|
||||
if let Some((name, settings)) = brush {
|
||||
let draft = paints.brushes.iter().find(|d| &d.name == name)?;
|
||||
if !super::super::brush::draws_as(draft, settings) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
stroke_layer(item)?
|
||||
}
|
||||
};
|
||||
if let Some(look) = st.looks.get(n) {
|
||||
with_look(&mut layer, look);
|
||||
@@ -976,6 +989,8 @@ pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle)
|
||||
for (n, item) in st.items.iter().enumerate() {
|
||||
let mut layer = match item {
|
||||
StyleItem::Fill(paint) => AppearanceItem::Fill(FillLayer::new(paint.clone())),
|
||||
// A graphic style's brush isn't checked against the file's brushes: it is left out.
|
||||
StyleItem::Stroke { brush: Some(_), .. } => return None,
|
||||
StyleItem::Stroke { .. } => stroke_layer(item)?,
|
||||
StyleItem::Gradient { .. } | StyleItem::Pattern { .. } => return None,
|
||||
};
|
||||
@@ -989,9 +1004,10 @@ pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle)
|
||||
|
||||
/// A style's stroke as an appearance item (`None` for a fill).
|
||||
fn stroke_layer(item: &crate::import::ai::style::StyleItem) -> Option<AppearanceItem> {
|
||||
let crate::import::ai::style::StyleItem::Stroke { paint, width, cap, join, miter, dash, profile } = item else { return None };
|
||||
let crate::import::ai::style::StyleItem::Stroke { paint, width, cap, join, miter, dash, profile, brush } = item else { return None };
|
||||
let mut layer = StrokeLayer::new(paint.clone(), line_width(*width));
|
||||
(layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile) = (*cap, *join, *miter, dash.clone(), profile.clone());
|
||||
layer.brush = brush.as_ref().map(|(name, _)| name.clone());
|
||||
Some(AppearanceItem::Stroke(layer))
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,13 @@
|
||||
//! gradient (`Bb` … `flag (name) x y angle length … Bg` … `BB`); `stroke fill Xd` says whether it
|
||||
//! paints at all. A blend filter's `mode opacity isolate knockout shape Xy` is its transparency.
|
||||
//!
|
||||
//! A stroke with a Calligraphic brush is its own basic filter, `(Adobe Calligraphic Brush Tool)`, with
|
||||
//! its stroke's style and the brush's settings for the object as entries (see
|
||||
//! [`super::brush`]).
|
||||
//!
|
||||
//! A style is *plain* when every filter in it is one read here: fills and strokes (in a colour; fills
|
||||
//! with a pattern or gradient too), each with its own transparency and effects, and the effects of
|
||||
//! with a pattern or gradient too; strokes with a Calligraphic brush too), each with its own
|
||||
//! transparency and effects, and the effects of
|
||||
//! [`effect`]. Such objects are read as themselves with an appearance of those fills, strokes and
|
||||
//! effects; others keep their drawn look. Effect › Pathfinder effects (`(Adobe Pathfinder)` in the
|
||||
//! object's chain) are read on a group with no fills or strokes of its own, which keeps its members.
|
||||
@@ -72,6 +77,8 @@ const BAS_RELIEF: &str = "PSAdapter_plugin_BsRl";
|
||||
const SHAPE_EFFECTS: &str = "Adobe Shape Effects"; // brand-ok: the filter's name in the files
|
||||
const PATHFINDER: &str = "Adobe Pathfinder"; // brand-ok: the filter's name in the files
|
||||
|
||||
const CALLIGRAPHIC: &str = "Adobe Calligraphic Brush Tool"; // brand-ok: the filter's and plug-in's name in the files
|
||||
|
||||
const CHAIN: &str = "Chain Style Filter";
|
||||
const STACK: &str = "Stack Style Filter";
|
||||
const FILL: &str = "Fill Style Filter";
|
||||
@@ -174,6 +181,8 @@ pub(super) enum StyleItem {
|
||||
miter: f64,
|
||||
dash: Option<Dash>,
|
||||
profile: Option<WidthProfile>,
|
||||
/// A Calligraphic brush: its name, and its settings for this object.
|
||||
brush: Option<(String, vectorcraft_brush::Calligraphic)>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -231,6 +240,9 @@ struct Filter {
|
||||
gradient_of: Option<(Option<String>, f64)>,
|
||||
/// `(name) … [matrix] p`, and the numbers between the name and the matrix.
|
||||
pattern: Option<(String, Option<[f64; 6]>, Vec<f64>)>,
|
||||
/// The plug-in object it refers to (`/GObjRef : (name) (plug-in) /PluginObject`): its name and
|
||||
/// plug-in name.
|
||||
plugin_object: Option<(String, String)>,
|
||||
/// `mode opacity … Xy`.
|
||||
xy: Option<(usize, f32)>,
|
||||
/// `… isolate knockout … Xy`.
|
||||
@@ -259,6 +271,7 @@ impl Default for Filter {
|
||||
gradient: false,
|
||||
gradient_of: None,
|
||||
pattern: None,
|
||||
plugin_object: None,
|
||||
xy: None,
|
||||
xy_group: None,
|
||||
}
|
||||
@@ -388,6 +401,13 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> {
|
||||
}
|
||||
}
|
||||
b"Array" => dict_array = Some(vec![]),
|
||||
b"PluginObject" if in_basic => {
|
||||
let mut texts = operands.iter().rev().filter_map(V::text);
|
||||
if let (Some(plugin), Some(name), Some(f)) = (texts.next(), texts.next(), filters.last_mut()) {
|
||||
f.plugin_object = Some((name, plugin));
|
||||
}
|
||||
other_entry = Some(None);
|
||||
}
|
||||
b"Int" | b"Real" | b"Bool" => {
|
||||
// The number right before the type: an earlier one isn't this value.
|
||||
let value = operands.last().and_then(V::num);
|
||||
@@ -1066,13 +1086,46 @@ fn paint_item(f: &Filter) -> Result<Option<StyleItem>, ()> {
|
||||
miter: f.miter,
|
||||
dash: f.dash.clone(),
|
||||
profile: width_profile(&f.arrays),
|
||||
brush: None,
|
||||
})),
|
||||
_ => Err(()),
|
||||
},
|
||||
CALLIGRAPHIC => calligraphic_stroke(f).map(Some).ok_or(()),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A Calligraphic brush's filter `f` as the stroke it paints: `None` when it isn't a stroke in a
|
||||
/// colour, names no Calligraphic brush, or its settings aren't all there as numbers. An angle
|
||||
/// relative to the path isn't read.
|
||||
fn calligraphic_stroke(f: &Filter) -> Option<StyleItem> {
|
||||
let real = |key: &str| entry(f, key, Kind::Real).ok().flatten();
|
||||
let code = |key: &str| entry(f, key, Kind::Int).ok().flatten();
|
||||
if !f.paints.0 || f.gradient || f.pattern.is_some() || f.repeated {
|
||||
return None;
|
||||
}
|
||||
if entry(f, "AngleRelativeTo", Kind::Int).ok()?.unwrap_or(0.0) != 0.0 {
|
||||
return None;
|
||||
}
|
||||
let (name, plugin) = f.plugin_object.as_ref()?;
|
||||
if plugin != CALLIGRAPHIC {
|
||||
return None;
|
||||
}
|
||||
let values = [real("Angle")?, real("Roundness")?, real("Diameter")?];
|
||||
let variation = [real("AngleVariation")?, real("RoundnessVariation")?, real("DiameterVariation")?].map(f64::abs);
|
||||
let codes = [code("AngleDependsOn")?, code("RoundnessDependsOn")?, code("DiameterDependsOn")?];
|
||||
Some(StyleItem::Stroke {
|
||||
paint: f.stroke.clone()?,
|
||||
width: f.width,
|
||||
cap: f.cap,
|
||||
join: f.join,
|
||||
miter: f.miter,
|
||||
dash: None,
|
||||
profile: None,
|
||||
brush: Some((name.clone(), super::brush::calligraphic(values, variation, codes))),
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds an [`ArtStyle`] from a style's filter tree.
|
||||
struct Builder<'a> {
|
||||
filters: &'a [Filter],
|
||||
@@ -1113,7 +1166,7 @@ impl Builder<'_> {
|
||||
let c = &filters[k];
|
||||
match c.name.as_str() {
|
||||
_ if c.compound => {}
|
||||
FILL | STROKE => match paint_item(c) {
|
||||
FILL | STROKE | CALLIGRAPHIC => match paint_item(c) {
|
||||
Ok(Some(StyleItem::Stroke { .. })) => under.strokes = true,
|
||||
Ok(Some(StyleItem::Pattern { .. })) => under.patterns = true,
|
||||
_ => {}
|
||||
@@ -1127,7 +1180,7 @@ impl Builder<'_> {
|
||||
let c = &filters[k];
|
||||
match c.name.as_str() {
|
||||
_ if c.compound => self.plain = false,
|
||||
FILL | STROKE => {
|
||||
FILL | STROKE | CALLIGRAPHIC => {
|
||||
paints += 1;
|
||||
look.visible &= c.visible;
|
||||
match paint_item(c) {
|
||||
@@ -1187,7 +1240,7 @@ fn style(filters: &[Filter]) -> ArtStyle {
|
||||
let f = &filters[id];
|
||||
match f.name.as_str() {
|
||||
STACK if f.compound => b.stack(id, &mut st),
|
||||
FILL | STROKE | CHAIN => {
|
||||
FILL | STROKE | CALLIGRAPHIC | CHAIN => {
|
||||
if let Some((item, look)) = b.item(id) {
|
||||
st.items.push(item);
|
||||
st.looks.push(look);
|
||||
|
||||
@@ -6481,6 +6481,107 @@ fn a_gradients_br_is_not_a_brush() {
|
||||
assert_eq!(g.gradient.stops.len(), 2);
|
||||
}
|
||||
|
||||
/// The Brush Options codes for how a Calligraphic brush's values vary: Tilt, Bearing and Rotation
|
||||
/// are the pen's other channels (#372: a 6D Art Pen brush varies its diameter with the pressure,
|
||||
/// its roundness with the bearing and its angle with the barrel's rotation). The stylus wheel isn't
|
||||
/// read: it varies at random, as an unknown code does.
|
||||
#[test]
|
||||
fn a_calligraphic_brushs_pen_channels_come_in_by_their_codes() {
|
||||
use vectorcraft_brush::Variation::{Bearing, Fixed, Pressure, Random, Rotation, Tilt};
|
||||
let modes = |params: &str| {
|
||||
let d = brush::parse_tool_object(brush::Tool::Calligraphic, "Pen", params).unwrap();
|
||||
let brush::DraftKind::Calligraphic(c) = d.kind else { panic!("{d:?}") };
|
||||
c.modes.unwrap()
|
||||
};
|
||||
assert_eq!(modes("1 51 69 13 57 -346 14 2 5 6 0"), [Rotation, Bearing, Pressure]);
|
||||
assert_eq!(modes("1 2 6 40 60 -30 10 4 4 4 0"), [Tilt, Tilt, Tilt]);
|
||||
assert_eq!(modes("1 2 6 40 60 -30 10 3 3 3 0"), [Random, Random, Random], "the stylus wheel");
|
||||
assert_eq!(modes("1 4 4 50 50 10 10 3 9 9 0"), [Fixed, Fixed, Fixed], "nothing to vary");
|
||||
}
|
||||
|
||||
/// A 6D Art Pen brush: diameter 4 to 8 with the pressure, roundness 30 to 50 with the bearing and
|
||||
/// angle -150 to 210 with the barrel's rotation.
|
||||
const SIX_D: &str = "1 4 8 30 50 -150 210 2 5 6 0";
|
||||
|
||||
/// The style `Inked`: a stroke in red of weight `weight` with the Calligraphic brush `brush` at
|
||||
/// diameter, roundness and angle `settings` (each its value and variation either side), varying by
|
||||
/// the codes `codes`, its angle relative to `relative` (0 the page).
|
||||
fn brushed_style(brush: &str, weight: f64, settings: [(f64, f64); 3], codes: [u8; 3], relative: u8) -> String {
|
||||
let [(d, dv), (r, rv), (a, av)] = settings;
|
||||
let [dm, rm, am] = codes;
|
||||
format!(
|
||||
"%AI9_BeginArtStyles\n/KnownStyle :\n(Inked) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/BasicFilter :\n({CALLIGRAPHIC}) 1 0 /Filter ,\n1 /Visible ,\n(Calligraphic Brush) /Title ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\
|
||||
/StrokeStyle : 0 R\n0 1 1 0 1 0 0 XA\n0 1 0 0 0 Xy\n0 J 0 j {weight} w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\
|
||||
{d} /Real (Diameter) ,\n{dv} /Real (DiameterVariation) ,\n{dm} /Int (DiameterDependsOn) ,\n\
|
||||
{r} /Real (Roundness) ,\n{rv} /Real (RoundnessVariation) ,\n{rm} /Int (RoundnessDependsOn) ,\n\
|
||||
{a} /Real (Angle) ,\n{av} /Real (AngleVariation) ,\n{am} /Int (AngleDependsOn) ,\n{relative} /Int (AngleRelativeTo) ,\n\
|
||||
({brush}) /UnicodeString (DisplayString) ,\n/GObjRef : ({brush}) ({CALLIGRAPHIC}) /PluginObject ; (Brush) ,\n; /Dict ;\n /Part ,\n\
|
||||
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n\
|
||||
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\
|
||||
/FillStyle : 0 O\n0 0 0 0 k\n0 0 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
|
||||
;\n /Part ,\n;\n%AI9_EndArtStyles\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// The settings [`SIX_D`] has, as an art style writes them.
|
||||
const SIX_D_SETTINGS: [(f64, f64); 3] = [(6.0, 2.0), (40.0, 10.0), (30.0, 180.0)];
|
||||
|
||||
/// A brushed path as the file writes it: its drawn look (a filled outline) in a group, then the
|
||||
/// path itself on `%_` lines, unpainted, with the style `Inked`.
|
||||
fn brushed_path() -> String {
|
||||
let line = "20 20 m\n100 60 L\n";
|
||||
format!("0 Ae\nu\n{RED}{}f\nU\n0 0 Xd\n6 () XW\n%_{}n\n1 (Inked) XW\n", rect_path(20.0, 18.0, 100.0, 62.0), line.replace('\n', "\n%_"))
|
||||
}
|
||||
|
||||
/// A path drawn with a Calligraphic brush comes in as the path, its stroke in that brush at its
|
||||
/// weight (the weight scales the brush), not as its drawn look: here a 6D Art Pen brush, whose
|
||||
/// channels the brush keeps (#372).
|
||||
#[test]
|
||||
fn a_path_with_a_calligraphic_brush_comes_in_brushed() {
|
||||
let def = plugin_object(CALLIGRAPHIC, "Six D", SIX_D);
|
||||
let style = brushed_style("Six D", 0.5, SIX_D_SETTINGS, [2, 5, 6], 0);
|
||||
let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path())));
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{a:#?}");
|
||||
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the path, not its look: {a:#?}");
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
|
||||
let st = a[0].appearance.stroke().unwrap();
|
||||
assert_eq!((st.brush.as_deref(), st.width, st.paint.color()), (Some("Six D"), 0.5, Some(Color::rgb(1.0, 0.0, 0.0))));
|
||||
assert!(a[0].appearance.fill().is_none_or(|f| f.paint.is_none()), "{:?}", a[0].appearance);
|
||||
use vectorcraft_brush::{BrushKind, Variation};
|
||||
let lib = vectorcraft_brush::library(&r.doc);
|
||||
let BrushKind::Calligraphic(c) = brush_named(&lib, "Six D") else { panic!("calligraphic") };
|
||||
assert_eq!(c.modes, Some([Variation::Rotation, Variation::Bearing, Variation::Pressure]));
|
||||
// With no pen recorded on the path, the nib is the brush's own at the stroke's weight.
|
||||
let pieces = vectorcraft_brush::stroke_pieces(&r.doc, &lib[0], &a[0].path_data().unwrap().to_bezpath(), st);
|
||||
let height = pieces.iter().filter_map(|n| n.geometric_bounds()).map(|b| b.height()).fold(0.0, f64::max);
|
||||
assert!(height > 40.0 && height < 46.0, "the line's rise and about half the nib's 3 pt: {height}");
|
||||
}
|
||||
|
||||
/// A brushed path keeps its drawn look when its stroke can't be read as the brush: the object's
|
||||
/// own settings differ from the brush's (Options of Selected Object), its angle follows the path,
|
||||
/// or the file defines no brush of its name.
|
||||
#[test]
|
||||
fn a_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() {
|
||||
let def = plugin_object(CALLIGRAPHIC, "Six D", SIX_D);
|
||||
let mut own = SIX_D_SETTINGS;
|
||||
own[0] = (9.0, 2.0);
|
||||
let cases = [
|
||||
("its own settings", def.clone(), brushed_style("Six D", 1.0, own, [2, 5, 6], 0)),
|
||||
("its own modes", def.clone(), brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 4], 0)),
|
||||
("an angle on the path", def.clone(), brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 6], 1)),
|
||||
("no such brush", def, brushed_style("Other", 1.0, SIX_D_SETTINGS, [2, 5, 6], 0)),
|
||||
];
|
||||
for (why, def, style) in cases {
|
||||
let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path())));
|
||||
let a = art(&r.doc);
|
||||
assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{why}: the drawn look, {a:#?}");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{why}: {:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
use read::{GRAPH_PLAIN, GRAPH_UNENDED};
|
||||
|
||||
/// Editing data in the legacy format (version 7 specification, chapter 2): `%AI5_FileFormat 2.0`,
|
||||
|
||||
File diff suppressed because one or more lines are too long
+13
-4
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (`.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -36,6 +36,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-05 | minor | G9, G14: `.ai` paths with a Calligraphic brush open brushed (the brush on the stroke, at its weight) where the object's settings are the brush's own, and the brush's Tilt, Bearing and Rotation codes come in as those channels; Windows reads the pen's tilt and barrel rotation from Windows Ink (#372). Checked against a user's file of 6D Art Pen strokes, used locally; not tried with a pen. Scores and estimates unchanged. |
|
||||
| 2026-11-04 | minor | Contour add-on (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `addon.contour.set` is the command behind it. It is a built-in add-on: Object › Plug-ins › Contour, and its own heading in All Tools, with no default shortcut or toolbar slot. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. |
|
||||
| 2026-11-04 | minor | Contour tool (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `object.contour` is the command behind it. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. |
|
||||
| 2026-11-05 | minor | G12: Hyphenation › Default Language sets the language of new type unless Document Setup chose one, stored as a tag; each run hyphenates in its own language, English only so far, so 2 Preferences options stay unused (#394) |
|
||||
@@ -182,8 +183,14 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
|
||||
- **Done (2026-10-16):** EPS legacy mesh corners and vector opacity masks retained; winding-declared compound member groups keep shared paint. Raster print-page minification no longer causes a false editing-data fallback; comparison limits remain unchanged. Synthetic regressions cover damage and missing artwork. This narrows G9; scores and estimates unchanged.
|
||||
|
||||
- **Done (2026-11-05):** paths with a Calligraphic brush open as paths with the brush on their stroke, at their
|
||||
weight, where the object's brush settings are the brush's own and its angle is on the page (#372, from a
|
||||
user's file of 6D Art Pen strokes: all 455 of its brushed styles), and a Calligraphic brush's Tilt, Bearing and
|
||||
Rotation (codes 4, 5 and 6) come in as those channels instead of Random. Others keep their drawn look.
|
||||
|
||||
- **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead
|
||||
of 14), open speed on large files (#758), live effects, brushes and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). Writing Illustrator's own editing data is out of scope
|
||||
of 14), open speed on large files (#758), live effects, Scatter, Art, Pattern and Bristle brushes on paths, a
|
||||
path's own Calligraphic brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). Writing Illustrator's own editing data is out of scope
|
||||
(undocumented): a PDF-compatible `.ai` opens in Illustrator 2018 without its layers and groups (#868). Save As
|
||||
`.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification
|
||||
instead, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and
|
||||
@@ -548,9 +555,11 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
The pen's eraser end on Windows with Windows Ink, read from the pen's pointer messages; not yet tried with a
|
||||
pen. On Linux under X11 and XWayland and on macOS, the Paintbrush records a pen's tilt, bearing and barrel
|
||||
rotation, and Calligraphic and Scatter brushes vary with them (#764; not yet tried on a Mac with a tablet).
|
||||
On Windows with Windows Ink, the pen's tilt and barrel rotation where the pen reports them (#372; not yet
|
||||
tried with a pen).
|
||||
- **Missing:** pen pressure on native Wayland (#491, #764) — winit 0.30 reports none there; tilt, bearing and
|
||||
barrel rotation on Windows, native Wayland and the web, and for Art, Pattern and Bristle brushes (6D Art Pen,
|
||||
#372); the eraser end on native Wayland.
|
||||
barrel rotation on native Wayland and the web, and for Art, Pattern and Bristle brushes (6D Art Pen, #372);
|
||||
the eraser end on native Wayland.
|
||||
- **Estimate:** 4–10 h, partly waiting on winit 0.31; needs pen hardware to verify.
|
||||
|
||||
## G5 Brushes, symbols and libraries
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hardware parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** patch (Linux: on a Wayland session with a pen attached, Automatic opens the window through XWayland, where the pen draws, #4967) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Windows: the pen's tilt and barrel rotation from Windows Ink, #372) · **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
|
||||
@@ -22,7 +22,7 @@ It reads the pen's tilt and barrel rotation on macOS and on Linux under X11 and
|
||||
| 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) | yes, read from AppKit's tablet events before the window gets them (winit reports none), each move of a stroke with its own pressure, mouse event coalescing off (#852; not yet tried on a Mac) | yes (pointer force via `WM_POINTER`) | under X11 and XWayland (Preferences › Performance › Linux Display Server, #1151, #2028; Automatic uses XWayland when a pen is attached on a Wayland session, #4967), read from XInput2 raw events (#764); **no** on native Wayland, where winit reports none and the pen may not reach the window (#491) | browser pointer events, with coalesced events; Apple Pencil on iPad (hover, palm rejection: fingers navigate after a pen contact until reload) | partial: 2–4 h for Wayland (winit 0.31), plus a check on a Mac |
|
||||
| Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | read from AppKit's tablet events (tilt, and the barrel rotation of a pen that reports one); Calligraphic and Scatter brushes vary with them (#764; not yet tried on a Mac) | no | under X11 and XWayland, read from XInput2 raw events; Calligraphic and Scatter brushes vary with them (#764); **no** on native Wayland | no | partial: Windows, native Wayland and the web read none, and Art, Pattern and Bristle brushes do not use them; 4–8 h after the input layer (#372) |
|
||||
| Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | read from AppKit's tablet events (tilt, and the barrel rotation of a pen that reports one); Calligraphic and Scatter brushes vary with them (#764; not yet tried on a Mac) | with Windows Ink, read from the pen's `POINTER_PEN_INFO` (tilt and barrel rotation where its pen mask says the pen reports them); Calligraphic and Scatter brushes vary with them (#372; not yet tried with a pen) | under X11 and XWayland, read from XInput2 raw events; Calligraphic and Scatter brushes vary with them (#764); **no** on native Wayland | no | partial: native Wayland and the web read none, and Art, Pattern and Bristle brushes do not use them; 3–6 h (#372) |
|
||||
| Pen eraser end | yes | the eraser end (AppKit's tablet proximity events) selects the Eraser tool and the tip brings back the tool used before, as on Linux (#852; not yet tried on a Mac) | with Windows Ink, an inverted pen or its eraser button (`PEN_FLAG_INVERTED`, `PEN_FLAG_ERASER` in the pen's pointer messages) selects the Eraser tool and the tip brings back the tool used before, as on Linux (not yet tried with a pen) | under X11 and XWayland, the eraser end selects the Eraser tool and the tip brings back the tool used before (#764); **no** on native Wayland | a press of the eraser end (Pointer Events eraser button, `button` 5) selects the Eraser tool, and that press erases; a press of the tip brings back the tool used before; Pointer Events report no end while the pen hovers | partial |
|
||||
| Touch gestures (pinch zoom, two-finger pan, rotate view, Touch workspace) | yes (Windows touch, Surface) | trackpad pinch, scroll and rotate | pinch, pan (a second finger cancels the first finger's tool press, #4198); two-finger tap undo, three-finger redo (#585) | same where winit reports touch | eframe until the first pen contact, then Pointer Events (pen and fingers told apart); long press opens the context menu | partial: touch workspace and touch-screen rotation missing, 3–5 h |
|
||||
| Trackpad (smooth scroll, pinch) | yes | yes | precision touchpads | yes | yes | done |
|
||||
@@ -35,6 +35,7 @@ It reads the pen's tilt and barrel rotation on macOS and on Linux under X11 and
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-05 | minor | Windows (#372): with Windows Ink, each pen pointer message's `POINTER_PEN_INFO` gives the pen's tilt (`tiltX`, `tiltY`) and barrel rotation where its pen mask says the pen reports them, written into the canvas's pen feed as on macOS and Linux, so a 6D Art Pen's barrel turns a Calligraphic brush's nib. Unit-tested (`windows_stylus::tests::a_pens_tilt_and_rotation_come_where_it_reports_them`); not yet tried with a pen, so the score is unchanged (estimated) |
|
||||
| 2026-11-01 | patch | Linux (#4967): on a Wayland session, Linux Display Server › Automatic opens the window through XWayland when a pen is attached as the app starts, as X11 (XWayland) When a Pen Is Attached does and as PhotoCraft does; on native Wayland winit 0.30 gets no tablet input and KWin 6.3+ and Mutter emulate no pointer, so the pen moved only the compositor's cursor. `VECTORCRAFT_NATIVE_WAYLAND=1` keeps Wayland. Unit-tested (`xwayland::tests`); not tried with a tablet; score unchanged (estimated) |
|
||||
| 2026-10-30 | patch | Touch (#4198): a second finger landing cancels the tool press the first finger began, also in the same frame (the Pencil's stroke and a Selection move are rolled back, nothing enters the undo history), and a finger landing while another is down presses nothing, so a pinch or two-finger pan only navigates. Headless canvas test `a_second_finger_cancels_the_tool_gesture_the_first_began`; not tried on a touch screen; score unchanged (estimated) |
|
||||
| 2026-10-24 | minor | macOS: an AppKit local event monitor reads each mouse and tablet event's pen pressure and the pen's end before the window gets it and writes them into the canvas's pen feed (#852): each pointer move of a stroke keeps its own pressure, mouse event coalescing is off, and the eraser end selects the Eraser tool. The mapping is unit-tested on every platform and the macOS code type-checks for `aarch64-apple-darwin`; not yet tried on a Mac with a tablet, so the score is unchanged (estimated) |
|
||||
|
||||
Reference in New Issue
Block a user