mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
* Clone Source: clip the overlay to a shaped brush tip (#4372) Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs: regenerate scorecard after merging main Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
4 files changed
+107
-5
No files matched your search
@@ -73,7 +73,7 @@ pub(crate) fn cursor(painter: &Painter, at: Pos2, brush: &BrushSettings, radius:
|
||||
|
||||
/// The tip outline in radius units, cached across frames; `None` when the plain circle says it all
|
||||
/// (a plain round tip) or the tip has no traceable shape.
|
||||
fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> Option<Arc<Vec<Vec<[f32; 2]>>>> {
|
||||
pub(crate) fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> Option<Arc<Vec<Vec<[f32; 2]>>>> {
|
||||
if matches!(brush.tip, TipShape::Round) && brush.angle == 0.0 && brush.roundness >= 1.0 {
|
||||
return None;
|
||||
}
|
||||
@@ -88,6 +88,43 @@ fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> Option<Arc<V
|
||||
traced
|
||||
}
|
||||
|
||||
/// Which texels of the document-pixel `rect` (`x0, y0, x1, y1`, row-major) lie inside the tip
|
||||
/// `rings` (radius units, even-odd so holes stay clear) when the tip is centred on `at` with
|
||||
/// `radius` document pixels. The Clone Source overlay uses it to clip to a shaped tip.
|
||||
pub(crate) fn tip_mask(rings: &[Vec<[f32; 2]>], at: [f64; 2], radius: f64, rect: [i32; 4]) -> Vec<bool> {
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
let (w, h) = (usize::try_from(x1 - x0).unwrap_or(0), usize::try_from(y1 - y0).unwrap_or(0));
|
||||
let mut mask = vec![false; w.saturating_mul(h)];
|
||||
let mut hits: Vec<f64> = Vec::new();
|
||||
for row in 0..h {
|
||||
let y = f64::from(y0) + row as f64 + 0.5;
|
||||
hits.clear();
|
||||
for ring in rings {
|
||||
for (i, a) in ring.iter().enumerate() {
|
||||
let Some(b) = ring.get((i + 1) % ring.len()) else { continue };
|
||||
let (ax, ay) = (at[0] + f64::from(a[0]) * radius, at[1] + f64::from(a[1]) * radius);
|
||||
let (bx, by) = (at[0] + f64::from(b[0]) * radius, at[1] + f64::from(b[1]) * radius);
|
||||
if (ay <= y) != (by <= y) {
|
||||
hits.push(ax + (y - ay) / (by - ay) * (bx - ax));
|
||||
}
|
||||
}
|
||||
}
|
||||
hits.sort_by(f64::total_cmp);
|
||||
for pair in hits.chunks(2) {
|
||||
let [a, b] = pair else { continue };
|
||||
let (from, to) = (a - f64::from(x0) - 0.5, b - f64::from(x0) - 0.5);
|
||||
let start = from.ceil().max(0.0) as usize;
|
||||
let end = ((to.floor() + 1.0).max(0.0) as usize).min(w);
|
||||
for x in start..end {
|
||||
if let Some(m) = mask.get_mut(row * w + x) {
|
||||
*m = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
mask
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -142,4 +179,19 @@ mod tests {
|
||||
assert!(ex.x.is_finite() && ex.y.is_finite() && ey.x.is_finite() && ey.y.is_finite(), "size {size}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The overlay clip follows the tip: a square ring covers its own texels, a hole stays clear,
|
||||
/// and a rotated flat tip leaves the corners of the brush square outside.
|
||||
#[test]
|
||||
fn tip_mask_follows_the_ring_shape() {
|
||||
let square = vec![[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]];
|
||||
let hole = vec![[-0.1, -0.1], [0.1, -0.1], [0.1, 0.1], [-0.1, 0.1]];
|
||||
let mask = tip_mask(&[square.clone(), hole], [10.0, 10.0], 10.0, [0, 0, 20, 20]);
|
||||
let at = |x: usize, y: usize| mask[y * 20 + x];
|
||||
assert!(at(6, 10) && at(14, 10) && at(10, 6) && at(10, 14), "inside the square");
|
||||
assert!(!at(3, 10) && !at(10, 16) && !at(0, 0), "outside the square");
|
||||
assert!(!at(10, 10) && at(8, 10), "the hole is clear");
|
||||
assert_eq!(mask.iter().filter(|m| **m).count(), 10 * 10 - 2 * 2, "10x10 square minus the 2x2 hole");
|
||||
assert!(tip_mask(&[square], [10.0, 10.0], 10.0, [5, 5, 5, 9]).is_empty(), "an empty rect is harmless");
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,17 @@ fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie
|
||||
let rect = DRect::new((at[0] - radius).floor() as i32, (at[1] - radius).floor() as i32, (at[0] + radius).ceil() as i32, (at[1] + radius).ceil() as i32);
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let (display, display_key) = canvas_display(app, &st.doc, output);
|
||||
// A shaped tip (sampled bitmap, or a rotated / flattened round one) clips the overlay to its
|
||||
// outline instead of a circle, as Photoshop does with Clipped on.
|
||||
let tip_rings = if overlay.clipped { crate::brush_cursor::rings(painter.ctx(), &app.session.tools.brush, true) } else { None };
|
||||
let tip_key = tip_rings.as_ref().map(|r| {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
r.iter().flatten().for_each(|p| (p[0].to_bits(), p[1].to_bits()).hash(&mut h));
|
||||
(h.finish(), radius.to_bits(), at.map(f64::to_bits))
|
||||
});
|
||||
let key = format!(
|
||||
"{:?}:{}:{:?}:{rect:?}:{map:?}:{}:{}:{}:{}",
|
||||
"{:?}:{}:{:?}:{rect:?}:{map:?}:{}:{}:{}:{}:{tip_key:?}",
|
||||
st.doc.id, st.revision, st.active_layer, app.ui.tool_options.clone_sample, display_key, overlay.invert, overlay.blend
|
||||
);
|
||||
if app.clone_preview.as_ref().is_none_or(|c| c.key != key) {
|
||||
@@ -62,6 +71,14 @@ fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie
|
||||
*px = overlay_blend(&overlay.blend, *px, *d);
|
||||
}
|
||||
}
|
||||
if let Some(rings) = &tip_rings {
|
||||
let mask = crate::brush_cursor::tip_mask(rings, at, radius, [rect.x0, rect.y0, rect.x1, rect.y1]);
|
||||
for (px, inside) in buf.px.iter_mut().zip(mask) {
|
||||
if !inside {
|
||||
*px = [0.0; 4];
|
||||
}
|
||||
}
|
||||
}
|
||||
let image = display_image(display.as_deref(), &buf);
|
||||
match app.clone_preview.as_mut() {
|
||||
Some(cache) => {
|
||||
@@ -80,7 +97,7 @@ fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie
|
||||
uv: pos2((x - f64::from(rect.x0)) as f32 / rect.width() as f32, (y - f64::from(rect.y0)) as f32 / rect.height() as f32),
|
||||
color: tint,
|
||||
};
|
||||
if !overlay.clipped {
|
||||
if !overlay.clipped || tip_rings.is_some() {
|
||||
for (x, y) in [(rect.x0, rect.y0), (rect.x1, rect.y0), (rect.x1, rect.y1), (rect.x0, rect.y1)] {
|
||||
mesh.vertices.push(vertex(f64::from(x), f64::from(y)));
|
||||
}
|
||||
@@ -6137,6 +6154,39 @@ mod tests {
|
||||
assert_eq!(centre("lighten"), Color32::WHITE);
|
||||
}
|
||||
|
||||
/// A shaped tip clips the Clone Source overlay to its outline, not to the brush circle.
|
||||
#[test]
|
||||
fn clone_overlay_is_clipped_to_a_shaped_tip() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||||
app.run("cloneSource.set", json!({"source": [16,16]})).unwrap();
|
||||
app.ui.tool = Tool::CloneStamp;
|
||||
app.session.tools.brush.size = 20.0;
|
||||
app.session.tools.brush.roundness = 0.25;
|
||||
let xf =
|
||||
ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 };
|
||||
let mut output = ctx.run_ui(Default::default(), |ui| {
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("clone-test")));
|
||||
draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None);
|
||||
});
|
||||
let image = output
|
||||
.textures_delta
|
||||
.set
|
||||
.values()
|
||||
.flatten()
|
||||
.find_map(|d| match &d.image {
|
||||
egui::ImageData::Color(c) if c.size == [20, 20] => Some(c.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the overlay uploads a texture");
|
||||
output.textures_delta.clear();
|
||||
let alpha = |x: usize, y: usize| image.pixels[y * image.width() + x].a();
|
||||
assert_eq!(alpha(10, 10), 255, "the tip's centre shows the source");
|
||||
assert_eq!(alpha(10, 2), 0, "inside the circle but outside the flat tip: clipped");
|
||||
assert_eq!(alpha(1, 10), 255, "the tip's long axis stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wayland_start_screen_hint_does_not_claim_file_drop_works() {
|
||||
let x11 = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
|
||||
+1
-1
@@ -137,7 +137,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
| 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 |
|
||||
| TOOL-213-6 | Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace | done | [#213](https://github.com/storytold/photocraft/issues/213) | fill_ui.rs dialog over edit.fill (fill_cmds.rs): all contents, mode, opacity, preserve; remembered in prefs; Shift+F5 / Shift+Backspace |
|
||||
| TOOL-213-7 | Live stroke preview for every brush-based retouch tool | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040); the release commits the live stroke's pixels (P32). Not yet live: Spot Healing Brush, Mixer Brush, Background Eraser, Pattern Stamp |
|
||||
| TOOL-213-8 | Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Clone Source overlay drawn with opacity, inversion, circular clipping, auto-hide and the Normal/Darken/Lighten/Difference blend against the image under the brush (canvas.rs draw_clone_preview, clone_source::overlay_blend); W/H link (cloneSource.set linked, panel chain button, #3298); custom-tip clipping remains missing |
|
||||
| TOOL-213-8 | Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Clone Source overlay drawn with opacity, inversion, circular clipping, auto-hide and the Normal/Darken/Lighten/Difference blend against the image under the brush (canvas.rs draw_clone_preview, clone_source::overlay_blend); a shaped tip (sampled bitmap, rotation, roundness) clips the overlay to its outline (brush_cursor::tip_mask, #4372); W/H link (cloneSource.set linked, panel chain button, #3298) |
|
||||
| TOOL-213-9 | Eyedropper comparison ring (new colour above, current below) while sampling | done | [#213](https://github.com/storytold/photocraft/issues/213) | eyedropper_ring (canvas.rs): the sampled colour on the upper arc, the foreground below, drawn for the Eyedropper and Alt-sampling while the button is held; the Precise-cursor preference keeps the crosshair |
|
||||
| TOOL-213-10 | Ctrl+Alt drag resizes brush size/hardness with an on-canvas readout | done | [#213](https://github.com/storytold/photocraft/issues/213) | brush_resize.rs (#297, #370): Alt+right-drag, or Control+Option-drag on macOS (Control+Alt everywhere), sets diameter horizontally and hardness vertically, circle plus Diameter/Hardness readout, one coalesced tools.setBrush, no paint or history; 9 tests there |
|
||||
| TOOL-213-11 | Editable pressure curve, global and per control | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Global: Preferences › Tools › Pressure Curve (prefs tools.pressureCurve; monotone cubic, engine prefs::PressureCurve; applied to every pen sample in ui-egui stylus.rs before any brush; editor pressure_curve_ui.rs with Linear/Soft/Hard presets). Missing: per-control curves (each brush dynamic's own response to pressure, tilt, rotation) |
|
||||
|
||||
@@ -59,7 +59,7 @@ id = "TOOL-213-8"
|
||||
target = "Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor"
|
||||
status = "partial"
|
||||
issue = 213
|
||||
note = "Clone Source overlay drawn with opacity, inversion, circular clipping, auto-hide and the Normal/Darken/Lighten/Difference blend against the image under the brush (canvas.rs draw_clone_preview, clone_source::overlay_blend); W/H link (cloneSource.set linked, panel chain button, #3298); custom-tip clipping remains missing"
|
||||
note = "Clone Source overlay drawn with opacity, inversion, circular clipping, auto-hide and the Normal/Darken/Lighten/Difference blend against the image under the brush (canvas.rs draw_clone_preview, clone_source::overlay_blend); a shaped tip (sampled bitmap, rotation, roundness) clips the overlay to its outline (brush_cursor::tip_mask, #4372); W/H link (cloneSource.set linked, panel chain button, #3298)"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-213-9"
|
||||
|
||||
Reference in new issue
Block a user