mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
Merge PR #6939: Mixer Brush: Clean Brush empties the brush, and the automatic Load and Clean can be switched off
This commit is contained in:
@@ -644,13 +644,13 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mix: pct(p, "mix", bm.mix * 100.0),
|
||||
flow: pct(p, "flow", bm.flow * 100.0),
|
||||
sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers),
|
||||
load_after_stroke: flag(p, "loadAfterStroke", bm.load_after_stroke),
|
||||
clean_after_stroke: flag(p, "cleanAfterStroke", bm.clean_after_stroke),
|
||||
};
|
||||
let sample_all = m.sample_all_layers;
|
||||
let (clean, load_after) = (flag(p, "cleanAfterStroke", true), flag(p, "loadAfterStroke", true));
|
||||
let paint = color(p.get("color"), s.tools.foreground);
|
||||
let mut state = s.tools.mixer.clone();
|
||||
if load_after || state.reservoir.is_none() {
|
||||
state.load(color(p.get("color"), s.tools.foreground));
|
||||
}
|
||||
state.begin_stroke(paint);
|
||||
let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) };
|
||||
let stroke = Stroke { brush, points: pts };
|
||||
let dmg = s.edit("Mixer Brush", |doc, _| {
|
||||
@@ -672,13 +672,43 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
Ok(apply_mixer_stroke(surf, sample.as_ref(), &stroke, &m, &mut state, sel.as_ref(), lock))
|
||||
})?;
|
||||
if clean {
|
||||
state.clean();
|
||||
}
|
||||
state.end_stroke(paint, m.clean_after_stroke, m.load_after_stroke);
|
||||
s.tools.mixer = state;
|
||||
Ok(damage_json(s, dmg))
|
||||
}
|
||||
|
||||
/// `brush.mixer.load`: Load Brush fills the Mixer Brush's reservoir with the color. The next stroke
|
||||
/// keeps that paint unless a different foreground is chosen first. Returns the paint the brush now
|
||||
/// carries.
|
||||
fn mixer_load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.mixer.load";
|
||||
let paint = match p.get("color").filter(|v| !v.is_null()) {
|
||||
None => Some(s.tools.foreground),
|
||||
Some(Value::String(h)) => crate::commands::parse_hex(h),
|
||||
Some(Value::Array(a)) => {
|
||||
let c: Option<Vec<f32>> = a.iter().map(|x| x.as_f64().filter(|x| (0.0..=1.0).contains(x)).map(|x| x as f32)).collect();
|
||||
match c.as_deref() {
|
||||
Some(&[r, g, b]) => Some([r, g, b, 1.0]),
|
||||
Some(&[r, g, b, a]) => Some([r, g, b, a]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Some(_) => None,
|
||||
};
|
||||
let paint = paint.ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b, a?] in 0..1"))?;
|
||||
s.tools.mixer.load(paint);
|
||||
s.tools.mixer.color = Some(s.tools.foreground);
|
||||
Ok(serde_json::to_value(&s.tools.mixer).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// `brush.mixer.clean`: Clean Brush empties the Mixer Brush. The next stroke starts empty unless
|
||||
/// a different color is chosen first. Returns the paint the brush now carries.
|
||||
fn mixer_clean(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.tools.mixer.clean();
|
||||
s.tools.mixer.color = Some(s.tools.foreground);
|
||||
Ok(serde_json::to_value(&s.tools.mixer).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// `paint.colorReplacement`'s stroke and settings. The replacement colour is the brush colour
|
||||
/// (the foreground unless `color` says otherwise). It paints layer pixels only: a mask, alpha
|
||||
/// channel or Quick Mask `target` is refused rather than recolouring the layer instead.
|
||||
@@ -1135,11 +1165,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"paint.mixerBrush",
|
||||
"Mixer Brush",
|
||||
r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##,
|
||||
r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground (a color other than the one recorded by the last stroke, load or clean loads it first; a load or clean records the foreground),"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke (empty the brush after the stroke),"loadAfterStroke":bool=brush.mixer.loadAfterStroke (load the brush with `color` after the stroke),"seed":u64?}"##,
|
||||
has_paintable,
|
||||
mixer_brush,
|
||||
true
|
||||
),
|
||||
spec!("brush.mixer.load", "Load Mixer Brush", r##"{"color":"#rrggbb"|[r,g,b,a?] 0..1?=foreground}"##, always, mixer_load, true),
|
||||
spec!("brush.mixer.clean", "Clean Mixer Brush", "{}", always, mixer_clean, true),
|
||||
spec!(
|
||||
"paint.colorReplacement",
|
||||
"Color Replacement",
|
||||
|
||||
@@ -814,6 +814,101 @@ fn mixer_brush_uses_the_brush_mixer_settings() {
|
||||
assert!(rgba(&s, 190, 5)[3] > 0.95);
|
||||
}
|
||||
|
||||
/// A red image with an empty layer above it, a blue foreground, and a Mixer Brush that samples all
|
||||
/// layers, keeps its paint along the stroke (Load 100 %) and has the automatic Load off.
|
||||
fn mixer_over_red() -> Session {
|
||||
let mut s = session(200, 30);
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("tools.setColors", json!({"foreground": "#0000ff"})).unwrap();
|
||||
let mixer = json!({"load": 1, "sampleAllLayers": true, "loadAfterStroke": false});
|
||||
s.execute("tools.setBrush", json!({"brush": {"pressureSize": false, "spacing": 0.25, "mixer": mixer}})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn mixer_row(s: &mut Session, y: i32) -> [f32; 4] {
|
||||
s.execute("paint.mixerBrush", json!({"points": [[5, y], [195, y]], "size": 6})).unwrap();
|
||||
rgba(s, 100, y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_brush_keeps_the_foreground_out_of_the_next_stroke() {
|
||||
let mut s = mixer_over_red();
|
||||
s.execute("brush.mixer.load", json!({})).unwrap();
|
||||
let state = s.execute("brush.mixer.clean", json!({})).unwrap();
|
||||
assert_eq!((state["reservoir"].clone(), state["pickup"].clone()), (Value::Null, Value::Null));
|
||||
let c = mixer_row(&mut s, 15);
|
||||
assert!(c[3] > 0.1 && c[0] > 0.99 && c[2] < 0.01, "the clean brush lays down only the red it picks up: {c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_automatic_clean_empties_the_reservoir() {
|
||||
let mut s = mixer_over_red();
|
||||
// The brush was never loaded, so the first stroke takes the foreground.
|
||||
assert!(mixer_row(&mut s, 5)[2] > 0.2);
|
||||
assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.pickup), (None, None));
|
||||
let c = mixer_row(&mut s, 15);
|
||||
assert!(c[3] > 0.1 && c[2] < 0.01, "the cleaned brush lays down no blue: {c:?}");
|
||||
// With the automatic Load on as well (the default), the brush is cleaned and then refilled
|
||||
// after the stroke, so the next one starts with a full reservoir and no pickup.
|
||||
s.execute("tools.setBrush", json!({"brush": {"mixer": {"loadAfterStroke": true}}})).unwrap();
|
||||
mixer_row(&mut s, 25);
|
||||
assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.amount, s.tools.mixer.pickup), (Some([0.0, 0.0, 1.0, 1.0]), 1.0, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_brush_after_a_clean_paints_the_foreground_again() {
|
||||
let mut s = mixer_over_red();
|
||||
s.execute("brush.mixer.clean", json!({})).unwrap();
|
||||
assert!(mixer_row(&mut s, 5)[2] < 0.01);
|
||||
let state = s.execute("brush.mixer.load", json!({})).unwrap();
|
||||
assert_eq!(state["reservoir"], json!([0.0, 0.0, 1.0, 1.0]));
|
||||
assert!(mixer_row(&mut s, 15)[2] > 0.2, "the loaded brush paints blue");
|
||||
// A color param loads that color instead of the foreground, and the next stroke paints it.
|
||||
s.execute("brush.mixer.load", json!({"color": [0.0, 1.0, 0.0]})).unwrap();
|
||||
assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.amount), (Some([0.0, 1.0, 0.0, 1.0]), 1.0));
|
||||
assert_eq!(s.tools.mixer.next_load(s.tools.foreground), Some([0.0, 1.0, 0.0, 1.0]));
|
||||
let c = mixer_row(&mut s, 25);
|
||||
assert!(c[1] > 0.2 && c[2] < 0.01, "the stroke paints the loaded green, not the blue foreground: {c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_the_automatic_load_off_a_new_foreground_loads_at_the_next_stroke() {
|
||||
let mut s = mixer_over_red();
|
||||
assert!(mixer_row(&mut s, 5)[2] > 0.2);
|
||||
s.execute("tools.setColors", json!({"foreground": "#00ff00"})).unwrap();
|
||||
let c = mixer_row(&mut s, 15);
|
||||
assert!(c[1] > 0.2, "the new foreground is loaded: {c:?}");
|
||||
// The automatic Clean emptied it again, and the foreground did not change.
|
||||
let c = mixer_row(&mut s, 25);
|
||||
assert!(c[3] > 0.1 && c[1] < 0.01 && c[2] < 0.01, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_and_clean_brush_refuse_bad_params_and_keep_the_paint() {
|
||||
let mut s = mixer_over_red();
|
||||
s.execute("brush.mixer.load", json!({"color": "#336699"})).unwrap();
|
||||
let before = s.tools.mixer.clone();
|
||||
for bad in [
|
||||
json!({"color": 5}),
|
||||
json!({"color": "#zz0000"}),
|
||||
json!({"color": "#ff"}),
|
||||
json!({"color": [2, 0, 0]}),
|
||||
json!({"color": [0, 0]}),
|
||||
json!({"color": [0, 0, 0, 0, 0]}),
|
||||
json!({"color": {"r": 1}}),
|
||||
json!({"color": ["a", 0, 0]}),
|
||||
] {
|
||||
assert!(s.execute("brush.mixer.load", bad.clone()).is_err(), "{bad}");
|
||||
assert_eq!(s.tools.mixer, before, "{bad}");
|
||||
}
|
||||
// Clean Brush takes no params, so anything extra is ignored, without a document too.
|
||||
let mut empty = Session::new();
|
||||
empty.execute("brush.mixer.clean", json!({"color": [], "junk": {"x": null}})).unwrap();
|
||||
empty.execute("brush.mixer.load", json!({"color": null})).unwrap();
|
||||
assert_eq!(empty.tools.mixer.reservoir, Some(empty.tools.foreground));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesced_set_brush_calls_journal_once_per_gesture() {
|
||||
let mut s = session(20, 20);
|
||||
|
||||
@@ -26,11 +26,15 @@ pub struct MixerSettings {
|
||||
pub flow: f32,
|
||||
/// Pick up colour from all visible layers instead of the target layer only.
|
||||
pub sample_all_layers: bool,
|
||||
/// Load the brush with the stroke color after each stroke (the automatic Load option).
|
||||
pub load_after_stroke: bool,
|
||||
/// Empty the brush after each stroke (the automatic Clean option).
|
||||
pub clean_after_stroke: bool,
|
||||
}
|
||||
|
||||
impl Default for MixerSettings {
|
||||
fn default() -> Self {
|
||||
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false }
|
||||
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false, load_after_stroke: true, clean_after_stroke: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,18 +48,47 @@ pub struct MixerState {
|
||||
pub amount: f32,
|
||||
/// Colour picked up from the canvas (straight RGBA), None = clean.
|
||||
pub pickup: Option<[f32; 4]>,
|
||||
/// The color recorded by the last stroke (its color) or the last Load Brush or Clean Brush
|
||||
/// (the foreground). None = no stroke, load or clean yet.
|
||||
pub color: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
impl MixerState {
|
||||
/// Load the reservoir with a colour (full).
|
||||
/// Load Brush: fill the reservoir with a color (full).
|
||||
pub fn load(&mut self, color: [f32; 4]) {
|
||||
self.reservoir = Some(color);
|
||||
self.amount = 1.0;
|
||||
}
|
||||
/// Clean the brush (drop the picked-up colour).
|
||||
/// Clean Brush: empty the reservoir and the pickup well.
|
||||
pub fn clean(&mut self) {
|
||||
self.reservoir = None;
|
||||
self.amount = 0.0;
|
||||
self.pickup = None;
|
||||
}
|
||||
/// Before a stroke in `color`: choosing a color other than [`MixerState::color`] loads the
|
||||
/// reservoir with it.
|
||||
pub fn begin_stroke(&mut self, color: [f32; 4]) {
|
||||
if self.color != Some(color) {
|
||||
self.load(color);
|
||||
self.color = Some(color);
|
||||
}
|
||||
}
|
||||
/// After a stroke in `color`: the automatic Clean empties the brush, then the automatic Load
|
||||
/// fills the reservoir again.
|
||||
pub fn end_stroke(&mut self, color: [f32; 4], clean: bool, load: bool) {
|
||||
if clean {
|
||||
self.clean();
|
||||
}
|
||||
if load {
|
||||
self.load(color);
|
||||
}
|
||||
self.color = Some(color);
|
||||
}
|
||||
/// The reservoir color the next stroke in `color` starts with (the Current Brush Load
|
||||
/// swatch); None = a clean brush.
|
||||
pub fn next_load(&self, color: [f32; 4]) -> Option<[f32; 4]> {
|
||||
if self.color != Some(color) { Some(color) } else { self.reservoir }
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint a Mixer Brush stroke. `sample` is the surface colour is picked up from (the layer itself
|
||||
@@ -209,6 +242,48 @@ mod tests {
|
||||
assert!(st.pickup.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_brush_paints_only_the_canvas_color_it_picks_up() {
|
||||
let mut st = MixerState::default();
|
||||
st.load([0.0, 0.0, 1.0, 1.0]);
|
||||
st.pickup = Some([0.0, 1.0, 0.0, 1.0]);
|
||||
st.clean();
|
||||
assert_eq!((st.reservoir, st.amount, st.pickup), (None, 0.0, None));
|
||||
// On red, the clean wet brush smears red and lays down none of the blue it was loaded with.
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
s.fill_rect(Rect::new(0, 0, 200, 20), &[1.0, 0.0, 0.0, 1.0]);
|
||||
apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings::default(), &mut st, None, false);
|
||||
let c = s.rgba(100, 10);
|
||||
assert!(c[0] > 0.99 && c[1] < 0.01 && c[2] < 0.01, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_color_loads_the_brush_and_the_automatic_options_run_after_the_stroke() {
|
||||
let (blue, green) = ([0.0, 0.0, 1.0, 1.0], [0.0, 1.0, 0.0, 1.0]);
|
||||
let mut st = MixerState::default();
|
||||
// A brush never loaded takes the stroke's color.
|
||||
assert_eq!(st.next_load(blue), Some(blue));
|
||||
st.begin_stroke(blue);
|
||||
assert_eq!((st.reservoir, st.amount), (Some(blue), 1.0));
|
||||
// Clean on, Load off: the next stroke in the same color starts empty.
|
||||
st.end_stroke(blue, true, false);
|
||||
assert_eq!(st.next_load(blue), None);
|
||||
st.begin_stroke(blue);
|
||||
assert_eq!((st.reservoir, st.pickup), (None, None));
|
||||
// Choosing another color loads it.
|
||||
assert_eq!(st.next_load(green), Some(green));
|
||||
st.begin_stroke(green);
|
||||
assert_eq!((st.reservoir, st.amount), (Some(green), 1.0));
|
||||
// Load on, Clean off: the reservoir is full again and the pickup stays.
|
||||
st.amount = 0.1;
|
||||
st.pickup = Some(blue);
|
||||
st.end_stroke(green, false, true);
|
||||
assert_eq!((st.reservoir, st.amount, st.pickup), (Some(green), 1.0, Some(blue)));
|
||||
// Both on: a fresh load with no pickup.
|
||||
st.end_stroke(green, true, true);
|
||||
assert_eq!((st.reservoir, st.amount, st.pickup), (Some(green), 1.0, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_empty_brush_paints_nothing() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
|
||||
@@ -97,7 +97,15 @@ fn edit_section(i: usize, b: &mut BrushSettings) {
|
||||
b.transfer.flow = dynamic(0.25, Control::Fade, 0.3);
|
||||
b.transfer.wetness = dynamic(0.35, Control::PenPressure, 0.1);
|
||||
b.transfer.mix = dynamic(0.45, Control::Fade, 0.2);
|
||||
b.mixer = paint::MixerSettings { wet: 0.9, load: 0.15, mix: 0.7, flow: 0.8, sample_all_layers: true };
|
||||
b.mixer = paint::MixerSettings {
|
||||
wet: 0.9,
|
||||
load: 0.15,
|
||||
mix: 0.7,
|
||||
flow: 0.8,
|
||||
sample_all_layers: true,
|
||||
load_after_stroke: false,
|
||||
clean_after_stroke: false,
|
||||
};
|
||||
}
|
||||
7 => {
|
||||
let p = &mut b.pose;
|
||||
@@ -488,6 +496,56 @@ fn mixer_brush_options_update_the_persisted_mixer_settings() {
|
||||
assert_eq!(params["brush"]["mixer"]["sampleAllLayers"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixer_brush_clean_brush_and_the_automatic_load_keep_the_foreground_out() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = options_bar_harness(crate::state::Tool::MixerBrush);
|
||||
// A red image, an empty layer above it, a blue foreground, and a brush that samples all layers.
|
||||
for (id, p) in [
|
||||
("file.new", json!({"width": 200, "height": 40, "background": "transparent"})),
|
||||
("edit.fill", json!({"color": "#ff0000"})),
|
||||
("layer.new.layer", json!({})),
|
||||
("tools.setColors", json!({"foreground": "#0000ff"})),
|
||||
("tools.setBrush", json!({"brush": {"size": 8, "pressureSize": false, "mixer": {"sampleAllLayers": true}}})),
|
||||
] {
|
||||
h.state_mut().run(id, p).unwrap();
|
||||
}
|
||||
h.run_steps(3);
|
||||
for label in ["Current Brush Load", "Load the brush after each stroke", "Clean the brush after each stroke"] {
|
||||
assert!(h.get_by_label(label).rect().is_positive(), "missing Mixer Brush option {label}");
|
||||
}
|
||||
|
||||
// Turn the automatic Load off: one tools.setBrush call.
|
||||
h.get_by_label("Load the brush after each stroke").click();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().session.tools.brush.mixer.load_after_stroke);
|
||||
let (id, params) = last_journal(h.state()).unwrap();
|
||||
assert_eq!((id.as_str(), ¶ms["brush"]["mixer"]["loadAfterStroke"]), ("tools.setBrush", &json!(false)));
|
||||
|
||||
// Current Brush Load › Clean Brush empties the brush.
|
||||
h.get_by_label("Current Brush Load").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Clean Brush").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(last_journal(h.state()).map(|(id, _)| id), Some("brush.mixer.clean".to_string()));
|
||||
assert_eq!(h.state().session.tools.mixer.reservoir, None);
|
||||
|
||||
// The stroke the canvas commits on release lays down only the red it picks up.
|
||||
crate::retouch_ui::finish_stroke(h.state_mut(), crate::state::Tool::MixerBrush, &[[5.0, 20.0, 1.0], [195.0, 20.0, 1.0]], egui::Modifiers::NONE);
|
||||
let d = h.state().session.active().unwrap();
|
||||
let c = d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(100, 20);
|
||||
assert!(c[3] > 0.1 && c[0] > 0.99 && c[2] < 0.01, "the stroke carries no foreground: {c:?}");
|
||||
// With the automatic Load off, the stroke did not refill the brush either.
|
||||
assert_eq!(h.state().session.tools.mixer.reservoir, None);
|
||||
|
||||
// Load Brush fills it with the foreground again.
|
||||
h.get_by_label("Current Brush Load").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Load Brush").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.tools.mixer.reservoir, Some([0.0, 0.0, 1.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_targets_and_actions_reorder_presets() {
|
||||
use crate::brushes_tab::{Action, apply, drop_target, group_key};
|
||||
|
||||
@@ -565,6 +565,8 @@ fn mixer_options(ui: &mut egui::Ui, b: &mut BrushSettings) {
|
||||
pct(ui, tl!("Mix"), &mut m.mix, 1.0);
|
||||
pct(ui, tl!("Flow"), &mut m.flow, 1.0);
|
||||
widgets::checkbox(ui, &mut m.sample_all_layers, "Sample All Layers");
|
||||
widgets::checkbox(ui, &mut m.load_after_stroke, tl!("Load the brush after each stroke"));
|
||||
widgets::checkbox(ui, &mut m.clean_after_stroke, tl!("Clean the brush after each stroke"));
|
||||
}
|
||||
|
||||
/// Tilt is stored in degrees (±90, W3C Pointer Events); Photoshop shows it as ±100 %.
|
||||
|
||||
@@ -2171,6 +2171,11 @@
|
||||
Color Replacement Tool Nástroj Nahrazení barvy
|
||||
Color Replacement Nahrazení barvy
|
||||
Wet Vlhkost
|
||||
Current Brush Load Aktuální náplň štětce
|
||||
Load Brush Naplnit štětec
|
||||
Clean Brush Vyčistit štětec
|
||||
Load the brush after each stroke Naplnit štětec po každém tahu
|
||||
Clean the brush after each stroke Vyčistit štětec po každém tahu
|
||||
Mix Míchání
|
||||
Wetness Jitter Kolísání vlhkosti
|
||||
Mix Jitter Kolísání míchání
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2140,6 +2140,11 @@
|
||||
Mixer Brush Tool Mischer-Pinsel-Werkzeug
|
||||
Color Replacement Tool Farbersatz-Werkzeug
|
||||
Wet Feucht
|
||||
Current Brush Load Aktuelle Pinselladung
|
||||
Load Brush Pinsel laden
|
||||
Clean Brush Pinsel reinigen
|
||||
Load the brush after each stroke Pinsel nach jedem Strich laden
|
||||
Clean the brush after each stroke Pinsel nach jedem Strich reinigen
|
||||
Mix Mischen
|
||||
Wetness Jitter Feuchtigkeitsschwankung
|
||||
Mix Jitter Mischschwankung
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1984,6 +1984,11 @@
|
||||
Color Replacement Tool Αντικατάσταση χρώματος (Color Replacement Tool)
|
||||
Color Replacement Αντικατάσταση χρώματος
|
||||
Wet Υγρασία
|
||||
Current Brush Load Τρέχουσα φόρτωση πινέλου
|
||||
Load Brush Φόρτωση πινέλου
|
||||
Clean Brush Καθαρισμός πινέλου
|
||||
Load the brush after each stroke Φόρτωση του πινέλου μετά από κάθε πινελιά
|
||||
Clean the brush after each stroke Καθαρισμός του πινέλου μετά από κάθε πινελιά
|
||||
Load Φόρτωση
|
||||
Mix Ανάμειξη
|
||||
8 bit 8 bit ανά κανάλι
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1030 and column 18.
|
@@ -2158,6 +2158,11 @@
|
||||
Mixer Brush Tool Herramienta Pincel mezclador
|
||||
Color Replacement Tool Herramienta Sustitución de color
|
||||
Wet Húmedo
|
||||
Current Brush Load Carga actual del pincel
|
||||
Load Brush Cargar pincel
|
||||
Clean Brush Limpiar pincel
|
||||
Load the brush after each stroke Cargar el pincel después de cada trazo
|
||||
Clean the brush after each stroke Limpiar el pincel después de cada trazo
|
||||
Mix Mezcla
|
||||
Wetness Jitter Variación de humedad
|
||||
Mix Jitter Variación de mezcla
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2118,6 +2118,11 @@
|
||||
Apply the pen's tilt and rotation to the tip shape Appliquer l'inclinaison et la rotation du stylet à la forme de la pointe
|
||||
Mixer Brush Pinceau mélangeur
|
||||
Wet Humidité
|
||||
Current Brush Load Charge actuelle du pinceau
|
||||
Load Brush Charger le pinceau
|
||||
Clean Brush Nettoyer le pinceau
|
||||
Load the brush after each stroke Charger le pinceau après chaque trait
|
||||
Clean the brush after each stroke Nettoyer le pinceau après chaque trait
|
||||
Mix Mélange
|
||||
Wetness Jitter Variation de l'humidité
|
||||
Mix Jitter Variation du mélange
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1407,6 +1407,11 @@
|
||||
Warp: Hajlítás:
|
||||
Weight Vastagság
|
||||
Wet Nedvesség
|
||||
Current Brush Load Az ecset jelenlegi töltete
|
||||
Load Brush Ecset feltöltése
|
||||
Clean Brush Ecset tisztítása
|
||||
Load the brush after each stroke Az ecset feltöltése minden ecsetvonás után
|
||||
Clean the brush after each stroke Az ecset tisztítása minden ecsetvonás után
|
||||
Wet Edges Nedves szélek
|
||||
Wetness Jitter Nedvesség ingadozása
|
||||
Width Szélesség
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2112,6 +2112,11 @@
|
||||
Apply the pen's tilt and rotation to the tip shape Terapkan kemiringan dan rotasi pena ke bentuk ujung
|
||||
Mixer Brush Kuas Pencampur
|
||||
Wet Basah
|
||||
Current Brush Load Muatan Kuas Saat Ini
|
||||
Load Brush Muat Kuas
|
||||
Clean Brush Bersihkan Kuas
|
||||
Load the brush after each stroke Muat kuas setelah setiap goresan
|
||||
Clean the brush after each stroke Bersihkan kuas setelah setiap goresan
|
||||
Mix Campur
|
||||
Wetness Jitter Jitter Kebasahan
|
||||
Mix Jitter Jitter Campuran
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2153,6 +2153,11 @@
|
||||
Mixer Brush Tool Strumento Pennello miscelatore
|
||||
Color Replacement Tool Strumento Sostituzione colore
|
||||
Wet Bagnato
|
||||
Current Brush Load Carico attuale del pennello
|
||||
Load Brush Carica pennello
|
||||
Clean Brush Pulisci pennello
|
||||
Load the brush after each stroke Carica il pennello dopo ogni tratto
|
||||
Clean the brush after each stroke Pulisci il pennello dopo ogni tratto
|
||||
Mix Miscela
|
||||
Wetness Jitter Variazione bagnato
|
||||
Mix Jitter Variazione miscela
|
||||
|
||||
|
@@ -2153,6 +2153,11 @@
|
||||
Mixer Brush Tool 混合ブラシツール
|
||||
Color Replacement Tool 色の置き換えツール
|
||||
Wet ウェット
|
||||
Current Brush Load 現在のブラシの絵の具
|
||||
Load Brush ブラシに補充
|
||||
Clean Brush ブラシを洗浄
|
||||
Load the brush after each stroke ストロークごとにブラシに補充
|
||||
Clean the brush after each stroke ストロークごとにブラシを洗浄
|
||||
Mix 混合
|
||||
Wetness Jitter ウェットのジッター
|
||||
Mix Jitter 混合のジッター
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2114,6 +2114,11 @@
|
||||
Apply the pen's tilt and rotation to the tip shape 펜의 기울기와 회전을 브러시 끝 모양에 적용
|
||||
Mixer Brush 혼합 브러시
|
||||
Wet 젖음
|
||||
Current Brush Load 현재 브러시 물감
|
||||
Load Brush 브러시 채우기
|
||||
Clean Brush 브러시 세척
|
||||
Load the brush after each stroke 획마다 브러시 채우기
|
||||
Clean the brush after each stroke 획마다 브러시 세척
|
||||
Mix 혼합
|
||||
Wetness Jitter 젖음 지터
|
||||
Mix Jitter 혼합 지터
|
||||
|
||||
|
@@ -1272,7 +1272,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mixer_brush_ui_strings_have_translations_in_every_registered_language() {
|
||||
const STRINGS: &[&str] = &["Mixer Brush", "Mixer Brush Tool", "Wet", "Load", "Mix", "Flow", "Sample All Layers"];
|
||||
const STRINGS: &[&str] = &[
|
||||
"Mixer Brush",
|
||||
"Mixer Brush Tool",
|
||||
"Wet",
|
||||
"Load",
|
||||
"Mix",
|
||||
"Flow",
|
||||
"Sample All Layers",
|
||||
"Current Brush Load",
|
||||
"Load Brush",
|
||||
"Clean Brush",
|
||||
"Load the brush after each stroke",
|
||||
"Clean the brush after each stroke",
|
||||
];
|
||||
for lang in Lang::all() {
|
||||
for source in STRINGS {
|
||||
let translated = tr(lang, source);
|
||||
|
||||
@@ -2118,6 +2118,11 @@
|
||||
Mixer Brush Tool Mengpenseelgereedschap
|
||||
Color Replacement Tool Kleurvervangingsgereedschap
|
||||
Wet Nat
|
||||
Current Brush Load Huidige penseellading
|
||||
Load Brush Penseel laden
|
||||
Clean Brush Penseel reinigen
|
||||
Load the brush after each stroke Penseel laden na elke streek
|
||||
Clean the brush after each stroke Penseel reinigen na elke streek
|
||||
Mix Mengen
|
||||
Wetness Jitter Nattigheidsvariatie
|
||||
Mix Jitter Mengvariatie
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -2137,6 +2137,11 @@
|
||||
Mixer Brush Tool Narzędzie Pędzel mieszający
|
||||
Color Replacement Tool Narzędzie Zamiana koloru
|
||||
Wet Wilgotność
|
||||
Current Brush Load Bieżący ładunek pędzla
|
||||
Load Brush Napełnij pędzel
|
||||
Clean Brush Wyczyść pędzel
|
||||
Load the brush after each stroke Napełniaj pędzel po każdym pociągnięciu
|
||||
Clean the brush after each stroke Czyść pędzel po każdym pociągnięciu
|
||||
Mix Mieszanie
|
||||
Wetness Jitter Rozrzut wilgotności
|
||||
Mix Jitter Rozrzut mieszania
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2112,6 +2112,11 @@
|
||||
Apply the pen's tilt and rotation to the tip shape Aplicar a inclinação e a rotação da caneta à forma da ponta
|
||||
Mixer Brush Pincel misturador
|
||||
Wet Molhado
|
||||
Current Brush Load Carga atual do pincel
|
||||
Load Brush Carregar pincel
|
||||
Clean Brush Limpar pincel
|
||||
Load the brush after each stroke Carregar o pincel após cada traço
|
||||
Clean the brush after each stroke Limpar o pincel após cada traço
|
||||
Mix Mistura
|
||||
Wetness Jitter Variação da umidade
|
||||
Mix Jitter Variação da mistura
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1023,6 +1023,11 @@
|
||||
Mix Смешивание
|
||||
Auto Clean Brush Автоочистка кисти
|
||||
Wet Влага
|
||||
Current Brush Load Текущая загрузка кисти
|
||||
Load Brush Загрузить кисть
|
||||
Clean Brush Очистить кисть
|
||||
Load the brush after each stroke Загружать кисть после каждого штриха
|
||||
Clean the brush after each stroke Очищать кисть после каждого штриха
|
||||
Rate Скорость
|
||||
Airbrush: keeps depositing paint while the pointer rests. Аэрограф: продолжает наносить краску, пока курсор неподвижен.
|
||||
Pulled String Mode Режим натянутой нити
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2095,6 +2095,11 @@
|
||||
Apply the pen's tilt and rotation to the tip shape Primeni nagib i rotaciju olovke na oblik vrha
|
||||
Mixer Brush Četkica za mešanje
|
||||
Wet Vlažnost
|
||||
Current Brush Load Trenutno punjenje četkice
|
||||
Load Brush Napuni četkicu
|
||||
Clean Brush Očisti četkicu
|
||||
Load the brush after each stroke Napuni četkicu posle svakog poteza
|
||||
Clean the brush after each stroke Očisti četkicu posle svakog poteza
|
||||
Mix Mešanje
|
||||
Wetness Jitter Kolebanje vlažnosti
|
||||
Mix Jitter Kolebanje mešanja
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2092,6 +2092,11 @@
|
||||
Mixer Brush Tool Karıştırıcı Fırça Aracı
|
||||
Color Replacement Tool Renk Değiştirme Aracı
|
||||
Wet Islaklık
|
||||
Current Brush Load Geçerli Fırça Yükü
|
||||
Load Brush Fırçayı Yükle
|
||||
Clean Brush Fırçayı Temizle
|
||||
Load the brush after each stroke Her vuruştan sonra fırçayı yükle
|
||||
Clean the brush after each stroke Her vuruştan sonra fırçayı temizle
|
||||
Mix Karışım
|
||||
Wetness Jitter Islaklık Titreme
|
||||
Mix Jitter Karıştırma Titreşimi
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -1000,6 +1000,11 @@
|
||||
Mix Змішування
|
||||
Auto Clean Brush Автоматично очищувати пензель
|
||||
Wet Вологість
|
||||
Current Brush Load Поточне завантаження пензля
|
||||
Load Brush Завантажити пензель
|
||||
Clean Brush Очистити пензель
|
||||
Load the brush after each stroke Завантажувати пензель після кожного штриха
|
||||
Clean the brush after each stroke Очищати пензель після кожного штриха
|
||||
Rate Частота
|
||||
Airbrush: keeps depositing paint while the pointer rests. Аерограф: продовжує наносити фарбу, коли вказівник нерухомий.
|
||||
Pulled String Mode Режим натягнутої нитки
|
||||
|
||||
|
@@ -1296,6 +1296,11 @@
|
||||
Mixer Brush Cọ trộn
|
||||
Mixer Brush Tool Công cụ Cọ trộn
|
||||
Wet Ướt
|
||||
Current Brush Load Lượng sơn hiện tại của cọ
|
||||
Load Brush Nạp sơn cho cọ
|
||||
Clean Brush Làm sạch cọ
|
||||
Load the brush after each stroke Nạp sơn cho cọ sau mỗi nét vẽ
|
||||
Clean the brush after each stroke Làm sạch cọ sau mỗi nét vẽ
|
||||
Load Tải
|
||||
Mix Trộn
|
||||
Flow Lưu lượng
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2221,6 +2221,11 @@ cameraRaw Blending 混合
|
||||
Apply the pen's tilt and rotation to the tip shape 将笔的倾斜和旋转应用于笔尖形状
|
||||
Mixer Brush 混合器画笔
|
||||
Wet 湿润
|
||||
Current Brush Load 当前画笔载入
|
||||
Load Brush 载入画笔
|
||||
Clean Brush 清理画笔
|
||||
Load the brush after each stroke 每次描边后载入画笔
|
||||
Clean the brush after each stroke 每次描边后清理画笔
|
||||
Mix 混合
|
||||
Wetness Jitter 湿润度抖动
|
||||
Mix Jitter 混合抖动
|
||||
|
||||
|
@@ -2160,6 +2160,11 @@
|
||||
Color Replacement Tool 顏色取代工具
|
||||
Color Replacement 顏色取代
|
||||
Wet 濕度
|
||||
Current Brush Load 目前筆刷載入
|
||||
Load Brush 載入筆刷
|
||||
Clean Brush 清理筆刷
|
||||
Load the brush after each stroke 每次描邊後載入筆刷
|
||||
Clean the brush after each stroke 每次描邊後清理筆刷
|
||||
Mix 混合
|
||||
Wetness Jitter 濕度快速變換
|
||||
Mix Jitter 混合快速變換
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1122,6 +1122,17 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
brush_preset_chip(ui, b, &mut app.ui);
|
||||
crate::brush_picker::settings_toggle(app, ui);
|
||||
widgets::vline(ui, 22.0);
|
||||
mixer_load_swatch(app, ui);
|
||||
let m = &mut b.mixer;
|
||||
for (icon, on, tip) in [
|
||||
("brush", &mut m.load_after_stroke, tl!("Load the brush after each stroke")),
|
||||
("brush-cleaning", &mut m.clean_after_stroke, tl!("Clean the brush after each stroke")),
|
||||
] {
|
||||
if icons::button(ui, icon, 24.0, *on, tip).clicked() {
|
||||
*on = !*on;
|
||||
}
|
||||
}
|
||||
widgets::vline(ui, 22.0);
|
||||
for (label, value) in [
|
||||
(tl!("Wet"), &mut b.mixer.wet),
|
||||
(tl!("Load"), &mut b.mixer.load),
|
||||
@@ -4124,6 +4135,47 @@ fn smoothing_options(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings
|
||||
});
|
||||
}
|
||||
|
||||
/// Mixer Brush: the Current Brush Load swatch shows the paint the next stroke starts with (an
|
||||
/// empty swatch when the brush is clean). Its menu holds Load Brush and Clean Brush.
|
||||
fn mixer_load_swatch(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(40.0, 26.0), Sense::hover());
|
||||
let resp = ui.interact(r, ui.id().with("mixer-brush-load"), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.hover);
|
||||
}
|
||||
let chip = Rect::from_min_size(pos2(r.left() + 4.0, r.center().y - 8.0), vec2(16.0, 16.0));
|
||||
let p = ui.painter();
|
||||
match app.session.tools.mixer.next_load(app.session.tools.foreground) {
|
||||
Some(c) => {
|
||||
p.rect_filled(chip, 2.0, c32(c));
|
||||
}
|
||||
None => {
|
||||
p.rect_filled(chip, 2.0, t.field);
|
||||
p.line_segment([chip.left_bottom(), chip.right_top()], Stroke::new(1.0, t.text_faint));
|
||||
}
|
||||
}
|
||||
p.rect_stroke(chip, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
|
||||
icons::paint(ui, Rect::from_center_size(pos2(r.right() - 9.0, r.center().y), vec2(10.0, 10.0)), "chevron-down", 9.0, t.text_faint);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Current Brush Load")));
|
||||
let resp = resp.on_hover_text(tl!("Current Brush Load"));
|
||||
let mut run = None;
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
if ui.button(tl!("Load Brush")).clicked() {
|
||||
run = Some("brush.mixer.load");
|
||||
}
|
||||
if ui.button(tl!("Clean Brush")).clicked() {
|
||||
run = Some("brush.mixer.clean");
|
||||
}
|
||||
});
|
||||
if let Some(id) = run
|
||||
&& let Err(e) = app.run(id, json!({}))
|
||||
{
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Options-bar brush chip: a click shows or hides Photoshop's Brush Preset picker below it (size,
|
||||
/// hardness, the preset library), the one a right-click on the canvas opens.
|
||||
fn brush_preset_chip(ui: &mut egui::Ui, b: &photocraft_engine::BrushSettings, state: &mut crate::state::UiState) {
|
||||
|
||||
@@ -91,7 +91,7 @@ tablets against Photoshop side by side.
|
||||
| Tool | Status | Notes |
|
||||
|---|---|---|
|
||||
| Brush, Pencil (aliased, Auto Erase), Eraser (Brush/Pencil/Block) | done | Pencil 1 px quality #2139 |
|
||||
| Mixer Brush (Wet, Load, Mix, Flow, Sample All Layers) | partial | "incomplete" (#2247) |
|
||||
| Mixer Brush (Wet, Load, Mix, Flow, Sample All Layers, Current Brush Load with Load Brush and Clean Brush, automatic Load and Clean) | partial | No live result while dragging, no Alt-click loading (#2247) |
|
||||
| Clone Stamp (opacity, flow, aligned, sample, overlay) | partial | Clone Source W/H link and Alt target cursor (TOOL-213-8) |
|
||||
| Pattern Stamp (aligned, impressionist) | done | |
|
||||
| History Brush | done | Art History Brush missing |
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
| Id | Target | Status | Issue | Note |
|
||||
|---|---|---|---|---|
|
||||
| TOOL-213-1 | Pencil tool: aliased 1 px painting, square pixel-aligned cursor | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::Pencil (B group): live aliased strokes on the pixel grid, square cursor, Auto Erase; pencil_tests.rs |
|
||||
| TOOL-213-2 | Mixer Brush tool on the toolbar | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, command/control dispatch and selection-clipping tests |
|
||||
| TOOL-213-2 | Mixer Brush tool on the toolbar | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, the Current Brush Load swatch with Load Brush and Clean Brush (brush.mixer.load, brush.mixer.clean) and the automatic Load and Clean toggles; command/control dispatch and selection-clipping tests |
|
||||
| TOOL-213-3 | Patch tool with a live preview of the patched area | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.patch (retouch_cmds/patch.rs, source/destination) + Tool::Patch (J group: lasso, then drag the selection); the drag shows the heal live (patch_preview.rs, coarse colour fit: 1000x1000 in 52 ms) |
|
||||
| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, small selections only, #4385). Missing: a progressive preview for big selections |
|
||||
| TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm |
|
||||
|
||||
@@ -17,7 +17,7 @@ id = "TOOL-213-2"
|
||||
target = "Mixer Brush tool on the toolbar"
|
||||
status = "done"
|
||||
issue = 213
|
||||
note = "Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, command/control dispatch and selection-clipping tests"
|
||||
note = "Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, the Current Brush Load swatch with Load Brush and Clean Brush (brush.mixer.load, brush.mixer.clean) and the automatic Load and Clean toggles; command/control dispatch and selection-clipping tests"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-213-3"
|
||||
|
||||
Reference in New Issue
Block a user