diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 370b6fcc78..8be6929356 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -133,6 +133,18 @@ pub fn merge_brush(base: &BrushSettings, patch: &Value, cmd: &str) -> Result Value { _ => (a != b).then(|| b.clone()), } } - diff(&light_json(old, skip), &light_json(new, skip)).unwrap_or_else(|| json!({})) + let mut patch = diff(&light_json(old, skip), &light_json(new, skip)).unwrap_or_else(|| json!({})); + // This readback is derived from the axis flags. Sending it alongside a + // one-axis delta would also overwrite the other axis through the legacy alias. + if let Some(pose) = patch.get_mut("pose").and_then(Value::as_object_mut) { + pose.remove("overrideTilt"); + } + patch } /// Journal hook ([`Session::execute`]): consecutive `tools.setBrush` calls with the same @@ -1142,7 +1160,14 @@ pub fn specs() -> Vec { spec!("brush.presets.setCurrent", "Set Current Brush Preset", r##"{"name":string}"##, always, presets_set_current, true), spec!("brush.defineFromSelection", "Define Brush Preset…", r##"{"name":string}"##, has_selection_and_pixels, define_from_selection, true), spec!("brush.get", "Get Brush", "{}", always, |s, _| Ok(brush_json(&s.tools.brush)), false), - spec!("tools.setBrush", "Set Brush", r##"{"preset":name?,"reset":bool?,…BrushSettings fields (camelCase, deep-merged)}"##, always, set_brush, true), + spec!( + "tools.setBrush", + "Set Brush", + r##"{"preset":name?,"reset":bool?,…BrushSettings fields (camelCase, deep-merged; pose.overrideTiltX/Y:bool; pose.overrideTilt:legacy combined alias)}"##, + always, + set_brush, + true + ), ] } diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index d91e9ec4d7..8ffd008ae1 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -652,6 +652,57 @@ fn live_stroke_equals_the_commit_at_every_zoom_with_smoothing() { } } +#[test] +fn pose_axes_commands_keep_minimal_patches_legacy_alias_and_preset_replay() { + let mut s = session(100, 80); + let input = StrokePoint { tilt_x: 13.0, tilt_y: -27.0, pressure: 0.71, rotation: 67.0, ..Default::default() }; + s.execute("tools.setBrush", json!({"brush": {"pose": {"enabled": true, "tiltX": 45, "tiltY": -18, "overrideTilt": true}}})).unwrap(); + assert_eq!(photocraft_paint::dynamics::apply_pose(&s.tools.brush, input), StrokePoint { tilt_x: 45.0, tilt_y: -18.0, ..input }); + let before = s.tools.brush.clone(); + s.execute("tools.setBrush", json!({"pose": {"overrideTiltX": false}})).unwrap(); + assert_eq!( + photocraft_paint::dynamics::apply_pose(&s.tools.brush, input), + StrokePoint { tilt_y: -18.0, ..input }, + "turning X off retains Y override and live X input" + ); + let mixed = s.tools.brush.clone(); + assert_eq!(brush_patch(&before, &mixed), json!({"pose": {"overrideTiltX": false}}), "the derived combined flag must not enter an axis-only patch"); + let wire = s.execute("brush.get", json!({})).unwrap(); + assert_eq!(wire["pose"]["overrideTiltX"], false); + assert_eq!(wire["pose"]["overrideTiltY"], true); + assert_eq!(wire["pose"]["overrideTilt"], false, "legacy readback reports whether both axes are overridden"); + s.execute("brush.presets.save", json!({"name": "Independent pose"})).unwrap(); + s.execute("tools.setBrush", json!({"brush": {"pose": {"overrideTilt": false}}})).unwrap(); + assert_eq!(photocraft_paint::dynamics::apply_pose(&s.tools.brush, input), input, "legacy false clears both axes even after axis editing"); + s.execute("tools.setBrush", json!({"preset": "Independent pose"})).unwrap(); + assert_eq!(s.tools.brush.pose, mixed.pose, "preset picking preserves independent state"); + // Explicit axis keys win over a legacy combined key in the same command. + s.execute("tools.setBrush", json!({"brush": {"pose": {"overrideTilt": true, "overrideTiltY": false}}})).unwrap(); + assert_eq!(photocraft_paint::dynamics::apply_pose(&s.tools.brush, input), StrokePoint { tilt_x: 45.0, ..input }); + let mut replay = Session::new(); + for (id, params) in &s.journal { + replay.execute(id, params.clone()).unwrap(); + } + assert_eq!(replay.tools.brush, s.tools.brush, "recorded commands reproduce the same axes"); +} + +#[test] +fn pose_axes_invalid_flags_fail_atomically() { + let mut s = session(100, 80); + let before = s.tools.brush.clone(); + let n = s.journal.len(); + for pose in [ + json!({"overrideTiltX": "yes"}), + json!({"overrideTiltY": null}), + json!({"overrideTiltX": true, "overrideTiltY": 1}), + json!({"overrideTilt": "yes", "overrideTiltX": false}), + ] { + assert!(s.execute("tools.setBrush", json!({"brush": {"pose": pose}})).is_err()); + assert_eq!(s.tools.brush, before); + assert_eq!(s.journal.len(), n, "a failed update records no command"); + } +} + #[test] fn new_brush_controls_round_trip_through_set_brush() { let mut s = session(40, 40); diff --git a/crates/io/src/abr_map.rs b/crates/io/src/abr_map.rs index c21800f13d..dfc5e78847 100644 --- a/crates/io/src/abr_map.rs +++ b/crates/io/src/abr_map.rs @@ -472,7 +472,8 @@ impl Mapper<'_> { tilt_y: (num(d, "brushPoseTiltY").unwrap_or(0.0) * 0.9).clamp(-90.0, 90.0) as f32, rotation: num(d, "brushPoseAngle").unwrap_or(0.0).rem_euclid(360.0) as f32, pressure: pct(d, "brushPosePressure").unwrap_or(1.0).clamp(0.0, 1.0), - override_tilt: boolean(d, "overridePoseTiltX").unwrap_or(false) || boolean(d, "overridePoseTiltY").unwrap_or(false), + override_tilt_x: boolean(d, "overridePoseTiltX").unwrap_or(false), + override_tilt_y: boolean(d, "overridePoseTiltY").unwrap_or(false), override_rotation: boolean(d, "overridePoseAngle").unwrap_or(false), override_pressure: boolean(d, "overridePosePressure").unwrap_or(false), }; diff --git a/crates/io/src/abr_map/tests.rs b/crates/io/src/abr_map/tests.rs index 8bea763861..46ac1c5473 100644 --- a/crates/io/src/abr_map/tests.rs +++ b/crates/io/src/abr_map/tests.rs @@ -128,6 +128,43 @@ fn grit_pattern() -> PsdPattern { } } +#[test] +fn pose_axes_abr_import_preserves_independent_stylus_tilt() { + let input = photocraft_paint::StrokePoint { tilt_x: 13.0, tilt_y: -27.0, pressure: 0.71, rotation: 67.0, wheel: 0.24, time: 73.0, ..Default::default() }; + for subversion in [1, 2] { + for (x, y) in [(false, false), (true, false), (false, true), (true, true)] { + let preset = Descriptor::new("brushPreset") + .with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 47.0)))) + .with("useBrushPose", Value::Boolean(true)) + .with("brushPoseTiltX", prc(50.0)) + .with("brushPoseTiltY", prc(-20.0)) + .with("overridePoseTiltX", Value::Boolean(x)) + .with("overridePoseTiltY", Value::Boolean(y)); + let bytes = write_v6(subversion, &[], &[], &[preset], false).unwrap(); + let imported = read_abr(&bytes, "Pose axes").unwrap(); + assert_eq!(imported.presets.len(), 1); + let b = &imported.presets[0].brush; + assert!(b.pose.enabled); + assert_eq!((b.pose.tilt_x, b.pose.tilt_y), (45.0, -18.0), "ABR percentages retain the W3C degree conversion"); + let mut expected = input; + if x { + expected.tilt_x = 45.0; + } + if y { + expected.tilt_y = -18.0; + } + assert_eq!( + photocraft_paint::dynamics::apply_pose(b, input), + expected, + "v6.{subversion}, X={x}, Y={y}: one axis must not override the other's tablet data" + ); + let stored = serde_json::to_vec(&imported.presets[0]).unwrap(); + let restored: photocraft_paint::BrushPreset = serde_json::from_slice(&stored).unwrap(); + assert_eq!(photocraft_paint::dynamics::apply_pose(&restored.brush, input), expected, "preset persistence retains the independent flags"); + } + } +} + #[test] fn v6_settings_map_onto_brush_sections() { for sub in [1, 2] { @@ -166,7 +203,7 @@ fn v6_settings_map_onto_brush_sections() { assert!(b.transfer.enabled); assert_eq!((b.transfer.opacity.control, b.transfer.opacity.minimum), (Control::PenPressure, 0.1)); assert_eq!((b.transfer.flow.control, b.transfer.flow.jitter), (Control::Fade, 0.15)); - assert!(b.pose.enabled && b.pose.override_rotation && !b.pose.override_tilt); + assert!(b.pose.enabled && b.pose.override_rotation && !b.pose.override_tilt_x && !b.pose.override_tilt_y); assert_eq!((b.pose.tilt_x, b.pose.rotation), (45.0, 90.0)); assert!(b.wet_edges && b.noise && b.build_up); assert_eq!((b.opacity, b.flow, b.smoothing.amount, b.pressure_size), (0.8, 0.6, 0.4, true)); diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index 0dd4d21d65..7bf78ac6ee 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -405,9 +405,9 @@ pub struct SectionLocks { pub protect_texture: bool, } -/// Brush Pose: stylus values used instead of (override) or for missing device data. +/// Brush Pose: fixed values used for the selected stylus-component overrides. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] +#[serde(from = "PoseWire", into = "PoseWire")] pub struct Pose { pub enabled: bool, /// Degrees, -90..90 (W3C Pointer Events convention). @@ -417,7 +417,8 @@ pub struct Pose { pub rotation: f32, /// 0..1. pub pressure: f32, - pub override_tilt: bool, + pub override_tilt_x: bool, + pub override_tilt_y: bool, pub override_rotation: bool, pub override_pressure: bool, } @@ -430,13 +431,88 @@ impl Default for Pose { tilt_y: 0.0, rotation: 0.0, pressure: 1.0, - override_tilt: false, + override_tilt_x: false, + override_tilt_y: false, override_rotation: false, override_pressure: false, } } } +// Old presets used one combined flag. Keep that JSON input and readback while the +// model has only the two independent sources of truth. Explicit axis fields win. +#[derive(Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct PoseWire { + enabled: bool, + tilt_x: f32, + tilt_y: f32, + rotation: f32, + pressure: f32, + override_tilt: bool, + #[serde(default, deserialize_with = "pose_axis_bool")] + override_tilt_x: Option, + #[serde(default, deserialize_with = "pose_axis_bool")] + override_tilt_y: Option, + override_rotation: bool, + override_pressure: bool, +} + +// Missing axis keys migrate from the old flag; a present null/non-boolean is an error. +fn pose_axis_bool<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + bool::deserialize(d).map(Some) +} + +impl Default for PoseWire { + fn default() -> Self { + Self { + enabled: false, + tilt_x: 0.0, + tilt_y: 0.0, + rotation: 0.0, + pressure: 1.0, + override_tilt: false, + override_tilt_x: None, + override_tilt_y: None, + override_rotation: false, + override_pressure: false, + } + } +} + +impl From for Pose { + fn from(p: PoseWire) -> Self { + Self { + enabled: p.enabled, + tilt_x: p.tilt_x, + tilt_y: p.tilt_y, + rotation: p.rotation, + pressure: p.pressure, + override_tilt_x: p.override_tilt_x.unwrap_or(p.override_tilt), + override_tilt_y: p.override_tilt_y.unwrap_or(p.override_tilt), + override_rotation: p.override_rotation, + override_pressure: p.override_pressure, + } + } +} + +impl From for PoseWire { + fn from(p: Pose) -> Self { + Self { + enabled: p.enabled, + tilt_x: p.tilt_x, + tilt_y: p.tilt_y, + rotation: p.rotation, + pressure: p.pressure, + override_tilt: p.override_tilt_x && p.override_tilt_y, + override_tilt_x: Some(p.override_tilt_x), + override_tilt_y: Some(p.override_tilt_y), + override_rotation: p.override_rotation, + override_pressure: p.override_pressure, + } + } +} + /// Stroke smoothing (Photoshop's Smoothing % and its options). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index 284b7ae3b9..77e7183a7f 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -852,6 +852,93 @@ fn smoothing_without_catch_up_stops_while_the_pointer_pauses() { assert_eq!(out.len(), n, "held still: the brush doesn't move"); } +#[test] +fn pose_axes_override_only_the_selected_stylus_components() { + let input = StrokePoint { x: 20.0, y: 30.0, pressure: 0.71, tilt_x: 13.0, tilt_y: -27.0, rotation: 67.0, wheel: 0.24, time: 73.0 }; + for enabled in [false, true] { + for (x, y) in [(false, false), (true, false), (false, true), (true, true)] { + let b: BrushSettings = serde_json::from_value(serde_json::json!({"pose": { + "enabled": enabled, "tiltX": 45, "tiltY": -18, + "overrideTiltX": x, "overrideTiltY": y, + "pressure": 0.4, "rotation": 120 + }})) + .unwrap(); + let mut expected = input; + if enabled && x { + expected.tilt_x = 45.0; + } + if enabled && y { + expected.tilt_y = -18.0; + } + assert_eq!(crate::dynamics::apply_pose(&b, input), expected, "enabled={enabled}, X={x}, Y={y}"); + } + } +} + +#[test] +fn pose_axes_reach_dabs_and_pixels_at_all_rgba_depths() { + let points = [ + StrokePoint { tilt_x: 13.0, tilt_y: -27.0, ..StrokePoint::new(20.0, 30.0, 0.71) }, + StrokePoint { tilt_x: -31.0, tilt_y: 42.0, ..StrokePoint::new(75.0, 45.0, 0.83) }, + ]; + for (x, y) in [(true, false), (false, true), (true, true)] { + let b: BrushSettings = serde_json::from_value(serde_json::json!({ + "size": 20, "roundness": 0.47, "angle": -23, + "pressureSize": false, "smoothing": {"enabled": false}, + "shapeDynamics": {"enabled": true, "brushProjection": true}, + "pose": {"enabled": true, "tiltX": 45, "tiltY": -18, "overrideTiltX": x, "overrideTiltY": y} + })) + .unwrap(); + let expected_points: Vec<_> = + points.iter().map(|p| StrokePoint { tilt_x: if x { 45.0 } else { p.tilt_x }, tilt_y: if y { -18.0 } else { p.tilt_y }, ..*p }).collect(); + let mut expected_brush = b.clone(); + expected_brush.pose.enabled = false; + let actual_dabs = dabs_of(&b, &points); + let expected_dabs = dabs_of(&expected_brush, &expected_points); + assert_eq!(actual_dabs.len(), expected_dabs.len()); + for (index, (actual, expected)) in actual_dabs.iter().zip(&expected_dabs).enumerate() { + assert_eq!(actual, expected, "resolved tilt reaches dab {index}"); + } + for format in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut actual = Surface::new(format); + let mut expected = Surface::new(format); + render_stroke(&mut actual, &b, &points, None, false, 1.0); + render_stroke(&mut expected, &expected_brush, &expected_points, None, false, 1.0); + assert!(actual.rgba(20, 30)[3] > 0.0, "the comparison includes painted pixels"); + for py in 0..80 { + for px in 0..100 { + assert_eq!(actual.rgba(px, py), expected.rgba(px, py), "{format:?}, X={x}, Y={y}, pixel ({px},{py})"); + } + } + } + } +} + +#[test] +fn pose_axes_legacy_combined_override_and_existing_rotation_pressure_are_preserved() { + let input = StrokePoint { tilt_x: 13.0, tilt_y: -27.0, pressure: 0.71, rotation: 67.0, ..Default::default() }; + for enabled in [false, true] { + for legacy in [false, true] { + let b: BrushSettings = serde_json::from_value(serde_json::json!({"pose": { + "enabled": enabled, "tiltX": 45, "tiltY": -18, "overrideTilt": legacy, + "pressure": 0.4, "overridePressure": true, "rotation": 120, "overrideRotation": true + }})) + .unwrap(); + let expected = StrokePoint { + tilt_x: if enabled && legacy { 45.0 } else { input.tilt_x }, + tilt_y: if enabled && legacy { -18.0 } else { input.tilt_y }, + pressure: if enabled { 0.4 } else { input.pressure }, + rotation: if enabled { 120.0 } else { input.rotation }, + ..input + }; + assert_eq!(crate::dynamics::apply_pose(&b, input), expected); + let stored = serde_json::to_vec(&b).unwrap(); + let restored: BrushSettings = serde_json::from_slice(&stored).unwrap(); + assert_eq!(crate::dynamics::apply_pose(&restored, input), expected, "legacy brush settings retain their painting behavior through persistence"); + } + } +} + #[test] fn pose_overrides_stylus_values() { let b = BrushSettings { diff --git a/crates/paint/src/dynamics.rs b/crates/paint/src/dynamics.rs index 25354dd6d5..b90a7c15b3 100644 --- a/crates/paint/src/dynamics.rs +++ b/crates/paint/src/dynamics.rs @@ -644,8 +644,10 @@ impl DualBuilder { pub fn apply_pose(b: &BrushSettings, mut p: StrokePoint) -> StrokePoint { let pose = &b.pose; if pose.enabled { - if pose.override_tilt { + if pose.override_tilt_x { p.tilt_x = pose.tilt_x; + } + if pose.override_tilt_y { p.tilt_y = pose.tilt_y; } if pose.override_rotation { diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 750a9d5506..c8f1d59977 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -104,7 +104,8 @@ fn edit_section(i: usize, b: &mut BrushSettings) { p.enabled = true; p.tilt_x = 45.0; p.tilt_y = -27.0; - p.override_tilt = true; + p.override_tilt_x = true; + p.override_tilt_y = true; p.rotation = 120.0; p.override_rotation = true; p.pressure = 0.4; @@ -560,6 +561,55 @@ fn toggle_value(app: &PhotocraftApp, section: usize) -> bool { brush.pointer(path).and_then(Value::as_bool).expect("the checkbox has a boolean brush setting") } +#[test] +fn pose_axes_window_routes_each_checkbox_through_a_minimal_command() { + use egui::accesskit::{Role, Toggled}; + use egui_kittest::kittest::{NodeT, Queryable}; + let mut h = harness(0, 7); + h.state_mut() + .session + .execute( + "tools.setBrush", + json!({"pose": { + "enabled": true, "tiltX": 45, "tiltY": -18, "overrideTilt": true, + "pressure": 0.71, "rotation": 67 + }}), + ) + .unwrap(); + h.run_steps(3); + let before = h.state().session.tools.brush.clone(); + let n = h.state().session.journal.len(); + h.get_by_role_and_label(Role::CheckBox, "Override Tilt X").click(); + h.run_steps(3); + assert_eq!(h.get_by_label("Override Tilt X").accesskit_node().toggled(), Some(Toggled::False)); + assert_eq!(h.get_by_label("Override Tilt Y").accesskit_node().toggled(), Some(Toggled::True)); + assert_eq!(h.state().session.journal.len(), n + 1); + let (id, params) = last_journal(h.state()).unwrap(); + assert_eq!(id, "tools.setBrush"); + assert_eq!(params["brush"], json!({"pose": {"overrideTiltX": false}})); + let mut expected = serde_json::to_value(before).unwrap(); + expected["pose"]["overrideTiltX"] = json!(false); + expected["pose"]["overrideTilt"] = json!(false); + assert_eq!(serde_json::to_value(&h.state().session.tools.brush).unwrap(), expected); + h.get_by_role_and_label(Role::CheckBox, "Override Tilt Y").click(); + h.run_steps(3); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"pose": {"overrideTiltY": false}})); + let before = h.state().session.tools.brush.clone(); + h.state_mut().session.execute("tools.setBrush", json!({"pose": {"enabled": false}})).unwrap(); + h.run_steps(3); + let n = h.state().session.journal.len(); + for label in ["Override Tilt X", "Override Tilt Y"] { + let node = h.get_by_role_and_label(Role::CheckBox, label); + assert!(node.accesskit_node().is_disabled()); + node.click(); + h.run_steps(2); + } + let mut expected = before; + expected.pose.enabled = false; + assert_eq!(h.state().session.tools.brush, expected); + assert_eq!(h.state().session.journal.len(), n, "a disabled pose records no checkbox update"); +} + #[test] fn hue_jitter_amount_edits_keep_percentage_units_and_command_routing() { use egui::accesskit::Role; diff --git a/crates/ui-egui/src/brush_sections.rs b/crates/ui-egui/src/brush_sections.rs index ff7ca5d292..9dce174070 100644 --- a/crates/ui-egui/src/brush_sections.rs +++ b/crates/ui-egui/src/brush_sections.rs @@ -554,8 +554,10 @@ fn tilt(ui: &mut egui::Ui, label: &str, deg: &mut f32) { fn pose(ui: &mut egui::Ui, b: &mut BrushSettings) { let p = &mut b.pose; tilt(ui, tl!("Tilt X"), &mut p.tilt_x); + widgets::checkbox(ui, &mut p.override_tilt_x, tl!("Override Tilt X")); + ui.add_space(8.0); tilt(ui, tl!("Tilt Y"), &mut p.tilt_y); - widgets::checkbox(ui, &mut p.override_tilt, tl!("Override Tilt")); + widgets::checkbox(ui, &mut p.override_tilt_y, tl!("Override Tilt Y")); ui.add_space(8.0); num(ui, tl!("Rotation"), &mut p.rotation, 0.0..=360.0, "°"); widgets::checkbox(ui, &mut p.override_rotation, tl!("Override Rotation")); @@ -645,6 +647,40 @@ mod tests { h } + #[test] + fn pose_axes_controls_are_independent_and_below_each_tilt_in_all_themes() { + use egui::accesskit::Toggled; + for kind in theme::ThemeKind::ALL { + let b: BrushSettings = serde_json::from_value(serde_json::json!({"pose": { + "enabled": true, "tiltX": 45.0, "tiltY": -18.0, + "overrideTiltX": true, "overrideTiltY": false, + "pressure": 0.71, "rotation": 67.0 + }})) + .unwrap(); + let mut h = section_harness(7, b.clone()); + crate::PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + let x = h.get_by_role_and_label(Role::CheckBox, "Override Tilt X"); + let y = h.get_by_role_and_label(Role::CheckBox, "Override Tilt Y"); + assert_eq!(x.accesskit_node().toggled(), Some(Toggled::True)); + assert_eq!(y.accesskit_node().toggled(), Some(Toggled::False)); + assert!(h.get_by_label("Tilt X").rect().bottom() < x.rect().top() && x.rect().bottom() < h.get_by_label("Tilt Y").rect().top()); + assert!(h.get_by_label("Tilt Y").rect().bottom() < y.rect().top() && y.rect().bottom() < h.get_by_label("Rotation").rect().top()); + assert_eq!(h.state(), &b, "drawing preserves pose degrees and flags"); + y.focus(); + h.run_steps(1); + h.key_press(egui::Key::Space); + h.run_steps(2); + let wire = serde_json::to_value(h.state()).unwrap(); + assert_eq!(wire["pose"]["overrideTiltX"], true); + assert_eq!(wire["pose"]["overrideTiltY"], true); + let mut expected = serde_json::to_value(b).unwrap(); + expected["pose"]["overrideTiltY"] = serde_json::json!(true); + expected["pose"]["overrideTilt"] = serde_json::json!(true); + assert_eq!(wire, expected, "Space changes only Y, plus the derived legacy readback"); + } + } + /// #3825: jitter is an amount, not a hue selector; it uses the themed amount track. #[test] fn hue_jitter_paints_an_amount_track_in_every_theme() { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 805c20f76a..6335d6b3bc 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1343,6 +1343,8 @@ Override Pressure Přepsat přítlak Override Rotation Přepsat natočení Override Tilt Přepsat náklon + Override Tilt X Přepsat náklon X + Override Tilt Y Přepsat náklon Y PRESET DETAILS PODROBNOSTI PŘEDNASTAVENÍ PSD PSD Paint Overlay Překryv malby diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 88ef957675..bc7b04f676 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1326,6 +1326,8 @@ Override Pressure Druck überschreiben Override Rotation Rotation überschreiben Override Tilt Neigung überschreiben + Override Tilt X Neigung X überschreiben + Override Tilt Y Neigung Y überschreiben PRESET DETAILS VORGABENDETAILS PSD PSD Paint Overlay Übermalung diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e8889313cb..0df2dc0c2f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -797,6 +797,8 @@ Override Pressure Παράκαμψη πίεσης Override Rotation Παράκαμψη περιστροφής Override Tilt Παράκαμψη κλίσης + Override Tilt X Παράκαμψη κλίσης X + Override Tilt Y Παράκαμψη κλίσης Y PRESET DETAILS ΛΕΠΤΟΜΕΡΕΙΕΣ ΠΡΟΕΠΙΛΟΓΗΣ Pattern Size Μέγεθος μοτίβου Perspective Warp is not active Ο μετασχηματισμός προοπτικής δεν είναι ενεργός diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 37f88f4294..a19c7d9789 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1332,6 +1332,8 @@ Override Pressure Anular presión Override Rotation Anular rotación Override Tilt Anular inclinación + Override Tilt X Anular inclinación X + Override Tilt Y Anular inclinación Y PRESET DETAILS DETALLES DEL AJUSTE PREESTABLECIDO PSD PSD Paint Overlay Superposición de pintura diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f7d508fb5c..b56208864c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1312,6 +1312,8 @@ Override Pressure Forcer la pression Override Rotation Forcer la rotation Override Tilt Forcer l'inclinaison + Override Tilt X Forcer l'inclinaison X + Override Tilt Y Forcer l'inclinaison Y PRESET DETAILS DÉTAILS DU PRÉRÉGLAGE PSD PSD Paint Overlay Superposition de peinture diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 70c2294205..474f664f2b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1214,6 +1214,8 @@ Override Pressure Nyomás felülbírálása Override Rotation Elforgatás felülbírálása Override Tilt Dőlés felülbírálása + Override Tilt X X dőlés felülbírálása + Override Tilt Y Y dőlés felülbírálása Paints from the document's opening state A dokumentum megnyitáskori állapotából fest Paper Papír Paragraph Bekezdés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index dcc02ec185..a4b101ebc3 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1306,6 +1306,8 @@ Override Pressure Timpa Tekanan Override Rotation Timpa Rotasi Override Tilt Timpa Kemiringan + Override Tilt X Timpa Kemiringan X + Override Tilt Y Timpa Kemiringan Y PRESET DETAILS DETAIL PRESET PSD PSD Paint Overlay Overlay Cat diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c077d90aa1..87dc88d3cc 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1328,6 +1328,8 @@ Override Pressure Sovrascrivi pressione Override Rotation Sovrascrivi rotazione Override Tilt Sovrascrivi inclinazione + Override Tilt X Sovrascrivi inclinazione X + Override Tilt Y Sovrascrivi inclinazione Y PRESET DETAILS DETTAGLI PREDEFINITO PSD PSD Paint Overlay Sovrapposizione pittura diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9f074e3e30..96d3b5939c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1331,6 +1331,8 @@ Override Pressure 筆圧を上書き Override Rotation 回転を上書き Override Tilt 傾きを上書き + Override Tilt X 傾き X を上書き + Override Tilt Y 傾き Y を上書き PRESET DETAILS プリセットの詳細 PSD PSD Paint Overlay ペイントオーバーレイ diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index eefa162a65..0b541ef7ff 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1303,6 +1303,8 @@ Override Pressure 필압 재정의 Override Rotation 회전 재정의 Override Tilt 기울기 재정의 + Override Tilt X X축 기울기 재정의 + Override Tilt Y Y축 기울기 재정의 PRESET DETAILS 사전 설정 세부 정보 PSD PSD Paint Overlay 오버레이 칠하기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 68e98a6fef..e7f1b38204 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1312,6 +1312,8 @@ Override Pressure Druk overschrijven Override Rotation Rotatie overschrijven Override Tilt Kanteling overschrijven + Override Tilt X Kanteling X overschrijven + Override Tilt Y Kanteling Y overschrijven PRESET DETAILS DETAILS VOORINSTELLING PSD PSD Paint Overlay Verfbedekking diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 2d76183794..21f4b6d7d4 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1327,6 +1327,8 @@ Override Pressure Zastąp nacisk Override Rotation Zastąp obrót Override Tilt Zastąp nachylenie + Override Tilt X Zastąp nachylenie X + Override Tilt Y Zastąp nachylenie Y PRESET DETAILS SZCZEGÓŁY USTAWIENIA WSTĘPNEGO PSD PSD Paint Overlay Nakładka malowania diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0d48ba9b47..3ff8c1075e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1308,6 +1308,8 @@ Override Pressure Substituir pressão Override Rotation Substituir rotação Override Tilt Substituir inclinação + Override Tilt X Substituir inclinação X + Override Tilt Y Substituir inclinação Y PRESET DETAILS DETALHES DA PREDEFINIÇÃO PSD PSD Paint Overlay Sobreposição de pintura diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 11a3cb6b2e..ad09c67ddf 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1057,6 +1057,8 @@ No brush presets Нет наборов кистей Override Pressure Переопределить нажим Override Tilt Переопределить наклон + Override Tilt X Переопределить наклон X + Override Tilt Y Переопределить наклон Y Override Rotation Переопределить поворот Apply the pen's tilt and rotation to the tip shape Применять наклон и поворот пера к форме отпечатка diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f47fe20162..e05143881d 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1291,6 +1291,8 @@ Override Pressure Nadjačaj pritisak Override Rotation Nadjačaj rotaciju Override Tilt Nadjačaj nagib + Override Tilt X Nadjačaj nagib X + Override Tilt Y Nadjačaj nagib Y PRESET DETAILS DETALJI ŠABLONA PSD PSD Paint Overlay Preklop za slikanje diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 948c967467..e0e09c55c8 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1288,6 +1288,8 @@ Override Pressure Basıncı Geçersiz Kıl Override Rotation Döndürmeyi Geçersiz Kıl Override Tilt Eğimi Geçersiz Kıl + Override Tilt X X Eğimini Geçersiz Kıl + Override Tilt Y Y Eğimini Geçersiz Kıl PRESET DETAILS ÖN AYAR DETAYLARI PSD PSD Paint Overlay Boya Kaplaması diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 2a72fdaf19..3ca620436f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1035,6 +1035,8 @@ No brush presets Немає наборів пензлів Override Pressure Перевизначити натиск Override Tilt Перевизначити нахил + Override Tilt X Перевизначити нахил X + Override Tilt Y Перевизначити нахил Y Override Rotation Перевизначити обертання Apply the pen's tilt and rotation to the tip shape Застосувати нахил і обертання пера до форми кінчика diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 88b855a707..75670e1602 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1181,6 +1181,8 @@ Tilt X Độ nghiêng X Tilt Y Độ nghiêng Y Override Tilt Ghi đè nghiêng + Override Tilt X Ghi đè độ nghiêng X + Override Tilt Y Ghi đè độ nghiêng Y Override Rotation Ghi đè xoay Override Pressure Ghi đè áp lực Pressure Áp lực diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2842771124..e20b8569c8 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1341,6 +1341,8 @@ Override Pressure 覆盖压感 Override Rotation 覆盖旋转 Override Tilt 覆盖倾斜 + Override Tilt X 覆盖 X 倾斜 + Override Tilt Y 覆盖 Y 倾斜 PRESET DETAILS 预设详细信息 PSD PSD Paint Overlay 绘制叠加层 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d02e06646e..7158be9012 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1338,6 +1338,8 @@ Override Pressure 覆寫壓力 Override Rotation 覆寫旋轉 Override Tilt 覆寫傾斜 + Override Tilt X 覆寫傾斜 X + Override Tilt Y 覆寫傾斜 Y PRESET DETAILS 預設集詳細資料 PSD PSD Paint Overlay 繪畫覆蓋 diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 9e56022cef..a45e9a76ba 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1598,10 +1598,10 @@ fn checker_tile(ctx: &egui::Context) -> egui::TextureHandle { /// Spectrum-style checkbox (blue when checked). pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { let t = Tokens::get(ui.ctx()); - let mut resp = ui + let (mut resp, label_resp) = ui .horizontal(|ui| { ui.spacing_mut().item_spacing.x = 6.0; - let (rect, resp) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::click()); + let (rect, resp) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::CLICK); let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); let l = ui .add(egui::Label::new(egui::RichText::new(tl!(label)).color(t.text_dim)).selectable(false).sense(Sense::click())) @@ -1609,13 +1609,16 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { // Pressing the label presses the box (#4476). let pressed = resp.is_pointer_button_down_on() || l.is_pointer_button_down_on(); check_box(ui.painter(), rect, *on, resp.hovered() || l.hovered(), pressed, &t); - resp.union(l) + (resp.union(l.clone()), l) }) .inner; if resp.clicked() { *on = !*on; resp.mark_changed(); } + // The label is the single named, focusable checkbox; the box is a mouse target. + // Override Label's metadata instead of creating a second node with the same name. + label_resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), *on, tl!(label))); resp } @@ -1707,7 +1710,10 @@ mod tests { #[test] fn checkbox_label_is_a_button_target_not_selectable_text() { use egui::CursorIcon; - use egui_kittest::{Harness, kittest::Queryable}; + use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, + }; let mut h = Harness::builder().with_size(egui::vec2(300.0, 80.0)).build_ui_state( |ui, on: &mut bool| { @@ -1723,6 +1729,13 @@ mod tests { h.get_by_label("Show Transform Controls").click(); h.run_steps(2); assert!(*h.state()); + let checkbox = h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Show Transform Controls"); + assert_eq!(checkbox.accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + checkbox.focus(); + h.run_steps(1); + h.key_press(egui::Key::Space); + h.run_steps(2); + assert!(!*h.state(), "the same named checkbox supports Space"); } /// #3208: a rounded swatch's checker is one shared, repeating, unblurred two-by-two tile on diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 7a5e499820..bbf8237373 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (independent Brush Pose tilt-axis overrides; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -28,6 +28,7 @@ tablets against Photoshop side by side. | Transfer (opacity/flow/wetness/mix jitter and controls) | done | | | Shape Dynamics and Transfer minimum fields disabled when their Control is Off, retaining their values | done | #3825 (minimum-field interaction only); `brush_sections.rs` UI regressions cover all six fields, reactivation and disabled input | | Brush Pose (tilt, rotation, pressure overrides) | done | | +| Brush Pose: separate Override Tilt X/Y flags, preserved through ABR import, presets and commands | done | #3825 (axis overrides only); imported single-axis flags retain the other live stylus component; dab/pixel tests cover 8/16/32-bit RGBA; legacy combined inputs migrate and command deltas contain only the edited axis. | | Noise, Wet Edges, Build-up, Protect Texture | done | | | Smoothing: amount, Pulled String, Stroke Catch-up, Catch-up on End, Adjust for Zoom | done | `brush.rs:428-438` | | Section locks | done | #174 | @@ -115,6 +116,7 @@ tablets against Photoshop side by side. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Brush Pose tilt overrides are independent end to end (#3825 partial); ABR flags, legacy JSON/commands, presets and three pixel depths covered; no readiness percentage or estimate changed | | 2026-10-10 | major | First version, from a source audit and the open issues | | 2026-10-11 | minor | `~` held with the Brush or Pencil erases with that tool's brush, and Clear joins their Mode menus (#3439) | | 2026-10-12 | minor | Five Brush Settings switch rows toggle without selecting a properties pane; Smoothing has a default-on switch and retains its settings while disabled (#4283). No brush-feel estimate changed. | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 771fec92e9..812d0088d6 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -54,6 +54,8 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.set` also accepts `camTransformOnDrop` (boolean), the Content-Aware Move tool's Transform On Drop checkbox: a drop then puts a scale/rotate box on the patch (`ui.inspect` › `camTransform`: its document/revision, drag `offset` and `frame`), driven with `ui.pointer` (handles as Free Transform's; outside the box rotates), committed with `ui.key Enter` (one `paint.contentAwareMove` with `scaleX`, `scaleY`, `rotate`) or dropped with `Escape`. - `ui.set` also accepts `tileGrid` (boolean), a developer overlay of the active layer's storage tiles: the tile grid over the document, every stored tile filled and labelled `tx,ty`, tiles with identical pixels in a shared colour and unique ones in blue. It has no menu item and View › Extras doesn't hide it. `ui.inspect` reports it as `view.tile_grid`. - Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back. +- Brush Pose's `pose.overrideTiltX` and `pose.overrideTiltY` are independent booleans. For example, `engine.execute {"command":"tools.setBrush","params":{"pose":{"enabled":true,"tiltX":45,"overrideTiltX":true,"overrideTiltY":false}}}` fixes X at 45° while Y follows the stylus. Pose tilts are degrees (±90); the Brush Settings panel displays percentages (±100). Old stored `overrideTilt` values migrate to both axes. In command patches, `overrideTilt` sets both axes unless an explicit axis field in the same patch overrides it. `brush.get` includes both axis flags and the legacy combined readback (`overrideTilt` is true only when both flags are true). Axis-only journals omit that derived combined key so replay retains the other axis. Present flag values must be booleans; null and other types return an error. + - `ui.set` also accepts the Eyedropper options bar (#1649): `eyedropperSampleSize` (`"point"` or 1, 3, 5, 11, 31, 51, 101: the side of the averaged square, shared by the Eyedropper, a painting tool's Alt-click eyedropper, the Curves/Color Picker eyedroppers and the Info panel), `eyedropperSample` (`current`, `currentAndBelow`, `all`, `allNoAdjustments`, `currentAndBelowNoAdjustments`) and `eyedropperRing` (boolean, Show Sampling Ring). `ui.inspect` reports them under `toolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`). The engine command `document.sampleColor {x, y, size, sampleLayer}` returns the colour such a sample picks. - `ui.set` also accepts the Crop tool's overlay menu (#1919): `cropOverlay` (`thirds`, `grid`, `diagonal`, `triangle`, `goldenRatio`, `goldenSpiral`), `cropOverlayShow` (`auto`: only while the crop box is dragged, Photoshop's default; `always`; `never`) and `cropOverlayOrientation` (0..3; Triangle has two orientations and Golden Spiral four, the others ignore it). `ui.inspect` reports them under `toolOptions` (`crop_overlay`, `crop_overlay_show`, `crop_overlay_orientation`). In the app, O cycles the overlay and ⇧O its orientation while the Crop tool shows a box. Both gradient modes use the current preset, including its opacity stops. Select `Foreground to Transparent` through `gradient.presets.select`, or pass custom `stops` and `transparency: [[0,100],[1,0]]` (location 0..1, opacity 0..100). Use `applyToLayer: false` to edit only the tool preset. Classic gradient drags dispatch `paint.gradient` without overriding them. diff --git a/docs/gaps.md b/docs/gaps.md index 25a286aab8..b7044c0498 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (G4: independent Brush Pose tilt-axis overrides, #3825 partial; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -156,6 +156,11 @@ what an agent can't finish alone. - **Estimate:** 250–400 h (many overlap with other gaps; count each fix once). ### G4. Brush feel +- **Progress (2026-10-14, #3825 partial):** Brush Pose has separate X/Y tilt override + controls. ABR import retains both flags independently instead of combining them; + only the selected component replaces live stylus input. Legacy combined JSON and + command inputs retain their two-axis behavior, and minimal command patches preserve + the other axis. Other Brush Settings and brush-feel gaps remain open. - **Progress (2026-10-13, #3825 partial):** Hue Jitter shows a standard themed percentage-amount track rather than a hue spectrum. Other Brush Settings layout and brush-behaviour gaps remain open. @@ -263,6 +268,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | G4: independent Brush Pose X/Y overrides across UI/import/painting/commands (#3825 partial); legacy combined input retained; no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done |