mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-22 23:24:07 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9281dffa6 | ||
|
|
180185a703 |
No files matched your search
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user