mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Render: the ink planes of CMYK documents with transparency are composed on several threads
A CMYK document that shows transparency is drawn as two ink planes, and ink::compose turned them into screen colors one pixel at a time on one thread. At 2880 x 1800 that took about as long as drawing one plane of 50,000 paths. The renderer also searched a CMYK document for transparency in every frame. compose now splits the frame into bands, as many as the renderer has threads and no more than one per 65,536 pixels. The calling thread composes the first band and a scoped thread composes each other band, with the same per-pixel code. The renderer keeps the answer of the transparency test with the layers, symbol art and pattern art it was found for, held weakly, and tests again only when the document holds other art. With Overprint Preview on, a document whose layers or symbols hold an overprinting fill or stroke is still tested in every frame. FrameStats::ink_threads reports how many threads composed a frame. On 8 threads at 2880 x 1800, a page of 300 shapes at 20% to 90% opacity takes 30 ms a frame instead of 55 ms, and 50,000 paths with one at 99% opacity take 258 ms instead of 361 ms, with the same pixels. Docs: docs/target-app-parity.md.
This commit is contained in:
1 parent
2ce6f90522
commit
f9cbcfb046
4 files changed
+193
-22
No files matched your search
+75
-18
@@ -27,8 +27,8 @@ use vello_cpu::Pixmap;
|
||||
use vello_cpu::color::PremulRgba8;
|
||||
use vello_cpu::peniko;
|
||||
|
||||
use crate::RenderOptions;
|
||||
use crate::images::{self, ImageKey};
|
||||
use crate::{RenderOptions, Renderer};
|
||||
|
||||
/// What a frame paints for colours.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
@@ -91,15 +91,31 @@ fn is_pure_black([c, m, y, k]: [f32; 4]) -> bool {
|
||||
c <= 0.0 && m <= 0.0 && y <= 0.0 && k >= 1.0
|
||||
}
|
||||
|
||||
/// Whether `doc` is drawn by ink planes: a CMYK document that shows transparency, unless drawn
|
||||
/// in Outline view or Separations Preview (which paints plates).
|
||||
pub(crate) fn blends_in_cmyk(doc: &Document, opts: &RenderOptions) -> bool {
|
||||
doc.color_mode == ColorMode::Cmyk
|
||||
&& !opts.outline
|
||||
&& opts.proof.as_ref().is_none_or(|p| p.separations.is_none())
|
||||
&& (doc.layers.iter().any(|l| l.shows_transparency())
|
||||
|| doc.symbols.iter().any(|s| s.art.shows_transparency())
|
||||
|| doc.patterns.iter().any(|p| p.art.iter().any(|a| a.shows_transparency())))
|
||||
impl Renderer {
|
||||
/// Whether `doc` is drawn by ink planes: a CMYK document that shows transparency, unless drawn
|
||||
/// in Outline view or Separations Preview (which paints plates).
|
||||
pub(crate) fn blends_in_cmyk(&mut self, doc: &Document, opts: &RenderOptions) -> bool {
|
||||
doc.color_mode == ColorMode::Cmyk
|
||||
&& !opts.outline
|
||||
&& opts.proof.as_ref().is_none_or(|p| p.separations.is_none())
|
||||
&& self.shows_transparency(doc)
|
||||
}
|
||||
|
||||
/// Whether `doc`'s layers, symbol art or pattern art show transparency
|
||||
/// ([`vectorcraft_doc::Node::shows_transparency`]). The answer is kept with that art (see
|
||||
/// [`Self::transparency`]) and found again only when the document holds other art.
|
||||
fn shows_transparency(&mut self, doc: &Document) -> bool {
|
||||
let art = || doc.layers.iter().chain(doc.symbols.iter().map(|s| &s.art)).chain(doc.patterns.iter().flat_map(|p| &p.art));
|
||||
if let Some((held, shows)) = &self.transparency
|
||||
&& held.len() == art().count()
|
||||
&& held.iter().zip(art()).all(|(h, a)| std::ptr::eq(h.as_ptr(), Arc::as_ptr(a)))
|
||||
{
|
||||
return *shows;
|
||||
}
|
||||
let shows = art().any(|a| a.shows_transparency());
|
||||
self.transparency = Some((art().map(Arc::downgrade).collect(), shows));
|
||||
shows
|
||||
}
|
||||
}
|
||||
|
||||
/// The inks of `c` in the working CMYK space: CMYK as it is, grey as black ink, RGB and Lab
|
||||
@@ -175,19 +191,62 @@ fn cached<T>(slot: &Cached<T>, build: impl FnOnce(&Cms) -> T) -> (Arc<T>, u64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`compose`] puts at least this many pixels in each band but the last.
|
||||
const BAND_PIXELS: usize = 1 << 16;
|
||||
|
||||
/// The picture of a CMYK document from its ink planes (premultiplied RGBA8 drawn with
|
||||
/// [`Ink::Cmy`] and [`Ink::K`]): each pixel's inks shown through the working CMYK profile (pure
|
||||
/// black as RGB black with [`RenderOptions::rich_black`]). Where only the `background` of `opts`
|
||||
/// shows it keeps its exact screen colour.
|
||||
pub(crate) fn compose(cmy: &[u8], k: &[u8], opts: &RenderOptions) -> Vec<u8> {
|
||||
/// shows it keeps its exact screen colour. The pixels are split into bands of one size (the last can
|
||||
/// be smaller): `threads` of them, or one per [`BAND_PIXELS`] pixels in the frame when that makes
|
||||
/// fewer, and at least one. This thread composes the first band, and a thread of its own composes
|
||||
/// each other band (this thread does when that one can't start). Returns the picture and the number
|
||||
/// of threads that composed it.
|
||||
pub(crate) fn compose(cmy: &[u8], k: &[u8], opts: &RenderOptions, threads: u16) -> (Vec<u8>, usize) {
|
||||
let (lut, _) = cached(&DISPLAY, InkLut::build);
|
||||
let bg = opts.background.map(|b| (Ink::Cmy.fixed8(b), Ink::K.fixed8(b), b));
|
||||
let mut out = Vec::with_capacity(cmy.len());
|
||||
let mut out = vec![0; cmy.len().min(k.len()) / 4 * 4];
|
||||
let pixels = out.len() / 4;
|
||||
let bands = (pixels / BAND_PIXELS).clamp(1, usize::from(threads).max(1));
|
||||
let band = |o: &mut [u8], p: &[u8], q: &[u8]| compose_band(o, p, q, &lut, bg, opts.rich_black);
|
||||
if bands == 1 {
|
||||
band(&mut out, cmy, k);
|
||||
return (out, 1);
|
||||
}
|
||||
let size = pixels.div_ceil(bands).max(1) * 4;
|
||||
let mut unstarted = vec![];
|
||||
let used = std::thread::scope(|s| {
|
||||
let mut parts = out.chunks_mut(size).zip(cmy.chunks(size)).zip(k.chunks(size)).enumerate();
|
||||
let first = parts.next();
|
||||
let mut used = 1;
|
||||
for (i, ((o, p), q)) in parts {
|
||||
match std::thread::Builder::new().spawn_scoped(s, move || band(o, p, q)) {
|
||||
Ok(_) => used += 1,
|
||||
Err(_) => unstarted.push(i),
|
||||
}
|
||||
}
|
||||
if let Some((_, ((o, p), q))) = first {
|
||||
band(o, p, q);
|
||||
}
|
||||
used
|
||||
});
|
||||
for at in unstarted.into_iter().map(|i| i * size) {
|
||||
let end = (at + size).min(out.len());
|
||||
if let (Some(o), Some(p), Some(q)) = (out.get_mut(at..end), cmy.get(at..end), k.get(at..end)) {
|
||||
band(o, p, q);
|
||||
}
|
||||
}
|
||||
(out, used)
|
||||
}
|
||||
|
||||
/// [`compose`] for one band: the pixels of `cmy` and `k` into `out`. `bg` is the background as
|
||||
/// the [`Ink::Cmy`] and [`Ink::K`] planes paint it, and its screen color.
|
||||
fn compose_band(out: &mut [u8], cmy: &[u8], k: &[u8], lut: &InkLut, bg: Option<([u8; 4], [u8; 4], [u8; 4])>, rich_black: bool) {
|
||||
// Runs of one colour convert once.
|
||||
let mut last: Option<([u8; 8], [u8; 4])> = None;
|
||||
for (p, q) in cmy.as_chunks::<4>().0.iter().zip(k.as_chunks::<4>().0) {
|
||||
for ((o, p), q) in out.as_chunks_mut::<4>().0.iter_mut().zip(cmy.as_chunks::<4>().0).zip(k.as_chunks::<4>().0) {
|
||||
let key = [p[0], p[1], p[2], p[3], q[0], q[1], q[2], q[3]];
|
||||
let px = match (&last, &bg) {
|
||||
*o = match (&last, &bg) {
|
||||
_ if p[3] == 0 => [0; 4],
|
||||
(Some((k0, v)), _) if *k0 == key => *v,
|
||||
(_, Some((bc, bk, b))) if p == bc && q == bk => *b,
|
||||
@@ -195,16 +254,14 @@ pub(crate) fn compose(cmy: &[u8], k: &[u8], opts: &RenderOptions) -> Vec<u8> {
|
||||
let un = |v: u8, a: u8| if a == 0 { 1.0 } else { (v as f32 / a as f32).min(1.0) };
|
||||
let a = p[3];
|
||||
let inks = planes_cmyk([un(p[0], a), un(p[1], a), un(p[2], a)], un(q[0], q[3]));
|
||||
let rgb = if opts.rich_black && is_pure_black(inks) { [0.0; 3] } else { lut.apply(inks) };
|
||||
let rgb = if rich_black && is_pure_black(inks) { [0.0; 3] } else { lut.apply(inks) };
|
||||
let pm = |v: f32| (v.clamp(0.0, 1.0) * a as f32).round() as u8;
|
||||
let v = [pm(rgb[0]), pm(rgb[1]), pm(rgb[2]), a];
|
||||
last = Some((key, v));
|
||||
v
|
||||
}
|
||||
};
|
||||
out.extend_from_slice(&px);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Each pixel's ink amounts (C, M, Y, K bytes, 0 = no ink) from its ink planes (premultiplied
|
||||
|
||||
@@ -434,6 +434,10 @@ pub struct Renderer {
|
||||
/// What the contents of those rasters read from the document, and the color settings, as last
|
||||
/// found for a frame (`stamp`) and document (address) (see [`Self::doc_inputs`]).
|
||||
doc_inputs: Option<((u64, usize), Arc<fx::DocInputs>)>,
|
||||
/// Whether a document's layers, symbol art and pattern art show transparency, as last found
|
||||
/// (see [`Self::blends_in_cmyk`]), with that art in order, held weakly: a weak reference keeps
|
||||
/// the allocation, so no other art gets one of those addresses while the answer is kept.
|
||||
transparency: Option<(Vec<std::sync::Weak<Node>>, bool)>,
|
||||
/// Whether the group being drawn is a knockout group (what its neutral children inherit).
|
||||
knockout: bool,
|
||||
/// Address of the knockout-group element being drawn as its knockout shape: at full object
|
||||
@@ -494,6 +498,9 @@ pub struct FrameStats {
|
||||
/// The drop shadows, outer glows and filtered contents the frame drew from rasters made before
|
||||
/// their object moved, or made over such rasters ([`RenderOptions::interacting`]).
|
||||
pub moved: usize,
|
||||
/// How many threads composed the frame from its two ink planes, in a CMYK document that shows
|
||||
/// transparency; 0 for other frames.
|
||||
pub ink_threads: usize,
|
||||
pub micros: u64,
|
||||
}
|
||||
|
||||
@@ -556,6 +563,7 @@ impl Renderer {
|
||||
shadows: PtrMap::default(),
|
||||
pixel_fx: PtrMap::default(),
|
||||
doc_inputs: None,
|
||||
transparency: None,
|
||||
live: live::LiveCache::default(),
|
||||
knockout: false,
|
||||
shape_of: 0,
|
||||
@@ -617,13 +625,19 @@ impl Renderer {
|
||||
self.stats.micros = now().saturating_sub(start);
|
||||
return Rendered { width: w as u32, height: h as u32, pixels };
|
||||
}
|
||||
let mut pixels = if inks || ink::blends_in_cmyk(doc, opts) {
|
||||
let mut pixels = if inks || self.blends_in_cmyk(doc, opts) {
|
||||
// Blending in CMYK: one frame per ink plane, then their inks shown on screen.
|
||||
let cmy = self.draw_frame(&Frame { ink: Ink::Cmy, ..frame }, w, h);
|
||||
let stats = self.stats;
|
||||
let k = self.draw_frame(&Frame { ink: Ink::K, ..frame }, w, h);
|
||||
self.stats = stats;
|
||||
if inks { ink::amounts(&cmy, &k) } else { ink::compose(&cmy, &k, opts) }
|
||||
if inks {
|
||||
ink::amounts(&cmy, &k)
|
||||
} else {
|
||||
let (pixels, composed) = ink::compose(&cmy, &k, opts, threads);
|
||||
self.stats.ink_threads = composed;
|
||||
pixels
|
||||
}
|
||||
} else {
|
||||
self.draw_frame(&frame, w, h)
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use super::*;
|
||||
use vectorcraft_color::blend::MASK_LUM;
|
||||
use vectorcraft_color::{BlendMode as Blend, Color, Paint};
|
||||
use vectorcraft_color::{BlendMode as Blend, Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::{Appearance, ColorMode, ImageBlob, ImageObject, Node, OpacityMask};
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
@@ -90,10 +90,109 @@ fn rgb_documents_and_opaque_cmyk_documents_keep_screen_colours() {
|
||||
assert!(near(got, [want[0], want[1], want[2], 255], 1), "{got:?} ≠ {want:?}");
|
||||
// A CMYK document without transparency paints each colour as it is.
|
||||
let d = doc(ColorMode::Cmyk, Some(MAGENTA), CYAN, Blend::Normal, 1.0);
|
||||
assert!(!ink::blends_in_cmyk(&d, &RenderOptions::default()));
|
||||
assert!(!Renderer::new().blends_in_cmyk(&d, &RenderOptions::default()));
|
||||
assert_eq!(centre(&d), CYAN.to_rgba8(1.0));
|
||||
}
|
||||
|
||||
/// Whether a document shows transparency is found once for its layers, symbol art and pattern
|
||||
/// art, and kept while the document holds the same art: an edit, or a new symbol, finds it again.
|
||||
#[test]
|
||||
fn the_transparency_test_is_kept_while_the_art_stays_the_same() {
|
||||
let frame = |r: &mut Renderer, d: &Document| r.render_region(d, d.artboards[0].rect, 1.0, true).pixels;
|
||||
let kept = |r: &Renderer| r.transparency.as_ref().map(|(art, shows)| (art.as_ptr(), *shows));
|
||||
// The ink planes show a Gray fill lighter than screen colors do, so a frame drawn the other
|
||||
// way differs.
|
||||
let d = doc(ColorMode::Cmyk, Some(Color::gray(0.9)), CYAN, Blend::Normal, 0.5);
|
||||
let mut r = Renderer::new();
|
||||
let first = frame(&mut r, &d);
|
||||
let found = kept(&r);
|
||||
assert_eq!(found.map(|(_, shows)| shows), Some(true));
|
||||
assert!(frame(&mut r, &d) == first);
|
||||
assert_eq!(kept(&r), found, "the same art was tested again");
|
||||
// The top object made opaque: the frame paints screen colors.
|
||||
let mut e = d.clone();
|
||||
let top = e.layers[0].children().unwrap()[1].id;
|
||||
e.node_mut(top).unwrap().opacity = 1.0;
|
||||
assert!(frame(&mut r, &e) == frame(&mut Renderer::new(), &e));
|
||||
assert_eq!(kept(&r).map(|(_, shows)| shows), Some(false));
|
||||
// A symbol whose art shows transparency, placed nowhere, makes the page draw by ink planes again.
|
||||
let mut art = rect(&mut e, Rect::new(0.0, 0.0, 5.0, 5.0), CYAN);
|
||||
art.opacity = 0.5;
|
||||
e.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) });
|
||||
assert!(frame(&mut r, &e) == frame(&mut Renderer::new(), &e));
|
||||
assert_eq!(kept(&r).map(|(_, shows)| shows), Some(true));
|
||||
}
|
||||
|
||||
/// A 200 × 150 pt CMYK page drawn by ink planes: a Gray background, translucent ellipses in each
|
||||
/// blend mode, an object with an opacity mask, one with a drop shadow, a gradient with a
|
||||
/// translucent stop, an overprinting fill and objects across the page's edges.
|
||||
fn busy_page() -> Document {
|
||||
let mut d = Document::new_with_mode(200.0, 150.0, ColorMode::Cmyk);
|
||||
let back = rect(&mut d, Rect::new(0.0, 0.0, 200.0, 150.0), Color::gray(0.3));
|
||||
add(&mut d, back);
|
||||
for i in 0..240 {
|
||||
let (x, y) = ((i * 37 % 200) as f64, (i * 53 % 150) as f64);
|
||||
let c = Color::cmyk((i % 5) as f32 / 4.0, (i % 7) as f32 / 6.0, (i % 3) as f32 / 2.0, (i % 4) as f32 / 8.0);
|
||||
let mut n = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::ellipse(Rect::new(x - 9.0, y - 6.0, x + 9.0, y + 6.0)),
|
||||
Appearance::basic(Paint::solid(c), Paint::solid(MAGENTA), 0.6),
|
||||
);
|
||||
(n.blend, n.opacity) = (Blend::ALL[i % Blend::ALL.len()], 0.4 + (i % 6) as f32 / 10.0);
|
||||
add(&mut d, n);
|
||||
}
|
||||
let mut masked = rect(&mut d, Rect::new(20.0, 20.0, 90.0, 70.0), CYAN);
|
||||
let mask = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::ellipse(Rect::new(30.0, 25.0, 80.0, 65.0)),
|
||||
Appearance::basic(Paint::solid(Color::gray(0.4)), Paint::None, 0.0),
|
||||
);
|
||||
masked.extras_mut().mask = Some(Box::new(OpacityMask::new(mask, true)));
|
||||
add(&mut d, masked);
|
||||
let mut shadowed = rect(&mut d, Rect::new(110.0, 20.0, 170.0, 60.0), Color::cmyk(0.0, 0.2, 0.9, 0.0));
|
||||
let shadow = serde_json::json!({"x": 6.0, "y": 6.0, "blur": 4.0, "opacity": 75.0, "mode": "multiply"});
|
||||
shadowed.appearance.effects.push(vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: shadow, visible: true });
|
||||
add(&mut d, shadowed);
|
||||
let stops = vec![GradientStop::new(0.0, CYAN), GradientStop { opacity: 0.3, ..GradientStop::new(1.0, MAGENTA) }];
|
||||
let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
|
||||
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 90.0, 120.0, 130.0)), Appearance::basic(ramp, Paint::None, 0.0));
|
||||
add(&mut d, n);
|
||||
let mut over = rect(&mut d, Rect::new(100.0, 80.0, 160.0, 120.0), Color::cmyk(0.0, 0.0, 1.0, 0.0));
|
||||
if let Some(AppearanceItem::Fill(f)) = over.appearance.items.first_mut() {
|
||||
f.overprint = true;
|
||||
}
|
||||
add(&mut d, over);
|
||||
for r in [Rect::new(-15.0, -10.0, 25.0, 20.0), Rect::new(180.0, 130.0, 230.0, 170.0), Rect::new(-30.0, 60.0, 260.0, 75.0)] {
|
||||
let mut n = rect(&mut d, r, Color::cmyk(0.7, 0.0, 0.3, 0.1));
|
||||
n.opacity = 0.7;
|
||||
add(&mut d, n);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// The renderer's threads each compose a band of a frame's two ink planes, and the frame has the
|
||||
/// pixels that one thread composes, with and without a background.
|
||||
#[test]
|
||||
fn ink_planes_compose_on_the_renderer_threads() {
|
||||
let d = busy_page();
|
||||
let canvas = RenderOptions { background: Some([224, 224, 224, 255]), artboards: true, overprint_preview: true, ..Default::default() };
|
||||
for opts in [RenderOptions::default(), canvas] {
|
||||
let frame = |threads: u16| {
|
||||
let mut r = Renderer::new();
|
||||
r.threads = threads;
|
||||
let pixels = r.render(&d, 1200, 900, Affine::scale(6.0), &opts).pixels;
|
||||
(pixels, r.stats.ink_threads)
|
||||
};
|
||||
let (one, threads) = frame(0);
|
||||
assert_eq!(threads, 1);
|
||||
for n in [3, 8] {
|
||||
let (pixels, threads) = frame(n);
|
||||
assert_eq!(threads, usize::from(n), "composed on {threads} threads, not {n}");
|
||||
assert!(pixels == one, "{n} threads changed pixels");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_masks_take_screen_luminance() {
|
||||
let mut d = Document::new_with_mode(20.0, 20.0, ColorMode::Cmyk);
|
||||
|
||||
@@ -513,6 +513,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
cursors. DPI/hotspot and preference checks in `cursors.rs` and `tests_prefs.rs` cover #1269;
|
||||
actual OS display and Caps Lock remain unverified. Behaviour reference: [public tool-pointer documentation](https://helpx.adobe.com/illustrator/using/tools.html#change_tool_pointers).
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. Print-size images (more than 512 MB decoded, up to 2 GB) draw in the canvas and in raster exports and can be trimmed with Crop Image; an image with a side past 65,535 px is shrunk to fit, a raster export says when an image could not be decoded, and Crop Image's trim leaves CMYK images alone (#1517). On Windows `vectorcraft-cli perf` reports the cores busy and, with `--deep`, its working set, and warns on a busy machine as it does on macOS and Linux (#1502).
|
||||
The renderer composes the two ink planes of a CMYK document that shows transparency into screen colors on up to as many threads as it rasterizes with, instead of on one. At 2880 × 1800 on 8 threads, a page of 300 shapes at 20% to 90% opacity takes 30 ms a frame instead of 55 ms, and 50,000 paths with one at 99% opacity take 258 ms instead of 361 ms (`vectorcraft-cli bench`), with the same pixels.
|
||||
The Pathfinder panel, Offset Path, Clean Up, Divide Objects Below, Make and Release Blend, Envelope Distort's Make and Release, Repeat (Radial, Grid, Mirror and Release), Vector Halftone, New and Redefine Symbol, Delete Symbol, erasing with the Symbol Sprayer, Rasterize, Convert to Artboards, Flatten Transparency, Clear Guides, Shape Builder, Live Paint, Release Image Trace, Make and Release Opacity Mask, Delete with direct-selected anchor points, Remove Anchor Points, Cut Path, and the Shaper, Knife, Eraser, Path Eraser, Line Cut, Rectangle Cut, Mirror & Cut and Blob Brush tools add and remove objects in one pass over each layer or group they change, and Outline Stroke and Expand write each new object in the place of the object it replaces (#1429).
|
||||
Delete, Cut, Group, Ungroup, Make and Release Compound Path, Make Clipping Mask, Send to Current Layer, Duplicate, Copy in the Move, Rotate, Scale, Reflect and Shear dialogs, Transform Each with Copy and each move of an Alt-drag take time in proportion to the size of the document (#1429).
|
||||
An Alt-drag with the Selection, Direct Selection, Group Selection, Rotate, Scale, Reflect or Shear tool copies the selection on its first move, and the moves after it transform those copies; the previews of the Move, Rotate, Scale, Reflect and Shear dialogs with Copy and Preview on copy the selection once in the same way (#1429).
|
||||
|
||||
Reference in new issue
Block a user