diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index eae5a0fe01..47d532e6a7 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -3616,6 +3616,47 @@ public void resetAffine(Object nativeGraphics) { System.out.println("Affine unsupported"); } + /// Indicates whether the underlying implementation composes + /// `g.translateMatrix(float, float)` onto the impl-side transform matrix. + /// When this returns false `Graphics.translateMatrix` silently falls + /// back to the per-Graphics integer accumulator + /// (`Graphics.translate(int, int)`), so apps don't render at the wrong + /// position on ports that haven't been updated. Ports that DO route + /// `translateMatrix` through the matrix (iOS, JavaSE, Android, modern + /// JavaScript) must override this to return true AND override + /// `#translateMatrix(Object, float, float)`. The legacy / restricted + /// JavaScript builds keep the default false until the matrix path is + /// wired up. + /// + /// #### Returns + /// + /// true if `translateMatrix` reaches the impl matrix on this port. + public boolean isTranslateMatrixSupported() { + return false; + } + + /// Composes a translation onto the impl-side transform matrix -- the + /// matrix-correct counterpart of `Graphics.translate(int, int)`. Pairs + /// with `#scale(Object, float, float)` and `#rotate(Object, float, int, int)`: + /// the new transform is `currentMatrix * T(x, y)` and any subsequent + /// draw applies that composed matrix as a single step (no separate + /// integer accumulator pre-applied before the matrix). Only invoked + /// when `#isTranslateMatrixSupported()` returns true; ports must keep + /// the two in sync. + /// + /// #### Parameters + /// + /// - `nativeGraphics`: the native graphics object + /// + /// - `x`: x-axis translation + /// + /// - `y`: y-axis translation + public void translateMatrix(Object nativeGraphics, float x, float y) { + // Default no-op: ports advertise translateMatrix support via + // isTranslateMatrixSupported(); Graphics.translateMatrix never + // reaches this body when that's false. + } + /// Scales the coordinate system using the affine transform /// /// #### Parameters diff --git a/CodenameOne/src/com/codename1/ui/Graphics.java b/CodenameOne/src/com/codename1/ui/Graphics.java index 4c5f6c65fe..b131cb8d27 100644 --- a/CodenameOne/src/com/codename1/ui/Graphics.java +++ b/CodenameOne/src/com/codename1/ui/Graphics.java @@ -1637,6 +1637,67 @@ public void scale(float x, float y) { scaleY = y; } + /// Translates the coordinate system using the affine transform matrix + /// (as opposed to `#translate(int, int)` which uses a per-Graphics + /// integer accumulator). On every port today + /// `isTranslationSupported() == false`, which means `g.translate(int, int)` + /// is added to draw coordinates **before** the impl matrix is applied; + /// a subsequent `g.scale()` or `g.rotate()` therefore multiplies the + /// integer translate too. That's surprising when porting code that came + /// from Java2D / AWT where translate composes into the matrix the same + /// way as scale and rotate. + /// + /// `translateMatrix` composes the translation directly onto the impl + /// matrix, exactly like `#scale(float, float)` and `#rotate(float)` do. + /// The result is uniform "post-multiply translate onto the current + /// transform" semantics across iOS / JavaSE / Android / JavaScript -- + /// the same code produces the same on-screen position regardless of + /// which port you target or whether you're drawing into a Form's + /// Graphics or a mutable Image's Graphics. + /// + /// On ports where `#isTranslateMatrixSupported()` returns false (e.g. + /// the legacy JavaScript port) the call falls back to the integer + /// `#translate(int, int)` so apps don't silently render at the wrong + /// position -- the visual result on those ports matches whatever + /// `translate(int, int)` does there. + /// + /// #### Parameters + /// + /// - `x`: x-axis translation + /// + /// - `y`: y-axis translation + /// + /// #### See also + /// + /// - `#isTranslateMatrixSupported()` + /// - `#translate(int, int)` + /// - `#scale(float, float)` + /// - `#rotateRadians(float, int, int)` + public void translateMatrix(float x, float y) { + if (impl.isTranslateMatrixSupported()) { + impl.translateMatrix(nativeGraphics, x, y); + } else { + translate((int) x, (int) y); + } + } + + /// Checks whether `#translateMatrix(float, float)` composes through the + /// impl matrix on this port (the matrix-correct mode) versus falling + /// back to the integer `#translate(int, int)` accumulator. Use this to + /// gate code that needs matrix-correct translation semantics. + /// + /// #### Returns + /// + /// true if `translateMatrix` reaches the impl matrix; false on ports + /// where it falls back to the integer accumulator. + /// + /// #### See also + /// + /// - `#translateMatrix(float, float)` + public boolean isTranslateMatrixSupported() { + return impl.isTranslateMatrixSupported(); + } + /// Rotates the coordinate system around a radian angle using the affine transform /// /// #### Parameters diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidGraphics.java b/Ports/Android/src/com/codename1/impl/android/AndroidGraphics.java index a50fa22e22..4922ac1994 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidGraphics.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidGraphics.java @@ -1483,6 +1483,17 @@ public void scale(float x, float y) { } + public void translateMatrix(float x, float y) { + // Composes T(x, y) onto the impl-side matrix, exactly like scale. + // Lets Graphics.translateMatrix produce matrix-correct translation + // semantics on Android -- see Graphics.translateMatrix javadoc for + // why this is a separate API from translate(int, int). + getTransform().translate(x, y); + transformDirty = true; + inverseTransformDirty = true; + clipFresh = false; + } + public void rotate(float angle) { getTransform().rotate(angle, 0, 0); transformDirty = true; diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 5dfb5ea1b0..96a1e892ca 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -5483,6 +5483,16 @@ public void rotate(Object nativeGraphics, float angle, int x, int y) { ((AndroidGraphics) nativeGraphics).rotate(angle, x, y); } + @Override + public boolean isTranslateMatrixSupported() { + return true; + } + + @Override + public void translateMatrix(Object nativeGraphics, float x, float y) { + ((AndroidGraphics) nativeGraphics).translateMatrix(x, y); + } + public void shear(Object nativeGraphics, float x, float y) { } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index bac4798a75..79d58e6ea9 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -10572,6 +10572,21 @@ public void scale(Object nativeGraphics, float x, float y) { setTransform(nativeGraphics, tf); } + @Override + public boolean isTranslateMatrixSupported() { + // JavaSE composes translateMatrix onto the Graphics2D AffineTransform + // the same way it does for scale/rotate (via getTransform / setTransform). + return true; + } + + @Override + public void translateMatrix(Object nativeGraphics, float x, float y) { + checkEDT(); + com.codename1.ui.Transform tf = getTransform(nativeGraphics); + tf.translate(x, y); + setTransform(nativeGraphics, tf); + } + public void rotate(Object nativeGraphics, float angle) { /* checkEDT(); diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Graphics.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Graphics.java index f8716cee27..fb8188bce2 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Graphics.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Graphics.java @@ -312,6 +312,19 @@ public void scale(double sx, double sy) { setTransform(Transform.makeScale((float)sx, (float)sy)); } } + + public void translateMatrix(double tx, double ty) { + // Compose T(x, y) onto the impl-side matrix, mirroring scale/rotate. + // Lets Graphics.translateMatrix produce matrix-correct semantics on + // HTML5; see Graphics.translateMatrix javadoc. + if (transform != null) { + transform.translate((float)tx, (float)ty); + setTransformChanged(); + applyTransform(); + } else { + setTransform(Transform.makeTranslation((float)tx, (float)ty)); + } + } public void drawImage(Object img, int x, int y, int w, int h) { imageTransformRenderAdapter.drawImage((NativeImage)img, x, y, w, h); diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java index 3a7010d046..f776937442 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java @@ -5991,6 +5991,16 @@ public void scale(Object nativeGraphics, float x, float y) { ((HTML5Graphics)nativeGraphics).scale(x, y); } + @Override + public boolean isTranslateMatrixSupported() { + return true; + } + + @Override + public void translateMatrix(Object nativeGraphics, float x, float y) { + ((HTML5Graphics)nativeGraphics).translateMatrix(x, y); + } + @Override public boolean isAffineSupported() { return true; diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 9ca018a352..f185e2dd3c 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -4230,7 +4230,18 @@ public void rotate(Object nativeGraphics, float angle, int x, int y) { ((NativeGraphics)nativeGraphics).rotate(angle, x, y); } - + @Override + public boolean isTranslateMatrixSupported() { + // iOS dispatches translateMatrix into NativeGraphics.transform the + // same way it dispatches scale/rotate, so the impl matrix sees the + // translate as a real composition step. + return true; + } + + @Override + public void translateMatrix(Object nativeGraphics, float x, float y) { + ((NativeGraphics)nativeGraphics).translateMatrix(x, y); + } @Override public boolean isTranslationSupported() { @@ -4909,14 +4920,23 @@ void setNativeClipping(ClipShape shape){ } void nativeDrawLine(int color, int alpha, int x1, int y1, int x2, int y2) { + // The C-side nativeDrawLineMutableImpl already short-circuits + // through Metal under #ifdef CN1_USE_METAL (queues a DrawLine op + // tagged with currentMutableImage). No Java-side Metal/GL gate + // needed. nativeDrawLineMutable(color, alpha, x1, y1, x2, y2); } void nativeFillRect(int color, int alpha, int x, int y, int width, int height) { + // Same as nativeDrawLine: the C-side nativeFillRectMutableImpl + // routes through the Metal pipeline under #ifdef CN1_USE_METAL. nativeFillRectMutable(color, alpha, x, y, width, height); } void nativeDrawRect(int color, int alpha, int x, int y, int width, int height) { + // Same as nativeDrawLine / nativeFillRect: the C-side + // nativeDrawRectMutableImpl routes through the Metal pipeline + // under #ifdef CN1_USE_METAL. nativeDrawRectMutable(color, alpha, x, y, width, height); } @@ -5184,24 +5204,27 @@ private void renderShapeViaAlphaMask(Shape shape, Stroke stroke) { if (tmpDrawShape == null) tmpDrawShape = new GeneralPath(); if (tmpTransform == null) tmpTransform = Transform.makeIdentity(); if (tmpDrawStroke == null) tmpDrawStroke = new Stroke(); - GeneralPath p = (GeneralPath) shape; if (tmpRect2 == null) tmpRect2 = new Rectangle(); - Rectangle origBounds = reusableRect; - Rectangle transformedBounds = tmpRect2; - p.getBounds(origBounds); - tmpDrawShape.setShape(shape, transform); - tmpDrawShape.getBounds(transformedBounds); - double h1 = Math.sqrt(origBounds.getWidth() * origBounds.getWidth() + origBounds.getHeight() * origBounds.getHeight()); - double h2 = Math.sqrt(transformedBounds.getWidth() * transformedBounds.getWidth() + transformedBounds.getHeight() * transformedBounds.getHeight()); - if (h2 < 1) h2 = 1; - if (h1 < 1) h1 = 1; - float scale = (float) (h2 / h1); - tmpTransform.setScale(scale, scale); + // Metal-only path (entry to this method is already gated on + // metalRendering). Factor the user transform into a non-uniform + // pre-rasterisation scale (sx, sy) plus a residual GPU transform + // -- see the matching block in GlobalGraphics.nativeDrawShape for + // the rationale (GH-3302 inscribed-shape drift). + Matrix nm = (Matrix) transform.getNativeTransform(); + float[] m = nm.getData(); + float c0x = m[0], c0y = m[1]; + float c1x = m[4], c1y = m[5]; + float sx = (float) Math.sqrt((double) c0x * c0x + (double) c0y * c0y); + float sy = (float) Math.sqrt((double) c1x * c1x + (double) c1y * c1y); + if (sx < 1e-6f) sx = 1f; + if (sy < 1e-6f) sy = 1f; + float strokeScale = (sx == sy) ? sx : (float) Math.sqrt((double) sx * (double) sy); + tmpTransform.setScale(sx, sy); tmpDrawShape.setShape(shape, tmpTransform); Stroke scaledStroke = null; if (stroke != null) { tmpDrawStroke.setStroke(stroke); - tmpDrawStroke.setLineWidth(tmpDrawStroke.getLineWidth() * scale); + tmpDrawStroke.setLineWidth(tmpDrawStroke.getLineWidth() * strokeScale); scaledStroke = tmpDrawStroke; } TextureAlphaMask mask = textureCache.get(tmpDrawShape, scaledStroke); @@ -5210,15 +5233,23 @@ private void renderShapeViaAlphaMask(Shape shape, Stroke stroke) { textureCache.add(tmpDrawShape, scaledStroke, mask); } if (mask == null) return; - Transform saved = transform; - Transform inv = Transform.makeIdentity(); - inv.setTransform(transform); - inv.scale(1f / scale, 1f / scale); - setTransform(inv); + // Apply the residual S(1/sx, 1/sy) via the impl-side scale path + // -- the same path g.scale uses. Going through setTransform with + // a separately-built composed Transform has been documented to + // silently fail to update the Metal-side currentTransform in + // some cases (see the Transform.setTransform comment about + // "iOS Metal port has shown that without this flag + // setTransform(composed) silently fails to apply"). The scale + // path queues a SetTransform op that reliably reaches both the + // screen and mutable-image encoders. + scale(1f / sx, 1f / sy); try { nativeDrawAlphaMask(mask); } finally { - setTransform(saved); + // Restore by composing the inverse residual back onto the + // matrix. After this call the impl matrix is back at + // T(...) * S(sx, sy) -- exactly what the caller expects. + scale(sx, sy); } } @@ -5320,9 +5351,25 @@ public void rotate(float angle, int x, int y) { inverseTransformDirty = true; this.applyTransform(); } - + + public void translateMatrix(float x, float y) { + // Composes T(x, y) onto the impl-side matrix, exactly like + // scale/rotate. NOTE: deliberately does NOT touch the + // framework-level xTranslate/yTranslate accumulator that the + // legacy g.translate(int, int) path uses. Mixing them is well- + // defined (xTranslate is added to draw coords first, then this + // matrix applies) but apps that switch to translateMatrix + // should generally avoid g.translate so the two don't fight. + this.transform.translate(x, y, 0); + clipDirty = true; + transformApplied = false; + inverseClipDirty = true; + inverseTransformDirty = true; + this.applyTransform(); + } + public void translate(int x, int y){ - + } public int getTranslateX(){ @@ -5748,7 +5795,6 @@ void nativeDrawShape(Shape shape, Stroke stroke){//float lineWidth, int capStyle } else { - GeneralPath p = (GeneralPath)shape; if (tmpDrawShape == null) { tmpDrawShape = new GeneralPath(); } @@ -5764,63 +5810,98 @@ void nativeDrawShape(Shape shape, Stroke stroke){//float lineWidth, int capStyle if (tmpDrawStroke == null) { tmpDrawStroke = new Stroke(); } - // If the shape is very small and would be scaled dramatically - // by the transform, then we will want to rasterize the shape in a larger - // size to prevent the OGL transform from making the path too blurry. - // But we can't just apply the full transform because the renderer - // won't render the stroke correctly with transform - // So we need to factor the transformation matrix - Rectangle origBounds = reusableRect; - Rectangle transformedBounds = tmpRect2; - p.getBounds(origBounds); - tmpDrawShape.setShape(shape, transform); - tmpDrawShape.getBounds(transformedBounds); - - double h1 = Math.sqrt(origBounds.getWidth() * origBounds.getWidth() + origBounds.getHeight() * origBounds.getHeight()); - double h2 = Math.sqrt(transformedBounds.getWidth() * transformedBounds.getWidth() + transformedBounds.getHeight() * transformedBounds.getHeight()); - if (h2 < 1) h2 = 1; - if (h1 < 1) h1 = 1; - - - float scale = (float)(h2/h1); - tmpTransform.setScale(scale, scale); + // Factor the user transform into a pre-rasterisation scale + // (sx, sy) and a residual GPU transform = transform * + // S(1/sx, 1/sy). Two strategies: + // + // - Metal: take sx, sy from the column norms of the 2x2 + // linear part of the transform. The path is rasterised + // at the actual per-axis scale so the residual GPU + // transform is pure rotation/shear -- no non-uniform + // texture stretch. This fixes GH-3302: under + // g.translate + non-uniform g.scale + fillShape the + // inscribed shape used to drift off the axis-aligned + // drawRect because the uniform-scale rasterise + + // non-uniform GPU stretch round to different pixel + // grids. + // + // - GL ES2: keep the legacy uniform h2/h1 diagonal ratio. + // Existing GL goldens are calibrated against this + // behaviour; only Metal opts in to the per-axis + // decomposition. + float sx, sy; + if (metalRendering) { + Matrix nm = (Matrix) transform.getNativeTransform(); + float[] m = nm.getData(); + // Column-major 4x4: column 0 = [m[0], m[1], ...], + // column 1 = [m[4], m[5], ...]. Length of each column + // is the per-axis scale magnitude (true for pure + // scale, scale-then-rotate, and rotate-then-scale; + // shear contributes to both norms equally). + float c0x = m[0], c0y = m[1]; + float c1x = m[4], c1y = m[5]; + sx = (float) Math.sqrt((double) c0x * c0x + (double) c0y * c0y); + sy = (float) Math.sqrt((double) c1x * c1x + (double) c1y * c1y); + if (sx < 1e-6f) sx = 1f; + if (sy < 1e-6f) sy = 1f; + } else { + GeneralPath p = (GeneralPath) shape; + Rectangle origBounds = reusableRect; + Rectangle transformedBounds = tmpRect2; + p.getBounds(origBounds); + tmpDrawShape.setShape(shape, transform); + tmpDrawShape.getBounds(transformedBounds); + double h1 = Math.sqrt(origBounds.getWidth() * origBounds.getWidth() + origBounds.getHeight() * origBounds.getHeight()); + double h2 = Math.sqrt(transformedBounds.getWidth() * transformedBounds.getWidth() + transformedBounds.getHeight() * transformedBounds.getHeight()); + if (h2 < 1) h2 = 1; + if (h1 < 1) h1 = 1; + float scale = (float) (h2 / h1); + sx = sy = scale; + } + // Stroke widening: in path space the renderer can only + // produce a circular pen, but the residual GPU transform + // does not scale (Metal) or applies a non-uniform stretch + // (GL legacy). Use the geometric mean of the per-axis + // scales so the on-screen stroke matches what the user + // asked for on average; when sx == sy this collapses to + // the uniform legacy behaviour. + float strokeScale = (sx == sy) ? sx : (float) Math.sqrt((double) sx * (double) sy); + tmpTransform.setScale(sx, sy); tmpDrawShape.setShape(shape, tmpTransform); - tmpTransform.setTransform(transform); - tmpTransform.scale(1/scale, 1/scale); - - tmpTransform2.setTransform(transform); + if (stroke != null) { + tmpDrawStroke.setStroke(stroke); + tmpDrawStroke.setLineWidth(tmpDrawStroke.getLineWidth() * strokeScale); + } + TextureAlphaMask mask = textureCache.get(tmpDrawShape, stroke==null?null:tmpDrawStroke); + if ( mask == null ){ + mask = (TextureAlphaMask)createAlphaMask(tmpDrawShape, stroke==null?null:tmpDrawStroke); + textureCache.add(tmpDrawShape, stroke==null?null:tmpDrawStroke, mask); + } + if (mask==null){ + return; + } + if (paint != null && paint instanceof RadialGradient) { + RadialGradient rgp = (RadialGradient)paint; + rgp.x = (int) (rgp.x * sx); + rgp.y = (int) (rgp.y * sy); + rgp.width = (int) (rgp.width * sx); + rgp.height = (int) (rgp.height * sy); + applyPaint(); + } + // Apply the residual S(1/sx, 1/sy) via the impl-side scale + // path (the same path g.scale uses). Going through + // setTransform with a separately-built composed Transform + // has been documented to silently fail to update the + // Metal-side currentTransform (see the comment in + // Transform.setTransform). The scale path reliably queues + // a SetTransform op that reaches both the screen and the + // mutable-image encoders. + scale(1f / sx, 1f / sy); try { - this.setTransform(tmpTransform); - if (stroke != null) { - - tmpDrawStroke.setStroke(stroke); - tmpDrawStroke.setLineWidth(tmpDrawStroke.getLineWidth() * scale); - } - //applyTransform(); - TextureAlphaMask mask = textureCache.get(tmpDrawShape, stroke==null?null:tmpDrawStroke); - if ( mask == null ){ - mask = (TextureAlphaMask)createAlphaMask(tmpDrawShape, stroke==null?null:tmpDrawStroke); - textureCache.add(tmpDrawShape, stroke==null?null:tmpDrawStroke, mask); - - } - if (mask==null){ - // A null mask generally means the shape had zero bounds - return; - } - //mask = (TextureAlphaMask)createAlphaMask(shape, stroke); - if (paint != null && paint instanceof RadialGradient) { - RadialGradient rgp = (RadialGradient)paint; - rgp.x *= scale; - rgp.y *= scale; - rgp.width *= scale; - rgp.height *= scale; - applyPaint(); - } nativeDrawAlphaMask(mask); } finally { - setTransform(tmpTransform2); - //applyTransform(); + scale(sx, sy); } } } else { diff --git a/scripts/android/screenshots/graphics-inscribed-triangle-grid.png b/scripts/android/screenshots/graphics-inscribed-triangle-grid.png new file mode 100644 index 0000000000..3b31d8a0a1 Binary files /dev/null and b/scripts/android/screenshots/graphics-inscribed-triangle-grid.png differ 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 f98d558ff4..78326bff1e 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 @@ -25,6 +25,7 @@ import com.codenameone.examples.hellocodenameone.tests.graphics.FillRoundRect; import com.codenameone.examples.hellocodenameone.tests.graphics.FillShape; import com.codenameone.examples.hellocodenameone.tests.graphics.FillTriangle; +import com.codenameone.examples.hellocodenameone.tests.graphics.InscribedTriangleGrid; import com.codenameone.examples.hellocodenameone.tests.graphics.Rotate; import com.codenameone.examples.hellocodenameone.tests.graphics.Scale; import com.codenameone.examples.hellocodenameone.tests.graphics.StrokeTest; @@ -127,6 +128,7 @@ private static int testTimeoutMs() { new FillTriangle(), new DrawShape(), new FillShape(), + new InscribedTriangleGrid(), new StrokeTest(), new Clip(), new ClipUnderRotation(), @@ -380,6 +382,7 @@ private static boolean isJsSkippedScreenshotTest(String testName) { || "FillRoundRect".equals(testName) || "FillShape".equals(testName) || "FillTriangle".equals(testName) + || "InscribedTriangleGrid".equals(testName) || "Rotate".equals(testName) || "Scale".equals(testName) || "StrokeTest".equals(testName) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/InscribedTriangleGrid.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/InscribedTriangleGrid.java new file mode 100644 index 0000000000..5dc7822639 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/InscribedTriangleGrid.java @@ -0,0 +1,134 @@ +package com.codenameone.examples.hellocodenameone.tests.graphics; + +import com.codename1.ui.Graphics; +import com.codename1.ui.Stroke; +import com.codename1.ui.geom.GeneralPath; +import com.codename1.ui.geom.Rectangle; +import com.codenameone.examples.hellocodenameone.tests.AbstractGraphicsScreenshotTest; + +// Repro for GH-3302: a triangle inscribed in a rectangle should remain +// visually inscribed under non-uniform g.scale. Each cell paints: +// +// - the rectangle (-BASE_HALF_W, -BASE_H, 2*BASE_HALF_W, BASE_H) in BLACK, +// - the triangle ((-BASE_HALF_W, 0), (0, -BASE_H), (BASE_HALF_W, 0)) filled +// in GREEN ('inscribed fill'), +// - the triangle outline in BLUE ('inscribed stroke'). +// +// The cells form a 2x2 grid: +// 1x1 (top-left) 1x2 (top-right) +// 2x1 (bottom-left) 2x2 (bottom-right) +// +// On a correct port the green fill stays exactly within the black rectangle +// in every cell. The legacy iOS alpha-mask path rasterised the path at a +// uniform diagonal-ratio scale and stretched the resulting texture non- +// uniformly, drifting the inscribed shape off the axis-aligned drawRect. +// The Metal path now factors per-axis (sx, sy) out of the transform before +// rasterisation so the GPU only applies the residual rotation/shear. +// +// Cell layout is computed from `bounds` so the grid fits inside small +// simulator panels (Android CI runs at 320x640 -> 160x320 per cell, which +// rules out fixed pixel offsets). Cell anchors come from +// `g.translateMatrix(...)` so the same code produces the same on-screen +// positions whether the underlying Graphics is the form's GlobalGraphics or +// a mutable Image's NativeGraphics (which would otherwise diverge because +// `g.translate(int, int)` is a per-Graphics integer accumulator that gets +// multiplied by subsequent g.scale calls -- see Graphics.translateMatrix +// javadoc). +public class InscribedTriangleGrid extends AbstractGraphicsScreenshotTest { + + @Override + protected void drawContent(Graphics g, Rectangle bounds) { + g.setColor(0xeeeeee); + g.fillRect(bounds.getX(), bounds.getY(), bounds.getWidth(), bounds.getHeight()); + + if (!g.isShapeSupported() || !g.isAffineSupported()) { + g.setColor(0x000000); + g.drawString("Shape or affine unsupported", bounds.getX() + 4, bounds.getY() + 4); + return; + } + + int panelW = bounds.getWidth(); + int panelH = bounds.getHeight(); + + // Fit the 2x2 grid into the panel. The widest cell is 2x scaled so it + // needs 2 * baseHalfWidth on each side; tallest is 2x so it needs + // 2 * baseHeight. Reserve ~10% of the panel for margins between cells. + int headerHeight = Math.max(12, panelH / 20); + int gridW = panelW; + int gridH = panelH - headerHeight; + // Two cell columns; the right column has sx=2 so its width is double + // the base. Total horizontal demand = baseW * (1 + 2) plus three + // gutters. Solve baseW * 3 + gutters * 3 == gridW with gutters == baseW / 2. + int baseW = Math.max(8, gridW * 2 / 9); // == gridW / 4.5 + // Two cell rows; bottom row has sy=2. Same arithmetic on height. + int baseH = Math.max(6, gridH * 2 / 9); + int baseHalfW = baseW / 2; + + int cellGutterX = baseHalfW; + int cellGutterY = baseH / 2; + // Column centres: column 0 sits at baseHalfW + gutter; column 1 sits + // far enough right that the 2x-scaled cell still fits (baseW from + // centre). + int col0 = bounds.getX() + cellGutterX + baseHalfW; + int col1 = col0 + baseHalfW + cellGutterX + baseW; + // Row centres: row 0 baseline at baseH + gutter; row 1 baseline far + // enough down that the 2x-scaled cell still fits. + int row0 = bounds.getY() + headerHeight + cellGutterY + baseH; + int row1 = row0 + cellGutterY + baseH * 2; + + g.setColor(0x000000); + g.drawString("Triangle inscribed in rect (sx,sy)", bounds.getX() + 4, bounds.getY() + 2); + + GeneralPath triangle = new GeneralPath(); + triangle.moveTo(-baseHalfW, 0); + triangle.lineTo(0, -baseH); + triangle.lineTo(baseHalfW, 0); + triangle.closePath(); + + Stroke pen = new Stroke(1f, Stroke.CAP_BUTT, Stroke.JOIN_ROUND, 1f); + + int[][] cells = new int[][]{ + {col0, row0, 1, 1}, + {col1, row0, 2, 1}, + {col0, row1, 1, 2}, + {col1, row1, 2, 2} + }; + for (int[] cell : cells) { + paintCell(g, cell[0], cell[1], cell[2], cell[3], baseHalfW, baseH, triangle, pen); + } + } + + private void paintCell(Graphics g, int cellX, int cellY, int sx, int sy, + int baseHalfW, int baseH, GeneralPath triangle, Stroke pen) { + // translateMatrix composes T(cellX, cellY) onto the impl matrix -- + // it does NOT use the per-Graphics integer translate accumulator, + // so a subsequent g.scale(sx, sy) doesn't multiply the cell anchor. + // This makes the form-direct and mutable-image renderings produce + // identical on-screen pixels (modulo the blit offset that places + // the mutable image's content under the right component bounds). + g.translateMatrix(cellX, cellY); + g.scale(sx, sy); + + // Black rectangle frame -- the "ground truth" axis-aligned reference. + g.setColor(0x000000); + g.drawRect(-baseHalfW, -baseH, baseHalfW * 2, baseH); + + // Green triangle fill -- exits the black frame iff the alpha-mask + // texture stretch has drifted off the pixel grid. + g.setColor(0x00aa00); + g.fillShape(triangle); + + // Blue triangle outline -- same shape, drawShape path, exercises the + // stroke widening code in nativeDrawShape too. + g.setColor(0x0000aa); + g.drawShape(triangle, pen); + + g.scale(1f / sx, 1f / sy); + g.translateMatrix(-cellX, -cellY); + } + + @Override + protected String screenshotName() { + return "graphics-inscribed-triangle-grid"; + } +} diff --git a/scripts/ios/screenshots-metal/graphics-inscribed-triangle-grid.png b/scripts/ios/screenshots-metal/graphics-inscribed-triangle-grid.png new file mode 100644 index 0000000000..e8e4c18f74 Binary files /dev/null and b/scripts/ios/screenshots-metal/graphics-inscribed-triangle-grid.png differ diff --git a/scripts/ios/screenshots/graphics-inscribed-triangle-grid.png b/scripts/ios/screenshots/graphics-inscribed-triangle-grid.png new file mode 100644 index 0000000000..6e1e2b05d6 Binary files /dev/null and b/scripts/ios/screenshots/graphics-inscribed-triangle-grid.png differ diff --git a/scripts/javascript/screenshots/graphics-inscribed-triangle-grid.png b/scripts/javascript/screenshots/graphics-inscribed-triangle-grid.png new file mode 100644 index 0000000000..400ca78943 Binary files /dev/null and b/scripts/javascript/screenshots/graphics-inscribed-triangle-grid.png differ diff --git a/scripts/javase/screenshots/graphics-inscribed-triangle-grid.png b/scripts/javase/screenshots/graphics-inscribed-triangle-grid.png new file mode 100644 index 0000000000..89d421453c Binary files /dev/null and b/scripts/javase/screenshots/graphics-inscribed-triangle-grid.png differ