From afcac3954335a84e7e10cadb53b4b3045c112fbe Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 21 May 2026 14:00:55 +0300 Subject: [PATCH 1/2] Add ImageClipUnderRotation screenshot test (#3921) Issue #3921 ("Clipping region not respected with non-90 degree rotations") reports an EncodedImage misclip under a rotated clip when the outer Graphics already has scale + translate applied. The existing ClipUnderRotation screenshot test (added in 95d64ba6) covers the polygon-clip rasterisation path with fillRect; this new test exercises the same path through drawImage, which is what ddyer0's original screenshots actually show failing. Uses pushClip / popClip throughout. ddyer0's repro relied on `int[] clip = g.getClip(); ...; g.setClip(clip);` for save/restore, which can't by API contract preserve a non-axis-aligned (rotated-rect) clip shape -- ddyer0's own follow-up comment on the issue identified that round-trip as the source of the artefact, not a port bug. This test goes through the documented save/restore API so the rasterisation half of the bug is isolated. Three visual outcomes, distinguishable against a navy reference outline of the pre-rotation inner clip: - Correct: 30deg-tilted slice of the test image, overhanging the navy outline on two diagonal corners. - Bug A: image slice matches the navy outline exactly (rasteriser collapsed the rotated-rect clip to its bbox). - Bug B: full 60x60 image rendered, swamping the outline (rasteriser saw a polygon clip and disabled clipping; suspected pre-fix iOS Metal behaviour). Registered in Cn1ssDeviceRunner alongside ClipUnderRotation; added to the HTML5 skip list to match the other graphics screenshot tests. Goldens not included -- they need a CI capture per port. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/Cn1ssDeviceRunner.java | 3 + .../graphics/ImageClipUnderRotation.java | 175 ++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/ImageClipUnderRotation.java diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index fc4810df89..440f689dee 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -10,6 +10,7 @@ import com.codenameone.examples.hellocodenameone.tests.graphics.AffineScale; import com.codenameone.examples.hellocodenameone.tests.graphics.Clip; import com.codenameone.examples.hellocodenameone.tests.graphics.ClipUnderRotation; +import com.codenameone.examples.hellocodenameone.tests.graphics.ImageClipUnderRotation; import com.codenameone.examples.hellocodenameone.tests.graphics.DrawArc; import com.codenameone.examples.hellocodenameone.tests.graphics.DrawGradient; import com.codenameone.examples.hellocodenameone.tests.graphics.DrawGradientStops; @@ -136,6 +137,7 @@ private static int testTimeoutMs() { new StrokeTest(), new Clip(), new ClipUnderRotation(), + new ImageClipUnderRotation(), new TileImage(), new Rotate(), new TransformTranslation(), @@ -375,6 +377,7 @@ private static boolean isJsSkippedScreenshotTest(String testName) { || "AffineScale".equals(testName) || "Clip".equals(testName) || "ClipUnderRotation".equals(testName) + || "ImageClipUnderRotation".equals(testName) || "DrawArc".equals(testName) || "DrawGradient".equals(testName) || "DrawGradientStops".equals(testName) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/ImageClipUnderRotation.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/ImageClipUnderRotation.java new file mode 100644 index 0000000000..0d0d605a80 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/ImageClipUnderRotation.java @@ -0,0 +1,175 @@ +package com.codenameone.examples.hellocodenameone.tests.graphics; + +import com.codename1.ui.EncodedImage; +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; +import com.codename1.ui.Transform; +import com.codename1.ui.geom.Rectangle; +import com.codenameone.examples.hellocodenameone.tests.AbstractGraphicsScreenshotTest; + +// Targeted repro for issue #3921 ("Clipping region not respected with +// non-90 degree rotations") in the exact shape ddyer0 reported: an image +// drawn inside a rotated rect-clip while the outer Graphics already has a +// scale + translate applied. The companion ClipUnderRotation test covers +// the same polygon-clip rasterisation path with a fillRect; this one +// stresses the drawImage code path on top of it, because the original +// report's screenshots show a misclipped EncodedImage, not a misclipped +// fillRect. +// +// Differences from ddyer0's dtest.java: +// - pushClip / popClip is used for save / restore. The original repro +// used `int[] clip = g.getClip(); ...; g.setClip(clip);` which can't +// by API contract preserve a non-axis-aligned clip shape (a rotated +// rectangle on screen). ddyer0's own follow-up comment on the issue +// identified that round-trip as the source of his visible artefact; +// it is not a port bug, it is the rectangular shape of the int[4] +// return of getClip(). Using pushClip/popClip isolates the +// rasterisation half of the bug from that API limitation. +// - The "translate" vs "translateMatrix" simulator switch from the +// original repro is dropped; this test goes through the normal +// Graphics.translate / scale / rotateRadians path on every port. +// +// Sequence inside drawContent (per cell): +// pushClip +// clipRect(cell) outer axis-aligned clip +// scale(s, s) outer scale +// translate(tx, ty) outer translate (post-scale) +// pushClip +// rotateRadians(30deg, pivot) rotate around image centre +// clipRect(inner) intersect; screen-space clip is now a +// rotated rect (polygon) +// drawImage(testImage, ...) draw the recognisable test pattern +// rotateRadians(-30deg, ...) unrotate +// popClip restore axis-aligned outer clip +// translate(-tx, -ty) +// scale(1/s, 1/s) +// popClip restore identity-transform clip +// drawRect(inner outline) navy reference: where an axis-aligned +// bbox-clipped render would land +// +// Possible renders, visually distinguishable against the navy outline: +// +// - Correct: a 30deg-tilted slice of the test image (yellow / green +// border / black X), overhanging the navy outline on two corners +// and falling short on the opposite two. The polygon clip was +// honoured. +// - Bug A (clip widened to axis-aligned bbox): a slice of the image +// that matches the navy outline exactly. The rasteriser collapsed +// the rotated-rect clip to its bbox. +// - Bug B (polygon clip dropped entirely): the full test image is +// drawn at its native rectangle, swamping the navy outline. The +// rasteriser saw a polygon clip and disabled clipping. Suspected +// iOS Metal failure mode before the stencil-clip work landed +// (319c758b6, c56e7aab0, 1a5b132a0). +// +// The 2x2 grid emitted by AbstractGraphicsScreenshotTest separates the +// form-graphics path (top cells) from the mutable-image path (bottom +// cells), and anti-aliasing off/on left vs. right. A bug that only +// shows up on the mutable-image (drawImage) path stays localised. +public class ImageClipUnderRotation extends AbstractGraphicsScreenshotTest { + // ddyer0's reproduction image: blue square, black X across the + // diagonal, green 4-pixel border. The border is what makes the + // clipping shape obvious: a correctly-clipped rotated rect shows + // diagonal slivers of green that an axis-aligned bbox clip can't + // produce. + private static final int TEST_IMAGE_SIZE = 100; + + private EncodedImage testImage; + + private EncodedImage buildTestImage() { + Image src = Image.createImage(TEST_IMAGE_SIZE, TEST_IMAGE_SIZE); + Graphics gc = src.getGraphics(); + gc.setColor(0x0000ff); + gc.fillRect(0, 0, TEST_IMAGE_SIZE, TEST_IMAGE_SIZE); + gc.setColor(0x000000); + gc.drawLine(0, 0, TEST_IMAGE_SIZE, TEST_IMAGE_SIZE); + gc.drawLine(0, TEST_IMAGE_SIZE, TEST_IMAGE_SIZE, 0); + gc.setColor(0x00ff00); + gc.fillRect(0, 0, TEST_IMAGE_SIZE, 4); + gc.fillRect(0, 0, 4, TEST_IMAGE_SIZE); + gc.fillRect(0, TEST_IMAGE_SIZE - 4, TEST_IMAGE_SIZE, 4); + gc.fillRect(TEST_IMAGE_SIZE - 4, 0, 4, TEST_IMAGE_SIZE); + return EncodedImage.createFromImage(src, true); + } + + @Override + protected void drawContent(Graphics g, Rectangle bounds) { + int x = bounds.getX(); + int y = bounds.getY(); + int w = bounds.getWidth(); + int h = bounds.getHeight(); + + g.setColor(0xeeeeee); + g.fillRect(x, y, w, h); + + if (!Transform.isSupported()) { + g.setColor(0); + g.drawString("Affine unsupported", x + 4, y + 4); + return; + } + if (testImage == null) { + testImage = buildTestImage(); + } + + // Geometry: target a 60x60 image drawn at a logical (xp, yp) under + // a 2.5x scale and a small translate offset, so the outer + // transform stack matches ddyer0's repro shape (scale + translate + // + per-tile rotate + clipRect). Everything is anchored to the + // cell bounds so the test renders consistently for both the form + // and mutable cell sizes. + float scale = 2.5f; + int imageDrawSize = 60; + int clipInset = 2; + int clipSize = 56; + int xp = (int)((w / scale) / 2 - imageDrawSize / 2); + int yp = (int)((h / scale) / 2 - imageDrawSize / 2); + int pivotX = xp + imageDrawSize / 2; + int pivotY = yp + imageDrawSize / 2; + int tx = x; + int ty = y; + + g.pushClip(); + g.clipRect(x, y, w, h); + g.scale(scale, scale); + g.translate((int)(tx / scale), (int)(ty / scale)); + + g.pushClip(); + float angle = (float)(Math.PI / 6); // 30deg + g.rotateRadians(angle, pivotX, pivotY); + g.clipRect(xp + clipInset, yp + clipInset, clipSize, clipSize); + g.drawImage(testImage, xp, yp, imageDrawSize, imageDrawSize); + g.rotateRadians(-angle, pivotX, pivotY); + g.popClip(); + + g.translate(-(int)(tx / scale), -(int)(ty / scale)); + g.scale(1f / scale, 1f / scale); + g.popClip(); + + // Reference outline of the pre-rotation inner clip, in + // post-scale + post-translate coordinates. Drawn after popClip in + // identity-transform space so its position is independent of the + // popped clip state. Apply the same scale/translate transform + // chain just for this drawRect, otherwise the navy outline lands + // somewhere unrelated to where the image was rendered. + g.pushClip(); + g.clipRect(x, y, w, h); + g.scale(scale, scale); + g.translate((int)(tx / scale), (int)(ty / scale)); + g.setColor(0x000080); + g.drawRect(xp + clipInset, yp + clipInset, clipSize - 1, clipSize - 1); + g.translate(-(int)(tx / scale), -(int)(ty / scale)); + g.scale(1f / scale, 1f / scale); + g.popClip(); + + // Sentinel green dot in the corner; identity-transform space. + // If popClip / un-rotate / un-scale all restored correctly this + // pixel lands at the cell's top-left, not somewhere transformed. + g.setColor(0x008000); + g.fillRect(x + 2, y + 2, 6, 6); + } + + @Override + protected String screenshotName() { + return "graphics-image-clip-under-rotation"; + } +} From c3d54c4cef0d3f69feaa587f1d2b32f56926cdda Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 21 May 2026 14:50:44 +0300 Subject: [PATCH 2/2] ImageClipUnderRotation: centre geometry, drop outer scale/translate Feedback on the first revision: the 4 grid cells looked different from one another (the outer scale + translate I copied from ddyer0's repro mapped the image to slightly different positions depending on cell bounds), the colours were not vivid enough (the green 4px border disappeared at the rendering scale), and the comment block kept referring to a non-existent "red fill" carried over from the sibling ClipUnderRotation.java. Changes: - Geometry computed from cell centre: `side = 0.7 * min(w, h)`, `imgX = x + (w - side) / 2`. Identical across all 4 cells. - Outer scale + translate dropped. They were ddyer0's repro shape, but the bug is about rotation under clip, not scale, so the scale/translate was noise. - Test image is now solid yellow with a 10-pixel magenta border and a black diagonal X. Yellow / magenta / black all distinct from the gray background and the navy reference outline. - A dim (alpha=64) full-size copy of the image is drawn first as an underlay so the clipped over-paint has a baseline to compare against: if the bright over-paint extends past the dim underlay it's a no-clip bug; if it matches the navy outline exactly it's a bbox-collapse bug; if it's a tilted square overhanging the navy outline at two corners it's correct. - Comment block shrunk to ~15 lines. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../graphics/ImageClipUnderRotation.java | 175 ++++++------------ 1 file changed, 59 insertions(+), 116 deletions(-) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/ImageClipUnderRotation.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/ImageClipUnderRotation.java index 0d0d605a80..b7f3a242b2 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/ImageClipUnderRotation.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/ImageClipUnderRotation.java @@ -7,88 +7,47 @@ import com.codename1.ui.geom.Rectangle; import com.codenameone.examples.hellocodenameone.tests.AbstractGraphicsScreenshotTest; -// Targeted repro for issue #3921 ("Clipping region not respected with -// non-90 degree rotations") in the exact shape ddyer0 reported: an image -// drawn inside a rotated rect-clip while the outer Graphics already has a -// scale + translate applied. The companion ClipUnderRotation test covers -// the same polygon-clip rasterisation path with a fillRect; this one -// stresses the drawImage code path on top of it, because the original -// report's screenshots show a misclipped EncodedImage, not a misclipped -// fillRect. +// Issue #3921: image drawn inside a rotated clip should be clipped to the +// rotated rect, not its axis-aligned bounding box, and not left unclipped. // -// Differences from ddyer0's dtest.java: -// - pushClip / popClip is used for save / restore. The original repro -// used `int[] clip = g.getClip(); ...; g.setClip(clip);` which can't -// by API contract preserve a non-axis-aligned clip shape (a rotated -// rectangle on screen). ddyer0's own follow-up comment on the issue -// identified that round-trip as the source of his visible artefact; -// it is not a port bug, it is the rectangular shape of the int[4] -// return of getClip(). Using pushClip/popClip isolates the -// rasterisation half of the bug from that API limitation. -// - The "translate" vs "translateMatrix" simulator switch from the -// original repro is dropped; this test goes through the normal -// Graphics.translate / scale / rotateRadians path on every port. +// Per cell: draw a recognisable test image, rotate the Graphics 30deg +// around the image centre, clipRect a slightly inset rectangle (which is +// a rotated rectangle in screen space, i.e. a polygon clip), then draw +// the same image again on top. After popClip, draw a navy outline of the +// same inset rect un-rotated as a reference. // -// Sequence inside drawContent (per cell): -// pushClip -// clipRect(cell) outer axis-aligned clip -// scale(s, s) outer scale -// translate(tx, ty) outer translate (post-scale) -// pushClip -// rotateRadians(30deg, pivot) rotate around image centre -// clipRect(inner) intersect; screen-space clip is now a -// rotated rect (polygon) -// drawImage(testImage, ...) draw the recognisable test pattern -// rotateRadians(-30deg, ...) unrotate -// popClip restore axis-aligned outer clip -// translate(-tx, -ty) -// scale(1/s, 1/s) -// popClip restore identity-transform clip -// drawRect(inner outline) navy reference: where an axis-aligned -// bbox-clipped render would land +// Correct: the over-painted image appears as a 30deg-tilted square, +// overhanging the navy outline at two diagonal corners. +// Bug: the over-paint matches the navy outline exactly (rasteriser +// collapsed the polygon clip to its bbox), or covers the whole +// underlying image (rasteriser dropped the clip entirely). // -// Possible renders, visually distinguishable against the navy outline: -// -// - Correct: a 30deg-tilted slice of the test image (yellow / green -// border / black X), overhanging the navy outline on two corners -// and falling short on the opposite two. The polygon clip was -// honoured. -// - Bug A (clip widened to axis-aligned bbox): a slice of the image -// that matches the navy outline exactly. The rasteriser collapsed -// the rotated-rect clip to its bbox. -// - Bug B (polygon clip dropped entirely): the full test image is -// drawn at its native rectangle, swamping the navy outline. The -// rasteriser saw a polygon clip and disabled clipping. Suspected -// iOS Metal failure mode before the stencil-clip work landed -// (319c758b6, c56e7aab0, 1a5b132a0). -// -// The 2x2 grid emitted by AbstractGraphicsScreenshotTest separates the -// form-graphics path (top cells) from the mutable-image path (bottom -// cells), and anti-aliasing off/on left vs. right. A bug that only -// shows up on the mutable-image (drawImage) path stays localised. +// Uses pushClip/popClip only -- no getClip/setClip(int[]) -- so the +// rectangular-int[4] limitation that ddyer0 himself called out on the +// issue can't confound the rasterisation signal. public class ImageClipUnderRotation extends AbstractGraphicsScreenshotTest { - // ddyer0's reproduction image: blue square, black X across the - // diagonal, green 4-pixel border. The border is what makes the - // clipping shape obvious: a correctly-clipped rotated rect shows - // diagonal slivers of green that an axis-aligned bbox clip can't - // produce. - private static final int TEST_IMAGE_SIZE = 100; + private static final int TEST_IMAGE_SIZE = 120; private EncodedImage testImage; + // Test image: solid yellow with a thick magenta border and a thick + // black diagonal X. Yellow + magenta + black are all easy to tell + // apart from the gray background and the navy reference outline. private EncodedImage buildTestImage() { Image src = Image.createImage(TEST_IMAGE_SIZE, TEST_IMAGE_SIZE); Graphics gc = src.getGraphics(); - gc.setColor(0x0000ff); + gc.setAntiAliased(false); + gc.setColor(0xffff00); gc.fillRect(0, 0, TEST_IMAGE_SIZE, TEST_IMAGE_SIZE); + gc.setColor(0xff00ff); + int border = 10; + gc.fillRect(0, 0, TEST_IMAGE_SIZE, border); + gc.fillRect(0, TEST_IMAGE_SIZE - border, TEST_IMAGE_SIZE, border); + gc.fillRect(0, 0, border, TEST_IMAGE_SIZE); + gc.fillRect(TEST_IMAGE_SIZE - border, 0, border, TEST_IMAGE_SIZE); gc.setColor(0x000000); - gc.drawLine(0, 0, TEST_IMAGE_SIZE, TEST_IMAGE_SIZE); - gc.drawLine(0, TEST_IMAGE_SIZE, TEST_IMAGE_SIZE, 0); - gc.setColor(0x00ff00); - gc.fillRect(0, 0, TEST_IMAGE_SIZE, 4); - gc.fillRect(0, 0, 4, TEST_IMAGE_SIZE); - gc.fillRect(0, TEST_IMAGE_SIZE - 4, TEST_IMAGE_SIZE, 4); - gc.fillRect(TEST_IMAGE_SIZE - 4, 0, 4, TEST_IMAGE_SIZE); + gc.drawLine(0, 0, TEST_IMAGE_SIZE - 1, TEST_IMAGE_SIZE - 1); + gc.drawLine(0, TEST_IMAGE_SIZE - 1, TEST_IMAGE_SIZE - 1, 0); return EncodedImage.createFromImage(src, true); } @@ -111,61 +70,45 @@ protected void drawContent(Graphics g, Rectangle bounds) { testImage = buildTestImage(); } - // Geometry: target a 60x60 image drawn at a logical (xp, yp) under - // a 2.5x scale and a small translate offset, so the outer - // transform stack matches ddyer0's repro shape (scale + translate - // + per-tile rotate + clipRect). Everything is anchored to the - // cell bounds so the test renders consistently for both the form - // and mutable cell sizes. - float scale = 2.5f; - int imageDrawSize = 60; - int clipInset = 2; - int clipSize = 56; - int xp = (int)((w / scale) / 2 - imageDrawSize / 2); - int yp = (int)((h / scale) / 2 - imageDrawSize / 2); - int pivotX = xp + imageDrawSize / 2; - int pivotY = yp + imageDrawSize / 2; - int tx = x; - int ty = y; + // Centre a square image region of side = 70% of min(w, h) in the + // cell, then inset by 12% on every side for the inner clip. This + // keeps the geometry identical across all four cells regardless + // of their size or position, and leaves a generous gray margin + // so a Bug-no-clip render is unmistakable. + int side = (int)(Math.min(w, h) * 0.7f); + int imgX = x + (w - side) / 2; + int imgY = y + (h - side) / 2; + int inset = side / 8; + int clipX = imgX + inset; + int clipY = imgY + inset; + int clipW = side - inset * 2; + int clipH = side - inset * 2; + int pivotX = imgX + side / 2; + int pivotY = imgY + side / 2; - g.pushClip(); - g.clipRect(x, y, w, h); - g.scale(scale, scale); - g.translate((int)(tx / scale), (int)(ty / scale)); + // Underlay: a dim greyed-out copy of the image at full size, no + // clip applied. This gives a baseline so the over-painted clipped + // copy is visually anchored against the full image footprint. + g.setAlpha(64); + g.drawImage(testImage, imgX, imgY, side, side); + g.setAlpha(255); + // Clipped rotated over-paint. After popClip the transform is + // restored to identity so the navy outline below lands where the + // un-rotated inset rect would. g.pushClip(); float angle = (float)(Math.PI / 6); // 30deg g.rotateRadians(angle, pivotX, pivotY); - g.clipRect(xp + clipInset, yp + clipInset, clipSize, clipSize); - g.drawImage(testImage, xp, yp, imageDrawSize, imageDrawSize); + g.clipRect(clipX, clipY, clipW, clipH); + g.drawImage(testImage, imgX, imgY, side, side); g.rotateRadians(-angle, pivotX, pivotY); g.popClip(); - g.translate(-(int)(tx / scale), -(int)(ty / scale)); - g.scale(1f / scale, 1f / scale); - g.popClip(); - - // Reference outline of the pre-rotation inner clip, in - // post-scale + post-translate coordinates. Drawn after popClip in - // identity-transform space so its position is independent of the - // popped clip state. Apply the same scale/translate transform - // chain just for this drawRect, otherwise the navy outline lands - // somewhere unrelated to where the image was rendered. - g.pushClip(); - g.clipRect(x, y, w, h); - g.scale(scale, scale); - g.translate((int)(tx / scale), (int)(ty / scale)); + // Navy reference outline of the un-rotated inset rect: the + // bbox-bug render would line up with this exactly; the correct + // render is a tilted square overhanging it at two corners. g.setColor(0x000080); - g.drawRect(xp + clipInset, yp + clipInset, clipSize - 1, clipSize - 1); - g.translate(-(int)(tx / scale), -(int)(ty / scale)); - g.scale(1f / scale, 1f / scale); - g.popClip(); - - // Sentinel green dot in the corner; identity-transform space. - // If popClip / un-rotate / un-scale all restored correctly this - // pixel lands at the cell's top-left, not somewhere transformed. - g.setColor(0x008000); - g.fillRect(x + 2, y + 2, 6, 6); + g.drawRect(clipX, clipY, clipW - 1, clipH - 1); } @Override