From 05fd55bde1e76319d19ad82d7ac9ae52337c29d2 Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Thu, 12 Nov 2026 14:30:19 -0700 Subject: [PATCH 01/14] 3D effects: every facet's seam has round joins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each 3D facet is stroked with a narrow seam of its own paint (0.05 to 0.75 pt; Revolve's is always 0.75 pt) so neighboring facets meet without an antialiasing hairline. In Extrude & Bevel, Inflate and Rotate under a white light, and in every face of the 3D (Classic) effects (Extrude & Bevel, Rotate and Revolve (Classic); shaded, No Shading, gradient and pattern alike), that seam kept the default Miter Join with its limit of 10. Revolve's seams and the colored-light seams were already round. A facet seen almost edge-on is a sharp sliver, and its mitered seam reached up to ten times its half-width past the facet's tip: thorns of up to 3.75 pt past the solid, along the tops of type seen tilted and at every edge-on bevel facet. #2706 (multiple lights) deliberately kept white-light seam joins as they were, so that legacy white-light output stayed byte-identical to main (its comment "Preserve existing white-light paints, alpha composition and seam joins exactly", and four PNGs compared byte for byte). That kept the thorns too. This change reverses that one part on purpose: the paints and alpha composition stay exactly as #2706 left them; only the join changes. - Outputs change, the document model doesn't. Exports draw the new joins (SVG writes stroke-linejoin="round", PDF and .ai draw round joins), and Expand Appearance writes round seams into the document. But nothing a document stores selects the join, and the format is unchanged, so there is no version gate: an old file drawn now simply has no thorns. - Round joins. Projected facets rarely have right-angle corners (a square seen at an angle is a parallelogram with acute corners, and an exact 90° corner sits at a √2 limit), and round is what the seams of colored lights and Revolve already draw. The cost: where a facet has no neighbor (Rotate's plane, every silhouette corner) its convex corners are drawn rounded by up to 0.375 pt instead of sharp. Alternatives weighed and not taken: a Miter Join with a limit near √2 at every seam, the composed clip included, which would bevel slivers but chamfer most projected corners as well; and seaming only the edges facets share, or clipping facets to their union as solid_surface does, which would keep every outer corner sharp but means a new geometry pass for every 3D effect. Round is the smallest change that matches the seams already drawn. - A seam no longer reaches past its facet by more than half its width, so the art's visual bounds shrink by up to 3.375 pt where a thorn stood (10 × 0.375 less the 0.375 a round seam still reaches). White-light output is no longer byte-identical to #2706's comparison; docs/3d-lighting-performance.md says so. Every seam stroke is made by lighting::seam_stroke. The colored clip in composed_art already outlined its seam with round joins and is unchanged. Tests: every seam of Extrude & Bevel, Extrude & Bevel (Classic, each branch), Rotate (Classic), Inflate and Rotate, under white and colored light, has round joins and reaches no more than half its width past its face, at views that make a sliver; Revolve (Classic)'s seams are round on each surface; a mitered seam fails them, and so does the legacy white-light Inflate test, which now expects Round. --- crates/effects/src/lighting.rs | 20 +- crates/effects/src/revolve.rs | 10 +- crates/effects/src/solids.rs | 9 +- crates/effects/src/solids/classic.rs | 13 +- crates/effects/src/solids/tests.rs | 225 +++++++++++++++++++++- crates/effects/src/solids/tests_shadow.rs | 2 +- docs/3d-lighting-performance.md | 40 +++- docs/effects-parity.md | 7 +- docs/gaps.md | 5 +- docs/target-app-parity.md | 5 +- docs/three-d.md | 33 +++- 11 files changed, 333 insertions(+), 36 deletions(-) diff --git a/crates/effects/src/lighting.rs b/crates/effects/src/lighting.rs index 9a352189d1..8592776a95 100644 --- a/crates/effects/src/lighting.rs +++ b/crates/effects/src/lighting.rs @@ -96,6 +96,18 @@ pub(crate) fn rgb_gradient_art( composed_art(id, path, paint, Ramp::Rgb(light), 0.75) } +/// The narrow stroke of a facet's own `paint`, `width` wide, that closes the hairline seam between +/// it and its neighbors where each is antialiased alone. Its joins are round: a facet seen almost +/// edge-on is a sharp sliver, and a mitered seam would reach far past its tip as a thorn, where a +/// round one reaches no more than half its width past the facet. Revolve's meets() counts on a +/// stroked seam reaching no more than half its width; faces drawn as clipped groups (whose clip, +/// in `composed_art`, already outlines the seam with round joins) are measured by their box. +pub(crate) fn seam_stroke(paint: Paint, width: f64) -> vectorcraft_doc::StrokeLayer { + let mut edge = vectorcraft_doc::StrokeLayer::new(paint, width); + edge.join = vectorcraft_doc::LineJoin::Round; + edge +} + /// Smooth Inflate triangles keep the seam within their narrow projected footprint. pub(crate) fn facet_art( id: vectorcraft_doc::NodeId, @@ -109,8 +121,7 @@ pub(crate) fn facet_art( let light = vectorcraft_three_d::LightGradient { diffuse: r, ambient: 0.0, color: [1.0; 3] }; if let Some(color) = paint.color() { let paint = smooth_paint(light, color); - let mut edge = vectorcraft_doc::StrokeLayer::new(paint.clone(), seam); - edge.join = vectorcraft_doc::LineJoin::Round; + let edge = seam_stroke(paint.clone(), seam); return vectorcraft_doc::Node::path( id, path.clone(), @@ -131,7 +142,7 @@ pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_t use kurbo::Shape; use std::sync::Arc; use vectorcraft_doc::color::BlendMode; - use vectorcraft_doc::{Appearance, Node, NodeKind, StrokeLayer}; + use vectorcraft_doc::{Appearance, Node, NodeKind}; use vectorcraft_geom::{BezPath, PathData, Rect, shapes}; let mut boundary = BezPath::new(); @@ -163,8 +174,7 @@ pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_t } else { Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel]))) }; - let mut edge = StrokeLayer::new(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75)); - edge.join = vectorcraft_doc::LineJoin::Round; + let edge = seam_stroke(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75)); Arc::new(Node::path( id, face.path(), diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index d82e4e8818..1eadfbd2c5 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Node, NodeId, NodeKind}; use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve}; @@ -404,10 +404,10 @@ fn surface( .into_iter() .flat_map(|item| { let AppearanceItem::Fill(fill) = &item else { return vec![item] }; - let mut edge = StrokeLayer::new(fill.paint.clone(), SEAM_WIDTH); - // Acute face corners must stay within the overlap width. Miter joins - // amplify this seam stroke into visible spikes along the silhouette. - edge.join = LineJoin::Round; + // Acute face corners must stay within the overlap width: meets() counts on a + // stroked seam reaching no more than half its width; faces drawn as clipped + // groups are measured by their box. + let mut edge = crate::lighting::seam_stroke(fill.paint.clone(), SEAM_WIDTH); edge.opacity = fill.opacity; vec![item, AppearanceItem::Stroke(edge)] }) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 9e7578d6d7..0905275571 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{BlendMode, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask}; use vectorcraft_geom::{FillRule, PathData, Rect}; use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, Mesh, View}; @@ -475,7 +475,7 @@ fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: Ble group.blend = blend; return group; } - // Preserve existing white-light paints, alpha composition and seam joins exactly. + // Preserve existing white-light paints and alpha composition exactly. let (opaque, mask) = if colored { crate::lighting::opaque_paint(paint) } else { (paint.clone(), None) }; let paint = &opaque; let children = facets @@ -496,10 +496,7 @@ fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: Ble .into_iter() .flat_map(|item| { let AppearanceItem::Fill(fill) = &item else { return vec![item] }; - let mut edge = StrokeLayer::new(fill.paint.clone(), seam); - if colored { - edge.join = vectorcraft_doc::LineJoin::Round; - } + let mut edge = crate::lighting::seam_stroke(fill.paint.clone(), seam); edge.opacity = fill.opacity; vec![item, AppearanceItem::Stroke(edge)] }) diff --git a/crates/effects/src/solids/classic.rs b/crates/effects/src/solids/classic.rs index 919de831ce..18b503b5b7 100644 --- a/crates/effects/src/solids/classic.rs +++ b/crates/effects/src/solids/classic.rs @@ -218,18 +218,23 @@ pub(crate) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32 // Unshaded (No Shading, no shading color, or a face in full light) with no // highlight: the paint itself, its color space and swatch kept. Some(_) if (tone >= 1.0 || shade.is_none()) && spec <= 0.0 => { - vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))] + vec![ + AppearanceItem::Fill(FillLayer::new(paint.clone())), + AppearanceItem::Stroke(crate::lighting::seam_stroke(paint.clone(), seam)), + ] } Some(c) => { let fill = Paint::solid(shaded(c, tone, spec, shade)); - vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(StrokeLayer::new(fill, seam))] + vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(crate::lighting::seam_stroke(fill, seam))] } // Gradients and patterns keep their paint, darkened by a layer of the shading color // and lightened by one of white. As with the other 3D effects' pattern shadow, the // layers cover the whole face, transparent pattern cells included. None => { - let mut items = - vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))]; + let mut items = vec![ + AppearanceItem::Fill(FillLayer::new(paint.clone())), + AppearanceItem::Stroke(crate::lighting::seam_stroke(paint.clone(), seam)), + ]; for (color, share) in [(shade, 1.0 - tone), (Some(Color::WHITE), spec)] { if let Some(color) = color.filter(|_| share > 0.0) { let mut layer = FillLayer::new(Paint::solid(color)); diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 1efe826c75..2917c67618 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -1,5 +1,5 @@ use serde_json::json; -use vectorcraft_doc::{Document, NodeId}; +use vectorcraft_doc::{Document, NodeId, StrokeLayer}; use vectorcraft_geom::{Affine, BezPath, Point, Shape}; use super::*; @@ -628,6 +628,9 @@ fn inflate_reads_twist_taper_and_cap() { assert_eq!(crate::default_params(INFLATE).unwrap()["cap"], json!(true)); } +/// Inflate under the legacy white light keeps the polygon faces it had before the colored-light +/// batching (each face a path of its own, not a channel group) and its seam's 0.05–0.75 pt range; +/// its seams' joins are round, as every 3D seam's. #[test] fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() { let mut n = square_node(); @@ -640,7 +643,9 @@ fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() { assert!(matches!(face.kind, NodeKind::Path { .. })); let AppearanceItem::Stroke(stroke) = &face.appearance.items[1] else { panic!("seam") }; assert!((0.05..=0.75).contains(&stroke.width)); - assert_eq!(stroke.join, vectorcraft_doc::LineJoin::Miter); + // Round, as every seam is: a facet seen almost edge-on is a sliver, and a mitered seam + // reached past the solid as a thorn. + assert_eq!(stroke.join, vectorcraft_doc::LineJoin::Round); } // A tiny shape exercises the lower bound, which otherwise disappears at grazing angles. n.transform(Affine::scale(0.001), false); @@ -1032,3 +1037,219 @@ fn classic_rotate_parameters_are_checked() { assert!(err.starts_with(says), "{p}: {err}"); } } + +/// Under a white light and under colored ones, in Extrude & Bevel, Inflate, Rotate and Extrude & +/// Bevel (Classic, each of its shading branches), a face seen almost edge-on (a sharp sliver) has +/// its seam stroke rounded at the corners: every seam's joins are round, and none reaches past its +/// face more than half its width as it is outlined (a mitered seam would reach past the solid as a +/// thorn up to ten times that beyond the face's tip). Each case's view makes some face with a +/// corner a miter would spike from. +#[test] +fn a_faces_seam_stays_within_half_its_width_of_the_face() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, RED), GradientStop::new(1.0, BLUE)], + )))); + // Does `path` have a convex corner sharper than 45° that a miter with `limit` would spike from + // (one sharper than 2·asin(1/limit) is bevelled instead, so it can't thorn; a reflex corner's + // miter points inward)? An open subpath's ends are no corners, and a closing anchor repeating + // the first is one corner. + fn acute(path: &PathData, limit: f64) -> bool { + let floor = 2.0 * (1.0 / limit).asin(); + path.subpaths.iter().any(|sp| { + let mut pts: Vec = sp.anchors.iter().map(|a| a.p).collect(); + pts.dedup_by(|a, b| a.distance(*b) < 1e-9); + if sp.closed && pts.len() > 1 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.distance(*b) < 1e-9) { + pts.pop(); + } + let n = pts.len(); + let corners: Vec = if sp.closed { (0..n).collect() } else { (1..n.saturating_sub(1)).collect() }; + // Which way the subpath turns as a whole: a convex corner turns the same way. + let area: f64 = (0..n).map(|k| pts[k].to_vec2().cross(pts[(k + 1) % n].to_vec2())).sum(); + n >= 3 + && corners.into_iter().any(|k| { + let (a, b, c) = (pts[(k + n - 1) % n], pts[k], pts[(k + 1) % n]); + let (u, v) = (a - b, c - b); + let convex = !sp.closed || (b - a).cross(c - b) * area > 0.0; + let angle = (u.dot(v) / (u.hypot() * v.hypot())).clamp(-1.0, 1.0).acos(); + convex && angle > floor && angle < std::f64::consts::FRAC_PI_4 + }) + }) + } + // Views that make slivers whose corners a miter would spike from, in every case. + let views = [(-35.0, 25.0), (-85.0, 15.0), (60.0, 60.0)]; + let mut cases = vec![]; + for &(x, y) in &views { + let view = json!({"rotationX": x, "rotationY": y, "rotationZ": 0, "depth": 60}); + // Rotate (Classic) takes no depth. + let flat = json!({"rotationX": x, "rotationY": y, "rotationZ": 0}); + let with = |extra: Value| { + let mut p = view.clone(); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + p + }; + cases.push((EXTRUDE, x, y, with(json!({"taper": 47.5})), None)); + // A gradient under a white light: its paint and the shadow fill over it each have a seam. + cases.push((EXTRUDE, x, y, with(json!({"taper": 47.5})), Some(gradient.clone()))); + cases.push((INFLATE, x, y, with(json!({})), None)); + // Under colored lights: one, two, and a gradient's paint. + let one = json!({"taper": 47.5, "lights": [{"color": "#ff8080"}]}); + let two = json!({"taper": 47.5, "lights": [{"color": "#ff8080"}, {"color": "#8080ff", "rotation": -60}]}); + cases.push((EXTRUDE, x, y, with(one.clone()), None)); + cases.push((EXTRUDE, x, y, with(two.clone()), None)); + cases.push((EXTRUDE, x, y, with(one), Some(gradient.clone()))); + cases.push((INFLATE, x, y, with(json!({"lights": [{"color": "#ff8080"}, {"color": "#8080ff", "rotation": -60}]})), None)); + // Rotate turns the art flat: a thin triangle, some corner of which stays sharp enough at + // every view; under a white light and a colored one. + cases.push((ROTATE, x, y, with(json!({})), None)); + cases.push((ROTATE, x, y, with(json!({"lights": [{"color": "#ff8080"}]})), None)); + // Classic: shaded, unshaded (No Shading) and a gradient's paint kept under its shading + // layers each make their seam their own way. + let bevel = json!({"bevel": "classic", "bevelHeight": 1.5}); + cases.push((CLASSIC_EXTRUDE, x, y, with(bevel.clone()), None)); + cases.push((CLASSIC_EXTRUDE, x, y, with(json!({"bevel": "classic", "bevelHeight": 1.5, "surface": "none"})), None)); + cases.push((CLASSIC_EXTRUDE, x, y, with(bevel), Some(gradient.clone()))); + // Rotate (Classic) draws its plane through Classic's surfaces: No Shading and Diffuse. + for surface in ["none", "diffuse"] { + let mut p = flat.clone(); + p["surface"] = json!(surface); + cases.push((CLASSIC_ROTATE, x, y, p, None)); + } + } + // Classic's own paint for its fill: a swatch in CMYK, which its unshaded faces keep as it is. + let brick = Paint::Solid { color: Color::Cmyk { c: 0.0, m: 0.75, y: 0.9, k: 0.2 }, swatch: Some("Brick".into()), tint: 1.0 }; + for (id, x, y, params, paint) in cases { + let mut n = if id == ROTATE || id == CLASSIC_ROTATE { + let mut b = BezPath::new(); + b.move_to((100.0, 90.0)); + b.line_to((160.0, 110.0)); + b.line_to((100.0, 130.0)); + b.close_path(); + stroked(&b) + } else { + stroked_rect(4.0) + }; + let unshaded = params["surface"] == "none"; + for (k, item) in n.appearance.items.iter_mut().enumerate() { + match &paint { + Some(paint) => *item.paint_mut() = paint.clone(), + None if id == CLASSIC_EXTRUDE && k == 0 => *item.paint_mut() = brick.clone(), + None => {} + } + } + // Each solid on its own (the fill's, then the stroke's), so every check holds for each. + for keep in 0..n.appearance.items.len() { + let mut solo = n.clone(); + let item = solo.appearance.items.remove(keep); + solo.appearance.items = vec![item.clone()]; + let source = item.paint().clone(); + let what = format!("{id} {params} gradient {} view ({x}, {y}) solid {keep}", paint.is_some()); + solo.appearance.effects.push(fx(id, params.clone())); + let art = solid_art(&solo).unwrap(); + let mut seamed = vec![]; + art.walk(&mut |face| { + if let Some(path) = face.path_data() + && face.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))) + { + seamed.push((path.clone(), face.appearance.items.clone())); + } + }); + // Each Classic case reaches its own branch: an unshaded face keeps the paint itself (its + // swatch and color space too), a shaded one is the paint darkened, and a gradient's + // face keeps the gradient. + if id == CLASSIC_EXTRUDE { + let fills: Vec<&Paint> = seamed + .iter() + .filter_map(|(_, items)| match items.first() { + Some(AppearanceItem::Fill(f)) => Some(&f.paint), + _ => None, + }) + .collect(); + assert!(!fills.is_empty(), "{what}: faces"); + if paint.is_some() || unshaded { + assert!(fills.iter().all(|f| **f == source), "{what}: every face keeps its paint as it is"); + } else { + let [r, g, b] = source.color().map(|c| c.to_rgb()).unwrap_or([0.0; 3]); + let darkened = fills.iter().any(|f| match f { + Paint::Solid { color, swatch: None, .. } => { + let [fr, fg, fb] = color.to_rgb(); + fr <= r + 1e-4 && fg <= g + 1e-4 && fb <= b + 1e-4 && fr + fg + fb < r + g + b - 1e-3 + } + _ => false, + }); + assert!(darkened, "{what}: some face shaded darker than its paint"); + } + } + // Every seamed fill (the paint's, a shadow's over it) has its seams checked, and on some + // face a miter would spike from. + let (mut checked, mut sliver): (usize, Vec) = (0, vec![]); + for (path, items) in &seamed { + let outline = path.to_bezpath(); + let acute_here = items.iter().find_map(|i| match i { + AppearanceItem::Stroke(s) => Some(acute(path, s.miter_limit)), + _ => None, + }); + let mut which = 0; + for item in items { + // A seam paints something (a stroke of no width or no paint draws nothing). + let AppearanceItem::Stroke(seam) = item else { continue }; + if seam.width <= 0.0 || matches!(seam.paint, Paint::None) { + continue; + } + if sliver.len() <= which { + sliver.resize(which + 1, false); + } + sliver[which] |= acute_here.unwrap_or(false); + which += 1; + checked += 1; + assert_eq!(seam.join, vectorcraft_doc::LineJoin::Round, "{what}: a seam's joins"); + // The seam outlined as it is drawn: no point of it farther from the face than + // half its width. + let drawn = crate::stroke::line_outline(&outline, seam, seam.width, seam.width * 1e-3); + let (mut farthest, mut points): (f64, usize) = (0.0, 0); + kurbo::flatten(drawn.iter(), seam.width * 1e-4, |el| { + if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el { + points += 1; + farthest = farthest.max(path.nearest(p).map_or(f64::INFINITY, |n| n.4)); + } + }); + assert!(points > 0, "{what}: a seam outlined as nothing"); + assert!(farthest <= seam.width / 2.0 * 1.01, "{what}: a seam {} wide reaches {farthest} from its face", seam.width); + } + } + assert!(checked > 0, "{what}: seams checked"); + for (k, s) in sliver.iter().enumerate() { + assert!(s, "{what}: no face with an acute corner carries seam {k}"); + } + } + } +} + +/// Revolve (Classic) draws its faces through Classic's surfaces: every face's seam has round joins, +/// on the plastic, diffuse and unshaded surfaces. +#[test] +fn classic_revolves_seams_are_round() { + let mut b = BezPath::new(); + b.move_to((100.0, 40.0)); + b.line_to((130.0, 60.0)); + b.line_to((130.0, 140.0)); + b.line_to((100.0, 160.0)); + for surface in ["plastic", "diffuse", "none"] { + let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(BLUE), Paint::None, 0.0)); + n.appearance.effects.push(fx(crate::CLASSIC_REVOLVE, json!({"surface": surface}))); + let art = crate::revolve_art(&n).unwrap(); + let mut seams = 0; + art.walk(&mut |face| { + for item in &face.appearance.items { + if let AppearanceItem::Stroke(s) = item + && s.width > 0.0 + { + seams += 1; + assert_eq!(s.join, vectorcraft_doc::LineJoin::Round, "{surface}: a seam's joins"); + } + } + }); + assert!(seams > 0, "{surface}: seams checked"); + } +} diff --git a/crates/effects/src/solids/tests_shadow.rs b/crates/effects/src/solids/tests_shadow.rs index 4400555d2b..7e7d2e1595 100644 --- a/crates/effects/src/solids/tests_shadow.rs +++ b/crates/effects/src/solids/tests_shadow.rs @@ -1,6 +1,6 @@ use serde_json::json; use vectorcraft_doc::color::Color; -use vectorcraft_doc::{Document, NodeId}; +use vectorcraft_doc::{Document, NodeId, StrokeLayer}; use vectorcraft_geom::{Affine, BezPath}; use super::*; diff --git a/docs/3d-lighting-performance.md b/docs/3d-lighting-performance.md index 0ce73e598b..2d40254ee6 100644 --- a/docs/3d-lighting-performance.md +++ b/docs/3d-lighting-performance.md @@ -1,6 +1,6 @@ # 3D lighting performance -Measured 2026-10-17 for PR #2706 on one macOS machine. +Measured 2026-10-17 for PR #2706 on one macOS machine (an Apple M1 Pro, below); the round seam joins on 2026-11-12 on another (an Apple M4 Pro, in their own section). ## Method @@ -34,6 +34,8 @@ vectorcraft-cli perf ## Measurements +Only the legacy white-light row and the white-light comparison below are affected by the round seam joins of 2026-11-12: they were measured on 2026-10-17, and colored-light seams were already round; see [Round seam joins](#round-seam-joins). + Milliseconds per frame, medians of three five-frame averages. `8` is the CLI default thread count on this machine; `0` selects the single-thread renderer. | Inflate scene | Before, 8 | After, 8 | Before, 0 | After, 0 | @@ -66,10 +68,42 @@ The colored fixture uses the same projected mesh before and after. The solid-fil For a smooth colored solid fill, all facets share one compound coverage clip and three isolated channel groups. Each channel has an opaque black backdrop; Screen combines the disjoint RGB channels and Multiply applies the source color. Face order is retained in each channel. Surface opacity and outer coverage apply once. The group/clip counts are independent of triangle count, and export remains vector artwork. -Source gradients retain their independently placed per-facet vector compositor. They remain a more expensive case. Patterns retain their neutral shading approximation. White-light Inflate uses the original polygon shading, paint composition and default seam joins; the seam width retains its 0.05 to 0.75 pt bounds. +Source gradients retain their independently placed per-facet vector compositor. They remain a more expensive case. Patterns retain their neutral shading approximation. White-light Inflate uses the original polygon shading and paint composition; the seam width retains its 0.05 to 0.75 pt bounds. Its seams kept the default Miter Join until 2026-11-12, when every 3D seam took round joins (a mitered seam drew thorns past facets seen almost edge-on), so white-light output is no longer identical to the comparison below. Geometry/effects tests cover legacy polygons and seam bounds, shared smooth normals, finite ramps, holes and constant group/clip counts. Engine tests cover the batched SVG round trip, vector PDF, expansion, undo, compound holes and surface opacity. ## Legacy white-light comparison -Four original synthetic scenes were exported at 1200 x 1200 from unchanged main `f7de3d6a` and the updated implementation. The PNG files match byte for byte in all four cases: the legacy white-lit ellipse above; the same ellipse with both sides, hollow cap, twist 60 degrees, taper 70%, perspective 80 degrees and view Y rotation 145 degrees; an unshaded ellipse; and the transparent source gradient above under the legacy white light. The command recipe was also checked against the measured colored fixture's image pixels. +Four original synthetic scenes were exported at 1200 x 1200 from unchanged main `f7de3d6a` and the updated implementation. The PNG files matched (2026-10-17) byte for byte in all four cases: the legacy white-lit ellipse above; the same ellipse with both sides, hollow cap, twist 60 degrees, taper 70%, perspective 80 degrees and view Y rotation 145 degrees; an unshaded ellipse; and the transparent source gradient above under the legacy white light. The command recipe was also checked against the measured colored fixture's image pixels. + +## Round seam joins + +Measured once, on 2026-11-12, on another machine (an Apple M4 Pro with 14 cores) that was not idle: its one-minute load average was 6 to 13, so these are one loaded run, not the idle measurements above. Two `vectorcraft-cli` builds, each in release (`cargo build --release -p vectorcraft-cli`): main at `6d6bbff53`, and that commit with the round seam joins, run in turn, three times each. `bench` fits the artboard to 95% of 2880 x 1800 pixels, draws one frame to warm each renderer and reports the average of the frames after it: `threads 8` with the renderer's default workers (one per core it may use, up to 8, one core left free), `threads 0` on the calling thread alone. A cold frame is a whole `run` that opens the scene, builds its 3D art and draws it once. + +White light: extruded type with a cove bevel, an Inflate, an Extrude & Bevel (Classic) star with a classic bevel and a Revolve, no strokes (6,283 nodes drawn). The median of three runs of 20 frames, on 8 threads and on one: main 8.0 ms and 18.3 ms; with the round joins 7.5 ms and 17.7 ms. Cold frames took 0.08 s of processor time on each. The differences are within noise for one loaded run. Not measured: `vectorcraft-cli perf`, the legacy white-light Inflate row above (still the 2026-10-17 figures), frames whose 3D art is rebuilt (while dragging a 3D slider, say) apart from the cold run, and SVG/PDF exports. + +``` +{"command":"file.new","params":{"width":800,"height":600}} +{"command":"text.create","params":{"x":40,"y":120,"text":"SAMPLE TYPE","size":64,"font":"Source Sans 3","color":"#3366cc"}} +{"command":"effect.apply","params":{"effect":"threeD.extrude","item":null,"params":{"depth":40,"rotationX":-35,"rotationY":25,"taper":47.5,"bevel":"cove","bevelHeight":2}}} +{"command":"path.create","params":{"d":"M100 300C100 245 145 200 200 200C255 200 300 245 300 300C300 355 255 400 200 400C145 400 100 355 100 300Z"}} +{"command":"paint.setFill","params":{"color":"#cc6633"}} +{"command":"paint.setStroke","params":{"none":true}} +{"command":"effect.apply","params":{"effect":"threeD.inflate","item":null,"params":{}}} +{"command":"path.create","params":{"d":"M400 200L440 280L520 290L460 340L480 420L400 380L320 420L340 340L280 290L360 280Z"}} +{"command":"paint.setFill","params":{"color":"#33aa66"}} +{"command":"paint.setStroke","params":{"none":true}} +{"command":"effect.apply","params":{"effect":"threeD.classicExtrude","item":null,"params":{"depth":50,"bevel":"classic","bevelHeight":4,"rotationX":80,"rotationY":10}}} +{"command":"path.create","params":{"d":"M600 200L640 200L660 300L640 400L600 400Z"}} +{"command":"paint.setFill","params":{"color":"#aa33aa"}} +{"command":"paint.setStroke","params":{"none":true}} +{"command":"effect.apply","params":{"effect":"threeD.revolve","item":null,"params":{}}} +``` + +The type is set in Source Sans 3, which a build has only with craft-fonts (`CRAFT_FONTS_DIR`, see `docs/development.md` › Fonts); without it the type falls back to another face. To reproduce, save the commands as `scene.jsonl` and, with each build: + +``` +vectorcraft-cli run --commands scene.jsonl --export scene.vectorcraft +vectorcraft-cli bench scene.vectorcraft --iters 20 +/usr/bin/time -p vectorcraft-cli run --in scene.vectorcraft --export scene.png +``` diff --git a/docs/effects-parity.md b/docs/effects-parity.md index e219ce5e1b..d834a852a9 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -1,6 +1,6 @@ # Effects parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Effect Gallery: a thumbnail for each filter and an in-dialog preview on an original drawing; the 1–2 h for those is done; Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap, as type often draws a glyph, before building a solid; previously: Extrude & Bevel, Inflate and Rotate apply to groups and layers, their objects turning about one shared axis (#4506); the Transform effect's copies of a path with a fill and a stroke (or several fills and strokes, an even-odd fill, or a partly transparent one) are drawn each above the one before, as Expand Appearance makes them (#5920); Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Extrude & Bevel, Inflate and Rotate seams have round joins under a white light and in the 3D (Classic) effects too, no longer reaching past the solid as thorns; previously: Effect Gallery: a thumbnail for each filter and an in-dialog preview on an original drawing; the 1–2 h for those is done; Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap, as type often draws a glyph, before building a solid; previously: Extrude & Bevel, Inflate and Rotate apply to groups and layers, their objects turning about one shared axis (#4506); the Transform effect's copies of a path with a fill and a stroke (or several fills and strokes, an even-odd fill, or a partly transparent one) are drawn each above the one before, as Expand Appearance makes them (#5920); Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x) Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue (`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it @@ -179,9 +179,9 @@ VectorCraft choices; an original approximation, never compared with another appl | Item | Ours | |---|---| | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | -| Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | +| Extrude & Bevel, Inflate, Rotate | initial: live, round seam joins, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | | Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect, and Quick Actions › Expand as Wireframes ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); no materials, soft shadows or ray tracing | -| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none | +| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, round seam joins, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none | | Turntable (30.0 beta) | none | | Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) | @@ -193,6 +193,7 @@ angular cut caps, exact visibility, the rest of 3D Classic (Map Art), Turntable) | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | The seams of Extrude & Bevel, Inflate and Rotate under a white light, and every 3D (Classic) seam, went from miter to round joins (colored-light seams were already round): no thorns on edge-on facets, and facets with no neighbor now have rounded convex corners ([three-d.md](three-d.md#seams)); scores and estimates unchanged | | 2026-11-10 | minor | Effect Gallery: each filter has a thumbnail, and the dialog has a preview of the visible stack (or the browsed filter when the stack is empty). Both are an original landscape drawing passed through the filter ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)). The canvas preview and one-undo OK are unchanged. Remaining hours for the gallery's thumbnails and in-dialog preview: 1–2 → none. Counts unchanged (57/57). | | 2026-11-08 | minor | Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap (a glyph's arm across its stem) before building a solid, so the solid follows the shape as it is filled; others are built as drawn ([three-d.md](three-d.md)); scores and estimates unchanged | | 2026-11-06 | minor | Extrude & Bevel, Inflate and Rotate apply to a group or layer: a solid for every path, compound path and type in it, all turned about one shared axis as the public documentation describes for groups ([three-d.md](three-d.md#groups), #4506); scores and estimates unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index 826de6fe24..b4def77cd4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (#6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (3D: the seams of white-light Extrude & Bevel, Inflate and Rotate, and every seam of the 3D (Classic) effects, have round joins instead of miters; previously: #6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -668,7 +668,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi ## G3 3D and Materials - **Done:** an initial live Revolve in the `three-d` crate (#846, from the contributor who offered in #605), with automatic smooth vector shading, default-on circular caps and spike-free continuous rims; - Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md), #1407). Colored Inflate has automatic smooth vector lighting from shared membrane normals. White-light Inflate keeps its existing appearance. All four take one to ten colored directional lights, with neutral ambient applied once, a light list and independent previewing controls. Standard, Diffuse, Top Left and Right select original rigs, shared with commands; manual settings show Custom. + Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md), #1407). Colored Inflate has automatic smooth vector lighting from shared membrane normals. White-light Inflate keeps its polygon shading. Every 3D facet's seam has round joins ([three-d.md](three-d.md#seams)). All four take one to ten colored directional lights, with neutral ambient applied once, a light list and independent previewing controls. Standard, Diffuse, Top Left and Right select original rigs, shared with commands; manual settings show Custom. - **Missing:** materials (base, Substance, graphics), shadows, ray-traced rendering, angular cut caps, exact visibility for intersecting and concave surfaces, the rest of 3D Classic (Map Art), Turntable, textures, map art and shading in 3D export. @@ -865,6 +865,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | 3D: The seams of Extrude & Bevel, Inflate and Rotate under a white light, and every 3D (Classic) seam, went from miter to round joins (colored-light seams were already round): no thorns on edge-on facets, and facets with no neighbour now have rounded convex corners ([three-d.md](three-d.md#seams)). Scores and estimates unchanged. | | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-09 | minor | G4 review (#6084): positional variants no supported feature draws can be applied as glyph choices in both Glyphs views; unlisted glyphs still respect name ownership. OpenType style options keep only a valid last on-value when enabling a set. Existing contextual/ligature lookup and unsupported-feature gaps remain; estimate unchanged. | | 2026-11-07 | minor | G4: the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to that letter alone (`salt=2`; the style commands take `tag=N`; runs that differ only in their features shape as one piece of text), so an Arabic word stays joined, and Show › Entire Font lists the unencoded glyphs (#5514). Estimate unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 23080e57ee..f7504a487d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (#6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (3D: the seams of Extrude & Bevel, Inflate and Rotate under a white light, and every 3D (Classic) seam, went from miter to round joins, so white-light 3D output is no longer identical to the 2026-10-17 comparison; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -1788,7 +1788,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `xtask/src/pngsuite.rs` verifies each file from the pinned PngSuite mirror against `xtask/pngsuite.sha256` before it reaches the corpus. Seven synthetic regressions cover substitution, oversize, cache repair, failure cleanup, nonregular targets, stale staging and manifest limits. The opt-in corpus remains gitignored; no PNGs are committed. -Evidence for the shared light Color: geometry tests in `tests_lighting`, effect paint tests, command/save/undo/expand and SVG/PDF checks in `three_d_light_color`, and headless picker/reset/preview tests in `tests_revolve`. Revolve keeps smooth colored vector ramps and continuous flat-lit caps. Inflate geometry tests check spherical lighting, shared vertex/edge continuity, concave cell coverage, puffed backs at perspective and legacy single-light/list equivalence; rendered command/export regressions cover smooth shading and editable expansion. Four legacy white-light PNGs match main byte for byte; [release measurements](3d-lighting-performance.md) record the colored solid-fill batch and the remaining source-gradient cost. +Evidence for the shared light Color: geometry tests in `tests_lighting`, effect paint tests, command/save/undo/expand and SVG/PDF checks in `three_d_light_color`, and headless picker/reset/preview tests in `tests_revolve`. Revolve keeps smooth colored vector ramps and continuous flat-lit caps. Inflate geometry tests check spherical lighting, shared vertex/edge continuity, concave cell coverage, puffed backs at perspective and legacy single-light/list equivalence; rendered command/export regressions cover smooth shading and editable expansion. Four legacy white-light PNGs matched main byte for byte when this was measured (2026-10-17); since every 3D seam took round joins (2026-11-12), white-light output is expected to differ from them at facet corners ([Seams](three-d.md#seams)); the four scenes were not exported again to measure it; [release measurements](3d-lighting-performance.md) record the colored solid-fill batch and the remaining source-gradient cost. ## Eyedropper character dimensions (2026-11-11) @@ -2120,6 +2120,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | 3D: The seams of Extrude & Bevel, Inflate and Rotate under a white light, and every 3D (Classic) seam, went from miter to round joins (colored-light seams were already round): no thorns on edge-on facets, and facets with no neighbour now have rounded convex corners ([three-d.md](three-d.md#seams)). White-light output differs from the 2026-10-17 byte comparison at facet corners. Scores and estimates unchanged. | | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-12 | minor | Glyphs review (#6084): Entire Font uses the selection's version and variable axes for previews and alternate applicability. The default cell can reset an unlisted glyph choice when GSUB lists no alternates. Encoded Latin and other ordinary characters insert unchanged even if their glyph also has Arabic positional provenance; Arabic presentation forms retain base-letter insertion. Remove a redundant charmap scan from unlisted-glyph checks. Scores and estimates unchanged. | | 2026-11-09 | minor | Glyphs review (#6084): positional alternates with no supported feature are accepted as glyph choices in both panel views; Entire Font's unlisted-glyph and name-ownership behavior is retained. OpenType style options keep only a valid last on-value when enabling a set. Contradictory old glyph-choice evidence corrected; scores and estimates unchanged. | diff --git a/docs/three-d.md b/docs/three-d.md index 820bc1e805..57f1a91499 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -86,7 +86,7 @@ object is where the art was. | Bevel both sides | The back is bevelled too. | Holes stay holes, with walls of their own. Bevels move each corner along its bisector (mitres are limited to four -times the width), so a bevel wider than a narrow part of the shape folds over itself. +times the width), so a bevel wider than a narrow part of the shape folds over itself. Each facet has a seam ([Seams](#seams)). ## Inflate @@ -102,7 +102,33 @@ middle. | Volume | 0–100 %. The surface is a membrane under even pressure, held along the outline (and around holes): it rises vertically from the edge and rounds smoothly inside, without creases along the shape's middle. At 50 % a circle becomes a half sphere; 100 % puffs twice as high, 0 % is flat. | | Both sides | The back puffs out too; otherwise it is flat. | -The surface starts with a grid of 48 cells across the larger side, halved or quartered where it rises steeply near the outline. White-light Inflate retains the existing clipped polygon cells, shading, paint composition and seam joins. With colored lights, simple cells are triangulated with shared border samples, and lighting interpolates shared membrane normals through ordinary RGB vector gradients, including on a puffed back. Solid fills batch those triangles into three channel groups and one coverage clip for the whole surface. Source gradients retain per-facet vector composition and apply transparent coverage once. Seam widths retain the 0.05 to 0.75 pt bounds; colored facets use round joins. Shading is automatic, with no additional dialog option. +The surface starts with a grid of 48 cells across the larger side, halved or quartered where it rises steeply near the outline. White-light Inflate retains the existing clipped polygon cells, shading and paint composition. With colored lights, simple cells are triangulated with shared border samples, and lighting interpolates shared membrane normals through ordinary RGB vector gradients, including on a puffed back. Solid fills batch those triangles into three channel groups and one coverage clip for the whole surface. Source gradients retain per-facet vector composition and apply transparent coverage once. Seam widths retain the 0.05 to 0.75 pt bounds, and every facet's seam has round joins ([Seams](#seams)). Shading is automatic, with no additional dialog option. + +## Seams + +Facets side by side, each antialiased on its own, would leave hairlines between them, so 3D facets are drawn with +a seam of their own paint, with round joins. In Extrude & Bevel, Inflate, Rotate and the three 3D (Classic) effects a +facet's seam is half as wide as the facet is thick, from 0.05 to 0.75 pt; Revolve's seam is always 0.75 pt. + +How a facet carries its seam depends on the lights and the paint: + +- Extrude & Bevel, Inflate and Rotate, when no light is colored (one white light or several): a path filled with + the facet's shading and stroked with its seam. With a colored light, Extrude & Bevel and Rotate (planar shading) + are drawn the same way. +- Inflate with a colored light and shading on: a solid-color fill draws each facet's light ramps with its seam in three channel + groups clipped to the union of its facets, so no seam reaches past the surface; a gradient fill fills each facet's + ramps through a clip of the facet and its seam, outlined with round joins. +- Extrude & Bevel (Classic), Rotate (Classic) and Revolve (Classic): a path and a seam for every face; gradient and + pattern faces seam only the base paint, and the shading and highlight layers cover the face alone. The Wireframe + surface draws edges only, with no seam. +- Revolve, with shading on: when a `lights` list is given (even one of white lights), a solid-color or gradient face fills its light + ramps through a clip of the face and its 0.75 pt seam, outlined with round joins. With the single legacy light, + a face is a path and a seam; with a colored `lightColor` a gradient face fills through the same kind of clip. + +A facet seen almost edge-on is a sliver, and a seam with mitered joins would reach past the solid as a thorn; a round +one reaches no more than half its width past its facet. So where a facet has no neighbor (Rotate's plane, every +silhouette corner) its convex corners are drawn rounded by up to 0.375 pt (half the largest seam), except a +solid-color Inflate under a colored light, whose clip keeps its corners sharp. ## Fills and strokes @@ -185,7 +211,7 @@ solids are ([Groups](#groups)), turned about one shared axis. The flat art turned in 3D about the centre of its bounds. Every fill and stroke is turned with its own paint (a stroke as its outline, so it foreshortens with the art) and shaded as one plane; a plane has two sides, so its -back shows when it is turned past 90°. +back shows when it is turned past 90°. The plane has a seam ([Seams](#seams)). ## Rotation presets @@ -361,6 +387,7 @@ Release measurements and a repeatable CLI scene are in [3D lighting performance] | Date | Change | |---|---| +| 2026-11-12 | The seams of Extrude & Bevel, Inflate and Rotate under a white light, and every 3D (Classic) seam, went from miter to round joins (colored-light seams were already round): no thorns on edge-on facets, and facets with no neighbor now have rounded convex corners ([Seams](#seams)). | | 2026-11-08 | Contours that cross, touch or overlap are merged by vectorcraft-pathops before Extrude & Bevel, Inflate and Rotate build a solid, sharing its limits; others are built as drawn. | | 2026-11-06 | Extrude & Bevel, Inflate and Rotate apply to groups and layers: every path, compound path and type in them turns about one shared axis (#4506). | | 2026-10-21 | Four original lighting presets, shared by all four 3D effects and commands, with effective-setting recognition and existing preview/history behavior. | From 1032fb39799d85294a756d1659c9317eb28a046d Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Thu, 12 Nov 2026 16:28:51 -0700 Subject: [PATCH 02/14] Round seams: Classic Rotate and Revolve tests, Wireframe edge, docs - a_faces_seam_stays_within_half_its_width_of_the_face checks Rotate (Classic)'s branches too: No Shading keeps its paint, a gradient keeps the gradient, and Diffuse shades its plane darker at some view. - classic_revolves_seams_are_round checks each seam's reach as well as its join, and covers a gradient. - The Wireframe edge takes seam_stroke (the same stroke it built by hand). - Docs: which seams went from miter to round and which already were; the white-light difference from the 2026-10-17 comparison is expected, not measured; the 3D area rows record the change; the performance note says its measurement predates the Classic Rotate and Revolve paths. --- crates/effects/src/solids/classic.rs | 6 +-- crates/effects/src/solids/tests.rs | 65 ++++++++++++++++++++-------- docs/3d-lighting-performance.md | 2 +- docs/effects-parity.md | 4 +- docs/gaps.md | 2 +- docs/target-app-parity.md | 8 ++-- docs/three-d.md | 2 +- 7 files changed, 59 insertions(+), 30 deletions(-) diff --git a/crates/effects/src/solids/classic.rs b/crates/effects/src/solids/classic.rs index 18b503b5b7..23d8d0ffad 100644 --- a/crates/effects/src/solids/classic.rs +++ b/crates/effects/src/solids/classic.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node}; use vectorcraft_geom::Rect; use vectorcraft_three_d::{Bevel, BevelShape, ClassicShading, ClassicSurface, Extrude, Facet, Light}; @@ -207,8 +207,8 @@ pub(crate) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32 .into_iter() .map(|face| { if wire { - let mut edge = StrokeLayer::new(paint.clone(), WIRE); - edge.join = vectorcraft_doc::LineJoin::Round; + // A wire edge is drawn as a seam is: a round-joined stroke of the paint. + let edge = crate::lighting::seam_stroke(paint.clone(), WIRE); return Arc::new(Node::path(n.id, face.path(), Appearance { items: vec![AppearanceItem::Stroke(edge)], ..Default::default() })); } let (tone, spec) = shading.tone(face.normal, &lights); diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 2917c67618..abfe7fc368 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -1038,8 +1038,9 @@ fn classic_rotate_parameters_are_checked() { } } -/// Under a white light and under colored ones, in Extrude & Bevel, Inflate, Rotate and Extrude & -/// Bevel (Classic, each of its shading branches), a face seen almost edge-on (a sharp sliver) has +/// Under a white light and under colored ones, in Extrude & Bevel, Inflate, Rotate, Extrude & +/// Bevel (Classic, each of its shading branches) and Rotate (Classic, its No Shading and Diffuse +/// surfaces and a gradient), a face seen almost edge-on (a sharp sliver) has /// its seam stroke rounded at the corners: every seam's joins are round, and none reaches past its /// face more than half its width as it is outlined (a mitered seam would reach past the solid as a /// thorn up to ten times that beyond the face's tip). Each case's view makes some face with a @@ -1116,9 +1117,15 @@ fn a_faces_seam_stays_within_half_its_width_of_the_face() { p["surface"] = json!(surface); cases.push((CLASSIC_ROTATE, x, y, p, None)); } + let mut p = flat.clone(); + p["surface"] = json!("diffuse"); + cases.push((CLASSIC_ROTATE, x, y, p, Some(gradient.clone()))); } // Classic's own paint for its fill: a swatch in CMYK, which its unshaded faces keep as it is. let brick = Paint::Solid { color: Color::Cmyk { c: 0.0, m: 0.75, y: 0.9, k: 0.2 }, swatch: Some("Brick".into()), tint: 1.0 }; + // Rotate (Classic)'s one plane is shaded at some views, not at every one (the light can leave + // it as its paint), so its Diffuse case is held to a darkened face at one view at least. + let mut rotate_shaded = false; for (id, x, y, params, paint) in cases { let mut n = if id == ROTATE || id == CLASSIC_ROTATE { let mut b = BezPath::new(); @@ -1134,7 +1141,7 @@ fn a_faces_seam_stays_within_half_its_width_of_the_face() { for (k, item) in n.appearance.items.iter_mut().enumerate() { match &paint { Some(paint) => *item.paint_mut() = paint.clone(), - None if id == CLASSIC_EXTRUDE && k == 0 => *item.paint_mut() = brick.clone(), + None if (id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE) && k == 0 => *item.paint_mut() = brick.clone(), None => {} } } @@ -1155,10 +1162,10 @@ fn a_faces_seam_stays_within_half_its_width_of_the_face() { seamed.push((path.clone(), face.appearance.items.clone())); } }); - // Each Classic case reaches its own branch: an unshaded face keeps the paint itself (its - // swatch and color space too), a shaded one is the paint darkened, and a gradient's - // face keeps the gradient. - if id == CLASSIC_EXTRUDE { + // Each Classic case (Extrude & Bevel and Rotate) reaches its own branch: an unshaded face + // keeps the paint itself (its swatch and color space too), a shaded one is the paint + // darkened, and a gradient's face keeps the gradient. + if id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE { let fills: Vec<&Paint> = seamed .iter() .filter_map(|(_, items)| match items.first() { @@ -1178,7 +1185,11 @@ fn a_faces_seam_stays_within_half_its_width_of_the_face() { } _ => false, }); - assert!(darkened, "{what}: some face shaded darker than its paint"); + if id == CLASSIC_ROTATE { + rotate_shaded |= darkened; + } else { + assert!(darkened, "{what}: some face shaded darker than its paint"); + } } } // Every seamed fill (the paint's, a shadow's over it) has its seams checked, and on some @@ -1224,32 +1235,50 @@ fn a_faces_seam_stays_within_half_its_width_of_the_face() { } } } + assert!(rotate_shaded, "Rotate (Classic), Diffuse: no view shades its plane darker than its paint"); } -/// Revolve (Classic) draws its faces through Classic's surfaces: every face's seam has round joins, -/// on the plastic, diffuse and unshaded surfaces. +/// Revolve (Classic) draws its faces through Classic's surfaces: on the plastic, diffuse and +/// unshaded surfaces, and with a gradient, every face's seam has round joins and reaches no more +/// than half its width past its face as it is outlined (the reach Revolve's overlap test counts on). #[test] fn classic_revolves_seams_are_round() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, RED), GradientStop::new(1.0, BLUE)], + )))); let mut b = BezPath::new(); b.move_to((100.0, 40.0)); b.line_to((130.0, 60.0)); b.line_to((130.0, 140.0)); b.line_to((100.0, 160.0)); - for surface in ["plastic", "diffuse", "none"] { - let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(BLUE), Paint::None, 0.0)); + let cases = [("plastic", Paint::solid(BLUE)), ("diffuse", Paint::solid(BLUE)), ("none", Paint::solid(BLUE)), ("plastic", gradient)]; + for (surface, paint) in cases { + let what = format!("{surface} gradient {}", matches!(paint, Paint::Gradient(_))); + let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(paint, Paint::None, 0.0)); n.appearance.effects.push(fx(crate::CLASSIC_REVOLVE, json!({"surface": surface}))); let art = crate::revolve_art(&n).unwrap(); let mut seams = 0; art.walk(&mut |face| { + let Some(path) = face.path_data() else { return }; for item in &face.appearance.items { - if let AppearanceItem::Stroke(s) = item - && s.width > 0.0 - { - seams += 1; - assert_eq!(s.join, vectorcraft_doc::LineJoin::Round, "{surface}: a seam's joins"); + let AppearanceItem::Stroke(s) = item else { continue }; + if s.width <= 0.0 { + continue; } + seams += 1; + assert_eq!(s.join, vectorcraft_doc::LineJoin::Round, "{what}: a seam's joins"); + let drawn = crate::stroke::line_outline(&path.to_bezpath(), s, s.width, s.width * 1e-3); + let mut farthest: f64 = 0.0; + kurbo::flatten(drawn.iter(), s.width * 1e-4, |el| { + if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el { + farthest = farthest.max(path.nearest(p).map_or(f64::INFINITY, |n| n.4)); + } + }); + assert!(farthest <= s.width / 2.0 * 1.01, "{what}: a seam {} wide reaches {farthest} from its face", s.width); } }); - assert!(seams > 0, "{surface}: seams checked"); + assert!(seams > 0, "{what}: seams checked"); } } diff --git a/docs/3d-lighting-performance.md b/docs/3d-lighting-performance.md index 2d40254ee6..e7683cdf5c 100644 --- a/docs/3d-lighting-performance.md +++ b/docs/3d-lighting-performance.md @@ -78,7 +78,7 @@ Four original synthetic scenes were exported at 1200 x 1200 from unchanged main ## Round seam joins -Measured once, on 2026-11-12, on another machine (an Apple M4 Pro with 14 cores) that was not idle: its one-minute load average was 6 to 13, so these are one loaded run, not the idle measurements above. Two `vectorcraft-cli` builds, each in release (`cargo build --release -p vectorcraft-cli`): main at `6d6bbff53`, and that commit with the round seam joins, run in turn, three times each. `bench` fits the artboard to 95% of 2880 x 1800 pixels, draws one frame to warm each renderer and reports the average of the frames after it: `threads 8` with the renderer's default workers (one per core it may use, up to 8, one core left free), `threads 0` on the calling thread alone. A cold frame is a whole `run` that opens the scene, builds its 3D art and draws it once. +Measured once, on 2026-11-12, on another machine (an Apple M4 Pro with 14 cores) that was not idle: its one-minute load average was 6 to 13, so these are one loaded run, not the idle measurements above. Two `vectorcraft-cli` builds, each in release (`cargo build --release -p vectorcraft-cli`): main at `6d6bbff53`, and that commit with the round seam joins, run in turn, three times each. The measurement predates the change's rebase onto a main whose Rotate (Classic) and Revolve (Classic) draw their faces through Classic's surfaces, so it does not cover their seams. `bench` fits the artboard to 95% of 2880 x 1800 pixels, draws one frame to warm each renderer and reports the average of the frames after it: `threads 8` with the renderer's default workers (one per core it may use, up to 8, one core left free), `threads 0` on the calling thread alone. A cold frame is a whole `run` that opens the scene, builds its 3D art and draws it once. White light: extruded type with a cove bevel, an Inflate, an Extrude & Bevel (Classic) star with a classic bevel and a Revolve, no strokes (6,283 nodes drawn). The median of three runs of 20 frames, on 8 threads and on one: main 8.0 ms and 18.3 ms; with the round joins 7.5 ms and 17.7 ms. Cold frames took 0.08 s of processor time on each. The differences are within noise for one loaded run. Not measured: `vectorcraft-cli perf`, the legacy white-light Inflate row above (still the 2026-10-17 figures), frames whose 3D art is rebuilt (while dragging a 3D slider, say) apart from the cold run, and SVG/PDF exports. diff --git a/docs/effects-parity.md b/docs/effects-parity.md index d834a852a9..3465001072 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -181,7 +181,7 @@ VectorCraft choices; an original approximation, never compared with another appl | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | | Extrude & Bevel, Inflate, Rotate | initial: live, round seam joins, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | | Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect, and Quick Actions › Expand as Wireframes ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); no materials, soft shadows or ray tracing | -| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, round seam joins, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none | +| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, round seam joins, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, round seam joins, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, round seam joins, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none | | Turntable (30.0 beta) | none | | Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) | @@ -193,7 +193,7 @@ angular cut caps, exact visibility, the rest of 3D Classic (Map Art), Turntable) | Date | Change | Summary | |---|---|---| -| 2026-11-12 | minor | The seams of Extrude & Bevel, Inflate and Rotate under a white light, and every 3D (Classic) seam, went from miter to round joins (colored-light seams were already round): no thorns on edge-on facets, and facets with no neighbor now have rounded convex corners ([three-d.md](three-d.md#seams)); scores and estimates unchanged | +| 2026-11-12 | minor | The seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins (the seams of Revolve and of colored lights, and 3D (Classic) Wireframe edges, were already round): no thorns on edge-on facets, and facets with no neighbor now have rounded convex corners ([three-d.md](three-d.md#seams)); scores and estimates unchanged | | 2026-11-10 | minor | Effect Gallery: each filter has a thumbnail, and the dialog has a preview of the visible stack (or the browsed filter when the stack is empty). Both are an original landscape drawing passed through the filter ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)). The canvas preview and one-undo OK are unchanged. Remaining hours for the gallery's thumbnails and in-dialog preview: 1–2 → none. Counts unchanged (57/57). | | 2026-11-08 | minor | Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap (a glyph's arm across its stem) before building a solid, so the solid follows the shape as it is filled; others are built as drawn ([three-d.md](three-d.md)); scores and estimates unchanged | | 2026-11-06 | minor | Extrude & Bevel, Inflate and Rotate apply to a group or layer: a solid for every path, compound path and type in it, all turned about one shared axis as the public documentation describes for groups ([three-d.md](three-d.md#groups), #4506); scores and estimates unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index b4def77cd4..2aae655f63 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -865,7 +865,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| -| 2026-11-12 | minor | 3D: The seams of Extrude & Bevel, Inflate and Rotate under a white light, and every 3D (Classic) seam, went from miter to round joins (colored-light seams were already round): no thorns on edge-on facets, and facets with no neighbour now have rounded convex corners ([three-d.md](three-d.md#seams)). Scores and estimates unchanged. | +| 2026-11-12 | minor | 3D: The seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins (the seams of Revolve and of colored lights, and 3D (Classic) Wireframe edges, were already round): no thorns on edge-on facets, and facets with no neighbour now have rounded convex corners ([three-d.md](three-d.md#seams)). Scores and estimates unchanged. | | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-09 | minor | G4 review (#6084): positional variants no supported feature draws can be applied as glyph choices in both Glyphs views; unlisted glyphs still respect name ownership. OpenType style options keep only a valid last on-value when enabling a set. Existing contextual/ligature lookup and unsupported-feature gaps remain; estimate unchanged. | | 2026-11-07 | minor | G4: the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to that letter alone (`salt=2`; the style commands take `tag=N`; runs that differ only in their features shape as one piece of text), so an Arabic word stays joined, and Show › Entire Font lists the unencoded glyphs (#5514). Estimate unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f7504a487d..53fb7b1a02 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (3D: the seams of Extrude & Bevel, Inflate and Rotate under a white light, and every 3D (Classic) seam, went from miter to round joins, so white-light 3D output is no longer identical to the 2026-10-17 comparison; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (3D: the seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins, so white-light 3D output is expected to differ from the 2026-10-17 comparison at facet corners (not measured: the scenes were not exported again); previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -226,7 +226,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | Eyedropper copies effective character dimensions across text transforms (#6404); 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors| 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (nine generated libraries including Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); every 3D seam has round joins (2026-11-12, [three-d.md](three-d.md#seams)), score, gaps and hours unchanged | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | @@ -234,7 +234,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, and Map Markers are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility), Revolve (Classic) with Angle, Cap, Offset and the Plastic, Diffuse, No Shading and Wireframe surfaces, the Quick Actions › Expand as Wireframes command (#6354); materials, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); Revolve (Classic) is covered by the `revolve::classic` tests and `dialogs::revolve::tests::classic_revolve_shows_its_options_and_applies_as_one_step`; Expand as Wireframes is covered by `crates/effects/src/solids/wireframe/tests.rs` and `crates/engine/src/cmd/wireframe/tests.rs` (score, gaps and hours unchanged) | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility), Revolve (Classic) with Angle, Cap, Offset and the Plastic, Diffuse, No Shading and Wireframe surfaces, the Quick Actions › Expand as Wireframes command (#6354); materials, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); Revolve (Classic) is covered by the `revolve::classic` tests and `dialogs::revolve::tests::classic_revolve_shows_its_options_and_applies_as_one_step`; Expand as Wireframes is covered by `crates/effects/src/solids/wireframe/tests.rs` and `crates/engine/src/cmd/wireframe/tests.rs` (score, gaps and hours unchanged); every 3D seam has round joins (2026-11-12, [three-d.md](three-d.md#seams)), score, gaps and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | @@ -2120,7 +2120,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| -| 2026-11-12 | minor | 3D: The seams of Extrude & Bevel, Inflate and Rotate under a white light, and every 3D (Classic) seam, went from miter to round joins (colored-light seams were already round): no thorns on edge-on facets, and facets with no neighbour now have rounded convex corners ([three-d.md](three-d.md#seams)). White-light output differs from the 2026-10-17 byte comparison at facet corners. Scores and estimates unchanged. | +| 2026-11-12 | minor | 3D: The seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins (the seams of Revolve and of colored lights, and 3D (Classic) Wireframe edges, were already round): no thorns on edge-on facets, and facets with no neighbour now have rounded convex corners ([three-d.md](three-d.md#seams)). White-light output is expected to differ from the 2026-10-17 byte comparison at facet corners; the four scenes were not exported again to measure it. Scores and estimates unchanged. | | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-12 | minor | Glyphs review (#6084): Entire Font uses the selection's version and variable axes for previews and alternate applicability. The default cell can reset an unlisted glyph choice when GSUB lists no alternates. Encoded Latin and other ordinary characters insert unchanged even if their glyph also has Arabic positional provenance; Arabic presentation forms retain base-letter insertion. Remove a redundant charmap scan from unlisted-glyph checks. Scores and estimates unchanged. | | 2026-11-09 | minor | Glyphs review (#6084): positional alternates with no supported feature are accepted as glyph choices in both panel views; Entire Font's unlisted-glyph and name-ownership behavior is retained. OpenType style options keep only a valid last on-value when enabling a set. Contradictory old glyph-choice evidence corrected; scores and estimates unchanged. | diff --git a/docs/three-d.md b/docs/three-d.md index 57f1a91499..6c23d31b8a 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -387,7 +387,7 @@ Release measurements and a repeatable CLI scene are in [3D lighting performance] | Date | Change | |---|---| -| 2026-11-12 | The seams of Extrude & Bevel, Inflate and Rotate under a white light, and every 3D (Classic) seam, went from miter to round joins (colored-light seams were already round): no thorns on edge-on facets, and facets with no neighbor now have rounded convex corners ([Seams](#seams)). | +| 2026-11-12 | The seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins (the seams of Revolve and of colored lights, and 3D (Classic) Wireframe edges, were already round): no thorns on edge-on facets, and facets with no neighbor now have rounded convex corners ([Seams](#seams)). | | 2026-11-08 | Contours that cross, touch or overlap are merged by vectorcraft-pathops before Extrude & Bevel, Inflate and Rotate build a solid, sharing its limits; others are built as drawn. | | 2026-11-06 | Extrude & Bevel, Inflate and Rotate apply to groups and layers: every path, compound path and type in them turns about one shared axis (#4506). | | 2026-10-21 | Four original lighting presets, shared by all four 3D effects and commands, with effective-setting recognition and existing preview/history behavior. | From 0620df747c60e08445577c52da42aba87b11429f Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Thu, 12 Nov 2026 18:11:27 -0700 Subject: [PATCH 03/14] Parity: Rotate (Classic) is no longer listed as missing from 3D Classic --- docs/target-app-parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4aa7e8da42..9c31194ae0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -228,7 +228,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | Eyedropper copies effective character dimensions across text transforms (#6404); 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors| 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (nine generated libraries including Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); every 3D seam has round joins (2026-11-12, [three-d.md](three-d.md#seams)), score, gaps and hours unchanged | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); every 3D seam has round joins (2026-11-12, [three-d.md](three-d.md#seams)), score, gaps and hours unchanged | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | @@ -236,7 +236,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, and Map Markers are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility), Revolve (Classic) with Angle, Cap, Offset and the Plastic, Diffuse, No Shading and Wireframe surfaces, the Quick Actions › Expand as Wireframes command (#6354); materials, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); Revolve (Classic) is covered by the `revolve::classic` tests and `dialogs::revolve::tests::classic_revolve_shows_its_options_and_applies_as_one_step`; Expand as Wireframes is covered by `crates/effects/src/solids/wireframe/tests.rs` and `crates/engine/src/cmd/wireframe/tests.rs` (score, gaps and hours unchanged); every 3D seam has round joins (2026-11-12, [three-d.md](three-d.md#seams)), score, gaps and hours unchanged | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility), Revolve (Classic) with Angle, Cap, Offset and the Plastic, Diffuse, No Shading and Wireframe surfaces, the Quick Actions › Expand as Wireframes command (#6354); materials, shadows, ray tracing, the rest of 3D Classic (Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); Revolve (Classic) is covered by the `revolve::classic` tests and `dialogs::revolve::tests::classic_revolve_shows_its_options_and_applies_as_one_step`; Expand as Wireframes is covered by `crates/effects/src/solids/wireframe/tests.rs` and `crates/engine/src/cmd/wireframe/tests.rs` (score, gaps and hours unchanged); every 3D seam has round joins (2026-11-12, [three-d.md](three-d.md#seams)), score, gaps and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | From cf4d27c38eee4dc1917c75f492422a6dd835a9ee Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 02:15:55 -0800 Subject: [PATCH 04/14] Graphs: stroke, gradient and recolor commands update a series' stored paint A stroke weight set with stroke.set or stroke.weightUp/weightDown, a gradient made, placed or edited with paint.editGradient, paint.setGradientGeom or the freeform gradient edits, and colors changed by paint.invert, paint.complement, the Edit Colors recolor and Blend commands, recolor.apply, addon.color.filter, addon.color.applyNearestSwatch or the ink limit of addon.color.printFixes changed a series mark but not the paint the graph stores, so the mark went back to the old paint when the graph was drawn again. appearance::edit_items now stores what its edit changes on the topmost visible fill and stroke of series marks, as the Appearance commands do, and the recolor pass does the same for the series marks it reaches, including those inside a series group or a graph it is given. When one command changes several marks of a series, the frontmost one sets the stored paint. A gradient is stored with its placement. These commands do not store the colors they give a graph's axes and labels. docs/mcp.md, the params text and the Graphs line in docs/target-app-parity.md say what is stored. --- crates/engine/src/cmd/addons/color.rs | 6 +- .../src/cmd/addons/color/print_fixes.rs | 4 +- crates/engine/src/cmd/appearance.rs | 31 ++- crates/engine/src/cmd/colorcmds.rs | 29 ++- crates/engine/src/cmd/freeform.rs | 2 +- crates/engine/src/cmd/gradient.rs | 4 +- crates/engine/src/cmd/graph.rs | 2 +- crates/engine/src/cmd/graph/series.rs | 19 ++ crates/engine/src/cmd/graph/tests.rs | 202 ++++++++++++++++++ crates/engine/src/cmd/paint.rs | 4 +- crates/engine/src/cmd/recolor.rs | 6 +- crates/engine/src/cmd/stroke.rs | 4 +- docs/mcp.md | 19 +- docs/target-app-parity.md | 2 +- 14 files changed, 301 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/cmd/addons/color.rs b/crates/engine/src/cmd/addons/color.rs index cd30d29d15..ced10c9912 100644 --- a/crates/engine/src/cmd/addons/color.rs +++ b/crates/engine/src/cmd/addons/color.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Apply as Filter", ["Object", "Plug-ins", "Color Effects"], None, - "{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does → {changed}", + "{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed}", has_selection, filter ), @@ -52,7 +52,7 @@ pub fn specs() -> Vec { "Print Ink Fixes", ["Object", "Plug-ins", "Color Effects"], None, - "{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}", + "{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first). On a path directly in a graph series, the graph also stores a paint the ink limit changes on the topmost visible fill or stroke (docs/mcp.md) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}", has_doc, print_fixes::run ), @@ -70,7 +70,7 @@ pub fn specs() -> Vec { "Apply Nearest Swatch", ["Object", "Plug-ins", "Color Effects"], None, - "{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}", + "{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}", has_selection, nearest::apply ), diff --git a/crates/engine/src/cmd/addons/color/print_fixes.rs b/crates/engine/src/cmd/addons/color/print_fixes.rs index a0dd332fec..c5f430eb76 100644 --- a/crates/engine/src/cmd/addons/color/print_fixes.rs +++ b/crates/engine/src/cmd/addons/color/print_fixes.rs @@ -11,7 +11,7 @@ use vectorcraft_color::swatch::REGISTRATION; use vectorcraft_doc::overprint::{OverprintBlack, is_white}; use vectorcraft_doc::{ColorMode, Node, NodeKind}; -use super::super::super::colorcmds::{Recolor, Scope}; +use super::super::super::colorcmds::{Recolor, Scope, keeping_series_paint}; use super::super::super::overprint::edit_counted; use super::super::super::{bad, checked_ids_or_selection, number_or, switch_or, text_param}; use crate::{Result, Session}; @@ -149,7 +149,7 @@ pub(super) fn run(s: &mut Session, p: &Value) -> Result { true }; let scope = Scope { fill: fixes.fill, stroke: fixes.stroke, images: false, patterns: false }; - ink = Recolor::with_links(scope, &|c| fixes.limit(c), &links).run(d, &ids); + ink = keeping_series_paint(d, &ids, |d| Ok(Recolor::with_links(scope, &|c| fixes.limit(c), &links).run(d, &ids)))?; } if !fixes.remove_white_overprint && fixes.overprint_black <= 0.0 { return Ok(ink); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index fb86fa7852..fe24e75ac4 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -427,8 +427,9 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result> { /// One undo step (`label`) running `f` on each of `ids` with the item a fill (`fill`) or stroke /// edit aimed at `item` changes there (`None`: the topmost one; text then edits its characters). -/// Nothing targeted is not an edit, and when `f` leaves every target as it was, the document and -/// its undo history stay as they are. +/// What the edit changes on the topmost visible fill and stroke of graph series marks is stored on +/// their graphs in the same step ([`capture_series_changes`]). Nothing targeted is not an edit, and +/// when `f` leaves every target as it was, the document and its undo history stay as they are. pub(crate) fn edit_items( s: &mut Session, ids: &[NodeId], @@ -438,12 +439,13 @@ pub(crate) fn edit_items( fill: bool, f: impl FnMut(&mut Node, Option) -> Result<()>, ) -> Result<()> { - edit_items_then(s, ids, item, cmd, label, fill, f, |_, _| Ok(false)) + edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| Ok(capture_series_changes(before, d, edited))) } -/// [`edit_items`], then `after` on the ids `f` ran on, inside the same undo step. `after` returns -/// whether it changed the document; when it did not and `f` left every target as it was, the -/// document and its undo history stay as they are. +/// [`edit_items`], then `after` on the document as it was before the edit, the edited document and +/// the ids `f` ran on, inside the same undo step. `after` returns whether it changed the document; +/// when it did not and `f` left every target as it was, the document and its undo history stay as +/// they are. #[allow(clippy::too_many_arguments)] pub(crate) fn edit_items_then( s: &mut Session, @@ -453,7 +455,7 @@ pub(crate) fn edit_items_then( label: &str, fill: bool, mut f: impl FnMut(&mut Node, Option) -> Result<()>, - mut after: impl FnMut(&mut vectorcraft_doc::Document, &[NodeId]) -> Result, + mut after: impl FnMut(&vectorcraft_doc::Document, &mut vectorcraft_doc::Document, &[NodeId]) -> Result, ) -> Result<()> { if ids.is_empty() { return Ok(()); @@ -469,7 +471,7 @@ pub(crate) fn edit_items_then( f(n, index)?; edited.push(*id); } - let more = after(d, &edited)?; + let more = after(&before, d, &edited)?; Ok(count_changed(d, &before, &edited) + usize::from(more)) })?; Ok(()) @@ -710,6 +712,19 @@ pub(super) fn capture_series_writes( kept || painted || stored } +/// Store on the graph what an edit of `ids` (from `before` to `d`) changed on the series marks among them: the paint, +/// weight, opacity or blend mode of the topmost visible fill and stroke, and the object opacity and blend mode. Rows +/// and objects the edit left as they were keep what the graph stores. When the edit changes several marks of one series, +/// the frontmost one sets what the graph stores, whatever order `ids` are in. Returns whether the stored paint changed. +pub(crate) fn capture_series_changes(before: &vectorcraft_doc::Document, d: &mut vectorcraft_doc::Document, ids: &[NodeId]) -> bool { + // Only objects directly in a series group can be marks: other edits skip the search of the document for graphs. + let in_series = |id: &NodeId| d.parent_of(*id).and_then(|p| d.node(p)).is_some_and(|g| g.extras().series_index.is_some()); + let mut marks: Vec = ids.iter().copied().filter(in_series).collect(); + // A later mark's capture replaces an earlier one's: take them in paint order. + marks.sort_by_cached_key(|id| d.index_path(*id)); + !marks.is_empty() && capture_series_writes(before, d, &marks, (false, false), (false, false), false, false) +} + /// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`. fn top_row_kind(ap: &Appearance, at: usize) -> Option { let fill = ap.items.get(at)?.is_fill(); diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index bbc56dbc15..1238ba5978 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Invert Colors", ["Edit", "Edit Colors"], None, - "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}", + "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}", has_selection, |s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert) ), @@ -89,7 +89,7 @@ pub fn specs() -> Vec { "Blend Front to Back", ["Edit", "Edit Colors"], None, - "{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill → {changed}", + "{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill. On a path directly in a graph series, the graph also stores the new fill, as for the other Blend commands (docs/mcp.md) → {changed}", has_selection, |s, _| blend(s, BlendOrder::Stack) ), @@ -446,8 +446,11 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) - let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let (n, not_changed) = s.edit(label, |d, _| { let not_changed = NotChanged::of(pass.again().would_change(d, &left).into_iter().map(|(_, hidden)| hidden)); - let n = pass.run(d, &ids); - super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?; + let n = keeping_series_paint(d, &ids, |d| { + let n = pass.run(d, &ids); + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?; + Ok(n) + })?; Ok((n, not_changed)) })?; let mut out = json!({ "changed": n }); @@ -455,6 +458,19 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) - Ok(out) } +/// Run `recolor` on `d` (it returns how many objects it changed) over `roots`, then store on their graphs what it changed +/// on the graph series marks among `roots` and inside them, so the marks keep their new paint when the graph is drawn +/// again. +pub(crate) fn keeping_series_paint(d: &mut Document, roots: &[NodeId], recolor: impl FnOnce(&mut Document) -> Result) -> Result { + let marks = super::graph::series_marks_in(d, roots); + let before = (!marks.is_empty()).then(|| d.clone()); + let n = recolor(d)?; + if let Some(before) = before.filter(|_| n > 0) { + super::appearance::capture_series_changes(&before, d, &marks); + } + Ok(n) +} + /// The objects a recolor of `roots` leaves out of the art the user meant to recolor, each with /// whether it is hidden (otherwise it is locked). When `roots` are exactly the objects Select All /// takes (in isolation mode, those of the isolated group), these are the objects beside them that @@ -679,7 +695,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { }; let fills = Scope { fill: true, stroke: false, images: false, patterns: false }; let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); - let changed = s.edit(label, |d, _| { + let run = |d: &mut Document| -> Result { let mut changed = 0; for (i, id) in ids.iter().enumerate().take(n - 1).skip(1) { let c = lerp_color(&first, &last, i as f32 / (n - 1) as f32).in_model(model); @@ -698,7 +714,8 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { } super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?; Ok(changed) - })?; + }; + let changed = s.edit(label, |d, _| keeping_series_paint(d, &ids, run))?; Ok(json!({ "changed": changed })) } diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index 0f72b066ab..f4ec784af3 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -19,7 +19,7 @@ pub fn specs() -> Vec { "Add Freeform Point", [], None, - "{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it → {index}", + "{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md) → {index}", has_doc, add_point ), diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 090a7cb46c..fd850bb741 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md)", has_doc, edit_gradient ), @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Gradient Vector", [], None, - "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space)", + "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement when it is on the topmost visible fill or stroke (docs/mcp.md)", has_doc, set_gradient_geom ), diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 2c42494160..e2bb2b312c 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -46,7 +46,7 @@ use generate::generate; use series::default_series_color; pub(crate) use series::{ capture_label_anti_alias, capture_label_paragraphs, capture_label_styles, capture_series_object_opacity, capture_series_opacity, - capture_series_paints, capture_series_top_paints, paint_targets, series_object_opacity_differs, shown_paint, + capture_series_paints, capture_series_top_paints, paint_targets, series_marks_in, series_object_opacity_differs, shown_paint, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 229a4d4b42..a417a74015 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -98,6 +98,25 @@ fn series_members(doc: &Document) -> HashMap { out } +/// The objects directly in a series group among `roots` and inside them, for an edit that reaches graph series marks +/// without naming them. They may include objects of groups that only look like series groups; the capture functions +/// check each one. +pub(crate) fn series_marks_in(doc: &Document, roots: &[NodeId]) -> Vec { + let mut out = Vec::new(); + for id in roots { + if doc.parent_of(*id).and_then(|p| doc.node(p)).is_some_and(|g| g.extras().series_index.is_some()) { + out.push(*id); + } + let Some(root) = doc.node(*id) else { continue }; + root.walk(&mut |n| { + if n.extras().series_index.is_some() { + out.extend(n.children().into_iter().flatten().map(|c| c.id)); + } + }); + } + out +} + /// Group Selection on a graph series targets its constituent marks and legend swatch, not a /// group-level appearance which the generated art does not inherit. Other ids pass through. pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index aef1918a6d..fcb798c45e 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -1733,6 +1733,208 @@ fn swapping_onto_a_hidden_top_fill_leaves_the_stored_fill() { assert_eq!(spec(&s, id).series_paints[0].fill, red); } +/// Run `case` on each of `cases`, and fail naming every case that failed, not only the first one. +fn every_case(cases: impl IntoIterator, case: impl Fn(&T)) { + let failed: Vec = cases.into_iter().filter(|c| std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| case(c))).is_err()).collect(); + assert!(failed.is_empty(), "failed cases: {failed:?}"); +} + +/// The top fill paint of `mark`. +fn fill_of(s: &Session, mark: NodeId) -> Paint { + s.doc().unwrap().doc.node(mark).unwrap().appearance.fill_paint() +} + +/// A stroke weight set with `stroke.set`, or stepped with `stroke.weightUp` or `stroke.weightDown`, on a series mark is +/// stored on the graph with the mark's stroke paint, and undo takes it back with its edit. +#[test] +fn a_stroke_weight_set_on_a_series_mark_is_kept() { + let cases = [("stroke.set", json!({"weight": 7}), 7.0), ("stroke.weightUp", json!({}), 3.0), ("stroke.weightDown", json!({}), 1.0)]; + every_case(cases, |(cmd, params, weight)| { + let (mut s, id, marks) = stored_red_stroke(); + let was = spec(&s, id).series_paints; + let mut p = params.clone(); + p["ids"] = json!([marks[1].0]); + s.execute(cmd, &p).unwrap(); + let mark = s.doc().unwrap().doc.node(marks[1]).unwrap().clone(); + let (fill, stroke) = (mark.appearance.fill_paint(), mark.appearance.stroke_paint()); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(stroke.clone()), Some(*weight)), "{cmd}"); + series_0_is(&mut s, id, (&fill, 0.4, &stroke, *weight), &was); + }); +} + +/// A gradient made on a series mark's fill with `paint.editGradient` is stored on the graph, every mark of the series has +/// that fill after the graph is drawn again, and undo takes it back with its edit. +#[test] +fn a_gradient_edit_on_a_series_mark_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + let was = spec(&s, id).series_paints; + s.execute("paint.editGradient", &json!({"ids": [marks[0].0], "kind": "linear"})).unwrap(); + let fill = fill_of(&s, marks[0]); + assert!(matches!(fill, Paint::Gradient(_)), "{fill:?}"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone())); + let red = was[0].stroke.clone().unwrap(); + series_0_is(&mut s, id, (&fill, 0.4, &red, 2.0), &was); +} + +/// A gradient placed on a series mark's fill with `paint.setGradientGeom` is stored on the graph as it is placed, and +/// every mark of the series has that fill, placement included, after the graph is drawn again. +#[test] +fn a_gradient_placed_on_a_series_mark_is_kept_with_its_placement() { + let (mut s, id, _, marks) = column_marks(); + // `paint.setFill` stores the gradient before it is placed. + s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap(); + let fill = fill_of(&s, marks[0]); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone())); + s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap(); + let placed = fill_of(&s, marks[0]); + assert_ne!(placed, fill, "the command places the gradient"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(placed.clone())); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == placed), "{:?}", marks_of(&s, id, 0)); +} + +/// A point recolored with `paint.freeform.setPoint` in a freeform gradient on a series mark's fill is stored on the graph. +#[test] +fn a_freeform_gradient_edit_on_a_series_mark_is_kept() { + let (mut s, id, _, marks) = column_marks(); + let points = json!([{"at": [20, 20], "color": "#53aadf"}, {"at": [50, 50], "color": "#ff8000"}]); + // `paint.setFill` stores the gradient before its point is recolored. + s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "freeform", "freeform": {"points": points}}})).unwrap(); + let fill = fill_of(&s, marks[0]); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone())); + s.execute("paint.freeform.setPoint", &json!({"ids": [marks[0].0], "index": 1, "color": "#00ff00"})).unwrap(); + let edited = fill_of(&s, marks[0]); + assert_ne!(edited, fill, "the command recolors the point"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(edited.clone())); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == edited), "{:?}", marks_of(&s, id, 0)); +} + +/// A series mark's fill recolored with Invert or Complement (the proxy, or one appearance row), an Edit Colors command, +/// a color filter or Recolor Artwork is stored on the graph, and undo takes it back with its edit. +#[test] +fn a_recolored_series_mark_is_kept() { + let cases = [ + ("paint.invert", json!({}), false), + ("paint.complement", json!({}), false), + ("paint.invert", json!({}), true), + ("paint.complement", json!({}), true), + ("edit.colors.invert", json!({}), false), + ("edit.colors.toGrayscale", json!({}), false), + ("addon.color.filter", json!({"effect": "addon.color.invert"}), false), + ("recolor.apply", json!({"map": {"#3366cc": "#00ff00"}}), false), + ]; + every_case(cases, |(cmd, params, item)| { + let (mut s, id, marks) = stored_red_stroke(); + // Not the gray the series starts in: Complement leaves a gray as it is. + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#3366cc"})).unwrap(); + let was = spec(&s, id).series_paints; + let mut p = params.clone(); + if *cmd == "recolor.apply" { + // Recolor Artwork works on the selection. + s.execute("select.set", &json!({"ids": [marks[0].0]})).unwrap(); + } else { + p["ids"] = json!([marks[0].0]); + } + if *item { + p["item"] = json!(top_row(&s, marks[0], true)); + } + let was_fill = fill_of(&s, marks[0]); + s.execute(cmd, &p).unwrap(); + let fill = fill_of(&s, marks[0]); + assert_ne!(fill, was_fill, "{cmd} recolors the fill"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone()), "{cmd} item {item}"); + let red = was[0].stroke.clone().unwrap(); + series_0_is(&mut s, id, (&fill, 0.4, &red, 2.0), &was); + }); +} + +/// The ink limit of Print Ink Fixes on a series mark's fill is stored on the graph. +#[test] +fn an_ink_limit_on_a_series_mark_is_kept() { + let mut s = Session::new(); + s.execute("file.new", &json!({"colorMode": "cmyk"})).unwrap(); + let id = graph(&mut s, "column"); + let mark = series(&s.doc().unwrap().doc.node(id).unwrap().clone(), 0).children().unwrap()[0].id; + // `paint.setFill` stores the heavy color before the limit lowers it. + s.execute("paint.setFill", &json!({"ids": [mark.0], "color": {"c": 100, "m": 100, "y": 100, "k": 100}})).unwrap(); + let heavy = fill_of(&s, mark); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(heavy.clone())); + s.execute("addon.color.printFixes", &json!({"ids": [mark.0], "inkLimit": 300})).unwrap(); + let fill = fill_of(&s, mark); + assert_ne!(fill, heavy, "the limit lowers the ink"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone())); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == fill), "{:?}", marks_of(&s, id, 0)); +} + +/// A recolor of a whole graph, or of one series group, stores the new fill of each series it reaches, and the marks have +/// it after the graph is drawn again. +#[test] +fn recoloring_a_graph_or_a_series_group_stores_its_marks_fills() { + let cases = [("edit.colors.invert", true), ("edit.colors.invert", false), ("paint.invert", true), ("paint.invert", false)]; + every_case(cases, |(cmd, whole)| { + let (mut s, id, gray, _) = column_marks(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let (group, other) = (series(&n, 0).id, series(&n, 1).children().unwrap()[0].clone()); + let target = if *whole { id } else { group }; + s.execute(cmd, &json!({"ids": [target.0]})).unwrap(); + let fill = marks_of(&s, id, 0).last().unwrap().0.clone(); + assert_ne!(fill, gray, "{cmd} recolors the series"); + let kept = spec(&s, id).series_paints; + assert_eq!(kept[0].fill, Some(fill.clone()), "{cmd} whole {whole}"); + let other_now = fill_of(&s, other.id); + if *whole { + assert_ne!(other_now, other.appearance.fill_paint()); + assert_eq!(kept[1].fill, Some(other_now.clone())); + } else { + assert_eq!(other_now, other.appearance.fill_paint(), "the other series is left as it was"); + assert!(kept.get(1).is_none_or(|p| p.fill.is_none()), "{kept:?}"); + } + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == fill), "{:?}", marks_of(&s, id, 0)); + assert!(marks_of(&s, id, 1).iter().all(|m| m.0 == other_now), "{:?}", marks_of(&s, id, 1)); + }); +} + +/// A recolor that changes several marks of one series to different fills stores the frontmost mark's fill, whatever +/// order the ids are given in. +#[test] +fn recoloring_several_marks_of_a_series_stores_the_frontmost_ones_fill() { + let cases = + [("edit.colors.invert", false), ("paint.invert", true)].into_iter().flat_map(|c| [[0, 1, 2], [2, 1, 0], [1, 2, 0]].map(|o| (c.0, c.1, o))); + every_case(cases, |(cmd, item, order)| { + let (mut s, id, _, marks) = column_marks(); + for (mark, hex) in [(marks[0], "#ff0000"), (marks[1], "#00ff00"), (marks[2], "#0000ff")] { + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap(); + } + let mut p = json!({"ids": order.map(|i| marks[i].0)}); + if *item { + p["item"] = json!(top_row(&s, marks[0], true)); + } + s.execute(cmd, &p).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill_of(&s, marks[2])), "{cmd} {order:?}"); + }); +} + +/// Blend Horizontally across a series' three marks stores the fill it gives the middle one on the graph. +#[test] +fn blending_a_series_marks_fills_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + for (mark, hex) in [(marks[0], "#ff0000"), (marks[2], "#0000ff")] { + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap(); + } + let was = spec(&s, id).series_paints; + s.execute("select.set", &json!({"ids": [marks[0].0, marks[1].0, marks[2].0]})).unwrap(); + s.execute("edit.colors.blendHorizontally", &json!({})).unwrap(); + let mid = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill_paint(); + assert_ne!(Some(&mid), was[0].fill.as_ref()); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(mid.clone())); + let red = was[0].stroke.clone().unwrap(); + series_0_is(&mut s, id, (&mid, 0.4, &red, 2.0), &was); +} + /// A kept opacity read from a file outside 0..1 is held to that range, and one that is not a number is left out. #[test] fn a_kept_series_opacity_from_a_file_is_held_to_its_range() { diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index eaf1597423..266422fc02 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -88,7 +88,7 @@ pub fn proxy_specs() -> Vec { "Invert", [], None, - "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default → {changed}", + "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke (docs/mcp.md) → {changed}", has_doc, |s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert) ), @@ -583,7 +583,7 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) } Ok(()) }, - |d, changed| { + |_, d, changed| { let series = super::graph::capture_series_paints(d, changed, fill); let labels = super::graph::capture_label_styles(d, changed, &|st| { if fill { diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 931bcce681..e4b276a0f8 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -13,7 +13,7 @@ use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use vectorcraft_geom::{Point, Rect}; -use super::colorcmds::{NotChanged, Recolor, Scope, left_out}; +use super::colorcmds::{NotChanged, Recolor, Scope, keeping_series_paint, left_out}; use super::{CommandSpec, Result, Session, always, bad, checked_ids_param, cmd, color_value, has_doc, index, str_param, switch_or, text_param}; pub fn specs() -> Vec { @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Recolor Artwork", ["Edit", "Edit Colors"], None, - "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}", + "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}", has_doc, apply ), @@ -1521,7 +1521,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { .filter_map(|(id, hidden)| Some((id, shown_colors(d, d.node(id)?), hidden))) .collect(); map.converted.set(false); - let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids); + let changed = keeping_series_paint(d, &ids, |d| Ok(Recolor::with_links(scope, &f, &lf).run(d, &ids)))?; let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?; super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?; // Rewriting the group's swatches can change the colors of art linked to them, left out or diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index fe846ad8af..14c86c06ed 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md) → {objects: how many objects took the stroke options}", has_doc, stroke_set ), @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Stroke Weight Up", [], Some("Cmd+Alt+."), - "{ids?|id?} raise each selected stroke's weight by one step of Units › Stroke (a quarter unit below one unit, then whole units; up to 1000 pt), each from its own weight: the selected objects' active or top stroke, type's stroked characters; objects without a stroke are left alone. With nothing selected (and no ids), the weight of the next object drawn → {objects}", + "{ids?|id?} raise each selected stroke's weight by one step of Units › Stroke (a quarter unit below one unit, then whole units; up to 1000 pt), each from its own weight: the selected objects' active or top stroke, type's stroked characters; objects without a stroke are left alone. With nothing selected (and no ids), the weight of the next object drawn. On a path directly in a graph series, the graph also stores the new weight of the topmost visible stroke (docs/mcp.md) → {objects}", has_doc, |s, p| stroke_step(s, p, true) ), diff --git a/docs/mcp.md b/docs/mcp.md index 274db05a34..f2945064e1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -757,11 +757,26 @@ outline, so these commands do not store a stroke paint or weight for a series th designs keep their own paint. A row that had no paint before the command and has none after it does not change the paint the graph stores. `paint.setFill`, `paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other store the topmost row of the kind -even when it is hidden, and store a stroke set on a band. `stroke.set`, `paint.editGradient` and the recolor commands do -not store a series' paint. An `appearance.*` command or `graphicStyle.apply` given a series group's id, and +even when it is hidden, and store a stroke set on a band. +An `appearance.*` command or `graphicStyle.apply` given a series group's id, and `graphicStyle.redefine` on a series group linked to the style, edit the group's own rows, and the graph does not store them. +The graph also stores a paint or stroke weight that one of these commands changes on the topmost visible fill or stroke +of a path directly in a series: a weight set with `stroke.set`, `stroke.weightUp` or `stroke.weightDown`; a gradient +made, placed or edited with `paint.editGradient`, `paint.setGradientGeom`, `paint.freeform.addPoint`, +`paint.freeform.setPoint`, `paint.freeform.deletePoint`, `paint.freeform.addLine` or `paint.freeform.splitLine`; and a +color changed by `paint.invert`, `paint.complement`, `edit.colors.invert`, `edit.colors.adjustBalance`, +`edit.colors.saturate`, `edit.colors.toCMYK`, `edit.colors.toGrayscale`, `edit.colors.toRGB`, +`edit.colors.blendFrontToBack`, `edit.colors.blendHorizontally`, `edit.colors.blendVertically`, `recolor.apply`, +`addon.color.filter`, `addon.color.applyNearestSwatch` or the ink limit of `addon.color.printFixes`. The recolor +commands store it too when they are given a series group, the graph or a group that holds the graph. A row a command +leaves as it was keeps what the graph stores. When one command changes several paths of a series, the frontmost of +them sets what the graph stores. A gradient is stored with its placement, and every path of the series takes that +placement when the graph is drawn again. The graph does not store a stroke's dashes, caps, joins, arrowheads, width +profile or brush. These commands do not store the colors they give a graph's axes and labels, which are drawn again +with the paint they had. + `text.setFormat` without `start` and `end`, on a graph or on its labels, stores the character and paragraph attributes it sets for each kind of label it reaches (value axis numbers, category labels, legend labels), and every label of that kind takes them when the graph is drawn again. The graph saves the paragraph attributes as `valuesPara`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 086d4cfc0d..23d3c5c070 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1056,7 +1056,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. From 4b50fdba1ce2caa2f34729a5ca0189f707a4baef Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 07:27:30 -0800 Subject: [PATCH 05/14] View: the canvas is drawn through the monitor profile on Windows and X11 Preferences > Performance > Use Monitor Profile for Canvas (on by default, `monitorProfileDisplay`) converts the canvas's art, artboard paper and transparency grid from sRGB into the ICC profile of the display that shows most of the main window, after any proof. Arranged panes and the render worker's frames are converted too; the worker keeps its frame in sRGB. - Windows: the display's default profile for its standard color mode (ColorProfileGetDisplayDefault, GetICMProfileW where it is missing). A display with Advanced Color on is left to Windows. - X11: colord's profile for the RandR output, then the output's `_ICC_PROFILE`, then the screen's when only one output is active. - A Wayland session stays sRGB; macOS converts the window itself. - A profile that is missing, larger than 1 MiB, not an RGB display profile or unreadable leaves the canvas sRGB, with the reason in `ui.inspect`'s `displayProfile`. The conversion is sampled into a 33x33x33 table when a profile is read, on a background thread. Exports, `ui.render` and the document are unchanged. --- apps/vectorcraft/Cargo.toml | 8 +- apps/vectorcraft/src/app.rs | 3 + apps/vectorcraft/src/display_profile.rs | 629 +++++++++++++++++++++ apps/vectorcraft/src/main.rs | 4 + crates/color/src/cms.rs | 4 +- crates/color/src/cms/display.rs | 327 +++++++++++ crates/engine/src/cmd/prefscmds.rs | 1 + crates/engine/src/prefs.rs | 5 + crates/engine/src/tests/prefs.rs | 28 + crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/caches.rs | 3 + crates/ui-egui/src/canvas.rs | 22 +- crates/ui-egui/src/canvas/fill.rs | 2 + crates/ui-egui/src/canvas/grid.rs | 17 +- crates/ui-egui/src/canvas/panes.rs | 32 +- crates/ui-egui/src/canvas/raster.rs | 4 +- crates/ui-egui/src/canvas/tests.rs | 48 ++ crates/ui-egui/src/control.rs | 3 + crates/ui-egui/src/dialogs/save_for_web.rs | 2 +- crates/ui-egui/src/display_profile.rs | 311 ++++++++++ crates/ui-egui/src/i18n/bg.tsv | 5 + crates/ui-egui/src/i18n/bs.tsv | 5 + crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/da.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/id.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/nl.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 5 + crates/ui-egui/src/i18n/tests.rs | 16 + crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/prefs_dialog.rs | 10 + crates/ui-egui/src/render_worker.rs | 3 + docs/hardware-parity.md | 2 +- docs/mcp.md | 15 + docs/target-app-parity.md | 1 + 54 files changed, 1617 insertions(+), 23 deletions(-) create mode 100644 apps/vectorcraft/src/display_profile.rs create mode 100644 crates/color/src/cms/display.rs create mode 100644 crates/ui-egui/src/display_profile.rs diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 55b9e344e7..7bdfece60c 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -58,7 +58,10 @@ pollster = "1" # cores the process may run on. Win32_UI_Input_KeyboardAndMouse has `GetKeyState`, which # src/caps_lock.rs uses to read whether Caps Lock is on. Win32_System_Registry has `RegGetValueW`, # which src/host_services.rs uses to read the path PhotoCraft registers under App Paths. -windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } +# Win32_Devices_Display has `QueryDisplayConfig`, Win32_UI_ColorSystem the display's ICC profile and +# Win32_System_LibraryLoader `GetProcAddress`, which src/display_profile.rs uses to read the +# display's color profile for the canvas. +windows = { version = "0.62", features = ["Win32_Devices_Display", "Win32_Foundation", "Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_ColorSystem", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } # The renderer's worker count follows those cores (src/windows_cores.rs). vectorcraft-render = { workspace = true } @@ -93,7 +96,8 @@ winit = { version = "0.30", default-features = false, features = ["wayland-csd-a # A drawing tablet's pen pressure under X11 and XWayland (src/tablet.rs): XInput2 raw events on a # second X connection, x11rb's pure-Rust `RustConnection`. MIT OR Apache-2.0, already in the build # through winit with the `xinput` feature. XKB reads Caps Lock's locked modifier state (src/caps_lock.rs). -x11rb = { version = "0.13", default-features = false, features = ["xinput", "xkb"] } +# RandR reads a display's `_ICC_PROFILE` for the canvas (src/display_profile.rs). +x11rb = { version = "0.13", default-features = false, features = ["randr", "xinput", "xkb"] } # Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs), # so Automatic draws on that one. Both are already in the build through winit. diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 0391b7534b..b753427d8b 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -192,6 +192,9 @@ impl App { self.app.services.dock_monitors = window::dock_monitors(w, zoom); self.monitors_listed = Some((now, zoom)); } + if let Some(w) = &self.winit_window { + self.display_profile.tick(w, now, self.app.session.prefs.monitor_profile_display, &self.app.display_profile, ctx); + } self.app.tick(ctx); if self.pending_maximize { if let Some(w) = &self.winit_window { diff --git a/apps/vectorcraft/src/display_profile.rs b/apps/vectorcraft/src/display_profile.rs new file mode 100644 index 0000000000..2be6e27aa5 --- /dev/null +++ b/apps/vectorcraft/src/display_profile.rs @@ -0,0 +1,629 @@ +//! Reads the ICC profile of the display that shows most of the main window, for Preferences › +//! Performance › Use Monitor Profile for Canvas, and hands it to [`DisplayProfile::report`]. +//! +//! - Windows: the display's default profile for its standard color mode. A display with Advanced +//! Color on (HDR or Automatic Color Management) is converted by Windows, and nothing is read. +//! - X11: colord's profile for the RandR output, then the output's `_ICC_PROFILE` property, then +//! the screen's `_ICC_PROFILE` when only one output is active. +//! - Wayland, XWayland included: the compositor gives applications no display profile, and the +//! canvas stays sRGB. +//! - macOS: the window's layer is tagged sRGB and macOS converts it (`mac_color_space`). +//! +//! While the preference is on, a background thread reads the display every [`EVERY`] seconds, +//! and at once when the window moves to another display. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use vectorcraft_ui_egui::display_profile::{DisplayProfile, Found, Reading}; + +/// Seconds between two readings of the same display. +const EVERY: f64 = 10.0; +/// Seconds between two checks of which display shows the window. +const LOOK_EVERY: f64 = 1.0; + +/// Starts the readings from the main window's frames. +#[derive(Default)] +pub(crate) struct Reader { + /// When the last reading started, and the display it was for. + last: Option<(f64, Option)>, + /// When the window's display was last looked up. + looked: Option, + /// A reading is running. + busy: Arc, +} + +/// Clears [`Reader::busy`] when the reading thread ends, also by a panic. +struct Done(Arc); + +impl Drop for Done { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +impl Reader { + /// Starts a reading when one is due and none is running. `now` is the frame's time in seconds. + pub(crate) fn tick(&mut self, window: &winit::window::Window, now: f64, enabled: bool, profile: &DisplayProfile, ctx: &egui::Context) { + if !enabled || self.busy.load(Ordering::Acquire) || self.looked.is_some_and(|at| (at..at + LOOK_EVERY).contains(&now)) { + return; + } + self.looked = Some(now); + let display = window.current_monitor().and_then(|m| m.name()); + if !due(self.last.as_ref(), now, display.as_deref()) { + return; + } + self.last = Some((now, display.clone())); + profile.pending(); + self.busy.store(true, Ordering::Release); + let done = Done(self.busy.clone()); + let (profile, ctx) = (profile.clone(), ctx.clone()); + let spawned = std::thread::Builder::new().name("display profile".into()).spawn(move || { + let _done = done; + if profile.report(read(display)) { + ctx.request_repaint(); + } + }); + if let Err(e) = spawned { + log::warn!("couldn't start reading the display profile: {e}"); + } + } +} + +/// Whether a reading of `display` is due at `now`, after the last one (`last`): none yet, another +/// display, [`EVERY`] seconds later, or a clock that went back. +fn due(last: Option<&(f64, Option)>, now: f64, display: Option<&str>) -> bool { + last.is_none_or(|(at, shown)| shown.as_deref() != display || !(*at..at + EVERY).contains(&now)) +} + +#[cfg(windows)] +fn read(display: Option) -> Reading { + let found = win::read(display.as_deref()); + Reading { display, found } +} + +#[cfg(target_os = "linux")] +fn read(display: Option) -> Reading { + let found = if wayland_session(|k| std::env::var_os(k)) { + Found::NoProfile("a Wayland session doesn't give applications the display's profile".into()) + } else { + x11::read(display.as_deref()) + }; + Reading { display, found } +} + +#[cfg(all(target_os = "macos", feature = "wgpu"))] +fn read(display: Option) -> Reading { + Reading { display, found: Found::System("macOS converts the window to the display's profile".into()) } +} + +#[cfg(not(any(windows, target_os = "linux", all(target_os = "macos", feature = "wgpu"))))] +fn read(display: Option) -> Reading { + Reading { display, found: Found::NoProfile("this build doesn't read display profiles on this system".into()) } +} + +/// Whether the app runs in a Wayland session, read from the environment `var` gives. XWayland +/// windows count: the variables stay set for them. +#[cfg(any(target_os = "linux", test))] +fn wayland_session(var: impl Fn(&str) -> Option) -> bool { + ["WAYLAND_DISPLAY", "WAYLAND_SOCKET"].iter().any(|k| var(k).is_some_and(|v| !v.is_empty())) +} + +/// The X11 reading from colord's answer `colord` (the profile file it names, read) and the X11 +/// properties `properties`: colord's profile when its file can be read, otherwise the +/// properties' profile. When neither has one, the reason colord gave is kept. +#[cfg(any(target_os = "linux", test))] +fn x11_choice(colord: Option, String>>, properties: impl FnOnce() -> Result, String>) -> Found { + let colord_reason = match colord { + Some(Ok(Some(found @ Found::Profile { .. }))) => return found, + Some(Ok(Some(Found::NoProfile(e) | Found::System(e)))) | Some(Err(e)) => Some(e), + Some(Ok(None)) | None => None, + }; + match properties() { + Ok(Some(found)) => found, + Ok(None) => Found::NoProfile(match colord_reason { + Some(e) => format!("no profile is set for this display ({e})"), + None => "no profile is set for this display".into(), + }), + Err(e) => Found::NoProfile(e), + } +} + +/// `found` when it is not a profile or the profile can be used, otherwise why it can't, so the +/// next source is tried. +#[cfg(any(target_os = "linux", test))] +fn usable(found: Found) -> Found { + match found { + Found::Profile { icc, from } => match vectorcraft_ui_egui::display_profile::check_profile(&icc) { + Ok(()) => Found::Profile { icc, from }, + Err(e) => Found::NoProfile(format!("{from}: {e}")), + }, + other => other, + } +} + +/// The ICC file at `path`, refused before it is read when it is larger than [`MAX_PROFILE_BYTES`]. +/// +/// [`MAX_PROFILE_BYTES`]: vectorcraft_ui_egui::display_profile::MAX_PROFILE_BYTES +#[cfg(any(windows, target_os = "linux", test))] +fn read_icc(path: &std::path::Path) -> Result, String> { + use std::io::Read as _; + let max = vectorcraft_ui_egui::display_profile::MAX_PROFILE_BYTES; + let too_large = || format!("{} is larger than {} MiB", path.display(), max >> 20); + let file = std::fs::File::open(path).map_err(|e| format!("couldn't open {}: {e}", path.display()))?; + let len = file.metadata().map_err(|e| format!("couldn't read {}: {e}", path.display()))?.len(); + if len > max as u64 { + return Err(too_large()); + } + let mut bytes = Vec::with_capacity(len as usize); + file.take(max as u64 + 1).read_to_end(&mut bytes).map_err(|e| format!("couldn't read {}: {e}", path.display()))?; + if bytes.len() > max { Err(too_large()) } else { Ok(bytes) } +} + +/// The file a profile `name` stands for: `name` itself when it is a path, otherwise the file of +/// that name in the system's color folder `dir`. +#[cfg(any(windows, test))] +fn profile_path(dir: Option<&std::path::Path>, name: &str) -> std::path::PathBuf { + match dir { + Some(dir) if !name.contains(['\\', '/']) => dir.join(name), + _ => std::path::PathBuf::from(name), + } +} + +/// `path`'s profile, or why there is none. +#[cfg(any(windows, target_os = "linux"))] +fn profile_file(path: &std::path::Path) -> Found { + let from = path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()); + match read_icc(path) { + Ok(icc) => Found::Profile { icc, from }, + Err(e) => Found::NoProfile(e), + } +} + +#[cfg(windows)] +#[allow(unsafe_code)] +mod win { + use std::path::PathBuf; + use std::sync::OnceLock; + + use vectorcraft_ui_egui::display_profile::Found; + use windows::Win32::Devices::Display::{ + DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_HEADER, + DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_PATH_INFO, DISPLAYCONFIG_SOURCE_DEVICE_NAME, + DisplayConfigGetDeviceInfo, GetDisplayConfigBufferSizes, QDC_ONLY_ACTIVE_PATHS, QueryDisplayConfig, + }; + use windows::Win32::Foundation::{ERROR_INSUFFICIENT_BUFFER, ERROR_SUCCESS, HLOCAL, LUID, LocalFree}; + use windows::Win32::Graphics::Gdi::{CreateDCW, DeleteDC}; + use windows::Win32::System::LibraryLoader::{GetProcAddress, LOAD_LIBRARY_SEARCH_SYSTEM32, LoadLibraryExW}; + use windows::Win32::UI::ColorSystem::{ + CPST_STANDARD_DISPLAY_COLOR_MODE, CPT_ICC, GetColorDirectoryW, GetICMProfileW, WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER, + }; + use windows::core::{HRESULT, PCWSTR, PWSTR, s, w}; + + /// `ColorProfileGetDisplayDefault` and `ColorProfileGetDisplayUserScope` (mscms.dll), which + /// older Windows versions don't have. + type GetDefault = unsafe extern "system" fn(i32, LUID, u32, i32, i32, *mut PWSTR) -> HRESULT; + type GetScope = unsafe extern "system" fn(LUID, u32, *mut i32) -> HRESULT; + + /// The display path that shows a GDI device. + struct Path { + source: u32, + target_adapter: LUID, + target: u32, + } + + pub(super) fn read(display: Option<&str>) -> Found { + let Some(device) = display else { return Found::NoProfile("Windows didn't name the window's display".into()) }; + let path = match find_path(device) { + Ok(path) => path, + Err(e) => return Found::NoProfile(e), + }; + if path.as_ref().is_some_and(advanced_color) { + return Found::System("Windows manages this display's colors (Advanced Color)".into()); + } + let name = match path.as_ref().and_then(default_profile) { + Some(Ok(name)) => name, + Some(Err(e)) => return Found::NoProfile(e), + // Older Windows, or a display without an active path: GDI's profile for the device. + None => match icm_profile(device) { + Ok(name) => name, + Err(e) => return Found::NoProfile(e), + }, + }; + match name { + Some(name) => super::profile_file(&super::profile_path(color_directory().as_deref(), &name)), + None => Found::NoProfile("no color profile is set for this display".into()), + } + } + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain([0]).collect() + } + + fn until_nul(w: &[u16]) -> String { + String::from_utf16_lossy(&w[..w.iter().position(|&c| c == 0).unwrap_or(w.len())]) + } + + /// The active display paths. + fn paths() -> Result, String> { + for _ in 0..4 { + let (mut paths_len, mut modes_len) = (0u32, 0u32); + // SAFETY: both pointers are to live `u32`s. + let e = unsafe { GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut paths_len, &mut modes_len) }; + if e != ERROR_SUCCESS { + return Err(format!("Windows couldn't list the displays (error {})", e.0)); + } + let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); paths_len as usize]; + let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); modes_len as usize]; + // SAFETY: the arrays hold as many elements as the lengths passed with them. + let e = + unsafe { QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, &mut paths_len, paths.as_mut_ptr(), &mut modes_len, modes.as_mut_ptr(), None) }; + if e == ERROR_INSUFFICIENT_BUFFER { + // The displays changed between the two calls. + continue; + } + if e != ERROR_SUCCESS { + return Err(format!("Windows couldn't list the displays (error {})", e.0)); + } + paths.truncate(paths_len as usize); + return Ok(paths); + } + Err("the displays kept changing while Windows listed them".into()) + } + + /// The path whose source is the GDI device `device` (`\\.\DISPLAY1`), if one is active. + fn find_path(device: &str) -> Result, String> { + for p in paths()? { + let mut name = DISPLAYCONFIG_SOURCE_DEVICE_NAME { + header: DISPLAYCONFIG_DEVICE_INFO_HEADER { + r#type: DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, + size: size_of::() as u32, + adapterId: p.sourceInfo.adapterId, + id: p.sourceInfo.id, + }, + ..Default::default() + }; + // SAFETY: the header starts a `DISPLAYCONFIG_SOURCE_DEVICE_NAME` of the size it states. + if unsafe { DisplayConfigGetDeviceInfo(&mut name.header) } == 0 && until_nul(&name.viewGdiDeviceName).eq_ignore_ascii_case(device) { + return Ok(Some(Path { source: p.sourceInfo.id, target_adapter: p.targetInfo.adapterId, target: p.targetInfo.id })); + } + } + Ok(None) + } + + /// Whether Windows color-manages the path's display itself: Advanced Color is on (HDR), or + /// wide color is enforced (Automatic Color Management on an SDR display). + fn advanced_color(path: &Path) -> bool { + let mut info = DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO { + header: DISPLAYCONFIG_DEVICE_INFO_HEADER { + r#type: DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO, + size: size_of::() as u32, + adapterId: path.target_adapter, + id: path.target, + }, + ..Default::default() + }; + // SAFETY: the header starts a `DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO` of the size it states. + if unsafe { DisplayConfigGetDeviceInfo(&mut info.header) } != 0 { + return false; + } + // SAFETY: every bit pattern is a valid `u32`. + let bits = unsafe { info.Anonymous.value }; + // advancedColorEnabled is bit 1 and wideColorEnforced bit 2. + bits & 0b110 != 0 + } + + fn api() -> Option<(GetDefault, GetScope)> { + static API: OnceLock> = OnceLock::new(); + *API.get_or_init(|| { + // SAFETY: mscms.dll is loaded from System32 only, and stays loaded for the process. + let module = unsafe { LoadLibraryExW(w!("mscms.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32) }.ok()?; + // SAFETY: the names are NUL-terminated, and the module is loaded. + let (default, scope) = unsafe { + (GetProcAddress(module, s!("ColorProfileGetDisplayDefault"))?, GetProcAddress(module, s!("ColorProfileGetDisplayUserScope"))?) + }; + // SAFETY: both exports have these signatures (icm.h). + unsafe { + Some(( + std::mem::transmute:: isize, GetDefault>(default), + std::mem::transmute:: isize, GetScope>(scope), + )) + } + }) + } + + /// The display's default profile name for its standard color mode, `None` without the API. + fn default_profile(path: &Path) -> Option, String>> { + let (get_default, get_scope) = api()?; + let mut scope = WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER.0; + // SAFETY: `scope` is a live `i32`. + if unsafe { get_scope(path.target_adapter, path.source, &mut scope) }.is_err() { + scope = WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER.0; + } + let mut name = PWSTR::null(); + // SAFETY: `name` is a live pointer the function fills with a string it allocates. + let hr = unsafe { get_default(scope, path.target_adapter, path.source, CPT_ICC.0, CPST_STANDARD_DISPLAY_COLOR_MODE.0, &mut name) }; + if hr.is_err() { + return Some(Err(format!("Windows couldn't give the display's profile (error {:#010x})", hr.0))); + } + if name.is_null() { + return Some(Ok(None)); + } + // SAFETY: `name` is a NUL-terminated string from the call above, freed once with LocalFree. + let text = unsafe { name.to_string() }; + // SAFETY: as above. + let _ = unsafe { LocalFree(Some(HLOCAL(name.0.cast()))) }; + Some(text.map(|t| Some(t).filter(|t| !t.is_empty())).map_err(|e| format!("the display's profile name isn't readable: {e}"))) + } + + /// GDI's profile file for the display device `device`. + fn icm_profile(device: &str) -> Result, String> { + let device = wide(device); + // SAFETY: both strings are NUL-terminated and outlive the call. + let dc = unsafe { CreateDCW(w!("DISPLAY"), PCWSTR(device.as_ptr()), PCWSTR::null(), None) }; + if dc.is_invalid() { + return Err("Windows couldn't open the display".into()); + } + let mut len = 0u32; + // SAFETY: a null buffer asks for the length only. + let _ = unsafe { GetICMProfileW(dc, &mut len, None) }; + let mut buf = vec![0u16; len.clamp(1, 32 * 1024) as usize]; + len = buf.len() as u32; + // SAFETY: `buf` holds `len` characters. + let ok = unsafe { GetICMProfileW(dc, &mut len, Some(PWSTR(buf.as_mut_ptr()))) }.as_bool(); + // SAFETY: `dc` came from CreateDCW above. + let _ = unsafe { DeleteDC(dc) }; + Ok(ok.then(|| until_nul(&buf)).filter(|n| !n.is_empty())) + } + + /// The system's color folder (`…\spool\drivers\color`). + fn color_directory() -> Option { + let mut bytes = 0u32; + // SAFETY: a null buffer asks for the size only, in bytes. + let _ = unsafe { GetColorDirectoryW(PCWSTR::null(), None, &mut bytes) }; + let mut buf = vec![0u16; (bytes as usize / 2).clamp(1, 32 * 1024)]; + bytes = (buf.len() * 2) as u32; + // SAFETY: `buf` holds `bytes` bytes. + unsafe { GetColorDirectoryW(PCWSTR::null(), Some(PWSTR(buf.as_mut_ptr())), &mut bytes) }.as_bool().then(|| PathBuf::from(until_nul(&buf))) + } + + #[cfg(test)] + mod tests { + use windows::Win32::Graphics::Gdi::{DISPLAY_DEVICE_ATTACHED_TO_DESKTOP, DISPLAY_DEVICEW, EnumDisplayDevicesW}; + + use super::*; + + /// The GDI names (`\\.\DISPLAY1`) of the displays attached to the desktop. + fn attached_displays() -> Vec { + let mut names = Vec::new(); + for i in 0.. { + let mut device = DISPLAY_DEVICEW { cb: size_of::() as u32, ..Default::default() }; + // SAFETY: `device` is a live `DISPLAY_DEVICEW` with `cb` set to its size. + if !unsafe { EnumDisplayDevicesW(PCWSTR::null(), i, &mut device, 0) }.as_bool() { + break; + } + if device.StateFlags.contains(DISPLAY_DEVICE_ATTACHED_TO_DESKTOP) { + names.push(until_nul(&device.DeviceName)); + } + } + names + } + + #[test] + fn windows_reads_each_attached_display_and_refuses_an_unknown_one() { + assert_eq!(read(None), Found::NoProfile("Windows didn't name the window's display".into())); + assert!(color_directory().is_none_or(|dir| dir.is_absolute())); + match find_path(r"\\.\DISPLAY999") { + Ok(path) => { + assert!(path.is_none()); + assert_eq!(read(Some(r"\\.\DISPLAY999")), Found::NoProfile("Windows couldn't open the display".into())); + } + // A session without displays (a service): the reading gives Windows' error. + Err(e) => { + eprintln!("the displays couldn't be listed: {e}"); + assert_eq!(read(Some(r"\\.\DISPLAY999")), Found::NoProfile(e)); + return; + } + } + for name in attached_displays() { + // A display without an active path is read through GDI (`icm_profile`). + let path = find_path(&name).unwrap(); + // winit names the display in GDI's spelling; the match ignores letter case. + let lower = find_path(&name.to_lowercase()).unwrap(); + assert_eq!(lower.map(|p| (p.source, p.target)), path.as_ref().map(|p| (p.source, p.target)), "{name} in lowercase"); + match read(Some(&name)) { + Found::Profile { icc, from } => { + eprintln!("{name}: profile {from}, {} bytes", icc.len()); + assert!(!icc.is_empty() && !from.is_empty()); + assert!(!path.as_ref().is_some_and(advanced_color)); + } + Found::System(e) => { + eprintln!("{name}: system, {e}"); + assert!(path.as_ref().is_some_and(advanced_color)); + } + Found::NoProfile(e) => { + eprintln!("{name}: no profile, {e}"); + assert!(!e.is_empty()); + } + } + } + } + } +} + +#[cfg(target_os = "linux")] +mod x11 { + use std::collections::HashMap; + use std::path::Path; + use std::time::Duration; + + use vectorcraft_ui_egui::display_profile::{Found, MAX_PROFILE_BYTES}; + use x11rb::connection::Connection as _; + use x11rb::protocol::randr; + use x11rb::protocol::xproto::{self, AtomEnum}; + use zbus::blocking::{connection::Builder, proxy::Proxy}; + use zbus::zvariant::OwnedObjectPath; + + const COLORD: &str = "org.freedesktop.ColorManager"; + const COLORD_ROOT: &str = "/org/freedesktop/ColorManager"; + const COLORD_MANAGER: &str = "org.freedesktop.ColorManager"; + const COLORD_DEVICE: &str = "org.freedesktop.ColorManager.Device"; + const COLORD_PROFILE: &str = "org.freedesktop.ColorManager.Profile"; + + pub(super) fn read(output: Option<&str>) -> Found { + let colord = output.map(|o| colord_profile(o).map(|path| path.map(|path| super::usable(super::profile_file(Path::new(&path)))))); + super::x11_choice(colord, || properties(output)) + } + + /// The file colord assigns the RandR output `output`. + fn colord_profile(output: &str) -> Result, String> { + let connection = Builder::system() + .map_err(|e| format!("couldn't connect to the system bus: {e}"))? + .method_timeout(Duration::from_secs(2)) + .build() + .map_err(|e| format!("couldn't connect to the system bus: {e}"))?; + let manager = Proxy::new(&connection, COLORD, COLORD_ROOT, COLORD_MANAGER).map_err(|e| format!("couldn't reach colord: {e}"))?; + let devices: Vec = + manager.call("GetDevicesByKind", &("display",)).map_err(|e| format!("colord couldn't list the displays: {e}"))?; + for path in devices { + let Ok(device) = Proxy::new(&connection, COLORD, &path, COLORD_DEVICE) else { continue }; + let Ok(metadata) = device.get_property::>("Metadata") else { continue }; + if metadata.get("XRANDR_name").map(String::as_str) != Some(output) { + continue; + } + // `*` asks for the device's default profile. + let Ok(profile_path) = device.call::<_, _, OwnedObjectPath>("GetProfileForQualifiers", &(vec!["*"],)) else { return Ok(None) }; + let profile = + Proxy::new(&connection, COLORD, &profile_path, COLORD_PROFILE).map_err(|e| format!("couldn't reach colord's profile: {e}"))?; + return Ok(profile.get_property::("Filename").ok().filter(|f| !f.is_empty())); + } + Ok(None) + } + + /// The `_ICC_PROFILE` property of the RandR output `output`, or of the screen when only one + /// output is active. + fn properties(output: Option<&str>) -> Result, String> { + let (conn, screen) = x11rb::connect(None).map_err(|e| format!("couldn't connect to the X11 display: {e}"))?; + let root = conn.setup().roots.get(screen).ok_or_else(|| "the X11 display has no screen".to_string())?.root; + let atom = xproto::intern_atom(&conn, true, b"_ICC_PROFILE") + .map_err(|e| format!("couldn't ask the X11 display for profiles: {e}"))? + .reply() + .map_err(|e| format!("couldn't ask the X11 display for profiles: {e}"))? + .atom; + if atom == 0 { + return Ok(None); + } + let resources = randr::get_screen_resources_current(&conn, root) + .map_err(|e| format!("couldn't list the X11 outputs: {e}"))? + .reply() + .map_err(|e| format!("couldn't list the X11 outputs: {e}"))?; + let (mut active, mut shown) = (0usize, None); + for o in resources.outputs { + let Ok(Ok(info)) = randr::get_output_info(&conn, o, resources.config_timestamp).map(|c| c.reply()) else { continue }; + if info.connection != randr::Connection::CONNECTED || info.crtc == 0 { + continue; + } + active += 1; + if output.is_some_and(|name| info.name == name.as_bytes()) { + shown = Some(o); + } + } + let longs = (MAX_PROFILE_BYTES / 4) as u32; + let too_large = || Found::NoProfile(format!("the display's profile is larger than {} MiB", MAX_PROFILE_BYTES >> 20)); + if let Some(o) = shown + && let Ok(Ok(p)) = randr::get_output_property(&conn, o, atom, AtomEnum::ANY, 0, longs, false, false).map(|c| c.reply()) + && !p.data.is_empty() + { + if p.bytes_after > 0 { + return Ok(Some(too_large())); + } + return Ok(Some(Found::Profile { icc: p.data, from: "the X11 output's _ICC_PROFILE".into() })); + } + if active != 1 { + return Ok(None); + } + let Ok(Ok(p)) = xproto::get_property(&conn, false, root, atom, AtomEnum::ANY, 0, longs).map(|c| c.reply()) else { return Ok(None) }; + if p.value.is_empty() { + return Ok(None); + } + if p.bytes_after > 0 { + return Ok(Some(too_large())); + } + Ok(Some(Found::Profile { icc: p.value, from: "the X11 screen's _ICC_PROFILE".into() })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_reading_is_due_first_after_ten_seconds_and_when_the_window_changes_display() { + assert!(due(None, 0.0, None)); + let last = (100.0, Some("DP-1".to_string())); + assert!(!due(Some(&last), 100.0, Some("DP-1"))); + assert!(!due(Some(&last), 109.9, Some("DP-1"))); + assert!(due(Some(&last), 110.0, Some("DP-1"))); + assert!(due(Some(&last), 101.0, Some("HDMI-1"))); + assert!(due(Some(&last), 101.0, None)); + // A clock that went back. + assert!(due(Some(&last), 50.0, Some("DP-1"))); + } + + #[test] + fn wayland_sessions_are_told_apart_from_x11_by_the_environment() { + let env = |pairs: &'static [(&'static str, &'static str)]| { + move |k: &str| pairs.iter().find(|(n, _)| *n == k).map(|(_, v)| std::ffi::OsString::from(v)) + }; + assert!(wayland_session(env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]))); + assert!(wayland_session(env(&[("WAYLAND_SOCKET", "5")]))); + assert!(!wayland_session(env(&[("DISPLAY", ":0")]))); + assert!(!wayland_session(env(&[("WAYLAND_DISPLAY", ""), ("DISPLAY", ":0")]))); + } + + #[test] + fn profile_files_larger_than_the_limit_are_refused_before_they_are_read() { + let dir = tempfile::tempdir().unwrap(); + let small = dir.path().join("small.icc"); + std::fs::write(&small, [1u8, 2, 3]).unwrap(); + assert_eq!(read_icc(&small).unwrap(), [1, 2, 3]); + let big = dir.path().join("big.icc"); + let f = std::fs::File::create(&big).unwrap(); + f.set_len(vectorcraft_ui_egui::display_profile::MAX_PROFILE_BYTES as u64 + 1).unwrap(); + assert!(read_icc(&big).unwrap_err().contains("larger than 1 MiB")); + assert!(read_icc(&dir.path().join("missing.icc")).unwrap_err().starts_with("couldn't open")); + } + + #[test] + fn x11_takes_the_output_property_when_colord_s_file_cannot_be_read() { + let icc = |b: u8| Found::Profile { icc: vec![b], from: "test".into() }; + let property = || Ok(Some(icc(2))); + // colord's profile, read: the property is not asked for. + assert_eq!(x11_choice(Some(Ok(Some(icc(1)))), || panic!("not asked")), icc(1)); + // colord names a file that can't be read, or has no profile, or isn't running. + assert_eq!(x11_choice(Some(Ok(Some(Found::NoProfile("couldn't open a.icc".into())))), property), icc(2)); + assert_eq!(x11_choice(Some(Ok(None)), property), icc(2)); + assert_eq!(x11_choice(Some(Err("no colord".into())), property), icc(2)); + assert_eq!(x11_choice(None, property), icc(2)); + // Neither has a profile: colord's reason is kept. + assert_eq!( + x11_choice(Some(Ok(Some(Found::NoProfile("couldn't open a.icc".into())))), || Ok(None)), + Found::NoProfile("no profile is set for this display (couldn't open a.icc)".into()) + ); + assert_eq!(x11_choice(None, || Ok(None)), Found::NoProfile("no profile is set for this display".into())); + assert_eq!(x11_choice(None, || Err("no X11".into())), Found::NoProfile("no X11".into())); + // colord's file holds no usable profile: the property is used. + let broken = usable(Found::Profile { icc: vec![1, 2, 3], from: "a.icc".into() }); + assert_eq!(broken, Found::NoProfile("a.icc: the file is not an ICC profile".into())); + assert_eq!(x11_choice(Some(Ok(Some(broken))), property), icc(2)); + assert_eq!(usable(Found::NoProfile("none".into())), Found::NoProfile("none".into())); + } + + #[test] + fn a_profile_name_is_found_in_the_color_folder_and_a_path_is_kept() { + let dir = std::path::Path::new("C:/Windows/System32/spool/drivers/color"); + assert_eq!(profile_path(Some(dir), "Display.icm"), dir.join("Display.icm")); + assert_eq!(profile_path(Some(dir), "D:\\profiles\\Display.icm"), std::path::PathBuf::from("D:\\profiles\\Display.icm")); + assert_eq!(profile_path(None, "Display.icm"), std::path::PathBuf::from("Display.icm")); + } +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index c2b40e37e6..c10b72da44 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -28,6 +28,7 @@ mod clipboard; mod compositor_gpu; mod control_server; mod cursor_theme; +mod display_profile; #[cfg(feature = "wgpu")] mod gpu; mod hang_watch; @@ -126,6 +127,8 @@ struct App { held_files: Vec, /// The time and UI zoom of the last monitor list ([`window::monitors_due`]). monitors_listed: Option<(f64, f32)>, + /// Reads the profile of the display that shows the main window, for the canvas. + display_profile: display_profile::Reader, /// When this frame's input hook started, for the hang report's frame times (`hang_watch`). frame_start: Option, } @@ -629,6 +632,7 @@ fn main() -> std::process::ExitCode { maximize_tries: 8, held_files, monitors_listed: None, + display_profile: Default::default(), frame_start: None, })) }), diff --git a/crates/color/src/cms.rs b/crates/color/src/cms.rs index 46dcdb52ba..74ac2d50d5 100644 --- a/crates/color/src/cms.rs +++ b/crates/color/src/cms.rs @@ -2,7 +2,8 @@ //! gamut checks and soft-proof transforms. //! //! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Wide Gamut RGB -//! (1998 primaries), Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB. +//! (1998 primaries), Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB; +//! the desktop canvas converts it into the display's own profile last ([`display`]). //! * **CMYK spaces:** [`GENERIC_CMYK`] is our own documented parametric press model //! ([`generic`]); [`DEVICE_CMYK`] is the old profile-free formula (kept for exact legacy numbers); //! any CMYK `.icc` the user loads goes through `moxcms` (LUT-based profiles). @@ -10,6 +11,7 @@ //! the profiles [`use_document_profiles`] last put in place of the working spaces; //! [`crate::Color::to_rgb`] uses them. Code that needs deterministic numbers builds its own [`Cms`] with [`Cms::new`]. +pub mod display; pub mod generic; pub mod icc; pub mod lab; diff --git a/crates/color/src/cms/display.rs b/crates/color/src/cms/display.rs new file mode 100644 index 0000000000..03fe00220c --- /dev/null +++ b/crates/color/src/cms/display.rs @@ -0,0 +1,327 @@ +//! The canvas's last step before the screen: the sRGB the canvas draws, converted into a display's +//! ICC profile (Preferences › Performance › Use Monitor Profile for Canvas). +//! +//! The display profile is a destination only. Document colors, proofs, exports and the web build +//! keep their sRGB output; a soft proof is applied first, then this conversion. +//! +//! [`DisplayTransform::from_icc`] accepts an RGB display-class (`mntr`) profile of at most +//! [`MAX_PROFILE_BYTES`] whose declared length fits the data and from which a transform can be built. +//! The conversion is relative colorimetric, sampled once into a [`LUT_N`]³ table, so drawing a frame +//! costs a table lookup per pixel and never builds a transform. + +use std::hash::{Hash, Hasher}; + +use super::{Intent, ProfileKind, icc::IccProfile}; + +/// The largest display profile read: VectorCraft's own limit. Display profiles are a few kilobytes +/// (matrix/shaper) to a few hundred (table-based). +pub const MAX_PROFILE_BYTES: usize = 1 << 20; + +/// Grid points per channel of the conversion table. +const LUT_N: usize = 33; + +/// Why a display profile can't be used. The canvas then stays sRGB. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ProfileError { + /// More bytes than [`MAX_PROFILE_BYTES`]. + TooLarge(usize), + /// No ICC header (`acsp` signature) or a header cut short. + NotIcc, + /// The header's length is shorter than a header or longer than the data. + BadLength { declared: usize, len: usize }, + /// The profile class isn't a display's (`mntr`): an input, output or other profile. + NotDisplay(String), + /// The profile's data color space isn't RGB. + NotRgb(String), + /// The profile doesn't parse. + Unreadable(String), + /// No conversion from sRGB into the profile can be built. + NoTransform, +} + +impl std::fmt::Display for ProfileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::TooLarge(n) => write!(f, "the profile is {n} bytes, more than the {MAX_PROFILE_BYTES} a display profile may have"), + Self::NotIcc => f.write_str("the file is not an ICC profile"), + Self::BadLength { declared, len } => write!(f, "the profile's header gives a length of {declared} bytes for {len} bytes of data"), + Self::NotDisplay(class) => write!(f, "the profile's class is `{class}`, not a display profile (`mntr`)"), + Self::NotRgb(space) => write!(f, "the profile's color space is `{space}`, not RGB"), + Self::Unreadable(e) => write!(f, "the profile can't be read: {e}"), + Self::NoTransform => f.write_str("no conversion into the profile can be built"), + } + } +} + +/// Four header bytes as text, for an error message. +fn signature(b: &[u8]) -> String { + String::from_utf8_lossy(b).trim_end().to_string() +} + +/// sRGB → a display profile, as a table. +pub struct DisplayTransform { + name: String, + fingerprint: u64, + /// `LUT_N`³ display values, red slowest; `None` when the profile shows sRGB unchanged (every + /// grid point within half an 8-bit step of itself). + table: Option>, +} + +impl std::fmt::Debug for DisplayTransform { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DisplayTransform").field("name", &self.name).field("identity", &self.is_identity()).finish() + } +} + +impl DisplayTransform { + /// Checks `bytes` as a display profile, as [`Self::from_icc`] does, without building the + /// conversion table. + pub fn check(bytes: &[u8]) -> Result<(), ProfileError> { + Self::parse(bytes).map(|_| ()) + } + + /// Checks `bytes` as a display profile and builds the conversion into it. + pub fn from_icc(bytes: &[u8]) -> Result { + let (profile, bytes) = Self::parse(bytes)?; + let to_display = + |rgb: [f32; 3]| profile.from_srgb(rgb, Intent::RelativeColorimetric).and_then(|v| Some([*v.first()?, *v.get(1)?, *v.get(2)?])); + let s = (LUT_N - 1) as f32; + let mut table = Vec::with_capacity(LUT_N.pow(3)); + for r in 0..LUT_N { + for g in 0..LUT_N { + for b in 0..LUT_N { + let rgb = [r as f32 / s, g as f32 / s, b as f32 / s]; + table.push(to_display(rgb).ok_or(ProfileError::NoTransform)?); + } + } + } + let grid = |i: usize| [i / (LUT_N * LUT_N), i / LUT_N % LUT_N, i % LUT_N].map(|v| v as f32 / s); + let identity = table.iter().enumerate().all(|(i, v)| v.iter().zip(grid(i)).all(|(a, b)| (a - b).abs() <= 0.5 / 255.0)); + let mut h = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut h); + Ok(Self { name: profile.name.clone(), fingerprint: h.finish(), table: (!identity).then_some(table) }) + } + + /// The parsed profile and its bytes up to its declared length, when `bytes` is an RGB display + /// profile from which a conversion can be built. + fn parse(bytes: &[u8]) -> Result<(IccProfile, &[u8]), ProfileError> { + if bytes.len() > MAX_PROFILE_BYTES { + return Err(ProfileError::TooLarge(bytes.len())); + } + if bytes.len() < 132 || bytes.get(36..40) != Some(b"acsp") { + return Err(ProfileError::NotIcc); + } + let declared = bytes.get(0..4).map_or(0, |b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize); + if declared < 132 || declared > bytes.len() { + return Err(ProfileError::BadLength { declared, len: bytes.len() }); + } + let bytes = &bytes[..declared]; + if &bytes[12..16] != b"mntr" { + return Err(ProfileError::NotDisplay(signature(&bytes[12..16]))); + } + if &bytes[16..20] != b"RGB " { + return Err(ProfileError::NotRgb(signature(&bytes[16..20]))); + } + let profile = IccProfile::from_bytes(None, bytes).map_err(|e| ProfileError::Unreadable(e.to_string()))?; + if profile.kind != ProfileKind::Rgb { + return Err(ProfileError::NotRgb(format!("{:?}", profile.kind))); + } + let to_display = + |rgb: [f32; 3]| profile.from_srgb(rgb, Intent::RelativeColorimetric).and_then(|v| Some([*v.first()?, *v.get(1)?, *v.get(2)?])); + if to_display([0.5; 3]).is_none() { + return Err(ProfileError::NoTransform); + } + Ok((profile, bytes)) + } + + /// The profile's description. + pub fn name(&self) -> &str { + &self.name + } + + /// A hash of the profile's bytes, which tells two profiles with the same name apart. + pub fn fingerprint(&self) -> u64 { + self.fingerprint + } + + /// The profile shows sRGB unchanged, so nothing is converted. + pub fn is_identity(&self) -> bool { + self.table.is_none() + } + + /// One sRGB color (0..1) in the display profile. + pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] { + let Some(table) = &self.table else { return rgb.map(|v| v.clamp(0.0, 1.0)) }; + let n = LUT_N; + let s = (n - 1) as f32; + let p = rgb.map(|v| if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) * s }); + let i = p.map(|v| (v.floor() as usize).min(n - 2)); + let t = [p[0] - i[0] as f32, p[1] - i[1] as f32, p[2] - i[2] as f32]; + let mut out = [0.0f32; 3]; + for (dr, wr) in [(0, 1.0 - t[0]), (1, t[0])] { + for (dg, wg) in [(0, 1.0 - t[1]), (1, t[1])] { + for (db, wb) in [(0, 1.0 - t[2]), (1, t[2])] { + let w = wr * wg * wb; + if w == 0.0 { + continue; + } + let v = table[((i[0] + dr) * n + i[1] + dg) * n + i[2] + db]; + for c in 0..3 { + out[c] += w * v[c]; + } + } + } + } + out.map(|v| v.clamp(0.0, 1.0)) + } + + /// One 8-bit sRGB color in the display profile. + pub fn apply8(&self, rgb: [u8; 3]) -> [u8; 3] { + if self.table.is_none() { + return rgb; + } + self.apply(rgb.map(|v| f32::from(v) / 255.0)).map(|v| (v * 255.0).round() as u8) + } + + /// Premultiplied RGBA8 pixels, converted in place. Alpha is kept, and fully transparent + /// pixels are left as they are. + pub fn apply_premultiplied(&self, pixels: &mut [u8]) { + if self.table.is_none() { + return; + } + let mut last_in = [0u8; 4]; + let mut last_out = [0u8; 4]; + for px in pixels.as_chunks_mut::<4>().0 { + let a = px[3]; + if a == 0 { + continue; + } + if *px == last_in { + *px = last_out; + continue; + } + last_in = *px; + let un = |c: u8| if a == 255 { c } else { ((u32::from(c) * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8 }; + let out = self.apply8([un(px[0]), un(px[1]), un(px[2])]); + for (c, o) in px.iter_mut().zip(out) { + *c = if a == 255 { o } else { ((u32::from(o) * u32::from(a) + 127) / 255) as u8 }; + } + last_out = *px; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cms::{DISPLAY_P3, GENERIC_CMYK, SRGB, icc_bytes}; + + /// `name`'s ICC file, marked as a display profile. + fn display(name: &str) -> Vec { + let mut b = icc_bytes(name).unwrap().to_vec(); + b[12..16].copy_from_slice(b"mntr"); + b + } + + #[test] + fn a_wide_gamut_display_shows_srgb_red_less_saturated_and_srgb_is_left_alone() { + let p3 = DisplayTransform::from_icc(&display(DISPLAY_P3)).unwrap(); + assert!(!p3.is_identity()); + assert_eq!(p3.name(), DISPLAY_P3); + // sRGB red is inside Display P3: its display values have green and blue above 0 and less red. + let [r, g, b] = p3.apply8([255, 0, 0]); + assert!(r < 245 && g > 40 && b > 10, "{:?}", [r, g, b]); + // White and black stay where they are (relative colorimetric). + assert_eq!(p3.apply8([255; 3]), [255; 3]); + assert_eq!(p3.apply8([0; 3]), [0; 3]); + let srgb = DisplayTransform::from_icc(&display(SRGB)).unwrap(); + assert!(srgb.is_identity()); + assert_eq!(srgb.apply8([255, 0, 0]), [255, 0, 0]); + assert_ne!(srgb.fingerprint(), p3.fingerprint()); + } + + #[test] + fn the_table_matches_the_profile_between_its_grid_points() { + let bytes = display(DISPLAY_P3); + let p3 = DisplayTransform::from_icc(&bytes).unwrap(); + let profile = IccProfile::from_bytes(None, &bytes).unwrap(); + for rgb in [[0.1, 0.7, 0.33], [0.9, 0.05, 0.6], [0.47, 0.47, 0.47], [0.0, 0.2, 1.0]] { + let want = profile.from_srgb(rgb, Intent::RelativeColorimetric).unwrap(); + let got = p3.apply(rgb); + for c in 0..3 { + assert!((got[c] - want[c]).abs() < 1.0 / 255.0, "{rgb:?}: {got:?} vs {want:?}"); + } + } + } + + #[test] + fn premultiplied_pixels_keep_their_alpha_and_transparent_ones_stay_empty() { + let p3 = DisplayTransform::from_icc(&display(DISPLAY_P3)).unwrap(); + let mut px = vec![255, 0, 0, 255, 128, 0, 0, 128, 0, 0, 0, 0, 255, 0, 0, 255]; + p3.apply_premultiplied(&mut px); + let opaque = p3.apply8([255, 0, 0]); + assert_eq!(&px[0..4], &[opaque[0], opaque[1], opaque[2], 255]); + assert_eq!(&px[12..16], &px[0..4], "a repeated pixel converts the same"); + assert_eq!(px[7], 128); + // Half-covered red: the same color at half coverage, within rounding. + for c in 0..3 { + assert!((i32::from(px[4 + c]) - i32::from(opaque[c]) / 2).abs() <= 1, "{:?}", &px[4..8]); + } + assert_eq!(&px[8..12], &[0, 0, 0, 0]); + } + + #[test] + fn profiles_that_are_not_rgb_display_profiles_are_refused() { + let cmyk = icc_bytes(GENERIC_CMYK).unwrap().to_vec(); + assert!(matches!(DisplayTransform::from_icc(&cmyk), Err(ProfileError::NotDisplay(_)))); + let mut cmyk_display = cmyk.clone(); + cmyk_display[12..16].copy_from_slice(b"mntr"); + assert!(matches!(DisplayTransform::from_icc(&cmyk_display), Err(ProfileError::NotRgb(s)) if s == "CMYK")); + let mut input = display(DISPLAY_P3); + input[12..16].copy_from_slice(b"scnr"); + assert_eq!(DisplayTransform::from_icc(&input).unwrap_err(), ProfileError::NotDisplay("scnr".into())); + } + + #[test] + fn malformed_short_and_oversized_data_is_refused() { + assert_eq!(DisplayTransform::from_icc(&[]).unwrap_err(), ProfileError::NotIcc); + assert_eq!(DisplayTransform::from_icc(&[0; 132]).unwrap_err(), ProfileError::NotIcc); + let good = display(DISPLAY_P3); + assert_eq!(DisplayTransform::from_icc(&good[..100]).unwrap_err(), ProfileError::NotIcc); + let mut long = good.clone(); + long[0..4].copy_from_slice(&(good.len() as u32 + 1).to_be_bytes()); + assert_eq!(DisplayTransform::from_icc(&long).unwrap_err(), ProfileError::BadLength { declared: good.len() + 1, len: good.len() }); + let mut short = good.clone(); + short[0..4].copy_from_slice(&64u32.to_be_bytes()); + assert!(matches!(DisplayTransform::from_icc(&short), Err(ProfileError::BadLength { declared: 64, .. }))); + // Trailing bytes past the declared length are not part of the profile. + let mut padded = good.clone(); + padded.extend([0xAB; 16]); + let a = DisplayTransform::from_icc(&padded).unwrap(); + assert_eq!(a.fingerprint(), DisplayTransform::from_icc(&good).unwrap().fingerprint()); + let mut garbage_tags = good.clone(); + for b in &mut garbage_tags[128..] { + *b = 0xFF; + } + assert!(matches!(DisplayTransform::from_icc(&garbage_tags), Err(ProfileError::Unreadable(_) | ProfileError::NoTransform))); + let huge = vec![0u8; MAX_PROFILE_BYTES + 1]; + assert_eq!(DisplayTransform::from_icc(&huge).unwrap_err(), ProfileError::TooLarge(MAX_PROFILE_BYTES + 1)); + } + + #[test] + fn check_accepts_and_refuses_what_from_icc_does() { + assert_eq!(DisplayTransform::check(&display(DISPLAY_P3)), Ok(())); + assert_eq!(DisplayTransform::check(&display(SRGB)), Ok(())); + let cmyk = icc_bytes(GENERIC_CMYK).unwrap().to_vec(); + assert_eq!(DisplayTransform::check(&cmyk), Err(DisplayTransform::from_icc(&cmyk).unwrap_err())); + assert_eq!(DisplayTransform::check(&[1, 2, 3]), Err(ProfileError::NotIcc)); + } + + #[test] + fn nan_and_out_of_range_values_stay_in_range() { + let p3 = DisplayTransform::from_icc(&display(DISPLAY_P3)).unwrap(); + for v in p3.apply([f32::NAN, -1.0, 9.0]) { + assert!((0.0..=1.0).contains(&v)); + } + } +} diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index a73485f4fa..7920df223e 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -368,6 +368,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("realTimeDrawing", "Performance", "", "Real-time Drawing and Editing", bool), p!("renderThreads", "Performance", "", "Render Threads (-1 = Automatic)", int(-1, 64)), p!("linuxDisplayServer", "Performance", "", "Linux Display Server", choice(LINUX_DISPLAY_SERVERS)), + p!("monitorProfileDisplay", "Performance", "", "Use Monitor Profile for Canvas", bool), // File Handling p!("backgroundSave", "File Handling", "", "Enable Background Save", bool), p!("backgroundExport", "File Handling", "", "Enable Background Export", bool), diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 05ef5e84f5..2433923309 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -220,6 +220,10 @@ pub struct Prefs { /// Wayland session, where dropped files arrive (#1151); `x11WithPen` opens it through XWayland /// only when a pen is attached (#2028). Read when the app starts. pub linux_display_server: String, + /// The desktop canvas converts its sRGB into the ICC profile of the display that shows the + /// window, where the system doesn't (Windows without Advanced Color, X11). Display only: it + /// changes no document, proof or export. + pub monitor_profile_display: bool, // File Handling pub background_save: bool, pub background_export: bool, @@ -496,6 +500,7 @@ impl Default for Prefs { real_time_drawing: true, render_threads: -1, linux_display_server: s("automatic"), + monitor_profile_display: true, background_save: true, background_export: true, autosave_recovery: true, diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 14669fd357..e4d00cda5d 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -464,6 +464,34 @@ fn linux_display_server_defaults_to_automatic_and_validates() { assert_eq!(s.prefs.linux_display_server, "automatic"); } +/// Performance › Use Monitor Profile for Canvas: on by default and for preference files written +/// before it, a boolean only, saved, listed under Performance, and set without changing the +/// document. +#[test] +fn monitor_profile_display_is_on_by_default_and_changes_no_document() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + assert!(s.prefs.monitor_profile_display); + let old: Prefs = serde_json::from_value(json!({"gpuPerformance": true})).unwrap(); + assert!(old.monitor_profile_display); + let (revision, doc) = (s.active().unwrap().revision, s.active().unwrap().doc.clone()); + s.execute("prefs.set", &json!({"key": "monitorProfileDisplay", "value": false})).unwrap(); + assert!(!s.prefs.monitor_profile_display); + assert_eq!(s.prefs.to_json()["monitorProfileDisplay"], false); + for bad in [json!("off"), json!(0), json!(null)] { + assert!(s.execute("prefs.set", &json!({"key": "monitorProfileDisplay", "value": bad})).is_err(), "{bad}"); + assert!(!s.prefs.monitor_profile_display); + } + assert_eq!(s.active().unwrap().revision, revision); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &doc), "the document is not edited"); + let l = s.execute("prefs.list", &json!({})).unwrap(); + let row = l.as_array().unwrap().iter().find(|e| e["key"] == "monitorProfileDisplay").unwrap().clone(); + assert_eq!((row["category"].as_str(), row["label"].as_str()), (Some("Performance"), Some("Use Monitor Profile for Canvas"))); + s.execute("prefs.reset", &json!({"category": "Performance"})).unwrap(); + assert!(s.prefs.monitor_profile_display); +} + /// Linux Display Server's pen choice (#2028): set by value or by label, saved, and off by default. #[test] fn linux_display_server_has_a_choice_for_an_attached_pen() { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 67e67ac444..5b4b924f0f 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -98,6 +98,7 @@ impl VectorcraftApp { panes: Default::default(), windows: crate::doc_windows::Windows::new(known), }, + display_profile: Default::default(), perf: Perf::default(), dock_rt: Default::default(), titlebar_inset: 0.0, diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index 7f0d73511d..286b4d4d02 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -161,6 +161,9 @@ pub struct CacheKey { /// The Image Trace objects drawn with their source image /// ([`vectorcraft_render::RenderOptions::trace_source`]). pub trace_source: Option>>, + /// The display profile the frame is converted into ([`crate::display_profile::DisplayProfile::key`]), + /// 0 for none. + pub display: u64, } #[derive(Clone, Copy, Debug, Default)] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d75eae537f..96e08db309 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -619,6 +619,8 @@ fn cache_key( dragging: app.session.in_interaction() && ui.data(|d| d.get_temp::(drag_id())) == Some(Drag::Tool), // The Image Trace panel's eye button, while it is held. trace_source: app.ui.trace_hold_source.clone(), + // Preferences › Performance › Use Monitor Profile for Canvas. + display: app.display_profile.key(app.session.prefs.monitor_profile_display), } } @@ -870,6 +872,7 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { // A new view can copy from the worker's last frame. The frame shown asked for again renders // whole. The worker keeps either one for the next pan to copy from. let may_copy = app.canvas.key.as_ref() != Some(key); + let display = app.display_profile.transform(app.session.prefs.monitor_profile_display); let mut landed = None; match (&mut app.canvas.worker, heavy, changed) { // Only the document changed since the frame shown, in a part of it: that part is @@ -882,7 +885,9 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { } if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) { let renderer = &mut app.canvas.renderer; - let part = effects.view(|| renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts)); + let mut part = + effects.view(|| renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts)); + crate::display_profile::convert(display.as_deref(), &mut part.pixels); let color = color_image(&part); let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR }; tex.set_partial([x0 as usize, y0 as usize], color, options); @@ -907,6 +912,7 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { effects: effects.clone(), may_copy, may_keep: true, + display, }); // A light document's first frame is ready within the grace and shows in this frame, // with no frame of bare paper. Waited for once, when the frame is queued. @@ -917,8 +923,9 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { _ => { let t0 = now_ms(); let renderer = &mut app.canvas.renderer; - let img = effects.view(|| renderer.render(doc, w, h, view, &opts)); + let mut img = effects.view(|| renderer.render(doc, w, h, view, &opts)); effects.first_view_done(); + crate::display_profile::convert(display.as_deref(), &mut img.pixels); upload(app, ui.ctx(), img, pixel.is_some()); app.canvas.key = Some(key.clone()); app.canvas.shown = Some((doc.clone(), opts.clone())); @@ -967,13 +974,18 @@ fn paint_paper( // on, the canvas multiplies it with the paper color: each sRGB channel is artboard × paper / 255, // rounded. The transparency grid, while it shows, covers it as it covers the others. painter.rect_filled(rect, 0.0, t.pasteboard); + // The paper and the transparency grid are document colors: they go through the display + // profile as the art does. The pasteboard and the shadows are the interface's. + let display = app.display_profile.transform(app.session.prefs.monitor_profile_display); + let on_display = |c: Color32| crate::display_profile::color32(display.as_deref(), c); if !shows_artboards(app) { // View › Hide Artboards: no edges and no pasteboard, the whole canvas paper (#4038). let setup = &doc.setup; - painter.rect_filled(rect, 0.0, if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE }); + painter.rect_filled(rect, 0.0, on_display(if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE })); } else { let setup = &doc.setup; - let grid = (st.transparency_grid && setup.background == vectorcraft_doc::Background::Transparent).then(|| checker_texture(ui.ctx(), setup)); + let grid = (st.transparency_grid && setup.background == vectorcraft_doc::Background::Transparent) + .then(|| checker_texture(ui.ctx(), setup, display.as_deref())); let paper = if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE }; for ab in &doc.artboards { let q = xf.quad(ab.rect); @@ -985,7 +997,7 @@ fn paint_paper( Some((tex, cell)) => checker(painter, &q, ab.rect, xf.zoom, tex.id(), *cell), None => { let fill = ab.color.map_or(paper, |c| crate::panels::c32(&c) * paper); - painter.add(Shape::convex_polygon(q, fill, Stroke::NONE)); + painter.add(Shape::convex_polygon(q, on_display(fill), Stroke::NONE)); } } } diff --git a/crates/ui-egui/src/canvas/fill.rs b/crates/ui-egui/src/canvas/fill.rs index 8eda1db219..9e9c099cec 100644 --- a/crates/ui-egui/src/canvas/fill.rs +++ b/crates/ui-egui/src/canvas/fill.rs @@ -96,6 +96,7 @@ pub(super) fn fill_uncovered( app.canvas.fill = Some(Fill { key: done.key, view, size: (fw, fh), tex }); } let effects = app.session.documents().iter().find(|d| d.uid as usize == key.doc).map(|d| d.plugin_effects.clone()).unwrap_or_default(); + let display = app.display_profile.transform(app.session.prefs.monitor_profile_display); let Some(worker) = app.canvas.fill_worker.as_mut().filter(|_| !gaps.is_empty()) else { return }; if !app.canvas.fill.as_ref().is_some_and(|f| covers(&f.key)) && !worker.pending.as_ref().is_some_and(covers) { let (w, h, view) = fill_view(key); @@ -110,6 +111,7 @@ pub(super) fn fill_uncovered( effects, may_copy: false, may_keep: false, + display, }); } let Some(f) = app.canvas.fill.as_ref().filter(|f| usable(&f.key)) else { return }; diff --git a/crates/ui-egui/src/canvas/grid.rs b/crates/ui-egui/src/canvas/grid.rs index 2c99c618db..84adb0d861 100644 --- a/crates/ui-egui/src/canvas/grid.rs +++ b/crates/ui-egui/src/canvas/grid.rs @@ -3,11 +3,18 @@ use super::{ Color32, Point, PointerEvent, PointerKind, Pos2, RULER, Rect, Sense, Shape, Stroke, Tokens, Ui, Unit, VectorcraftApp, Xf, json, pos2, vec2, }; -/// The transparency grid's 2 × 2-cell tile in the document's colours (Document Setup), cached per -/// look, and its cell size in screen points. -pub(crate) fn checker_texture(ctx: &egui::Context, setup: &vectorcraft_doc::DocSetup) -> (egui::TextureHandle, f32) { - let colors = setup.grid_colors.map(|c| crate::panels::c32(&c)); - let id = egui::Id::unique("transparency-grid-tile"); +/// The transparency grid's 2 × 2-cell tile in the document's colours (Document Setup), converted +/// into the display profile `display` when there is one, cached per look, and its cell size in +/// screen points. +pub(crate) fn checker_texture( + ctx: &egui::Context, + setup: &vectorcraft_doc::DocSetup, + display: Option<&vectorcraft_color::cms::display::DisplayTransform>, +) -> (egui::TextureHandle, f32) { + let colors = setup.grid_colors.map(|c| crate::display_profile::color32(display, crate::panels::c32(&c))); + // A tile of its own with a display profile, so a preview drawn in sRGB beside the canvas + // doesn't make the canvas's tile again each frame. + let id = egui::Id::unique("transparency-grid-tile").with(display.is_some()); let cached = ctx.data(|d| d.get_temp::<([Color32; 2], egui::TextureHandle)>(id)).filter(|(c, _)| *c == colors); let tex = cached.map(|(_, t)| t).unwrap_or_else(|| { let [a, b] = colors; diff --git a/crates/ui-egui/src/canvas/panes.rs b/crates/ui-egui/src/canvas/panes.rs index fc440be673..76fb599fda 100644 --- a/crates/ui-egui/src/canvas/panes.rs +++ b/crates/ui-egui/src/canvas/panes.rs @@ -70,9 +70,17 @@ impl Panes { } /// Bring `slot`'s render up to `key` (`size` pixels through `view`), on the worker when there - /// is one, then draw the slot's latest render through `xf`, turned and scaled into the view it - /// was rendered for. - fn draw(&mut self, ctx: &egui::Context, painter: &egui::Painter, xf: &Xf, slot: Slot, frame: PaneFrame) { + /// is one, converted into the display profile `display`, then draw the slot's latest render + /// through `xf`, turned and scaled into the view it was rendered for. + fn draw( + &mut self, + ctx: &egui::Context, + painter: &egui::Painter, + xf: &Xf, + slot: Slot, + frame: PaneFrame, + display: Option>, + ) { let (key, doc, ((w, h), view), opts) = frame; if !self.worker_started { self.worker_started = true; @@ -101,8 +109,16 @@ impl Panes { if !current { let mut queued = false; if let Some(worker) = &self.worker { - let (job_doc, job_opts) = (doc.clone(), opts.clone()); - queued = worker.submit(slot, (slot, key.clone()), Box::new(move |r| Some(r.render(&job_doc, w, h, view, &job_opts)))); + let (job_doc, job_opts, job_display) = (doc.clone(), opts.clone(), display.clone()); + queued = worker.submit( + slot, + (slot, key.clone()), + Box::new(move |r| { + let mut img = r.render(&job_doc, w, h, view, &job_opts); + crate::display_profile::convert(job_display.as_deref(), &mut img.pixels); + Some(img) + }), + ); if queued { self.pending.insert(slot, key.clone()); } else { @@ -110,7 +126,8 @@ impl Panes { } } if !queued { - let img = self.renderer.get_or_insert_with(vectorcraft_render::Renderer::new).render(&doc, w, h, view, &opts); + let mut img = self.renderer.get_or_insert_with(vectorcraft_render::Renderer::new).render(&doc, w, h, view, &opts); + crate::display_profile::convert(display.as_deref(), &mut img.pixels); self.upload(ctx, slot, key, &img); } } @@ -314,7 +331,8 @@ pub(crate) fn pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect) let opts = frame_options(app, &key, st.shown_mask(), smooth_images); let (fw, fh, view) = frame_view(pixel, (pw, ph), &drawn, ppp); let doc = st.doc.clone(); - app.canvas.panes.draw(ui.ctx(), &painter, &xf, slot, (key, doc, ((fw, fh), view), opts)); + let display = app.display_profile.transform(app.session.prefs.monitor_profile_display); + app.canvas.panes.draw(ui.ctx(), &painter, &xf, slot, (key, doc, ((fw, fh), view), opts), display); let Some(st) = app.session.documents().get(w.0) else { return false }; if let Some(look) = grid_look.filter(|_| !grids_in_back) { grid(&painter, &xf, st.doc.grid.spacing, st.doc.grid.subdivisions, look); diff --git a/crates/ui-egui/src/canvas/raster.rs b/crates/ui-egui/src/canvas/raster.rs index 2722bc79fe..f57a15a795 100644 --- a/crates/ui-egui/src/canvas/raster.rs +++ b/crates/ui-egui/src/canvas/raster.rs @@ -216,7 +216,7 @@ pub(crate) fn render_job(r: &mut vectorcraft_render::Renderer, kept: &mut Option composed_frame(r, moved_pixels(k.pixels.as_ref()?, job.w, job.h, pan), parts, job) }); let reused = copied.is_some(); - let (img, moved) = copied.unwrap_or_else(|| { + let (mut img, moved) = copied.unwrap_or_else(|| { let img = r.render(&job.doc, job.w, job.h, job.view, &job.opts); let moved = r.stats.moved; (img, moved) @@ -225,6 +225,8 @@ pub(crate) fn render_job(r: &mut vectorcraft_render::Renderer, kept: &mut Option job.may_keep && moved == 0 && r.stats.view_sized == 0 && img.pixels.len() <= MAX_KEPT_BYTES && (img.width, img.height) == (job.w, job.h); *kept = keeps.then(|| KeptFrame { key: job.key.clone(), opts: job.opts.clone(), view: job.view, pixels: holds_pixels.then(|| img.pixels.clone()) }); + // The kept frame stays sRGB, so the next frame's copied pixels are converted only once. + crate::display_profile::convert(job.display.as_deref(), &mut img.pixels); WorkerFrame { img, moved, reused } } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 8597607717..802b42319c 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2387,6 +2387,53 @@ fn pixel_preview_shows_the_document_pixels() { assert!(w > 600 && !hard, "zoomed out: the screen's pixels"); } +/// Preferences › Performance › Use Monitor Profile for Canvas: with a display profile read, the +/// art is drawn converted into it, and drawn again when the profile or the preference changes. +#[test] +fn the_canvas_is_drawn_through_the_display_profile_while_the_preference_is_on() { + use crate::display_profile::{Found, Reading}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap(); + app.session.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + app.canvas.worker_started = true; // render on this thread + assert!(app.session.prefs.monitor_profile_display, "on by default"); + let ctx = egui::Context::default(); + // How many pixels of the art's raster one frame uploads are `c`, None when it was not re-rendered. + let count = |app: &mut VectorcraftApp, c: egui::Color32| -> Option { + let (_, mut delta) = frame_output(app, &ctx); + let art = app.canvas.texture.as_ref().unwrap().id(); + let n = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| { + let egui::ImageData::Color(img) = &d.image; + img.pixels.iter().filter(|p| **p == c).count() + }); + delta.clear(); + n + }; + let red = egui::Color32::RED; + let srgb = count(&mut app, red).expect("the first frame"); + assert!(srgb > 1000, "the rectangle in sRGB: {srgb}"); + let p3 = crate::display_profile::tests::p3(); + let t = vectorcraft_color::cms::display::DisplayTransform::from_icc(&p3).unwrap(); + let [r, g, b] = t.apply8([255, 0, 0]); + let shown = egui::Color32::from_rgb(r, g, b); + assert_ne!(shown, red, "sRGB red is less saturated on a wide-gamut display"); + let reading = |found| Reading { display: Some("DP-1".into()), found }; + assert!(app.display_profile.report(reading(Found::Profile { icc: p3.clone(), from: "test".into() }))); + assert_eq!(count(&mut app, shown), Some(srgb), "drawn again, converted"); + // The same profile again: nothing is drawn again. + assert!(!app.display_profile.report(reading(Found::Profile { icc: p3, from: "test".into() }))); + assert_eq!(count(&mut app, shown), None); + // Off: drawn again in sRGB. + app.session.prefs.monitor_profile_display = false; + assert_eq!(count(&mut app, red), Some(srgb)); + // On again, then the system takes over: sRGB. + app.session.prefs.monitor_profile_display = true; + assert_eq!(count(&mut app, shown), Some(srgb)); + app.display_profile.report(reading(Found::System("test".into()))); + assert_eq!(count(&mut app, red), Some(srgb)); +} + /// View › Hide Template (#2000): a template layer's art leaves the canvas, and Show Template /// draws it again, dimmed as before; each re-renders at once. #[test] @@ -11512,6 +11559,7 @@ fn pan_job(doc: &std::sync::Arc, key: &CacheKey, (dx, effects: Default::default(), may_copy: true, may_keep: true, + display: None, } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 89ab7e37b4..9c8a438b02 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -270,6 +270,9 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { "freeTransformWidget": crate::free_transform::rect(ctx).map(|r| json!([r.left(), r.top(), r.width(), r.height()])), "view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()), "canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])), + // The display profile the canvas is drawn through, or why none (Preferences › + // Performance › Use Monitor Profile for Canvas). + "displayProfile": app.display_profile.status(app.session.prefs.monitor_profile_display), // Window › Arrange › Tile (not saved, so not in `ui`) and the panes the last frame drew: // each one's document index, view number (Window › New Window) and rect. Canvas // coordinates map to the current window's pane, inside its rulers and scroll bars diff --git a/crates/ui-egui/src/dialogs/save_for_web.rs b/crates/ui-egui/src/dialogs/save_for_web.rs index 64dc2fae8c..d5ed966f94 100644 --- a/crates/ui-egui/src/dialogs/save_for_web.rs +++ b/crates/ui-egui/src/dialogs/save_for_web.rs @@ -536,7 +536,7 @@ fn preview_area(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, set let (original, shots) = previews(app, ui.ctx(), s, &panes); let zoom = d.str("__zoom").parse::().ok().filter(|z| z.is_finite() && *z > 0.0).map(|z| z / 100.0); let kbps = d.f64("__kbps", 56.6); - let Some(checker) = app.session.active().map(|st| crate::canvas::checker_texture(ui.ctx(), &st.doc.setup)) else { return false }; + let Some(checker) = app.session.active().map(|st| crate::canvas::checker_texture(ui.ctx(), &st.doc.setup, None)) else { return false }; let cells: Vec<(egui::Rect, Pane)> = match view { 0 => vec![(area, Pane::Original)], 1 => vec![(area, Pane::Optimized(0))], diff --git a/crates/ui-egui/src/display_profile.rs b/crates/ui-egui/src/display_profile.rs new file mode 100644 index 0000000000..486489e6fe --- /dev/null +++ b/crates/ui-egui/src/display_profile.rs @@ -0,0 +1,311 @@ +//! Preferences › Performance › Use Monitor Profile for Canvas: the canvas's art, its artboards' +//! paper and the transparency grid are converted from sRGB into the ICC profile of the display +//! that shows most of the main window, as the last step before they are drawn. +//! +//! The desktop app reads the display's profile on a background thread and hands the reading to +//! [`DisplayProfile::report`], which checks the profile and builds its conversion table there. +//! Where the system converts the window to the display itself (macOS, Windows with Advanced +//! Color, a Wayland session), or the display has no usable profile, nothing is converted and the +//! canvas stays sRGB. Exports, `ui.render`, proofs and the document are not affected: a proof is +//! drawn first and then converted like any other frame. + +use std::sync::{Arc, RwLock}; + +use serde_json::{Value, json}; +use vectorcraft_color::cms::display::DisplayTransform; +pub use vectorcraft_color::cms::display::MAX_PROFILE_BYTES; + +/// What the desktop app read for the display showing the main window. +#[derive(Clone, Debug, PartialEq)] +pub struct Reading { + /// The system's name for the display (Windows: `\\.\DISPLAY1`; X11: the output's name). + pub display: Option, + pub found: Found, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Found { + /// The display's ICC profile, and where it was read from. + Profile { icc: Vec, from: String }, + /// The system converts the window to the display: VectorCraft converts nothing. The reason. + System(String), + /// The display has no profile VectorCraft can read: the canvas stays sRGB. The reason. + NoProfile(String), +} + +/// What the canvas does with the reading. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + /// Nothing has been read: the web build, tests, or a platform without a reader. + Unsupported, + /// The first reading has not finished. + Pending, + /// The profile is converted into. + Applied, + /// The profile shows sRGB unchanged. + Identity, + /// The system converts the window. + System, + /// No profile, or one that can't be used. + NoProfile, +} + +impl Kind { + fn id(self) -> &'static str { + match self { + Self::Unsupported => "unsupported", + Self::Pending => "pending", + Self::Applied => "profile", + Self::Identity => "srgbProfile", + Self::System => "system", + Self::NoProfile => "none", + } + } +} + +#[derive(Debug)] +struct State { + /// Changes whenever the conversion changes, so frames rendered for the old one are rendered again. + generation: u64, + kind: Kind, + display: Option, + /// The profile's description and where it was read from. + profile: Option<(String, String)>, + reason: Option, + transform: Option>, + /// The bytes last reported, so a reading of the same profile builds nothing. + reported: Option<(Option, Vec)>, +} + +impl Default for State { + fn default() -> Self { + Self { + generation: 0, + kind: Kind::Unsupported, + display: None, + profile: None, + reason: Some("this build doesn't read display profiles".into()), + transform: None, + reported: None, + } + } +} + +/// The display profile of the main window, shared with the thread that reads it. +#[derive(Clone, Debug, Default)] +pub struct DisplayProfile(Arc>); + +impl DisplayProfile { + fn read(&self) -> std::sync::RwLockReadGuard<'_, State> { + self.0.read().unwrap_or_else(|e| e.into_inner()) + } + + fn write(&self) -> std::sync::RwLockWriteGuard<'_, State> { + self.0.write().unwrap_or_else(|e| e.into_inner()) + } + + /// The desktop app has started reading the display's profile. + pub fn pending(&self) { + let mut s = self.write(); + if s.kind == Kind::Unsupported { + s.kind = Kind::Pending; + s.reason = Some("the display profile hasn't been read yet".into()); + } + } + + /// Takes `reading` in. A profile is checked, and its conversion built, on the calling thread; + /// a profile that can't be used leaves the canvas sRGB, with the reason in [`Self::status`]. + /// The same profile reported again for the same display changes nothing. Returns whether the + /// canvas's conversion changed. + pub fn report(&self, reading: Reading) -> bool { + if let Found::Profile { icc, .. } = &reading.found + && self.read().reported.as_ref().is_some_and(|(d, b)| *d == reading.display && b == icc) + { + return false; + } + let (kind, profile, reason, transform, reported) = match reading.found { + Found::Profile { icc, from } => { + let reported = Some((reading.display.clone(), icc.clone())); + match DisplayTransform::from_icc(&icc) { + Ok(t) if t.is_identity() => (Kind::Identity, Some((t.name().to_string(), from)), None, None, reported), + Ok(t) => (Kind::Applied, Some((t.name().to_string(), from)), None, Some(Arc::new(t)), reported), + Err(e) => (Kind::NoProfile, None, Some(format!("{from}: {e}")), None, reported), + } + } + Found::System(r) => (Kind::System, None, Some(r), None, None), + Found::NoProfile(r) => (Kind::NoProfile, None, Some(r), None, None), + }; + let mut s = self.write(); + let changed = s.kind != kind || s.display != reading.display || s.profile != profile || s.reason != reason; + let converts_differently = match (&s.transform, &transform) { + (None, None) => false, + (Some(a), Some(b)) => a.fingerprint() != b.fingerprint(), + _ => true, + }; + if converts_differently { + s.generation += 1; + } + (s.kind, s.display, s.profile, s.reason, s.transform, s.reported) = (kind, reading.display, profile, reason, transform, reported); + changed || converts_differently + } + + /// The conversion the canvas draws through: `None` when `enabled` (the preference) is off, or + /// when nothing is to be converted. + pub fn transform(&self, enabled: bool) -> Option> { + if !enabled { + return None; + } + self.read().transform.clone() + } + + /// A number that changes whenever [`Self::transform`] does, 0 while it is `None`: the canvas's + /// frames depend on it ([`crate::CacheKey::display`]). + pub fn key(&self, enabled: bool) -> u64 { + let s = self.read(); + if enabled && s.transform.is_some() { s.generation } else { 0 } + } + + /// The status `ui.inspect` reports as `displayProfile`: the display, what was found + /// (`profile`, `srgbProfile`, `system`, `none`, `pending` or `unsupported`), the profile's + /// description and where it was read from, whether the canvas is converted, why not, and + /// whether the system converts the window. + pub fn status(&self, enabled: bool) -> Value { + let s = self.read(); + json!({ + "enabled": enabled, + "display": s.display, + "source": s.kind.id(), + "profile": s.profile.as_ref().map(|p| &p.0), + "from": s.profile.as_ref().map(|p| &p.1), + "applied": enabled && s.transform.is_some(), + "reason": s.reason, + "osManaged": s.kind == Kind::System, + }) + } + + /// The line the Preferences dialog shows under the checkbox, translated. + pub(crate) fn note(&self, enabled: bool) -> String { + let s = self.read(); + match (enabled, s.kind, &s.profile) { + (false, ..) => tl!("The canvas is drawn in sRGB.").to_string(), + (true, Kind::Applied | Kind::Identity, Some((name, _))) => format!("{} {name}", tl!("Canvas display profile:")), + (true, Kind::System, _) => tl!("The system matches the window to the display's colors.").to_string(), + _ => tl!("No usable display profile was found: the canvas is drawn in sRGB.").to_string(), + } + } +} + +/// Why `icc` can't be used as a display profile, or `Ok` when [`DisplayProfile::report`] would +/// convert through it (or find that it shows sRGB unchanged). Builds no conversion table. +pub fn check_profile(icc: &[u8]) -> Result<(), String> { + DisplayTransform::check(icc).map_err(|e| e.to_string()) +} + +/// `pixels` (premultiplied RGBA8) through `t`, using every core for a large frame. +pub(crate) fn convert(t: Option<&DisplayTransform>, pixels: &mut [u8]) { + let Some(t) = t else { return }; + #[cfg(not(target_arch = "wasm32"))] + { + const PIECE: usize = 256 * 1024 * 4; + let threads = std::thread::available_parallelism().map_or(1, usize::from).min(16); + if threads > 1 && pixels.len() > PIECE { + let per = pixels.len().div_ceil(threads).div_ceil(4) * 4; + std::thread::scope(|scope| { + for piece in pixels.chunks_mut(per) { + scope.spawn(move || t.apply_premultiplied(piece)); + } + }); + return; + } + } + t.apply_premultiplied(pixels); +} + +/// The opaque or translucent color `c` through `t`. +pub(crate) fn color32(t: Option<&DisplayTransform>, c: egui::Color32) -> egui::Color32 { + let Some(t) = t else { return c }; + let mut px = c.to_array(); + t.apply_premultiplied(&mut px); + egui::Color32::from_rgba_premultiplied(px[0], px[1], px[2], px[3]) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + /// Display P3's ICC file as a display profile. + pub(crate) fn p3() -> Vec { + let mut b = vectorcraft_color::cms::icc_bytes(vectorcraft_color::cms::DISPLAY_P3).unwrap().to_vec(); + b[12..16].copy_from_slice(b"mntr"); + b + } + + fn reading(found: Found) -> Reading { + Reading { display: Some("DP-1".into()), found } + } + + #[test] + fn a_display_profile_is_converted_into_while_the_preference_is_on() { + let d = DisplayProfile::default(); + assert_eq!(d.status(true)["source"], "unsupported"); + assert!(d.transform(true).is_none() && d.key(true) == 0); + d.pending(); + assert_eq!(d.status(true)["source"], "pending"); + assert!(d.report(reading(Found::Profile { icc: p3(), from: "colord".into() }))); + let st = d.status(true); + assert_eq!((st["source"].as_str(), st["applied"].as_bool(), st["profile"].as_str()), (Some("profile"), Some(true), Some("Display P3"))); + assert_eq!((st["display"].as_str(), st["from"].as_str(), st["osManaged"].as_bool()), (Some("DP-1"), Some("colord"), Some(false))); + assert!(d.transform(true).is_some()); + let key = d.key(true); + assert_ne!(key, 0); + // Off: nothing is converted, and frames don't depend on the profile. + assert!(d.transform(false).is_none()); + assert_eq!((d.key(false), d.status(false)["applied"].as_bool()), (0, Some(false))); + // The same profile again changes nothing. + assert!(!d.report(reading(Found::Profile { icc: p3(), from: "colord".into() }))); + assert_eq!(d.key(true), key); + // The system takes over: nothing is converted. + assert!(d.report(reading(Found::System("Windows manages this display's colors".into())))); + let st = d.status(true); + assert_eq!((st["source"].as_str(), st["applied"].as_bool(), st["osManaged"].as_bool()), (Some("system"), Some(false), Some(true))); + assert_eq!(d.key(true), 0); + // The profile back: a new key, so frames drawn before are drawn again. + d.report(reading(Found::Profile { icc: p3(), from: "colord".into() })); + assert!(d.key(true) > key); + } + + #[test] + fn a_profile_that_can_t_be_used_leaves_the_canvas_srgb_and_says_why() { + let d = DisplayProfile::default(); + let cmyk = vectorcraft_color::cms::icc_bytes(vectorcraft_color::cms::GENERIC_CMYK).unwrap().to_vec(); + assert!(d.report(reading(Found::Profile { icc: cmyk, from: "printer.icc".into() }))); + let st = d.status(true); + assert_eq!((st["source"].as_str(), st["applied"].as_bool()), (Some("none"), Some(false))); + assert!(st["reason"].as_str().is_some_and(|r| r.starts_with("printer.icc: ") && r.contains("not a display profile")), "{st}"); + assert!(d.transform(true).is_none()); + d.report(reading(Found::Profile { icc: vec![1, 2, 3], from: "broken.icc".into() })); + assert!(d.status(true)["reason"].as_str().is_some_and(|r| r.contains("not an ICC profile"))); + // An sRGB display profile converts nothing. + let mut srgb = vectorcraft_color::cms::icc_bytes(vectorcraft_color::cms::SRGB).unwrap().to_vec(); + srgb[12..16].copy_from_slice(b"mntr"); + d.report(reading(Found::Profile { icc: srgb, from: "sRGB.icc".into() })); + assert_eq!((d.status(true)["source"].as_str(), d.status(true)["applied"].as_bool()), (Some("srgbProfile"), Some(false))); + assert!(d.transform(true).is_none()); + } + + #[test] + fn large_frames_convert_the_same_on_several_threads() { + let t = DisplayTransform::from_icc(&p3()).unwrap(); + let mut big: Vec = (0..(600 * 1024 * 4)).map(|i| (i * 7 % 251) as u8).collect(); + for px in big.as_chunks_mut::<4>().0 { + px[3] = 255; + } + let mut one = big.clone(); + t.apply_premultiplied(&mut one); + convert(Some(&t), &mut big); + assert_eq!(big, one); + assert_eq!(color32(None, egui::Color32::RED), egui::Color32::RED); + let [r, g, b] = t.apply8([255, 0, 0]); + assert_eq!(color32(Some(&t), egui::Color32::RED), egui::Color32::from_rgb(r, g, b)); + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1fb96d9c7d..6aa3de6752 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2621,6 +2621,11 @@ digits Malayalam Малаялам Rating: Оценка: Real World: Реален свят: Real-time Drawing and Editing Чертане и редактиране в реално време + Use Monitor Profile for Canvas Използване на профила на монитора за платното + The canvas is drawn in sRGB. Платното се изобразява в sRGB. + Canvas display profile: Профил на дисплея за платното: + The system matches the window to the display's colors. Системата съгласува цветовете на прозореца с дисплея. + No usable display profile was found: the canvas is drawn in sRGB. Не е намерен използваем профил на дисплея: платното се изобразява в sRGB. Rearrange All Artboards Пренареждане на всички работни области Rearrange Пренареждане Select All Artboards Избиране на всички работни области diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index f04e9535e5..126081f29d 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -3824,6 +3824,11 @@ theme Appearance Izgled Primary Primarno Printing / Exporting Štampanje / izvoz Real-time Drawing and Editing Crtanje i uređivanje u stvarnom vremenu + Use Monitor Profile for Canvas Koristi profil monitora za platno + The canvas is drawn in sRGB. Platno se iscrtava u sRGB-u. + Canvas display profile: Profil ekrana za platno: + The system matches the window to the display's colors. Sistem prilagođava boje prozora ekranu. + No usable display profile was found: the canvas is drawn in sRGB. Nije pronađen upotrebljiv profil ekrana: platno se iscrtava u sRGB-u. Render Threads (-1 = Automatic) Niti iscrtavanja (-1 = automatski) Reset Preferences Vrati postavke na početne Export Preferences… Izvezi postavke… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index e6b6f398f2..f4c8ad9b2c 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3395,6 +3395,11 @@ digits Malayalam Malaiàlam Real name Nom real Real World: Món real: Real-time Drawing and Editing Dibuix i edició en temps real + Use Monitor Profile for Canvas Utilitza el perfil del monitor per al llenç + The canvas is drawn in sRGB. El llenç es dibuixa en sRGB. + Canvas display profile: Perfil de pantalla del llenç: + The system matches the window to the display's colors. El sistema ajusta la finestra als colors de la pantalla. + No usable display profile was found: the canvas is drawn in sRGB. No s’ha trobat cap perfil de pantalla utilitzable: el llenç es dibuixa en sRGB. Rearrange Reorganitza Select All Artboards Selecciona tots els taulers Switch Orientation Canvia l’orientació diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index a44e3b9979..0670f6b2af 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3880,6 +3880,11 @@ theme Appearance Motiv Primary Primární Printing / Exporting Tisk / export Real-time Drawing and Editing Kreslení a úpravy v reálném čase + Use Monitor Profile for Canvas Použít profil monitoru pro plátno + The canvas is drawn in sRGB. Plátno se vykresluje v sRGB. + Canvas display profile: Profil displeje pro plátno: + The system matches the window to the display's colors. Systém přizpůsobuje okno barvám displeje. + No usable display profile was found: the canvas is drawn in sRGB. Nebyl nalezen použitelný profil displeje: plátno se vykresluje v sRGB. Render Threads (-1 = Automatic) Vlákna vykreslování (-1 = automaticky) Reset Preferences Obnovit předvolby Export Preferences… Exportovat předvolby… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index bce3bd12da..865113ba2f 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3599,6 +3599,11 @@ theme Appearance Udseende Primary Primær Printing / Exporting Udskrivning / eksport Real-time Drawing and Editing Tegning og redigering i realtid + Use Monitor Profile for Canvas Brug skærmprofil til canvas + The canvas is drawn in sRGB. Canvasset tegnes i sRGB. + Canvas display profile: Canvassets skærmprofil: + The system matches the window to the display's colors. Systemet tilpasser vinduet til skærmens farver. + No usable display profile was found: the canvas is drawn in sRGB. Der blev ikke fundet nogen brugbar skærmprofil: canvasset tegnes i sRGB. Render Threads (-1 = Automatic) Renderingstråde (-1 = automatisk) Reset Preferences Nulstil indstillinger Export Preferences… Eksportér præferencer… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0d995ddc2e..661d318afe 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2665,6 +2665,11 @@ digits Malayalam Malayalam Rating: Bewertung: Real World: Reale Welt: Real-time Drawing and Editing Zeichnen und Bearbeiten in Echtzeit + Use Monitor Profile for Canvas Monitorprofil für Arbeitsfläche verwenden + The canvas is drawn in sRGB. Die Arbeitsfläche wird in sRGB dargestellt. + Canvas display profile: Bildschirmprofil der Arbeitsfläche: + The system matches the window to the display's colors. Das System passt das Fenster an die Farben des Bildschirms an. + No usable display profile was found: the canvas is drawn in sRGB. Kein verwendbares Bildschirmprofil gefunden: Die Arbeitsfläche wird in sRGB dargestellt. Rearrange All Artboards Alle Zeichenflächen neu anordnen Rearrange Neu anordnen Select All Artboards Alle Zeichenflächen auswählen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index cb409e9274..0c81df1fec 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3429,6 +3429,11 @@ digits Malayalam Μαλαγιάλαμ Rating: Αξιολόγηση: Real World: Πραγματικός κόσμος: Real-time Drawing and Editing Σχεδίαση και επεξεργασία σε πραγματικό χρόνο + Use Monitor Profile for Canvas Χρήση προφίλ οθόνης για τον καμβά + The canvas is drawn in sRGB. Ο καμβάς σχεδιάζεται σε sRGB. + Canvas display profile: Προφίλ οθόνης καμβά: + The system matches the window to the display's colors. Το σύστημα προσαρμόζει το παράθυρο στα χρώματα της οθόνης. + No usable display profile was found: the canvas is drawn in sRGB. Δεν βρέθηκε κατάλληλο προφίλ οθόνης: ο καμβάς σχεδιάζεται σε sRGB. Rearrange All Artboards Αναδιάταξη όλων των πινάκων σχεδίασης Rearrange Αναδιάταξη Select All Artboards Επιλογή όλων των πινάκων σχεδίασης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index de9512cddb..17ab676451 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2699,6 +2699,11 @@ digits Malayalam Malayálam Rating: Clasificación: Real World: Mundo real: Real-time Drawing and Editing Dibujo y edición en tiempo real + Use Monitor Profile for Canvas Usar perfil del monitor para el lienzo + The canvas is drawn in sRGB. El lienzo se dibuja en sRGB. + Canvas display profile: Perfil de pantalla del lienzo: + The system matches the window to the display's colors. El sistema ajusta la ventana a los colores de la pantalla. + No usable display profile was found: the canvas is drawn in sRGB. No se encontró ningún perfil de pantalla utilizable: el lienzo se dibuja en sRGB. Rearrange All Artboards Reorganizar todas las mesas de trabajo Rearrange Reorganizar Select All Artboards Seleccionar todas las mesas de trabajo diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1c3327fb1e..63ddc7cf4e 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3804,6 +3804,11 @@ theme Appearance Ulkoasu Primary Ensisijainen Printing / Exporting Tulostus / vienti Real-time Drawing and Editing Reaaliaikainen piirto ja muokkaus + Use Monitor Profile for Canvas Käytä näytön profiilia kankaalle + The canvas is drawn in sRGB. Kangas piirretään sRGB-tilassa. + Canvas display profile: Kankaan näyttöprofiili: + The system matches the window to the display's colors. Järjestelmä sovittaa ikkunan näytön väreihin. + No usable display profile was found: the canvas is drawn in sRGB. Käyttökelpoista näyttöprofiilia ei löytynyt: kangas piirretään sRGB-tilassa. Render Threads (-1 = Automatic) Renderöintisäikeet (-1 = automaattinen) Reset Preferences Palauta asetukset Export Preferences… Vie asetukset… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3b45c4d386..9d0c9ece99 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2691,6 +2691,11 @@ digits Malayalam Malayalam Rating: Classement : Real World: Monde réel : Real-time Drawing and Editing Dessin et édition en temps réel + Use Monitor Profile for Canvas Utiliser le profil du moniteur pour la zone de travail + The canvas is drawn in sRGB. La zone de travail est affichée en sRGB. + Canvas display profile: Profil d’affichage de la zone de travail : + The system matches the window to the display's colors. Le système adapte la fenêtre aux couleurs de l’écran. + No usable display profile was found: the canvas is drawn in sRGB. Aucun profil d’affichage utilisable n’a été trouvé : la zone de travail est affichée en sRGB. Rearrange All Artboards Réorganiser tous les plans de travail Rearrange Réorganiser Select All Artboards Sélectionner tous les plans de travail diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 0c6089e40a..946d9729c8 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3713,6 +3713,11 @@ antiAlias Sharp शार्प Real name वास्तविक नाम Real World: रियल वर्ल्ड: Real-time Drawing and Editing रियल टाइम ड्राइंग और संपादन + Use Monitor Profile for Canvas कैनवास के लिए मॉनिटर प्रोफ़ाइल का उपयोग करें + The canvas is drawn in sRGB. कैनवास sRGB में बनाया जाता है। + Canvas display profile: कैनवास की डिस्प्ले प्रोफ़ाइल: + The system matches the window to the display's colors. सिस्टम विंडो को डिस्प्ले के रंगों से मिलाता है। + No usable display profile was found: the canvas is drawn in sRGB. कोई उपयोगी डिस्प्ले प्रोफ़ाइल नहीं मिली: कैनवास sRGB में बनाया जाता है। Rearrange फिर से व्यवस्थित करें Select All Artboards सभी आर्टबोर्ड सिलेक्ट करें Switch Orientation ओरिएंटेशन स्विच करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 06122f94ee..7563432878 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3255,6 +3255,11 @@ mark-style Japanese Japán Real name Valódi név Real World: Valós méret: Real-time Drawing and Editing Valós idejű rajzolás és szerkesztés + Use Monitor Profile for Canvas Monitorprofil használata a vászonhoz + The canvas is drawn in sRGB. A vászon sRGB-ben jelenik meg. + Canvas display profile: A vászon kijelzőprofilja: + The system matches the window to the display's colors. A rendszer a kijelző színeihez igazítja az ablakot. + No usable display profile was found: the canvas is drawn in sRGB. Nem található használható kijelzőprofil: a vászon sRGB-ben jelenik meg. Rearrange Átrendezés Select All Artboards Összes rajztábla kijelölése Switch Orientation Tájolás váltása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 00d2b09600..13346dc437 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3599,6 +3599,11 @@ theme Appearance Tampilan Primary Utama Printing / Exporting Pencetakan / Ekspor Real-time Drawing and Editing Penggambaran dan pengeditan waktu nyata + Use Monitor Profile for Canvas Gunakan profil monitor untuk kanvas + The canvas is drawn in sRGB. Kanvas digambar dalam sRGB. + Canvas display profile: Profil layar kanvas: + The system matches the window to the display's colors. Sistem menyesuaikan jendela dengan warna layar. + No usable display profile was found: the canvas is drawn in sRGB. Tidak ditemukan profil layar yang dapat digunakan: kanvas digambar dalam sRGB. Render Threads (-1 = Automatic) Utas render (-1 = Otomatis) Reset Preferences Atur ulang preferensi Export Preferences… Ekspor preferensi… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 869fe2e4a0..107679a970 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2741,6 +2741,11 @@ digits Malayalam Malayalam Rating: Valutazione: Real World: Mondo reale: Real-time Drawing and Editing Disegno e modifica in tempo reale + Use Monitor Profile for Canvas Usa il profilo del monitor per l'area di disegno + The canvas is drawn in sRGB. L'area di disegno è visualizzata in sRGB. + Canvas display profile: Profilo del display per l'area di disegno: + The system matches the window to the display's colors. Il sistema adatta la finestra ai colori del display. + No usable display profile was found: the canvas is drawn in sRGB. Nessun profilo del display utilizzabile trovato: l'area di disegno è visualizzata in sRGB. Rearrange All Artboards Ridisponi tutte le tavole da disegno Rearrange Ridisponi Select All Artboards Seleziona tutte le tavole da disegno diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a0ce9c93b9..ff0d3c4bae 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2789,6 +2789,11 @@ mark-style Japanese 日本式 Rating: 評価: Real World: 実寸: Real-time Drawing and Editing リアルタイム描画および編集 + Use Monitor Profile for Canvas キャンバスにモニタープロファイルを使用 + The canvas is drawn in sRGB. キャンバスは sRGB で描画されます。 + Canvas display profile: キャンバスのディスプレイプロファイル: + The system matches the window to the display's colors. システムがウィンドウをディスプレイのカラーに合わせます。 + No usable display profile was found: the canvas is drawn in sRGB. 使用できるディスプレイプロファイルが見つかりません。キャンバスは sRGB で描画されます。 Rearrange All Artboards すべてのアートボードを再配置 Rearrange 再配置 Select All Artboards すべてのアートボードを選択 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index b19ed4009a..bf6d6ba36c 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3443,6 +3443,11 @@ theme Appearance იერსახე Primary ძირითადი Printing / Exporting ბეჭდვა / ექსპორტირება Real-time Drawing and Editing რეალურ დროში ხატვა და რედაქტირება + Use Monitor Profile for Canvas მონიტორის პროფილის გამოყენება ტილოსთვის + The canvas is drawn in sRGB. ტილო sRGB-ში იხატება. + Canvas display profile: ტილოს ეკრანის პროფილი: + The system matches the window to the display's colors. სისტემა ფანჯარას ეკრანის ფერებს უსადაგებს. + No usable display profile was found: the canvas is drawn in sRGB. გამოსაყენებელი ეკრანის პროფილი ვერ მოიძებნა: ტილო sRGB-ში იხატება. Render Threads (-1 = Automatic) ნაკადები (-1 = ავტომატური) Reset Preferences პარამეტრების ჩამოყრა Export Preferences… პარამეტრების ექსპორტირება… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3f518f00c4..95f70cfe62 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2779,6 +2779,11 @@ mark-style Japanese 일본식 Rating: 평점: Real World: 실제 크기: Real-time Drawing and Editing 실시간 그리기 및 편집 + Use Monitor Profile for Canvas 캔버스에 모니터 프로필 사용 + The canvas is drawn in sRGB. 캔버스는 sRGB로 그려집니다. + Canvas display profile: 캔버스 디스플레이 프로필: + The system matches the window to the display's colors. 시스템이 창을 디스플레이 색상에 맞춥니다. + No usable display profile was found: the canvas is drawn in sRGB. 사용할 수 있는 디스플레이 프로필이 없습니다. 캔버스는 sRGB로 그려집니다. Rearrange All Artboards 모든 아트보드 재정렬 Rearrange 재정렬 Select All Artboards 모든 아트보드 선택 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 40facfda42..929a950f4c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4114,6 +4114,11 @@ theme Appearance Vormgeving Primary Primair Printing / Exporting Afdrukken / exporteren Real-time Drawing and Editing Tekenen en bewerken in realtime + Use Monitor Profile for Canvas Monitorprofiel gebruiken voor canvas + The canvas is drawn in sRGB. Het canvas wordt in sRGB getekend. + Canvas display profile: Weergaveprofiel van het canvas: + The system matches the window to the display's colors. Het systeem stemt het venster af op de kleuren van het scherm. + No usable display profile was found: the canvas is drawn in sRGB. Geen bruikbaar weergaveprofiel gevonden: het canvas wordt in sRGB getekend. Render Threads (-1 = Automatic) Renderthreads (-1 = automatisch) Reset Preferences Voorkeuren herstellen Export Preferences… Voorkeuren exporteren… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 770622cc1e..162dc2c65e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3743,6 +3743,11 @@ theme Appearance Wygląd Primary Główny Printing / Exporting Drukowanie / eksportowanie Real-time Drawing and Editing Rysowanie i edycja w czasie rzeczywistym + Use Monitor Profile for Canvas Użyj profilu monitora dla płótna + The canvas is drawn in sRGB. Płótno jest rysowane w sRGB. + Canvas display profile: Profil wyświetlacza płótna: + The system matches the window to the display's colors. System dopasowuje okno do kolorów wyświetlacza. + No usable display profile was found: the canvas is drawn in sRGB. Nie znaleziono użytecznego profilu wyświetlacza: płótno jest rysowane w sRGB. Render Threads (-1 = Automatic) Wątki renderowania (-1 = automatycznie) Reset Preferences Resetuj preferencje Export Preferences… Eksportuj preferencje… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 77fce38c53..d419046e02 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2256,6 +2256,11 @@ digits Malayalam Malaiala Rating: Avaliação: Real World: Mundo Real: Real-time Drawing and Editing Desenho e Edição em Tempo Real + Use Monitor Profile for Canvas Usar Perfil do Monitor para a Tela + The canvas is drawn in sRGB. A tela é desenhada em sRGB. + Canvas display profile: Perfil do monitor para a tela: + The system matches the window to the display's colors. O sistema ajusta a janela às cores do monitor. + No usable display profile was found: the canvas is drawn in sRGB. Nenhum perfil de monitor utilizável foi encontrado: a tela é desenhada em sRGB. Rearrange All Artboards Reorganizar Todas as Mesas de Trabalho Rearrange Reorganizar Select All Artboards Selecionar Todas as Mesas de Trabalho diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b546e87258..53fc1703f5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2728,6 +2728,11 @@ digits Malayalam Малаялам Rating: Рейтинг: Real World: Реальный мир: Real-time Drawing and Editing Рисование и редактирование в реальном времени + Use Monitor Profile for Canvas Использовать профиль монитора для холста + The canvas is drawn in sRGB. Холст отображается в sRGB. + Canvas display profile: Профиль дисплея для холста: + The system matches the window to the display's colors. Система согласует цвета окна с дисплеем. + No usable display profile was found: the canvas is drawn in sRGB. Подходящий профиль дисплея не найден: холст отображается в sRGB. Rearrange All Artboards Переставить все монтажные области Rearrange Переставить Select All Artboards Выделить все монтажные области diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 9271ac4bac..4aa386976e 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2636,6 +2636,11 @@ digits Malayalam Malayalam Rating: Betyg: Real World: Verkligt mått: Real-time Drawing and Editing Ritning och redigering i realtid + Use Monitor Profile for Canvas Använd skärmprofil för arbetsytan + The canvas is drawn in sRGB. Arbetsytan ritas i sRGB. + Canvas display profile: Arbetsytans skärmprofil: + The system matches the window to the display's colors. Systemet anpassar fönstret till skärmens färger. + No usable display profile was found: the canvas is drawn in sRGB. Ingen användbar skärmprofil hittades: arbetsytan ritas i sRGB. Rearrange All Artboards Ordna om alla ritytor Rearrange Ordna om Select All Artboards Markera alla ritytor diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 605ff0382b..16c8213204 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -208,6 +208,22 @@ fn marquee_selection_labels_are_in_complete_catalogs() { } } +/// Preferences › Performance › Use Monitor Profile for Canvas and the note under it. +#[test] +fn monitor_profile_labels_are_in_complete_catalogs() { + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + for label in [ + "Use Monitor Profile for Canvas", + "The canvas is drawn in sRGB.", + "Canvas display profile:", + "The system matches the window to the display's colors.", + "No usable display profile was found: the canvas is drawn in sRGB.", + ] { + assert!(lang.catalog().plain(label).is_some(), "{} is missing {label}", lang.code); + } + } +} + /// The Control bar panel menu's items, its groups of options and the edges, and the label of the /// command that turns a group on or off. #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 107f30f3ea..6482f0cb65 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2507,6 +2507,11 @@ digits Malayalam Malayalam Rating: Değerlendirme: Real World: Gerçek Dünya: Real-time Drawing and Editing Gerçek Zamanlı Çizim ve Düzenleme + Use Monitor Profile for Canvas Tuval için Monitör Profilini Kullan + The canvas is drawn in sRGB. Tuval sRGB olarak çizilir. + Canvas display profile: Tuvalin ekran profili: + The system matches the window to the display's colors. Sistem pencereyi ekranın renklerine uyarlar. + No usable display profile was found: the canvas is drawn in sRGB. Kullanılabilir ekran profili bulunamadı: tuval sRGB olarak çizilir. Rearrange All Artboards Tüm Çalışma Yüzeylerini Yeniden Düzenleme Rearrange Yeniden Düzenle Select All Artboards Tüm Çalışma Yüzeylerini Seç diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 68aabf0253..d0873a63fe 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2733,6 +2733,11 @@ digits Malayalam Малаялам Rating: Рейтинг: Real World: Реальний світ: Real-time Drawing and Editing Малювання та редагування у реальному часі + Use Monitor Profile for Canvas Використовувати профіль монітора для полотна + The canvas is drawn in sRGB. Полотно відображається в sRGB. + Canvas display profile: Профіль дисплея для полотна: + The system matches the window to the display's colors. Система узгоджує кольори вікна з дисплеєм. + No usable display profile was found: the canvas is drawn in sRGB. Придатний профіль дисплея не знайдено: полотно відображається в sRGB. Rearrange All Artboards Переставити всі монтажні області Rearrange Переставити Select All Artboards Виділити всі монтажні області diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 36793e6c56..71896fee27 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3043,6 +3043,11 @@ mark-style Japanese Kiểu Nhật Real name Tên thật Real World: Thực tế: Real-time Drawing and Editing Vẽ và chỉnh sửa theo thời gian thực + Use Monitor Profile for Canvas Dùng hồ sơ màn hình cho vùng vẽ + The canvas is drawn in sRGB. Vùng vẽ được hiển thị ở sRGB. + Canvas display profile: Hồ sơ màn hình của vùng vẽ: + The system matches the window to the display's colors. Hệ thống điều chỉnh cửa sổ theo màu của màn hình. + No usable display profile was found: the canvas is drawn in sRGB. Không tìm thấy hồ sơ màn hình dùng được: vùng vẽ được hiển thị ở sRGB. Rearrange Sắp xếp lại Select All Artboards Chọn tất cả các bảng vẽ Switch Orientation Chuyển hướng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 27cad80b4a..02e23831ff 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2597,6 +2597,11 @@ digits Malayalam 马拉雅拉姆数字 Rating: 等级: Real World: 真实世界: Real-time Drawing and Editing 实时绘图和编辑 + Use Monitor Profile for Canvas 画布使用显示器配置文件 + The canvas is drawn in sRGB. 画布以 sRGB 绘制。 + Canvas display profile: 画布显示配置文件: + The system matches the window to the display's colors. 系统会使窗口匹配显示器的颜色。 + No usable display profile was found: the canvas is drawn in sRGB. 未找到可用的显示器配置文件:画布以 sRGB 绘制。 Rearrange All Artboards 重新排列所有画板 Rearrange 重新排列 Select All Artboards 选择所有画板 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ab1fd359cd..42caecb668 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3796,6 +3796,11 @@ digits Malayalam 馬來亞拉姆數字 Rating: 評等: Real World: 真實世界: Real-time Drawing and Editing 即時繪圖與編輯 + Use Monitor Profile for Canvas 畫布使用顯示器描述檔 + The canvas is drawn in sRGB. 畫布以 sRGB 繪製。 + Canvas display profile: 畫布顯示描述檔: + The system matches the window to the display's colors. 系統會讓視窗符合顯示器的色彩。 + No usable display profile was found: the canvas is drawn in sRGB. 找不到可用的顯示器描述檔:畫布以 sRGB 繪製。 Rearrange All Artboards 重新排列所有工作區域 Rearrange 重新排列 Select All Artboards 選取所有工作區域 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c4c6ded189..2bc505ee2d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -34,6 +34,7 @@ pub mod credits; pub mod cursors; mod custom_toolbars; pub mod dialogs; +pub mod display_profile; pub mod doc_windows; pub mod dock; mod edit_toolbar; @@ -144,6 +145,9 @@ pub struct VectorcraftApp { clipboard_probe: Option, pub services: Services, pub canvas: CanvasCache, + /// The main window's display profile, which the canvas is drawn through + /// ([`display_profile`]); the desktop app reports what it reads into it. + pub display_profile: display_profile::DisplayProfile, pub perf: Perf, /// Docking state that lives for the session only. pub(crate) dock_rt: panel_docking::Runtime, diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index a6d1828ac1..fbea47770b 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -66,6 +66,7 @@ fn settings() -> impl Iterator { "systemTitleBar" => SYSTEM_TITLE_BAR, "appTitleBar" => APP_TITLE_BAR_SHOWN, "linuxDisplayServer" => DISPLAY_SERVER_CHOICE, + "monitorProfileDisplay" => !cfg!(target_arch = "wasm32"), _ => true, }; let prefs = @@ -557,6 +558,15 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, ui.label(egui::RichText::new(note).color(t.text_dim).size(11.0)); } } + // The desktop app only: the web build has no display profile to read. A note says + // which profile the canvas is drawn through, or why none. + PrefKind::Bool if sp.key == "monitorProfileDisplay" => { + if !cfg!(target_arch = "wasm32") { + bool_row(ui, d, sp.key, sp.label); + let note = app.display_profile.note(d.bool(sp.key)); + ui.label(egui::RichText::new(note).color(t.text_dim).size(11.0)); + } + } PrefKind::Bool => bool_row(ui, d, sp.key, sp.label), PrefKind::Num { min, max, unit } => { labeled(ui, sp.label, |ui, label_id| { diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index 40478e36ed..50d5bc087c 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -33,6 +33,9 @@ pub struct Job { /// The worker may keep the frame for its next frame to copy from /// ([`crate::canvas::render_job`]). Off: the worker holds no frame after this one. pub may_keep: bool, + /// The display profile the finished frame is converted into ([`crate::display_profile`]). + /// The frame the worker keeps stays sRGB. + pub display: Option>, } pub struct Done { diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 38adb78090..37eae066c5 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -28,7 +28,7 @@ It reads the pen's tilt and barrel rotation on macOS and on Linux under X11 and | Trackpad (smooth scroll, pinch) | yes | yes | precision touchpads | yes | yes | done | | HiDPI / Retina, per-monitor scale | yes | yes | yes | yes | yes | done; a window dragged between monitors of different scale can still grow on Windows (#1459, open); the custom title bar's window on Windows stays truly borderless so the pointer lines up with what is drawn (#1791, #1758; awaiting the reporter's confirmation) ; macOS cursors use aligned 1×/2× native image representations (#2292), covered by headless construction tests; mixed-display transitions and physical cursor sizing remain unverified, no additional hardware validated | | Multiple monitors: panels and document windows on another screen | yes | no (one OS window) | no | no | — | missing: 6–10 h (with multiple windows, gap G7) | -| Display colour profile (proof and display through the monitor's ICC) | yes | no | no | no | — | missing: 3–5 h | +| Display colour profile (proof and display through the monitor's ICC) | yes | yes: the window is tagged sRGB and macOS converts it | yes: the display's default ICC profile, Windows' own conversion with Advanced Color on | X11: colord or `_ICC_PROFILE`; Wayland: sRGB | — | done on macOS, Windows and X11; Wayland and floating document windows remain | | 3D GPU rendering (Materials, ray tracing) | yes | — | — | — | — | blocked on 3D (gap G3) | ## Revision history diff --git a/docs/mcp.md b/docs/mcp.md index 41e28969f4..c9a5ed70b6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5550,6 +5550,21 @@ desktop app): in View › Pixel Preview, on softens placed and embedded bitmaps; `disableAutoAddDelete` (off by default): on, the Pen no longer adds or deletes an anchor when a click lands on a segment or an anchor of a selected path. +`monitorProfileDisplay` (Preferences › Performance › Use Monitor Profile for Canvas, on by default; desktop app) +converts the canvas from sRGB into the ICC profile of the display that shows most of the main window, as the last +step before it is drawn: the art, the artboards' paper and the transparency grid. On Windows the profile is the +display's default profile for its standard color mode; on X11 it is colord's profile for the RandR output, then the +output's `_ICC_PROFILE` property when colord has no usable profile, then the screen's when only one output is active. A display with Windows Advanced +Color on (HDR or Automatic Color Management) and macOS windows are converted by the system, and nothing is converted +again. In a Wayland session, and where no profile is set or the profile is not an RGB display profile of at most 1 MiB, +the canvas stays sRGB. The app reads the display again every 10 seconds and when the window moves to another display. +Exports, `ui.render`, the document, panels and thumbnails are not converted; with Proof Colors on, the proof is drawn +first and then converted. The art is converted before it is drawn over the converted paper, so the blend of a +translucent object or an antialiased edge with the paper can differ slightly from a conversion of the blended color. +`ui.inspect` returns `displayProfile`: `{enabled, display, source, profile, from, applied, reason, +osManaged}`, where `source` is `profile`, `srgbProfile` (a profile that shows sRGB unchanged), `system`, `none`, +`pending` or `unsupported`, `applied` says whether the canvas is converted, and `reason` says why not. + `ctrlWheelScrollsSideways` (on by default): on, Cmd/Ctrl with a mouse wheel's whole notches scrolls the canvas sideways instead of zooming while `zoomWithMouseWheel` is off; off, Cmd/Ctrl-wheel zooms about the pointer. Alt + Cmd/Ctrl-wheel and a pinch on a Mac trackpad, on a touch screen or in the web build still zoom about the pointer. Under either diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d9783b0216..9c6e7946c8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -704,6 +704,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. - **Document Setup › Color:** a Color tab shows the document color mode (RGB Color or CMYK Color), a Convert to CMYK or Convert to RGB box for a mode change (cleared: the colors keep their numbers), and the RGB and CMYK profiles assigned to the document, with a working-space entry for none. OK runs `edit.assignProfile` and then `file.documentColorMode` with the dialog's other changes in one `command.batch`, and the document's changes are one undo step. An unchanged Color tab adds no color commands, and Cancel, or OK with nothing changed, records no undo step. A Color tab field with a value of the wrong kind is an error, and the dialog stays open. The tab is VectorCraft's own layout; File › Document Color Mode and Edit › Assign Profile… stay as they are. Tests in `dialogs/document_setup.rs`. - **Assign Profile and the working spaces:** Edit › Assign Profile… tags the document and leaves the working spaces of Edit › Color Settings… as they are, in Color Settings, the Separations panel and the settings saved for the next start. Colors show and convert through the profiles assigned; a change in Color Settings keeps the profiles the document in front is assigned in use, and removing an assigned profile in use puts the working space back in its place. View › Proof Setup › Working CMYK simulates the working CMYK space of Color Settings in Proof Colors and Separations Preview, also in a document assigned another CMYK profile. Public references: [Changing the color profile for a document and About color working spaces](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=150) and [Soft-proof presets](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=134). Tests in `engine/tests/color_settings.rs`. +- **Canvas through the display's profile on Windows and X11:** Preferences › Performance › Use Monitor Profile for Canvas (on by default) converts the canvas's art, artboard paper and transparency grid from sRGB into the ICC profile of the display that shows most of the main window, after any proof, as the help page describes the monitor's profile translating the document's colors for the monitor ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=151)). Windows gives the display's default profile, and a display with Advanced Color on is left to Windows ([Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/wcs/advanced-color-icc-profiles)); X11 gives colord's profile for the output or the `_ICC_PROFILE` property. macOS converts the window itself, and a Wayland session stays sRGB. `ui.inspect` reports `displayProfile`. Tests in `color/src/cms/display.rs`, `ui-egui/src/display_profile.rs` and `canvas::tests::the_canvas_is_drawn_through_the_display_profile_while_the_preference_is_on`. - **New Document's More Settings and Save Preset:** More Settings opens a pop-up over New Document with only Number of Artboards, the artboard layout, Change to Right-to-Left Layout, Spacing and Columns; Done, Escape or a click outside closes it and keeps the values. Save Preset turns the top of Preset Details into a Save Document Preset heading with the preset's name, Cancel and Save, and hides the document's name, Close and Create until the preset is saved or canceled; nothing in that mode creates a document. Save Preset's default name is the first "Preset N" no saved preset has. VectorCraft's own layout. Harness tests in `dialogs/new_document.rs` cover both. - **New Document profiles:** each built-in category's presets start documents from that category's profile, a native document holding the starting swatches, graphic styles, brushes, symbols and other assets, the ruler and Document Setup options and the print settings ([Create a custom new document profile](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/create-a-custom-new-document-profile.html)). New Document's Manage Profiles pop-up opens a profile to edit and resets one to the shipped profile (`file.newProfiles`, `file.newProfiles.edit`, `file.newProfiles.reset`; `file.new {profile}`). The profiles live in VectorCraft's own `New Document Profiles` folder next to the preferences; a profile that cannot be read or is larger than 64 MiB leaves the defaults and is reported. Tests: `tests::newdoc::an_edited_profile_starts_the_documents_of_its_category`, `the_profile_follows_the_preset_access_and_reset`, `a_damaged_or_oversized_profile_leaves_the_defaults_and_is_reported`, `profile_names_are_checked_and_editing_needs_the_folder`, `dialogs::new_document::tests::manage_profiles_edits_and_resets_the_profiles`. - **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy). From 0663c76be1261b93cd0904c8aa66902a001b93fc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 08:38:48 -0800 Subject: [PATCH 06/14] Envelopes: a partly transparent image keeps its alpha where its warp pieces overlap An envelope or the Warp effect cuts an image into triangle pieces that reach a little past their edges, so neighbors overlap and no seams show. On an image with alpha, the overlap drew that alpha twice: a 50% image came out about 75% opaque along every cell edge and diagonal. - The pieces now form a knockout group, and each piece is marked as a warp piece (`warp_piece`) whose knockout shape is its whole triangle, so where pieces overlap only the top one draws. - The canvas knocks each piece out by its triangle at full coverage. - SVG and PDF draw the pieces of a partly transparent image (its pixels, opacity, color mask or colorize paint) through one mask each, of the later triangles that reach it; pixels on a triangle's antialiased edge come out slightly lighter there. Pieces of an opaque image are drawn one over another as before. - SVG writes one copy of the image for pieces cut by opacity masks (Preserve Shape Using Transparency) too. - A cell count past the generated-object budget makes an empty group instead of overflowing. Docs: docs/target-app-parity.md. --- crates/doc/src/live.rs | 56 +++++++++++++ crates/doc/src/live/expand.rs | 26 +++++-- crates/doc/src/node/extras.rs | 5 ++ crates/doc/src/node/ops.rs | 31 ++++++++ crates/doc/src/node/tests.rs | 3 + crates/doc/src/node/wire.rs | 5 ++ crates/doc/src/pixels.rs | 6 ++ crates/effects/src/clip.rs | 51 +++++++++++- crates/effects/src/lib.rs | 2 +- crates/engine/src/tests/warp_images.rs | 55 +++++++++++++ crates/pdf/src/export/nodes.rs | 56 +++++++++++-- crates/render/src/group.rs | 17 +++- crates/render/src/tests.rs | 1 + crates/render/src/tests/meshwarp.rs | 104 +++++++++++++++++++++++++ crates/svg/src/export/nodes.rs | 65 +++++++++++++++- crates/svg/tests/it/main.rs | 1 + crates/svg/tests/it/warp_pieces.rs | 86 ++++++++++++++++++++ docs/target-app-parity.md | 1 + 18 files changed, 550 insertions(+), 21 deletions(-) create mode 100644 crates/render/src/tests/meshwarp.rs create mode 100644 crates/svg/tests/it/warp_pieces.rs diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index cf7a4d2fa0..4ae34a2fd4 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -1247,4 +1247,60 @@ mod tests { assert_eq!(m, before); } } + + /// The pieces of a mesh-warped image make a knockout group, each knocking out its triangle + /// (clipping path or opacity mask art); the group keeps the image's transparency. + #[test] + fn mesh_warp_image_pieces_knock_out_by_their_triangles() { + let im = crate::ImageObject { + key: "pic".into(), + width: 4, + height: 4, + xf: Affine::scale(10.0), + link: None, + placement: Default::default(), + pixels: None, + }; + let mut n = Node::new(NodeId(3), NodeKind::Image(Box::new(im.clone()))); + n.opacity = 0.5; + n.blend = crate::color::BlendMode::Multiply; + let same = |p: Point| p; + for masked in [false, true] { + let g = mesh_warp_image(&n, &im, &same, 2, false, masked); + assert_eq!((g.knockout, g.opacity, g.blend, g.isolate), (crate::Knockout::On, 0.5, crate::color::BlendMode::Multiply, false)); + let NodeKind::Group { children, clip: false } = &g.kind else { panic!("a group") }; + assert_eq!(children.len(), 8); + for c in children { + assert!(c.extras().warp_piece); + let outline = c.knockout_outline().unwrap_or_else(|| panic!("masked {masked}: an outline")); + let NodeKind::Path { path, .. } = &outline.kind else { panic!("a path") }; + assert_eq!(path.anchor_count(), 3); + } + } + // A clip group or masked image that is not a piece knocks out by what it paints. + let mut plain = mesh_warp_image(&n, &im, &same, 1, false, false); + let NodeKind::Group { children, .. } = &mut plain.kind else { panic!("a group") }; + let piece = Arc::make_mut(&mut children[0]); + piece.extras_mut().warp_piece = false; + assert!(piece.knockout_outline().is_none()); + } + + /// More cells than the objects live art may make cut no pieces (the count overflowed). + #[test] + fn mesh_warp_image_past_the_budget_makes_an_empty_group() { + let im = crate::ImageObject { + key: "pic".into(), + width: 4, + height: 4, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; + let n = Node::new(NodeId(3), NodeKind::Image(Box::new(im.clone()))); + for cells in [usize::MAX, 1000] { + let g = mesh_warp_image(&n, &im, &|p| p, cells, false, false); + assert!(matches!(&g.kind, NodeKind::Group { children, .. } if children.is_empty()), "{cells}"); + } + } } diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index b8309f675b..d87d7a28c4 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -207,9 +207,21 @@ pub fn mesh_warp_cost(cells: usize) -> usize { /// triangle instead; with `pixelated`, drawn without smoothing). Each piece reaches a little past /// its edges (at most half a point) so neighbors overlap and no seams show. Each piece keeps the /// color `n` colorizes a grayscale image with, and `n`'s color mask. The result is a group with `n`'s id, name, -/// transparency, opacity mask and appearance. +/// transparency, opacity mask and appearance. It is a knockout group and each piece is a +/// [`crate::NodeExtras::warp_piece`], so where pieces overlap only the top one draws and a +/// partly transparent image keeps its own alpha there. More cells than [`MAX_GENERATED`] objects +/// allow make an empty group. pub fn mesh_warp_image(n: &Node, im: &ImageObject, f: &dyn Fn(Point) -> Point, cells: usize, pixelated: bool, masked: bool) -> Node { let k = cells.max(1); + let mut g = n.clone(); + g.kind = NodeKind::Group { children: vec![], clip: false }; + g.knockout = crate::Knockout::On; + if let Some(e) = g.extras_if_any() { + e.colorize = None; + } + if mesh_warp_cost(k) > MAX_GENERATED { + return g; + } // A grayscale image's fill color colorizes each piece; the group of pieces takes none. let colorize = n.extras().colorize.clone(); // Each piece shows what the image shows: its color mask's masked pixels. @@ -243,22 +255,22 @@ pub fn mesh_warp_image(n: &Node, im: &ImageObject, f: &dyn Fn(Point) -> Point, c // Preserve Shape Using Transparency: the triangle is the piece's alpha (a // white opacity mask) instead of its clipping path. let white = Node::path(n.id, shape, Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0)); - pieces.push(Arc::new(image.with_mask(Some(Box::new(crate::OpacityMask::new(white, true)))))); + let mut piece = image.with_mask(Some(Box::new(crate::OpacityMask::new(white, true)))); + piece.extras_mut().warp_piece = true; + pieces.push(Arc::new(piece)); continue; } let mut clip = Node::path(n.id, shape, Appearance::default()); if let NodeKind::Path { clipping, .. } = &mut clip.kind { *clipping = true; } - pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }))); + let mut piece = Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }); + piece.extras_mut().warp_piece = true; + pieces.push(Arc::new(piece)); } } } - let mut g = n.clone(); g.kind = NodeKind::Group { children: pieces, clip: false }; - if let Some(e) = g.extras_if_any() { - e.colorize = None; - } g } diff --git a/crates/doc/src/node/extras.rs b/crates/doc/src/node/extras.rs index 7b130bf395..e13750457e 100644 --- a/crates/doc/src/node/extras.rs +++ b/crates/doc/src/node/extras.rs @@ -75,6 +75,10 @@ pub struct NodeExtras { /// Image Tools › Color Mask: the colors an image hides while its pixels stay as they are /// ([`crate::color_mask`]). pub color_mask: Option>, + /// A piece of an image a raster mesh warp cut up ([`crate::live::mesh_warp_image`]): in a + /// knockout group its knockout shape is the whole area of its triangle, not only where the + /// image is opaque ([`Node::knockout_outline`]). + pub warp_piece: bool, } /// No extras: what [`Node::extras`] gives for a node without any. @@ -103,6 +107,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras { envelope_edit_margin: None, pixel_aligned: false, color_mask: None, + warp_piece: false, }; /// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 415fc1cad1..2bb655236a 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -716,6 +716,37 @@ impl Node { out } + /// As a piece of a mesh-warped image ([`crate::NodeExtras::warp_piece`]), the art whose area is its + /// knockout shape at full coverage: the art of its opacity mask (on and not inverted), or the + /// clipping path of its clip group. `None` for other objects, whose knockout shape is what they + /// paint. + pub fn knockout_outline(&self) -> Option<&Arc> { + let e = self.extras(); + if !e.warp_piece { + return None; + } + if let Some(m) = e.mask.as_deref() { + return (!m.disabled && !m.invert).then_some(&m.art); + } + match &self.kind { + NodeKind::Group { children, clip: true } => children.first(), + _ => None, + } + } + + /// The image a piece of a mesh-warped image draws ([`Self::knockout_outline`]): the piece itself + /// when an opacity mask cuts it, else the image its clip group clips. `None` for other objects. + pub fn warp_piece_image(&self) -> Option<&Node> { + if !self.extras().warp_piece { + return None; + } + match &self.kind { + NodeKind::Image(_) => Some(self), + NodeKind::Group { children, clip: true } => children.get(1).map(|c| &**c).filter(|c| matches!(c.kind, NodeKind::Image(_))), + _ => None, + } + } + /// Whether this is a clip group or a layer with a clipping mask: its first child clips the rest. /// A layer whose first child is a ruler guide holds no art, so a guide never clips it. pub fn clips(&self) -> bool { diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index 335eb6312e..9981514879 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -23,6 +23,7 @@ fn extras_are_written_flat_in_order() { e.shape_mode = crate::ShapeMode::Exclude; e.envelope_edit_margin = Some([0.5, 0.5, 0.25, 0.25]); e.pixel_aligned = true; + e.warp_piece = true; } /// The top-level keys of a JSON object, in the order written. struct Keys(Vec); @@ -69,10 +70,12 @@ fn extras_are_written_flat_in_order() { "shape_mode", "envelope_edit_margin", "pixel_aligned", + "warp_piece", ]; assert_eq!(keys, order); assert_eq!(v["shape_mode"], "exclude"); assert_eq!(v["pixel_aligned"], true); + assert_eq!(v["warp_piece"], true); assert_eq!(serde_json::from_value::(v).unwrap(), n); let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art()); let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap(); diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index 34d8e99c53..885f9a5853 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -56,6 +56,7 @@ impl Serialize for Node { e.envelope_edit_margin.is_none(), is_default(&e.pixel_aligned), e.color_mask.is_none(), + is_default(&e.warp_piece), ]; let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; let mut skip = skips.into_iter(); @@ -100,6 +101,7 @@ impl Serialize for Node { field!("envelope_edit_margin", &e.envelope_edit_margin); field!("pixel_aligned", &e.pixel_aligned); field!("color_mask", &e.color_mask); + field!("warp_piece", &e.warp_piece); st.end() } } @@ -245,6 +247,8 @@ pub(super) struct NodeWire { pixel_aligned: bool, #[serde(default)] color_mask: Option>, + #[serde(default)] + warp_piece: bool, } impl From for Node { @@ -274,6 +278,7 @@ impl From for Node { envelope_edit_margin: w.envelope_edit_margin, pixel_aligned: w.pixel_aligned, color_mask: w.color_mask, + warp_piece: w.warp_piece, }; Node { id: w.id, diff --git a/crates/doc/src/pixels.rs b/crates/doc/src/pixels.rs index 0812095826..ef72ae5721 100644 --- a/crates/doc/src/pixels.rs +++ b/crates/doc/src/pixels.rs @@ -266,6 +266,12 @@ impl ImageBlob { Some(out) } + /// Whether any pixel of the image is less than fully opaque. `false` when the image can't be + /// decoded. The last image decoded is kept, as for [`Self::sample`]. + pub fn has_transparency(&self) -> bool { + self.decoded().is_some_and(|img| img.pixels().any(|p| p.0[3] < 255)) + } + /// The decoded pixels (the last image decoded is kept for the next sample). fn decoded(&self) -> Option> { thread_local! { diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index 444e814d86..0616261d8a 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -1,7 +1,8 @@ //! The region a clip group clips to ([`Node::clip_outline`]) with text outlined by the font engine //! and several shapes united, so the renderer and the SVG and PDF writers clip alike. -use vectorcraft_doc::{Appearance, Node, NodeKind}; +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, NodeKind}; use vectorcraft_geom::{BezPath, FillRule, PathData}; /// The clipping path `clip` of a clip group as one path and fill rule: compound paths keep their @@ -11,6 +12,54 @@ pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> { clip.clip_outline(Some(&outline_text), &unite) } +/// The knockout shape of a piece of a mesh-warped image ([`Node::knockout_outline`]) painted opaque +/// in `color`, which the SVG and PDF writers put in their knockout masks: for a clip group, a +/// rectangle clipped by the same clipping path (so a viewer covers the same pixels of its edges as +/// when it clips the piece), else the path of its opacity mask's art, filled. `None` for other +/// objects and for a piece with nothing to clip by. +pub fn knockout_silhouette(n: &Node, color: Color) -> Option { + let outline = n.knockout_outline()?; + let (bp, rule) = clip_outline(outline)?; + let paint = Appearance::basic(Paint::solid(color), Paint::None, 0.0); + if n.clips() && n.extras().mask.is_none() { + let cover = Node::path(n.id, vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Shape::bounding_box(&bp).inflate(1.0, 1.0)), paint); + let children = vec![outline.clone(), std::sync::Arc::new(cover)]; + return Some(Node::new(n.id, NodeKind::Group { children, clip: true })); + } + let mut p = Node::path(n.id, PathData::from_bezpath(&bp), paint); + if let NodeKind::Path { rule: r, .. } = &mut p.kind { + *r = rule; + } + Some(p) +} + +/// Whether any of `pieces`, pieces of a mesh-warped image ([`Node::warp_piece_image`]), paints less +/// than opaque anywhere inside its triangle: it or its image is partly transparent, a color mask +/// clears some of its colors, a colorize paint draws its light pixels partly transparent, or the +/// pixels of its image (looked up in `doc`) are. Each distinct +/// image's pixels are scanned once. Pieces of an opaque image overlap without showing it, so the SVG +/// and PDF writers draw them one over another without masks. +pub fn warp_pieces_translucent(doc: &Document, pieces: &[&Node]) -> bool { + let mut scanned: Vec<*const vectorcraft_doc::ImageBlob> = vec![]; + for n in pieces { + let Some(image) = n.warp_piece_image() else { continue }; + let NodeKind::Image(im) = &image.kind else { continue }; + let e = image.extras(); + if n.opacity < 1.0 || image.opacity < 1.0 || e.color_mask.is_some() || e.colorize.is_some() { + return true; + } + let Some(blob) = doc.image_blob(im) else { continue }; + if scanned.contains(&std::ptr::from_ref(blob)) { + continue; + } + scanned.push(std::ptr::from_ref(blob)); + if blob.has_transparency() { + return true; + } + } + false +} + /// Text as one path of its glyph outlines, in document space. fn outline_text(n: &Node) -> Option { let NodeKind::Text(t) = &n.kind else { return None }; diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index fa06d00922..d84c54060d 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -69,7 +69,7 @@ pub use adjust::{ is_channel, }; pub use bake::{StrokeArt, bake_appearance, bake_document, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake}; -pub use clip::clip_outline; +pub use clip::{clip_outline, knockout_silhouette, warp_pieces_translucent}; pub use group::{ OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_leaves, member_shapes, paints, pathfinder_children, shape_art, diff --git a/crates/engine/src/tests/warp_images.rs b/crates/engine/src/tests/warp_images.rs index 47175fc866..1fc6b736e1 100644 --- a/crates/engine/src/tests/warp_images.rs +++ b/crates/engine/src/tests/warp_images.rs @@ -291,3 +291,58 @@ fn a_warp_on_a_group_bends_the_image_in_it() { s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap(); assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]); } + +/// A selected embedded 40×40 px red image at half alpha filling [`FRAME`], with Effect › Warp › +/// Arc at 50 %. +fn half_clear_arc(s: &mut Session) { + let img = image::RgbaImage::from_pixel(40, 40, image::Rgba([255, 0, 0, 128])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.edit("Place", |d, sel| { + d.images.insert("half".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let xf = Affine::translate((FRAME.x0, FRAME.y0)) * Affine::scale(2.0); + let im = + vectorcraft_doc::ImageObject { key: "half".into(), width: 40, height: 40, xf, link: None, placement: Default::default(), pixels: None }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap(); +} + +/// The points of a 50 × 50 grid over the bent image of [`half_clear_arc`], away from its outer +/// edges, where the document drawn on white at 8 px a point is not half red (its green is not in +/// `green`; half red over white is (255, 128, 128)): the first few. +fn not_half_red(s: &Session, green: std::ops::RangeInclusive) -> Vec<(f64, f64, [u8; 4])> { + let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 300.0, 300.0), 8.0, true); + let mut out = vec![]; + for i in 0..50 { + for j in 0..50 { + let (u, v) = (0.03 + 0.94 * f64::from(i) / 49.0, 0.03 + 0.94 * f64::from(j) / 49.0); + let p = arced(50.0, u, v); + let px = img.pixel((p.x * 8.0).floor() as u32, (p.y * 8.0).floor() as u32); + if !green.contains(&px[1]) && out.len() < 8 { + out.push((u, v, px)); + } + } + } + out +} + +/// The pieces a warp cuts a half transparent image into overlap: the image stays half +/// transparent there, on the canvas and in a PDF opened again. +#[test] +fn a_half_transparent_warped_image_has_no_darker_seams() { + let mut s = session(); + half_clear_arc(&mut s); + assert_eq!(not_half_red(&s, 120..=136), vec![], "canvas"); + // Opened again, each piece is drawn through masks of the pieces above it, and pixels on a + // triangle's antialiased edge come out a little lighter. Pieces whose alphas add up where they + // overlap draw a green of about 64 there. + let pdf = s.execute("document.export", &json!({"format": "pdf", "preserveEditing": false})).unwrap(); + let mut back = session(); + back.execute("document.open", &json!({"name": "half.pdf", "dataBase64": pdf["dataBase64"]})).unwrap(); + assert_eq!(not_half_red(&back, 120..=152), vec![], "pdf"); +} diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index f8fb792ec2..4d4b57c3d2 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -69,15 +69,25 @@ impl Exporter<'_> { } return; } - // The PDF writer has no knockout groups: each element is drawn through a soft mask of - // where the elements above it don't paint (the same look for Normal blending). The masks - // nest, so element i sits inside the masks of elements i+1…n: open them outermost first. - self.warn("knockout groups are written as soft-masked groups (same look, but not editable as knockout groups)"); let elements: Vec<_> = Node::knockout_elements(children) .into_iter() .map(|c| (c, self.measure(c))) .filter(|(_, m)| m.bounds.is_some_and(|b| rects_overlap(b.inflate(1.0, 1.0), page))) .collect(); + if !elements.is_empty() && elements.iter().all(|(c, _)| c.knockout_outline().is_some()) { + let pieces: Vec<&Node> = elements.iter().map(|(c, _)| &***c).collect(); + if vectorcraft_effects::warp_pieces_translucent(self.doc, &pieces) { + return self.warp_pieces(s, &elements, page); + } + for (c, m) in &elements { + self.measured_node(s, c, page, false, Some(m)); + } + return; + } + // The PDF writer has no knockout groups: each element is drawn through a soft mask of + // where the elements above it don't paint (the same look for Normal blending). The masks + // nest, so element i sits inside the masks of elements i+1…n: open them outermost first. + self.warn("knockout groups are written as soft-masked groups (same look, but not editable as knockout groups)"); let Some(((first, measured), rest)) = elements.split_first() else { return }; for (c, m) in rest.iter().rev() { let mask = self.knockout_mask(s, c, page, m); @@ -101,6 +111,40 @@ impl Exporter<'_> { s.opacity = 1.0; s }; + self.shapes_mask(s, &[(&shape, Some(measured))], page) + } + + /// The pieces of a partly transparent mesh-warped image (`elements`, each with its box) as the + /// elements of a knockout group ([`vectorcraft_effects::warp_pieces_translucent`]). Each piece + /// is drawn through one soft mask of where the pieces above it that reach its box don't cover + /// ([`vectorcraft_effects::knockout_silhouette`]): only neighbors overlap, so each mask holds a + /// few triangles and no mask nests in another. + fn warp_pieces(&mut self, s: &mut Surface, elements: &[(&std::sync::Arc, Measured)], page: Rect) { + // Each piece's triangle and its box: a piece paints inside its triangle only. + let silhouette = |c: &Node| { + let b = c.knockout_outline()?.geometric_bounds()?; + Some((vectorcraft_effects::knockout_silhouette(c, vectorcraft_doc::color::Color::WHITE)?, b)) + }; + let silhouettes: Vec> = elements.iter().map(|(c, _)| silhouette(c)).collect(); + for (i, (c, measured)) in elements.iter().enumerate() { + let Some((_, reach)) = &silhouettes[i] else { continue }; + let reach = reach.inflate(1.0, 1.0); + let shapes: Vec<(&Node, Option<&Measured>)> = + silhouettes[i + 1..].iter().flatten().filter(|(_, b)| rects_overlap(*b, reach)).map(|(n, _)| (n, None)).collect(); + if shapes.is_empty() { + self.measured_node(s, c, page, false, Some(measured)); + continue; + } + let mask = self.shapes_mask(s, &shapes, page); + s.push_mask(mask); + self.measured_node(s, c, page, false, Some(measured)); + s.pop(); + } + } + + /// A luminosity mask that is 1 − the coverage of `shapes` (each with its box when measured): + /// white, then black through an alpha mask of the shapes drawn one over another. + fn shapes_mask(&mut self, s: &mut Surface, shapes: &[(&Node, Option<&Measured>)], page: Rect) -> krilla::mask::Mask { let transform = s.group_transform(); let mut sb = s.stream_builder(); let mut ms = Surface::stream(sb.surface()); @@ -112,7 +156,9 @@ impl Exporter<'_> { let mut als = Surface::stream(ab.surface()); als.push_transform(&transform); let overprint = std::mem::take(&mut self.overprint); - self.measured_node(&mut als, &shape, page, false, Some(measured)); + for (shape, measured) in shapes { + self.measured_node(&mut als, shape, page, false, *measured); + } self.overprint = overprint; als.pop(); als.finish(); diff --git a/crates/render/src/group.rs b/crates/render/src/group.rs index 609771c664..09509f58e7 100644 --- a/crates/render/src/group.rs +++ b/crates/render/src/group.rs @@ -284,9 +284,20 @@ impl Renderer { ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, compose)), None, None, None); self.nested += 1; let shape = compose != Compose::Plus && !c.knockout_shape; - self.shape_of = if shape { Arc::as_ptr(c) as usize } else { 0 }; - self.draw_arc(ctx, f, c); - self.shape_of = 0; + // A piece of a mesh-warped image knocks out its whole triangle, where its neighbors + // overlap it, at full coverage whatever the image's own alpha there. + if shape && let Some(outline) = c.knockout_outline() { + if let Some(region) = self.clip_of(outline) { + ctx.set_transform(f.view); + ctx.set_paint(f.ink.fixed([255, 255, 255, 255])); + ctx.set_fill_rule(fill_rule(region.1)); + ctx.fill_path(®ion.0); + } + } else { + self.shape_of = if shape { Arc::as_ptr(c) as usize } else { 0 }; + self.draw_arc(ctx, f, c); + self.shape_of = 0; + } self.nested -= 1; ctx.pop_layer(); } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index bde3278bfc..99fa434ca3 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -25,6 +25,7 @@ mod knockout; mod layeropts; mod longstroke; mod memory; +mod meshwarp; mod movedstroke; mod objectfx; mod paintedbrush; diff --git a/crates/render/src/tests/meshwarp.rs b/crates/render/src/tests/meshwarp.rs new file mode 100644 index 0000000000..d4e5a98499 --- /dev/null +++ b/crates/render/src/tests/meshwarp.rs @@ -0,0 +1,104 @@ +//! The pieces a raster mesh warp cuts an image into (envelopes, Effect › Warp) overlap their +//! neighbors: a partly transparent image keeps its own alpha where they do. + +use crate::*; +use vectorcraft_doc::live::mesh_warp_image; +use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeKind}; +use vectorcraft_geom::Point; + +/// A 4 × 4 px red PNG, every pixel `alpha` opaque. +fn png(alpha: u8) -> Vec { + let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, alpha])); + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// The image of [`png`] over (10, 10)–(90, 90), pushed through `f` as 4 × 4 cells (with `masked`, +/// as Preserve Shape Using Transparency cuts it), on a page of 100 × 100 pt, after `tweak` +/// adjusts the warped group. +fn warped(f: &dyn Fn(Point) -> Point, masked: bool, pixelated: bool, tweak: impl FnOnce(&mut Node)) -> Document { + let mut d = Document::new(100.0, 100.0); + d.images.insert("pic".into(), ImageBlob::png(png(128))); + let im = ImageObject { + key: "pic".into(), + width: 4, + height: 4, + xf: Affine::translate((10.0, 10.0)) * Affine::scale(20.0), + link: None, + placement: Default::default(), + pixels: None, + }; + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im.clone()))); + let mut g = mesh_warp_image(&n, &im, f, 4, pixelated, masked); + tweak(&mut g); + d.insert(Some(d.layers[0].id), 0, g).unwrap(); + d +} + +/// `d` at 4 px a point over transparency. +fn drawn(d: &Document) -> Rendered { + Renderer::new().render(d, 400, 400, Affine::scale(4.0), &RenderOptions { background: None, ..Default::default() }) +} + +/// The pixels of `r` inside (x0, y0)–(x1, y1) (points) whose alpha is more than 4 off `want`, with +/// that alpha: the first few. +fn uneven(r: &Rendered, (x0, y0, x1, y1): (u32, u32, u32, u32), want: u8) -> Vec<(u32, u32, u8)> { + let mut out = vec![]; + for y in y0 * 4..y1 * 4 { + for x in x0 * 4..x1 * 4 { + let a = r.pixel(x, y)[3]; + if (i32::from(a) - i32::from(want)).abs() > 4 && out.len() < 8 { + out.push((x, y, a)); + } + } + } + out +} + +/// Inside the image, away from its outer edges. +const INSIDE: (u32, u32, u32, u32) = (12, 12, 88, 88); + +#[test] +fn a_half_transparent_image_is_half_transparent_where_its_warp_pieces_overlap() { + let same = |p: Point| p; + for masked in [false, true] { + for pixelated in [false, true] { + let r = drawn(&warped(&same, masked, pixelated, |_| {})); + // The cells meet at x and y = 30, 50 and 70, and each cell's diagonal crosses it. + assert_eq!(uneven(&r, INSIDE, 128), vec![], "masked {masked}, pixelated {pixelated}"); + } + } + // A wave bends every edge between the pieces. + let wave = |p: Point| Point::new(p.x + 3.0 * (p.y / 7.0).sin(), p.y + 2.0 * (p.x / 9.0).cos()); + for masked in [false, true] { + let r = drawn(&warped(&wave, masked, false, |_| {})); + assert_eq!(uneven(&r, (16, 16, 84, 84), 128), vec![], "waved, masked {masked}"); + } +} + +#[test] +fn the_images_opacity_applies_once_over_the_warp_pieces() { + let same = |p: Point| p; + for masked in [false, true] { + let r = drawn(&warped(&same, masked, false, |g| g.opacity = 0.5)); + assert_eq!(uneven(&r, INSIDE, 64), vec![], "masked {masked}"); + } +} + +/// A clip group of a half transparent image made by hand, in a knockout group, knocks out by +/// where its image is opaque: only the pieces of a warp knock out by their whole triangle. +#[test] +fn other_clip_groups_in_a_knockout_group_knock_out_by_their_alpha() { + let same = |p: Point| p; + let d = warped(&same, false, false, |g| { + let NodeKind::Group { children, .. } = &mut g.kind else { panic!("a group") }; + for c in children { + Arc::make_mut(c).extras_mut().warp_piece = false; + } + }); + let r = drawn(&d); + // On the shared edge at x = 30, y = 20: the neighbors' alphas add up there. + let a = r.pixel(120, 80)[3]; + assert!(a > 160, "{a}"); +} diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index 73700eb8e3..f1c989e282 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -122,14 +122,21 @@ impl Reuse { } /// Are `children` the pieces of a raster mesh warp ([`vectorcraft_doc::live::mesh_warp_image`]): -/// more than one, each a clip group of a triangle and one and the same image? +/// more than one, each a clip group of a triangle and one and the same image, or that image masked +/// by a triangle (Preserve Shape Using Transparency)? fn image_pieces(children: &[std::sync::Arc]) -> bool { let mut first: Option<(&str, u32, u32)> = None; children.len() > 1 && children.iter().all(|c| { - let NodeKind::Group { children, clip: true } = &c.kind else { return false }; - let [clip, im] = children.as_slice() else { return false }; - let (NodeKind::Path { path, .. }, NodeKind::Image(im)) = (&clip.kind, &im.kind) else { return false }; + let (shape, im) = match (&c.kind, c.extras().mask.as_deref()) { + (NodeKind::Group { children, clip: true }, _) => match children.as_slice() { + [clip, im] => (&**clip, &**im), + _ => return false, + }, + (NodeKind::Image(_), Some(m)) => (&*m.art, &**c), + _ => return false, + }; + let (NodeKind::Path { path, .. }, NodeKind::Image(im)) = (&shape.kind, &im.kind) else { return false }; let triangle = path.subpaths.len() == 1 && path.anchor_count() == 3; let image = (im.key.as_str(), im.width, im.height); triangle && *first.get_or_insert(image) == image @@ -578,6 +585,16 @@ impl Writer<'_> { return; } let elements = Node::knockout_elements(children); + if !elements.is_empty() && elements.iter().all(|c| c.knockout_outline().is_some()) { + let pieces: Vec<&Node> = elements.iter().map(|c| &***c).collect(); + if vectorcraft_effects::warp_pieces_translucent(self.doc, &pieces) { + return self.warp_pieces(&elements); + } + for c in elements { + self.node(c); + } + return; + } // Where every element over another covers it fully, drawing them in order knocks out as // much: the masks would only add seams along the shapes' antialiased edges. if elements.iter().skip(1).all(|c| paints_opaque(c)) { @@ -616,6 +633,46 @@ impl Writer<'_> { } } + /// The pieces of a partly transparent mesh-warped image (`elements`) as the elements of a + /// knockout group ([`vectorcraft_effects::warp_pieces_translucent`]). Each piece is drawn + /// through one mask of where the pieces above it that reach it don't cover + /// (their triangles, [`vectorcraft_effects::knockout_silhouette`], black over white): + /// only neighbors overlap, so each mask holds a few triangles and no mask nests in another. + fn warp_pieces(&mut self, elements: &[&std::sync::Arc]) { + // Each piece's triangle and its box: a piece paints inside its triangle only. + let silhouette = |c: &Node| { + let b = c.knockout_outline()?.geometric_bounds()?; + Some((vectorcraft_effects::knockout_silhouette(c, vectorcraft_doc::color::Color::BLACK)?, b)) + }; + let silhouettes: Vec> = elements.iter().map(|c| silhouette(c)).collect(); + for (i, c) in elements.iter().enumerate() { + let Some((own, reach)) = &silhouettes[i] else { continue }; + let reach = reach.inflate(1.0, 1.0); + let overlaps = |b: &Rect| b.x0 <= reach.x1 && reach.x0 <= b.x1 && b.y0 <= reach.y1 && reach.y0 <= b.y1; + let above: Vec<&Node> = silhouettes[i + 1..].iter().flatten().filter(|(_, b)| overlaps(b)).map(|(n, _)| n).collect(); + if above.is_empty() { + self.node(c); + continue; + } + let region = self.region_attrs(&[own]); + let anonymous = std::mem::replace(&mut self.anonymous, true); + let art: Vec = above.iter().map(|a| self.detached(2, |w| w.node(a))).collect(); + self.anonymous = anonymous; + let mid = self.fresh_id("knockout"); + self.def(1, &format!("")); + self.def(2, &format!("")); + for a in art { + self.defs.push_str(&a); + } + self.def(1, ""); + self.line(&format!("")); + self.depth += 1; + self.node(c); + self.depth -= 1; + self.line(""); + } + } + /// A mask that is 1 − the knockout shape of `c`: white, then `c` painted black (at full object /// opacity without its own mask, unless those define its shape). `region` is the attributes of /// the mask's region ([`Self::region_attrs`]), which the filter that paints `c` black shares. diff --git a/crates/svg/tests/it/main.rs b/crates/svg/tests/it/main.rs index 05546891f4..adf321ee59 100644 --- a/crates/svg/tests/it/main.rs +++ b/crates/svg/tests/it/main.rs @@ -41,3 +41,4 @@ mod text_layout; mod text_paint_order; mod text_stroke; mod title_control_chars; +mod warp_pieces; diff --git a/crates/svg/tests/it/warp_pieces.rs b/crates/svg/tests/it/warp_pieces.rs new file mode 100644 index 0000000000..575e98be16 --- /dev/null +++ b/crates/svg/tests/it/warp_pieces.rs @@ -0,0 +1,86 @@ +//! The pieces a raster mesh warp cuts an image into overlap their neighbors: in SVG a partly +//! transparent image keeps its own alpha where they do, and the pieces share one copy of the image. + +use vectorcraft_doc::live::mesh_warp_image; +use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; +use vectorcraft_geom::{Affine, Point}; +use vectorcraft_svg::{ExportOptions, export_full}; + +/// A 100 × 100 pt page holding a 4 × 4 px red image at half alpha over (10, 10)–(90, 90), pushed +/// through `f` as 4 × 4 cells (with `masked`, as Preserve Shape Using Transparency cuts it). +fn warped(f: &dyn Fn(Point) -> Point, masked: bool) -> Document { + warped_pixels(f, masked, [255, 0, 0, 128], |_| {}) +} + +/// [`warped`] with every pixel `rgba`, after `tweak` adjusts the image object. +fn warped_pixels(f: &dyn Fn(Point) -> Point, masked: bool, rgba: [u8; 4], tweak: impl FnOnce(&mut Node)) -> Document { + let img = image::RgbaImage::from_pixel(4, 4, image::Rgba(rgba)); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut d = Document::new(100.0, 100.0); + d.images.insert("pic".into(), ImageBlob::png(png)); + let im = ImageObject { + key: "pic".into(), + width: 4, + height: 4, + xf: Affine::translate((10.0, 10.0)) * Affine::scale(20.0), + link: None, + placement: Default::default(), + pixels: None, + }; + let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im.clone()))); + tweak(&mut n); + let g = mesh_warp_image(&n, &im, f, 4, false, masked); + d.insert(d.default_layer(), 0, g).unwrap(); + d +} + +/// `svg` drawn at 4 px a point over transparency: of the pixels inside (12, 12)–(88, 88), the most +/// and the least opaque, each with its position. +fn alpha_range(svg: &str) -> ((u32, u32, u8), (u32, u32, u8)) { + let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).unwrap(); + let mut pixmap = resvg::tiny_skia::Pixmap::new(400, 400).unwrap(); + resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(4.0, 4.0), &mut pixmap.as_mut()); + let (mut most, mut least) = ((0, 0, 0u8), (0, 0, 255u8)); + for y in 48..352 { + for x in 48..352 { + let a = pixmap.pixel(x, y).unwrap().alpha(); + if a > most.2 { + most = (x, y, a); + } + if a < least.2 { + least = (x, y, a); + } + } + } + (most, least) +} + +#[test] +fn a_half_transparent_image_is_half_transparent_where_its_warp_pieces_overlap() { + let same = |p: Point| p; + for masked in [false, true] { + let svg = export_full(&warped(&same, masked), &ExportOptions::default(), None).svg; + // Where pieces overlap, the image's alpha does not add up (it reached 75 % there). The + // viewer rasterizes each piece's triangle and the masks over it one by one, so pixels on a + // triangle's antialiased edge come out a little lighter. + let (most, least) = alpha_range(&svg); + assert!(most.2 <= 134, "masked {masked}: {most:?}"); + assert!(least.2 >= 104, "masked {masked}: {least:?}"); + // The 32 pieces draw one copy of the image. + assert_eq!(svg.matches("data:image/png;base64,").count(), 1, "masked {masked}"); + } +} + +/// Pieces of an opaque image are drawn one over another without knockout masks; a colorize paint +/// draws a gray image's light pixels partly transparent, so its pieces take them. +#[test] +fn only_pieces_that_show_through_take_knockout_masks() { + let same = |p: Point| p; + let knockouts = |d: &Document| export_full(d, &ExportOptions::default(), None).svg.matches("id=\"knockout").count(); + assert_eq!(knockouts(&warped_pixels(&same, false, [128, 128, 128, 255], |_| {})), 0); + let colorized = warped_pixels(&same, false, [128, 128, 128, 255], |n| { + n.extras_mut().colorize = Some(Box::new(vectorcraft_doc::color::Paint::solid(vectorcraft_doc::color::Color::rgb(1.0, 0.0, 0.0)))); + }); + assert!(knockouts(&colorized) > 0); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 65b58c6dc5..ffae1df168 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1366,6 +1366,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Window › Blend exposes easing Type, Ramp and independent color Shift through `object.blend.options`. Smooth Color counts the steps of each pair of keys from their color change, or from the distance between their visible bounding-box edges when their colors match or they hold gradients or patterns ([Blend Options](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)); the step counts, their caps and the colors compared are VectorCraft's own. Envelope Options › Anti-Alias off draws an envelope's distorted images without smoothing, and Preserve Shape Using › Transparency gives each piece of a distorted image an opacity mask in its shape instead of a clipping mask ([Envelope panel options](http://web.archive.org/web/20260513013456/https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/envelope-panel-options.html)). + The pieces of an image that an envelope or a Warp effect distorts form a knockout group in which each piece knocks out by its whole triangle, so a partly transparent image keeps its own alpha where neighboring pieces overlap instead of drawing it twice there. SVG and PDF draw the pieces of a partly transparent image through masks of the triangles above them, and pixels on a triangle's antialiased edge come out slightly lighter there. Expanded envelopes keep the knockout group. Tests: `crates/render/src/tests/meshwarp.rs`, `crates/svg/tests/it/warp_pieces.rs`, `warp_images::a_half_transparent_warped_image_has_no_darker_seams` in `crates/engine`. The Mesh tool and Direct Selection drag the points and handles of an envelope made with Warp, as the help page describes for any envelope ([Distort objects with envelopes](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-objects-with-envelopes.html)). A selected warp shows the points of its surface on 4×4 patches (8×8 or 16×16 where 4×4 draws more than 0.1% of its diagonal away from the warp), and the first edit makes it a mesh envelope on that grid in the same undo step; Undo or Escape gives back the warp with its style and bend. The grid sizes, the 0.1% and the change to a mesh envelope are VectorCraft's own rules. Tests: `every_warp_style_has_a_grid_that_keeps_its_shape` in `crates/doc/src/live/envelope.rs`, `moving_a_warp_point_makes_it_a_mesh_envelope_in_one_step` and `an_edited_warp_saves_resets_releases_and_expands_as_edited` in `crates/engine/src/tests/envelope_edit.rs`, `the_mesh_tool_and_direct_selection_drag_warp_envelope_points` in `crates/tools/src/meshedit.rs`. With Envelope Options › Distort Appearance on, type's strokes (its characters' and those on its own appearance) paint along the bent glyph outlines at their own weight instead of bending with the art, as the drawn art of users' `.ai` files strokes type in an envelope. This is VectorCraft's own rule from those files: the public page names applied effects and graphic styles, not strokes. This holds in an envelope inside an envelope too. Paths' strokes, geometry effects (type's included), opacity masks (on type too), linear gradients and pattern fills bend as before; Expand gives type's bent outlines with their strokes on them. The visual bounds of envelopes, blends and repeats, and the reach of symbol art, count type's character strokes as type's own visual bounds do (half the weight of a centred stroke); they left them out, though those strokes already painted past an envelope's grid. Envelopes of stroked type made in VectorCraft before this draw their strokes at their own weight now (#6644; `crates/engine/src/tests/envelope_distort.rs`, `crates/effects/src/bake/tests.rs`). The four polynomial distributions are original approximations of the public description, not measured From 711d7163f843b3d9da147addbe1f8424c037de80 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 09:37:14 -0800 Subject: [PATCH 07/14] Brushes: repeated brush names are numbered when a document loads A document or brush library file could hold two brushes of one name. Renaming the first in Brush Options moved every stroke of that name and left the second brush unreachable, deleting by name removed both, and the Brushes panel could not select the second row. Reading a stored brush library now keeps the first brush of a name as stored and numbers each later one with the first "Name 2", "Name 3" and so on that the file does not already have. Strokes name their brush, and a name always found the first brush, so every stroke keeps drawing the brush it drew before. The rule applies to opened documents and to brush library files; brushes added to a document that already has the name keep today's numbering. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/brush/src/lib.rs | 35 +++++++++++++++++++++++++++-- crates/brush/src/tests.rs | 28 +++++++++++++++++++++++ crates/engine/src/tests/brushlib.rs | 21 +++++++++++++++++ crates/engine/src/tests/brushsym.rs | 31 +++++++++++++++++++++++++ docs/mcp.md | 5 ++++- docs/target-app-parity.md | 1 + 6 files changed, 118 insertions(+), 3 deletions(-) diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index f25615c589..080462d0b6 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -762,9 +762,40 @@ pub fn library(doc: &Document) -> Vec { } /// Parse a stored library value (malformed entries are skipped, values kept within their -/// ranges; see [`Brush::from_json`]). +/// ranges; see [`Brush::from_json`]). A name that repeats is kept by its first brush, and each +/// later brush of that name is numbered ([`number_repeated_names`]). pub fn parse_library(v: &serde_json::Value) -> Vec { - v.as_array().map(|a| a.iter().filter_map(|b| Brush::from_json(b.clone()).ok()).collect()).unwrap_or_default() + let mut lib: Vec = v.as_array().map(|a| a.iter().filter_map(|b| Brush::from_json(b.clone()).ok()).collect()).unwrap_or_default(); + number_repeated_names(&mut lib); + lib +} + +/// Renames the second and later brushes of each repeated name in `lib` to the first "Name 2", +/// "Name 3"… that no brush in `lib` has. The first brush of a name keeps it, so a stroke that names +/// it draws the brush [`find`] gave it before. +fn number_repeated_names(lib: &mut [Brush]) { + let mut seen = std::collections::HashSet::new(); + if lib.iter().all(|b| seen.insert(b.name.as_str())) { + return; + } + let mut taken: std::collections::HashSet = lib.iter().map(|b| b.name.clone()).collect(); + let mut kept = std::collections::HashSet::new(); + // The next number to try for each repeated name, so the brushes of one name try each number + // once between them. + let mut next: std::collections::HashMap = std::collections::HashMap::new(); + for b in lib.iter_mut() { + if kept.insert(b.name.clone()) { + continue; + } + let from = next.entry(b.name.clone()).or_insert(2); + // `taken` is finite, so one of the numbers is free. + if let Some((i, name)) = (*from..).map(|i| (i, format!("{} {i}", b.name))).find(|(_, n)| !taken.contains(n)) { + *from = i.saturating_add(1); + taken.insert(name.clone()); + kept.insert(name.clone()); + b.name = name; + } + } } /// Store `lib` as the document's brush library. diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index affd2fa6b7..2d5156d162 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -945,6 +945,34 @@ fn library_defaults_store_and_find() { assert_eq!(unique_name(&lib, "Arrow"), "Arrow 2"); } +/// A stored library that repeats a name reads with the first brush of that name as stored and each +/// later one numbered, past every name the library already has; strokes of the name keep drawing +/// the first brush. +#[test] +fn repeated_brush_names_are_numbered_when_read() { + let mut d = Document::new(100.0, 100.0); + let named = |name: &str, size: f64| Brush { name: name.into(), ..calli(0.0, 100.0, size) }; + store(&mut d, &[named("Nib", 1.0), named("Nib 2", 2.0), named("Nib", 3.0), named("Other", 4.0), named("Nib", 5.0), named("Nib 2", 6.0)]); + let lib = library(&d); + let names: Vec<&str> = lib.iter().map(|b| b.name.as_str()).collect(); + assert_eq!(names, ["Nib", "Nib 2", "Nib 3", "Other", "Nib 4", "Nib 2 2"]); + let size = |b: &Brush| if let BrushKind::Calligraphic(c) = &b.kind { c.size } else { 0.0 }; + assert_eq!(lib.iter().map(size).collect::>(), [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + assert_eq!(find(&d, "Nib").map(|b| size(&b)), Some(1.0)); + assert_eq!(find(&d, "Nib 3").map(|b| size(&b)), Some(3.0)); + // Reading again gives the same names, and the numbered library stored again reads as stored. + assert_eq!(library(&d), lib); + store(&mut d, &lib); + assert_eq!(library(&d), lib); + // Many brushes of one name take the numbers in order, past the numbered names the file has. + let many: Vec = (0..5000).map(|i| named(if i == 1 { "Nib 3" } else { "Nib" }, 1.0)).collect(); + store(&mut d, &many); + let names: Vec = library(&d).into_iter().map(|b| b.name).collect(); + assert_eq!(names[..5], ["Nib", "Nib 3", "Nib 2", "Nib 4", "Nib 5"]); + assert_eq!(names.last().map(String::as_str), Some("Nib 5000")); + assert_eq!(names.iter().collect::>().len(), names.len()); +} + #[test] fn brushes_serde_round_trip() { let lib = defaults().to_vec(); diff --git a/crates/engine/src/tests/brushlib.rs b/crates/engine/src/tests/brushlib.rs index 2d363d18cf..98a577abed 100644 --- a/crates/engine/src/tests/brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -721,3 +721,24 @@ fn other_documents_brushes_load_and_unreadable_files_are_errors() { let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); assert_eq!(vectorcraft_brush::library(&vectorcraft_format::load(&bytes).unwrap()).len(), 1); } + +/// A library file whose brushes repeat a name loads with the later brush numbered, and each one +/// can be added to a document by its own name. +#[test] +fn a_library_file_that_repeats_a_brush_name_loads_with_the_later_one_numbered() { + use vectorcraft_brush::{Brush, Calligraphic}; + let nib = |size: f64| Brush { name: "Nib".into(), kind: BrushKind::Calligraphic(Calligraphic { size, ..Default::default() }) }; + let mut file = vectorcraft_doc::Document::new(100.0, 100.0); + vectorcraft_brush::store(&mut file, &[nib(1.0), nib(2.0)]); + let b64 = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&file)); + let mut s = session(); + let r = run(&mut s, "brush.loadLibrary", json!({"dataBase64": b64, "name": "Nibs.vectorcraft"})); + let library = r["library"].as_str().unwrap().to_string(); + let listed = run(&mut s, "brush.library", json!({ "library": library })); + let names: Vec<&str> = listed["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Nib", "Nib 2"]); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": library, "name": "Nib 2"})); + assert_eq!(strs(&r["added"]), ["Nib 2"]); + let size = |b: Brush| if let BrushKind::Calligraphic(c) = b.kind { c.size } else { 0.0 }; + assert_eq!(size(brush(&s, "Nib 2")), 2.0); +} diff --git a/crates/engine/src/tests/brushsym.rs b/crates/engine/src/tests/brushsym.rs index 1a1fcd87b5..e961a59e81 100644 --- a/crates/engine/src/tests/brushsym.rs +++ b/crates/engine/src/tests/brushsym.rs @@ -631,6 +631,37 @@ fn brush_options_rename_duplicate_and_delete_update_strokes() { assert!(s.execute("brush.delete", &json!({"name": "5 pt. Round"})).is_err()); } +/// A document whose brushes repeat a name opens with the later brush numbered: strokes of the name +/// keep the first brush, and renaming or deleting one brush leaves the other and its strokes alone. +#[test] +fn a_repeated_brush_name_is_numbered_when_the_document_opens() { + use vectorcraft_brush::{Brush, BrushKind, Calligraphic}; + let nib = |size: f64| Brush { name: "Nib".into(), kind: BrushKind::Calligraphic(Calligraphic { size, ..Default::default() }) }; + let mut s = session(); + vectorcraft_brush::store(std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc), &[nib(1.0), nib(2.0)]); + let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0); + s.execute("brush.apply", &json!({"name": "Nib", "ids": [a.0]})).unwrap(); + let saved = s.execute("document.serialize", &json!({})).unwrap()["dataBase64"].clone(); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": saved})).unwrap(); + let names = |o: &mut Session| -> Vec { + let list = o.execute("brush.list", &json!({})).unwrap(); + list["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap().to_string()).collect() + }; + assert_eq!(names(&mut o), ["Nib", "Nib 2"]); + assert_eq!(brush_of(&o, a).as_deref(), Some("Nib")); + assert_eq!(o.execute("brush.get", &json!({"name": "Nib"})).unwrap()["size"], json!(1.0)); + assert_eq!(o.execute("brush.get", &json!({"name": "Nib 2"})).unwrap()["size"], json!(2.0)); + let b = rect(&mut o, 80.0, 10.0, 50.0, 50.0); + o.execute("brush.apply", &json!({"name": "Nib 2", "ids": [b.0]})).unwrap(); + o.execute("brush.options", &json!({"name": "Nib", "newName": "Pen"})).unwrap(); + assert_eq!(names(&mut o), ["Pen", "Nib 2"]); + assert_eq!((brush_of(&o, a).as_deref(), brush_of(&o, b).as_deref()), (Some("Pen"), Some("Nib 2"))); + o.execute("brush.delete", &json!({"name": "Nib 2"})).unwrap(); + assert_eq!(names(&mut o), ["Pen"]); + assert_eq!((brush_of(&o, a).as_deref(), brush_of(&o, b)), (Some("Pen"), None)); +} + /// `brush.move`, which the Brushes panel runs when a brush is dragged, reorders brushes among those of /// their type in one undo step and refuses to move one into another type. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 59bb3c78ad..b1621d035a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2948,7 +2948,10 @@ already walks Image Trace. Short agent entry points: is unused, and offers Remove Strokes or Cancel when one of them is in use. - Brushes: `brush.list`, `brush.get {name}`, `brush.apply {name, ids?}`, `brush.new {type, name?, params?, ids?}`, `brush.setCurrent {name}`, `brush.options {name, params?, newName?}`, `brush.delete {name} | {names}`, - `brush.duplicate {name, newName?} | {names}`, `brush.freehand`, `object.expandBrush {ids?}`. `brush.new`, + `brush.duplicate {name, newName?} | {names}`, `brush.freehand`, `object.expandBrush {ids?}`. A document or brush + library file whose brushes repeat a name is read with the first brush of that name as stored and each later one + numbered with the first "`` 2", "`` 3"… the file does not have; strokes of the name keep the first + brush. `brush.new`, `brush.duplicate` and `brush.options` trim the white space around the name they are given. A blank `name` gives the type's default name ("New Calligraphic Brush" for a calligraphic brush), and a blank `newName` gives "`` copy" in `brush.duplicate` and keeps the name in `brush.options`. A Hue Shift `colorization` given to diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7044797d5e..45778d476f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -715,6 +715,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **New Document profiles:** each built-in category's presets start documents from that category's profile, a native document holding the starting swatches, graphic styles, brushes, symbols and other assets, the ruler and Document Setup options and the print settings ([Create a custom new document profile](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/create-a-custom-new-document-profile.html)). New Document's Manage Profiles pop-up opens a profile to edit and resets one to the shipped profile (`file.newProfiles`, `file.newProfiles.edit`, `file.newProfiles.reset`; `file.new {profile}`). The profiles live in VectorCraft's own `New Document Profiles` folder next to the preferences; a profile that cannot be read or is larger than 64 MiB leaves the defaults and is reported. Tests: `tests::newdoc::an_edited_profile_starts_the_documents_of_its_category`, `the_profile_follows_the_preset_access_and_reset`, `a_damaged_or_oversized_profile_leaves_the_defaults_and_is_reported`, `profile_names_are_checked_and_editing_needs_the_folder`, `dialogs::new_document::tests::manage_profiles_edits_and_resets_the_profiles`. - **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy). - **Names of new and renamed artboards, brushes and patterns:** `artboard.new` and `artboard.setProps` trim the white space around the name they are given, as the Artboards panel's rename does, and a blank name gives the artboard its default name, Artboard N for the Nth artboard. `brush.new`, `brush.duplicate`, `brush.options`, `object.pattern.make`, `object.pattern.saveCopy` and `pattern.options` trim the name as the Brush Options dialog and the Pattern Options panel do, and compare the trimmed name with the names there are; a blank name gives the name each command gives when there is none, and Brush Options and Pattern Options keep the name. Tests in `tests/panelcmds.rs`, `tests/brushsym.rs` and `tests/pattern.rs` cover each command. Public references: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html), [Brushes](https://web.archive.org/web/20251018101754/https://helpx.adobe.com/illustrator/using/brushes.html) and [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copies). +- **Repeated brush names in a file:** a document or brush library file whose brushes repeat a name opens with the first brush of that name as stored and each later one numbered, as "Name 2", "Name 3" and so on, skipping names the file already has. Strokes that name the brush keep drawing the first one, and `brush.list`, `brush.options`, `brush.delete`, `brush.library` and the Brushes panel then reach each brush by its own name. This numbering is VectorCraft's own rule. Tests in `crates/brush/src/tests.rs`, `tests/brushsym.rs` and `tests/brushlib.rs` cover it. - **Pattern names and swatch names:** `pattern.options` refuses a new name that another pattern, a swatch or a color group has. It takes a name that only the pattern's own swatches have. The reference page does not say what happens when the new name is taken; the refusal follows VectorCraft's rule that swatches and color groups share one namespace (`Document::swatch_name_taken`), which `swatch.new` and `swatch.edit` also follow. A rename gives the new name only to the pattern's swatches that had the old one; any other swatch with the old name keeps it, and art linked to that swatch stays linked. `object.pattern.make` and `object.pattern.saveCopy` number a color group's name as they number a swatch's. Tests in `tests/pattern.rs` cover each command. Public reference: [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copy). - **Cancel, Delete and rename leave no paint of a missing pattern:** the help page lists Save a Copy, Done and Cancel for leaving pattern editing (https://helpx.adobe.com/illustrator/using/create-edit-patterns.html). Canceling a pattern made for the edit removes it with every swatch of it, a color group's included. Canceling a new pattern, `pattern.delete` and a `pattern.options` rename reach every paint of the pattern: art outside the editing layer, opacity mask art, type runs, symbol definitions, graph designs, the series and label paints a graph keeps for its next redraw, other patterns' tiles (a copy Save a Copy made included), graphic and character styles, brush art, and the fill, stroke and appearance new art takes. Canceling a new pattern and Delete make those paints no paint, a VectorCraft rule; a rename renames them. Canceling the edit of an existing pattern puts its definition back as it was when editing started; after a rename in Pattern Options, it also gives the pattern its old name back with its swatches and paints, unless another pattern, swatch or color group has taken that name. Undo and Redo restore new art's paints with the document. Tests in `tests/pattern.rs` cover each command. - **A clip group in Properties:** with one clip group selected, the panel has Edit Clipping Path and Edit Contents at its top, under the Clip Group heading, which select the clipping path or the clipped art for the selection tools to transform alone, as Object › Clipping Mask does (: with a clip group selected, the tutorial uses Edit Clipping Path at the top of the Properties panel to select the mask for editing, and uses Edit Contents to select the masked art). Edit Contents shows only when the group clips something. Not checked against the reference app: whether Properties keeps the buttons while the clipping path or the contents is selected; the Control bar has no clip-group buttons here. Evidence: `panels::properties::tests::one_clip_group_has_edit_clipping_path_and_edit_contents_at_the_top`. From 19be0bb880fed5fa7557a4ee54d4b1532ccfad72 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 10:17:33 -0800 Subject: [PATCH 08/14] Type: inline graphics in symbol, mask and pattern art follow their symbols Type with an inline graphic was resolved only in the layers, so type in the art of another symbol, a graph design, a pattern tile or an opacity mask kept the size its graphic had when it was made. The symbol holding that type, its instances and the inline graphics that show it were measured from the stale layout. - Inline graphics are resolved in every tree that holds type. Symbols are resolved in dependency order, so a symbol's type is laid out before the type that shows that symbol inline. Edits, opening, reverting and Refresh Font List use the same pass. - The pass visits each object once: it resolves the children first and copies a node only when something below it changed. It measures each symbol's art once per pass. - An inline graphic of a symbol whose art shows that symbol inline, directly or through other symbols, is an empty slot. The symbols on such a cycle are found once and cached until the symbols change. - A changed inline graphic also clears the text's cached baselines. --- crates/doc/src/document.rs | 2 + crates/doc/src/inline.rs | 474 ++++++++++++++++++++++--- crates/doc/src/lib.rs | 4 + crates/engine/src/cmd/fonts.rs | 34 +- crates/engine/src/cmd/inline.rs | 12 +- crates/engine/src/session/documents.rs | 12 +- crates/engine/src/tests/inline.rs | 160 ++++++++- docs/target-app-parity.md | 1 + 8 files changed, 609 insertions(+), 90 deletions(-) diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 057a9df6a6..418e454b92 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -78,6 +78,7 @@ impl Document { units_from_file: _, index: _, guide_cache: _, + inline_cycles: _, } = self; *version == other.version && *title == other.title @@ -262,6 +263,7 @@ impl Document { units_from_file: false, index: Default::default(), guide_cache: Default::default(), + inline_cycles: Default::default(), }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); diff --git a/crates/doc/src/inline.rs b/crates/doc/src/inline.rs index c5bf1972d8..a8e3955015 100644 --- a/crates/doc/src/inline.rs +++ b/crates/doc/src/inline.rs @@ -2,10 +2,13 @@ //! from the document's symbols, which the text layout needs to size and place it. use std::borrow::Cow; +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, RwLock, Weak}; use vectorcraft_geom::{Affine, Rect}; -use crate::{Document, NodeId, NodeKind, TextObject}; +use crate::{Document, Node, NodeId, NodeKind, Symbol, TextObject}; /// The document key under which the app records each symbol's natural size (`{name: [w, h]}`). pub const SYMBOL_SIZES: &str = "symbolSizes"; @@ -39,9 +42,27 @@ impl Document { usable(Some(self.symbol_natural_xf(name).transform_rect_bbox(b))) } + /// The art bounds an inline graphic of the symbol named `name` takes: [`Self::symbol_art_bounds`], + /// or `None` (an empty slot) when the symbol's art shows the symbol itself inline, directly or + /// through the inline graphics in other symbols' art, so that its size would depend on itself. + pub fn inline_art_bounds(&self, name: &str) -> Option { + if self.inline_cycles().contains(name) { None } else { self.symbol_art_bounds(name) } + } + + /// The names of the symbols that reach themselves through the inline graphics in symbol art, + /// found once while the symbols stay as they are. + fn inline_cycles(&self) -> Arc> { + if let Some(found) = self.inline_cycles.get(&self.symbols) { + return found; + } + let found = Arc::new(InlineGraph::of(self).cyclic); + self.inline_cycles.set(&self.symbols, &found); + found + } + /// Does `t` have inline graphics whose resolved bounds differ from the document's symbols? fn inline_stale(&self, t: &TextObject) -> bool { - t.runs.iter().filter_map(|r| r.inline.as_ref()).any(|a| a.bounds != self.symbol_art_bounds(&a.symbol)) + stale_by(t, &|n| self.inline_art_bounds(n)) } /// `t` with its inline graphics' art bounds resolved from this document's symbols (borrowed @@ -56,54 +77,421 @@ impl Document { Cow::Owned(t) } - /// Fill in the art bounds of `t`'s inline graphics; returns whether any changed (the text's - /// cached layout bounds are then cleared). + /// Fill in the art bounds of `t`'s inline graphics ([`Self::inline_art_bounds`]); returns + /// whether any changed (the text's cached layout is then cleared). pub fn resolve_text_inline(&self, t: &mut TextObject) -> bool { - let mut changed = false; - for r in &mut t.runs { - if let Some(a) = &mut r.inline { - let b = self.symbol_art_bounds(&a.symbol); - if a.bounds != b { - a.bounds = b; - changed = true; - } - } - } - if changed { - t.cached_bounds = None; - t.cached_ink = None; - } - changed + resolve_runs(t, &|n| self.inline_art_bounds(n)) } - /// Resolve the inline graphics of every text object in the layers (after opening a document - /// or changing its symbols). Returns the ids of the texts that changed: their cached layout - /// bounds were cleared and need recomputing. + /// Resolve the inline graphics of every text object in the document (after opening a document + /// or changing its symbols), without laying the type out again: see + /// [`Self::resolve_inline_art_with`]. pub fn resolve_inline_art(&mut self) -> Vec { - let mut stale = vec![]; - self.walk(|n| { - if let NodeKind::Text(t) = &n.kind - && t.runs.iter().any(|r| r.inline.is_some()) - && self.inline_stale(t) - { - stale.push(n.id); - } - }); + self.resolve_inline_art_with(&mut |_| {}) + } + + /// Resolve the inline graphics of every text object in the document: in the layers, the art + /// of symbols, graph designs and pattern tiles, and the opacity masks and live objects in + /// them. Each text whose inline graphics changed has its cached layout cleared and is passed + /// to `layout` to be laid out again. The symbols are resolved first, each after the symbols its + /// inline graphics show, so a symbol's art is measured with its type already laid out; a + /// symbol that shows itself inline ([`Self::inline_art_bounds`]) is shown as an empty slot. + /// Returns the ids of the texts in the layers that changed. + pub fn resolve_inline_art_with(&mut self, layout: &mut dyn FnMut(&mut TextObject)) -> Vec { let mut out = vec![]; - for id in stale { - let Some(mut t) = self.node(id).and_then(|n| match &n.kind { - NodeKind::Text(t) => Some(t.clone()), - _ => None, - }) else { - continue; - }; - if self.resolve_text_inline(&mut t) - && let Some(n) = self.node_mut(id) - { - n.kind = NodeKind::Text(t); - out.push(id); + let mut unused = vec![]; + let graph = InlineGraph::of(self); + let measured = Measured::default(); + for &i in &graph.order { + let mut art = self.symbols[i].art.clone(); + if resolve_tree(&mut art, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut unused) { + self.symbols[i].art = art; + } + } + for i in 0..self.layers.len() { + let mut layer = self.layers[i].clone(); + if resolve_tree(&mut layer, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut out) { + self.layers[i] = layer; + } + } + for i in 0..self.graph_designs.len() { + let mut art = self.graph_designs[i].art.clone(); + if resolve_tree(&mut art, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut unused) { + self.graph_designs[i].art = art; + } + } + for i in 0..self.patterns.len() { + for j in 0..self.patterns[i].art.len() { + let mut art = self.patterns[i].art[j].clone(); + if resolve_tree(&mut art, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut unused) { + self.patterns[i].art[j] = art; + } } } out } } + +/// Whether `t` has inline graphics whose bounds differ from what `bounds` gives their symbols. +fn stale_by(t: &TextObject, bounds: &dyn Fn(&str) -> Option) -> bool { + t.runs.iter().filter_map(|r| r.inline.as_ref()).any(|a| a.bounds != bounds(&a.symbol)) +} + +/// Gives `t`'s inline graphics the bounds `bounds` gives their symbols; returns whether any changed +/// (the text's cached layout is then cleared). +fn resolve_runs(t: &mut TextObject, bounds: &dyn Fn(&str) -> Option) -> bool { + let mut changed = false; + for a in t.runs.iter_mut().filter_map(|r| r.inline.as_mut()) { + let b = bounds(&a.symbol); + if a.bounds != b { + a.bounds = b; + changed = true; + } + } + if changed { + t.cached_bounds = None; + t.cached_baselines.clear(); + t.cached_ink = None; + } + changed +} + +/// Resolves the inline graphics of the type in tree `n` (opacity mask art included) with +/// `bounds`, passing each text that changed to `layout` and adding its id to `changed`; returns +/// whether the tree changed. Each node is visited once, and only the nodes on the way to changed +/// type are copied. +fn resolve_tree( + n: &mut Arc, + bounds: &dyn Fn(&str) -> Option, + layout: &mut dyn FnMut(&mut TextObject), + changed: &mut Vec, +) -> bool { + let mut copied = false; + if matches!(&n.kind, NodeKind::Text(t) if stale_by(t, bounds)) { + let node = Arc::make_mut(n); + if let NodeKind::Text(t) = &mut node.kind + && resolve_runs(t, bounds) + { + layout(t); + changed.push(node.id); + } + copied = true; + } + let mask = n.extras().mask.as_deref().and_then(|m| { + let mut art = m.art.clone(); + resolve_tree(&mut art, bounds, layout, changed).then_some(art) + }); + let mut kids = vec![]; + for (i, c) in n.children().into_iter().flatten().enumerate() { + let mut c = c.clone(); + if resolve_tree(&mut c, bounds, layout, changed) { + kids.push((i, c)); + } + } + if mask.is_none() && kids.is_empty() { + return copied; + } + let node = Arc::make_mut(n); + if let Some(art) = mask + && let Some(m) = node.extras_if_any().and_then(|e| e.mask.as_deref_mut()) + { + m.art = art; + } + if let Some(ch) = node.children_mut() { + for (i, c) in kids { + ch[i] = c; + } + } + true +} + +/// The art bounds of each symbol, measured once in a pass of [`Document::resolve_inline_art_with`]. +/// A symbol is first asked for after its own type is resolved (the pass goes in +/// [`InlineGraph::order`]), so its measurement stays current for the rest of the pass. +#[derive(Default)] +struct Measured(RefCell>>); + +impl Measured { + /// The bounds kept for `name`, or `measure`'s, kept for later. + fn get(&self, name: &str, measure: impl FnOnce() -> Option) -> Option { + if let Some(&b) = self.0.borrow().get(name) { + return b; + } + let b = measure(); + self.0.borrow_mut().insert(name.to_owned(), b); + b + } +} + +/// The names of the symbols the inline graphics in tree `n` show (opacity mask art included). +fn inline_symbols(n: &Node) -> Vec { + fn collect(n: &Node, out: &mut Vec) { + if let NodeKind::Text(t) = &n.kind { + out.extend(t.runs.iter().filter_map(|r| r.inline.as_ref().map(|a| a.symbol.clone()))); + } + if let Some(m) = n.extras().mask.as_deref() { + collect(&m.art, out); + } + for c in n.children().into_iter().flatten() { + collect(c, out); + } + } + let mut out = vec![]; + collect(n, &mut out); + out +} + +/// Each symbol name of `d` → the index of the first symbol of that name (the one a name finds). +fn symbol_index(d: &Document) -> HashMap<&str, usize> { + let mut index = HashMap::with_capacity(d.symbols.len()); + for (i, s) in d.symbols.iter().enumerate() { + index.entry(s.name.as_str()).or_insert(i); + } + index +} + +/// The symbols of a document as their inline graphics depend on each other. +struct InlineGraph { + /// Every symbol index, each after the symbols its art shows inline (except along a cycle). + order: Vec, + /// The names of the symbols that reach themselves through inline graphics. + cyclic: HashSet, +} + +impl InlineGraph { + /// The graph of `d`'s symbols: each symbol's art is walked once, and the order and cycles are + /// found in time linear in the symbols and their inline references (Tarjan's strongly + /// connected components, without recursion). + fn of(d: &Document) -> Self { + let index = symbol_index(d); + let edges: Vec> = d + .symbols + .iter() + .map(|s| { + let mut e: Vec = inline_symbols(&s.art).iter().filter_map(|n| index.get(n.as_str()).copied()).collect(); + e.sort_unstable(); + e.dedup(); + e + }) + .collect(); + let (order, cyclic) = components(&edges); + let cyclic = order.iter().filter(|&&i| cyclic[i]).map(|&i| d.symbols[i].name.clone()).collect(); + Self { order, cyclic } + } + + /// [`Document::inline_art_bounds`] with the cycles found in this graph. + fn bounds(&self, d: &Document, name: &str) -> Option { + if self.cyclic.contains(name) { None } else { d.symbol_art_bounds(name) } + } +} + +/// The nodes of the graph `edges` (node → the nodes it depends on), each after the nodes it +/// depends on except within a cycle, and whether each node is on a cycle (a component of more than +/// one node, or an edge to itself). +fn components(edges: &[Vec]) -> (Vec, Vec) { + const UNSEEN: usize = usize::MAX; + let n = edges.len(); + let (mut index, mut low, mut on_stack) = (vec![UNSEEN; n], vec![0; n], vec![false; n]); + let (mut stack, mut order, mut cyclic) = (vec![], Vec::with_capacity(n), vec![false; n]); + let mut next = 0; + // The nodes being visited, each with the position of its next edge. + let mut calls: Vec<(usize, usize)> = vec![]; + for root in 0..n { + if index[root] != UNSEEN { + continue; + } + index[root] = next; + low[root] = next; + next += 1; + stack.push(root); + on_stack[root] = true; + calls.push((root, 0)); + while let Some(top) = calls.last_mut() { + let v = top.0; + if let Some(&w) = edges[v].get(top.1) { + top.1 += 1; + if index[w] == UNSEEN { + index[w] = next; + low[w] = next; + next += 1; + stack.push(w); + on_stack[w] = true; + calls.push((w, 0)); + } else if on_stack[w] { + low[v] = low[v].min(index[w]); + } + continue; + } + calls.pop(); + if let Some(&(u, _)) = calls.last() { + low[u] = low[u].min(low[v]); + } + if low[v] == index[v] { + let start = order.len(); + while let Some(w) = stack.pop() { + on_stack[w] = false; + order.push(w); + if w == v { + break; + } + } + if order.len() - start > 1 || edges[v].contains(&v) { + for &w in &order[start..] { + cyclic[w] = true; + } + } + } + } + } + (order, cyclic) +} + +/// The symbols [`Document::inline_cycles`] last found, with the name of each symbol and a weak +/// pointer to its art. While a weak pointer to an art's allocation is held, nothing else can be at +/// that address, and the art can't be changed in place (`Arc::get_mut` refuses it and +/// `Arc::make_mut` moves it), so any edit inside a symbol's art, and any symbol added, removed, +/// renamed or replaced, gives symbols the cache does not match. Clones of a document share it. +#[derive(Default)] +pub(crate) struct InlineCycles(RwLock>>); + +/// The name and art of each symbol the cycles were found in, and the names of the symbols on one. +type Found = (Vec<(String, Weak)>, Arc>); + +impl InlineCycles { + /// The cached names, when they were found in `symbols`. + fn get(&self, symbols: &[Symbol]) -> Option>> { + let g = self.0.read().ok()?; + let (from, found) = &**g.as_ref()?; + let same = from.len() == symbols.len() + && from.iter().zip(symbols).all(|((name, art), s)| *name == s.name && std::ptr::eq(art.as_ptr(), Arc::as_ptr(&s.art))); + same.then(|| found.clone()) + } + + /// Keep `found`, found in `symbols`. + fn set(&self, symbols: &[Symbol], found: &Arc>) { + if let Ok(mut g) = self.0.write() { + *g = Some(Arc::new((symbols.iter().map(|s| (s.name.clone(), Arc::downgrade(&s.art))).collect(), found.clone()))); + } + } +} + +impl Clone for InlineCycles { + fn clone(&self) -> Self { + InlineCycles(RwLock::new(self.0.read().ok().and_then(|g| g.clone()))) + } +} + +/// A cache, not document content: two documents are equal whatever their caches hold. +impl PartialEq for InlineCycles { + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl std::fmt::Debug for InlineCycles { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("InlineCycles") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The position of each node in `order`. + fn positions(order: &[usize]) -> Vec { + let mut at = vec![0; order.len()]; + for (p, &i) in order.iter().enumerate() { + at[i] = p; + } + at + } + + #[test] + fn components_put_dependencies_first_and_find_every_cycle_member() { + // 0 → 1 → 2 → 0 is a cycle, and 3, reached from 1, joins it through 2. + // 4 depends on 0 and is on no cycle; 5 shows itself. + let edges = vec![vec![1], vec![2, 3], vec![0], vec![2], vec![0], vec![5]]; + let (order, cyclic) = components(&edges); + let mut sorted = order.clone(); + sorted.sort_unstable(); + assert_eq!(sorted, [0, 1, 2, 3, 4, 5]); + assert_eq!(cyclic, [true, true, true, true, false, true]); + let at = positions(&order); + assert!(at[4] > at[0], "{order:?}"); + // A chain: each node after the one it depends on. + let (order, cyclic) = components(&[vec![], vec![0], vec![1]]); + assert_eq!(order, [0, 1, 2]); + assert!(cyclic.iter().all(|c| !c)); + } + + /// Type at the bottom of 2,000 nested groups is checked once, not once for each group above it, + /// and only the groups on the way to it are copied. + #[test] + fn resolving_deeply_nested_type_visits_each_node_once() { + use std::cell::Cell; + use vectorcraft_geom::Point; + + let mut t = TextObject::point(Point::ZERO, "Type", crate::CharStyle::default()); + t.runs.push(crate::TextRun::new("", crate::CharStyle::default())); + t.runs[1].inline = Some(crate::InlineArt { symbol: "S".into(), scale: 1.0, baseline_shift: 0.0, bounds: None }); + let depth = 2_000; + let mut tree = Arc::new(Node::new(NodeId(0), NodeKind::Text(Box::new(t)))); + for i in 1..=depth { + let side = Arc::new(Node::group(NodeId(depth + i), vec![])); + tree = Arc::new(Node::group(NodeId(i), vec![side, tree])); + } + let before = tree.clone(); + let asked = Cell::new(0); + let r = Rect::new(0.0, 0.0, 10.0, 20.0); + let bounds = |_: &str| { + asked.set(asked.get() + 1); + Some(r) + }; + let (mut laid_out, mut changed) = (0, vec![]); + assert!(resolve_tree(&mut tree, &bounds, &mut |_| laid_out += 1, &mut changed)); + assert!(asked.get() <= 2, "bounds asked {} times for one inline graphic", asked.get()); + assert_eq!(laid_out, 1); + assert_eq!(changed, [NodeId(0)]); + // The bottom of the new tree holds the resolved type; the side groups are shared. + let (mut new, mut old) = (&tree, &before); + while let (Some(n), Some(o)) = (new.children(), old.children()) { + assert!(Arc::ptr_eq(&n[0], &o[0])); + (new, old) = (&n[1], &o[1]); + } + let NodeKind::Text(t) = &new.kind else { panic!("the type is at the bottom") }; + assert_eq!(t.runs[1].inline.as_ref().and_then(|a| a.bounds), Some(r)); + // Nothing left to change: the tree is kept and the type is checked once. + asked.set(0); + let kept = tree.clone(); + assert!(!resolve_tree(&mut tree, &bounds, &mut |_| laid_out += 1, &mut changed)); + assert!(Arc::ptr_eq(&tree, &kept)); + assert_eq!(asked.get(), 1); + } + + #[test] + fn a_symbol_is_measured_once_in_a_pass() { + let measured = Measured::default(); + let count = std::cell::Cell::new(0); + let measure = |b: Option| { + count.set(count.get() + 1); + b + }; + let r = Some(Rect::new(0.0, 0.0, 1.0, 1.0)); + for _ in 0..3 { + assert_eq!(measured.get("A", || measure(r)), r); + assert_eq!(measured.get("Missing", || measure(None)), None); + } + assert_eq!(count.get(), 2); + } + + #[test] + fn components_handle_a_long_chain_without_recursion() { + let n = 200_000; + let edges: Vec> = (0..n).map(|i| if i + 1 < n { vec![i + 1] } else { vec![] }).collect(); + let (order, cyclic) = components(&edges); + assert_eq!(order.first(), Some(&(n - 1))); + assert_eq!(order.last(), Some(&0)); + assert!(cyclic.iter().all(|c| !c)); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 63a2676f77..e6986ad00e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -362,6 +362,10 @@ pub struct Document { /// The ruler guides as [`Document::ruler_guides`] lists them: a cache, never saved. #[serde(skip)] guide_cache: document_guides::GuideCache, + /// The symbols that show themselves inline ([`Document::inline_art_bounds`]): a cache, never + /// saved. + #[serde(skip)] + inline_cycles: inline::InlineCycles, } fn ppi72() -> f64 { diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 005427ded8..7c8f664cf3 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -1136,37 +1136,6 @@ fn font_list(_: &mut Session, p: &Value) -> Result { } } -/// Gives the inline graphics in the type of [`art_texts_of`] the current bounds of their symbols' -/// art, and lays out again the type whose inline graphics changed. A symbol drawn inline in the -/// art of another symbol takes one more round, so there is at most one round per symbol. -fn resolve_art_inline(doc: &mut Document) { - for _ in 0..=doc.symbols.len() { - let names: BTreeSet = - art_texts_of(doc).iter().flat_map(|(_, t, _)| t.runs.iter().filter_map(|r| r.inline.as_ref().map(|a| a.symbol.clone()))).collect(); - let bounds: BTreeMap> = names - .into_iter() - .map(|name| { - let b = doc.symbol_art_bounds(&name); - (name, b) - }) - .collect(); - let stale = |t: &TextObject| t.runs.iter().any(|r| r.inline.as_ref().is_some_and(|a| bounds.get(&a.symbol).is_some_and(|b| *b != a.bounds))); - let mut changed = false; - edit_art_texts_of(doc, &stale, &mut |t| { - for a in t.runs.iter_mut().filter_map(|r| r.inline.as_mut()) { - if let Some(b) = bounds.get(&a.symbol) { - a.bounds = *b; - } - } - refresh_bounds(t); - changed = true; - }); - if !changed { - break; - } - } -} - pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result { let db = vectorcraft_text::FontDb::global(); #[cfg(not(target_arch = "wasm32"))] @@ -1188,8 +1157,7 @@ pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result { d.change_unsaved(|doc| { let doc = std::sync::Arc::make_mut(doc); edit_art_texts_of(doc, &|_| true, &mut refresh_bounds); - resolve_art_inline(doc); - doc.resolve_inline_art(); + doc.resolve_inline_art_with(&mut refresh_bounds); for id in texts { if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { refresh_bounds(t); diff --git a/crates/engine/src/cmd/inline.rs b/crates/engine/src/cmd/inline.rs index 14264fc809..db3e929952 100644 --- a/crates/engine/src/cmd/inline.rs +++ b/crates/engine/src/cmd/inline.rs @@ -22,16 +22,14 @@ pub fn specs() -> Vec { } /// Inline graphics follow their symbols: after an edit of a document that has (or had) symbols, -/// resolve the inline art of every text again (only texts whose art changed are touched). +/// resolve the inline art of every text again, in the layers and in the art of symbols, graph +/// designs, pattern tiles and opacity masks, and lay out again the texts whose art changed (a +/// symbol's type before the type that shows that symbol inline). pub(crate) fn refresh(before: &Document, d: &mut Document) { if before.symbols.is_empty() && d.symbols.is_empty() { return; } - for id in d.resolve_inline_art() { - if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { - refresh_bounds(t); - } - } + d.resolve_inline_art_with(&mut refresh_bounds); } fn insert_inline(s: &mut Session, p: &Value) -> Result { @@ -85,7 +83,7 @@ fn insert_inline(s: &mut Session, p: &Value) -> Result { } let caret = s.edit("Insert Inline Symbol", |d, _| { let mut art = InlineArt { scale, baseline_shift: shift, ..InlineArt::new(symbol.clone()) }; - art.bounds = d.symbol_art_bounds(&symbol); + art.bounds = d.inline_art_bounds(&symbol); let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; let style = edit::insertion_style(&t.runs, a, b); let caret = edit::replace_range_styled(&mut t.runs, a, b, &[TextRun::inline(art, style)]); diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 0d472ef81d..b9af6dadf5 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -270,6 +270,9 @@ impl Session { for tree in text_trees_mut(doc) { each_text_where(tree, &mut uncached, &mut cmd::typecmd::refresh_bounds_for_first_frame); } + // The symbols' type is measured now: the inline graphics showing those symbols take their + // art's measured size, a symbol's type before the type that shows that symbol inline. + doc.resolve_inline_art_with(&mut cmd::typecmd::refresh_bounds_for_first_frame); } /// Measure the type in open document `index` that `stale` picks (in its trees as @@ -287,12 +290,9 @@ impl Session { each_text_where(tree, &mut stale, &mut cmd::typecmd::refresh_bounds_for_first_frame); } // Inline graphics take their size from their symbols' art, whose type may just have - // been measured again: the text holding one whose size changed is measured again too. - if !doc.resolve_inline_art().is_empty() { - for tree in text_trees_mut(doc) { - each_text_where(tree, &mut |t| t.cached_bounds.is_none(), &mut cmd::typecmd::refresh_bounds_for_first_frame); - } - } + // been measured again: the text holding one whose size changed is measured again too, + // a symbol's type before the type that shows that symbol inline. + doc.resolve_inline_art_with(&mut cmd::typecmd::refresh_bounds_for_first_frame); }); } diff --git a/crates/engine/src/tests/inline.rs b/crates/engine/src/tests/inline.rs index 4b14b74be6..f7c72956ad 100644 --- a/crates/engine/src/tests/inline.rs +++ b/crates/engine/src/tests/inline.rs @@ -2,7 +2,7 @@ use serde_json::json; use vectorcraft_doc::text::INLINE_CHAR; -use vectorcraft_doc::{NodeId, NodeKind, TextObject}; +use vectorcraft_doc::{Node, NodeId, NodeKind, TextObject}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; use crate::*; @@ -167,6 +167,164 @@ fn inline_graphics_follow_their_symbol() { let _ = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap(); } +/// The first text in tree `n`, its opacity mask art included. +fn first_text(n: &Node) -> Option { + if let NodeKind::Text(t) = &n.kind { + return Some((**t).clone()); + } + if let Some(t) = n.extras().mask.as_deref().and_then(|m| first_text(&m.art)) { + return Some(t); + } + n.children().into_iter().flatten().find_map(|c| first_text(c)) +} + +/// The first text in the art of symbol `name`. +fn art_text(s: &Session, name: &str) -> TextObject { + let d = &s.doc().unwrap().doc; + first_text(&d.symbols.iter().find(|x| x.name == name).unwrap().art).unwrap() +} + +/// The bounds of the first inline graphic in `t`. +fn inline_bounds(t: &TextObject) -> Option { + t.runs.iter().find_map(|r| r.inline.as_ref()).unwrap().bounds +} + +/// Redefines symbol `name` as a 40 × 20 ellipse. +fn redefine_as_wide_ellipse(s: &mut Session, name: &str) { + let e = s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 40, "height": 20})).unwrap(); + s.execute("select.set", &json!({"ids": [e["id"]]})).unwrap(); + s.execute("symbol.update", &json!({"name": name})).unwrap(); +} + +/// A symbol made from type with an inline graphic: when the symbol the graphic shows changes, the +/// type in the symbol's art is laid out again, the symbol's art takes its new size, and an inline +/// graphic of that symbol follows. Undo, redo and opening a saved copy keep it so. +#[test] +fn inline_graphics_in_symbol_art_follow_their_symbol() { + let mut s = session(); + circle_symbol(&mut s, "Dot", 10.0); + let id = text(&mut s, "ab"); + s.execute("text.insertInline", &json!({"id": id.0, "at": 1, "symbol": "Dot", "scale": 1})).unwrap(); + s.execute("symbol.new", &json!({"name": "Holder", "ids": [id.0]})).unwrap(); + let line = text(&mut s, "x"); + s.execute("text.insertInline", &json!({"id": line.0, "at": 0, "symbol": "Holder", "scale": 1})).unwrap(); + let held = art_text(&s, "Holder"); + let w0 = held.cached_bounds.unwrap().width(); + let holder0 = s.doc().unwrap().doc.symbol_art_bounds("Holder").unwrap(); + redefine_as_wide_ellipse(&mut s, "Dot"); + let d = &s.doc().unwrap().doc; + let held = art_text(&s, "Holder"); + let b = inline_bounds(&held).unwrap(); + assert!((b.width() / b.height() - 2.0).abs() < 0.1, "{b:?}"); + assert_eq!(Some(b), d.symbol_art_bounds("Dot")); + let w1 = held.cached_bounds.unwrap().width(); + assert!((w1 - w0 - 20.0).abs() < 1.5, "{w0} → {w1}"); + assert!(held.cached_ink.is_some() && !held.cached_baselines.is_empty()); + let holder1 = d.symbol_art_bounds("Holder").unwrap(); + assert!(holder1.width() > holder0.width() + 1.0, "{holder0:?} → {holder1:?}"); + assert_eq!(inline_bounds(&obj(&s, line)), Some(holder1), "an inline graphic of the holder follows it"); + // Undo and redo. + s.execute("edit.undo", &json!({})).unwrap(); + assert!((art_text(&s, "Holder").cached_bounds.unwrap().width() - w0).abs() < 1e-6); + s.execute("edit.redo", &json!({})).unwrap(); + assert!((art_text(&s, "Holder").cached_bounds.unwrap().width() - w1).abs() < 1e-6); + // Opening a saved copy measures it the same way. + let doc = (*s.doc().unwrap().doc).clone(); + let mut back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); + back.title = "copy".into(); + let mut s2 = Session::new(); + s2.add_document(back, None); + let held2 = art_text(&s2, "Holder"); + assert_eq!(inline_bounds(&held2), s2.doc().unwrap().doc.symbol_art_bounds("Dot")); + assert!((held2.cached_bounds.unwrap().width() - w1).abs() < 1e-6); + assert_eq!(inline_bounds(&obj(&s2, line)), s2.doc().unwrap().doc.symbol_art_bounds("Holder")); +} + +/// Type with an inline graphic in an opacity mask and in a pattern tile follows its symbol. +#[test] +fn inline_graphics_in_masks_and_pattern_tiles_follow_their_symbol() { + let mut s = session(); + circle_symbol(&mut s, "Dot", 10.0); + let masked = text(&mut s, "ab"); + s.execute("text.insertInline", &json!({"id": masked.0, "at": 1, "symbol": "Dot"})).unwrap(); + let owner = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 20, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap()); + s.edit("Mask", |d, _| { + let art = d.remove(masked)?; + d.node_mut(owner).unwrap().extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new((*art).clone(), true))); + Ok(()) + }) + .unwrap(); + let tiled = text(&mut s, "cd"); + s.execute("text.insertInline", &json!({"id": tiled.0, "at": 1, "symbol": "Dot"})).unwrap(); + s.execute("object.pattern.make", &json!({"ids": [tiled.0], "name": "Tile", "edit": false})).unwrap(); + redefine_as_wide_ellipse(&mut s, "Dot"); + let d = &s.doc().unwrap().doc; + let want = d.symbol_art_bounds("Dot"); + let mask = first_text(&d.node(owner).unwrap().extras().mask.as_deref().unwrap().art).unwrap(); + assert_eq!(inline_bounds(&mask), want); + let tile = d.patterns.iter().find(|p| p.name == "Tile").unwrap().art.iter().find_map(|n| first_text(n)).unwrap(); + assert_eq!(inline_bounds(&tile), want); +} + +/// Symbol C shows B inline and B shows A: with the symbols listed C, B, A, a change to A reaches C +/// through B's new size. +#[test] +fn symbols_are_resolved_after_the_symbols_they_show_inline() { + let mut s = session(); + circle_symbol(&mut s, "A", 10.0); + for (name, shows) in [("B", "A"), ("C", "B")] { + let t = text(&mut s, "z"); + s.execute("text.insertInline", &json!({"id": t.0, "at": 0, "symbol": shows})).unwrap(); + s.execute("symbol.new", &json!({"name": name, "ids": [t.0]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + } + s.edit("Reverse the symbols", |d, _| { + d.symbols.reverse(); + Ok(()) + }) + .unwrap(); + let b0 = s.doc().unwrap().doc.symbol_art_bounds("B"); + redefine_as_wide_ellipse(&mut s, "A"); + let d = &s.doc().unwrap().doc; + assert_eq!(inline_bounds(&art_text(&s, "B")), d.symbol_art_bounds("A")); + assert_ne!(d.symbol_art_bounds("B"), b0); + assert_eq!(inline_bounds(&art_text(&s, "C")), d.symbol_art_bounds("B")); +} + +/// A symbol whose art shows the symbol itself inline, directly or through another symbol, is an +/// empty slot, and stays one through later edits. +#[test] +fn a_symbol_that_shows_itself_inline_is_an_empty_slot() { + let mut s = session(); + for name in ["Loop", "P", "Q"] { + circle_symbol(&mut s, name, 10.0); + } + for (name, shows) in [("Loop", "Loop"), ("P", "Q"), ("Q", "P")] { + let t = text(&mut s, "ab"); + s.execute("text.insertInline", &json!({"id": t.0, "at": 1, "symbol": shows})).unwrap(); + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + s.execute("symbol.update", &json!({"name": name})).unwrap(); + } + let line = text(&mut s, "x"); + s.execute("text.insertInline", &json!({"id": line.0, "at": 0, "symbol": "P"})).unwrap(); + for _ in 0..2 { + for name in ["Loop", "P", "Q"] { + assert_eq!(inline_bounds(&art_text(&s, name)), None, "{name}"); + assert_eq!(s.doc().unwrap().doc.inline_art_bounds(name), None, "{name}"); + } + assert_eq!(inline_bounds(&obj(&s, line)), None); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + } + let _ = s.execute("document.serialize", &json!({"format": "png"})).unwrap(); + // Redefined without the inline graphic, Q is on no cycle, and neither is P. + redefine_as_wide_ellipse(&mut s, "Q"); + let d = &s.doc().unwrap().doc; + assert!(d.inline_art_bounds("P").is_some()); + assert_eq!(inline_bounds(&obj(&s, line)), d.symbol_art_bounds("P")); + assert_eq!(inline_bounds(&art_text(&s, "P")), d.symbol_art_bounds("Q")); + assert_eq!(d.inline_art_bounds("Loop"), None); +} + /// A list paragraph that starts with an inline graphic draws its bullet before the graphic, and /// live SVG text writes the bullet. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c50a45b639..e10a4b6174 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1246,6 +1246,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Kerning across style runs:** characters that differ only in fill, stroke, underline, strikethrough, overprint, character style name, leading, baseline shift, rotation, Character Alignment, kerning, or tracking that keeps or drops ligatures alike are shaped together, so restyling one character keeps the font's kerning pairs with its neighbors. A character with manual kerning that follows one with automatic kerning keeps the font's kerning with it (unless All Caps or Small Caps sets it as two letters, as ß becomes SS), and its value replaces the font's kerning after it. A ligature does not form across a change of style, and a change of font, size or OpenType features still shapes the characters apart. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/text/src/tests/bidi.rs`. - **Manual kerning in right-to-left text:** a manual kerning value on a Hebrew or Arabic character changes the space between it and the next character in reading order, the one drawn to its left, under Auto and Optical kerning. The character before it keeps its place, and the caret between the pair stays beside the next character. The side the space goes on is VectorCraft's own rule. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html). Test: `rtl_manual_kerning_spaces_the_character_from_the_next_in_reading_order`. - **Manual kerning on inline graphics:** a manual kerning value on an inline graphic's character spaces it from the next character in reading order by the same distance as on a letter of its run's size, in left-to-right, right-to-left and vertical type; Auto and Optical kerning still apply no font pairs to it. This is VectorCraft's own rule. Test: `manual_kerning_on_an_inline_graphic_spaces_it_like_a_letter`. +- **Inline graphics in symbol art follow their symbols:** when a symbol changes, type with an inline graphic of it is laid out again wherever the type is: in the layers, in the art of other symbols, graph designs and pattern tiles, and in opacity masks. A symbol's type is laid out before the type that shows that symbol inline, so a symbol whose art holds an inline graphic, its instances and the inline graphics that show it take its new size. Opening and reverting a document and Refresh Font List resolve them in the same order. An inline graphic of a symbol whose art shows that symbol inline, directly or through other symbols, is an empty slot. This is VectorCraft's own rule. Tests: `inline_graphics_in_symbol_art_follow_their_symbol`, `inline_graphics_in_masks_and_pattern_tiles_follow_their_symbol`, `symbols_are_resolved_after_the_symbols_they_show_inline`, `a_symbol_that_shows_itself_inline_is_an_empty_slot` in `crates/engine/src/tests/inline.rs`. - **Recent font pickers (#3515):** The shared Character, Properties, Glyphs and Find Font family picker lists recently applied families first, once each, in most-recent order and limited by Preferences › Type › Number of Recent Fonts. Search and filters apply to both sections; unavailable families are omitted. Section headings cannot be selected or previewed. Confirmed character-range font edits update the same saved history; previews and failed edits do not. Requirement: [issue #3515](https://github.com/storytold/vectorcraft/issues/3515). - **Show samples on white (a VectorCraft addition):** Preferences › Type › Show samples on white (`fontSamplesOnWhite`, off by default) draws each family's sample in every font menu, and in Type → Font in the in-window menu bar, in dark type on a white area in every interface brightness. A highlighted row's color shows above and below the white area, and the names and the rest of the menu keep the interface's colors. The checkbox is dimmed while Enable in-menu font previews is off. Tests: `crates/ui-egui/src/tests/font_menu.rs`, `crates/ui-egui/src/font_menu/tests.rs`, `crates/ui-egui/src/prefs_dialog/tests.rs`. - **Find Font: Replace With Font From:** a dropdown under Replace With Font chooses the list the replacement font comes from: Document lists the families the document uses, once each; Recent lists the recently applied families that are installed (fonts applied with Find Font's Change and Change All included), newest first, as many as Preferences › Type › Number of Recent Fonts allows; System, the default, lists the installed families available in the font menu. The dialog opens on System with Source Sans 3 chosen, or the font menu's first family when Source Sans 3 is not installed. Choosing a list keeps the chosen font when the list has it and it is installed; otherwise Document and Recent choose the list's first installed font, and System chooses Source Sans 3 or the font menu's first family. Change and Change All are unavailable when the list has no installed font (Recent before any font was applied) and when the chosen family is not installed or is not in the shown list. Behavior reference: [Find and replace fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-replace-fonts.html) (public documentation). Tests: `crates/ui-egui/src/find_font.rs`. From 4d4d7ee1a781a37bf76bd198d172b8ea5f9aae20 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 10:36:16 -0800 Subject: [PATCH 09/14] Envelopes: opaque pixels of a warped image stay opaque where its pieces overlap In SVG and PDF output, each piece of a warped image whose pieces can show through was drawn through a mask of every later piece that reaches it. On a later piece's antialiased edge the viewer multiplies the earlier piece's coverage by the mask and draws the later piece over it, so an image with both opaque and partly transparent pixels came out lighter at its opaque pixels along the triangles' edges (alpha 184 of 255 in the new SVG test). A later piece now goes into an earlier piece's mask only when a pixel of either piece's image within 2 pixels of their overlap is less than opaque (vectorcraft_effects::WarpSeams). Where two pieces overlap over opaque pixels, the later one is drawn over the earlier one without a mask. --- crates/doc/src/pixels.rs | 8 ++ crates/effects/src/clip.rs | 155 +++++++++++++++++++++++++ crates/effects/src/lib.rs | 2 +- crates/engine/src/tests/warp_images.rs | 38 +++++- crates/pdf/src/export/nodes.rs | 14 ++- crates/svg/src/export/nodes.rs | 12 +- crates/svg/tests/it/warp_pieces.rs | 46 ++++++++ 7 files changed, 269 insertions(+), 6 deletions(-) diff --git a/crates/doc/src/pixels.rs b/crates/doc/src/pixels.rs index ef72ae5721..fe200c2f28 100644 --- a/crates/doc/src/pixels.rs +++ b/crates/doc/src/pixels.rs @@ -272,6 +272,14 @@ impl ImageBlob { self.decoded().is_some_and(|img| img.pixels().any(|p| p.0[3] < 255)) } + /// The alpha of each pixel, row by row from the top: the width, the height and one byte a + /// pixel. `None` when the image can't be decoded. The last image decoded is kept, as for + /// [`Self::sample`]. + pub fn alpha(&self) -> Option<(u32, u32, Vec)> { + let img = self.decoded()?; + Some((img.width(), img.height(), img.pixels().map(|p| p.0[3]).collect())) + } + /// The decoded pixels (the last image decoded is kept for the next sample). fn decoded(&self) -> Option> { thread_local! { diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index 0616261d8a..a3e31f1842 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -60,6 +60,161 @@ pub fn warp_pieces_translucent(doc: &Document, pieces: &[&Node]) -> bool { false } +/// Which overlaps of the pieces of a mesh-warped image ([`Node::warp_piece_image`]) can show +/// through. Where an upper piece overlaps a lower one only over fully opaque pixels of the image, +/// the SVG and PDF writers draw the upper piece over the lower one without a knockout mask for it: +/// a mask there would make the opaque pixels on the upper piece's antialiased edge lighter. +pub struct WarpSeams { + pieces: Vec>, + alphas: Vec<(u32, u32, Vec)>, +} + +/// A piece of a mesh-warped image whose pixels decide whether its overlaps show through. +struct SeamPiece { + /// The piece's triangle, in document space. + triangle: [vectorcraft_geom::Point; 3], + /// Maps document space to the pixels of its image as decoded. + to_pixels: vectorcraft_geom::Affine, + /// The index of its image's alpha in [`WarpSeams::alphas`]. + alpha: usize, +} + +/// How far from an overlap, in image pixels, a less than opaque pixel makes the overlap show +/// through: a smoothed image blends each pixel with its neighbors. +const SEAM_MARGIN: f64 = 2.0; + +impl WarpSeams { + /// The overlaps of `pieces` (pieces of a mesh-warped image, in paint order), with the pixels of + /// their images looked up in `doc`. Each distinct image is decoded once. + pub fn new(doc: &Document, pieces: &[&Node]) -> Self { + let mut images: Vec<*const vectorcraft_doc::ImageBlob> = vec![]; + let mut alphas = vec![]; + let pieces = pieces + .iter() + .map(|n| { + let image = n.warp_piece_image()?; + let NodeKind::Image(im) = &image.kind else { return None }; + let e = image.extras(); + if n.opacity < 1.0 || image.opacity < 1.0 || e.color_mask.is_some() || e.colorize.is_some() { + return None; + } + let triangle = triangle(n.knockout_outline()?)?; + let blob = doc.image_blob(im)?; + let alpha = match images.iter().position(|b| std::ptr::eq(*b, blob)) { + Some(i) => i, + None => { + alphas.push(blob.alpha()?); + images.push(std::ptr::from_ref(blob)); + alphas.len() - 1 + } + }; + let (w, h, _) = &alphas[alpha]; + if im.width == 0 || im.height == 0 || im.xf.determinant().abs() < 1e-12 { + return None; + } + let scale = vectorcraft_geom::Affine::scale_non_uniform(f64::from(*w) / f64::from(im.width), f64::from(*h) / f64::from(im.height)); + Some(SeamPiece { triangle, to_pixels: scale * im.xf.inverse(), alpha }) + }) + .collect(); + WarpSeams { pieces, alphas } + } + + /// Whether piece `above` (an index into the pieces) drawn over piece `below` can show the + /// lower piece through where they overlap: either piece or its image is partly transparent, a + /// color mask or a colorize paint changes its alpha, or a pixel of either image within + /// [`SEAM_MARGIN`] of the overlap is less than opaque. `true` for a piece that is not a + /// triangle of an image that can be decoded; `false` when the triangles do not overlap. + pub fn shows_through(&self, below: usize, above: usize) -> bool { + let (Some(Some(a)), Some(Some(b))) = (self.pieces.get(below), self.pieces.get(above)) else { return true }; + let overlap = convex_intersection(&a.triangle, &b.triangle); + overlap.len() >= 3 && [a, b].into_iter().any(|p| self.translucent_near(p, &overlap)) + } + + /// Whether a pixel of piece `p`'s image within [`SEAM_MARGIN`] of the convex polygon `overlap` + /// (document space) is less than opaque. + fn translucent_near(&self, p: &SeamPiece, overlap: &[vectorcraft_geom::Point]) -> bool { + let Some((w, h, alpha)) = self.alphas.get(p.alpha) else { return true }; + let poly: Vec = overlap.iter().map(|q| p.to_pixels * *q).collect(); + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for q in &poly { + (x0, y0, x1, y1) = (x0.min(q.x), y0.min(q.y), x1.max(q.x), y1.max(q.y)); + } + let span = |lo: f64, hi: f64, n: u32| { + let lo = (lo - SEAM_MARGIN).floor().clamp(0.0, f64::from(n)) as u32; + let hi = (hi + SEAM_MARGIN).ceil().clamp(0.0, f64::from(n)) as u32; + lo..hi + }; + let orientation = signed_area(&poly).signum(); + for y in span(y0, y1, *h) { + for x in span(x0, x1, *w) { + let c = vectorcraft_geom::Point::new(f64::from(x) + 0.5, f64::from(y) + 0.5); + let near = poly.iter().zip(poly.iter().cycle().skip(1)).all(|(s, e)| { + let edge = *e - *s; + orientation * edge.cross(c - *s) >= -SEAM_MARGIN * edge.hypot() + }); + let i = y as usize * *w as usize + x as usize; + if near && alpha.get(i).is_none_or(|a| *a < 255) { + return true; + } + } + } + false + } +} + +/// The three corners of the straight-sided triangle `outline` draws, in document space. +fn triangle(outline: &Node) -> Option<[vectorcraft_geom::Point; 3]> { + let (bp, _) = clip_outline(outline)?; + let mut points = vec![]; + for el in bp.elements() { + match el { + vectorcraft_geom::PathEl::MoveTo(p) | vectorcraft_geom::PathEl::LineTo(p) if points.is_empty() || points.last() != Some(p) => { + points.push(*p) + } + vectorcraft_geom::PathEl::MoveTo(_) | vectorcraft_geom::PathEl::LineTo(_) | vectorcraft_geom::PathEl::ClosePath => {} + _ => return None, + } + } + if points.len() == 4 && points.first() == points.last() { + points.pop(); + } + let [a, b, c] = points.as_slice() else { return None }; + Some([*a, *b, *c]) +} + +/// Twice the signed area of polygon `poly`. +fn signed_area(poly: &[vectorcraft_geom::Point]) -> f64 { + poly.iter().zip(poly.iter().cycle().skip(1)).map(|(a, b)| a.to_vec2().cross(b.to_vec2())).sum() +} + +/// The intersection of convex polygons `subject` and `clip`, as a convex polygon (empty when they +/// do not overlap). +fn convex_intersection(subject: &[vectorcraft_geom::Point], clip: &[vectorcraft_geom::Point]) -> Vec { + let orientation = signed_area(clip).signum(); + if orientation == 0.0 { + return vec![]; + } + let mut out = subject.to_vec(); + for (s, e) in clip.iter().zip(clip.iter().cycle().skip(1)) { + let edge = *e - *s; + let side = |p: vectorcraft_geom::Point| orientation * edge.cross(p - *s); + let input = std::mem::take(&mut out); + for (p, q) in input.iter().zip(input.iter().cycle().skip(1)) { + let (sp, sq) = (side(*p), side(*q)); + if sp >= 0.0 { + out.push(*p); + } + if (sp >= 0.0) != (sq >= 0.0) { + out.push(*p + (*q - *p) * (sp / (sp - sq))); + } + } + if out.is_empty() { + break; + } + } + out +} + /// Text as one path of its glyph outlines, in document space. fn outline_text(n: &Node) -> Option { let NodeKind::Text(t) = &n.kind else { return None }; diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index d84c54060d..743af8860f 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -69,7 +69,7 @@ pub use adjust::{ is_channel, }; pub use bake::{StrokeArt, bake_appearance, bake_document, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake}; -pub use clip::{clip_outline, knockout_silhouette, warp_pieces_translucent}; +pub use clip::{WarpSeams, clip_outline, knockout_silhouette, warp_pieces_translucent}; pub use group::{ OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_leaves, member_shapes, paints, pathfinder_children, shape_art, diff --git a/crates/engine/src/tests/warp_images.rs b/crates/engine/src/tests/warp_images.rs index 1fc6b736e1..9dc781ee16 100644 --- a/crates/engine/src/tests/warp_images.rs +++ b/crates/engine/src/tests/warp_images.rs @@ -295,7 +295,12 @@ fn a_warp_on_a_group_bends_the_image_in_it() { /// A selected embedded 40×40 px red image at half alpha filling [`FRAME`], with Effect › Warp › /// Arc at 50 %. fn half_clear_arc(s: &mut Session) { - let img = image::RgbaImage::from_pixel(40, 40, image::Rgba([255, 0, 0, 128])); + clear_arc(s, |_| 128); +} + +/// [`half_clear_arc`] with each column `x` of pixels at alpha `alpha(x)`. +fn clear_arc(s: &mut Session, alpha: impl Fn(u32) -> u8) { + let img = image::RgbaImage::from_fn(40, 40, |x, _| image::Rgba([255, 0, 0, alpha(x)])); let mut png = vec![]; img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); s.edit("Place", |d, sel| { @@ -346,3 +351,34 @@ fn a_half_transparent_warped_image_has_no_darker_seams() { back.execute("document.open", &json!({"name": "half.pdf", "dataBase64": pdf["dataBase64"]})).unwrap(); assert_eq!(not_half_red(&back, 120..=152), vec![], "pdf"); } + +/// An image whose left half is opaque and whose right half is at half alpha keeps its opaque +/// pixels opaque where the pieces of a warp overlap, away from the partly transparent pixels, on +/// the canvas and in a PDF opened again. +#[test] +fn opaque_pixels_of_a_partly_transparent_warped_image_have_no_lighter_seams() { + let mut s = session(); + clear_arc(&mut s, |x| if x < 20 { 255 } else { 128 }); + // The points of a fine grid over the left 30 % of the bent image, away from its outer edges, + // where the document drawn on white at 8 px a point is not red: the first few. + let not_red = |s: &Session| { + let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 300.0, 300.0), 8.0, true); + let mut out = vec![]; + for i in 0..300 { + for j in 0..800 { + let (u, v) = (0.03 + 0.27 * f64::from(i) / 299.0, 0.03 + 0.94 * f64::from(j) / 799.0); + let p = arced(50.0, u, v); + let px = img.pixel((p.x * 8.0).floor() as u32, (p.y * 8.0).floor() as u32); + if px[1] > 6 && out.len() < 8 { + out.push((u, v, px)); + } + } + } + out + }; + assert_eq!(not_red(&s), vec![], "canvas"); + let pdf = s.execute("document.export", &json!({"format": "pdf", "preserveEditing": false})).unwrap(); + let mut back = session(); + back.execute("document.open", &json!({"name": "mixed.pdf", "dataBase64": pdf["dataBase64"]})).unwrap(); + assert_eq!(not_red(&back), vec![], "pdf"); +} diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 4d4b57c3d2..bb73980177 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -118,7 +118,8 @@ impl Exporter<'_> { /// elements of a knockout group ([`vectorcraft_effects::warp_pieces_translucent`]). Each piece /// is drawn through one soft mask of where the pieces above it that reach its box don't cover /// ([`vectorcraft_effects::knockout_silhouette`]): only neighbors overlap, so each mask holds a - /// few triangles and no mask nests in another. + /// few triangles and no mask nests in another. A piece above that overlaps it only over opaque + /// pixels ([`vectorcraft_effects::WarpSeams`]) is left out of its mask. fn warp_pieces(&mut self, s: &mut Surface, elements: &[(&std::sync::Arc, Measured)], page: Rect) { // Each piece's triangle and its box: a piece paints inside its triangle only. let silhouette = |c: &Node| { @@ -126,11 +127,18 @@ impl Exporter<'_> { Some((vectorcraft_effects::knockout_silhouette(c, vectorcraft_doc::color::Color::WHITE)?, b)) }; let silhouettes: Vec> = elements.iter().map(|(c, _)| silhouette(c)).collect(); + let pieces: Vec<&Node> = elements.iter().map(|(c, _)| &***c).collect(); + let seams = vectorcraft_effects::WarpSeams::new(self.doc, &pieces); for (i, (c, measured)) in elements.iter().enumerate() { let Some((_, reach)) = &silhouettes[i] else { continue }; let reach = reach.inflate(1.0, 1.0); - let shapes: Vec<(&Node, Option<&Measured>)> = - silhouettes[i + 1..].iter().flatten().filter(|(_, b)| rects_overlap(*b, reach)).map(|(n, _)| (n, None)).collect(); + let shapes: Vec<(&Node, Option<&Measured>)> = silhouettes + .iter() + .enumerate() + .skip(i + 1) + .filter_map(|(j, s)| s.as_ref().filter(|(_, b)| rects_overlap(*b, reach) && seams.shows_through(i, j))) + .map(|(n, _)| (n, None)) + .collect(); if shapes.is_empty() { self.measured_node(s, c, page, false, Some(measured)); continue; diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index f1c989e282..a4cdf26271 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -638,6 +638,8 @@ impl Writer<'_> { /// through one mask of where the pieces above it that reach it don't cover /// (their triangles, [`vectorcraft_effects::knockout_silhouette`], black over white): /// only neighbors overlap, so each mask holds a few triangles and no mask nests in another. + /// A piece above that overlaps it only over opaque pixels ([`vectorcraft_effects::WarpSeams`]) + /// is left out of its mask. fn warp_pieces(&mut self, elements: &[&std::sync::Arc]) { // Each piece's triangle and its box: a piece paints inside its triangle only. let silhouette = |c: &Node| { @@ -645,11 +647,19 @@ impl Writer<'_> { Some((vectorcraft_effects::knockout_silhouette(c, vectorcraft_doc::color::Color::BLACK)?, b)) }; let silhouettes: Vec> = elements.iter().map(|c| silhouette(c)).collect(); + let pieces: Vec<&Node> = elements.iter().map(|c| &***c).collect(); + let seams = vectorcraft_effects::WarpSeams::new(self.doc, &pieces); for (i, c) in elements.iter().enumerate() { let Some((own, reach)) = &silhouettes[i] else { continue }; let reach = reach.inflate(1.0, 1.0); let overlaps = |b: &Rect| b.x0 <= reach.x1 && reach.x0 <= b.x1 && b.y0 <= reach.y1 && reach.y0 <= b.y1; - let above: Vec<&Node> = silhouettes[i + 1..].iter().flatten().filter(|(_, b)| overlaps(b)).map(|(n, _)| n).collect(); + let above: Vec<&Node> = silhouettes + .iter() + .enumerate() + .skip(i + 1) + .filter_map(|(j, s)| s.as_ref().filter(|(_, b)| overlaps(b) && seams.shows_through(i, j))) + .map(|(n, _)| n) + .collect(); if above.is_empty() { self.node(c); continue; diff --git a/crates/svg/tests/it/warp_pieces.rs b/crates/svg/tests/it/warp_pieces.rs index 575e98be16..9c8b3e1760 100644 --- a/crates/svg/tests/it/warp_pieces.rs +++ b/crates/svg/tests/it/warp_pieces.rs @@ -72,6 +72,52 @@ fn a_half_transparent_image_is_half_transparent_where_its_warp_pieces_overlap() } } +/// An image whose left half is opaque and whose right half is at half alpha, cut into 4 × 4 cells: +/// its opaque pixels stay opaque on the pieces' antialiased edges, away from the partly +/// transparent pixels, and the right half stays half transparent. +#[test] +fn opaque_pixels_stay_opaque_where_warp_pieces_overlap() { + let img = image::RgbaImage::from_fn(16, 16, |x, _| image::Rgba([255, 0, 0, if x < 8 { 255 } else { 128 }])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + for masked in [false, true] { + let mut d = Document::new(100.0, 100.0); + d.images.insert("pic".into(), ImageBlob::png(png.clone())); + let im = ImageObject { + key: "pic".into(), + width: 16, + height: 16, + xf: Affine::translate((10.0, 10.0)) * Affine::scale(5.0), + link: None, + placement: Default::default(), + pixels: None, + }; + let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im.clone()))); + let g = mesh_warp_image(&n, &im, &|p| p, 4, false, masked); + d.insert(d.default_layer(), 0, g).unwrap(); + let svg = export_full(&d, &ExportOptions::default(), None).svg; + let tree = resvg::usvg::Tree::from_str(&svg, &resvg::usvg::Options::default()).unwrap(); + let mut pixmap = resvg::tiny_skia::Pixmap::new(400, 400).unwrap(); + resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(4.0, 4.0), &mut pixmap.as_mut()); + let alpha = |x0: u32, x1: u32| { + let mut range = (255u8, 0u8); + for y in 48..352 { + for x in x0..x1 { + let a = pixmap.pixel(x, y).unwrap().alpha(); + range = (range.0.min(a), range.1.max(a)); + } + } + range + }; + // The first column of cells and its seam with the second, (12, 12)–(29.75, 88). + let opaque = alpha(48, 119); + assert!(opaque.0 >= 253, "masked {masked}: {opaque:?}"); + // The right half, (62, 12)–(88, 88). + let half = alpha(248, 352); + assert!(half.0 >= 104 && half.1 <= 134, "masked {masked}: {half:?}"); + } +} + /// Pieces of an opaque image are drawn one over another without knockout masks; a colorize paint /// draws a gray image's light pixels partly transparent, so its pieces take them. #[test] From d1f76ede8abac5c8879ac7971e763169b00bb1a1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 10:15:10 -0800 Subject: [PATCH 10/14] Smooth: a drag along a segment smooths it when its anchors are out of reach A Smooth drag along the middle of a curved segment smoothed nothing when neither of the segment's anchors was within the reach, and paths of two anchors were never smoothed. - path.smoothRegion searches each curved segment where it can come within the reach, halving it down to pieces no longer than half the reach, and adds an anchor in the middle of each stretch in reach; the existing reach split then cuts the segment where the drag enters and leaves the reach. A drag that only grazes a curve finds the place on it nearest the drag. The search is bounded per drag and shared equally by the segments in reach. - Paths of two anchors are smoothed, open or closed. - Alt with the Paintbrush smooths a selected two-anchor curve; a two-anchor straight line still gets the closed stroke. Both sides use one segment_is_straight test. Part of #2224 --- crates/engine/src/cmd/draw2/smooth.rs | 235 +++++++++++++++++++++++- crates/engine/src/tests/draw2.rs | 204 ++++++++++++++++++++ crates/tools/src/draw2.rs | 38 ++++ crates/tools/src/draw2/gesture.rs | 6 +- crates/tools/src/draw2/gesture/tests.rs | 29 +++ docs/target-app-parity.md | 1 + 6 files changed, 504 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs index 0ac4fc8d6b..660e5b8370 100644 --- a/crates/engine/src/cmd/draw2/smooth.rs +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -2,7 +2,7 @@ use vectorcraft_pathops::addons::pathtools as pt; use super::common::{dist_to_polyline, ids_json, path_mut, path_of, points_param, poly_bounds, subpath_len, target_paths}; use super::{ - Anchor, BezPath, CubicBez, NodeId, ParamCurve, ParamCurveNearest, PathData, Point, Result, Session, SimplifyOptions, SubPath, Value, json, + Anchor, BezPath, CubicBez, NodeId, ParamCurve, ParamCurveNearest, PathData, Point, Rect, Result, Session, SimplifyOptions, SubPath, Value, json, number_or, po, }; @@ -37,10 +37,192 @@ fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64) -> Vec { out } -fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64) -> Option { +/// Pieces of curve that one Smooth drag measures against the drag while it searches the segments +/// under it ([`reach_samples`]), shared equally by the curved segments that come within its reach, +/// each of which gets at least one: enough to search 300,000 pt of curve within the default reach +/// of the drag. +const MAX_REACH_PIECES: usize = 1 << 17; + +/// The box around the control points of `s`, which holds all of `s`. +fn control_box(s: CubicBez) -> Rect { + Rect::from_points(s.p0, s.p1).union_pt(s.p2).union_pt(s.p3) +} + +/// The stretches of the drag `pts` (stretch `k` runs from `pts[k]` to the next point; a drag of one +/// point has the point as its only stretch). +fn drag_stretches(pts: &[Point]) -> Vec { + (0..pts.len().saturating_sub(1).max(1)).collect() +} + +/// The stretches of the drag `pts` among `of` whose bounds come within `radius` of `r` on both +/// axes: every stretch within `radius` of a point in `r`, and perhaps a few more. +fn stretches_near(pts: &[Point], of: &[usize], r: Rect, radius: f64) -> Vec { + let r = r.inflate(radius, radius); + of.iter() + .copied() + .filter(|&k| { + let Some(&a) = pts.get(k) else { return false }; + let b = pts.get(k + 1).copied().unwrap_or(a); + a.x.min(b.x) <= r.x1 && a.x.max(b.x) >= r.x0 && a.y.min(b.y) <= r.y1 && a.y.max(b.y) >= r.y0 + }) + .collect() +} + +/// The distance from `q` to the nearest of the stretches `of` of the drag `pts`. +fn dist_to_stretches(pts: &[Point], of: &[usize], q: Point) -> f64 { + of.iter() + .filter_map(|&k| { + let a = *pts.get(k)?; + Some(dist_to_polyline(&[a, pts.get(k + 1).copied().unwrap_or(a)], q)) + }) + .fold(f64::INFINITY, f64::min) +} + +/// The place on `s` (0 to 1) nearest to the stretches `of` of the drag `pts`, and its distance from +/// them. For each stretch the nearest place is at one of the stretch's ends, at an end of `s`, or +/// where `s` crosses the stretch's line or stops moving toward or away from it. +fn nearest_to_stretches(s: CubicBez, pts: &[Point], of: &[usize]) -> (f64, f64) { + let (c1, c2, c3) = (3.0 * (s.p1 - s.p0), 3.0 * ((s.p2 - s.p1) - (s.p1 - s.p0)), (s.p3 - s.p0) - 3.0 * (s.p2 - s.p1)); + let mut best = (0.5, f64::INFINITY); + for &k in of { + let Some(&a) = pts.get(k) else { continue }; + let b = pts.get(k + 1).copied().unwrap_or(a); + let mut places: Vec = vec![0.0, 1.0, s.nearest(a, 1e-9).t, s.nearest(b, 1e-9).t]; + let d = b - a; + if d.hypot() > 1e-12 { + let (f0, f1, f2, f3) = (d.cross(s.p0 - a), d.cross(c1), d.cross(c2), d.cross(c3)); + places.extend(kurbo::common::solve_cubic(f0, f1, f2, f3)); + places.extend(kurbo::common::solve_quadratic(f1, 2.0 * f2, 3.0 * f3)); + } + for u in places.into_iter().filter(|u| (0.0..=1.0).contains(u)) { + let dist = dist_to_polyline(&[a, b], s.eval(u)); + if dist < best.1 { + best = (u, dist); + } + } + } + best +} + +/// How many curved segments of `pd` may come within `radius` of the drag `pts`. +fn segments_in_reach(pd: &PathData, pts: &[Point], radius: f64) -> usize { + let all = drag_stretches(pts); + pd.subpaths + .iter() + .map(|sp| { + (0..sp.segment_count()) + .filter(|&i| { + !vectorcraft_tools::draw2::segment_is_straight(sp, i) && !stretches_near(pts, &all, control_box(sp.segment(i)), radius).is_empty() + }) + .count() + }) + .sum() +} + +/// Samples along the part of `c` from `t0` to `t1`, in order: (t, whether the point there is within +/// `radius` of the stretches `of` of the drag `pts`). A piece no stretch comes near gives one sample +/// out of reach; the others are halved until their control polygon is no longer than half the +/// reach, or until `budget` pieces have been measured, and then give one sample: in reach at the +/// piece's middle or at its place nearest the drag when either is in reach, otherwise out of reach +/// at its middle. Each piece measures only the stretches near +/// the piece it was cut from, so a long drag along a long curve costs about one pass over the drag +/// per halving, plus the stretches near each piece. +fn reach_samples(c: CubicBez, (t0, t1): (f64, f64), (pts, of): (&[Point], &[usize]), radius: f64, budget: &mut usize, out: &mut Vec<(f64, bool)>) { + *budget = budget.saturating_sub(1); + let s = c.subsegment(t0..t1); + let mid = 0.5 * (t0 + t1); + let near = stretches_near(pts, of, control_box(s), radius); + if near.is_empty() { + out.push((mid, false)); + return; + } + let len = s.p0.distance(s.p1) + s.p1.distance(s.p2) + s.p2.distance(s.p3); + if len <= (radius / 2.0).max(1e-3) || *budget == 0 || mid <= t0 || mid >= t1 { + // The piece's middle, or the place on it nearest the drag when the middle is out of reach. + let sample = if dist_to_stretches(pts, &near, c.eval(mid)) <= radius { + (mid, true) + } else { + let (u, dist) = nearest_to_stretches(s, pts, &near); + if dist <= radius { (t0 + u * (t1 - t0), true) } else { (mid, false) } + }; + out.push(sample); + return; + } + reach_samples(c, (t0, mid), (pts, &near), radius, budget, out); + reach_samples(c, (mid, t1), (pts, &near), radius, budget, out); +} + +/// `sp` cut where the reach `radius` of the drag `pts` crosses its curved segments, and whether each +/// anchor of the result is in reach. An anchor in reach is added in the middle of each stretch of a +/// segment in reach that does not run on from one of the segment's anchors in reach, and an anchor +/// out of reach in the middle of each stretch out of reach between two stretches in reach, so that a +/// stretch in reach is smoothed even when the segment's anchors are not, and the stretches between +/// stay as they are. A segment is searched no more than half the reach apart where it comes within +/// the reach of the drag ([`reach_samples`]), measuring up to `share` pieces of each segment. +fn mark_reached(sp: &SubPath, pts: &[Point], radius: f64, share: usize) -> (SubPath, Vec) { + let near = |q: Point| dist_to_polyline(pts, q) <= radius; let n = sp.anchors.len(); let marked: Vec = sp.anchors.iter().map(|a| near(a.p)).collect(); - if n < 3 || !marked.iter().any(|m| *m) { + let mut places = vec![]; + let all = drag_stretches(pts); + for i in 0..sp.segment_count() { + if vectorcraft_tools::draw2::segment_is_straight(sp, i) { + continue; + } + let c = sp.segment(i); + let (mut samples, mut budget) = (vec![], share); + reach_samples(c, (0.0, 1.0), (pts, &all), radius, &mut budget, &mut samples); + // Stretches of samples alike, in order: (in reach, first, last). + let mut runs: Vec<(bool, usize, usize)> = vec![]; + for (k, s) in samples.iter().enumerate() { + match runs.last_mut() { + Some(r) if r.0 == s.1 => r.2 = k, + _ => runs.push((s.1, k, k)), + } + } + let last = samples.len().saturating_sub(1); + let (start, end) = (marked[i], marked[(i + 1) % n]); + for (j, &(reached, first, to)) in runs.iter().enumerate() { + let mid = samples[(first + to) / 2].0; + let joined = (first == 0 && start) || (to == last && end); + if reached && !joined { + places.push((i, mid)); + } + // A gap with reach on both sides: the segment's anchor or a stretch on each side. + let before = if j == 0 { start } else { runs[j - 1].0 }; + let after = runs.get(j + 1).map_or(end, |r| r.0); + if !reached && before && after { + places.push((i, mid)); + } + } + } + // A place on one of the segment's ends would name that anchor rather than add one. + places.retain(|(_, t)| (1e-5..=1.0 - 1e-5).contains(t)); + if places.is_empty() { + return (sp.clone(), marked); + } + let (out, added) = pt::insert_at(sp, &places); + // The anchors already there keep their marks. A new anchor split off at a sample in reach can + // land a rounding error farther away than the sample. + let mut kept = marked.into_iter(); + let marked = out + .anchors + .iter() + .enumerate() + .map(|(j, a)| if added.contains(&j) { dist_to_polyline(pts, a.p) <= radius + 1e-9 * radius.max(1.0) } else { kept.next().unwrap_or(false) }) + .collect(); + (out, marked) +} + +fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f64, sigma: f64) -> Option { + if sp.anchors.len() < 2 { + return None; + } + let near = &|q: Point| dist_to_polyline(pts, q) <= radius; + // A drag along a segment smooths it even when its anchors are out of reach. + let (sp, marked) = mark_reached(sp, pts, radius, share); + let sp = &sp; + if !marked.iter().any(|m| *m) { return None; } if sp.closed { @@ -115,7 +297,7 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool for (a, b) in runs.into_iter().rev() { let lo = a.saturating_sub(1); let hi = (b + 1).min(n - 1); - if hi - lo < 2 { + if hi == lo { continue; } let mut piece = out[lo..=hi].to_vec(); @@ -150,16 +332,21 @@ pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result { let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_FIDELITY, 100.0); let sigma = radius * SMOOTH_SPREAD; let st = s.doc()?; - let near = |q: Point| dist_to_polyline(&pts, q) <= radius; let mut plan = vec![]; let (mut before, mut after) = (0, 0); + let mut paths = vec![]; for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, radius)) { - let (pd, _) = path_of(&st.doc, id)?; + paths.push((id, path_of(&st.doc, id)?.0)); + } + // Each curved segment in reach gets the same share of the search, whatever order they come in. + let in_reach: usize = paths.iter().map(|(_, pd)| segments_in_reach(pd, &pts, radius)).sum(); + let share = (MAX_REACH_PIECES / in_reach.max(1)).max(1); + for (id, pd) in paths { let mut changed = false; let subs: Vec = pd .subpaths .iter() - .map(|sp| match smooth_subpath(sp, &near, tol, sigma) { + .map(|sp| match smooth_subpath(sp, &pts, radius, share, tol, sigma) { Some(n) if n != *sp && n.anchors.len() >= 2 => { changed = true; n @@ -414,3 +601,37 @@ pub(super) fn join_scrub(s: &mut Session, p: &Value) -> Result { })?; Ok(json!({ "id": ida.0 })) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Searching a curve 1,000,000 pt long under a drag of 1,000 points along it measures no more + /// pieces than the budget it is given, and a search with none left gives one sample. + #[test] + fn searching_a_segment_under_a_drag_stays_within_its_budget() { + let c = CubicBez::new(Point::new(0.0, 0.0), Point::new(300000.0, 0.0), Point::new(700000.0, 0.0), Point::new(1000000.0, 0.0)); + let pts: Vec = (0..1000).map(|i| Point::new(f64::from(i) * 1000.0, 0.0)).collect(); + let all = drag_stretches(&pts); + let mut budget = MAX_REACH_PIECES; + let (mut first, mut second) = (vec![], vec![]); + reach_samples(c, (0.0, 1.0), (&pts, &all), 12.0, &mut budget, &mut first); + reach_samples(c, (0.0, 1.0), (&pts, &all), 12.0, &mut budget, &mut second); + assert!(first.len() <= MAX_REACH_PIECES, "{} samples", first.len()); + assert_eq!((budget, second.len()), (0, 1), "the budget is spent, so the next search gives one sample"); + } + + /// A small piece of curve is measured only against the few stretches of a drag of 100,000 + /// points that come near it, and a drag of one point has that point as its stretch. + #[test] + fn a_small_piece_is_measured_against_the_stretches_near_it() { + let pts: Vec = (0..100_000).map(|i| Point::new(f64::from(i) * 5.0, 0.0)).collect(); + let all = drag_stretches(&pts); + let near = stretches_near(&pts, &all, Rect::new(1000.0, 3.0, 1004.0, 6.0), 12.0); + assert_eq!(near, (197..=203).collect::>()); + assert!((dist_to_stretches(&pts, &near, Point::new(1002.0, 5.0)) - 5.0).abs() < 1e-12); + let one = [Point::new(10.0, 10.0)]; + assert_eq!(drag_stretches(&one), vec![0]); + assert!((dist_to_stretches(&one, &[0], Point::new(13.0, 14.0)) - 5.0).abs() < 1e-12); + } +} diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index f29df7ff23..c957e81332 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -1606,6 +1606,210 @@ fn smoothing_a_whole_closed_path_smooths_a_bump_at_its_start() { assert!(start <= far + 0.5, "the bump at the start stays {start} pt high, the far one {far} pt"); } +/// `path.smoothRegion` along `drag` with the Smooth tool's reach and default Fidelity → the ids changed. +fn smooth_pass_ids(s: &mut Session, drag: &[Point]) -> serde_json::Value { + let pts: Vec<_> = drag.iter().map(|p| json!([p.x, p.y])).collect(); + s.execute("path.smoothRegion", &json!({"points": pts, "radius": 12, "fidelity": 2.5})).unwrap()["ids"].clone() +} + +/// The corner, in degrees, at each anchor between two segments of `p`'s first open subpath. +fn smooth_test_kinks(p: &PathData) -> Vec { + let sp = &p.subpaths[0]; + (1..sp.segment_count()) + .map(|i| { + let (a, b) = (sp.segment(i - 1), sp.segment(i)); + let tin = if a.p3.distance(a.p2) > 1e-9 { a.p3 - a.p2 } else { a.p3 - a.p1 }; + let tout = if b.p1.distance(b.p0) > 1e-9 { b.p1 - b.p0 } else { b.p2 - b.p0 }; + tin.cross(tout).atan2(tin.dot(tout)).abs().to_degrees() + }) + .collect() +} + +/// A 200 pt segment rising to a sharp peak at (200, 125) in its middle, then a 100 pt line: +/// (100, 200) to (300, 200) to (400, 200). +fn long_peak_segment(s: &mut Session) -> NodeId { + let anchors = [ + json!({"x": 100.0, "y": 200.0, "out": [200.0, 100.0]}), + json!({"x": 300.0, "y": 200.0, "in": [200.0, 100.0]}), + json!({"x": 400.0, "y": 200.0}), + ]; + smooth_test_path(s, &anchors, false) +} + +/// The sharpest bend of `p` between x = 180 and 220: the most it turns, in radians per point, over +/// points about half a point apart. +fn sharpest_bend_near_200(p: &PathData) -> f64 { + use kurbo::ParamCurve; + let sp = &p.subpaths[0]; + let pts: Vec = (0..sp.segment_count()).flat_map(|i| (0..=800).map(move |k| sp.segment(i).eval(f64::from(k) / 800.0))).collect(); + pts.windows(3) + .filter(|w| (180.0..=220.0).contains(&w[1].x)) + .map(|w| (w[1] - w[0], w[2] - w[1])) + .filter(|(u, v)| u.hypot() > 1e-6 && v.hypot() > 1e-6) + .map(|(u, v)| u.cross(v).atan2(u.dot(v)).abs() / (0.5 * (u.hypot() + v.hypot()))) + .fold(0.0, f64::max) +} + +/// A drag along the middle of a long segment smooths the stretch under it although both of the +/// segment's anchors are out of reach; the rest of the segment and the line after it keep their shape. +#[test] +fn a_smooth_drag_along_a_segment_smooths_it_with_its_anchors_out_of_reach() { + use kurbo::ParamCurve; + let mut s = session(); + let id = long_peak_segment(&mut s); + let before = path(&s, id); + let drag = smooth_drag_along(126.0, 185.0, 215.0); + assert_eq!(smooth_pass_ids(&mut s, &drag), json!([id.0])); + let after = path(&s, id); + let (b0, b1) = (sharpest_bend_near_200(&before), sharpest_bend_near_200(&after)); + assert!(b1 < 0.97 * b0, "the peak's sharpest bend {b0} -> {b1}"); + let kinks = smooth_test_kinks(&after); + // The anchors where the reach was cut join smoothly; the corner before the line stays. + assert!(kinks[..kinks.len() - 1].iter().all(|k| *k < 0.5), "{kinks:?}"); + let a = &after.subpaths[0].anchors; + assert_eq!((a[0].p, a[0].h_out.y < 200.0), (Point::new(100.0, 200.0), true), "the first anchor keeps its place: {:?}", a[0]); + let (end, last) = (a[a.len() - 2], a[a.len() - 1]); + assert!(end.p == Point::new(300.0, 200.0) && !end.has_out(), "the segment's end keeps its place: {end:?}"); + assert_eq!(last, before.subpaths[0].anchors[2], "and the line after it stays"); + // Every point of the segment farther than the reach from the drag is still on the path. + let seg = before.subpaths[0].segment(0); + let sp = &after.subpaths[0]; + let on_after: Vec = (0..sp.segment_count()).flat_map(|i| (0..=400).map(move |k| sp.segment(i).eval(f64::from(k) / 400.0))).collect(); + let off_drag = |q: Point| { + drag.windows(2) + .map(|w| { + let ab = w[1] - w[0]; + (w[0] + ab * ((q - w[0]).dot(ab) / ab.hypot2()).clamp(0.0, 1.0)).distance(q) + }) + .fold(f64::INFINITY, f64::min) + }; + for q in (0..=200).map(|k| seg.eval(f64::from(k) / 200.0)).filter(|q| off_drag(*q) > 14.0) { + let gap = on_after.iter().map(|r| r.distance(q)).fold(f64::INFINITY, f64::min); + assert!(gap < 1.0, "{q:?} is {gap} pt off the smoothed path"); + } +} + +/// A drag that reaches a segment at its first anchor, leaves it and comes back to its middle smooths +/// the middle too, and the stretch between stays as it was. +#[test] +fn a_smooth_drag_that_comes_back_to_a_segment_smooths_where_it_comes_back() { + use kurbo::ParamCurve; + let mut s = session(); + let id = long_peak_segment(&mut s); + let before = path(&s, id); + let drag = [Point::new(100.0, 200.0), Point::new(100.0, 320.0), Point::new(185.0, 320.0), Point::new(185.0, 126.0), Point::new(215.0, 126.0)]; + assert_eq!(smooth_pass_ids(&mut s, &drag), json!([id.0])); + let after = path(&s, id); + let (b0, b1) = (sharpest_bend_near_200(&before), sharpest_bend_near_200(&after)); + assert!(b1 < 0.97 * b0, "the peak's sharpest bend {b0} -> {b1}"); + let between = before.subpaths[0].segment(0).eval(0.3); + let sp = &after.subpaths[0]; + let gap = (0..sp.segment_count()) + .flat_map(|i| (0..=400).map(move |k| sp.segment(i).eval(f64::from(k) / 400.0))) + .map(|r| r.distance(between)) + .fold(f64::INFINITY, f64::min); + assert!(gap < 0.5, "{between:?} is {gap} pt off the smoothed path"); +} + +/// A click on a curve 100,000 pt long, between the places a fixed number of samples along it would +/// look at, still finds the curve under it. +#[test] +fn a_smooth_drag_finds_a_very_long_curve_under_it() { + use kurbo::ParamCurve; + let mut s = session(); + let anchors = [json!({"x": 0.0, "y": 0.0, "out": [30000.0, -9000.0]}), json!({"x": 100000.0, "y": 0.0, "in": [70000.0, -9000.0]})]; + let id = smooth_test_path(&mut s, &anchors, false); + let at = path(&s, id).subpaths[0].segment(0).eval(0.5 + 0.5 / 1024.0); + assert_eq!(smooth_pass_ids(&mut s, &[at]), json!([id.0])); +} + +/// A click just within the reach of a segment's peak, with both of the segment's anchors out of +/// reach, smooths the segment although only a few points of it are within the reach. +#[test] +fn a_smooth_click_that_only_just_reaches_a_segment_smooths_it() { + let mut s = session(); + let id = long_peak_segment(&mut s); + let before = path(&s, id); + assert_eq!(smooth_pass_ids(&mut s, &[Point::new(200.0, 113.1)]), json!([id.0])); + assert_ne!(path(&s, id), before); +} + +/// A drag that runs along a very long curve first and then crosses a second curve away from its +/// middle, with both of its anchors out of reach, smooths the second curve too: each curve in reach +/// gets the same share of the search, so the long one does not use it all. +#[test] +fn a_long_curve_searched_first_leaves_the_next_curve_its_share() { + let mut s = session(); + let long = smooth_test_path( + &mut s, + &[json!({"x": 0.0, "y": 0.0, "out": [30000.0, 1.0]}), json!({"x": 100000.0, "y": 0.0, "in": [70000.0, 1.0]})], + false, + ); + let next = smooth_test_path( + &mut s, + &[json!({"x": 100100.0, "y": -100.0, "out": [100130.0, 200.0]}), json!({"x": 100100.0, "y": 900.0, "in": [100130.0, 600.0]})], + false, + ); + s.execute("select.set", &json!({"ids": [long.0, next.0]})).unwrap(); + let before = path(&s, next); + let mut drag: Vec = (0..=1000).map(|i| Point::new(f64::from(i) * 100.0, 0.0)).collect(); + drag.push(Point::new(100200.0, 0.0)); + let ids = smooth_pass_ids(&mut s, &drag); + assert!(ids.as_array().is_some_and(|v| v.contains(&json!(next.0))), "{ids}"); + assert_ne!(path(&s, next), before); +} + +/// An open path of two anchors is smoothed: an S-curve smoothed along its whole length. +#[test] +fn smoothing_smooths_an_open_two_anchor_path() { + use kurbo::ParamCurve; + let mut s = session(); + let anchors = [json!({"x": 100.0, "y": 200.0, "out": [170.0, 130.0]}), json!({"x": 200.0, "y": 200.0, "in": [130.0, 270.0]})]; + let id = smooth_test_path(&mut s, &anchors, false); + let before = path(&s, id); + let seg = before.subpaths[0].segment(0); + let drag: Vec = (0..=20).map(|k| seg.eval(f64::from(k) / 20.0)).collect(); + assert_eq!(smooth_pass_ids(&mut s, &drag), json!([id.0])); + let after = path(&s, id); + let (t0, t1) = (total_turning(&before), total_turning(&after)); + assert!(t1 < t0, "open: turning {t0} -> {t1}"); + let a = &after.subpaths[0].anchors; + assert_eq!((a[0].p, a[a.len() - 1].p), (Point::new(100.0, 200.0), Point::new(200.0, 200.0)), "the ends stay"); +} + +/// A closed path of two anchors is smoothed along the middle of one side and stays closed. +#[test] +fn smoothing_smooths_a_closed_two_anchor_path() { + use kurbo::ParamCurve; + let mut s = session(); + let anchors = [ + json!({"x": 100.0, "y": 300.0, "in": [100.0, 400.0], "out": [100.0, 200.0]}), + json!({"x": 300.0, "y": 300.0, "in": [300.0, 160.0], "out": [300.0, 440.0]}), + ]; + let id = smooth_test_path(&mut s, &anchors, true); + let before = path(&s, id); + let top = before.subpaths[0].segment(0); + let mid = top.eval(0.5); + assert_eq!(smooth_pass_ids(&mut s, &[mid - kurbo::Vec2::new(10.0, 0.0), mid + kurbo::Vec2::new(10.0, 0.0)]), json!([id.0])); + let after = path(&s, id); + assert!(after.subpaths[0].closed && after != before, "{after:?}"); +} + +/// A drag that passes nowhere near a path's segments, or only over the middle of a straight +/// segment, leaves the path as it was. +#[test] +fn smoothing_leaves_paths_out_of_reach_and_straight_segments_alone() { + let mut s = session(); + let id = long_peak_segment(&mut s); + let before = path(&s, id); + assert_eq!(smooth_pass_ids(&mut s, &smooth_drag_along(260.0, 100.0, 400.0)), json!([]), "60 pt off the path"); + assert_eq!(path(&s, id), before); + let line = smooth_test_path(&mut s, &[json!({"x": 100.0, "y": 400.0}), json!({"x": 300.0, "y": 400.0}), json!({"x": 300.0, "y": 450.0})], false); + let before = path(&s, line); + assert_eq!(smooth_pass_ids(&mut s, &smooth_drag_along(400.0, 190.0, 210.0)), json!([]), "a straight segment"); + assert_eq!(path(&s, line), before); +} + #[test] fn join_tool_joins_two_lines() { let mut s = session(); diff --git a/crates/tools/src/draw2.rs b/crates/tools/src/draw2.rs index f586650200..f57936bd83 100644 --- a/crates/tools/src/draw2.rs +++ b/crates/tools/src/draw2.rs @@ -42,6 +42,28 @@ pub const MAX_FIT_TOLERANCE: f64 = 100.0 / 0.0313; /// The lowest Fidelity (points) the Smooth tool takes. pub const SMOOTH_MIN_FIDELITY: f64 = 0.05; +/// Whether segment `i` of `sp` is straight, so the Smooth tool leaves it as it is: its handles lie +/// on the line through its ends, and the segment runs from one end to the other without turning back. +pub fn segment_is_straight(sp: &SubPath, i: usize) -> bool { + let c = sp.segment(i); + let chord = c.p3 - c.p0; + let len = chord.hypot(); + if len < 1e-9 { + return c.p1.distance(c.p0) < 1e-9 && c.p2.distance(c.p0) < 1e-9; + } + // A millionth of a point, and the rounding error of a very long segment's coordinates. + let eps = 1e-6 + 1e-9 * len; + let off = |q: Point| chord.cross(q - c.p0).abs() / len; + if off(c.p1) > eps || off(c.p2) > eps { + return false; + } + // How far along the line the segment moves at its start, middle and end (the Bernstein + // coefficients of its speed along it): it never moves backward when these allow no negative. + let along = |q: Point| chord.dot(q - c.p0) / len; + let (a, b, e) = (along(c.p1), along(c.p2) - along(c.p1), len - along(c.p2)); + a >= -eps && e >= -eps && (b >= -eps || b * b <= a * e + eps) +} + /// The lowest Fidelity tool `id` takes. pub fn min_fidelity(id: &str) -> f64 { if id == "smooth" { SMOOTH_MIN_FIDELITY } else { MIN_FIDELITY } @@ -417,6 +439,22 @@ mod tests { use super::*; use vectorcraft_geom::ParamCurve; + /// A segment is straight only when its handles lie on its line between its ends; a bend of 50 pt + /// on a segment 100,000,000 pt long is a curve. + #[test] + fn segment_is_straight_needs_handles_on_the_line_between_the_ends() { + let seg = |h1: (f64, f64), h2: (f64, f64), end: f64| { + let a = Anchor::with_handles(Point::new(0.0, 0.0), Point::new(0.0, 0.0), Point::new(h1.0, h1.1)); + let b = Anchor::with_handles(Point::new(end, 0.0), Point::new(h2.0, h2.1), Point::new(end, 0.0)); + segment_is_straight(&SubPath::new(vec![a, b], false), 0) + }; + assert!(seg((0.0, 0.0), (100.0, 0.0), 100.0)); + assert!(seg((30.0, 0.0), (70.0, 0.0), 100.0)); + assert!(!seg((30.0, 1.0), (70.0, 0.0), 100.0)); + assert!(!seg((150.0, 0.0), (-50.0, 0.0), 100.0), "turns back along its line"); + assert!(!seg((3e7, 50.0), (7e7, 50.0), 1e8), "a long, gentle bend"); + } + #[test] fn catmull_rom_passes_through_points() { let pts = [(Point::new(0.0, 0.0), false), (Point::new(50.0, 50.0), false), (Point::new(100.0, 0.0), false)]; diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index d426044379..153d7103ff 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -149,13 +149,15 @@ fn redraw_targets(cx: &ToolContext, id: NodeId) -> Vec { } } -/// Whether the selection contains a path the Smooth command can act on. +/// Whether the selection contains a path the Smooth command can act on: one with a subpath of three +/// or more anchors, or of two anchors joined by a curve. fn has_selected_smooth_path(cx: &ToolContext) -> bool { let editable = cx.doc.editable(); + let smoothable = |sp: &vectorcraft_geom::SubPath| sp.anchors.len() >= 3 || (0..sp.segment_count()).any(|i| !super::segment_is_straight(sp, i)); cx.selection.objects.iter().filter_map(|id| cx.doc.node(*id)).any(|n| { let mut found = false; n.walk(&mut |child| { - if matches!(&child.kind, NodeKind::Path { guide: false, path, .. } if path.control_bounds().is_some() && path.subpaths.iter().any(|sp| sp.anchors.len() >= 3)) + if matches!(&child.kind, NodeKind::Path { guide: false, path, .. } if path.control_bounds().is_some() && path.subpaths.iter().any(smoothable)) && editable(child.id) { found = true; diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index f367040611..aba0188e02 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -1148,6 +1148,35 @@ fn paintbrush_alt_with_only_two_anchor_line_selected_draws_a_closed_stroke() { assert!(matches!(acts.last().and_then(|a| a.get(1)), Some(Action::Commit)), "{acts:?}"); } +/// A selected two-anchor curve can be smoothed, so Alt Paintbrush smooths it with the Smooth cursor, +/// as it does a segment along a line that runs past its end and turns back; a two-anchor line whose +/// handles lie along it between its ends cannot, so Alt still draws the closed stroke. +#[test] +fn paintbrush_alt_with_a_two_anchor_curve_selected_smooths() { + let p = paint(); + let alt = Mods { alt: true, ..Default::default() }; + let cases = [((340.0, 250.0), (360.0, 350.0), true), ((450.0, 300.0), (250.0, 300.0), true), ((330.0, 300.0), (370.0, 300.0), false)]; + for (h1, h2, smooths) in cases { + let (mut d, _) = doc_with_rect(); + let layer = d.layers[0].id; + let id = d.alloc_id(); + let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ + vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), + vectorcraft_geom::PathEl::CurveTo(Point::new(h1.0, h1.1), Point::new(h2.0, h2.1), Point::new(400.0, 300.0)), + ])); + d.insert(Some(layer), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut selected = Selection::default(); + selected.set([id]); + let cx = cx(&d, &selected, &p); + let mut t = GestureTool::new("paintbrush"); + let want = if smooths { Cursor::Smooth } else { Cursor::PaintbrushClose }; + assert_eq!(t.cursor(&cx, Point::new(50.0, 150.0), alt), want, "handles {h1:?} {h2:?}"); + let acts = drag_held(&mut t, &cx, &[(300.0, 300.0), (350.0, 300.0), (400.0, 300.0)], alt); + let smoothed = matches!(acts.last().and_then(|a| a.first()), Some(Action::Exec(c, _)) if c == "path.smoothRegion"); + assert_eq!(smoothed, smooths, "handles {h1:?} {h2:?}: {acts:?}"); + } +} + /// #3799: Alt's line starts and ends on Smart Guides as a stroke does, and pressed on an end of /// a selected open path it continues the path from that end to the pointer. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c50a45b639..c5e17d7ee2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1109,6 +1109,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Pencil's and the Paintbrush's Fidelity is in screen pixels, the same at any zoom: their Tool Options give it in px, from 0.01 px on the logarithmic slider to 20 px and up to 100 px in the field, and the tool sends `path.freehand` the value in points at the current zoom (#5729). The Smooth tool's and the Blob Brush's Fidelity stays in points. `path.freehand` and `brush.freehand` take a Fidelity of up to 100 / 0.0313 pt (about 3194.89 pt), the 100 px field maximum at the lowest zoom (3.13 %), and reject a value outside their range with an error, so a Pencil or Paintbrush stroke drawn zoomed out keeps the Fidelity set in pixels (engine tests `pencil_fit_keeps_its_anchors_on_a_stroke_drawn_zoomed_out` and `pencil_takes_fidelity_within_its_range_and_rejects_the_rest`). Each drag of the Smooth tool smooths the stretch of a selected path in its reach along its length before refitting it within Fidelity, so a wobble wider than Fidelity is smoothed out and another drag over the same place smooths it further, as [the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html) describes (`path.smoothRegion`). The smoothing spread (a third of the reach) and the rule that each refit stays within twice the distance the smoothing moved the path, so repeated drags do not drift from it, are VectorCraft's own. A drag over the whole of a closed path smooths it across its first anchor too, so a bump there is smoothed like any other and no corner is left; Object › Path › Smooth smooths a closed path across its first anchor the same way. + A Smooth drag along a curved segment smooths the stretch of it in the drag's reach even when neither of the segment's anchors is in reach, as [the help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/refine-paths.html) describes dragging along the path segment; the segment is cut where the drag enters and leaves the reach, and the parts outside keep their shape (`path.smoothRegion`). Paths of two anchors are smoothed too, and Alt with the Paintbrush smooths a selected two-anchor curve; a two-anchor straight line still gets the closed stroke. How the reach is found on a segment (samples no more than half the reach apart) is VectorCraft's own, and a straight segment is left as it is. Before a press that draws a new path, the Pencil cursor shows a small star, as [Draw freeform paths with the Pencil tool](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-freeform-paths-with-the-pencil-tool.html) describes; over a selected path within Edit selected paths' distance, which the press continues from an end or redraws, the star is not shown ([Reshape paths with the Pencil tool](https://web.archive.org/web/20260106004407/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/reshape-paths-with-the-pencil-tool.html)). Keep selected in the Pencil and Paintbrush Tool Options is on by default: while it is on, a stroke selects the path it draws or continues and replaces the earlier selection. While it is off, a new stroke leaves nothing selected, the earlier selection included, as the reference application starts the stroke by dropping it (#5752); a continued or redrawn path is deselected while the rest of the selection stays (`path.freehand` `keepSelected`). With two open paths selected, a Pencil stroke from an end of one that ends within Edit selected paths' distance of an end of the other connects them into one path with the first path's appearance, in one undo step, as [Connect two paths with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/connect-two-paths-with-the-pencil-tool.html) describes (`path.freehand` `connect`). From 444f50a4c4705117915b35438ff78de459c97a65 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 10:49:22 -0800 Subject: [PATCH 11/14] Objects on Path: each path is flattened once per placement or transform stops and stop flattened the path twice, once for the points and once for its length, and stop did both before rejecting an index past the last object. place validated the path with attachable_path first, and transform measured the old and new paths up to three times each. A private Measured value now holds the flattened first subpath with its length and closed flag, and path_length, stops, stop and transform use it. stop returns None for an out-of-range index without measuring, and a move of the group measures no path. attachable_path now uses path_length, so a path whose length overflows to infinity is no longer offered as one objects can attach to when placement cannot measure it. --- crates/doc/src/blend.rs | 9 ++ crates/doc/src/objects_on_path.rs | 150 ++++++++++++++++++++++++------ docs/target-app-parity.md | 2 + 3 files changed, 131 insertions(+), 30 deletions(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index d170bd606f..d936891514 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1205,6 +1205,13 @@ pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) -> // Spine // ===================================================================================== +#[cfg(test)] +thread_local! { + /// How many times [`Spine::new`] has run on this thread, for tests that count how often a path + /// is flattened. + pub(crate) static SPINES_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// A spine (the first subpath of a path) flattened to a polyline, with cumulative lengths and the /// arc length at each of its anchors. pub struct Spine { @@ -1223,6 +1230,8 @@ impl Spine { /// arc lengths, and the arc length at each anchor. `None` when `path` has no subpath, or when /// the first one flattens to fewer than two points (so it has no length to walk). pub fn new(path: &PathData) -> Option { + #[cfg(test)] + SPINES_BUILT.with(|n| n.set(n.get() + 1)); let sp = path.subpaths.first()?; let mut pts: Vec = Vec::new(); let mut cum: Vec = Vec::new(); diff --git a/crates/doc/src/objects_on_path.rs b/crates/doc/src/objects_on_path.rs index 26a11cf733..35de7cce73 100644 --- a/crates/doc/src/objects_on_path.rs +++ b/crates/doc/src/objects_on_path.rs @@ -121,19 +121,22 @@ impl ObjectsOnPath { if a.determinant() < 0.0 && self.rotate != 0.0 { self.rotate = -self.rotate; } - let along = paths.and_then(|(old, new)| { - let s = path_length(new)?.0 / path_length(old)?.0; - (s.is_finite() && s > 0.0).then_some(s) - }); - let scale = along.unwrap_or_else(|| a.determinant().abs().sqrt()); // A move leaves the distances along the path as they are. let moves = a0 == 1.0 && a1 == 0.0 && a2 == 0.0 && a3 == 1.0; - if !moves && scale.is_finite() && scale > 0.0 { - let scaled = |v: f64| Some(v * scale).filter(|s| s.is_finite()).unwrap_or(v); - let moved = paths.and_then(|(old, new)| self.moved_along(a, old, new, scale)); - let (spacing, start) = moved.unwrap_or((None, None)); - self.spacing = self.spacing.map(|g| spacing.unwrap_or_else(|| scaled(g))); - self.start = start.unwrap_or_else(|| scaled(self.start)); + if !moves { + let measured = paths.and_then(|(old, new)| Some((Measured::new(old)?, Measured::new(new)?))); + let along = measured.as_ref().and_then(|(was, is)| { + let s = is.len / was.len; + (s.is_finite() && s > 0.0).then_some(s) + }); + let scale = along.unwrap_or_else(|| a.determinant().abs().sqrt()); + if scale.is_finite() && scale > 0.0 { + let scaled = |v: f64| Some(v * scale).filter(|s| s.is_finite()).unwrap_or(v); + let moved = measured.as_ref().and_then(|(was, is)| self.moved_along(a, was, is, scale)); + let (spacing, start) = moved.unwrap_or((None, None)); + self.spacing = self.spacing.map(|g| spacing.unwrap_or_else(|| scaled(g))); + self.start = start.unwrap_or_else(|| scaled(self.start)); + } } let after = tilt(self.rotate); for m in &mut self.members { @@ -150,11 +153,10 @@ impl ObjectsOnPath { /// point on `old`, mapped by `a`, measured along `new` near `scale` times its distance along /// `old`. Either one is `None` when it cannot be followed that way: a start before or beyond the /// ends of an open path, or a Space value of 0, one the path has no room for, or one with fewer - /// than two objects. `None` for a path without length or a start that is not finite. - fn moved_along(&self, a: Affine, old: &PathData, new: &PathData, scale: f64) -> Option<(Option, Option)> { - let (was, is) = (Spine::new(old)?, Spine::new(new)?); - let (len, closed) = path_length(old)?; - let (new_len, _) = path_length(new)?; + /// than two objects. `None` for a start that is not finite. + fn moved_along(&self, a: Affine, old: &Measured, new: &Measured, scale: f64) -> Option<(Option, Option)> { + let (was, is) = (&old.spine, &new.spine); + let (len, closed, new_len) = (old.len, old.closed, new.len); if !self.start.is_finite() { return None; } @@ -230,10 +232,10 @@ pub fn is_objects_on_path(n: &Node) -> bool { } /// The path of `n` if it is a path object an Objects on Path group can attach objects to: a path -/// (not a guide) whose first subpath has some length. +/// (not a guide) whose first subpath has a length ([`path_length`]). pub fn attachable_path(n: &Node) -> Option<&PathData> { match &n.kind { - NodeKind::Path { path, guide: false, .. } if Spine::new(path).is_some_and(|s| s.length() > 1e-9) => Some(path), + NodeKind::Path { path, guide: false, .. } if path_length(path).is_some() => Some(path), _ => None, } } @@ -292,11 +294,27 @@ pub fn upright_frame(n: &Node) -> Affine { } /// The length of `path`'s first subpath and whether it is closed. `None` for a path without -/// length. +/// length, or with a length too large to be finite. pub fn path_length(path: &PathData) -> Option<(f64, bool)> { - let len = Spine::new(path)?.length(); - let closed = path.subpaths.first().is_some_and(|s| s.closed); - (len.is_finite() && len > 1e-9).then_some((len, closed)) + Measured::new(path).map(|m| (m.len, m.closed)) +} + +/// A path's first subpath flattened ([`Spine`]), with its length and whether it is closed, so that +/// one flattening serves both measuring the path and finding points along it. +struct Measured { + spine: Spine, + len: f64, + closed: bool, +} + +impl Measured { + /// `None` for a path without length, or with a length too large to be finite. + fn new(path: &PathData) -> Option { + let spine = Spine::new(path)?; + let len = spine.length(); + let closed = path.subpaths.first().is_some_and(|s| s.closed); + (len.is_finite() && len > 1e-9).then_some(Self { spine, len, closed }) + } } /// Where the first of `n` objects attaches along a path of length `len` (closed or not), and the @@ -341,18 +359,20 @@ fn wrap(s: f64, len: f64) -> f64 { /// (radians, as `atan2` gives in the document's axes). They go along it as [`layout`] spaces them /// for Space `spacing` (`None`: Auto) from `start`. `None` for a path without length. pub fn stops(path: &PathData, n: usize, spacing: Option, start: f64) -> Option> { - let spine = Spine::new(path)?; - let (len, closed) = path_length(path)?; + let Measured { spine, len, closed } = Measured::new(path)?; let (start, gap) = layout(len, closed, n, spacing, start); Some((0..n).map(|i| spine.at_length(nth_length(len, closed, start, gap, i))).collect()) } /// Where object `i` of `n` attaches along `path`, as [`stops`] places it, without the others. +/// `None` for an `i` of `n` or more, without measuring the path. pub fn stop(path: &PathData, n: usize, spacing: Option, start: f64, i: usize) -> Option<(Point, f64)> { - let spine = Spine::new(path)?; - let (len, closed) = path_length(path)?; + if i >= n { + return None; + } + let Measured { spine, len, closed } = Measured::new(path)?; let (start, gap) = layout(len, closed, n, spacing, start); - (i < n).then(|| spine.at_length(nth_length(len, closed, start, gap, i))) + Some(spine.at_length(nth_length(len, closed, start, gap, i))) } /// The distance along a path of length `len` (closed or not) to object `i`, from `start` and @@ -400,11 +420,15 @@ pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool { let old = rec.clone(); let NodeKind::Group { children, .. } = &mut g.kind else { return false }; let path_id = rec.path; - let Some(path) = children.iter().find(|c| c.id == path_id).filter(|c| matches!(c.kind, NodeKind::Path { .. })) else { + let Some(path) = children.iter().find(|c| c.id == path_id).filter(|c| matches!(c.kind, NodeKind::Path { .. })).cloned() else { g.extras_mut().objects_on_path = None; return true; }; - let path = attachable_path(path).cloned(); + // `stops` measures the path: on a path without length no object moves. + let path = match &path.kind { + NodeKind::Path { path, guide: false, .. } => Some(path), + _ => None, + }; let at: HashMap = children.iter().enumerate().map(|(i, c)| (c.id, i)).collect(); rec.members.retain(|m| m.id != path_id && at.contains_key(&m.id)); let mut listed: HashSet = rec.members.iter().map(|m| m.id).collect(); @@ -414,7 +438,7 @@ pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool { } } let mut moved = false; - if let Some(stops) = path.and_then(|p| stops(&p, rec.members.len(), rec.spacing, rec.start)) { + if let Some(stops) = path.and_then(|p| stops(p, rec.members.len(), rec.spacing, rec.start)) { let turn = tilt(rec.rotate); for (m, (p, dir)) in rec.members.iter_mut().zip(stops) { let Some(c) = at.get(&m.id).and_then(|i| children.get_mut(*i)) else { continue }; @@ -709,6 +733,72 @@ mod tests { assert!((back.angle - 0.5).abs() < 1e-12, "{back:?}"); } + /// What `f` returns, and how many times it flattened a path ([`Spine::new`]). + fn flattenings(f: impl FnOnce() -> T) -> (T, usize) { + crate::blend::SPINES_BUILT.with(|n| n.set(0)); + let out = f(); + (out, crate::blend::SPINES_BUILT.with(|n| n.get())) + } + + #[test] + fn stops_and_stop_flatten_the_path_once() { + let open = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0)); + let closed = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)); + for path in [&open, &closed] { + let (all, count) = flattenings(|| stops(path, 4, Some(40.0), 25.0)); + let all = all.unwrap(); + assert_eq!((all.len(), count), (4, 1)); + for (i, s) in all.iter().enumerate() { + assert_eq!(flattenings(|| stop(path, 4, Some(40.0), 25.0, i)), (Some(*s), 1), "object {i}"); + } + assert_eq!(flattenings(|| stop(path, 4, Some(40.0), 25.0, 4)), (None, 0), "an index past the last object"); + assert_eq!(flattenings(|| stop(path, 0, None, 0.0, 0)), (None, 0), "no objects"); + } + let point = shapes::line(Point::ZERO, Point::ZERO); + assert!(stops(&point, 2, None, 0.0).is_none() && stop(&point, 2, None, 0.0, 0).is_none()); + } + + #[test] + fn transforming_the_group_flattens_each_path_once() { + let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); + let b = rect(3, Rect::new(20.0, 0.0, 30.0, 10.0)); + let level = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0)); + let stretch = Affine::scale_non_uniform(2.0, 1.0); + let wide = level.transformed(stretch); + let mut rec = ObjectsOnPath { spacing: Some(40.0), start: 10.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0) }; + let ((), count) = flattenings(|| rec.transform(stretch, Some((&level, &wide)))); + assert_eq!(count, 2, "the path before the transform and after it"); + assert!((rec.start - 20.0).abs() < 1e-9 && (rec.spacing.unwrap() - 80.0).abs() < 1e-9, "{rec:?}"); + let moved = level.transformed(Affine::translate((5.0, 5.0))); + let ((), count) = flattenings(|| rec.transform(Affine::translate((5.0, 5.0)), Some((&level, &moved)))); + assert_eq!(count, 0, "a move leaves the distances along the path as they are"); + } + + #[test] + fn placing_the_objects_flattens_the_path_once() { + let line = Node::path(NodeId(1), shapes::line(Point::new(0.0, 100.0), Point::new(300.0, 100.0)), Default::default()); + let (a, b) = (rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)), rect(3, Rect::new(20.0, 0.0, 30.0, 10.0))); + let mut g = Node::new(NodeId(4), NodeKind::Group { children: vec![Arc::new(line), Arc::new(a.clone()), Arc::new(b.clone())], clip: false }); + g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0))); + let empty = crate::Document::new(1.0, 1.0); + let symbols = empty.symbol_boxes(); + assert_eq!(flattenings(|| place(&mut g, &symbols)), (true, 1)); + let x = |id| g.children().and_then(|c| c.iter().find(|c| c.id == id).cloned()).unwrap().geometric_bounds().unwrap().center().x; + assert!((x(NodeId(2))).abs() < 1e-9 && (x(NodeId(3)) - 300.0).abs() < 1e-9, "{} {}", x(NodeId(2)), x(NodeId(3))); + } + + /// A path whose length adds up past the largest finite number cannot be measured, so nothing + /// attaches to it. + #[test] + fn a_path_too_long_to_measure_is_not_attachable() { + let huge = Node::path(NodeId(1), shapes::line(Point::new(-1e308, 0.0), Point::new(1e308, 0.0)), Default::default()); + let NodeKind::Path { path, .. } = &huge.kind else { panic!("a path") }; + assert!(path_length(path).is_none() && stops(path, 2, None, 0.0).is_none()); + assert!(attachable_path(&huge).is_none()); + let square = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); + assert!(attachable_path(&square).is_some()); + } + #[test] fn a_group_without_its_path_is_a_plain_group() { let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c50a45b639..7db39ff6b6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1832,6 +1832,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Positions along a blend spine:** blend steps, keys spread along a spine, the blend widgets and Objects on Path find the spine segment for each arc length with a binary search over the anchors' arc lengths, so a spine of 4,096 segments takes at most 13 comparisons per position instead of one per segment before it. Points and tangents are unchanged, including at anchors shared by zero-length segments. Evidence: `blend::tests::{spine_lookup_compares_a_few_segment_ends, spine_lookup_matches_walking_the_segments}`. +- **Objects on Path measures its path once:** placing the objects, finding one object's place and transforming the group flatten each path once and use it both for the path's length and for the points along it, where they flattened it two or three times before; a request for an object past the last one returns nothing without flattening the path, and a move of the group flattens no path. A path whose length adds up past the largest finite number no longer counts as one objects can be attached to. Evidence: `objects_on_path::tests::{stops_and_stop_flatten_the_path_once, transforming_the_group_flattens_each_path_once, placing_the_objects_flattens_the_path_once, a_path_too_long_to_measure_is_not_attachable}` in `crates/doc`. + - **Blend, mesh and envelope numbers given as text:** `object.blend.make` and `object.blend.options` (`steps`, `distance`, `value`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`bend`, `h`, `v`, `horizontalDistortion`, `verticalDistortion`), `object.envelope.makeWithMesh` and `resetWithMesh` (`rows`, `cols`, `columns`), `object.envelope.options` (`fidelity`), `object.mesh.create` (`rows`, `cols`, `columns`, `highlight`) and `object.mesh.setPointColor` (`opacity`) refuse a value other than a number or null, such as `"8"`, and change nothing. `object.mesh.create` refuses an `at` that is not `[x, y]`. - **Blend and envelope options given as text:** `object.blend.make` and `object.blend.options` (`smooth`), `object.blend.spine.moveAnchor` (`independent`) and `reshapeSegment` (`perpendicular`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`horizontal`), `object.envelope.resetWithMesh` (`maintainShape`), `object.envelope.options` (`antiAlias`, `distortAppearance`, `distortLinearGradients`, `distortPatternFills`) and `object.envelope.editContents` (`editing`) refuse a value other than true, false or null, such as `"false"`, and leave the document and its undo history as they were. From 2aa7bbd7912e50b8d9574b42b2429bd57bf8fe14 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 10:50:53 -0800 Subject: [PATCH 12/14] Plug-ins: file-key exemptions name only params a command takes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A command now counts as taking a file or folder when its params description (with the commands its `…` entries name) or its schema takes a file key as a param. A word in the description's prose or result no longer counts; a description whose keys are not known keeps the text scan. The code of the call-time scan of arguments is unchanged, but it reads the changed exemption tables. The confined run is unchanged. - NOT_FILES drops seven entries that named no param of their command (object.setLiveShape path, path.reverse paths, object.pathfinder.options paths, object.maskImage path, imageTrace.make paths, svgInteractivity.info files, addon.cleanup.rejoin paths). A top-level `path` to object.setLiveShape or object.maskImage, `paths` to path.reverse, imageTrace.make or addon.cleanup.rejoin, and `files` to svgInteractivity.info are now refused. - Image Trace's path fidelity setting is exempt only inside `params` and `savedPreset` of imageTrace.make and imageTrace.makeAndExpand, so a call with it is no longer refused. - every_command_is_sorted checks that each exemption names a param its command takes, and that each nested exemption starts at one. - effect.expandAsWireframes is reviewed as allowed and stroke.arrowheads.list as refused. --- crates/engine/src/cmd/params.rs | 7 + crates/engine/src/cmd/plugin_call.rs | 143 ++++++++++++++---- crates/engine/src/cmd/plugin_call/reviewed.rs | 6 +- docs/target-app-parity.md | 2 +- 4 files changed, 128 insertions(+), 30 deletions(-) diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index c38854ae76..c4dd000d12 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -172,6 +172,13 @@ fn accepted(doc: &'static str, depth: usize) -> Option> { Some(keys) } +/// Every key the description `doc` takes, those of the commands its `…` entries name included, or +/// `None` when that isn't known: it doesn't parse, a `…` entry names no command, or one names a +/// command in [`UNCHECKED`]. +pub(crate) fn accepted_keys(doc: &'static str) -> Option> { + accepted(doc, 0) +} + /// Does the description `doc` name `key` as a parameter (its own keys, and those of the commands /// its `…` entries name)? pub(crate) fn takes_key(doc: &'static str, key: &str) -> bool { diff --git a/crates/engine/src/cmd/plugin_call.rs b/crates/engine/src/cmd/plugin_call.rs index 71fbcc8540..d42cc5b82c 100644 --- a/crates/engine/src/cmd/plugin_call.rs +++ b/crates/engine/src/cmd/plugin_call.rs @@ -150,28 +150,47 @@ pub fn location_keys(spec: &CommandSpec) -> Vec<(&'static str, Location)> { keys } -/// Commands and the param key in [`FILE_KEYS`] that, in them, names part of the document (a path of -/// its art, a setting, an attribute it stores or a result field), not a file. +/// Commands and the param of theirs, a key in [`FILE_KEYS`], that names part of the document (a path +/// of its art, a setting or an attribute it stores), not a file. Each names a param the command +/// takes ([`takes_param`]). pub(crate) const NOT_FILES: &[(&str, &str)] = &[ - ("object.setLiveShape", "path"), - ("path.reverse", "paths"), - ("object.pathfinder.options", "paths"), - ("object.maskImage", "path"), ("text.createInPath", "path"), - ("imageTrace.make", "paths"), ("symbol.instance.setAppearance", "path"), ("symbol.instance.selectPart", "path"), ("object.objectsOnPath.attach", "path"), ("attributes.set", "url"), - ("svgInteractivity.info", "files"), - ("addon.cleanup.rejoin", "paths"), ("object.slice.options", "url"), ("document.exportForScreens", "folders"), ]; /// Commands and the keys from the top of their params down to a key in [`FILE_KEYS`] that, there, -/// names part of the document, not a file: a custom arrowhead's outline in `stroke.set`. -const NESTED_NOT_FILES: &[(&str, &[&str])] = &[("stroke.set", &["startArrow", "Custom", "path"]), ("stroke.set", &["endArrow", "Custom", "path"])]; +/// names part of the document, not a file: a custom arrowhead's outline in `stroke.set`, and the +/// path fidelity setting (0 to 100) of Image Trace's tracing settings. The first key of each is a +/// param the command takes ([`takes_param`]). +const NESTED_NOT_FILES: &[(&str, &[&str])] = &[ + ("stroke.set", &["startArrow", "Custom", "path"]), + ("stroke.set", &["endArrow", "Custom", "path"]), + ("imageTrace.make", &["params", "paths"]), + ("imageTrace.make", &["savedPreset", "paths"]), + ("imageTrace.makeAndExpand", &["params", "paths"]), + ("imageTrace.makeAndExpand", &["savedPreset", "paths"]), +]; + +/// Does `spec` take `key` as a param: its params description names it as one of its keys, or one +/// of the commands its `…` entries name does ([`super::params::accepted_keys`]), or its schema has +/// it? When the description doesn't say which keys it takes, any `key:` in its text counts +/// ([`names_key`]). A word in its prose, in a nested object or in its result isn't a param. +fn takes_param(spec: &CommandSpec, key: &str) -> bool { + spec.schema.is_some_and(|s| s.params.iter().any(|p| p.name == key)) || described_param(spec.params, key) +} + +/// [`takes_param`] for the params description `doc` alone. +fn described_param(doc: &'static str, key: &str) -> bool { + match super::params::accepted_keys(doc) { + Some(keys) => keys.contains(&key), + None => names_key(doc, key) || super::params::takes_key(doc, key), + } +} /// Is command id `id` one a plug-in may never call, from its id alone ([`DENIED_PREFIXES`], /// [`FILE_WORDS`])? @@ -195,16 +214,14 @@ pub(crate) fn denied(spec: &CommandSpec) -> Option<&'static str> { None } -/// Why a plug-in may never call `spec` from its id ([`denied_id`]) or its params (a key in -/// [`FILE_KEYS`] that [`NOT_FILES`] doesn't name for it), or `None`. +/// Why a plug-in may never call `spec` from its id ([`denied_id`]) or its params (a param in +/// [`FILE_KEYS`], [`takes_param`], that [`NOT_FILES`] doesn't name for it), or `None`. fn denied_by_rule(spec: &CommandSpec) -> Option<&'static str> { let id = spec.id; if denied_id(id) { return Some("id"); } - let mut keys = FILE_KEYS.iter().filter(|k| !NOT_FILES.contains(&(id, **k))); - let in_schema = |k: &str| spec.schema.is_some_and(|s| s.params.iter().any(|p| p.name == k)); - if keys.any(|k| names_key(spec.params, k) || in_schema(k)) { + if FILE_KEYS.iter().any(|k| !NOT_FILES.contains(&(id, *k)) && takes_param(spec, k)) { return Some("file key"); } None @@ -498,9 +515,10 @@ impl Saved { mod tests { use super::*; - /// Every command in the registry is sorted: the ones a plug-in may call name no file, folder, - /// clipboard, preference or script in their params text, and the known file, document, script - /// and history commands are refused. + /// Every command in the registry is sorted: the ones a plug-in may call take no file, folder, + /// clipboard, preference or script param, and the known file, document, script and history + /// commands are refused. Each [`NOT_FILES`] key, and the first key of each [`NESTED_NOT_FILES`] + /// list, is a param its command takes, not a word in its description's prose or result. #[test] fn every_command_is_sorted() { let specs = crate::cmd::command_specs(); @@ -521,18 +539,43 @@ mod tests { "edit.copy", "edit.paste", "prefs.set", + "stroke.arrowheads.list", ] { let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") }; assert!(denied(spec).is_some(), "{id} is allowed"); } - for id in ["select.all", "object.transform", "path.reverse", "symbol.instance.selectPart"] { + for id in [ + "select.all", + "object.transform", + "path.reverse", + "symbol.instance.selectPart", + "object.setLiveShape", + "object.maskImage", + "imageTrace.make", + "svgInteractivity.info", + "addon.cleanup.rejoin", + ] { let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") }; assert_eq!(denied(spec), None, "{id} is refused"); } - for (id, key) in NOT_FILES { + let Some(options) = crate::cmd::find_command("object.pathfinder.options") else { panic!("no command object.pathfinder.options") }; + assert_eq!(denied(options), Some("reviewed")); + let real = |id: &str, key: &str| { let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") }; - let described = crate::cmd::params::doc_keys(spec.params).is_ok_and(|p| p.keys.contains(key)); - assert!(names_key(spec.params, key) || described, "{id} has no `{key}` param"); + let in_schema = spec.schema.is_some_and(|s| s.params.iter().any(|p| p.name == key)); + let Some(described) = crate::cmd::params::accepted_keys(spec.params) else { + panic!("{id}: its params description doesn't say which keys it takes") + }; + in_schema || described.contains(&key) + }; + for (id, key) in NOT_FILES { + assert!(FILE_KEYS.contains(key), "{id} `{key}` isn't a file key"); + assert!(real(id, key), "{id} has no `{key}` param"); + } + for (id, keys) in NESTED_NOT_FILES { + let (Some(first), Some(last)) = (keys.first(), keys.last()) else { panic!("{id}: an empty key list") }; + assert!(FILE_KEYS.contains(last), "{id} {keys:?} doesn't end at a file key"); + assert!(real(id, first), "{id} has no `{first}` param"); } let leaks: Vec<&str> = specs .iter() @@ -540,7 +583,7 @@ mod tests { .filter(|c| { let lower = c.id.to_ascii_lowercase(); FILE_WORDS.iter().any(|w| lower.contains(w)) - || FILE_KEYS.iter().any(|k| names_key(c.params, k) && !NOT_FILES.contains(&(c.id, *k))) + || FILE_KEYS.iter().any(|k| takes_param(c, k) && !NOT_FILES.contains(&(c.id, *k))) || names_key(c.params, "sourceDocument") }) .map(|c| c.id) @@ -598,10 +641,23 @@ mod tests { assert!(names_file("select.all", &json!([[[{"url": "file:///etc/hosts"}]]]))); assert!(!names_file("effect.apply", &json!({"effect": "distort.glass", "params": {"distortion": 5, "textureImage": "frosted"}}))); assert!(!names_file("object.move", &json!({"dx": 5, "dy": 0, "ids": [1, 2], "opts": {"copy": false}}))); - // `path` is a shape's path in object.setLiveShape, but only as its own param. - assert!(!names_file("object.setLiveShape", &json!({"path": [[0, 0], [1, 1]]}))); - assert!(names_file("object.setLiveShape", &json!({"options": {"path": "/tmp/x"}}))); + // `path` is the path text is set on in text.createInPath, but only as its own param. + assert!(!names_file("text.createInPath", &json!({"path": 5, "text": "a"}))); + assert!(names_file("text.createInPath", &json!({"options": {"path": "/tmp/x"}}))); assert!(names_file("object.move", &json!({"path": [[0, 0]]}))); + // object.setLiveShape takes no `path`; its description says "on any path:" in its prose. + assert!(names_file("object.setLiveShape", &json!({"path": "/tmp/x"}))); + // Image Trace's path fidelity setting, only inside its tracing settings. + for id in ["imageTrace.make", "imageTrace.makeAndExpand"] { + assert!(!names_file(id, &json!({"preset": "Default", "params": {"mode": "color", "paths": 50}}))); + assert!(!names_file(id, &json!({"savedPreset": {"paths": 50, "corners": 75}}))); + assert!(names_file(id, &json!({"paths": 50}))); + assert!(names_file(id, &json!({"params": {"paths": 50, "path": "/tmp/x"}}))); + assert!(names_file(id, &json!({"params": {"file": "/tmp/x"}}))); + assert!(names_file(id, &json!({"params": {"paths": {"file": "/tmp/x"}}}))); + assert!(names_file(id, &json!({"options": {"paths": 50}}))); + } + assert!(names_file("object.move", &json!({"params": {"paths": 50}}))); // A custom arrowhead's outline in stroke.set, only where stroke.set takes one. let outline = json!({"subpaths": []}); assert!(!names_file("stroke.set", &json!({"endArrow": {"Custom": {"name": "A", "path": outline}}}))); @@ -675,6 +731,39 @@ mod tests { assert_eq!(key_location("pathData"), None); } + /// A file key counts as a command's param only where its description takes it: not a word in + /// the prose after the params, in a nested object or in the result. A description that doesn't + /// say which keys it takes counts any `key:` in its text. + #[test] + fn a_param_is_a_key_the_description_takes() { + assert!(described_param("{path: id, text?: string}", "path")); + assert!(described_param("{id?|ids?, path?: id}", "path")); + assert!(!described_param("{id?, radius?: pt} rounds the corners on any path: …", "path")); + assert!(!described_param("{ids?: [id]} reverses the paths: their direction", "paths")); + assert!(!described_param("{} → {id, path: the mask's path}", "path")); + assert!(!described_param("{params?: {paths: 0-100}}", "paths")); + // The keys of the command a `…` entry names. + assert!(described_param("{…as links.relink}", "folder")); + assert!(!described_param("{…as links.relink}", "url")); + // No parameter object, or a `…` entry that names no command: any `key:` counts. + assert!(described_param("path: a file", "path")); + assert!(described_param("{…more settings, id?} saves to path: a file", "path")); + assert!(!described_param("{…more settings, id?} reverses the subpath: x", "path")); + for (id, key) in [ + ("object.setLiveShape", "path"), + ("path.reverse", "paths"), + ("object.pathfinder.options", "paths"), + ("object.maskImage", "path"), + ("imageTrace.make", "paths"), + ("svgInteractivity.info", "files"), + ("addon.cleanup.rejoin", "paths"), + ] { + let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") }; + assert!(!takes_param(spec, key), "{id} takes `{key}`"); + assert!(!NOT_FILES.contains(&(id, key)), "{id} `{key}` is exempt"); + } + } + #[test] fn keys_are_found_as_whole_keys() { assert!(names_key("{path: a .wasm file}", "path")); diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 21d9d65c3d..55ffa4e420 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -166,6 +166,7 @@ pub(super) const ALLOWED: &[&str] = &[ "effect.deferredStatus", "effect.duplicate", "effect.expandAppearance", + "effect.expandAsWireframes", "effect.list", "effect.move", "effect.remove", @@ -690,8 +691,8 @@ pub(super) const ALLOWED: &[&str] = &[ ]; /// Commands a plug-in may not call although no rule refuses them, in order: they change the -/// preferences, a tool's options, a drag's state or the font names the whole app knows, or run -/// plug-ins again. +/// preferences, a tool's options, a drag's state or the font names the whole app knows, run +/// plug-ins again, or read a file and report where it is on disk. pub(super) const DENIED: &[&str] = &[ "addon.cleanup.splitText", "addon.pathtools.movePointsTogether", @@ -703,5 +704,6 @@ pub(super) const DENIED: &[&str] = &[ "object.liquify", "object.pathfinder.options", "stroke.arrowheads.edit", + "stroke.arrowheads.list", "type.compositeFonts.set", ]; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c50a45b639..83fb27ea83 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1864,7 +1864,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **A plug-in with no permissions ends its id's grant (G17):** installing a module that lists no permissions under a granted id forgets the grant, in the process and in `grants.json`, so the formerly approved bytes need a new approval. Attaching a `Plug-ins` folder whose module for that id lists no permissions forgets it too, and plug-in folders forget the process's grant. A refused install leaves the grant. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_safe_mode.rs`. Scores and hours unchanged. - **A grant that can't be forgotten stops counting in the whole process (G17):** when such a grant can't be removed from `grants.json`, no session in the process that uses the same `Plug-ins` folder, by any path to it, counts it for `plugin.inspect`, `plugin.install` or loading plug-ins, until `grants.json` can be written for that plug-in; another process on the folder reads `grants.json` as it is. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin..` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. -- **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged. +- **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. A command counts as taking a file or folder when its params description or schema names a file key as a param; a word in the description's prose or result does not count. Each exemption for a key that names part of the document names a param its command takes, which `every_command_is_sorted` checks, and Image Trace's path fidelity setting (`params.paths`, `savedPreset.paths`) is exempt only inside those settings. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged. - **The command sweep sends file and folder params into its temp folder by the command catalog:** the sweeps in `crates/engine/tests/command_sweep.rs` take each command's file and folder keys from the catalog (`cmd::location_keys`, the same key list the plug-in call rules use, less the keys that name part of the document in a command), read every key a params description names (`{path | name …}`, `{folder? (…)}` included) and its schema's params, and send every string under such a key, in arrays and in objects inside them (`paths: [{ids, path}]`), as a location in the temp folder, so a command added later is swept and checked without editing the sweep. `diffPath` counts as a file key, and Slice Options' `url` and Export for Screens' `folders` do not. Regressions: `sweep_cases_name_files_only_inside_the_temp_folder` and `sweep_cases_send_the_file_keys_of_the_command_catalog` in `command_sweep`, `location_keys_come_from_the_command_catalog` in `cmd::plugin_call`. Scores and hours unchanged. - **A drag ended by a command with Real-time Drawing off:** when Undo, Redo, a filter that calls commands or a plug-in command that calls commands ends a drag whose preview Real-time Drawing off has not applied yet, the preview is applied first and kept as an undo step of its own, as releasing the drag keeps it; a preview that fails cancels the drag. The filter or plug-in command is then given the objects where the drag left them. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/src/tests/prefs.rs`. Scores and hours unchanged. - **The warning of a preview applied when a command ends a drag:** a preview that Real-time Drawing off left unapplied, and that Undo, Clear History or another command applies before it runs, keeps its warning: the command's result carries it as `previewWarning`, and as `warning` when the command has none of its own, and the status bar shows it, as releasing the drag shows it. Typing kept as its own step when a tool's drag begins, or when Cmd lends the Selection tool, shows its applied preview's warning in the status bar too. Regressions: `settling_a_deferred_preview_keeps_its_warning` and `settling_typing_for_a_tool_shows_its_preview_warning` in `crates/engine/src/tests/prefs.rs`, `settled_warnings_join_the_result` in `crates/engine/src/session/execute.rs`. Scores and hours unchanged. From 05500f7ddbe1027d8c9d7c75acecb6ef132c3f2a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 11:20:05 -0800 Subject: [PATCH 13/14] Artwork Cleanup: update the selected objects in one pass after each fix With the selection as its scope, a fix that removes or unwraps objects (Needless Groups, Stray Points, Empty Text and the others) kept the list of selected objects current by searching it and splicing it once for each object it changed. Removing many selected objects above a long run of others took time that grew with the square of their number. The list is now rebuilt in one walk after each pass: each changed object gives way to what replaces it, in its place, and the others keep their order. The objects found, the counts, the document and the undo step are the same as before. Empty Artboards deleted each empty artboard with its own Vec::remove and walked every ruler guide again for each one. It now drops the empty artboards in one walk and deletes their guides in one more. The same artboards and guides are deleted as before. Tests cover the order after unwrapping nested and empty groups, Fix All reaching an unwrapped member on a later round, the rebuild itself, the number of objects looked at for 150 and 300 removals, and the number of walks over the artboards and guides when 40 empty artboards are deleted. --- .../engine/src/cmd/addons/cleanup/checks.rs | 75 +++++++-- crates/engine/src/cmd/addons/cleanup/tests.rs | 144 ++++++++++++++++++ 2 files changed, 207 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/addons/cleanup/checks.rs b/crates/engine/src/cmd/addons/cleanup/checks.rs index 4fd1f1ce96..52b20cdddc 100644 --- a/crates/engine/src/cmd/addons/cleanup/checks.rs +++ b/crates/engine/src/cmd/addons/cleanup/checks.rs @@ -369,11 +369,7 @@ fn restructure(d: &mut Document, scope: &mut Scope, kind: Kind) -> Result Result = const { std::cell::Cell::new(0) }; +} + +/// The scope's objects after a pass replaced `found`: each found object gives way to the ids of +/// what replaces it, in its place, and the others keep their order. An id that `roots` lists more +/// than once is replaced at its first places, once for each time `found` lists it. One walk of +/// `roots`. +pub(super) fn replace_roots(roots: &[NodeId], found: &[(NodeId, Vec)]) -> Vec { + let mut with: HashMap = HashMap::with_capacity(found.len()); + for (id, nodes) in found { + with.entry(*id).or_insert((nodes.as_slice(), 0)).1 += 1; + } + let mut out = Vec::with_capacity(roots.len()); + for r in roots { + #[cfg(test)] + ROOTS_SEEN.set(ROOTS_SEEN.get() + 1); + match with.get_mut(r) { + Some((nodes, left)) if *left > 0 => { + *left -= 1; + out.extend(nodes.iter().map(|n| n.id)); + } + _ => out.push(*r), + } + } + out +} + /// Run `f` on a copy of each path in scope (not guides), and keep the copies it changed: `f` /// returns how many issues it fixed. fn reshape(d: &mut Document, scope: &Scope, f: impl Fn(&Node, &mut PathData) -> usize) -> Result { @@ -479,13 +505,38 @@ fn near_points(d: &mut Document, scope: &Scope) -> Result { fn artboards(d: &mut Document, sel: &mut Selection, scope: &Scope) -> Result { let Scope::Document = scope else { return Ok(Outcome::default()) }; let empty = empty_artboards(d); - // The last first, so the others keep their places. - for &i in empty.iter().rev() { - if i < d.artboards.len() { - let id = d.artboards.remove(i).id; - d.retire_artboard_id(id); - d.retain_guides(sel, |g| g.artboard != Some(id)); - } + if empty.is_empty() { + return Ok(Outcome::default()); } + // `empty` is in artboard order: one walk drops them and the others keep their order. + let mut gone: HashSet = HashSet::with_capacity(empty.len()); + let mut i = 0; + d.artboards.retain(|a| { + let keep = empty.binary_search(&i).is_err(); + i += 1; + if !keep { + gone.insert(a.id); + } + keep + }); + count_artboard_pass(); + for &id in &gone { + d.retire_artboard_id(id); + } + d.retain_guides(sel, |g| !g.artboard.is_some_and(|a| gone.contains(&a))); + count_artboard_pass(); Ok(Outcome { count: empty.len(), ids: vec![], artboards: empty }) } + +#[cfg(test)] +thread_local! { + /// Tests: how many walks [`artboards`] made over the artboards and the ruler guides on this + /// thread. + pub(super) static ARTBOARD_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Tests: count one walk over the artboards or the ruler guides. +fn count_artboard_pass() { + #[cfg(test)] + ARTBOARD_PASSES.set(ARTBOARD_PASSES.get() + 1); +} diff --git a/crates/engine/src/cmd/addons/cleanup/tests.rs b/crates/engine/src/cmd/addons/cleanup/tests.rs index 1184687366..2ccdce9dad 100644 --- a/crates/engine/src/cmd/addons/cleanup/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/tests.rs @@ -389,6 +389,150 @@ fn the_scope_is_the_selection_or_the_document() { } } +/// The ids of the first layer's objects, bottom to top. +fn layer_ids(s: &Session) -> Vec { + match &s.doc().unwrap().doc.layers[0].kind { + NodeKind::Layer { children, .. } => children.iter().map(|c| c.id).collect(), + other => panic!("not a layer: {other:?}"), + } +} + +/// A plain group of the one object `id`. +fn group(s: &mut Session, id: NodeId) -> NodeId { + wrap(s, id, |children| NodeKind::Group { children, clip: false }) +} + +#[test] +fn unwrapping_selected_groups_keeps_their_places_and_order() { + let mut s = session(); + let c1 = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + let a = rect(&mut s, 40.0, 10.0, 20.0, 20.0); + let g1 = group(&mut s, a); + let c2 = rect(&mut s, 70.0, 10.0, 20.0, 20.0); + let empty = s + .edit("t", |d, _| { + let (parent, i, _) = d.position(c2).unwrap(); + let g = d.alloc_id(); + Ok(d.insert(parent, i + 1, Node::new(g, NodeKind::Group { children: vec![], clip: false }))?) + }) + .unwrap(); + let c3 = rect(&mut s, 100.0, 10.0, 20.0, 20.0); + let b = rect(&mut s, 130.0, 10.0, 20.0, 20.0); + let inner = group(&mut s, b); + let outer = group(&mut s, inner); + let c4 = rect(&mut s, 160.0, 10.0, 20.0, 20.0); + let x = rect(&mut s, 190.0, 10.0, 20.0, 20.0); + let unselected = group(&mut s, x); + assert_eq!(layer_ids(&s), [c1, g1, c2, empty, c3, outer, c4, unselected]); + select(&mut s, &[c1, g1, c2, empty, c3, outer, c4]); + let check = s.execute("addon.cleanup.check", &json!({"kinds": ["needlessGroups"], "scope": "selection"})).unwrap(); + let r = s.execute("addon.cleanup.fix", &json!({"kinds": ["needlessGroups"], "scope": "selection"})).unwrap(); + // The inner group is found only once its outer group is gone: on a later pass, as a root. + assert_eq!(r["fixed"]["needlessGroups"], 4, "{r}"); + assert_eq!(check["total"], 4, "the check finds what Fix fixes: {check}"); + let mut listed: Vec = check["findings"][0]["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + listed.sort_unstable(); + let mut gone: Vec = [g1, empty, outer, inner].iter().map(|i| i.0).collect(); + gone.sort_unstable(); + assert_eq!(listed, gone); + assert_eq!(layer_ids(&s), [c1, a, c2, c3, b, c4, unselected], "each member takes its group's place"); + for id in [g1, empty, outer, inner] { + assert!(node(&s, id).is_none(), "{id:?} unwrapped"); + } +} + +#[test] +fn fix_all_reaches_the_members_of_unwrapped_selected_groups() { + let mut s = session(); + rect(&mut s, 200.0, 20.0, 80.0, 60.0); + let below = rect(&mut s, 20.0, 150.0, 30.0, 30.0); + let copy = s + .edit("t", |d, _| { + let mut copy = d.node(below).unwrap().clone(); + copy.id = d.alloc_id(); + let (parent, i, _) = d.position(below).unwrap(); + Ok(d.insert(parent, i + 1, copy)?) + }) + .unwrap(); + let g = group(&mut s, copy); + select(&mut s, &[below, g]); + // The copy sits apart from the object it doubles until its group is unwrapped. + assert_eq!(found(&mut s, "duplicateObjects", "selection"), 0); + assert_eq!(found(&mut s, "needlessGroups", "selection"), 1); + let r = s.execute("addon.cleanup.fix", &json!({"scope": "selection"})).unwrap(); + assert_eq!((r["fixed"]["needlessGroups"].as_u64(), r["fixed"]["duplicateObjects"].as_u64()), (Some(1), Some(1)), "{r}"); + assert!(node(&s, g).is_none() && node(&s, copy).is_none()); + assert!(node(&s, below).is_some()); +} + +#[test] +fn replacing_roots_takes_each_found_object_at_its_first_place() { + use super::checks::replace_roots; + let n = |id: u64| Node::new(NodeId(id), NodeKind::Group { children: vec![], clip: false }); + let ids = |v: &[u64]| v.iter().map(|i| NodeId(*i)).collect::>(); + let roots = ids(&[1, 2, 1, 3]); + assert_eq!(replace_roots(&roots, &[(NodeId(1), vec![n(10), n(11)])]), ids(&[10, 11, 2, 1, 3])); + assert_eq!(replace_roots(&roots, &[(NodeId(1), vec![]), (NodeId(1), vec![])]), ids(&[2, 3]), "listed twice: both places"); + assert_eq!(replace_roots(&roots, &[(NodeId(3), vec![]), (NodeId(2), vec![n(20)])]), ids(&[1, 20, 1])); + // A found object inside a root leaves the roots as they are. + assert_eq!(replace_roots(&roots, &[(NodeId(7), vec![n(70)])]), roots); + assert_eq!(replace_roots(&[], &[(NodeId(7), vec![])]), ids(&[])); +} + +/// How many scope roots fixing `n` selected stray points above `n` selected clean objects looks at. +fn roots_seen_removing(n: usize) -> usize { + use super::checks::ROOTS_SEEN; + let mut s = session(); + let mut ids: Vec = (0..n).map(|i| rect(&mut s, (i % 20) as f64 * 15.0, (i / 20) as f64 * 15.0, 10.0, 10.0)).collect(); + ids.extend((0..n).map(|i| path(&mut s, json!([{"x": i % 300, "y": i / 300}]), false))); + select(&mut s, &ids); + ROOTS_SEEN.set(0); + let r = s.execute("addon.cleanup.fix", &json!({"kinds": ["strayPoints"], "scope": "selection"})).unwrap(); + assert_eq!(r["total"].as_u64(), Some(n as u64)); + assert_eq!(layer_ids(&s), ids[..n]); + ROOTS_SEEN.get() +} + +#[test] +fn removing_many_selected_objects_looks_at_each_root_once_a_pass() { + let (small, large) = (roots_seen_removing(150), roots_seen_removing(300)); + assert_eq!(small, 300, "one pass over the 300 roots"); + assert!(large <= 2 * small, "{small} roots looked at for 150 removals, {large} for 300"); +} + +#[test] +fn deleting_many_empty_artboards_walks_the_artboards_and_guides_once() { + use super::checks::ARTBOARD_PASSES; + let mut s = session(); + rect(&mut s, 200.0, 20.0, 80.0, 60.0); + let (keep, gone) = s + .edit("t", |d, _| { + let first = d.artboards[0].id; + let keep = d.add_ruler_guide(&vectorcraft_doc::Guide { artboard: Some(first), ..vectorcraft_doc::Guide::new(true, 100.0) }); + let mut gone = vec![]; + for i in 0..40 { + let mut a = d.artboards[0].clone(); + a.id = d.next_artboard_id(); + let x = 1000.0 + i as f64 * 200.0; + a.rect = Rect::new(x, 1000.0, x + 100.0, 1100.0); + gone.push(d.add_ruler_guide(&vectorcraft_doc::Guide { artboard: Some(a.id), ..vectorcraft_doc::Guide::new(true, x + 50.0) })); + d.artboards.push(a); + } + Ok((keep, gone)) + }) + .unwrap(); + let first = s.doc().unwrap().doc.artboards[0].clone(); + ARTBOARD_PASSES.set(0); + let r = s.execute("addon.cleanup.fix", &json!({"kinds": ["emptyArtboards"], "scope": "document"})).unwrap(); + assert_eq!(r["total"].as_u64(), Some(40), "{r}"); + assert_eq!(ARTBOARD_PASSES.get(), 2, "one walk over the artboards and one over the guides"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.artboards, vec![first]); + let left: Vec> = d.ruler_guides().into_iter().map(|g| g.id).collect(); + assert_eq!(left, vec![Some(keep)]); + assert!(gone.iter().all(|id| !left.contains(&Some(*id)))); +} + #[test] fn show_selects_the_objects_with_the_issue() { let mut s = session(); From b6e3e96db39b2a38aa2a953df193d18f2d6ce75a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 11:23:40 -0800 Subject: [PATCH 14/14] Brushes: tests expect the first brush of a repeated name With repeated names numbered when a brush library is read, a stroke that names a repeated brush draws the first brush of that name, and a later brush of the name is read as "Name 2". Three tests built a library with a repeated name and expected the later brush: - Document Info lists a name two stored brushes share with the first brush's type (art), not the last one's. - The two render memory tests add their own `Dots` scatter brush beside the default library's `Dots`. They now remove the default `Dots` first, so the stroke draws their brush. - The .ai writer test that leaves out a Pattern brush "X" gets the Calligraphic brush after it back as "X 2", the name it had in the document. --- crates/engine/src/cmd/docinfo.rs | 6 +++--- crates/eps/src/ai_editing/tests.rs | 7 ++++--- crates/render/src/tests/memory.rs | 4 ++++ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index a43325becd..8ed1b5cad6 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -633,7 +633,7 @@ fn info(s: &mut Session, p: &Value) -> Result { fonts.extend(super::fonts::art_texts_of(d).into_iter().flat_map(|(_, t, _)| t.runs.iter().map(|r| super::fonts::font_of(&r.style)))); } let font_names: Vec = fonts.iter().map(super::fonts::font_label).collect(); - // Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the last of two + // Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the first of two // brushes with one name); a brush the panel does not hold draws as a plain stroke and has no type. A row names the type // as the New Brush dialog does (`Art Brush`), and the panel translates these values when it shows them. let kinds: HashMap<&str, &vectorcraft_brush::BrushKind> = library.iter().map(|b| (b.name.as_str(), &b.kind)).collect(); @@ -910,7 +910,7 @@ mod tests { let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); d.node_mut(NodeId(missing)).unwrap().appearance.stroke_mut().unwrap().paint = Paint::None; assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([])); - // Two panel brushes with one name: the type is the one the renderer draws with, the last one's. + // Two panel brushes with one name: the type is the one the renderer draws with, the first one's. let twin = square(&mut s, 140.0); s.execute("brush.apply", &json!({"ids": [twin], "name": "Charcoal"})).unwrap(); let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); @@ -920,7 +920,7 @@ mod tests { lib.extend(kinds.into_iter().map(|kind| vectorcraft_brush::Brush { name: "Twin".into(), kind })); vectorcraft_brush::store(d, &lib); d.node_mut(NodeId(twin)).unwrap().appearance.stroke_mut().unwrap().brush = Some("Twin".into()); - assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([{"name": "Twin", "type": "scatter"}])); + assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([{"name": "Twin", "type": "art"}])); // Type: a brushed stroke on drawn characters is listed, one on spaces alone is not. let blank = s.execute("text.create", &json!({"x": 10, "y": 60, "text": " "})).unwrap()["id"].as_u64().unwrap(); let word = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index ab39f467d2..d8b61b5c72 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -8378,8 +8378,9 @@ fn brushes_the_reader_cant_read_are_left_out() { } left_out(vec![Brush { name: "A/B".into(), ..keep.clone() }], "slash"); left_out(vec![Brush { kind: BrushKind::Bristle(Bristle::default()), ..keep.clone() }], "a second brush of the name"); - // A Pattern brush named "X" is left out, so a Calligraphic brush named "X" after it comes back. A - // path with "X" is drawn with the Pattern brush, so it comes back as its drawn look. + // A Pattern brush named "X" is left out, so a Calligraphic brush named "X" after it comes back, + // as "X 2", the name the library gives it when it is read. A path with "X" is drawn with the + // Pattern brush, so it comes back as its drawn look. let mut doc = Document::new(300.0, 200.0); let x = Brush { name: "X".into(), ..keep.clone() }; vectorcraft_brush::store(&mut doc, &[Brush { name: "X".into(), kind: BrushKind::Pattern(PatternBrush::default()) }, x.clone()]); @@ -8387,7 +8388,7 @@ fn brushes_the_reader_cant_read_are_left_out() { let out = export_ai_editing(&doc, &opts()).unwrap(); assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); let back = read_back(&doc); - assert_eq!(vectorcraft_brush::library(&back), [x]); + assert_eq!(vectorcraft_brush::library(&back), [Brush { name: "X 2".into(), ..x }]); let n = &back.layers[0].children().unwrap()[0]; assert!(n.appearance.stroke().is_none_or(|s| s.brush.is_none()), "not with the other brush of the name: {n:#?}"); } diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs index 011f9fd7fe..d286fa7e3c 100644 --- a/crates/render/src/tests/memory.rs +++ b/crates/render/src/tests/memory.rs @@ -931,6 +931,8 @@ fn brush_art_draws_edits_of_the_symbols_and_patterns_it_shows() { painted.transform(Affine::translate((0.0, 8.0)), true); let scatter = Node::group(d.alloc_id(), vec![Arc::new(instance), Arc::new(painted)]); let mut lib = vectorcraft_brush::library(&d); + // The default library has its own `Dots`, which a stroke would draw with first. + lib.retain(|b| b.name != "Dots"); lib.push(Brush { name: "Dots".into(), kind: BrushKind::Scatter(Scatter { art: scatter, spacing: (150.0, 150.0), ..Default::default() }) }); vectorcraft_brush::store(&mut d, &lib); let mut ap = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); @@ -997,6 +999,8 @@ fn brush_art_draws_renamed_symbols_and_patterns_it_shows() { painted.transform(Affine::translate((0.0, 8.0)), true); let scatter = Node::group(d.alloc_id(), vec![Arc::new(instance), Arc::new(painted)]); let mut lib = vectorcraft_brush::library(&d); + // The default library has its own `Dots`, which a stroke would draw with first. + lib.retain(|b| b.name != "Dots"); lib.push(Brush { name: "Dots".into(), kind: BrushKind::Scatter(Scatter { art: scatter, spacing: (150.0, 150.0), ..Default::default() }) }); vectorcraft_brush::store(&mut d, &lib); let mut ap = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);