mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
fix(engine): adjustments on a smart object record smart filters (FILE-215-4); strokes honour doc CMYK (#2229)
On a smart-object layer, Image > Adjustments now records an editable
smart filter instead of committing disaster.
- new engine helpers:
- adjust_cmds::has_adjustable / adjust_as_smart_filter route
image.adjustments.<kind> onto the source, with the selection as the
filter mask, through smart_cmds::add_smart_filter
- brush/eraser/pencil/live preview strokes now render inside
with_cmyk_space(doc_cmyk_space(session)), so painted colours follow the
document profile the same way the compositor does
- docs/scorecard.md: FILE-215-4 partial -> done
- tests: adjust_cmds/tests + brush_cmds/tests, plus panic_hunt and
the lib suite all green on the committed tree
Known follow-up, already filed:
- FILE-215-5 rows: double-clicking an adjustment smart-filter row is still
a no-op. smart_ui::open_editor only proceeds when
filter_dialog::has_dialog returns true, and has_dialog accepts
filter.*, a fixed non-adjustment set and PREVIEWED - but not
image.adjustments.* (those open through adjust_dialog::open, which isn't
wired into the smart-filter row).
This is one commit on top of the latest upstream/main (rebased to fix the
stale fork ancestry that was blocking PR creation).
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
1 parent
29d5f791d5
commit
4c06b9fe4d
7 files changed
+299
-85
No files matched your search
@@ -250,6 +250,52 @@ fn has_pixels(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Image › Adjustments targets: a pixel layer, a smart object (the adjustment is recorded as a
|
||||
/// smart filter, as in Photoshop), or a targeted alpha channel / Quick Mask.
|
||||
pub(crate) fn has_adjustable(s: &Session) -> std::result::Result<(), String> {
|
||||
if crate::channel_cmds::edits_channel(s) {
|
||||
return Ok(());
|
||||
}
|
||||
let d = s.active().ok_or("no document open")?;
|
||||
let l = d.active_layer.and_then(|id| d.doc.layer(id)).ok_or("no active layer")?;
|
||||
match &l.content {
|
||||
LayerContent::Raster(_) => Ok(()),
|
||||
LayerContent::Smart(sm) if sm.cache.is_some() => Ok(()),
|
||||
other => Err(format!("active layer is {} {} layer, not a pixel layer", other.article(), other.kind_name())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Image › Adjustments on a smart object record `image.adjustments.<kind>` as a smart filter
|
||||
/// (Photoshop): the adjustment re-runs from the source whenever the object re-renders, through
|
||||
/// [`adjust_as_filter`]. `Ok(true)` when it was recorded (one history step, the selection becomes
|
||||
/// the filter mask); `Ok(false)` when the command should edit pixels instead (a mask or channel
|
||||
/// target, or the active layer is not a smart object).
|
||||
pub(crate) fn adjust_as_smart_filter(s: &mut Session, kind: &str, label: &str, p: &Value) -> Result<bool> {
|
||||
if crate::commands::is_mask_target(p) || crate::channel_cmds::is_channel_target(p) {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(id) = s.active().and_then(|d| d.active_layer) else { return Ok(false) };
|
||||
let smart = s.active().is_some_and(|d| matches!(d.doc.layer(id).map(|l| &l.content), Some(LayerContent::Smart(_))));
|
||||
if !smart {
|
||||
return Ok(false);
|
||||
}
|
||||
// The recorded params are the command's own, minus the engine's private `__kind` key.
|
||||
let mut params = p.clone();
|
||||
if let Value::Object(m) = &mut params {
|
||||
m.remove("__kind");
|
||||
}
|
||||
let sf = photocraft_doc::SmartFilter {
|
||||
command: format!("image.adjustments.{kind}"),
|
||||
params,
|
||||
blend: photocraft_color::BlendMode::Normal,
|
||||
opacity: 1.0,
|
||||
visible: true,
|
||||
};
|
||||
let sel = s.active().and_then(|d| d.doc.selection.clone());
|
||||
s.edit(label, |doc, _| crate::smart_cmds::add_smart_filter(doc, id, sf, sel.as_ref()))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||||
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
|
||||
}
|
||||
@@ -273,6 +319,9 @@ pub(crate) fn adjust_as_filter(kind: &str, p: &Value, surf: &Surface) -> Option<
|
||||
}
|
||||
|
||||
fn shadows_highlights(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if adjust_as_smart_filter(s, "shadowsHighlights", "Shadows/Highlights", p)? {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let sh = shadows_highlights_params(p);
|
||||
rgba_edit(s, "Shadows/Highlights", p, |px, r| tone::shadows_highlights(px, r.width() as usize, r.height() as usize, &sh))
|
||||
}
|
||||
@@ -406,7 +455,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shadows/Highlights…",
|
||||
["Image", "Adjustments"],
|
||||
r##"{"shadowAmount":0..100=35,"shadowTone":0..100=50,"shadowRadius":0..2500=30,"highlightAmount":0..100=0,"highlightTone":0..100=50,"highlightRadius":0..2500=30,"color":-100..100=20,"midtone":-100..100=0,"blackClip":0..50=0.01,"whiteClip":0..50=0.01}"##,
|
||||
has_pixels,
|
||||
has_adjustable,
|
||||
shadows_highlights
|
||||
),
|
||||
spec!(
|
||||
|
||||
@@ -356,6 +356,104 @@ fn invert_with_the_mask_targeted_inverts_the_mask() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Image › Adjustments as smart filters (FILE-215-4) ----------
|
||||
|
||||
/// A painted pixel layer converted to a smart object, over the painted Background.
|
||||
fn smart_session() -> Session {
|
||||
let mut s = session(8, "rgb");
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
paint(&mut s, |x, y| [0.1 + x as f32 / 60.0, 0.2 + y as f32 / 50.0, 0.6 - x as f32 / 120.0, 1.0]);
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn flatten(s: &Session) -> Vec<[f32; 4]> {
|
||||
photocraft_compose::flatten(doc(s)).px
|
||||
}
|
||||
|
||||
fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> f32 {
|
||||
a.iter().zip(b).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
fn smart_filters_of(s: &Session) -> Vec<photocraft_doc::SmartFilter> {
|
||||
let d = s.active().unwrap();
|
||||
match &d.doc.layer(d.active_layer.unwrap()).unwrap().content {
|
||||
LayerContent::Smart(sm) => sm.smart_filters.clone(),
|
||||
other => panic!("not a smart object: {}", other.kind_name()),
|
||||
}
|
||||
}
|
||||
|
||||
/// FILE-215-4: Image › Adjustments on a smart object record a smart filter (Photoshop) instead
|
||||
/// of baking into a copy of the pixels: it renders exactly what the destructive command would
|
||||
/// bake in, and can be hidden, re-edited and undone, each in one history step.
|
||||
#[test]
|
||||
fn adjustments_on_a_smart_object_record_a_smart_filter() {
|
||||
let mut s = smart_session();
|
||||
let original = flatten(&s);
|
||||
// The destructive result on the same pixels: the smart filter must match it exactly.
|
||||
let mut plain = session(8, "rgb");
|
||||
plain.execute("image.adjustments.invert", json!({})).unwrap();
|
||||
let baked = flatten(&plain);
|
||||
assert!(max_diff(&original, &baked) > 0.5, "the control changes the pixels");
|
||||
assert!(s.is_enabled("image.adjustments.invert"), "an adjustment runs on a smart object");
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
s.execute("image.adjustments.invert", json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one history step");
|
||||
let sf = smart_filters_of(&s);
|
||||
assert_eq!(sf.len(), 1);
|
||||
assert_eq!(sf[0].command, "image.adjustments.invert");
|
||||
assert!(sf[0].visible && sf[0].opacity == 1.0 && sf[0].blend == photocraft_color::BlendMode::Normal);
|
||||
assert!(sf[0].params.get("__kind").is_none(), "the engine's private key is not recorded");
|
||||
assert_eq!(max_diff(&flatten(&s), &baked), 0.0, "the smart filter renders the destructive result");
|
||||
// Hiding the filter shows the original exactly.
|
||||
s.execute("layer.smartFilter.setVisible", json!({"index": 0})).unwrap();
|
||||
assert_eq!(flatten(&s), original);
|
||||
s.undo();
|
||||
assert_eq!(max_diff(&flatten(&s), &baked), 0.0, "undo restores the filtered pixels");
|
||||
// Re-editing the recorded params re-renders; the same params give the same pixels.
|
||||
s.execute("layer.smartFilter.setParams", json!({"index": 0, "params": {}})).unwrap();
|
||||
assert_eq!(max_diff(&flatten(&s), &baked), 0.0, "the same params render the same pixels");
|
||||
// The setParams step, then the filter itself, undo away.
|
||||
s.undo();
|
||||
s.undo();
|
||||
assert!(smart_filters_of(&s).is_empty(), "the filter is gone");
|
||||
assert_eq!(flatten(&s), original);
|
||||
}
|
||||
|
||||
/// A selection becomes the smart filter's mask, and the adjustment with its own command
|
||||
/// (Shadows/Highlights) records a filter too.
|
||||
#[test]
|
||||
fn adjustment_smart_filters_take_a_selection_as_their_mask() {
|
||||
let mut s = smart_session();
|
||||
let original = flatten(&s);
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 24, "height": 32})).unwrap();
|
||||
s.execute("image.adjustments.shadowsHighlights", json!({"shadowAmount": 100, "blackClip": 0, "whiteClip": 0})).unwrap();
|
||||
let d = s.active().unwrap();
|
||||
let LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("not smart") };
|
||||
assert_eq!(sm.smart_filters.len(), 1);
|
||||
assert_eq!(sm.smart_filters[0].command, "image.adjustments.shadowsHighlights");
|
||||
assert!(sm.filter_mask.is_some(), "the selection became the filter mask");
|
||||
let now = flatten(&s);
|
||||
let at = |px: &[[f32; 4]], x: i32, y: i32| px[(y * 48 + x) as usize];
|
||||
assert!(changed(at(&original, 5, 16), at(&now, 5, 16)) > 1e-3, "inside the selection the shadows lifted");
|
||||
assert_eq!(at(&now, 40, 16), at(&original, 40, 16), "outside the mask the composite is unchanged");
|
||||
}
|
||||
|
||||
/// A wrong-typed value errors before anything is recorded (never a panic), and the adjustments
|
||||
/// that can't re-render from a source (Replace Color, Match Color) stay off a smart object.
|
||||
#[test]
|
||||
fn a_bad_adjustment_param_on_a_smart_object_is_an_error() {
|
||||
let mut s = smart_session();
|
||||
let (filters, steps, flat) = (smart_filters_of(&s).len(), s.active().unwrap().history.past_len(), flatten(&s));
|
||||
let e = s.execute("image.adjustments.colorLookup", json!({"lut": 42})).unwrap_err();
|
||||
assert!(e.to_string().contains("lut"), "{e}");
|
||||
assert_eq!(smart_filters_of(&s).len(), filters, "nothing recorded");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps, "no history step");
|
||||
assert_eq!(flatten(&s), flat, "pixels untouched");
|
||||
assert!(!s.is_enabled("image.adjustments.replaceColor"), "only adjustments that re-render become smart filters");
|
||||
assert!(!s.is_enabled("image.adjustments.matchColor"), "only adjustments that re-render become smart filters");
|
||||
}
|
||||
|
||||
/// #780: a targeted mask enables Invert (and filters) on layers without pixels, such as an
|
||||
/// adjustment layer; viewing the mask (⌥-click) targets it too.
|
||||
#[test]
|
||||
|
||||
@@ -245,45 +245,55 @@ fn erase_locked(brush: &mut BrushSettings, lock: bool, bg: [f32; 4]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The document's own CMYK profile (absent, built-in or not a CMYK document: `None`, the
|
||||
/// default conversions), which the stroke's colour conversions must honour — the compositors
|
||||
/// and composite exports enter the same scope.
|
||||
fn doc_cmyk_space(s: &Session) -> Option<std::sync::Arc<photocraft_color::convert::CmykSpace>> {
|
||||
photocraft_color::convert::CmykSpace::for_profile(s.active().and_then(|d| d.doc.icc_profile.as_ref()))
|
||||
}
|
||||
|
||||
/// Stroke with a resolved brush onto the target layer (pixels or mask).
|
||||
fn stroke_with(s: &mut Session, p: &Value, label: &str, brush: BrushSettings, pts: Vec<StrokePoint>, auto_erase: bool) -> Result<Value> {
|
||||
let bg = s.tools.background;
|
||||
let fg = brush.color;
|
||||
let symmetry = s.active().and_then(|st| st.symmetry.clone());
|
||||
let (id, brush, zoom) = stroke_target(s, p, brush)?;
|
||||
let dmg = s.edit(label, |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
let mut brush = brush;
|
||||
erase_locked(&mut brush, lock, bg);
|
||||
if auto_erase {
|
||||
apply_auto_erase(&mut brush, surf, pts.first(), fg, bg);
|
||||
}
|
||||
let damage = if let Some(axis) = &symmetry {
|
||||
let reflected = axis.reflected_passes(&pts);
|
||||
let copies: Vec<_> = reflected
|
||||
.iter()
|
||||
.filter(|p| crate::symmetry_cmds::SymmetryAxis::has_distinct_mirror(&pts, p))
|
||||
.map(|p| {
|
||||
let mut renderer = StrokeRenderer::new(&brush, Some(surf.format()), zoom);
|
||||
renderer.push(p);
|
||||
renderer.finish();
|
||||
renderer
|
||||
})
|
||||
.collect();
|
||||
if copies.is_empty() {
|
||||
render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom)
|
||||
} else {
|
||||
let pre = surf.clone();
|
||||
let mut original = StrokeRenderer::new(&brush, Some(surf.format()), zoom);
|
||||
original.push(&pts);
|
||||
original.finish();
|
||||
original.composite_union_many(&copies, &pre, surf, sel.as_ref(), lock)
|
||||
let cmyk = doc_cmyk_space(s);
|
||||
let dmg = photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
|
||||
s.edit(label, |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
let mut brush = brush;
|
||||
erase_locked(&mut brush, lock, bg);
|
||||
if auto_erase {
|
||||
apply_auto_erase(&mut brush, surf, pts.first(), fg, bg);
|
||||
}
|
||||
} else {
|
||||
render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom)
|
||||
};
|
||||
Ok(damage)
|
||||
let damage = if let Some(axis) = &symmetry {
|
||||
let reflected = axis.reflected_passes(&pts);
|
||||
let copies: Vec<_> = reflected
|
||||
.iter()
|
||||
.filter(|p| crate::symmetry_cmds::SymmetryAxis::has_distinct_mirror(&pts, p))
|
||||
.map(|p| {
|
||||
let mut renderer = StrokeRenderer::new(&brush, Some(surf.format()), zoom);
|
||||
renderer.push(p);
|
||||
renderer.finish();
|
||||
renderer
|
||||
})
|
||||
.collect();
|
||||
if copies.is_empty() {
|
||||
render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom)
|
||||
} else {
|
||||
let pre = surf.clone();
|
||||
let mut original = StrokeRenderer::new(&brush, Some(surf.format()), zoom);
|
||||
original.push(&pts);
|
||||
original.finish();
|
||||
original.composite_union_many(&copies, &pre, surf, sel.as_ref(), lock)
|
||||
}
|
||||
} else {
|
||||
render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom)
|
||||
};
|
||||
Ok(damage)
|
||||
})
|
||||
})?;
|
||||
Ok(damage_json(s, dmg))
|
||||
}
|
||||
@@ -353,6 +363,9 @@ pub struct LiveStroke {
|
||||
params: Value,
|
||||
/// Where the doc shows the stroke's end as finishing it would draw it (see `push`).
|
||||
tail: Rect,
|
||||
/// The document's CMYK profile the commit enters (`stroke_with`), so the preview shows the
|
||||
/// pixels a `paint.stroke` with `seed` will write.
|
||||
cmyk: Option<std::sync::Arc<photocraft_color::convert::CmykSpace>>,
|
||||
}
|
||||
|
||||
impl LiveStroke {
|
||||
@@ -401,6 +414,7 @@ impl LiveStroke {
|
||||
layer,
|
||||
params: p.clone(),
|
||||
tail: Rect::EMPTY,
|
||||
cmyk: doc_cmyk_space(s),
|
||||
};
|
||||
live.push(&pts)?;
|
||||
Ok(live)
|
||||
@@ -425,57 +439,60 @@ impl LiveStroke {
|
||||
/// Invalid coordinates reject the whole batch without changing the preview.
|
||||
pub fn push(&mut self, pts: &[StrokePoint]) -> Result<Rect> {
|
||||
check_coords(pts, &self.cmd)?;
|
||||
self.renderer.push(pts);
|
||||
if let Some(symmetry) = &self.symmetry {
|
||||
for ((mirror, distinct), reflected) in self.mirrors.iter_mut().zip(&mut self.mirror_distinct).zip(symmetry.reflected_passes(pts)) {
|
||||
*distinct |= crate::symmetry_cmds::SymmetryAxis::has_distinct_mirror(pts, &reflected);
|
||||
mirror.push(&reflected);
|
||||
let cmyk = self.cmyk.clone();
|
||||
photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
|
||||
self.renderer.push(pts);
|
||||
if let Some(symmetry) = &self.symmetry {
|
||||
for ((mirror, distinct), reflected) in self.mirrors.iter_mut().zip(&mut self.mirror_distinct).zip(symmetry.reflected_passes(pts)) {
|
||||
*distinct |= crate::symmetry_cmds::SymmetryAxis::has_distinct_mirror(pts, &reflected);
|
||||
mirror.push(&reflected);
|
||||
}
|
||||
}
|
||||
}
|
||||
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?;
|
||||
if self.mirror_distinct.iter().any(|d| *d) {
|
||||
// Merge every finished pass once, both here and on commit, including shared axes.
|
||||
let old = self.tail;
|
||||
let mut original_preview = self.renderer.clone();
|
||||
original_preview.finish();
|
||||
let copies: Vec<_> = self
|
||||
.mirrors
|
||||
.iter()
|
||||
.zip(&self.mirror_distinct)
|
||||
.filter(|(_, d)| **d)
|
||||
.map(|(r, _)| {
|
||||
let mut r = r.clone();
|
||||
r.finish();
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
let bounds = copies.iter().fold(original_preview.bounds().union(&old), |b, r| b.union(&r.bounds()));
|
||||
if self.symmetry_regions.is_empty() && !old.is_empty() {
|
||||
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?;
|
||||
if self.mirror_distinct.iter().any(|d| *d) {
|
||||
// Merge every finished pass once, both here and on commit, including shared axes.
|
||||
let old = self.tail;
|
||||
let mut original_preview = self.renderer.clone();
|
||||
original_preview.finish();
|
||||
let copies: Vec<_> = self
|
||||
.mirrors
|
||||
.iter()
|
||||
.zip(&self.mirror_distinct)
|
||||
.filter(|(_, d)| **d)
|
||||
.map(|(r, _)| {
|
||||
let mut r = r.clone();
|
||||
r.finish();
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
let bounds = copies.iter().fold(original_preview.bounds().union(&old), |b, r| b.union(&r.bounds()));
|
||||
if self.symmetry_regions.is_empty() && !old.is_empty() {
|
||||
surf.write_region(old, &self.pre.read_region(old));
|
||||
}
|
||||
for region in &self.symmetry_regions {
|
||||
surf.write_region(*region, &self.pre.read_region(*region));
|
||||
}
|
||||
self.symmetry_regions =
|
||||
std::iter::once(original_preview.bounds()).chain(copies.iter().map(StrokeRenderer::bounds)).filter(|r| !r.is_empty()).collect();
|
||||
let damage = original_preview.composite_union_many(&copies, &self.pre, surf, self.sel.as_ref(), self.lock);
|
||||
self.tail = bounds;
|
||||
return Ok(damage.union(&bounds));
|
||||
}
|
||||
let mut dmg = Rect::EMPTY;
|
||||
let old = std::mem::replace(&mut self.tail, Rect::EMPTY);
|
||||
if !old.is_empty() {
|
||||
// Back to the stroke without the previous tail.
|
||||
surf.write_region(old, &self.pre.read_region(old));
|
||||
self.renderer.mark_dirty(old);
|
||||
dmg = old;
|
||||
}
|
||||
for region in &self.symmetry_regions {
|
||||
surf.write_region(*region, &self.pre.read_region(*region));
|
||||
dmg = dmg.union(&self.renderer.composite(&self.pre, surf, self.sel.as_ref(), self.lock, false));
|
||||
if let Some(mut tail) = self.renderer.tail_preview() {
|
||||
self.tail = tail.composite(&self.pre, surf, self.sel.as_ref(), self.lock, false);
|
||||
dmg = dmg.union(&self.tail);
|
||||
}
|
||||
self.symmetry_regions =
|
||||
std::iter::once(original_preview.bounds()).chain(copies.iter().map(StrokeRenderer::bounds)).filter(|r| !r.is_empty()).collect();
|
||||
let damage = original_preview.composite_union_many(&copies, &self.pre, surf, self.sel.as_ref(), self.lock);
|
||||
self.tail = bounds;
|
||||
return Ok(damage.union(&bounds));
|
||||
}
|
||||
let mut dmg = Rect::EMPTY;
|
||||
let old = std::mem::replace(&mut self.tail, Rect::EMPTY);
|
||||
if !old.is_empty() {
|
||||
// Back to the stroke without the previous tail.
|
||||
surf.write_region(old, &self.pre.read_region(old));
|
||||
self.renderer.mark_dirty(old);
|
||||
dmg = old;
|
||||
}
|
||||
dmg = dmg.union(&self.renderer.composite(&self.pre, surf, self.sel.as_ref(), self.lock, false));
|
||||
if let Some(mut tail) = self.renderer.tail_preview() {
|
||||
self.tail = tail.composite(&self.pre, surf, self.sel.as_ref(), self.lock, false);
|
||||
dmg = dmg.union(&self.tail);
|
||||
}
|
||||
Ok(dmg)
|
||||
Ok(dmg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -695,3 +695,48 @@ fn out_of_range_scatter_is_rejected_and_the_maximum_still_paints() {
|
||||
s.execute("tools.setBrush", json!({ "brush": scatter(2.0) })).unwrap();
|
||||
assert_eq!(s.execute("paint.stroke", stroke).unwrap(), json!({"damage": [17, 14, 23, 23]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strokes_convert_through_the_documents_cmyk_profile() {
|
||||
// A synthetic uncoated-like profile: heavier dot gain than the built-in coated CMYK (whose
|
||||
// params are the defaults here), so painted ink differs from the default conversion. The
|
||||
// compositors and composite exports enter the document's space; the stroke must too (#583).
|
||||
let profile = photocraft_cms::synth::cmyk_profile(&photocraft_cms::synth::CmykParams {
|
||||
description: "Stroke Test Uncoated".into(),
|
||||
tvi: [0.26, 0.26, 0.26, 0.3],
|
||||
grid_a2b: 5,
|
||||
grid_b2a: 9,
|
||||
..Default::default()
|
||||
})
|
||||
.to_bytes();
|
||||
let cmyk_session = |tagged: bool| {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 32, "height": 32, "mode": "cmyk", "background": "white"})).unwrap();
|
||||
if tagged {
|
||||
s.edit("icc", |doc, _| {
|
||||
doc.icc_profile = Some(profile.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
s
|
||||
};
|
||||
let params = json!({"points": [[4, 16], [28, 16]], "size": 8, "hardness": 1, "opacity": 1, "flow": 1, "color": "#cc3366", "smoothing": 0});
|
||||
let mut s = cmyk_session(true);
|
||||
// The live preview enters the same space, so it commits the pixels it shows.
|
||||
let mut live =
|
||||
LiveStroke::begin(&s, &json!({"points": [[4, 16]], "size": 8, "hardness": 1, "opacity": 1, "flow": 1, "color": "#cc3366", "smoothing": 0})).unwrap();
|
||||
live.push(&[StrokePoint::new(28.0, 16.0, 1.0)]).unwrap();
|
||||
let mut commit = params.clone();
|
||||
commit["seed"] = json!(live.seed);
|
||||
s.execute("paint.stroke", commit).unwrap();
|
||||
let shown = live.doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().clone();
|
||||
assert!(same_pixels(&shown, &surface(&s), Rect::new(0, 0, 32, 32)), "the preview commits the pixels it shows");
|
||||
// The stroke's ink differs from the same stroke without the embedded profile.
|
||||
let ink = |surf: &Surface| surf.read_region(Rect::new(15, 16, 17, 18));
|
||||
let tagged = ink(&surface(&s));
|
||||
let mut plain = cmyk_session(false);
|
||||
plain.execute("paint.stroke", params).unwrap();
|
||||
let untagged = ink(&surface(&plain));
|
||||
assert!(tagged != untagged, "the document's CMYK profile must convert the painted ink: {tagged:?} vs {untagged:?}");
|
||||
}
|
||||
@@ -1093,7 +1093,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
_ => None,
|
||||
},
|
||||
params,
|
||||
enabled: has_pixel_or_channel,
|
||||
enabled: crate::adjust_cmds::has_adjustable,
|
||||
run: |s, p| {
|
||||
let kind = p.get("__kind").and_then(Value::as_str).unwrap_or("invert").to_string();
|
||||
let eyedropped = if kind == "curves" { crate::adjust_params::curves_eyedropper_from_params(s, p)? } else { None };
|
||||
@@ -1102,6 +1102,11 @@ fn build() -> Vec<CommandSpec> {
|
||||
None => crate::adjust_params::from_params(&kind, p, None, doc_mode(s))?,
|
||||
};
|
||||
let label = adj.label().to_string();
|
||||
// A smart object records the adjustment as a smart filter (Photoshop), which
|
||||
// re-runs from the source instead of baking into a copy of the pixels.
|
||||
if crate::adjust_cmds::adjust_as_smart_filter(s, &kind, &label, p)? {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
destructive_adjust(s, &label, adj, p)
|
||||
},
|
||||
journal: true,
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
||||
| Area | Done | Partial | Missing | Key numbers |
|
||||
|---|---:|---:|---:|---|
|
||||
| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 15 not run, 7 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 4 | 12 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
|
||||
| [File compatibility](#file-compatibility) | 5 | 11 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
|
||||
| [Tools](#tools) | 19 | 15 | 6 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 5 | 7 | 11 | settings that do nothing: 46 of 148 (target 0) |
|
||||
| [Type](#type) | 0 | 3 | 6 | |
|
||||
@@ -101,7 +101,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
| FILE-215-1 | Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded |
|
||||
| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning |
|
||||
| FILE-215-3 | Filter mask can be painted directly, with undo | missing | [#215](https://github.com/storytold/photocraft/issues/215) | smart_cmds.rs:382 mask only from the selection |
|
||||
| FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | partial | [#215](https://github.com/storytold/photocraft/issues/215) | PSD Levels/Curves/Shadows-Highlights smart filters render (adjust_cmds.rs adjust_as_filter); Image › Adjustments on a smart object doesn't add one yet |
|
||||
| FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments.<kind> as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work |
|
||||
| FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu |
|
||||
| FILE-215-6 | Smart Object corner skew and perspective (quad or homography transform) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | doc lib.rs:312 SmartObject.transform is an Affine |
|
||||
| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | partial | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels and a Quick Mask are not written (the TIFF codec holds one transparency sample) |
|
||||
|
||||
@@ -29,9 +29,9 @@ note = "smart_cmds.rs:382 mask only from the selection"
|
||||
[[item]]
|
||||
id = "FILE-215-4"
|
||||
target = "Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters"
|
||||
status = "partial"
|
||||
status = "done"
|
||||
issue = 215
|
||||
note = "PSD Levels/Curves/Shadows-Highlights smart filters render (adjust_cmds.rs adjust_as_filter); Image › Adjustments on a smart object doesn't add one yet"
|
||||
note = "Image › Adjustments on a smart object records image.adjustments.<kind> as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-5"
|
||||
|
||||
Reference in new issue
Block a user