Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 c9281dffa6 Brush Settings panel: Photoshop structure and depth, edits through tools.setBrush
- Sections split into brush_sections.rs: Brush Tip Shape with a tip grid of
  preset tips (relative-size thumbnails), Restore original size, Flip X/Y, a
  draggable angle/roundness ellipse with fields, Hardness (greyed for sampled
  tips), Spacing; Shape Dynamics, Scattering, Texture (pattern swatch, style,
  invert, scale, brightness, contrast, each tip, mode, depth, minimum depth,
  depth jitter), Dual Brush (mode, flip, tip grid, size, spacing, scatter,
  both axes, count), Color Dynamics, Transfer, Brush Pose (tilt now in ±100 %
  of the model's ±90°, was off by 90x), Build-up, Smoothing.
- Control pop-ups offer the direction controls only for Angle; Fade shows its
  step count; disabled sections show their options greyed; clicking a section
  name shows and turns it on.
- Every edit becomes one tools.setBrush with only the changed fields (bitmaps
  skipped unless changed), so the panel is journaled and drivable.
- brush_preview.rs: slot cache keyed on a cheap settings signature; the strip
  and thumbnails re-render only on change and no longer leak a texture per
  slider value.
- Brushes tab: size slider, search, collapsible groups (state in
  UiState.brushes_panel), tip thumbnail + stroke preview + name rows, new /
  delete / import buttons.

Refs #95

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:36:51 -04:00
Brandon ThomasandClaude Opus 5.5 180185a703 tools.setBrush: skip untouched tip/pattern bitmaps; enum variant changes replace
merge_brush no longer base64-round-trips sampled tips and pattern tiles the
patch doesn't name: a Brush Settings edit on a 2500 px imported tip goes from
121 ms to 0.68 ms. A patch switching an externally tagged enum's variant
(texture pattern tile -> procedural) now replaces it instead of merging into
an invalid two-variant object.

Refs #95

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:36:51 -04:00
8 changed files with 1615 additions and 429 deletions

No files matched your search

+34 -2
View File
@@ -84,6 +84,11 @@ pub fn parse_points(p: &Value, cmd: &str) -> Result<Vec<StrokePoint>> {
/// Deep-merge `patch` into `base` (objects merge key by key; anything else replaces).
fn merge(base: &mut Value, patch: &Value) {
match (base, patch) {
// A different variant of an externally tagged enum (`{"tile":…}` → `{"procedural":…}`)
// replaces the old one instead of merging into a two-variant object.
(Value::Object(b), Value::Object(p)) if b.len() == 1 && p.len() == 1 && b.keys().next() != p.keys().next() => {
*b = p.clone();
}
(Value::Object(b), Value::Object(p)) => {
for (k, v) in p {
match b.get_mut(k) {
@@ -100,9 +105,36 @@ fn merge(base: &mut Value, patch: &Value) {
/// Apply a JSON brush patch onto a brush.
pub fn merge_brush(base: &BrushSettings, patch: &Value, cmd: &str) -> Result<BrushSettings> {
let mut v = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?;
// Bitmaps the patch doesn't touch (sampled tips, pattern tiles: up to megabytes of base64) skip
// the JSON round trip, so a Brush Settings slider on an imported tip stays cheap.
let touches = |path: &[&str]| {
let mut v = patch;
for k in path {
match v.get(k) {
Some(x) => v = x,
// A non-object on the way replaces the whole section.
None => return !v.is_object(),
}
}
true
};
let mut light = base.clone();
let tip = (!touches(&["tip"])).then(|| std::mem::take(&mut light.tip));
let dual_tip = (!touches(&["dualBrush", "tip"])).then(|| std::mem::take(&mut light.dual_brush.tip));
let pattern = (!touches(&["texture", "pattern"])).then(|| std::mem::take(&mut light.texture.pattern));
let mut v = serde_json::to_value(&light).map_err(|e| bad(cmd, e.to_string()))?;
merge(&mut v, patch);
serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid brush: {e}")))
let mut out: BrushSettings = serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid brush: {e}")))?;
if let Some(t) = tip {
out.tip = t;
}
if let Some(t) = dual_tip {
out.dual_brush.tip = t;
}
if let Some(p) = pattern {
out.texture.pattern = p;
}
Ok(out)
}
fn find_preset<'a>(s: &'a Session, name: &str, cmd: &str) -> Result<&'a BrushPreset> {
+39
View File
@@ -176,6 +176,45 @@ fn set_brush_merges_fields() {
assert_eq!(s.tools.brush.texture.pattern, chalk_pattern);
}
#[test]
fn set_brush_keeps_untouched_bitmaps_and_replaces_touched_ones() {
let tile = |w, h, k: u32| GrayTile::from_fn(w, h, move |x, y| ((x * k + y) % 5) as f32 / 4.0);
let base = BrushSettings {
tip: TipShape::Sampled(tile(900, 700, 3)),
dual_brush: photocraft_paint::DualBrush { tip: TipShape::Sampled(tile(30, 20, 2)), ..Default::default() },
texture: photocraft_paint::Texture { pattern: photocraft_paint::Pattern::Tile(tile(64, 64, 1)), ..Default::default() },
..Default::default()
};
let cmd = "tools.setBrush";
// Untouched bitmaps survive any patch that doesn't name them.
for patch in [json!({"size": 12}), json!({"dualBrush": {"size": 9}}), json!({"texture": {"depth": 0.5}}), json!({})] {
let b = merge_brush(&base, &patch, cmd).unwrap();
assert_eq!((&b.tip, &b.dual_brush.tip, &b.texture.pattern), (&base.tip, &base.dual_brush.tip, &base.texture.pattern), "{patch}");
}
let b = merge_brush(&base, &json!({"size": 12, "dualBrush": {"size": 9}}), cmd).unwrap();
assert_eq!((b.size, b.dual_brush.size), (12.0, 9.0));
// Named ones are replaced.
let b = merge_brush(
&base,
&json!({"tip": "round", "dualBrush": {"tip": "round"}, "texture": {"pattern": {"procedural": {"style": "dots", "size": 32, "seed": 2}}}}),
cmd,
)
.unwrap();
assert_eq!((&b.tip, &b.dual_brush.tip), (&TipShape::Round, &TipShape::Round));
assert!(matches!(b.texture.pattern, photocraft_paint::Pattern::Procedural { size: 32, .. }));
// A section replaced by a non-object is an error, not a silently kept bitmap.
assert!(merge_brush(&base, &json!({"dualBrush": 3}), cmd).is_err());
assert!(merge_brush(&base, &json!(null), cmd).is_err());
// Speed: a panel edit on a big imported tip doesn't base64 the tip.
let big = BrushSettings { tip: TipShape::Sampled(tile(2500, 2500, 7)), ..Default::default() };
let t0 = std::time::Instant::now();
for i in 0..10 {
merge_brush(&big, &json!({"size": 10 + i}), cmd).unwrap();
}
let ms = t0.elapsed().as_secs_f64() * 100.0;
eprintln!("merge_brush on a 2500 px tip: {ms:.2} ms per edit");
}
#[test]
fn define_brush_from_selection() {
let mut s = session(40, 40);
+325 -427
View File
@@ -1,18 +1,28 @@
//! Window › Brush Settings (F5): Photoshop's floating brush panel. Section list with enable boxes
//! on the left, the selected section's controls on the right, and a live stroke preview below.
//! A second tab lists the brush presets. Edits go straight to the session brush (tool state, like
//! the options bar); presets are applied through `tools.setBrush`.
use std::sync::Arc;
//! Window › Brush Settings (F5) and Window › Brushes: Photoshop's floating brush panel.
//!
//! Brush Settings: the section list with enable boxes on the left (clicking a name shows and turns
//! on that section), the selected section's controls on the right (see [`crate::brush_sections`]),
//! and a live stroke preview strip below, rendered with the real brush engine and cached on the
//! settings (it re-renders only when they change). Brushes: the presets in collapsible groups with
//! tip thumbnails and stroke previews, a size slider and a search field.
//!
//! The panel never writes the session brush itself: each frame's edits become one
//! `tools.setBrush` call carrying only the changed fields (Rule 1), so the journal, the control
//! channel and the MCP server see exactly what the panel did and can do the same.
use egui::{Color32, CornerRadius, RichText, Sense, Stroke, vec2};
use photocraft_engine::BrushSettings;
use photocraft_engine::paint::{self, Control, Dynamic, MaskMode, Pattern, PatternStyle, StrokePoint};
use serde_json::json;
use photocraft_engine::paint::{self, TipShape};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::brush_preview;
use crate::theme::{self, Tokens};
use crate::{PhotocraftApp, icons, widgets};
pub use crate::brush_preview::{preview_pixels, preview_sig};
pub use crate::brush_sections::section_body;
/// Sections in Photoshop's order. The bool says whether the section has an enable box.
pub const SECTIONS: [(&str, bool); 13] = [
("Brush Tip Shape", false),
@@ -30,7 +40,21 @@ pub const SECTIONS: [(&str, bool); 13] = [
("Protect Texture", true),
];
/// The enable flag behind section `i` (None for Brush Tip Shape).
/// Panel width and the stroke strip's size.
const WIDTH: f32 = 540.0;
const STRIP: (u32, u32) = (488, 76);
/// Brushes panel view state (serde, so the control channel can read and drive it).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct BrushesPanelState {
/// Names of collapsed groups.
pub collapsed: Vec<String>,
/// Case-insensitive name filter.
pub filter: String,
}
/// The enable flag behind section `i` (None for Brush Tip Shape and Smoothing).
pub fn section_flag(b: &mut BrushSettings, i: usize) -> Option<&mut bool> {
Some(match i {
1 => &mut b.shape_dynamics.enabled,
@@ -43,324 +67,68 @@ pub fn section_flag(b: &mut BrushSettings, i: usize) -> Option<&mut bool> {
8 => &mut b.noise,
9 => &mut b.wet_edges,
10 => &mut b.build_up,
11 => return None,
12 => &mut b.protect_texture,
_ => return None,
})
}
const CONTROLS: [(Control, &str); 8] = [
(Control::Off, "Off"),
(Control::Fade, "Fade"),
(Control::PenPressure, "Pen Pressure"),
(Control::PenTilt, "Pen Tilt"),
(Control::StylusWheel, "Stylus Wheel"),
(Control::Rotation, "Rotation"),
(Control::InitialDirection, "Initial Direction"),
(Control::Direction, "Direction"),
];
const MASK_MODES: [(MaskMode, &str); 10] = [
(MaskMode::Multiply, "Multiply"),
(MaskMode::Subtract, "Subtract"),
(MaskMode::Darken, "Darken"),
(MaskMode::Overlay, "Overlay"),
(MaskMode::ColorDodge, "Color Dodge"),
(MaskMode::ColorBurn, "Color Burn"),
(MaskMode::LinearBurn, "Linear Burn"),
(MaskMode::HardMix, "Hard Mix"),
(MaskMode::LinearHeight, "Linear Height"),
(MaskMode::Height, "Height"),
];
/// Percent slider over a 0..`max` fraction.
fn pct(ui: &mut egui::Ui, label: &str, v: &mut f32, max: f32) -> bool {
let mut p = *v * 100.0;
let changed = widgets::slider_row(ui, label, &mut p, 0.0..=max * 100.0, "%", None).changed();
if changed {
*v = (p / 100.0).clamp(0.0, max);
/// The brush as JSON with the bitmaps (sampled tips, pattern tiles) that `skip` names replaced by
/// placeholders: they can be megabytes of base64, and skipped ones are equal on both sides.
fn light_json(b: &BrushSettings, skip: (bool, bool, bool)) -> Value {
let mut c = b.clone();
if skip.0 {
c.tip = TipShape::Round;
}
changed
if skip.1 {
c.dual_brush.tip = TipShape::Round;
}
if skip.2 {
c.texture.pattern = paint::Pattern::default();
}
serde_json::to_value(&c).unwrap_or(Value::Null)
}
fn num(ui: &mut egui::Ui, label: &str, v: &mut f32, range: std::ops::RangeInclusive<f32>, suffix: &str) -> bool {
widgets::slider_row(ui, label, v, range, suffix, None).changed()
}
/// A jitter slider plus its Control dropdown (and the Fade step count when fading).
fn dynamic(ui: &mut egui::Ui, id: &str, label: &str, d: &mut Dynamic, max: f32, minimum: Option<&str>) {
let t = Tokens::get(ui.ctx());
pct(ui, label, &mut d.jitter, max);
ui.horizontal(|ui| {
ui.label(RichText::new("Control:").color(t.text_dim));
widgets::dropdown(ui, id, &mut d.control, &CONTROLS, 130.0);
if d.control == Control::Fade {
let mut steps = d.fade_steps as f32;
if widgets::value_field(ui, &mut steps, 1.0..=9999.0, "", 52.0).changed() {
d.fade_steps = steps.round().clamp(1.0, 9999.0) as u32;
/// Fields of `new` that differ from `old`, nested objects diffed key by key: a minimal
/// `tools.setBrush` patch (`{}` when nothing changed).
pub fn brush_patch(old: &BrushSettings, new: &BrushSettings) -> Value {
let skip = (old.tip == new.tip, old.dual_brush.tip == new.dual_brush.tip, old.texture.pattern == new.texture.pattern);
fn diff(a: &Value, b: &Value) -> Option<Value> {
match (a, b) {
(Value::Object(ao), Value::Object(bo)) => {
let mut out = Map::new();
for (k, bv) in bo {
match ao.get(k) {
Some(av) => {
if let Some(d) = diff(av, bv) {
out.insert(k.clone(), d);
}
}
None => {
out.insert(k.clone(), bv.clone());
}
}
}
(!out.is_empty()).then_some(Value::Object(out))
}
}
});
if let Some(m) = minimum {
pct(ui, m, &mut d.minimum, 1.0);
}
ui.add_space(4.0);
}
/// Size: a value field over a logarithmic slider (small sizes get most of the travel).
fn size_row(ui: &mut egui::Ui, label: &str, size: &mut f32, max: f32) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.label(RichText::new(label).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
widgets::value_field(ui, size, 1.0..=max, "px", 74.0);
});
});
let mut lv = size.max(1.0).ln();
if widgets::slider(ui, &mut lv, 0.0..=max.ln(), None).changed() {
*size = lv.exp().round().clamp(1.0, max);
}
ui.add_space(4.0);
}
fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings) {
size_row(ui, "Size", &mut b.size, 5000.0);
ui.horizontal(|ui| {
widgets::checkbox(ui, &mut b.flip_x, "Flip X");
widgets::checkbox(ui, &mut b.flip_y, "Flip Y");
});
num(ui, "Angle", &mut b.angle, -180.0..=180.0, "°");
pct(ui, "Roundness", &mut b.roundness, 1.0);
if matches!(b.tip, paint::TipShape::Round) {
pct(ui, "Hardness", &mut b.hardness, 1.0);
}
let mut spacing_on = b.spacing > 0.0;
widgets::checkbox(ui, &mut spacing_on, "Spacing");
if spacing_on {
pct(ui, "Spacing", &mut b.spacing, 10.0);
b.spacing = b.spacing.max(0.01);
}
}
fn texture(ui: &mut egui::Ui, b: &mut BrushSettings) {
let tx = &mut b.texture;
let t = Tokens::get(ui.ctx());
if let Pattern::Procedural { style, size, .. } = &mut tx.pattern {
ui.horizontal(|ui| {
ui.label(RichText::new("Pattern").color(t.text_dim));
let opts = [(PatternStyle::Paper, "Paper"), (PatternStyle::Canvas, "Canvas"), (PatternStyle::Noise, "Noise"), (PatternStyle::Dots, "Dots")];
widgets::dropdown(ui, "brush-pattern", style, &opts, 110.0);
});
let mut s = *size as f32;
if num(ui, "Pattern Size", &mut s, 16.0..=1024.0, "px") {
*size = s.round() as u32;
}
} else {
ui.label(RichText::new("Custom pattern").color(t.text_faint));
}
widgets::checkbox(ui, &mut tx.invert, "Invert");
pct(ui, "Scale", &mut tx.scale, 10.0);
let mut br = tx.brightness * 150.0;
if num(ui, "Brightness", &mut br, -150.0..=150.0, "") {
tx.brightness = br / 150.0;
}
let mut ct = tx.contrast * 50.0;
if num(ui, "Contrast", &mut ct, -50.0..=100.0, "") {
tx.contrast = ct / 50.0;
}
widgets::checkbox(ui, &mut tx.each_tip, "Texture Each Tip");
ui.horizontal(|ui| {
ui.label(RichText::new("Mode").color(t.text_dim));
widgets::dropdown(ui, "brush-texture-mode", &mut tx.mode, &MASK_MODES, 130.0);
});
pct(ui, "Depth", &mut tx.depth, 1.0);
if tx.each_tip {
dynamic(ui, "brush-depth-ctl", "Depth Jitter", &mut tx.depth_jitter, 1.0, Some("Minimum Depth"));
}
}
fn dual_brush(ui: &mut egui::Ui, b: &mut BrushSettings) {
let t = Tokens::get(ui.ctx());
let d = &mut b.dual_brush;
ui.horizontal(|ui| {
ui.label(RichText::new("Mode").color(t.text_dim));
widgets::dropdown(ui, "brush-dual-mode", &mut d.mode, &MASK_MODES, 130.0);
widgets::checkbox(ui, &mut d.flip, "Flip");
});
size_row(ui, "Size", &mut d.size, 2500.0);
pct(ui, "Hardness", &mut d.hardness, 1.0);
pct(ui, "Spacing", &mut d.spacing, 10.0);
pct(ui, "Scatter", &mut d.scatter, 10.0);
widgets::checkbox(ui, &mut d.both_axes, "Both Axes");
let mut c = d.count as f32;
if num(ui, "Count", &mut c, 1.0..=16.0, "") {
d.count = c.round() as u32;
}
}
fn color_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) {
let c = &mut b.color_dynamics;
widgets::checkbox(ui, &mut c.per_tip, "Apply Per Tip");
dynamic(ui, "brush-fgbg-ctl", "Foreground/Background Jitter", &mut c.fg_bg, 1.0, None);
pct(ui, "Hue Jitter", &mut c.hue_jitter, 1.0);
pct(ui, "Saturation Jitter", &mut c.saturation_jitter, 1.0);
pct(ui, "Brightness Jitter", &mut c.brightness_jitter, 1.0);
let mut p = c.purity * 100.0;
if num(ui, "Purity", &mut p, -100.0..=100.0, "%") {
c.purity = p / 100.0;
}
}
fn pose(ui: &mut egui::Ui, b: &mut BrushSettings) {
let p = &mut b.pose;
let mut tx = p.tilt_x * 100.0;
if num(ui, "Tilt X", &mut tx, -100.0..=100.0, "%") {
p.tilt_x = tx / 100.0;
}
let mut ty = p.tilt_y * 100.0;
if num(ui, "Tilt Y", &mut ty, -100.0..=100.0, "%") {
p.tilt_y = ty / 100.0;
}
widgets::checkbox(ui, &mut p.override_tilt, "Override Tilt");
num(ui, "Rotation", &mut p.rotation, 0.0..=360.0, "°");
widgets::checkbox(ui, &mut p.override_rotation, "Override Rotation");
pct(ui, "Pressure", &mut p.pressure, 1.0);
widgets::checkbox(ui, &mut p.override_pressure, "Override Pressure");
}
fn smoothing(ui: &mut egui::Ui, b: &mut BrushSettings) {
let s = &mut b.smoothing;
pct(ui, "Smoothing", &mut s.amount, 1.0);
widgets::checkbox(ui, &mut s.pulled_string, "Pulled String Mode");
widgets::checkbox(ui, &mut s.catch_up, "Stroke Catch-up");
widgets::checkbox(ui, &mut s.catch_up_on_end, "Catch-up on Stroke End");
widgets::checkbox(ui, &mut s.adjust_for_zoom, "Adjust for Zoom");
}
/// Controls for section `i`.
pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize) {
let t = Tokens::get(ui.ctx());
let note = |ui: &mut egui::Ui, s: &str| {
ui.label(RichText::new(s).color(t.text_faint));
};
match i {
0 => tip_shape(ui, b),
1 => {
let sd = &mut b.shape_dynamics;
dynamic(ui, "brush-size-ctl", "Size Jitter", &mut sd.size, 1.0, Some("Minimum Diameter"));
dynamic(ui, "brush-angle-ctl", "Angle Jitter", &mut sd.angle, 1.0, None);
dynamic(ui, "brush-round-ctl", "Roundness Jitter", &mut sd.roundness, 1.0, Some("Minimum Roundness"));
widgets::checkbox(ui, &mut sd.flip_x_jitter, "Flip X Jitter");
widgets::checkbox(ui, &mut sd.flip_y_jitter, "Flip Y Jitter");
}
2 => {
let sc = &mut b.scattering;
widgets::checkbox(ui, &mut sc.both_axes, "Both Axes");
dynamic(ui, "brush-scatter-ctl", "Scatter", &mut sc.scatter, 10.0, None);
let mut c = sc.count as f32;
if num(ui, "Count", &mut c, 1.0..=16.0, "") {
sc.count = c.round() as u32;
}
dynamic(ui, "brush-count-ctl", "Count Jitter", &mut sc.count_jitter, 1.0, None);
}
3 => texture(ui, b),
4 => dual_brush(ui, b),
5 => color_dynamics(ui, b),
6 => {
let tr = &mut b.transfer;
dynamic(ui, "brush-opacity-ctl", "Opacity Jitter", &mut tr.opacity, 1.0, Some("Minimum"));
dynamic(ui, "brush-flow-ctl", "Flow Jitter", &mut tr.flow, 1.0, Some("Minimum"));
}
7 => pose(ui, b),
8 => note(ui, "Adds grain to the soft edges of the brush tip."),
9 => note(ui, "Watercolour look: lighter interior, darker rims."),
10 => {
note(ui, "Airbrush: keeps painting while the pointer rests.");
num(ui, "Rate", &mut b.build_up_rate, 1.0..=200.0, "/s");
}
11 => smoothing(ui, b),
_ => note(ui, "Keeps the current texture when switching presets."),
}
}
/// A stroke rendered with the brush (scaled down to fit), as RGBA8, for previews and preset rows.
pub fn preview_pixels(b: &BrushSettings, w: u32, h: u32, color: [f32; 4]) -> Vec<u8> {
use photocraft_color::{ColorMode, PixelFormat, SampleType};
let mut brush = b.clone();
let k = ((h as f32 * 0.55) / brush.size.max(1.0)).min(1.0);
brush.size = (brush.size * k).max(1.0);
brush.dual_brush.size = (brush.dual_brush.size * k).max(1.0);
brush.color = color;
brush.erase = false;
brush.seed = 7;
// Photoshop's preview: an S-curve with pressure tapering in and out.
let n = 64;
let points: Vec<StrokePoint> = (0..=n)
.map(|i| {
let s = i as f64 / n as f64;
let x = 10.0 + s * (w as f64 - 20.0);
let y = h as f64 / 2.0 - (s * std::f64::consts::TAU).sin() * h as f64 * 0.22;
StrokePoint::new(x, y, (s * std::f64::consts::PI).sin().max(0.05) as f32)
})
.collect();
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true);
let mut s = photocraft_raster::Surface::new(fmt);
paint::apply_stroke(&mut s, &paint::Stroke { brush, points }, None, false);
let r = photocraft_geom::Rect::new(0, 0, w as i32, h as i32);
let mut px = vec![[0.0f32; 4]; (w * h) as usize];
s.read_rgba_into(r, &mut px);
px.iter().flat_map(|p| p.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect()
}
/// Cheap signature of everything that changes a brush's preview. Sampled tips and pattern tiles
/// contribute their size and a strided sample instead of every pixel, so imported presets with
/// big tips don't serialise megabytes per frame.
pub fn preview_sig(b: &BrushSettings) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
fn js<T: serde::Serialize>(h: &mut DefaultHasher, v: &T) {
serde_json::to_vec(v).unwrap_or_default().hash(h);
}
fn tile(h: &mut DefaultHasher, t: &paint::GrayTile) {
(t.width, t.height).hash(h);
let step = (t.data.len() / 4096).max(1);
t.data.iter().step_by(step).for_each(|v| v.hash(h));
}
fn tip(h: &mut DefaultHasher, t: &paint::TipShape) {
match t {
paint::TipShape::Round => 0u8.hash(h),
paint::TipShape::Sampled(g) => tile(h, g),
// Enums with payloads (tips, patterns) are replaced whole.
_ => (a != b).then(|| b.clone()),
}
}
let mut h = DefaultHasher::new();
js(&mut h, &(b.size, b.hardness, b.spacing, b.opacity, b.flow, b.pressure_size, b.pressure_opacity, b.erase, b.mode, b.angle, b.roundness));
js(&mut h, &(b.flip_x, b.flip_y, b.aliased, b.noise, b.wet_edges, b.build_up, b.build_up_rate, b.protect_texture, b.seed));
js(&mut h, &(&b.shape_dynamics, &b.scattering, &b.color_dynamics, &b.transfer, &b.pose, &b.smoothing));
tip(&mut h, &b.tip);
let d = &b.dual_brush;
js(&mut h, &(d.enabled, d.mode, d.size, d.hardness, d.roundness, d.angle, d.spacing, d.scatter, d.both_axes, d.count, d.flip));
tip(&mut h, &d.tip);
let t = &b.texture;
js(&mut h, &(t.enabled, t.invert, t.scale, t.brightness, t.contrast, t.each_tip, t.mode, t.depth, t.depth_jitter));
match &t.pattern {
Pattern::Tile(g) => tile(&mut h, g),
p => js(&mut h, p),
}
h.finish()
diff(&light_json(old, skip), &light_json(new, skip)).unwrap_or_else(|| json!({}))
}
/// Texture for a brush preview, cached on [`preview_sig`].
fn preview_texture(ui: &egui::Ui, b: &BrushSettings, w: u32, h: u32, color: Color32) -> Arc<egui::TextureHandle> {
let id = egui::Id::new(("brush-preview", preview_sig(b), w, h, color));
if let Some(tex) = ui.data(|d| d.get_temp::<Arc<egui::TextureHandle>>(id)) {
return tex;
/// Send the panel's edits (`before` → `after`) through `tools.setBrush`.
pub fn commit(app: &mut PhotocraftApp, before: &BrushSettings, after: &BrushSettings) {
if before == after {
return;
}
let patch = brush_patch(before, after);
if patch.as_object().is_some_and(Map::is_empty) {
return;
}
if let Err(e) = app.run("tools.setBrush", json!({ "brush": patch })) {
app.ui.status = e;
}
let c = color.to_normalized_gamma_f32();
let px = preview_pixels(b, w, h, [c[0], c[1], c[2], 1.0]);
let img = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &px);
let tex = Arc::new(ui.ctx().load_texture("brush-preview", img, egui::TextureOptions::LINEAR));
ui.data_mut(|d| d.insert_temp(id, tex.clone()));
tex
}
/// Group label for presets saved without a group.
@@ -379,49 +147,135 @@ pub fn grouped_presets(presets: &[paint::BrushPreset]) -> Vec<(String, Vec<usize
out
}
fn presets_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
/// Does the current brush match `preset` (everything but colour and size)? Cheap fields first:
/// the full comparison clones the preset and its tip.
pub fn is_current(preset: &BrushSettings, brush: &BrushSettings) -> bool {
preset.hardness == brush.hardness
&& preset.spacing == brush.spacing
&& preset.tip == brush.tip
&& BrushSettings { color: brush.color, size: brush.size, background: brush.background, ..preset.clone() } == *brush
}
/// A fresh "Brush N" name.
fn new_preset_name(presets: &[paint::BrushPreset]) -> String {
(1..).map(|n| format!("Brush {n}")).find(|n| !presets.iter().any(|p| &p.name == n)).unwrap_or_else(|| "Brush".into())
}
fn run_or_status(app: &mut PhotocraftApp, id: &str, p: Value) {
if let Err(e) = app.run(id, p) {
app.ui.status = e;
}
}
fn full_uv() -> egui::Rect {
egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0))
}
fn brushes_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
// Size of the current brush (Photoshop's Brushes panel slider).
let before = app.session.tools.brush.clone();
let mut b = before.clone();
ui.horizontal(|ui| {
ui.label(RichText::new(format!("{} presets", app.session.tools.presets.len())).color(t.text_faint));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Import Brushes…").on_hover_text("Load Photoshop brushes (.abr)").clicked() {
app.open_dialog_file();
ui.label(RichText::new("Size").color(t.text_dim));
let mut lv = b.size.max(1.0).ln();
ui.add_sized(vec2(WIDTH - 140.0, 18.0), |ui: &mut egui::Ui| {
let r = widgets::slider(ui, &mut lv, 0.0..=5000f32.ln(), None);
if r.changed() {
b.size = lv.exp().round().clamp(1.0, 5000.0);
}
r
});
let mut s = b.size;
if widgets::value_field(ui, &mut s, 1.0..=5000.0, "px", 74.0).changed() {
b.size = s.round().clamp(1.0, 5000.0);
}
});
commit(app, &before, &b);
ui.add_space(4.0);
ui.horizontal(|ui| {
icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "search", 14.0, t.text_faint);
ui.add_space(20.0);
ui.add(egui::TextEdit::singleline(&mut app.ui.brushes_panel.filter).hint_text("Search Brushes").desired_width(WIDTH - 60.0));
});
ui.add_space(6.0);
let filter = app.ui.brushes_panel.filter.trim().to_lowercase();
let groups = grouped_presets(&app.session.tools.presets);
// The current brush matches a preset when everything but its colour and size agrees.
// Cheap fields first: the full comparison clones the preset (and its tip).
let same = |a: &BrushSettings, b: &BrushSettings| {
a.hardness == b.hardness
&& a.spacing == b.spacing
&& a.tip == b.tip
&& BrushSettings { color: b.color, size: b.size, background: b.background, ..a.clone() } == *b
};
let brush = &app.session.tools.brush;
let mut clicked = None;
egui::ScrollArea::vertical().id_salt("brush-presets").max_height(360.0).auto_shrink([false, true]).show(ui, |ui| {
let mut toggle = None;
egui::ScrollArea::vertical().id_salt("brush-presets").max_height(400.0).auto_shrink([false, true]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 1.0;
for (group, items) in groups {
ui.add_space(4.0);
ui.label(RichText::new(&group).font(theme::semibold(12.0)).color(t.text));
ui.add_space(2.0);
let items: Vec<usize> = items
.into_iter()
.filter(|i| filter.is_empty() || app.session.tools.presets.get(*i).is_some_and(|p| p.name.to_lowercase().contains(&filter)))
.collect();
if items.is_empty() {
continue;
}
let open = !filter.is_empty() || !app.ui.brushes_panel.collapsed.contains(&group);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, t.radius_sm, t.hover);
}
let x = r.left() + 4.0;
icons::paint(
ui,
egui::Rect::from_min_size(egui::pos2(x, r.center().y - 7.0), vec2(14.0, 14.0)),
if open { "chevron-down" } else { "chevron-right" },
12.0,
t.text_dim,
);
icons::paint(
ui,
egui::Rect::from_min_size(egui::pos2(x + 18.0, r.center().y - 8.0), vec2(16.0, 16.0)),
if open { "folder-open" } else { "folder" },
14.0,
t.icon,
);
ui.painter().text(egui::pos2(x + 40.0, r.center().y), egui::Align2::LEFT_CENTER, &group, theme::semibold(12.0), t.text);
ui.painter().text(
egui::pos2(r.right() - 8.0, r.center().y),
egui::Align2::RIGHT_CENTER,
items.len().to_string(),
theme::medium(11.0),
t.text_faint,
);
if resp.clicked() {
toggle = Some(group.clone());
}
if !open {
continue;
}
for i in items {
let Some(p) = app.session.tools.presets.get(i) else { continue };
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::click());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click());
if !ui.is_rect_visible(r) {
continue;
}
if same(&p.brush, brush) {
if is_current(&p.brush, brush) {
ui.painter().rect_filled(r, t.radius_sm, t.accent_soft);
} else if resp.hovered() {
ui.painter().rect_filled(r, t.radius_sm, t.hover);
}
let tex = preview_texture(ui, &p.brush, 180, 36, t.text);
let ir = egui::Rect::from_min_size(r.left_top() + vec2(4.0, 2.0), vec2(180.0, 36.0));
ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), Color32::WHITE);
let pb = &p.brush;
let tip = brush_preview::tip_texture(ui.ctx(), &format!("brushes-tip:{}", p.name), &pb.tip, (pb.hardness, pb.angle, pb.roundness), 36, t.text);
let cell = egui::Rect::from_min_size(r.left_top() + vec2(22.0, 2.0), vec2(36.0, 30.0));
let tr = egui::Rect::from_center_size(cell.center(), vec2(30.0, 30.0) * crate::brush_sections::thumb_scale(pb.size));
ui.painter().image(tip.id(), tr, full_uv(), Color32::WHITE);
ui.painter().text(
egui::pos2(ir.right() + 10.0, r.center().y),
egui::pos2(cell.center().x, r.bottom() - 1.0),
egui::Align2::CENTER_BOTTOM,
format!("{}", pb.size.round() as i64),
egui::FontId::proportional(9.0),
t.text_faint,
);
let stroke = brush_preview::stroke_texture(ui.ctx(), &format!("brushes-stroke:{}", p.name), pb, 170, 36, t.text);
let sr = egui::Rect::from_min_size(egui::pos2(cell.right() + 8.0, r.top() + 4.0), vec2(170.0, 36.0));
ui.painter().image(stroke.id(), sr, full_uv(), Color32::WHITE);
ui.painter().text(
egui::pos2(sr.right() + 12.0, r.center().y),
egui::Align2::LEFT_CENTER,
&p.name,
egui::FontId::proportional(12.0),
@@ -431,13 +285,137 @@ fn presets_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
clicked = Some(p.name.clone());
}
}
ui.add_space(2.0);
}
});
if let Some(name) = clicked
&& let Err(e) = app.session.execute("tools.setBrush", json!({"preset": name}))
{
app.ui.status = e.to_string();
if let Some(g) = toggle {
let c = &mut app.ui.brushes_panel.collapsed;
match c.iter().position(|x| *x == g) {
Some(i) => {
c.remove(i);
}
None => c.push(g),
}
}
if let Some(name) = clicked {
run_or_status(app, "tools.setBrush", json!({ "preset": name }));
}
ui.add_space(4.0);
widgets::hairline(ui);
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(RichText::new(format!("{} presets", app.session.tools.presets.len())).color(t.text_faint));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let current = app.session.tools.presets.iter().find(|p| is_current(&p.brush, &app.session.tools.brush)).map(|p| p.name.clone());
if ui.add_enabled_ui(current.is_some(), |ui| icons::button(ui, "trash", 24.0, false, "Delete brush")).inner.clicked()
&& let Some(name) = current
{
run_or_status(app, "brush.presets.delete", json!({ "name": name }));
}
if icons::button(ui, "square-plus", 24.0, false, "Create new brush from the current settings").clicked() {
let name = new_preset_name(&app.session.tools.presets);
run_or_status(app, "brush.presets.save", json!({ "name": name }));
}
if icons::button(ui, "folder-open", 24.0, false, "Import Brushes… (.abr)").clicked() {
app.open_dialog_file();
}
});
});
// Forget previews of presets that no longer exist.
let names: std::collections::HashSet<&str> = app.session.tools.presets.iter().map(|p| p.name.as_str()).collect();
brush_preview::with_cache(ui.ctx(), |c| {
c.retain(|slot| slot.split_once(':').is_none_or(|(_, n)| names.contains(n)));
});
}
/// The section list: enable boxes, names, the selection. Clicking a name shows the section and
/// turns it on (Photoshop); clicking a box only toggles it.
fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSettings) {
let t = Tokens::get(ui.ctx());
ui.vertical(|ui| {
ui.set_width(150.0);
ui.spacing_mut().item_spacing.y = 1.0;
for (i, (name, has_box)) in SECTIONS.iter().enumerate() {
let sel = app.ui.brush_section == i;
let (r, resp) = ui.allocate_exact_size(vec2(150.0, 23.0), Sense::click());
if sel {
ui.painter().rect_filled(r, t.radius_sm, t.accent_soft);
} else if resp.hovered() {
ui.painter().rect_filled(r, t.radius_sm, t.hover);
}
let mut x = r.left() + 6.0;
if *has_box && let Some(flag) = section_flag(b, i) {
let br = egui::Rect::from_center_size(egui::pos2(x + 6.0, r.center().y), vec2(13.0, 13.0));
let box_resp = ui.interact(br, ui.id().with(("brush-sec-box", i)), Sense::click());
if *flag {
ui.painter().rect_filled(br, 2.0, t.accent);
let (a, m, c) = (br.left_center() + vec2(3.0, 0.5), br.center_bottom() + vec2(-1.0, -3.5), br.right_top() + vec2(-3.0, 3.5));
ui.painter().line_segment([a, m], Stroke::new(1.6, Color32::WHITE));
ui.painter().line_segment([m, c], Stroke::new(1.6, Color32::WHITE));
} else {
ui.painter().rect_stroke(br, 2.0, Stroke::new(1.2, t.text_faint), egui::StrokeKind::Inside);
}
if box_resp.clicked() {
*flag = !*flag;
} else if resp.clicked() {
*flag = true;
}
x += 20.0;
} else if i > 0 {
x += 20.0;
}
let color = if sel { t.text } else { t.text_dim };
let font = if i == 0 { theme::semibold(12.0) } else { theme::medium(12.0) };
ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, *name, font, color);
if resp.clicked() {
app.ui.brush_section = i;
}
if i == 0 {
ui.add_space(4.0);
}
}
});
}
fn settings_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let before = app.session.tools.brush.clone();
let mut b = before.clone();
let section = app.ui.brush_section.min(SECTIONS.len() - 1);
ui.horizontal_top(|ui| {
section_list(app, ui, &mut b);
widgets::vline(ui, 482.0);
ui.vertical(|ui| {
ui.set_width(WIDTH - 190.0);
ui.label(RichText::new(SECTIONS[section].0).font(theme::semibold(12.5)).color(t.text));
ui.add_space(6.0);
let on = section_flag(&mut b, section).is_none_or(|f| *f);
egui::ScrollArea::vertical().id_salt(("brush-section", section)).max_height(450.0).auto_shrink([false, false]).show(ui, |ui| {
ui.set_width(WIDTH - 204.0);
// A section that's off shows its options greyed out (Photoshop).
ui.add_enabled_ui(on, |ui| section_body(ui, &mut b, section, &app.session.tools.presets));
});
});
});
ui.add_space(6.0);
widgets::hairline(ui);
ui.add_space(6.0);
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(STRIP.0 as f32, STRIP.1 as f32), Sense::hover());
ui.painter().rect_filled(r, t.radius_sm, t.field);
let tex = brush_preview::stroke_texture(ui.ctx(), "settings-strip", &b, STRIP.0, STRIP.1, t.text);
ui.painter().image(tex.id(), r, full_uv(), Color32::WHITE);
ui.vertical(|ui| {
if icons::button(ui, "square-plus", 24.0, false, "Create new brush from these settings").clicked() {
let name = new_preset_name(&app.session.tools.presets);
run_or_status(app, "brush.presets.save", json!({ "name": name, "brush": serde_json::to_value(&b).unwrap_or(Value::Null) }));
}
if icons::button(ui, "undo-2", 24.0, false, "Reset the brush to the defaults").clicked() {
b = BrushSettings { color: b.color, background: b.background, smoothing: b.smoothing.clone(), ..Default::default() };
}
});
});
commit(app, &before, &b);
}
pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) {
@@ -458,9 +436,9 @@ pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) {
.title_bar(false)
.resizable(false)
.frame(frame)
.default_pos(egui::pos2((canvas.right() - 500.0).max(canvas.left() + 8.0), canvas.top() + 40.0))
.default_pos(egui::pos2((canvas.right() - WIDTH - 30.0).max(canvas.left() + 8.0), canvas.top() + 24.0))
.show(ctx, |ui| {
ui.set_width(470.0);
ui.set_width(WIDTH);
ui.horizontal(|ui| {
let mut tab = app.ui.brush_tab;
for (i, name) in ["Brush Settings", "Brushes"].iter().enumerate() {
@@ -479,65 +457,9 @@ pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) {
widgets::hairline(ui);
ui.add_space(6.0);
if app.ui.brush_tab == 1 {
presets_tab(app, ui);
return;
}
let mut b = app.session.tools.brush.clone();
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(150.0);
ui.spacing_mut().item_spacing.y = 1.0;
for (i, (name, has_box)) in SECTIONS.iter().enumerate() {
let sel = app.ui.brush_section == i;
let (r, resp) = ui.allocate_exact_size(vec2(150.0, 21.0), Sense::click());
if sel {
ui.painter().rect_filled(r, t.radius_sm, t.accent_soft);
} else if resp.hovered() {
ui.painter().rect_filled(r, t.radius_sm, t.hover);
}
let mut x = r.left() + 6.0;
if *has_box && let Some(flag) = section_flag(&mut b, i) {
let br = egui::Rect::from_center_size(egui::pos2(x + 6.0, r.center().y), vec2(12.0, 12.0));
let box_resp = ui.interact(br, ui.id().with(("brush-sec-box", i)), Sense::click());
if *flag {
ui.painter().rect_filled(br, 2.0, t.accent);
ui.painter().text(br.center(), egui::Align2::CENTER_CENTER, "✓", egui::FontId::proportional(10.0), Color32::WHITE);
} else {
ui.painter().rect_stroke(br, 2.0, Stroke::new(1.2, t.text_faint), egui::StrokeKind::Inside);
}
if box_resp.clicked() {
*flag = !*flag;
}
x += 18.0;
} else if i > 0 {
x += 18.0;
}
let color = if sel { t.text } else { t.text_dim };
ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, *name, theme::medium(12.0), color);
if resp.clicked() {
app.ui.brush_section = i;
}
}
});
widgets::vline(ui, 350.0);
ui.vertical(|ui| {
ui.set_width(290.0);
ui.label(RichText::new(SECTIONS[app.ui.brush_section.min(SECTIONS.len() - 1)].0).font(theme::semibold(12.5)).color(t.text));
ui.add_space(4.0);
egui::ScrollArea::vertical().id_salt("brush-section").max_height(330.0).auto_shrink([false, true]).show(ui, |ui| {
section_body(ui, &mut b, app.ui.brush_section);
});
});
});
ui.add_space(6.0);
widgets::hairline(ui);
ui.add_space(6.0);
let tex = preview_texture(ui, &b, 450, 72, t.text);
let (r, _) = ui.allocate_exact_size(vec2(450.0, 72.0), Sense::hover());
ui.painter().rect_filled(r, t.radius_sm, t.field);
ui.painter().image(tex.id(), r, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), Color32::WHITE);
if b != app.session.tools.brush {
app.session.tools.brush = b;
brushes_tab(app, ui);
} else {
settings_tab(app, ui);
}
});
if !open {
@@ -546,29 +468,5 @@ pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sections_with_boxes_have_flags() {
let mut b = BrushSettings::default();
for (i, (_, has_box)) in SECTIONS.iter().enumerate() {
assert_eq!(section_flag(&mut b, i).is_some(), *has_box, "{i}");
}
*section_flag(&mut b, 3).unwrap() = true;
assert!(b.texture.enabled);
}
#[test]
fn preview_draws_a_stroke_that_fits() {
let mut b = BrushSettings { size: 400.0, ..Default::default() };
let px = preview_pixels(&b, 120, 40, [1.0, 0.0, 0.0, 1.0]);
assert_eq!(px.len(), 120 * 40 * 4);
let covered = px.as_chunks::<4>().0.iter().filter(|p| p[3] > 128).count();
assert!(covered > 200 && covered < 120 * 40 / 2, "{covered}");
// Dynamics change the preview.
b.scattering.enabled = true;
b.scattering.scatter.jitter = 3.0;
assert_ne!(preview_pixels(&b, 120, 40, [1.0, 0.0, 0.0, 1.0]), px);
}
}
#[path = "brush_panel_tests.rs"]
mod tests;
+272
View File
@@ -0,0 +1,272 @@
use egui::vec2;
use egui_kittest::Harness;
use photocraft_engine::paint::{Control, GrayTile, MaskMode, Pattern, PatternStyle, TipShape};
use super::*;
fn app() -> PhotocraftApp {
PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())
}
fn last_journal(app: &PhotocraftApp) -> Option<(String, Value)> {
app.session.journal.last().cloned()
}
#[test]
fn sections_with_boxes_have_flags() {
let mut b = BrushSettings::default();
for (i, (_, has_box)) in SECTIONS.iter().enumerate() {
assert_eq!(section_flag(&mut b, i).is_some(), *has_box, "{i}");
}
*section_flag(&mut b, 3).unwrap() = true;
assert!(b.texture.enabled);
}
/// What each section's controls edit, set to non-default values.
fn edit_section(i: usize, b: &mut BrushSettings) {
let dynamic = |jitter: f32, control: Control, minimum: f32| paint::Dynamic { jitter, control, fade_steps: 40, minimum };
match i {
0 => {
b.size = 87.0;
b.flip_x = true;
b.flip_y = true;
b.angle = -35.0;
b.roundness = 0.4;
b.hardness = 0.25;
b.spacing = 0.6;
}
1 => {
let sd = &mut b.shape_dynamics;
sd.enabled = true;
sd.size = dynamic(0.3, Control::Fade, 0.2);
sd.angle = dynamic(0.1, Control::Direction, 0.0);
sd.roundness = dynamic(0.5, Control::PenTilt, 0.25);
sd.flip_x_jitter = true;
sd.flip_y_jitter = true;
}
2 => {
let sc = &mut b.scattering;
sc.enabled = true;
sc.scatter = dynamic(2.5, Control::PenPressure, 0.0);
sc.both_axes = true;
sc.count = 4;
sc.count_jitter = dynamic(0.4, Control::StylusWheel, 0.0);
}
3 => {
let tx = &mut b.texture;
tx.enabled = true;
tx.pattern = Pattern::Procedural { style: PatternStyle::Canvas, size: 200, seed: 1 };
tx.invert = true;
tx.scale = 1.5;
tx.brightness = -0.2;
tx.contrast = 0.6;
tx.each_tip = true;
tx.mode = MaskMode::HardMix;
tx.depth = 0.7;
tx.depth_jitter = dynamic(0.3, Control::Rotation, 0.1);
}
4 => {
let d = &mut b.dual_brush;
d.enabled = true;
d.mode = MaskMode::ColorBurn;
d.flip = true;
d.tip = TipShape::Sampled(GrayTile::from_fn(8, 6, |x, y| ((x + y) % 2) as f32));
d.size = 33.0;
d.hardness = 0.5;
d.spacing = 0.8;
d.scatter = 1.2;
d.both_axes = true;
d.count = 3;
}
5 => {
let c = &mut b.color_dynamics;
c.enabled = true;
c.per_tip = false;
c.fg_bg = dynamic(0.6, Control::Fade, 0.0);
c.hue_jitter = 0.1;
c.saturation_jitter = 0.2;
c.brightness_jitter = 0.3;
c.purity = -0.4;
}
6 => {
b.transfer.enabled = true;
b.transfer.opacity = dynamic(0.5, Control::PenPressure, 0.1);
b.transfer.flow = dynamic(0.25, Control::Fade, 0.3);
}
7 => {
let p = &mut b.pose;
p.enabled = true;
p.tilt_x = 45.0;
p.tilt_y = -27.0;
p.override_tilt = true;
p.rotation = 120.0;
p.override_rotation = true;
p.pressure = 0.4;
p.override_pressure = true;
}
8 => b.noise = true,
9 => b.wet_edges = true,
10 => {
b.build_up = true;
b.build_up_rate = 55.0;
}
11 => {
let s = &mut b.smoothing;
s.amount = 0.45;
s.pulled_string = true;
s.catch_up = false;
s.catch_up_on_end = false;
s.adjust_for_zoom = false;
}
_ => b.protect_texture = true,
}
}
#[test]
fn every_section_round_trips_through_set_brush() {
for (i, (name, _)) in SECTIONS.iter().enumerate() {
let mut app = app();
let before = app.session.tools.brush.clone();
let mut after = before.clone();
edit_section(i, &mut after);
assert_ne!(before, after, "section {i} edits nothing");
commit(&mut app, &before, &after);
assert_eq!(app.session.tools.brush, after, "section {i} ({name})");
// Through the command, with only the changed fields.
let (id, p) = last_journal(&app).expect("journaled");
assert_eq!(id, "tools.setBrush");
let patch = &p["brush"];
assert!(patch.as_object().is_some_and(|o| !o.is_empty() && o.len() <= 7), "section {i}: {patch}");
// And the engine's view (`brush.get`) reads back the same settings.
let got = app.session.execute("brush.get", json!({})).unwrap();
assert_eq!(serde_json::from_value::<BrushSettings>(got).unwrap(), after, "section {i}");
// Replaying the journaled call onto a fresh session gives the same brush (drivable).
let mut s = photocraft_engine::Session::new();
s.execute(&id, p).unwrap();
assert_eq!(s.tools.brush, after, "replay of section {i}");
}
}
#[test]
fn patches_are_minimal_and_skip_unchanged_bitmaps() {
let tip = TipShape::Sampled(GrayTile::from_fn(400, 300, |x, y| ((x * y) % 7) as f32 / 7.0));
let old = BrushSettings {
tip: tip.clone(),
texture: paint::Texture { pattern: Pattern::Tile(GrayTile::from_fn(64, 64, |x, _| x as f32 / 64.0)), ..Default::default() },
..Default::default()
};
let new = BrushSettings { size: 44.0, ..old.clone() };
assert_eq!(brush_patch(&old, &new), json!({"size": 44.0}));
let new = BrushSettings { shape_dynamics: paint::ShapeDynamics { enabled: true, ..Default::default() }, ..old.clone() };
assert_eq!(brush_patch(&old, &new), json!({"shapeDynamics": {"enabled": true}}));
assert_eq!(brush_patch(&old, &old), json!({}));
// A changed tip is sent whole.
let new = BrushSettings { tip: TipShape::Round, ..old.clone() };
assert_eq!(brush_patch(&old, &new), json!({"tip": "round"}));
// Applying any of these through the engine gives back `new`.
let mut app = app();
app.session.tools.brush = old.clone();
let new = BrushSettings { size: 9.0, roundness: 0.5, ..old.clone() };
commit(&mut app, &old, &new);
assert_eq!(app.session.tools.brush, new);
// No change, no command.
let n = app.session.journal.len();
commit(&mut app, &new, &new);
assert_eq!(app.session.journal.len(), n);
}
#[test]
fn bad_patches_fail_without_touching_the_brush() {
let mut app = app();
let before = app.session.tools.brush.clone();
for bad in [
json!({"brush": {"size": "huge"}}),
json!({"brush": {"shapeDynamics": {"size": {"control": "telepathy"}}}}),
json!({"brush": {"tip": {"sampled": {"width": 0}}}}),
] {
assert!(app.run("tools.setBrush", bad).is_err());
assert_eq!(app.session.tools.brush, before);
}
}
fn harness(tab: usize, section: usize) -> Harness<'static, PhotocraftApp> {
let mut app = app();
app.ui.panels.brush_settings = true;
app.ui.brush_tab = tab;
app.ui.brush_section = section;
let mut h = Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
let ctx = ui.ctx().clone();
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
window(app, &ctx);
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.run_steps(4);
h
}
#[test]
fn preview_strip_re_renders_only_when_the_brush_changes() {
let mut h = harness(0, 0);
let ctx = h.ctx.clone();
let n0 = brush_preview::render_count(&ctx);
assert!(n0 >= 1, "the strip rendered");
h.run_steps(6);
assert_eq!(brush_preview::render_count(&ctx), n0, "idle frames re-rendered a preview");
// Change the brush through the engine: the strip renders once more, then stays cached.
h.state_mut().run("tools.setBrush", json!({"brush": {"scattering": {"enabled": true, "scatter": {"jitter": 2.0}}}})).unwrap();
h.run_steps(5);
assert_eq!(brush_preview::render_count(&ctx), n0 + 1);
// Colour and the jitter seed of strokes aren't preview inputs: no re-render.
h.state_mut().session.tools.brush.color = [1.0, 0.0, 0.0, 1.0];
h.run_steps(3);
assert_eq!(brush_preview::render_count(&ctx), n0 + 1);
}
#[test]
fn every_section_draws_without_issuing_commands() {
for tab in 0..2 {
for section in 0..SECTIONS.len() {
let mut h = harness(tab, section);
// A brush with every section on and a sampled tip.
let mut b = BrushSettings::default();
for i in 0..SECTIONS.len() {
edit_section(i, &mut b);
}
b.tip = TipShape::Sampled(GrayTile::from_fn(50, 40, |x, y| ((x ^ y) & 1) as f32));
h.state_mut().session.tools.brush = b.clone();
let n = h.state().session.journal.len();
h.run_steps(4);
// Merely showing the panel must not rewrite the brush (no rounding drift, no commands).
assert_eq!(h.state().session.journal.len(), n, "tab {tab} section {section}");
assert_eq!(h.state().session.tools.brush, b, "tab {tab} section {section}");
}
}
}
#[test]
fn brushes_panel_groups_collapse_and_filter() {
let mut h = harness(1, 0);
let groups = grouped_presets(&h.state().session.tools.presets);
assert!(groups.len() >= 2, "{groups:?}");
let ctx = h.ctx.clone();
let shown = |h: &mut Harness<'static, PhotocraftApp>| {
h.run_steps(2);
brush_preview::with_cache(&ctx, |c| c.len())
};
let all = shown(&mut h);
// Collapse every group: no more previews are needed, and the old ones are kept (cached).
h.state_mut().ui.brushes_panel.collapsed = groups.iter().map(|(g, _)| g.clone()).collect();
let n = brush_preview::render_count(&ctx);
assert_eq!(shown(&mut h), all);
assert_eq!(brush_preview::render_count(&ctx), n);
// A filter shows matching presets even in collapsed groups.
let first = h.state().session.tools.presets[0].name.clone();
h.state_mut().ui.brushes_panel.filter = first.to_uppercase();
h.run_steps(2);
assert!(brush_preview::render_count(&ctx) >= n);
}
+333
View File
@@ -0,0 +1,333 @@
//! Brush previews for the Brush Settings and Brushes panels: the S-curve stroke strip and tip
//! thumbnails, rendered with the real brush engine and cached per slot on a cheap signature of the
//! settings, so a preview re-renders only when something that changes it changes.
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use egui::Color32;
use photocraft_engine::BrushSettings;
use photocraft_engine::paint::{self, Pattern, StrokePoint, TipShape};
/// Values cached per named slot, each tagged with the signature it was rendered for. A slot holds
/// one value, so dragging a slider replaces the slot's texture instead of piling up new ones.
#[derive(Clone)]
pub struct SigCache<T> {
slots: HashMap<String, (u64, T)>,
/// Number of renders so far (tests and the perf overlay).
pub renders: u64,
}
impl<T> Default for SigCache<T> {
fn default() -> Self {
Self { slots: HashMap::new(), renders: 0 }
}
}
impl<T: Clone> SigCache<T> {
/// The value for `slot`, rendered by `render` only when the slot is empty or `sig` changed.
pub fn get(&mut self, slot: &str, sig: u64, render: impl FnOnce() -> T) -> T {
if let Some((s, v)) = self.slots.get(slot)
&& *s == sig
{
return v.clone();
}
self.renders += 1;
let v = render();
self.slots.insert(slot.to_string(), (sig, v.clone()));
v
}
/// Drop slots not in `keep` (presets deleted from the session).
pub fn retain(&mut self, keep: impl Fn(&str) -> bool) {
self.slots.retain(|k, _| keep(k));
}
pub fn len(&self) -> usize {
self.slots.len()
}
pub fn is_empty(&self) -> bool {
self.slots.is_empty()
}
}
fn js<T: serde::Serialize>(h: &mut DefaultHasher, v: &T) {
serde_json::to_vec(v).unwrap_or_default().hash(h);
}
/// Size plus a strided sample of a bitmap: imported tips can be millions of pixels.
fn tile_sig(h: &mut DefaultHasher, t: &paint::GrayTile) {
(t.width, t.height, t.data.len()).hash(h);
let step = (t.data.len() / 4096).max(1);
t.data.iter().step_by(step).for_each(|v| v.hash(h));
}
fn tip_shape_sig(h: &mut DefaultHasher, t: &TipShape) {
match t {
TipShape::Round => 0u8.hash(h),
TipShape::Sampled(g) => tile_sig(h, g),
}
}
/// Cheap signature of everything that changes a brush's stroke preview.
pub fn preview_sig(b: &BrushSettings) -> u64 {
let mut h = DefaultHasher::new();
js(&mut h, &(b.size, b.hardness, b.spacing, b.opacity, b.flow, b.pressure_size, b.pressure_opacity, b.erase, b.mode, b.angle, b.roundness));
js(&mut h, &(b.flip_x, b.flip_y, b.aliased, b.noise, b.wet_edges, b.build_up, b.build_up_rate, b.protect_texture, b.seed));
js(&mut h, &(&b.shape_dynamics, &b.scattering, &b.color_dynamics, &b.transfer, &b.pose, &b.smoothing));
tip_shape_sig(&mut h, &b.tip);
let d = &b.dual_brush;
js(&mut h, &(d.enabled, d.mode, d.size, d.hardness, d.roundness, d.angle, d.spacing, d.scatter, d.both_axes, d.count, d.flip));
tip_shape_sig(&mut h, &d.tip);
let t = &b.texture;
js(&mut h, &(t.enabled, t.invert, t.scale, t.brightness, t.contrast, t.each_tip, t.mode, t.depth, t.depth_jitter));
match &t.pattern {
Pattern::Tile(g) => tile_sig(&mut h, g),
p => js(&mut h, p),
}
h.finish()
}
/// Signature of a tip thumbnail's inputs.
pub fn tip_sig(tip: &TipShape, hardness: f32, angle: f32, roundness: f32, flip: (bool, bool)) -> u64 {
let mut h = DefaultHasher::new();
tip_shape_sig(&mut h, tip);
(hardness.to_bits(), angle.to_bits(), roundness.to_bits(), flip).hash(&mut h);
h.finish()
}
/// A stroke rendered with the brush (scaled down to fit), as straight RGBA8: Photoshop's preview
/// S-curve with pressure tapering in and out.
pub fn preview_pixels(b: &BrushSettings, w: u32, h: u32, color: [f32; 4]) -> Vec<u8> {
use photocraft_color::{ColorMode, PixelFormat, SampleType};
let mut brush = b.clone();
let k = ((h as f32 * 0.55) / brush.size.max(1.0)).min(1.0);
brush.size = (brush.size * k).max(1.0);
brush.dual_brush.size = (brush.dual_brush.size * k).max(1.0);
brush.color = color;
brush.erase = false;
brush.seed = 7;
// Build-up adds dabs over (simulated) time: the preview has none.
brush.build_up = false;
let n = 64;
let points: Vec<StrokePoint> = (0..=n)
.map(|i| {
let s = i as f64 / n as f64;
let x = 10.0 + s * (w as f64 - 20.0);
let y = h as f64 / 2.0 - (s * std::f64::consts::TAU).sin() * h as f64 * 0.22;
StrokePoint::new(x, y, (s * std::f64::consts::PI).sin().max(0.05) as f32)
})
.collect();
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true);
let mut s = photocraft_raster::Surface::new(fmt);
paint::apply_stroke(&mut s, &paint::Stroke { brush, points }, None, false);
let r = photocraft_geom::Rect::new(0, 0, w as i32, h as i32);
let mut px = vec![[0.0f32; 4]; (w * h) as usize];
s.read_rgba_into(r, &mut px);
px.iter().flat_map(|p| p.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect()
}
/// Box-downsample a tip bitmap so its larger side is at most `max` (cheap bilinear lookups after).
fn shrink(g: &paint::GrayTile, max: u32) -> (u32, u32, Vec<f32>) {
let (w, h) = (g.width.max(1), g.height.max(1));
if !g.is_valid() {
return (1, 1, vec![0.0]);
}
let f = (w.max(h) as f32 / max.max(1) as f32).max(1.0);
let (nw, nh) = (((w as f32 / f).ceil() as u32).max(1), ((h as f32 / f).ceil() as u32).max(1));
let mut sum = vec![0.0f32; (nw * nh) as usize];
let mut cnt = vec![0u32; (nw * nh) as usize];
for y in 0..h {
let ny = ((y as f32 / f) as u32).min(nh - 1);
for x in 0..w {
let nx = ((x as f32 / f) as u32).min(nw - 1);
let i = (ny * nw + nx) as usize;
sum[i] += f32::from(g.data[(y * w + x) as usize]) / 65535.0;
cnt[i] += 1;
}
}
(nw, nh, sum.iter().zip(&cnt).map(|(s, c)| if *c > 0 { s / *c as f32 } else { 0.0 }).collect())
}
/// Coverage (0..1) of a brush tip drawn into an `n × n` cell, with the tip's angle, roundness,
/// flips and (for computed tips) hardness: the Brush Tip Shape grid and Brushes panel thumbnails.
pub fn tip_alpha(tip: &TipShape, hardness: f32, angle: f32, roundness: f32, flip: (bool, bool), n: u32) -> Vec<f32> {
let n = n.clamp(2, 512);
let c = n as f32 / 2.0;
let r = c - 1.0;
let (sin, cos) = angle.to_radians().sin_cos();
let round = roundness.clamp(0.01, 1.0);
let sampled = match tip {
TipShape::Sampled(g) => Some(shrink(g, n * 2)),
TipShape::Round => None,
};
let mut out = vec![0.0f32; (n * n) as usize];
// 2×2 supersampling keeps small thumbnails smooth.
for y in 0..n {
for x in 0..n {
let mut acc = 0.0;
for (sx, sy) in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)] {
let (dx, dy) = (x as f32 + sx - c, c - (y as f32 + sy));
// Into tip space: undo the counter-clockwise angle, then the roundness squash.
let mut u = dx * cos + dy * sin;
let mut v = (-dx * sin + dy * cos) / round;
if flip.0 {
u = -u;
}
if flip.1 {
v = -v;
}
acc += match &sampled {
None => paint::dab_coverage(u.hypot(v), r, hardness),
Some((tw, th, data)) => {
// The tip's larger side spans the cell diameter.
let k = (*tw).max(*th) as f32 / (2.0 * r);
let tx = u * k + *tw as f32 / 2.0 - 0.5;
let ty = -v * k + *th as f32 / 2.0 - 0.5;
bilinear(data, *tw, *th, tx, ty)
}
};
}
out[(y * n + x) as usize] = (acc / 4.0).clamp(0.0, 1.0);
}
}
out
}
fn bilinear(data: &[f32], w: u32, h: u32, x: f32, y: f32) -> f32 {
if !(x > -1.0 && y > -1.0 && x < w as f32 && y < h as f32) {
return 0.0;
}
let at = |xi: i32, yi: i32| -> f32 {
if xi < 0 || yi < 0 || xi >= w as i32 || yi >= h as i32 { 0.0 } else { data.get((yi as u32 * w + xi as u32) as usize).copied().unwrap_or(0.0) }
};
let (x0, y0) = (x.floor(), y.floor());
let (fx, fy) = (x - x0, y - y0);
let (xi, yi) = (x0 as i32, y0 as i32);
let top = at(xi, yi) * (1.0 - fx) + at(xi + 1, yi) * fx;
let bottom = at(xi, yi + 1) * (1.0 - fx) + at(xi + 1, yi + 1) * fx;
top * (1.0 - fy) + bottom * fy
}
fn upload(ctx: &egui::Context, name: &str, w: u32, h: u32, rgba: &[u8]) -> egui::TextureHandle {
let img = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], rgba);
ctx.load_texture(name, img, egui::TextureOptions::LINEAR)
}
fn cache_id() -> egui::Id {
egui::Id::new("brush-preview-cache")
}
/// Run `f` on the context's preview cache. The cache is taken out of egui's memory while `f` runs,
/// because uploading a texture needs the context (which `data_mut` holds locked).
pub fn with_cache<R>(ctx: &egui::Context, f: impl FnOnce(&mut SigCache<egui::TextureHandle>) -> R) -> R {
let mut cache = ctx.data_mut(|d| std::mem::take(d.get_temp_mut_or_default::<SigCache<egui::TextureHandle>>(cache_id())));
let r = f(&mut cache);
ctx.data_mut(|d| d.insert_temp(cache_id(), cache));
r
}
/// Renders done by the preview cache so far.
pub fn render_count(ctx: &egui::Context) -> u64 {
with_cache(ctx, |c| c.renders)
}
/// The stroke preview of `b` for `slot`, re-rendered only when the brush or size changes.
pub fn stroke_texture(ctx: &egui::Context, slot: &str, b: &BrushSettings, w: u32, h: u32, color: Color32) -> egui::TextureHandle {
let mut hs = DefaultHasher::new();
(preview_sig(b), w, h, color).hash(&mut hs);
let sig = hs.finish();
with_cache(ctx, |cache| {
cache.get(slot, sig, || {
let c = color.to_normalized_gamma_f32();
upload(ctx, slot, w, h, &preview_pixels(b, w, h, [c[0], c[1], c[2], 1.0]))
})
})
}
/// A tip thumbnail for `slot` (`n × n`; `shape` is hardness, angle, roundness), re-rendered only
/// when the tip changes.
pub fn tip_texture(ctx: &egui::Context, slot: &str, tip: &TipShape, shape: (f32, f32, f32), n: u32, color: Color32) -> egui::TextureHandle {
let (hardness, angle, roundness) = shape;
let mut hs = DefaultHasher::new();
(tip_sig(tip, hardness, angle, roundness, (false, false)), n, color).hash(&mut hs);
let sig = hs.finish();
with_cache(ctx, |cache| {
cache.get(slot, sig, || {
let a = tip_alpha(tip, hardness, angle, roundness, (false, false), n);
let px: Vec<u8> = a.iter().flat_map(|v| [color.r(), color.g(), color.b(), (v * 255.0).round() as u8]).collect();
upload(ctx, slot, n, n, &px)
})
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sig_cache_renders_only_on_change() {
let mut c: SigCache<u32> = SigCache::default();
let mut b = BrushSettings::default();
let mut calls = 0;
for _ in 0..5 {
c.get("strip", preview_sig(&b), || {
calls += 1;
calls
});
}
assert_eq!((calls, c.renders), (1, 1));
b.shape_dynamics.size.jitter = 0.5;
assert_eq!(c.get("strip", preview_sig(&b), || 99), 99);
assert_eq!(c.get("strip", preview_sig(&b), || 100), 99);
assert_eq!((c.renders, c.len()), (2, 1));
// Colour and the session's foreground don't matter; the brush's own fields do.
let base = preview_sig(&BrushSettings::default());
assert_eq!(base, preview_sig(&BrushSettings { color: [1.0, 0.0, 0.0, 1.0], ..Default::default() }));
assert_ne!(base, preview_sig(&BrushSettings { texture: paint::Texture { enabled: true, ..Default::default() }, ..Default::default() }));
c.retain(|_| false);
assert!(c.is_empty());
}
#[test]
fn preview_draws_a_stroke_that_fits() {
let mut b = BrushSettings { size: 400.0, ..Default::default() };
let px = preview_pixels(&b, 120, 40, [1.0, 0.0, 0.0, 1.0]);
assert_eq!(px.len(), 120 * 40 * 4);
let covered = px.as_chunks::<4>().0.iter().filter(|p| p[3] > 128).count();
assert!(covered > 200 && covered < 120 * 40 / 2, "{covered}");
// Dynamics change the preview.
b.scattering.enabled = true;
b.scattering.scatter.jitter = 3.0;
assert_ne!(preview_pixels(&b, 120, 40, [1.0, 0.0, 0.0, 1.0]), px);
// The Brush Settings strip with several sections on stays cheap to re-render.
b.shape_dynamics.enabled = true;
b.shape_dynamics.size.jitter = 0.4;
b.texture.enabled = true;
b.dual_brush.enabled = true;
let t0 = std::time::Instant::now();
preview_pixels(&b, 488, 76, [1.0; 4]);
eprintln!("strip render: {:.2} ms", t0.elapsed().as_secs_f64() * 1e3);
}
#[test]
fn tip_thumbnails_follow_shape_angle_and_roundness() {
let n = 32;
let at = |a: &[f32], x: u32, y: u32| a[(y * n + x) as usize];
let hard = tip_alpha(&TipShape::Round, 1.0, 0.0, 1.0, (false, false), n);
assert!(at(&hard, 16, 16) > 0.99 && at(&hard, 0, 0) < 0.01 && at(&hard, 29, 16) > 0.9);
let soft = tip_alpha(&TipShape::Round, 0.0, 0.0, 1.0, (false, false), n);
assert!(at(&soft, 26, 16) < at(&hard, 26, 16));
// 25 % roundness: flat ellipse, wide along x; rotated 90°, tall along y.
let flat = tip_alpha(&TipShape::Round, 1.0, 0.0, 0.25, (false, false), n);
assert!(at(&flat, 28, 16) > 0.9 && at(&flat, 16, 4) < 0.01);
let tall = tip_alpha(&TipShape::Round, 1.0, 90.0, 0.25, (false, false), n);
assert!(at(&tall, 16, 4) > 0.9 && at(&tall, 28, 16) < 0.01);
// A sampled tip: a 300×100 bar fills the cell's width, centred.
let bar = paint::GrayTile::from_fn(300, 100, |_, _| 1.0);
let s = tip_alpha(&TipShape::Sampled(bar), 1.0, 0.0, 1.0, (false, false), n);
assert!(at(&s, 3, 16) > 0.9 && at(&s, 16, 2) < 0.01);
// Degenerate input never panics.
let empty = paint::GrayTile { width: 0, height: 0, data: vec![] };
assert!(tip_alpha(&TipShape::Sampled(empty), 1.0, 0.0, 0.0, (true, true), 0).iter().all(|v| *v == 0.0));
}
}
+606
View File
@@ -0,0 +1,606 @@
//! The Brush Settings panel's section bodies (Photoshop's right-hand side): Brush Tip Shape with
//! its tip grid and angle/roundness widget, Shape Dynamics, Scattering, Texture, Dual Brush, Color
//! Dynamics, Transfer, Brush Pose, Build-up and Smoothing. Each edits a working copy of the brush;
//! the panel turns the difference into one `tools.setBrush` call.
use egui::{Color32, RichText, Sense, Stroke, pos2, vec2};
use photocraft_engine::BrushSettings;
use photocraft_engine::paint::{self, BrushPreset, Control, Dynamic, MaskMode, Pattern, PatternStyle, TipShape};
use crate::brush_preview;
use crate::theme::{self, Tokens};
use crate::widgets;
pub const CONTROLS: [(Control, &str); 8] = [
(Control::Off, "Off"),
(Control::Fade, "Fade"),
(Control::PenPressure, "Pen Pressure"),
(Control::PenTilt, "Pen Tilt"),
(Control::StylusWheel, "Stylus Wheel"),
(Control::Rotation, "Rotation"),
(Control::InitialDirection, "Initial Direction"),
(Control::Direction, "Direction"),
];
pub const MASK_MODES: [(MaskMode, &str); 10] = [
(MaskMode::Multiply, "Multiply"),
(MaskMode::Subtract, "Subtract"),
(MaskMode::Darken, "Darken"),
(MaskMode::Overlay, "Overlay"),
(MaskMode::ColorDodge, "Color Dodge"),
(MaskMode::ColorBurn, "Color Burn"),
(MaskMode::LinearBurn, "Linear Burn"),
(MaskMode::HardMix, "Hard Mix"),
(MaskMode::LinearHeight, "Linear Height"),
(MaskMode::Height, "Height"),
];
const PATTERNS: [(PatternStyle, &str); 4] =
[(PatternStyle::Paper, "Paper"), (PatternStyle::Canvas, "Canvas"), (PatternStyle::Noise, "Noise"), (PatternStyle::Dots, "Dots")];
/// Percent slider over a 0..`max` fraction.
fn pct(ui: &mut egui::Ui, label: &str, v: &mut f32, max: f32) -> bool {
signed_pct(ui, label, v, 0.0, max)
}
/// Percent slider over a `min..max` fraction.
fn signed_pct(ui: &mut egui::Ui, label: &str, v: &mut f32, min: f32, max: f32) -> bool {
let mut p = (*v * 100.0).round();
let changed = widgets::slider_row(ui, label, &mut p, min * 100.0..=max * 100.0, "%", None).changed();
if changed {
*v = (p.round() / 100.0).clamp(min, max);
}
changed
}
fn num(ui: &mut egui::Ui, label: &str, v: &mut f32, range: std::ops::RangeInclusive<f32>, suffix: &str) -> bool {
let mut x = *v;
let changed = widgets::slider_row(ui, label, &mut x, range.clone(), suffix, None).changed();
if changed {
*v = x.round().clamp(*range.start(), *range.end());
}
changed
}
fn dim(ui: &mut egui::Ui, s: &str) {
let t = Tokens::get(ui.ctx());
ui.label(RichText::new(s).color(t.text_dim));
}
/// `Control: [Off ▾] [25]`: the controller pop-up plus Fade's step count. `angle` offers the
/// direction controls (Photoshop only has them for Angle Jitter).
fn control_row(ui: &mut egui::Ui, id: &str, d: &mut Dynamic, angle: bool) {
ui.horizontal(|ui| {
dim(ui, "Control:");
let n = if angle || matches!(d.control, Control::InitialDirection | Control::Direction) { 8 } else { 6 };
widgets::dropdown(ui, id, &mut d.control, &CONTROLS[..n], 130.0);
if d.control == Control::Fade {
let mut steps = d.fade_steps as f32;
if widgets::value_field(ui, &mut steps, 1.0..=9999.0, "", 56.0).on_hover_text("Fade steps").changed() {
d.fade_steps = steps.round().clamp(1.0, 9999.0) as u32;
}
}
});
ui.add_space(2.0);
}
/// A jitter slider, its Control row and (optionally) the Minimum slider: one Photoshop block.
fn dynamic(ui: &mut egui::Ui, id: &str, label: &str, d: &mut Dynamic, max: f32, minimum: Option<&str>, angle: bool) {
pct(ui, label, &mut d.jitter, max);
control_row(ui, id, d, angle);
if let Some(m) = minimum {
pct(ui, m, &mut d.minimum, 1.0);
}
ui.add_space(6.0);
}
/// Size: a value field over a logarithmic slider (small sizes get most of the travel).
/// `restore` adds Photoshop's "Restore original size" button (sampled tips).
fn size_row(ui: &mut egui::Ui, label: &str, size: &mut f32, max: f32, restore: Option<f32>) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.label(RichText::new(label).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut s = *size;
if widgets::value_field(ui, &mut s, 1.0..=max, "px", 74.0).changed() {
*size = s.round().clamp(1.0, max);
}
if let Some(orig) = restore
&& crate::icons::button(ui, "undo-2", 22.0, false, "Restore original size").clicked()
{
*size = orig.clamp(1.0, max);
}
});
});
let mut lv = size.max(1.0).ln();
if widgets::slider(ui, &mut lv, 0.0..=max.ln(), None).changed() {
*size = lv.exp().round().clamp(1.0, max);
}
ui.add_space(4.0);
}
/// Which part of the angle/roundness widget a drag grabbed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EllipseGrab {
Angle,
Roundness,
}
/// Outer radius of the angle/roundness widget's ellipse.
const ELLIPSE_R: f32 = 34.0;
/// Screen positions (relative to the widget centre) of the two roundness handles.
pub fn roundness_handles(angle: f32, roundness: f32) -> [egui::Vec2; 2] {
let (s, c) = angle.to_radians().sin_cos();
// The minor axis is the major axis turned 90° counter-clockwise; screen y points down.
let minor = vec2(-s, -c) * ELLIPSE_R * roundness.clamp(0.0, 1.0);
[minor, -minor]
}
/// What a press at `rel` (relative to the centre) grabs: a roundness handle within 7 px, else the angle.
pub fn ellipse_grab(rel: egui::Vec2, angle: f32, roundness: f32) -> EllipseGrab {
if roundness_handles(angle, roundness).iter().any(|h| (*h - rel).length() <= 7.0) { EllipseGrab::Roundness } else { EllipseGrab::Angle }
}
/// The new (angle, roundness) for a drag to `rel`: the angle points at the pointer (degrees,
/// counter-clockwise, -180..180); the roundness is the pointer's distance along the minor axis.
pub fn ellipse_drag(grab: EllipseGrab, rel: egui::Vec2, angle: f32, roundness: f32) -> (f32, f32) {
match grab {
EllipseGrab::Angle if rel.length() > 1.0 => ((-rel.y).atan2(rel.x).to_degrees().round(), roundness),
EllipseGrab::Angle => (angle, roundness),
EllipseGrab::Roundness => {
let (s, c) = angle.to_radians().sin_cos();
let along = (rel.x * -s + rel.y * -c).abs();
(angle, (along / ELLIPSE_R).clamp(0.01, 1.0))
}
}
}
/// Photoshop's angle/roundness widget: drag the arrow to rotate, the dots to squash.
fn ellipse_widget(ui: &mut egui::Ui, angle: &mut f32, roundness: &mut f32) -> bool {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(84.0, 84.0), Sense::click_and_drag());
let c = rect.center();
let grab_id = resp.id.with("grab");
let mut changed = false;
if resp.drag_started()
&& let Some(p) = resp.interact_pointer_pos()
{
let g = ellipse_grab(p - c, *angle, *roundness);
ui.data_mut(|d| d.insert_temp(grab_id, g == EllipseGrab::Roundness));
}
if (resp.dragged() || resp.clicked())
&& let Some(p) = resp.interact_pointer_pos()
{
let grab = if resp.clicked() {
ellipse_grab(p - c, *angle, *roundness)
} else if ui.data(|d| d.get_temp::<bool>(grab_id)).unwrap_or(false) {
EllipseGrab::Roundness
} else {
EllipseGrab::Angle
};
let (a, r) = ellipse_drag(grab, p - c, *angle, *roundness);
if a != *angle || (r - *roundness).abs() > 1e-4 {
*angle = a;
*roundness = r;
changed = true;
}
}
let p = ui.painter();
p.circle_filled(c, 40.0, t.field);
p.circle_stroke(c, 40.0, Stroke::new(1.0, t.field_border));
p.line_segment([c - vec2(40.0, 0.0), c + vec2(40.0, 0.0)], Stroke::new(1.0, t.separator));
p.line_segment([c - vec2(0.0, 40.0), c + vec2(0.0, 40.0)], Stroke::new(1.0, t.separator));
let (s, co) = angle.to_radians().sin_cos();
let major = vec2(co, -s);
let minor = vec2(-s, -co);
let rr = roundness.clamp(0.01, 1.0);
let pts: Vec<egui::Pos2> = (0..48)
.map(|i| {
let a = i as f32 / 48.0 * std::f32::consts::TAU;
c + major * a.cos() * ELLIPSE_R + minor * a.sin() * ELLIPSE_R * rr
})
.collect();
p.add(egui::Shape::convex_polygon(pts, t.text.gamma_multiply(0.18), Stroke::new(1.5, t.text)));
// Arrow along the major axis.
let tip = c + major * (ELLIPSE_R + 5.0);
p.line_segment([c, tip], Stroke::new(1.5, t.accent));
let back = tip - major * 7.0;
p.add(egui::Shape::convex_polygon(vec![tip, back + minor * 4.0, back - minor * 4.0], t.accent, Stroke::NONE));
for h in roundness_handles(*angle, *roundness) {
p.circle_filled(c + h, 4.0, Color32::WHITE);
p.circle_stroke(c + h, 4.0, Stroke::new(1.0, t.accent));
}
let _ = resp.on_hover_text("Drag the arrow to set the angle, the dots to set the roundness");
changed
}
/// The tip a preset contributes to a tip grid: (shape, size, hardness, angle, roundness, spacing).
pub fn preset_tip(p: &BrushPreset) -> (&TipShape, f32, f32, f32, f32, f32) {
let b = &p.brush;
(&b.tip, b.size, b.hardness, b.angle, b.roundness, b.spacing)
}
/// Thumbnails show relative size: small tips are drawn smaller (down to 30 %), 40 px and up fill the cell.
pub fn thumb_scale(size: f32) -> f32 {
(size / 40.0).clamp(0.3, 1.0)
}
/// Does `b`'s tip look like `p`'s (same bitmap or round hardness, and size)?
fn same_tip(tip: &TipShape, size: f32, hardness: f32, p: &BrushPreset) -> bool {
let (pt, ps, ph, ..) = preset_tip(p);
(size - ps).abs() < 0.5
&& match (tip, pt) {
(TipShape::Round, TipShape::Round) => (hardness - ph).abs() < 0.01,
(TipShape::Sampled(a), TipShape::Sampled(b)) => a.width == b.width && a.height == b.height && a.data == b.data,
_ => false,
}
}
/// Photoshop's scrolling grid of preset tips with their sizes. Returns the clicked preset index.
fn tip_grid(ui: &mut egui::Ui, id: &str, presets: &[BrushPreset], selected: impl Fn(&BrushPreset) -> bool) -> Option<usize> {
let t = Tokens::get(ui.ctx());
let mut clicked = None;
let frame = egui::Frame::NONE.fill(t.field).stroke(Stroke::new(1.0, t.field_border)).corner_radius(t.radius_sm as u8).inner_margin(egui::Margin::same(3));
frame.show(ui, |ui| {
egui::ScrollArea::vertical().id_salt(id).max_height(104.0).auto_shrink([false, true]).show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
for (i, p) in presets.iter().enumerate() {
let (cell, resp) = ui.allocate_exact_size(vec2(44.0, 50.0), Sense::click());
if !ui.is_rect_visible(cell) {
continue;
}
if selected(p) {
ui.painter().rect_filled(cell, 3.0, t.accent_soft);
ui.painter().rect_stroke(cell, 3.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
} else if resp.hovered() {
ui.painter().rect_filled(cell, 3.0, t.hover);
}
let (tip, size, hard, angle, round, _) = preset_tip(p);
let tex = brush_preview::tip_texture(ui.ctx(), &format!("{id}:{}", p.name), tip, (hard, angle, round), 34, t.text);
let ir = egui::Rect::from_center_size(pos2(cell.center().x, cell.top() + 20.0), vec2(34.0, 34.0) * thumb_scale(size));
ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
ui.painter().text(
pos2(cell.center().x, cell.bottom() - 6.0),
egui::Align2::CENTER_CENTER,
format!("{}", size.round() as i64),
egui::FontId::proportional(9.5),
t.text_dim,
);
if resp.on_hover_text(&p.name).clicked() {
clicked = Some(i);
}
}
});
});
});
ui.add_space(6.0);
clicked
}
fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset]) {
let (tip, size, hardness) = (b.tip.clone(), b.size, b.hardness);
if let Some(p) = tip_grid(ui, "brush-tip-grid", presets, |p| same_tip(&tip, size, hardness, p)).and_then(|i| presets.get(i)) {
// Picking a tip changes the tip only: the dynamics sections stay (Photoshop).
let (pt, ps, ph, pa, pr, psp) = preset_tip(p);
b.tip = pt.clone();
b.size = ps;
b.hardness = ph;
b.angle = pa;
b.roundness = pr;
b.spacing = psp;
}
let orig = match &b.tip {
TipShape::Sampled(g) => Some(g.width.max(g.height) as f32),
TipShape::Round => None,
};
size_row(ui, "Size", &mut b.size, 5000.0, orig);
ui.horizontal(|ui| {
widgets::checkbox(ui, &mut b.flip_x, "Flip X");
ui.add_space(8.0);
widgets::checkbox(ui, &mut b.flip_y, "Flip Y");
});
ui.add_space(4.0);
ui.horizontal(|ui| {
ellipse_widget(ui, &mut b.angle, &mut b.roundness);
ui.add_space(8.0);
ui.vertical(|ui| {
ui.add_space(10.0);
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| dim(ui, "Angle:"));
let mut a = b.angle;
if widgets::value_field(ui, &mut a, -180.0..=180.0, "°", 70.0).changed() {
b.angle = a.round().clamp(-180.0, 180.0);
}
});
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| dim(ui, "Roundness:"));
let mut r = (b.roundness * 100.0).round();
if widgets::value_field(ui, &mut r, 1.0..=100.0, "%", 70.0).changed() {
b.roundness = (r.round() / 100.0).clamp(0.01, 1.0);
}
});
});
});
ui.add_space(6.0);
ui.add_enabled_ui(matches!(b.tip, TipShape::Round), |ui| {
pct(ui, "Hardness", &mut b.hardness, 1.0);
});
signed_pct(ui, "Spacing", &mut b.spacing, 0.01, 10.0);
}
fn shape_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) {
let sd = &mut b.shape_dynamics;
dynamic(ui, "brush-size-ctl", "Size Jitter", &mut sd.size, 1.0, Some("Minimum Diameter"), false);
dynamic(ui, "brush-angle-ctl", "Angle Jitter", &mut sd.angle, 1.0, None, true);
dynamic(ui, "brush-round-ctl", "Roundness Jitter", &mut sd.roundness, 1.0, Some("Minimum Roundness"), false);
ui.horizontal(|ui| {
widgets::checkbox(ui, &mut sd.flip_x_jitter, "Flip X Jitter");
ui.add_space(8.0);
widgets::checkbox(ui, &mut sd.flip_y_jitter, "Flip Y Jitter");
});
}
fn scattering(ui: &mut egui::Ui, b: &mut BrushSettings) {
let sc = &mut b.scattering;
pct(ui, "Scatter", &mut sc.scatter.jitter, 10.0);
widgets::checkbox(ui, &mut sc.both_axes, "Both Axes");
ui.add_space(2.0);
control_row(ui, "brush-scatter-ctl", &mut sc.scatter, false);
ui.add_space(6.0);
let mut c = sc.count as f32;
if num(ui, "Count", &mut c, 1.0..=16.0, "") {
sc.count = c as u32;
}
dynamic(ui, "brush-count-ctl", "Count Jitter", &mut sc.count_jitter, 1.0, None, false);
}
/// The pattern swatch: the (adjusted) texture as the stroke sees it.
fn pattern_swatch(ui: &mut egui::Ui, tx: &paint::Texture) {
let t = Tokens::get(ui.ctx());
let n = 56u32;
let (rect, _) = ui.allocate_exact_size(vec2(n as f32, n as f32), Sense::hover());
let sig = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
serde_json::to_vec(&(
match &tx.pattern {
Pattern::Tile(g) => {
(g.width, g.height, g.data.len() as u32, g.data.iter().step_by((g.data.len() / 512).max(1)).map(|v| *v as u32).sum::<u32>())
}
Pattern::Procedural { style, size, seed } => (*style as u32, *size, *seed, 0),
},
tx.invert,
tx.brightness,
tx.contrast,
))
.unwrap_or_default()
.hash(&mut h);
h.finish()
};
let tex = brush_preview::with_cache(ui.ctx(), |c| {
c.get("texture-swatch", sig, || {
let img = match &tx.pattern {
Pattern::Procedural { style, size, seed } => paint::procedural::pattern(*style, (*size).clamp(4, 1024), *seed),
Pattern::Tile(g) if g.is_valid() => paint::tile::PatternImage { width: g.width as usize, height: g.height as usize, data: g.to_f32() },
Pattern::Tile(_) => paint::tile::PatternImage { width: 1, height: 1, data: vec![0.5] },
};
let (br, ct) = (tx.brightness.clamp(-1.0, 1.0), tx.contrast.clamp(-1.0, 1.0));
let px: Vec<u8> = (0..n * n)
.flat_map(|i| {
let v = img.sample_wrap((i % n) as f32, (i / n) as f32);
let mut x = ((v - 0.5) * (1.0 + ct) + 0.5 + br).clamp(0.0, 1.0);
if tx.invert {
x = 1.0 - x;
}
let g = (x * 255.0).round() as u8;
[g, g, g, 255]
})
.collect();
let img = egui::ColorImage::from_rgba_unmultiplied([n as usize, n as usize], &px);
ui.ctx().load_texture("texture-swatch", img, egui::TextureOptions::LINEAR)
})
});
ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
ui.painter().rect_stroke(rect, 0.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
}
fn texture(ui: &mut egui::Ui, b: &mut BrushSettings) {
let tx = &mut b.texture;
ui.horizontal(|ui| {
pattern_swatch(ui, tx);
ui.add_space(8.0);
ui.vertical(|ui| {
let mut style = match &tx.pattern {
Pattern::Procedural { style, .. } => Some(*style),
Pattern::Tile(_) => None,
};
let mut opts: Vec<(Option<PatternStyle>, &str)> = PATTERNS.iter().map(|(s, l)| (Some(*s), *l)).collect();
if style.is_none() {
opts.push((None, "Custom"));
}
if widgets::dropdown(ui, "brush-pattern", &mut style, &opts, 110.0)
&& let Some(s) = style
{
let size = match &tx.pattern {
Pattern::Procedural { size, .. } => *size,
Pattern::Tile(_) => 128,
};
tx.pattern = Pattern::Procedural { style: s, size, seed: 1 };
}
ui.add_space(4.0);
widgets::checkbox(ui, &mut tx.invert, "Invert");
});
});
ui.add_space(6.0);
if let Pattern::Procedural { size, .. } = &mut tx.pattern {
let mut s = *size as f32;
if num(ui, "Pattern Size", &mut s, 16.0..=1024.0, "px") {
*size = s as u32;
}
}
pct(ui, "Scale", &mut tx.scale, 10.0);
let mut br = (tx.brightness * 150.0).round();
if num(ui, "Brightness", &mut br, -150.0..=150.0, "") {
tx.brightness = br / 150.0;
}
// Photoshop's -50..100 contrast onto the model's -1..1.
let mut ct = if tx.contrast < 0.0 { tx.contrast * 50.0 } else { tx.contrast * 100.0 }.round();
if num(ui, "Contrast", &mut ct, -50.0..=100.0, "") {
tx.contrast = if ct < 0.0 { ct / 50.0 } else { ct / 100.0 };
}
widgets::checkbox(ui, &mut tx.each_tip, "Texture Each Tip");
ui.add_space(4.0);
ui.horizontal(|ui| {
dim(ui, "Mode:");
widgets::dropdown(ui, "brush-texture-mode", &mut tx.mode, &MASK_MODES, 130.0);
});
ui.add_space(4.0);
pct(ui, "Depth", &mut tx.depth, 1.0);
let each = tx.each_tip;
ui.add_enabled_ui(each, |ui| {
pct(ui, "Minimum Depth", &mut tx.depth_jitter.minimum, 1.0);
pct(ui, "Depth Jitter", &mut tx.depth_jitter.jitter, 1.0);
control_row(ui, "brush-depth-ctl", &mut tx.depth_jitter, false);
});
}
fn dual_brush(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset]) {
let d = &mut b.dual_brush;
ui.horizontal(|ui| {
dim(ui, "Mode:");
widgets::dropdown(ui, "brush-dual-mode", &mut d.mode, &MASK_MODES, 120.0);
ui.add_space(8.0);
widgets::checkbox(ui, &mut d.flip, "Flip");
});
ui.add_space(4.0);
let (tip, size, hardness) = (d.tip.clone(), d.size, d.hardness);
if let Some(p) = tip_grid(ui, "brush-dual-grid", presets, |p| same_tip(&tip, size, hardness, p)).and_then(|i| presets.get(i)) {
let (pt, ps, ph, pa, pr, psp) = preset_tip(p);
d.tip = pt.clone();
d.size = ps;
d.hardness = ph;
d.angle = pa;
d.roundness = pr;
d.spacing = psp;
}
let orig = match &d.tip {
TipShape::Sampled(g) => Some(g.width.max(g.height) as f32),
TipShape::Round => None,
};
size_row(ui, "Size", &mut d.size, 2500.0, orig);
signed_pct(ui, "Spacing", &mut d.spacing, 0.01, 10.0);
pct(ui, "Scatter", &mut d.scatter, 10.0);
widgets::checkbox(ui, &mut d.both_axes, "Both Axes");
ui.add_space(4.0);
let mut c = d.count as f32;
if num(ui, "Count", &mut c, 1.0..=16.0, "") {
d.count = c as u32;
}
}
fn color_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) {
let c = &mut b.color_dynamics;
widgets::checkbox(ui, &mut c.per_tip, "Apply Per Tip");
ui.add_space(6.0);
dynamic(ui, "brush-fgbg-ctl", "Foreground/Background Jitter", &mut c.fg_bg, 1.0, None, false);
let mut h = (c.hue_jitter * 100.0).round();
if widgets::slider_row(ui, "Hue Jitter", &mut h, 0.0..=100.0, "%", Some(&widgets::hue_stops())).changed() {
c.hue_jitter = (h.round() / 100.0).clamp(0.0, 1.0);
}
pct(ui, "Saturation Jitter", &mut c.saturation_jitter, 1.0);
pct(ui, "Brightness Jitter", &mut c.brightness_jitter, 1.0);
signed_pct(ui, "Purity", &mut c.purity, -1.0, 1.0);
}
fn transfer(ui: &mut egui::Ui, b: &mut BrushSettings) {
let tr = &mut b.transfer;
dynamic(ui, "brush-opacity-ctl", "Opacity Jitter", &mut tr.opacity, 1.0, Some("Minimum"), false);
dynamic(ui, "brush-flow-ctl", "Flow Jitter", &mut tr.flow, 1.0, Some("Minimum"), false);
}
/// Tilt is stored in degrees (±90, W3C Pointer Events); Photoshop shows it as ±100 %.
fn tilt(ui: &mut egui::Ui, label: &str, deg: &mut f32) {
let mut v = (*deg / 90.0).clamp(-1.0, 1.0);
if signed_pct(ui, label, &mut v, -1.0, 1.0) {
*deg = v * 90.0;
}
}
fn pose(ui: &mut egui::Ui, b: &mut BrushSettings) {
let p = &mut b.pose;
tilt(ui, "Tilt X", &mut p.tilt_x);
tilt(ui, "Tilt Y", &mut p.tilt_y);
widgets::checkbox(ui, &mut p.override_tilt, "Override Tilt");
ui.add_space(8.0);
num(ui, "Rotation", &mut p.rotation, 0.0..=360.0, "°");
widgets::checkbox(ui, &mut p.override_rotation, "Override Rotation");
ui.add_space(8.0);
pct(ui, "Pressure", &mut p.pressure, 1.0);
widgets::checkbox(ui, &mut p.override_pressure, "Override Pressure");
}
fn smoothing(ui: &mut egui::Ui, b: &mut BrushSettings) {
let s = &mut b.smoothing;
pct(ui, "Smoothing", &mut s.amount, 1.0);
widgets::checkbox(ui, &mut s.pulled_string, "Pulled String Mode");
widgets::checkbox(ui, &mut s.catch_up, "Stroke Catch-up");
widgets::checkbox(ui, &mut s.catch_up_on_end, "Catch-up on Stroke End");
widgets::checkbox(ui, &mut s.adjust_for_zoom, "Adjust for Zoom");
}
/// Controls for section `i` (see [`crate::brush_panel::SECTIONS`]). `presets` feed the tip grids.
pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize, presets: &[BrushPreset]) {
let t = Tokens::get(ui.ctx());
let note = |ui: &mut egui::Ui, s: &str| {
ui.label(RichText::new(s).color(t.text_faint).font(theme::medium(11.5)));
};
match i {
0 => tip_shape(ui, b, presets),
1 => shape_dynamics(ui, b),
2 => scattering(ui, b),
3 => texture(ui, b),
4 => dual_brush(ui, b, presets),
5 => color_dynamics(ui, b),
6 => transfer(ui, b),
7 => pose(ui, b),
8 => note(ui, "Adds extra randomness to the soft edges of the brush tip. No options: turn it on in the list."),
9 => note(ui, "Builds up paint along the edges of the stroke for a watercolour look. No options: turn it on in the list."),
10 => {
note(ui, "Airbrush: keeps depositing paint while the pointer rests.");
ui.add_space(6.0);
num(ui, "Rate", &mut b.build_up_rate, 1.0..=200.0, "/s");
}
11 => smoothing(ui, b),
_ => note(ui, "Keeps the current pattern and scale when you switch to another textured brush preset."),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ellipse_widget_drags_angle_and_roundness() {
// Pointing straight up is 90° (counter-clockwise from +x; screen y is down).
assert_eq!(ellipse_drag(EllipseGrab::Angle, vec2(0.0, -20.0), 0.0, 1.0), (90.0, 1.0));
assert_eq!(ellipse_drag(EllipseGrab::Angle, vec2(-20.0, 0.0), 0.0, 1.0).0.abs(), 180.0);
// The centre itself doesn't change the angle.
assert_eq!(ellipse_drag(EllipseGrab::Angle, vec2(0.2, 0.1), 30.0, 0.5), (30.0, 0.5));
// At angle 0 the minor axis is vertical: half the radius up is 50 % roundness.
let (_, r) = ellipse_drag(EllipseGrab::Roundness, vec2(0.0, -ELLIPSE_R / 2.0), 0.0, 1.0);
assert!((r - 0.5).abs() < 1e-4);
// Handles sit on the minor axis and are what a press there grabs.
let [h, _] = roundness_handles(0.0, 0.5);
assert!((h - vec2(0.0, -ELLIPSE_R / 2.0)).length() < 1e-4);
assert_eq!(ellipse_grab(h, 0.0, 0.5), EllipseGrab::Roundness);
assert_eq!(ellipse_grab(vec2(ELLIPSE_R, 0.0), 0.0, 0.5), EllipseGrab::Angle);
// Rotated 90°: the minor axis is horizontal.
let [h, _] = roundness_handles(90.0, 1.0);
assert!((h.x.abs() - ELLIPSE_R).abs() < 1e-3 && h.y.abs() < 1e-3, "{h:?}");
let (_, r) = ellipse_drag(EllipseGrab::Roundness, vec2(500.0, 0.0), 90.0, 0.2);
assert_eq!(r, 1.0);
}
}
+2
View File
@@ -15,6 +15,8 @@ pub mod adjust_ui;
pub mod analysis_ui;
pub mod artboard_ui;
pub mod brush_panel;
pub mod brush_preview;
pub mod brush_sections;
pub mod camera_raw_ui;
pub mod canvas;
pub mod channel_view;
+4
View File
@@ -595,6 +595,9 @@ pub struct UiState {
pub brush_section: usize,
#[serde(default)]
pub brush_tab: usize,
/// Brushes panel: collapsed groups and the search filter.
#[serde(default)]
pub brushes_panel: crate::brush_panel::BrushesPanelState,
/// Marquee options-bar mode: 0 new, 1 add, 2 subtract, 3 intersect (modifier keys override).
#[serde(default)]
pub selection_mode: u8,
@@ -657,6 +660,7 @@ impl Default for UiState {
dock: Default::default(),
brush_section: 0,
brush_tab: 0,
brushes_panel: Default::default(),
selection_mode: 0,
tool_options: ToolOptions::default(),
polygon: Vec::new(),