mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 03:39:50 +00:00
.ai export: a masked image copy whose mask is left out keeps all of its pixels
A copy of an image written whole already was cut to the painted bounds of its opacity mask even when the mask was then left out, nested too deep or past the object limit, so the file read back with a cropped image and no mask. The copy is now cut to its mask only when the mask is written, and otherwise written whole. The progress-doc line now gives the measured total for the warped test image's pieces: about 7 times the image's samples, not twice.
This commit is contained in:
@@ -7649,6 +7649,72 @@ fn a_masked_image_keeps_the_pixels_its_mask_effects_show() {
|
||||
assert!(sizes[1] >= 35 * 35 * 3, "{sizes:?}");
|
||||
}
|
||||
|
||||
/// The speckled image with an opacity mask that clips it to six small white squares in its corner.
|
||||
fn photo_masked_to_its_corner(doc: &mut Document) -> Node {
|
||||
let mut masked = speckled_photo(doc);
|
||||
let squares = (0..6).map(|i| square(doc, Rect::new(20.0 + f64::from(i) * 5.0, 10.0, 23.0 + f64::from(i) * 5.0, 20.0), Color::WHITE)).collect();
|
||||
let art = group(doc, squares, false);
|
||||
masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true }));
|
||||
masked
|
||||
}
|
||||
|
||||
/// A copy of an image written whole already whose opacity mask is left out for the object limit is
|
||||
/// written whole: without the mask, it shows all of its pixels.
|
||||
#[test]
|
||||
fn a_masked_copy_whose_mask_is_left_out_keeps_all_of_its_pixels() {
|
||||
// GUARDS: the copy was cut to its mask, and the mask then left out, so it read back cropped.
|
||||
let doc = photo_doc(|d| {
|
||||
let photo = speckled_photo(d);
|
||||
let masked = photo_masked_to_its_corner(d);
|
||||
group(d, vec![photo, masked], false)
|
||||
});
|
||||
let (mut left_out, mut kept) = (0, 0);
|
||||
for (max, mask, _, shown) in samples_within_each_limit(&doc) {
|
||||
if shown.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
if mask {
|
||||
assert!(shown[1] < SPECKLED_WHOLE, "room for {max}: {shown:?}");
|
||||
kept += 1;
|
||||
} else {
|
||||
assert_eq!(shown, [SPECKLED_WHOLE, SPECKLED_WHOLE], "room for {max}");
|
||||
left_out += 1;
|
||||
}
|
||||
}
|
||||
assert!(left_out > 0 && kept > 0, "{left_out} limits left the mask out, {kept} kept it");
|
||||
}
|
||||
|
||||
/// A copy of an image written whole already whose opacity mask is nested too deep to be written is
|
||||
/// written whole.
|
||||
#[test]
|
||||
fn a_masked_copy_nested_too_deep_for_its_mask_keeps_all_of_its_pixels() {
|
||||
// GUARDS: the copy was cut to its mask, and the mask then left out for its depth.
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let photo = speckled_photo(&mut doc);
|
||||
doc.insert(Some(layer), usize::MAX, photo).unwrap();
|
||||
// Isolated instances are written as their art, each a level deeper: the masked copy is at the limit.
|
||||
let art = photo_masked_to_its_corner(&mut doc);
|
||||
doc.symbols.push(Symbol { dynamic: false, name: "S0".into(), art: Arc::new(art) });
|
||||
for i in 1..writer::MAX_NEST {
|
||||
let mut inner = instance(&mut doc, &format!("S{}", i - 1), Affine::IDENTITY);
|
||||
inner.isolate = true;
|
||||
doc.symbols.push(Symbol { dynamic: false, name: format!("S{i}"), art: Arc::new(inner) });
|
||||
}
|
||||
let mut outer = instance(&mut doc, &format!("S{}", writer::MAX_NEST - 1), Affine::IDENTITY);
|
||||
outer.isolate = true;
|
||||
doc.insert(Some(layer), usize::MAX, outer).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::DEEP_MASKS), "{:?}", out.warnings);
|
||||
// The symbols' definitions hold copies too: each copy written with its mask is the part the
|
||||
// mask shows, and each copy whose mask is left out is whole.
|
||||
let sizes = sample_sizes(&doc);
|
||||
let whole = sizes.iter().filter(|s| **s == SPECKLED_WHOLE).count();
|
||||
let masks = String::from_utf8_lossy(&out.program).matches("(AI9_Mask)").count();
|
||||
assert_eq!(sizes.len() - whole, masks, "{sizes:?}");
|
||||
assert!(whole >= 2, "the first copy and a copy whose mask is left out: {sizes:?}");
|
||||
}
|
||||
|
||||
/// A linked image, an image with an opacity mask, and an image with a Warp and another effect are
|
||||
/// written as their drawn art, with no art style.
|
||||
#[test]
|
||||
|
||||
@@ -282,6 +282,8 @@ pub(super) struct Writer<'a> {
|
||||
/// The outlines of the clipping groups the art being written is in, in the document (empty in
|
||||
/// a definition, an opacity mask's art and a brush's art, which are written apart from them).
|
||||
shown: Vec<vectorcraft_geom::BezPath>,
|
||||
/// The copy of an image that the last [`Self::image`] wrote as a window, when it did.
|
||||
windowed: Option<images::Windowed>,
|
||||
}
|
||||
|
||||
impl<'a> Writer<'a> {
|
||||
@@ -350,6 +352,7 @@ impl<'a> Writer<'a> {
|
||||
last_samples: None,
|
||||
whole_images: HashMap::new(),
|
||||
shown: vec![],
|
||||
windowed: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,6 +660,8 @@ impl<'a> Writer<'a> {
|
||||
xy.knockout = Knockout::Off;
|
||||
}
|
||||
let start = self.body.len();
|
||||
// An image's copy written as the part that its opacity mask, among other regions, shows.
|
||||
let mut mask_window = None;
|
||||
match &n.kind {
|
||||
NodeKind::Layer { .. } => {}
|
||||
NodeKind::Group { children, clip: true } => self.clip_group(children, xy),
|
||||
@@ -690,14 +695,16 @@ impl<'a> Writer<'a> {
|
||||
.extras()
|
||||
.mask
|
||||
.as_ref()
|
||||
.filter(|m| m.clip && !m.invert && !m.disabled)
|
||||
.filter(|m| m.clip && !m.invert && !m.disabled && self.nest < MAX_NEST)
|
||||
.and_then(|m| vectorcraft_render::painted_bounds(self.doc, &m.art));
|
||||
if let Some(b) = masked {
|
||||
self.shown.push(b.to_path(0.1));
|
||||
}
|
||||
self.windowed = None;
|
||||
self.image(&self.doc.shown_image(n, im), xy);
|
||||
if masked.is_some() {
|
||||
self.shown.pop();
|
||||
mask_window = self.windowed.take();
|
||||
}
|
||||
}
|
||||
NodeKind::SymbolInstance { symbol, xf } if self.live_instance_in_room(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy),
|
||||
@@ -763,10 +770,14 @@ impl<'a> Writer<'a> {
|
||||
// A group's dictionary goes between its end and its state block.
|
||||
let block = (self.block_end == self.body.len() && self.block_start >= start).then(|| self.body.split_off(self.block_start));
|
||||
self.block_end = usize::MAX;
|
||||
self.dictionary(n);
|
||||
let mask_written = self.dictionary(n);
|
||||
if let Some(b) = block {
|
||||
self.body.push_str(&b);
|
||||
}
|
||||
// Without its opacity mask, the image shows whole.
|
||||
if let Some(w) = mask_window.filter(|_| !mask_written) {
|
||||
self.write_whole(w);
|
||||
}
|
||||
}
|
||||
// An envelope's plugin `XP` follows its distorted art, after the art's state block.
|
||||
if deform {
|
||||
@@ -796,24 +807,27 @@ impl<'a> Writer<'a> {
|
||||
/// `n`'s `/ArtDictionary`, after it, when it has a name (`AI10_ArtUID`, see [`Self::take_id`]),
|
||||
/// is bound to variables ([`Self::bound_variables`]; without a name, it gets an XML id and an
|
||||
/// empty `AIArtName`), has an opacity mask (`AI9_Mask`, see [`Self::mask`]), or is a symbol's
|
||||
/// art (its symbol's entries first, see [`Self::define_symbol`]).
|
||||
fn dictionary(&mut self, n: &Node) {
|
||||
/// art (its symbol's entries first, see [`Self::define_symbol`]). False when `n` has an opacity
|
||||
/// mask that is left out: nested too deep, or its art doesn't fit.
|
||||
fn dictionary(&mut self, n: &Node) -> bool {
|
||||
let name = n.name.as_deref().filter(|s| !s.is_empty());
|
||||
let placed = std::mem::take(&mut self.placed);
|
||||
let typed = std::mem::take(&mut self.typed);
|
||||
let bound = self.bound_variables(n, placed, typed);
|
||||
let mut mask = n.extras().mask.as_deref();
|
||||
let mut mask_written = true;
|
||||
// Masks nested too deep are left out (an empty mask would hide the object).
|
||||
if mask.is_some() && self.nest >= MAX_NEST {
|
||||
self.warn(DEEP_MASKS);
|
||||
mask = None;
|
||||
mask_written = false;
|
||||
}
|
||||
let entries = match &self.root_entries {
|
||||
Some((id, _)) if *id == n.id => self.root_entries.take().map(|(_, e)| e),
|
||||
_ => None,
|
||||
};
|
||||
if name.is_none() && mask.is_none() && bound.is_empty() && entries.is_none() {
|
||||
return;
|
||||
return mask_written;
|
||||
}
|
||||
let start = self.body.len();
|
||||
let has_id = name.is_some() || !bound.is_empty();
|
||||
@@ -836,12 +850,15 @@ impl<'a> Writer<'a> {
|
||||
// A mask whose art doesn't fit is left out, and with nothing else in it the dictionary too.
|
||||
if let Some(m) = mask
|
||||
&& !self.mask(m)
|
||||
&& !has_id
|
||||
&& !has_entries
|
||||
{
|
||||
return self.body.truncate(start);
|
||||
if !has_id && !has_entries {
|
||||
self.body.truncate(start);
|
||||
return false;
|
||||
}
|
||||
mask_written = false;
|
||||
}
|
||||
self.body.push_str("%_;\n%_\n");
|
||||
mask_written
|
||||
}
|
||||
|
||||
/// An `/ArtDictionary` whose `AI10_ArtUID` is `name` as an XML id.
|
||||
|
||||
@@ -30,6 +30,18 @@ pub(super) const SAMPLES_LINE: &str = "XI\u{1}";
|
||||
/// An image's pixels as the data holds them, and whether it had 16 bits a channel ([`Samples::of`]).
|
||||
pub(super) type Read = Arc<(Samples, bool)>;
|
||||
|
||||
/// A copy of an image written as the part of it that its regions show ([`Writer::image`]).
|
||||
pub(super) struct Windowed {
|
||||
/// Where its raster is in the body.
|
||||
at: std::ops::Range<usize>,
|
||||
/// The index of its samples in [`Writer::payloads`].
|
||||
payload: usize,
|
||||
/// The image's pixels.
|
||||
samples: Read,
|
||||
/// What places the image's whole pixel grid in the document.
|
||||
xf: Affine,
|
||||
}
|
||||
|
||||
/// An image's pixels as the data holds them.
|
||||
pub(super) struct Samples {
|
||||
/// `/DeviceGray`, `/DeviceRGB` or `/DeviceCMYK`.
|
||||
@@ -166,11 +178,14 @@ impl Writer<'_> {
|
||||
ai_ops::image_window(&clips, im.xf * fit, (samples.width, samples.height))
|
||||
})
|
||||
.flatten();
|
||||
match window {
|
||||
Some(window) => {
|
||||
self.raster(&samples.window(window), im.xf * fit * Affine::translate((f64::from(window[0]), f64::from(window[1]))))
|
||||
match (window, &read) {
|
||||
(Some(window), Some(read)) => {
|
||||
let at = self.body.len();
|
||||
self.raster(&samples.window(window), im.xf * fit * Affine::translate((f64::from(window[0]), f64::from(window[1]))));
|
||||
self.windowed =
|
||||
Some(Windowed { at: at..self.body.len(), payload: self.payloads.len() - 1, samples: read.clone(), xf: im.xf * fit });
|
||||
}
|
||||
None => {
|
||||
_ => {
|
||||
if let Some(d) = data {
|
||||
// `raster` adds the samples as the next payload. A copy written whole on
|
||||
// `%_` lines after an earlier whole copy keeps the earlier one's entry.
|
||||
@@ -290,6 +305,18 @@ impl Writer<'_> {
|
||||
read
|
||||
}
|
||||
|
||||
/// The copy `w`, written as a window, written whole in its place, with fresh samples; its
|
||||
/// window's samples are emptied.
|
||||
pub(super) fn write_whole(&mut self, w: Windowed) {
|
||||
let tail = self.body.split_off(w.at.end);
|
||||
self.body.truncate(w.at.start);
|
||||
if let Some(p) = self.payloads.get_mut(w.payload) {
|
||||
*p = Vec::new();
|
||||
}
|
||||
self.raster(&w.samples.0, w.xf);
|
||||
self.body.push_str(&tail);
|
||||
}
|
||||
|
||||
/// Keep `bytes` to go in the program where the mark this returns is.
|
||||
fn payload(&mut self, bytes: Vec<u8>) -> String {
|
||||
self.payloads.push(bytes);
|
||||
|
||||
@@ -617,7 +617,7 @@ since 2026-10-01.
|
||||
- **Warp effects bend images:** a Warp effect on an image, or on a group that holds one, bends the image's pixels with its frame, as the Warp effect is documented to distort bitmap images in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html): after the last effect, the image is cut into 10 × 10 cells of two triangles, each a clipped piece under its own affine map. Effects after the Warp reshape only the frame, a second Warp bends the same pieces, and warps saved without a version bend the pixels with their earlier map, as they bend the frame. A placed gradient on a group that holds the image follows the group's bounds as the pieces draw them. SVG writes the image once for the pieces; PDF, EPS, `.ai` and PNG write the bent pieces. The images stay unbent when their pieces would make the art pass 2 million objects, and so does an image whose frame keeps its shape past the anchor budget. A grayscale image's fill color now colorizes the pieces of a warp or an envelope, and an image under any geometry effect keeps that color and its smoothing setting. Existing documents with a Warp effect on an image, or on a group that holds one, draw differently: before, the image showed unbent, cut by the bent frame. So do existing documents with a colorized image under an envelope or a geometry effect, or an image drawn without smoothing under a geometry effect: before, those lost the color or the setting. The cell count and the limits are VectorCraft's own (effects `tests::warped_images_stay_within_the_object_budget`, `tests::warped_images_keep_document_keys_and_hidden_images_apart`, `tests::an_image_whose_frame_passes_the_budget_stays_whole`, `tests::effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces`, `tests::an_images_pixels_take_the_map_their_warps_version_names`, `tests::an_image_under_a_geometry_effect_keeps_its_color_and_smoothing`, `tests::a_nested_groups_gradient_follows_its_warped_image`; engine `warp_images::an_images_pixels_bend_with_effect_warp`, `a_warped_image_keeps_its_bend_in_svg`, `a_warped_image_keeps_its_bend_in_pdf`, `a_warped_image_keeps_its_bend_in_ai`, `a_warped_image_keeps_its_bend_in_eps`, `a_warped_image_keeps_its_bend_in_png`, `svg_writes_an_image_clipped_twice_in_a_users_group_twice`, `expand_appearance_of_a_warped_image_draws_as_the_canvas_did`, `a_colorized_image_keeps_its_color_when_warped`, `a_warp_on_a_group_bends_the_image_in_it`, `envelope_distort::a_colorized_image_keeps_its_color_in_an_envelope`). Scores and hours unchanged.
|
||||
- **`.ai` files keep an image's Warp live:** an embedded image whose appearance is one Warp effect, with no opacity mask, is saved to the `.ai` editing data with an art style of that Warp: its bent pieces are its drawn look, and the image itself follows on `%_` lines, as for other styled objects. Opening an `.ai` file reads such an image as the image with its Warp live, its pixels, placement, name and opacity kept, when the live warp draws as the file's drawn look and the import has a comparison left; otherwise the drawn look stays, with the drawn-look note. The Warp effect applies to bitmap images ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html)), and native files keep live effects ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/supported-file-formats-in-illustrator.html)). A linked image with a Warp is saved as its drawn art, as before, and its live Warp is not saved. Images with an opacity mask, and images with a Warp and another effect or with another geometry effect, are saved and opened as their drawn art. Existing `.ai` files with an embedded image on `%_` lines open differently: before, that image also came in beside its drawn look. Which images keep the Warp and the comparison are VectorCraft's own rules (eps `ai_editing::tests::an_image_with_a_warp_reads_back_with_its_warp_live`, `an_image_whose_warp_draws_otherwise_than_its_look_keeps_its_look`, `an_image_on_hidden_lines_does_not_come_in_beside_its_look`, `other_warped_images_are_written_as_their_drawn_art`, `import::ai::read::ops::tests::an_image_takes_a_style_of_one_warp_alone`). Scores and hours unchanged.
|
||||
- **EPS writes a repeated image's samples once:** an image drawn more than once in an EPS file (the pieces of an envelope or a Warp effect, copies of one image, or one image in a symbol's instances) has its compressed samples written once in the setup, and each placement reads them from there: a reusable stream rewound for each placement at Language Level 3 ([PostScript Language Reference, 3rd ed., §3.13](https://www.adobe.com/jp/print/postscript/pdfs/PLRM.pdf)), and an array of strings of at most 49,152 bytes fed to the image by a procedure at Level 2. An image drawn once is written where it is drawn, as before. The 256 MB limit on the page counts the samples once, so an envelope of a large photo no longer loses pieces to it. A colorized image is reused per ink (eps `tests::an_image_placed_twice_writes_its_samples_once_at_both_levels`, `tests::an_envelopes_image_pieces_draw_one_copy_of_its_samples`, `tests::each_ink_of_a_colorized_image_placed_twice_is_written_once`, `tests::samples_longer_than_a_string_are_reused_at_both_levels`, `tests::the_page_limit_counts_a_repeated_images_samples_once`). Scores and hours unchanged.
|
||||
- **`.ai` files write a warped image's pieces as the parts they show:** in the `.ai` editing data and in legacy `.ai` files, an embedded image cut to a clipping path (in the editing data, also an image under an opacity mask that clips) is written with all of its pixels the first time, and every later copy of the same pixels is written as the rectangle of them that the path or mask shows, 2 pixels more on each side, placed where it lies in the image. The pieces of a Warp effect, an envelope or their expanded art, 200 copies of the image for 10 × 10 cells before, now add about twice the image's pixels. Each image's pixels are read once for the pieces that follow one another. An image cut to a rectangle of half its pixels or more, and the first copy written, keep all of their pixels. The `XI` operator's arguments give an image's location and size ([AI file-format specification, §8.1](https://web.archive.org/web/20050126105313/https://partners.adobe.com/public/developer/en/illustrator/sdk/AI7FileFormat.pdf)); the 2-pixel margin and the half-the-pixels limit are VectorCraft's own rules (eps `ai_editing::tests::the_pieces_of_a_warped_image_are_written_as_the_parts_they_show`, `masked_pieces_of_a_warped_image_are_written_as_the_parts_they_show`, `an_image_with_a_live_warp_writes_its_pixels_whole_once`, `a_clipped_image_written_first_keeps_all_of_its_pixels`, `legacy_ai::tests::the_pieces_of_a_warped_image_are_written_as_the_parts_they_show`). Scores and hours unchanged.
|
||||
- **`.ai` files write a warped image's pieces as the parts they show:** in the `.ai` editing data and in legacy `.ai` files, an embedded image cut to a clipping path (in the editing data, also an image under an opacity mask that clips) is written with all of its pixels the first time, and every later copy of the same pixels is written as the rectangle of them that the path or mask shows, 2 pixels more on each side, placed where it lies in the image. The pieces of a Warp effect, an envelope or their expanded art, 200 copies of the image for 10 × 10 cells before, now hold about 7 times the image's pixels in all for a 64 × 64 image. A copy whose opacity mask is left out, nested too deep or past the object limit, keeps all of its pixels. Each image's pixels are read once for the pieces that follow one another. An image cut to a rectangle of half its pixels or more, and the first copy written, keep all of their pixels. The `XI` operator's arguments give an image's location and size ([AI file-format specification, §8.1](https://web.archive.org/web/20050126105313/https://partners.adobe.com/public/developer/en/illustrator/sdk/AI7FileFormat.pdf)); the 2-pixel margin and the half-the-pixels limit are VectorCraft's own rules (eps `ai_editing::tests::the_pieces_of_a_warped_image_are_written_as_the_parts_they_show`, `masked_pieces_of_a_warped_image_are_written_as_the_parts_they_show`, `an_image_with_a_live_warp_writes_its_pixels_whole_once`, `a_clipped_image_written_first_keeps_all_of_its_pixels`, `a_masked_copy_whose_mask_is_left_out_keeps_all_of_its_pixels`, `a_masked_copy_nested_too_deep_for_its_mask_keeps_all_of_its_pixels`, `legacy_ai::tests::the_pieces_of_a_warped_image_are_written_as_the_parts_they_show`). Scores and hours unchanged.
|
||||
- **PDF writes a warped image once:** with downsampling on, the pieces of an image bent by a Warp effect are resampled for the widest and the highest size any piece places the image at, so every piece paints one image XObject (a colorized image: one colorized image and its soft mask), instead of one image for each size a piece is placed at. Another placement of the same image is still resampled for its own size. Without downsampling, a colorized image and a CMYK image with a grayscale image mask on its grid are decoded and encoded once for all the sizes they are placed at (pdf `tests::compression::a_warped_image_is_written_once_for_all_its_pieces`, `tests::colorize::a_colorized_warped_image_is_written_once_for_all_its_pieces`, `export::images::tests::colorized_and_grid_masked_images_are_made_once_for_every_size_without_downsampling`). Scores and hours unchanged.
|
||||
- **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged.
|
||||
- **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged.
|
||||
|
||||
Reference in New Issue
Block a user