diff --git a/CodenameOne/src/com/codename1/ui/RGBImage.java b/CodenameOne/src/com/codename1/ui/RGBImage.java index 21b427dd10..5bb024ebef 100644 --- a/CodenameOne/src/com/codename1/ui/RGBImage.java +++ b/CodenameOne/src/com/codename1/ui/RGBImage.java @@ -197,6 +197,47 @@ protected void drawImage(Graphics g, Object nativeGraphics, int x, int y) { g.drawRGB(rgb, 0, x, y, width, height, !opaque); } + /// {@inheritDoc} + /// + /// `RGBImage` has no native peer, so the inherited scaled-draw path + /// (`g.drawImageWH(image, ...)`) renders nothing. Instead, build a + /// translate + scale affine transform on top of the graphics context's + /// current transform and emit `drawRGB` at the image's native size -- + /// the platform pipeline (iOS Metal, Android Skia, Graphics2D, ...) + /// performs the actual scaling in hardware / native code. + /// + /// `Graphics.setTransform` is used (rather than `translateMatrix` + + /// `scale`) because on ports where `impl.isTranslationSupported()` is + /// false (iOS), prior `g.translate(int, int)` calls accumulate into a + /// per-Graphics integer translate that is baked into draw coordinates + /// **before** the impl matrix is applied. A naked `translateMatrix` / + /// `scale` composition would therefore multiply that accumulator by + /// the scale factor, shifting the on-screen position. `setTransform` + /// conjugates the matrix with `T(xTranslate, yTranslate)`, cancelling + /// the accumulator so the result lands at the requested coordinates + /// on every port. + @Override + protected void drawImage(Graphics g, Object nativeGraphics, int x, int y, int w, int h) { + if (w <= 0 || h <= 0) { + return; + } + if (w == width && h == height) { + g.drawRGB(rgb, 0, x, y, width, height, !opaque); + return; + } + Transform saved = Transform.makeIdentity(); + g.getTransform(saved); + try { + Transform scaled = saved.copy(); + scaled.translate(x, y); + scaled.scale(((float) w) / width, ((float) h) / height); + g.setTransform(scaled); + g.drawRGB(rgb, 0, 0, 0, width, height, !opaque); + } finally { + g.setTransform(saved); + } + } + /// Indicates if an image should be treated as opaque, this can improve support /// for fast drawing of RGB images without alpha support. @Override diff --git a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java index ce54977d3e..59c5fc2fb1 100644 --- a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java +++ b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java @@ -30,6 +30,7 @@ import com.codename1.ui.TextArea; import com.codename1.ui.TextField; import com.codename1.ui.TextSelection; +import com.codename1.ui.Transform; import com.codename1.ui.events.ActionEvent; import com.codename1.ui.events.ActionListener; import com.codename1.ui.events.MessageEvent; @@ -1293,10 +1294,34 @@ public void resetClipTracking() { @Override public void resetAffine(Object nativeGraphics) { + if (nativeGraphics instanceof TestGraphics) { + ((TestGraphics) nativeGraphics).transform.setIdentity(); + } } @Override public void scale(Object nativeGraphics, float x, float y) { + if (nativeGraphics instanceof TestGraphics) { + TestTransform t = ((TestGraphics) nativeGraphics).transform; + TestTransform s = new TestTransform(); + s.setScale(x, y, 1f); + t.concatenate(s); + } + } + + @Override + public boolean isTranslateMatrixSupported() { + return true; + } + + @Override + public void translateMatrix(Object nativeGraphics, float x, float y) { + if (nativeGraphics instanceof TestGraphics) { + TestTransform t = ((TestGraphics) nativeGraphics).transform; + TestTransform translation = new TestTransform(); + translation.setTranslation(x, y, 0f); + t.concatenate(translation); + } } @@ -1371,6 +1396,11 @@ public Object makeTransformAffine(double m00, double m10, double m01, double m11 return transform; } + @Override + public void setTransformAffine(Object nativeTransform, double m00, double m10, double m01, double m11, double m02, double m12) { + ((TestTransform) nativeTransform).setAffine((float) m00, (float) m01, (float) m02, (float) m10, (float) m11, (float) m12); + } + @Override public Object makeTransformInverse(Object nativeTransform) { return ((TestTransform) nativeTransform).createInverse(); @@ -2174,8 +2204,135 @@ public void fillLinearGradient(Object graphics, int startColor, int endColor, in public void drawImage(Object graphics, Object img, int x, int y) { } + /** + * Rasterizes the RGB array into the destination {@link TestImage} backing the + * supplied graphics, applying the current affine transform stored on the + * {@link TestGraphics}. Used by tests that exercise scaled image draws -- + * the production no-op was insufficient because pixel-level assertions need + * actual output. The mapping is inverse, nearest-neighbour, so an integer + * scale yields exact source pixel replication. + */ @Override public void drawRGB(Object graphics, int[] rgbData, int offset, int x, int y, int w, int h, boolean processAlpha) { + if (!(graphics instanceof TestGraphics) || w <= 0 || h <= 0) { + return; + } + TestGraphics g = (TestGraphics) graphics; + if (g.image == null) { + return; + } + + float[] corner = new float[2]; + float[] mapped = new float[2]; + float minDestX = Float.POSITIVE_INFINITY, minDestY = Float.POSITIVE_INFINITY; + float maxDestX = Float.NEGATIVE_INFINITY, maxDestY = Float.NEGATIVE_INFINITY; + for (int c = 0; c < 4; c++) { + corner[0] = (c & 1) == 0 ? x : x + w; + corner[1] = (c & 2) == 0 ? y : y + h; + g.transform.transformPoint(corner, mapped); + if (mapped[0] < minDestX) minDestX = mapped[0]; + if (mapped[1] < minDestY) minDestY = mapped[1]; + if (mapped[0] > maxDestX) maxDestX = mapped[0]; + if (mapped[1] > maxDestY) maxDestY = mapped[1]; + } + + int clipRight = g.clipX + Math.max(0, g.clipWidth); + int clipBottom = g.clipY + Math.max(0, g.clipHeight); + int destStartX = Math.max((int) Math.floor(minDestX), g.clipX); + int destStartY = Math.max((int) Math.floor(minDestY), g.clipY); + int destEndX = Math.min((int) Math.ceil(maxDestX), clipRight); + int destEndY = Math.min((int) Math.ceil(maxDestY), clipBottom); + if (destStartX >= destEndX || destStartY >= destEndY) { + return; + } + + TestTransform inv = g.transform.createInverse(); + int imgW = g.image.width; + int imgH = g.image.height; + float[] destSample = new float[2]; + float[] srcSample = new float[2]; + for (int dy = destStartY; dy < destEndY; dy++) { + if (dy < 0 || dy >= imgH) { + continue; + } + int rowOffset = dy * imgW; + for (int dx = destStartX; dx < destEndX; dx++) { + if (dx < 0 || dx >= imgW) { + continue; + } + destSample[0] = dx + 0.5f; + destSample[1] = dy + 0.5f; + inv.transformPoint(destSample, srcSample); + int sx = (int) Math.floor(srcSample[0]) - x; + int sy = (int) Math.floor(srcSample[1]) - y; + if (sx < 0 || sx >= w || sy < 0 || sy >= h) { + continue; + } + int srcIdx = offset + sy * w + sx; + if (srcIdx < 0 || srcIdx >= rgbData.length) { + continue; + } + int srcArgb = rgbData[srcIdx]; + int dstIdx = rowOffset + dx; + if (!processAlpha || (srcArgb & 0xff000000) == 0xff000000) { + g.image.argb[dstIdx] = srcArgb; + } else { + int srcAlpha = (srcArgb >>> 24) & 0xff; + if (srcAlpha == 0) { + continue; + } + int srcR = (srcArgb >>> 16) & 0xff; + int srcG = (srcArgb >>> 8) & 0xff; + int srcB = srcArgb & 0xff; + int dstArgb = g.image.argb[dstIdx]; + int dstR = (dstArgb >>> 16) & 0xff; + int dstG = (dstArgb >>> 8) & 0xff; + int dstB = dstArgb & 0xff; + int outR = (srcR * srcAlpha + dstR * (255 - srcAlpha)) / 255; + int outG = (srcG * srcAlpha + dstG * (255 - srcAlpha)) / 255; + int outB = (srcB * srcAlpha + dstB * (255 - srcAlpha)) / 255; + g.image.argb[dstIdx] = 0xff000000 | (outR << 16) | (outG << 8) | outB; + } + } + } + } + + @Override + public void setTransform(Object graphics, Transform transform) { + if (!(graphics instanceof TestGraphics)) { + super.setTransform(graphics, transform); + return; + } + TestGraphics g = (TestGraphics) graphics; + if (transform == null || transform.isIdentity()) { + g.transform.setIdentity(); + return; + } + Object nativeT = transform.getNativeTransform(); + if (nativeT instanceof TestTransform) { + g.transform.copyFrom((TestTransform) nativeT); + } else { + g.transform.setIdentity(); + } + } + + @Override + public Transform getTransform(Object graphics) { + if (!(graphics instanceof TestGraphics)) { + return super.getTransform(graphics); + } + TestTransform t = ((TestGraphics) graphics).transform; + return Transform.makeAffine(t.m00, t.m10, t.m01, t.m11, t.m02, t.m12); + } + + @Override + public void getTransform(Object graphics, Transform t) { + if (!(graphics instanceof TestGraphics)) { + super.getTransform(graphics, t); + return; + } + TestTransform src = ((TestGraphics) graphics).transform; + t.setAffine(src.m00, src.m10, src.m01, src.m11, src.m02, src.m12); } @Override @@ -3785,6 +3942,7 @@ public static final class TestGraphics { int clipHeight; int translateX; int translateY; + final TestTransform transform = new TestTransform(); TestFont font; TestImage image; diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/RGBImageTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/RGBImageTest.java index bac2e48480..dacee73b32 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/RGBImageTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/RGBImageTest.java @@ -4,6 +4,7 @@ import com.codename1.junit.UITestBase; import com.codename1.ui.Graphics; import com.codename1.ui.Image; +import com.codename1.ui.Transform; import static org.junit.jupiter.api.Assertions.*; @@ -73,4 +74,143 @@ void testDrawImageAndGetRGB() { Graphics g = canvas.getGraphics(); g.drawImage(image, 0, 0); } + + // Regression test for https://github.com/codenameone/CodenameOne/issues/4188: + // the (w, h) overload of drawImage used to fall through Image#drawImage, which + // dispatches through the (null) native peer and renders nothing. The new + // override pushes a translate + scale affine transform onto the graphics + // context and emits drawRGB at native size, so the platform pipeline applies + // the scaling. + @FormTest + void testScaledDrawImageIntegerScale() { + int red = 0xffff0000; + int green = 0xff00ff00; + int blue = 0xff0000ff; + int white = 0xffffffff; + RGBImage source = new RGBImage(new int[]{red, green, blue, white}, 2, 2); + + Image canvas = Image.createImage(4, 4, 0xff000000); + Graphics g = canvas.getGraphics(); + g.drawImage(source, 0, 0, 4, 4); + + int[] actual = canvas.getRGB(); + int[] expected = new int[]{ + red, red, green, green, + red, red, green, green, + blue, blue, white, white, + blue, blue, white, white + }; + assertArrayEquals(expected, actual, + "2x integer upscale of RGBImage should replicate each source pixel into a 2x2 block"); + } + + @FormTest + void testScaledDrawImageAtOffset() { + int red = 0xffff0000; + int green = 0xff00ff00; + int blue = 0xff0000ff; + int white = 0xffffffff; + int bg = 0xff000000; + RGBImage source = new RGBImage(new int[]{red, green, blue, white}, 2, 2); + + Image canvas = Image.createImage(6, 6, bg); + Graphics g = canvas.getGraphics(); + g.drawImage(source, 1, 1, 4, 4); + + int[] actual = canvas.getRGB(); + int[] expected = new int[]{ + bg, bg, bg, bg, bg, bg, + bg, red, red, green, green, bg, + bg, red, red, green, green, bg, + bg, blue, blue, white, white, bg, + bg, blue, blue, white, white, bg, + bg, bg, bg, bg, bg, bg + }; + assertArrayEquals(expected, actual, + "Scaled draw at (1,1) of a 4x4 region should land within the inner 4x4 of a 6x6 canvas"); + } + + @FormTest + void testScaledDrawImageDownscale() { + int red = 0xffff0000; + int green = 0xff00ff00; + int blue = 0xff0000ff; + int white = 0xffffffff; + int[] src = new int[16]; + for (int row = 0; row < 4; row++) { + for (int col = 0; col < 4; col++) { + int color; + if (row < 2 && col < 2) color = red; + else if (row < 2) color = green; + else if (col < 2) color = blue; + else color = white; + src[row * 4 + col] = color; + } + } + RGBImage source = new RGBImage(src, 4, 4); + + Image canvas = Image.createImage(2, 2, 0xff000000); + Graphics g = canvas.getGraphics(); + g.drawImage(source, 0, 0, 2, 2); + + int[] actual = canvas.getRGB(); + int[] expected = new int[]{red, green, blue, white}; + assertArrayEquals(expected, actual, + "2x integer downscale should pick one representative pixel per source quadrant"); + } + + // Regression test for the screen-rendering case: on ports where + // impl.isTranslationSupported() is false (iOS), g.translate(...) calls + // accumulate xTranslate/yTranslate on the Graphics object and are baked + // into drawRGB coordinates BEFORE the impl matrix is applied. A naked + // translateMatrix + scale composition would multiply that accumulator + // by the scale factor, shifting the image off-target. The fix uses + // setTransform, which conjugates the matrix with T(xT, yT) so the + // image lands at (xT + x, yT + y) regardless of the scale factor. + @FormTest + void testScaledDrawImageRespectsPriorGraphicsTranslate() { + int red = 0xffff0000; + int green = 0xff00ff00; + int blue = 0xff0000ff; + int white = 0xffffffff; + int bg = 0xff000000; + RGBImage source = new RGBImage(new int[]{red, green, blue, white}, 2, 2); + + Image canvas = Image.createImage(8, 8, bg); + Graphics g = canvas.getGraphics(); + g.translate(2, 2); + g.drawImage(source, 0, 0, 4, 4); + + int[] actual = canvas.getRGB(); + int[] expected = new int[]{ + bg, bg, bg, bg, bg, bg, bg, bg, + bg, bg, bg, bg, bg, bg, bg, bg, + bg, bg, red, red, green, green, bg, bg, + bg, bg, red, red, green, green, bg, bg, + bg, bg, blue, blue, white, white, bg, bg, + bg, bg, blue, blue, white, white, bg, bg, + bg, bg, bg, bg, bg, bg, bg, bg, + bg, bg, bg, bg, bg, bg, bg, bg + }; + assertArrayEquals(expected, actual, + "Scaled draw should land at (xTranslate + x, yTranslate + y) -- the prior g.translate " + + "must not be multiplied by the scale factor"); + } + + @FormTest + void testScaledDrawImagePreservesPriorTransform() { + RGBImage source = createSampleImage(); + + Image canvas = Image.createImage(4, 4, 0xff000000); + Graphics g = canvas.getGraphics(); + Transform before = Transform.makeIdentity(); + g.getTransform(before); + + g.drawImage(source, 0, 0, 4, 4); + + Transform after = Transform.makeIdentity(); + g.getTransform(after); + assertTrue(before.equals(after), + "drawImage(w,h) must restore the graphics transform it modified"); + } } diff --git a/scripts/android/screenshots/graphics-draw-image-rect.png b/scripts/android/screenshots/graphics-draw-image-rect.png index 8b42dbd6b0..877d71b031 100644 Binary files a/scripts/android/screenshots/graphics-draw-image-rect.png and b/scripts/android/screenshots/graphics-draw-image-rect.png differ diff --git a/scripts/ios/screenshots-metal/graphics-draw-image-rect.png b/scripts/ios/screenshots-metal/graphics-draw-image-rect.png index 7048412f1a..05c67699d9 100644 Binary files a/scripts/ios/screenshots-metal/graphics-draw-image-rect.png and b/scripts/ios/screenshots-metal/graphics-draw-image-rect.png differ diff --git a/scripts/ios/screenshots/graphics-draw-image-rect.png b/scripts/ios/screenshots/graphics-draw-image-rect.png index 8e427ddafa..38897da779 100644 Binary files a/scripts/ios/screenshots/graphics-draw-image-rect.png and b/scripts/ios/screenshots/graphics-draw-image-rect.png differ