diff --git a/CodenameOne/src/com/codename1/charts/ChartComponent.java b/CodenameOne/src/com/codename1/charts/ChartComponent.java index d6d90969f5..f5326ed50e 100644 --- a/CodenameOne/src/com/codename1/charts/ChartComponent.java +++ b/CodenameOne/src/com/codename1/charts/ChartComponent.java @@ -288,12 +288,17 @@ public void paint(Graphics g) { g.getTransform(tmpTransform); if (currentTransform == null) { - currentTransform = Transform.makeTranslation(getAbsoluteX(), getAbsoluteY()); + currentTransform = Transform.makeIdentity(); } else { - currentTransform.setTranslation(getAbsoluteX(), getAbsoluteY()); + currentTransform.setIdentity(); } currentTransform.concatenate(transform); - currentTransform.translate(-getAbsoluteX(), -getAbsoluteY()); + // Earlier this conjugated `transform` with T(absX, absY) to + // compensate for the xTranslate/yTranslate the platform was + // adding to vertex coords. Graphics.setTransform() now performs + // that conjugation uniformly across iOS / Android / JavaSE, so + // doing it manually here would shift the chart by 2*absX, + // 2*absY. Pass the user's transform through unchanged. g.setTransform(currentTransform); } else { diff --git a/CodenameOne/src/com/codename1/charts/compat/Canvas.java b/CodenameOne/src/com/codename1/charts/compat/Canvas.java index f8adb01b8d..2411f6ffc0 100644 --- a/CodenameOne/src/com/codename1/charts/compat/Canvas.java +++ b/CodenameOne/src/com/codename1/charts/compat/Canvas.java @@ -175,11 +175,14 @@ public void drawLine(float x1, float y1, float x2, float y2, Paint paint) { } public void rotate(float angle, float x, float y) { - //Log.p("Rotating by angle "+angle); + // (x, y) is in chart-local coords; Graphics.setTransform now + // conjugates with the active xTranslate/yTranslate, so we must NOT + // bake `absoluteX - bounds.getX()` (= xTranslate) into the rotation + // centre here -- that would apply the conjugation twice and rotate + // the chart around a point well off-screen. Transform t = g.getTransform(); - t.rotate((float) (angle * Math.PI / 180.0), x + absoluteX - bounds.getX(), y + absoluteY - bounds.getY()); + t.rotate((float) (angle * Math.PI / 180.0), x, y); g.setTransform(t); - } public void scale(float x, float y) { diff --git a/CodenameOne/src/com/codename1/charts/views/AbstractChart.java b/CodenameOne/src/com/codename1/charts/views/AbstractChart.java index cda12f8e47..e1a1a5f472 100644 --- a/CodenameOne/src/com/codename1/charts/views/AbstractChart.java +++ b/CodenameOne/src/com/codename1/charts/views/AbstractChart.java @@ -372,18 +372,47 @@ protected void drawPath(Canvas canvas, List points, Paint paint, boolean path.moveTo(tempDrawPoints[0], tempDrawPoints[1]); path.lineTo(tempDrawPoints[2], tempDrawPoints[3]); + // Track the running endpoint of the open subpath so we can emit a + // moveTo only when the data actually skips a segment (an off-screen + // point was filtered out via `continue`). The original loop emitted + // `path.moveTo(tempDrawPoints[0..1])` before EVERY lineTo, which + // produces N disjoint single-segment subpaths for an N-point line. + // The iOS GL+Metal form-Graphics drawShape -> TextureAlphaMask path + // crashes the on-screen frame buffer when fed a multi-subpath stroke + // covering most of the form (the chart fills BorderLayout.CENTER) -- + // every other port (Skia / JavaFX / mutable-image NativeGraphics) + // happily collapses the redundant moveTos but iOS form-Graphics + // ends up with the entire frame dropped (form title bar disappears + // along with the chart). Emit moveTo only when the previous segment + // was skipped or the next segment doesn't continue from the running + // endpoint, so an unfiltered line series renders as a single + // continuous polyline -- byte-equivalent to the historical output on + // every port that handled the multi-subpath form, and the form + // actually paints on iOS. + float lastEndX = tempDrawPoints[2]; + float lastEndY = tempDrawPoints[3]; + boolean haveOpenSubpath = true; + int length = points.size(); for (int i = 4; i < length; i += 2) { if ((points.get(i - 1) < 0 && points.get(i + 1) < 0) || (points.get(i - 1) > height && points.get(i + 1) > height)) { + haveOpenSubpath = false; continue; } tempDrawPoints = calculateDrawPoints(points.get(i - 2), points.get(i - 1), points.get(i), points.get(i + 1), height, width); if (!circular) { - path.moveTo(tempDrawPoints[0], tempDrawPoints[1]); + if (!haveOpenSubpath + || tempDrawPoints[0] != lastEndX + || tempDrawPoints[1] != lastEndY) { + path.moveTo(tempDrawPoints[0], tempDrawPoints[1]); + } } path.lineTo(tempDrawPoints[2], tempDrawPoints[3]); + lastEndX = tempDrawPoints[2]; + lastEndY = tempDrawPoints[3]; + haveOpenSubpath = true; } if (circular) { path.lineTo(points.get(0), points.get(1)); @@ -415,18 +444,35 @@ protected void drawPath(Canvas canvas, float[] points, Paint paint, boolean circ path.moveTo(tempDrawPoints[0], tempDrawPoints[1]); path.lineTo(tempDrawPoints[2], tempDrawPoints[3]); + // See the List overload above for the rationale: collapse + // redundant moveTos so an unfiltered line series renders as a + // single continuous polyline rather than N disjoint single-segment + // subpaths. iOS form-Graphics drawShape -> TextureAlphaMask drops + // the entire frame when fed the multi-subpath form. + float lastEndX = tempDrawPoints[2]; + float lastEndY = tempDrawPoints[3]; + boolean haveOpenSubpath = true; + int length = points.length; for (int i = 4; i < length; i += 2) { if ((points[i - 1] < 0 && points[i + 1] < 0) || (points[i - 1] > height && points[i + 1] > height)) { + haveOpenSubpath = false; continue; } tempDrawPoints = calculateDrawPoints(points[i - 2], points[i - 1], points[i], points[i + 1], height, width); if (!circular) { - path.moveTo(tempDrawPoints[0], tempDrawPoints[1]); + if (!haveOpenSubpath + || tempDrawPoints[0] != lastEndX + || tempDrawPoints[1] != lastEndY) { + path.moveTo(tempDrawPoints[0], tempDrawPoints[1]); + } } path.lineTo(tempDrawPoints[2], tempDrawPoints[3]); + lastEndX = tempDrawPoints[2]; + lastEndY = tempDrawPoints[3]; + haveOpenSubpath = true; } if (circular) { path.lineTo(points[0], points[1]); diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 1190843222..2262e17f31 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -1517,6 +1517,32 @@ public boolean isTranslationSupported() { return false; } + /// When `#isTranslationSupported()` returns false, Graphics.java keeps + /// `xTranslate`/`yTranslate` in its own state and bakes them into the + /// vertex coordinates passed to fill primitives. If the impl's render + /// path then applies the user's setTransform matrix on top of those + /// already-translated vertices (e.g. iOS Metal's + /// `projection * modelView * userTransform * pos` shader, or + /// AndroidGraphics's `canvas.concat(t); canvas.drawRect(x+xT, y+yT)`), + /// the translation is double-counted for any non-translation matrix and + /// the rendered output is shifted off-cell -- noticeable on Android + /// (small displacement) and catastrophic on iOS Metal at native pixel + /// resolution (output goes off-screen entirely). Override this and + /// return true so `Graphics.setTransform` conjugates the user's matrix + /// with `T(xTranslate, yTranslate)` before passing it to the impl, + /// restoring "transform applies in local coordinates" semantics across + /// every isTranslationSupported=false port. + /// + /// Internal Graphics.java callers that historically baked + /// xTranslate/yTranslate into their own setTransform argument + /// (com.codename1.ui.scene.Node, com.codename1.charts.ChartComponent, + /// FlipTransition perspective branch, ...) must drop the manual + /// conjugation when this returns true so the platform-side conjugation + /// doesn't double up. + public boolean isSetTransformTranslationConjugationRequired() { + return false; + } + /// Translates the X/Y location for drawing on the underlying surface. Translation /// is incremental so the new value will be added to the current translation and /// in order to reset translation we have to invoke diff --git a/CodenameOne/src/com/codename1/ui/Graphics.java b/CodenameOne/src/com/codename1/ui/Graphics.java index 6f24824d71..ddc93f03a1 100644 --- a/CodenameOne/src/com/codename1/ui/Graphics.java +++ b/CodenameOne/src/com/codename1/ui/Graphics.java @@ -59,6 +59,14 @@ public final class Graphics { private int xTranslate; private int yTranslate; private Transform translation; + /// Last non-identity argument to setTransform(). When the impl returns + /// true from isSetTransformTranslationConjugationRequired(), the matrix + /// actually pushed to impl.setTransform is + /// `T(xTranslate) * userTransform * T(-xTranslate)`, so the user-visible + /// transform applies to local coordinates regardless of any prior + /// g.translate(). getTransform() returns this original (un-conjugated) + /// matrix. + private Transform userTransform; private GeneralPath tmpClipShape; /// A buffer shape to use when we need to transform a shape private int color; @@ -137,6 +145,17 @@ public void translate(int x, int y) { } else { xTranslate += x; yTranslate += y; + // The conjugation in setTransform() depends on the current + // xTranslate/yTranslate. If the user accumulated more + // translation after setting a non-identity transform, + // re-conjugate so the impl-side matrix stays in sync. + if (userTransform != null + && impl.isSetTransformTranslationConjugationRequired()) { + Transform composed = Transform.makeTranslation(xTranslate, yTranslate); + composed.concatenate(userTransform); + composed.translate(-xTranslate, -yTranslate); + impl.setTransform(nativeGraphics, composed); + } } } @@ -1129,6 +1148,9 @@ public void transform(Transform transform) { /// /// - #setTransform public Transform getTransform() { + if (userTransform != null) { + return userTransform.copy(); + } return impl.getTransform(nativeGraphics); } @@ -1160,7 +1182,31 @@ public Transform getTransform() { /// /// - #setTransform(com.codename1.ui.geom.Matrix, int, int) public void setTransform(Transform transform) { - impl.setTransform(nativeGraphics, transform); + // On platforms where impl.isTranslationSupported() is false, this + // Graphics object accumulates xTranslate/yTranslate locally and bakes + // them into vertex coordinates passed to impl fill primitives. The + // user's setTransform matrix is then applied by the underlying + // platform on top of those already-translated vertices, which + // double-counts the cell origin for any non-translation matrix + // (rotate, scale, shear) -- the gradient ends up off-cell or + // off-screen. Conjugate the user's matrix with T(xTranslate, + // yTranslate) so its effect is independent of any prior g.translate + // call, matching the Android Skia / JavaSE Graphics2D semantics that + // the framework's own callers (LinearGradientPaint, etc.) work + // around manually today. Impls that don't need this opt out via + // isSetTransformTranslationConjugationRequired() returning false. + if (transform != null && !transform.isIdentity() + && (xTranslate != 0 || yTranslate != 0) + && impl.isSetTransformTranslationConjugationRequired()) { + userTransform = transform.copy(); + Transform composed = Transform.makeTranslation(xTranslate, yTranslate); + composed.concatenate(transform); + composed.translate(-xTranslate, -yTranslate); + impl.setTransform(nativeGraphics, composed); + } else { + userTransform = null; + impl.setTransform(nativeGraphics, transform); + } } /// Loads the provided transform with the current transform applied to this graphics context. @@ -1169,6 +1215,10 @@ public void setTransform(Transform transform) { /// /// - `t`: An "out" parameter to be filled with the current transform. public void getTransform(Transform t) { + if (userTransform != null) { + t.setTransform(userTransform); + return; + } impl.getTransform(nativeGraphics, t); } @@ -1576,6 +1626,7 @@ public void resetAffine() { impl.resetAffine(nativeGraphics); scaleX = 1; scaleY = 1; + userTransform = null; } /// Scales the coordinate system using the affine transform diff --git a/CodenameOne/src/com/codename1/ui/Transform.java b/CodenameOne/src/com/codename1/ui/Transform.java index 76b9399715..87fdcadb53 100644 --- a/CodenameOne/src/com/codename1/ui/Transform.java +++ b/CodenameOne/src/com/codename1/ui/Transform.java @@ -792,6 +792,16 @@ public void setTransform(Transform t) { initNativeTransform(); t.initNativeTransform(); impl.copyTransform(t.nativeTransform, nativeTransform); + // Mark the cached native matrix as dirty so subsequent + // getNativeTransform() calls re-run initNativeTransform. + // For TYPE_UNKNOWN this is a no-op for the matrix data + // itself, but it triggers any platform-side code that + // listens on initNativeTransform to refresh its cache -- + // the iOS Metal port has shown that without this flag + // setTransform(composed) silently fails to apply on the + // form-Graphics screen encoder while the equivalent + // g.rotate / g.scale / g.translate path renders correctly. + dirty = true; break; } diff --git a/CodenameOne/src/com/codename1/ui/scene/Node.java b/CodenameOne/src/com/codename1/ui/scene/Node.java index 785e18afea..717f9f558f 100644 --- a/CodenameOne/src/com/codename1/ui/scene/Node.java +++ b/CodenameOne/src/com/codename1/ui/scene/Node.java @@ -359,7 +359,14 @@ public Transform getLocalToScreenTransform() { Transform newT = Transform.isPerspectiveSupported() && scene != null && scene.camera.get() != null ? scene.camera.get().getTransform() : Transform.makeIdentity(); if (getScene() != null) { - newT.translate(getScene().getAbsoluteX(), getScene().getAbsoluteY()); + // The screen-translate component is contributed by the Graphics + // object's xTranslate/yTranslate (the cumulative parent + // translates applied during paint) -- on platforms where + // Graphics.setTransform() conjugates the user matrix with that + // translation, it would be double-counted if we baked + // scene.absX/absY in here too. Stop at the local-to-scene + // transform; the platform places it at the scene's screen + // origin. newT.concatenate(getLocalToSceneTransform()); } return newT; @@ -381,9 +388,16 @@ public void render(Graphics g) { scene.camera.get().getTransform() : Transform.makeIdentity(); if (getScene() != null) { - newT.translate(getScene().getAbsoluteX(), getScene().getAbsoluteY()); + // Earlier this conjugated localToScene with T(scene.absX, + // absY) so that, when applied to the xTranslate-shifted vertex + // coords the platform passed to the GPU, the rendering landed + // back at the scene's screen origin. Graphics.setTransform() + // now performs that conjugation uniformly across iOS / Android + // / JavaSE, so applying it manually here would double the + // translation and push the spinner rows off-cell. Hand the + // platform the local transform; it places it at xTranslate/ + // yTranslate, which is the scene's screen origin during paint. newT.concatenate(getLocalToSceneTransform()); - newT.translate(-scene.getAbsoluteX(), -scene.getAbsoluteY()); } g.setTransform(newT); int alpha = g.getAlpha(); diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 5dfb5ea1b0..a8f9d05a66 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -10583,6 +10583,23 @@ public boolean isTransformSupported() { return true; } + @Override + public boolean isSetTransformTranslationConjugationRequired() { + // Android's render path mirrors iOS: xTranslate/yTranslate accumulate + // in Graphics.java (since isTranslationSupported() is false) and end + // up baked into vertex coordinates. AndroidGraphics applies the + // user's setTransform matrix on top via canvas.concat at draw time, + // which double-counts the cell origin for any non-translation + // matrix. The displacement is small at typical phone resolutions + // (which is why the visual effect on Android is "shifted a bit" + // rather than the off-screen rendering iOS Metal exhibits) but it + // still differs from the user's intent. Conjugating in + // Graphics.setTransform yields the same "transform applies in local + // coordinates" contract as iOS, so identical CN1 code produces + // identical output across both ports. + return true; + } + @Override public boolean isPerspectiveTransformSupported() { diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index b274648770..6fb2bc8796 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -9257,7 +9257,22 @@ public void drawShape(Object graphics, com.codename1.ui.geom.Shape shape, com.co public boolean isTransformSupported(){ return true; } - + + @Override + public boolean isSetTransformTranslationConjugationRequired() { + // JavaSE's render path is identical in shape to Android's: + // xTranslate/yTranslate accumulate in Graphics.java (since + // isTranslationSupported() is false) and end up baked into the + // coordinates passed to fill primitives, while setTransform() + // replaces the AWT Graphics2D matrix outright. Applying the user + // matrix to xTranslate-shifted coordinates double-counts the cell + // origin -- visible as a slight shift at simulator resolution. + // Conjugating in Graphics.setTransform yields the same "transform + // applies in local coordinates" contract as iOS / Android. + return true; + } + + /** * Checks of the Transform class can be used on this platform to perform perspective transforms. * This is similar to 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..f018c95e83 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 @@ -5788,6 +5788,19 @@ public boolean isTransformSupported() { return true; } + @Override + public boolean isSetTransformTranslationConjugationRequired() { + // The HTML5 render path mirrors iOS / Android: xTranslate / + // yTranslate accumulate in Graphics.java (since + // isTranslationSupported() is false) and end up baked into the + // coordinates passed to fill primitives, while setTransform + // replaces the canvas matrix outright. Conjugating the user matrix + // with T(xTranslate, yTranslate) in Graphics.setTransform yields + // the same "transform applies in local coordinates" contract as + // every other CN1 port. + return true; + } + @Override public void concatenateTransform(Object t1, Object t2) { ((JSAffineTransform)t1).concatenate((JSAffineTransform)t2); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 5d46605911..7a5e183cfb 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -2402,9 +2402,22 @@ public void setTransform(Object graphics, Transform transform) { ng.transform = transform == null ? null : transform.copy(); } ng.transformApplied = false; + // The cached clip / inverseClip / inverseTransform are derived from + // the current transform; replacing the transform leaves them + // pointing at the previous transform's space. Subsequent draw ops + // (e.g. fillRect or fillLinearGradient on the form Graphics) read + // those caches via loadClipBounds / inverseClip and end up clipped + // to the wrong region, which is why TransformRotation and + // Scale/AffineScale produced empty top cells on iOS Metal while + // the equivalent rotation via g.rotate (which DOES invalidate + // these flags, line 5513) rendered correctly. Match the + // rotate/scale/translate/resetAffine paths so the cache is rebuilt + // before the next draw. + ng.clipDirty = true; + ng.inverseClipDirty = true; + ng.inverseTransformDirty = true; ng.checkControl(); ng.applyTransform(); - } public void setNativeTransformGlobal(Transform transform){ @@ -4213,15 +4226,28 @@ public void rotate(Object nativeGraphics, float angle, int x, int y) { @Override public boolean isTranslationSupported() { //return true; - // We'll leave this as false until the next iteration... - // ES2 should allow us to do all of this using transforms but + // We'll leave this as false until the next iteration... + // ES2 should allow us to do all of this using transforms but // let's take small steps first return false; } - - - + @Override + public boolean isSetTransformTranslationConjugationRequired() { + // The iOS render path bakes xTranslate/yTranslate into vertex coords + // (since isTranslationSupported() is false) and the GPU vertex + // shader then applies the user's setTransform matrix on top + // (`projection * modelView * userTransform * pos`). For any + // non-translation matrix this double-counts the cell origin and + // throws output off-screen at native pixel resolution. Conjugation + // in Graphics.setTransform restores "transform applies in local + // coordinates" semantics on this port. + return true; + } + + + + public void shear(Object nativeGraphics, float x, float y) { ((NativeGraphics)nativeGraphics).shear(x, y); } diff --git a/scripts/android/screenshots/chart-bar-stacked.png b/scripts/android/screenshots/chart-bar-stacked.png new file mode 100644 index 0000000000..663a7f2c52 Binary files /dev/null and b/scripts/android/screenshots/chart-bar-stacked.png differ diff --git a/scripts/android/screenshots/chart-bar.png b/scripts/android/screenshots/chart-bar.png new file mode 100644 index 0000000000..39d93c2bc5 Binary files /dev/null and b/scripts/android/screenshots/chart-bar.png differ diff --git a/scripts/android/screenshots/chart-bubble.png b/scripts/android/screenshots/chart-bubble.png new file mode 100644 index 0000000000..5cdcf23d36 Binary files /dev/null and b/scripts/android/screenshots/chart-bubble.png differ diff --git a/scripts/android/screenshots/chart-cubic-line.png b/scripts/android/screenshots/chart-cubic-line.png new file mode 100644 index 0000000000..fd6a5b75c6 Binary files /dev/null and b/scripts/android/screenshots/chart-cubic-line.png differ diff --git a/scripts/android/screenshots/chart-doughnut.png b/scripts/android/screenshots/chart-doughnut.png new file mode 100644 index 0000000000..5871643064 Binary files /dev/null and b/scripts/android/screenshots/chart-doughnut.png differ diff --git a/scripts/android/screenshots/chart-line.png b/scripts/android/screenshots/chart-line.png new file mode 100644 index 0000000000..a458aadc15 Binary files /dev/null and b/scripts/android/screenshots/chart-line.png differ diff --git a/scripts/android/screenshots/chart-pie.png b/scripts/android/screenshots/chart-pie.png new file mode 100644 index 0000000000..38059b1b38 Binary files /dev/null and b/scripts/android/screenshots/chart-pie.png differ diff --git a/scripts/android/screenshots/chart-radar.png b/scripts/android/screenshots/chart-radar.png new file mode 100644 index 0000000000..9e27d38dbe Binary files /dev/null and b/scripts/android/screenshots/chart-radar.png differ diff --git a/scripts/android/screenshots/chart-range-bar.png b/scripts/android/screenshots/chart-range-bar.png new file mode 100644 index 0000000000..bd783ccf7d Binary files /dev/null and b/scripts/android/screenshots/chart-range-bar.png differ diff --git a/scripts/android/screenshots/chart-scatter.png b/scripts/android/screenshots/chart-scatter.png new file mode 100644 index 0000000000..5ccd5dd3b2 Binary files /dev/null and b/scripts/android/screenshots/chart-scatter.png differ diff --git a/scripts/android/screenshots/chart-time.png b/scripts/android/screenshots/chart-time.png new file mode 100644 index 0000000000..80d6072156 Binary files /dev/null and b/scripts/android/screenshots/chart-time.png differ diff --git a/scripts/android/screenshots/graphics-affine-scale.png b/scripts/android/screenshots/graphics-affine-scale.png index fd989cc55c..82bd324c27 100644 Binary files a/scripts/android/screenshots/graphics-affine-scale.png and b/scripts/android/screenshots/graphics-affine-scale.png differ diff --git a/scripts/android/screenshots/graphics-scale.png b/scripts/android/screenshots/graphics-scale.png index e9fde63228..889ff94fd7 100644 Binary files a/scripts/android/screenshots/graphics-scale.png and b/scripts/android/screenshots/graphics-scale.png differ diff --git a/scripts/android/screenshots/graphics-transform-camera.png b/scripts/android/screenshots/graphics-transform-camera.png index 1a16aa0747..30a1fcb016 100644 Binary files a/scripts/android/screenshots/graphics-transform-camera.png and b/scripts/android/screenshots/graphics-transform-camera.png differ diff --git a/scripts/android/screenshots/graphics-transform-perspective.png b/scripts/android/screenshots/graphics-transform-perspective.png index 1a16aa0747..73c31551c2 100644 Binary files a/scripts/android/screenshots/graphics-transform-perspective.png and b/scripts/android/screenshots/graphics-transform-perspective.png differ diff --git a/scripts/android/screenshots/graphics-transform-rotation.png b/scripts/android/screenshots/graphics-transform-rotation.png index ac697a4a0e..188566ac90 100644 Binary files a/scripts/android/screenshots/graphics-transform-rotation.png and b/scripts/android/screenshots/graphics-transform-rotation.png differ diff --git a/scripts/common/java/Cn1ssChunkTools.java b/scripts/common/java/Cn1ssChunkTools.java index 5ef21983af..5cf4d83943 100644 --- a/scripts/common/java/Cn1ssChunkTools.java +++ b/scripts/common/java/Cn1ssChunkTools.java @@ -169,19 +169,87 @@ private static void runExtract(String[] args) throws IOException { for (Chunk chunk : chunks) { payload.append(chunk.payload); } + byte[] data = null; if (decode) { - byte[] data; try { data = Base64.getDecoder().decode(payload.toString()); } catch (IllegalArgumentException ex) { data = new byte[0]; } + } + // Verify the reassembled binary matches the advertised FNV-1a 64 + // hash from the emitter (only on the default PNG channel; the + // PREVIEW channel has its own JPEG bytes that don't match this + // hash). Hash mismatch means the chunk stream got corrupted in a + // way the gap detection above didn't catch -- e.g. a chunk's + // payload was rewritten in transit. Refuse to emit a stream that + // disagrees with its own integrity marker. + if (decode && (channel == null || channel.isEmpty())) { + String advertisedHash = readAdvertisedHash(path, targetTest); + if (advertisedHash != null) { + String actual = fnv1a64Hex(data); + if (!advertisedHash.equalsIgnoreCase(actual)) { + System.err.println("ERROR: reassembled bytes for test '" + targetTest + + "' in " + path + " hash mismatch:"); + System.err.println(" - advertised png_fnv1a64=" + advertisedHash); + System.err.println(" - reassembled png_fnv1a64=" + actual); + System.err.println(" - reassembled length=" + data.length); + System.err.println(" Refusing to emit a corrupted stream."); + System.exit(1); + } + } + } + if (decode) { System.out.write(data); } else { System.out.print(payload.toString()); } } + /// Returns the advertised FNV-1a 64-bit hash for the given test's PNG + /// payload, or null if no INFO line includes one. The emitter logs + /// `CN1SS:INFO:test= png_bytes= png_fnv1a64=` once the + /// image bytes are encoded; matching against the assembled stream's + /// hash gives an integrity check against silent chunk corruption. + /// + /// The negative lookahead `(?![A-Za-z0-9_.\-])` after the test name is + /// load-bearing -- a plain `\b` word boundary lets the regex match + /// `graphics-draw-string-decorated` when the caller asked for + /// `graphics-draw-string`, because `\b` is satisfied by the boundary + /// between `g` (word char) and `-` (non-word char). The lookahead + /// rejects the suffix continuation by checking the next char is not in + /// the test-name character class used by CHUNK_PATTERN. + private static String readAdvertisedHash(Path path, String testName) throws IOException { + String text = Files.readString(path, StandardCharsets.UTF_8); + Pattern info = Pattern.compile( + "CN1SS:INFO:test=" + Pattern.quote(testName) + + "(?![A-Za-z0-9_.\\-])[^\\n]*?\\bpng_fnv1a64=([0-9a-fA-F]{16})"); + Matcher m = info.matcher(text); + String latest = null; + while (m.find()) { + latest = m.group(1); + } + return latest; + } + + /// Mirror of Cn1ssDeviceRunnerHelper.fnv1a64Hex on the consumer side -- + /// keep the algorithm identical (FNV-1a 64-bit, lowercase hex, leading + /// zeros) so the integrity check holds. + private static String fnv1a64Hex(byte[] bytes) { + long h = 0xcbf29ce484222325L; + long prime = 0x100000001b3L; + for (int i = 0; i < bytes.length; i++) { + h ^= bytes[i] & 0xff; + h *= prime; + } + StringBuilder sb = new StringBuilder(16); + for (int i = 60; i >= 0; i -= 4) { + int nib = (int) ((h >>> i) & 0xf); + sb.append((char) (nib < 10 ? '0' + nib : 'a' + (nib - 10))); + } + return sb.toString(); + } + /** * Returns the total base64 length advertised by the emitter for the given * test/channel, or -1 if no matching INFO line was found. The emitter logs @@ -192,11 +260,14 @@ private static void runExtract(String[] args) throws IOException { private static long readTotalBase64Length(Path path, String testName, String channel) throws IOException { // The INFO line is always emitted on the default channel regardless of // whether the chunks themselves go to a side channel like PREVIEW, so - // we only filter by test name here. + // we only filter by test name here. See readAdvertisedHash for why + // the lookahead is required instead of `\b` -- prefixes like + // `graphics-draw-string` would otherwise match `graphics-draw- + // string-decorated`. String text = Files.readString(path, StandardCharsets.UTF_8); Pattern info = Pattern.compile( "CN1SS:INFO:test=" + Pattern.quote(testName) - + "\\b[^\\n]*?\\btotal_b64_len=(\\d+)"); + + "(?![A-Za-z0-9_.\\-])[^\\n]*?\\btotal_b64_len=(\\d+)"); Matcher m = info.matcher(text); long latest = -1; // The same test may emit multiple channels (PNG + PREVIEW). Without a diff --git a/scripts/common/java/PostPrComment.java b/scripts/common/java/PostPrComment.java index 46132ef23f..0729ecdf37 100644 --- a/scripts/common/java/PostPrComment.java +++ b/scripts/common/java/PostPrComment.java @@ -314,9 +314,45 @@ private static Map publishPreviewsToBranch(Path previewDir, Stri ProcessResult status = runGit(worktree, env, true, "status", "--porcelain"); if (!status.stdout.trim().isEmpty()) { runGit(worktree, env, "commit", "-m", "Add previews for PR #" + prNumber); - ProcessResult push = runGit(worktree, env, false, "push", "origin", "HEAD:cn1ss-previews"); - if (push.exitCode != 0) { - throw new IOException(push.stderr.isEmpty() ? push.stdout : push.stderr); + // Concurrent jobs (build-ios + build-ios-metal) can both try to + // push to cn1ss-previews; the loser gets "rejected (fetch first)" + // which previously aborted the comment-post step and left the PR + // showing stale screenshots. Retry with a fetch + rebase so each + // CI job's preview commit is appended onto the latest tip. + int maxAttempts = 5; + ProcessResult push = null; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + push = runGit(worktree, env, false, "push", "origin", "HEAD:cn1ss-previews"); + if (push.exitCode == 0) { + break; + } + if (attempt == maxAttempts) { + throw new IOException(push.stderr.isEmpty() ? push.stdout : push.stderr); + } + log("Preview push attempt " + attempt + " rejected; fetching + rebasing and retrying"); + runGit(worktree, env, false, "fetch", "origin", "cn1ss-previews"); + ProcessResult rebase = runGit(worktree, env, false, "rebase", "FETCH_HEAD"); + if (rebase.exitCode != 0) { + runGit(worktree, env, false, "rebase", "--abort"); + // The same prNumber/subdir directory was overwritten by + // the other job. Reset our index to FETCH_HEAD's tree and + // re-apply our preview files on top so we get a clean + // single commit. + runGit(worktree, env, false, "reset", "--hard", "FETCH_HEAD"); + Files.createDirectories(dest); + for (Path source : imageFiles) { + Files.copy(source, dest.resolve(source.getFileName()), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + runGit(worktree, env, "add", "-A", "."); + ProcessResult status2 = runGit(worktree, env, true, "status", "--porcelain"); + if (status2.stdout.trim().isEmpty()) { + log("Preview branch already up-to-date after rebase for PR #" + prNumber); + push = new ProcessResult(0, "", ""); + break; + } + runGit(worktree, env, "commit", "-m", "Add previews for PR #" + prNumber); + } } log("Published " + imageFiles.size() + " preview(s) to cn1ss-previews/pr-" + prNumber); } else { 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 e79afa5e28..3b1989de10 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 @@ -32,6 +32,20 @@ import com.codenameone.examples.hellocodenameone.tests.graphics.TransformPerspective; import com.codenameone.examples.hellocodenameone.tests.graphics.TransformRotation; import com.codenameone.examples.hellocodenameone.tests.graphics.TransformTranslation; +import com.codenameone.examples.hellocodenameone.tests.charts.ChartBarScreenshotTest; +import com.codenameone.examples.hellocodenameone.tests.charts.ChartBubbleScreenshotTest; +import com.codenameone.examples.hellocodenameone.tests.charts.ChartCombinedXYScreenshotTest; +import com.codenameone.examples.hellocodenameone.tests.charts.ChartCubicLineScreenshotTest; +import com.codenameone.examples.hellocodenameone.tests.charts.ChartDoughnutScreenshotTest; +import com.codenameone.examples.hellocodenameone.tests.charts.ChartLineScreenshotTest; +import com.codenameone.examples.hellocodenameone.tests.charts.ChartPieScreenshotTest; +import com.codenameone.examples.hellocodenameone.tests.charts.ChartRadarScreenshotTest; +import com.codenameone.examples.hellocodenameone.tests.charts.ChartRangeBarScreenshotTest; +import com.codenameone.examples.hellocodenameone.tests.charts.ChartRotatedScreenshotTest; +import com.codenameone.examples.hellocodenameone.tests.charts.ChartScatterScreenshotTest; +import com.codenameone.examples.hellocodenameone.tests.charts.ChartStackedBarScreenshotTest; +import com.codenameone.examples.hellocodenameone.tests.charts.ChartTimeChartScreenshotTest; +import com.codenameone.examples.hellocodenameone.tests.charts.ChartTransformScreenshotTest; import com.codenameone.examples.hellocodenameone.tests.accessibility.AccessibilityTest; @@ -119,6 +133,29 @@ private static int testTimeoutMs() { new TransformRotation(), new TransformPerspective(), new TransformCamera(), + // ChartComponent coverage. The 2026-05-09 conjugation refactor in + // Graphics.setTransform / iOS / Android / JavaSE / JS dropped + // ChartComponent.paint's manual T(absX) * X * T(-absX) + // compensation; without screenshot baselines for the major chart + // types a regression in the chart render path goes silent until + // a user reports it. Cover one test per chart family + two + // dedicated transform paths (scale + rotate) so the + // ChartComponent.setTransform branch (the one the refactor + // directly touched) has explicit visual coverage. + new ChartLineScreenshotTest(), + new ChartCubicLineScreenshotTest(), + new ChartBarScreenshotTest(), + new ChartStackedBarScreenshotTest(), + new ChartRangeBarScreenshotTest(), + new ChartScatterScreenshotTest(), + new ChartBubbleScreenshotTest(), + new ChartPieScreenshotTest(), + new ChartDoughnutScreenshotTest(), + new ChartRadarScreenshotTest(), + new ChartTimeChartScreenshotTest(), + new ChartCombinedXYScreenshotTest(), + new ChartTransformScreenshotTest(), + new ChartRotatedScreenshotTest(), new BrowserComponentScreenshotTest(), new MediaPlaybackScreenshotTest(), new SheetScreenshotTest(), @@ -335,7 +372,32 @@ private static boolean isJsSkippedScreenshotTest(String testName) { || "TransformCamera".equals(testName) || "TransformPerspective".equals(testName) || "TransformRotation".equals(testName) - || "TransformTranslation".equals(testName); + || "TransformTranslation".equals(testName) + // Chart screenshot tests: each ChartComponent renders ~12-30 + // styled primitives plus axis labels and a legend, so the + // chunked PNG/JPEG output ends up in the 30-60KB range per + // test. The JS port's 150s browser-lifetime budget can't + // afford 14 of those on top of the existing screenshot suite + // -- on the previous run every chart test came back empty + // because the EDT had already started the suite-shutdown + // fast-forward by the time they were invoked. Re-enable + // selectively when the JS port moves to a longer-lived + // harness; chart-package coverage stays on iOS / Android / + // JavaSE in the meantime. + || "ChartLineScreenshotTest".equals(testName) + || "ChartCubicLineScreenshotTest".equals(testName) + || "ChartBarScreenshotTest".equals(testName) + || "ChartStackedBarScreenshotTest".equals(testName) + || "ChartRangeBarScreenshotTest".equals(testName) + || "ChartScatterScreenshotTest".equals(testName) + || "ChartBubbleScreenshotTest".equals(testName) + || "ChartPieScreenshotTest".equals(testName) + || "ChartDoughnutScreenshotTest".equals(testName) + || "ChartRadarScreenshotTest".equals(testName) + || "ChartTimeChartScreenshotTest".equals(testName) + || "ChartCombinedXYScreenshotTest".equals(testName) + || "ChartTransformScreenshotTest".equals(testName) + || "ChartRotatedScreenshotTest".equals(testName); } private void awaitTestCompletion(int index, BaseTest testClass, String testName, long deadline) { diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunnerHelper.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunnerHelper.java index 4f5c94db43..1f666706bd 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunnerHelper.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunnerHelper.java @@ -70,7 +70,14 @@ static void emitImage(Image image, String testName, Runnable onComplete) { ByteArrayOutputStream pngOut = new ByteArrayOutputStream(Math.max(1024, width * height / 2)); io.save(image, pngOut, ImageIO.FORMAT_PNG, 1f); byte[] pngBytes = pngOut.toByteArray(); - println("CN1SS:INFO:test=" + safeName + " png_bytes=" + pngBytes.length); + String hash = fnv1a64Hex(pngBytes); + println("CN1SS:INFO:test=" + safeName + " png_bytes=" + pngBytes.length + + " png_fnv1a64=" + hash); + String previous = Cn1ssHashTracker.recordAndCheck(hash, safeName); + if (previous != null) { + println("CN1SS:WARN:test=" + safeName + + " duplicate_image_with=" + previous + " png_fnv1a64=" + hash); + } emitChannel(pngBytes, safeName, ""); byte[] preview = encodePreview(io, image, safeName); @@ -121,7 +128,14 @@ static void emitCurrentFormScreenshot(String testName, Runnable onComplete) { ByteArrayOutputStream pngOut = new ByteArrayOutputStream(Math.max(1024, width * height / 2)); io.save(screenshot, pngOut, ImageIO.FORMAT_PNG, 1f); byte[] pngBytes = pngOut.toByteArray(); - println("CN1SS:INFO:test=" + safeName + " png_bytes=" + pngBytes.length); + String hash = fnv1a64Hex(pngBytes); + println("CN1SS:INFO:test=" + safeName + " png_bytes=" + pngBytes.length + + " png_fnv1a64=" + hash); + String previous = Cn1ssHashTracker.recordAndCheck(hash, safeName); + if (previous != null) { + println("CN1SS:WARN:test=" + safeName + + " duplicate_image_with=" + previous + " png_fnv1a64=" + hash); + } emitChannel(pngBytes, safeName, ""); byte[] preview = encodePreview(io, screenshot, safeName); @@ -277,4 +291,98 @@ static void complete(Runnable runnable) { static boolean isHtml5() { return "HTML5".equals(Display.getInstance().getPlatformName()); } + + /// Computes a 64-bit FNV-1a hash of the given bytes. FNV-1a is fast and + /// has no platform dependencies (no java.security, no java.util.zip + /// CRC32 wrapping subtleties). 64 bits is enough to make accidental + /// collisions on real-world PNG payloads vanishingly unlikely while + /// keeping the hash short enough to log on a single line. The mixup + /// detector in `Cn1ssHashTracker` calls this on every emitted image so + /// that two tests producing bit-identical bytes (the symptom of an iOS + /// Metal stale-frame capture: MultiButtonTheme_light returning Tabs + /// Theme_light's pixels because the CAMetalLayer hadn't been re- + /// presented in time) get flagged with a CN1SS:WARN line. + static String fnv1a64Hex(byte[] bytes) { + long h = 0xcbf29ce484222325L; + long prime = 0x100000001b3L; + for (int i = 0; i < bytes.length; i++) { + h ^= bytes[i] & 0xff; + h *= prime; + } + StringBuilder sb = new StringBuilder(16); + for (int i = 60; i >= 0; i -= 4) { + int nib = (int) ((h >>> i) & 0xf); + sb.append((char) (nib < 10 ? '0' + nib : 'a' + (nib - 10))); + } + return sb.toString(); + } +} + +/// Tracks recently-emitted screenshot hashes per test name so a stale-frame +/// capture (the same PNG bytes attributed to two different tests in a row) +/// gets surfaced via CN1SS:WARN markers instead of silently shipping the +/// wrong image to the comparator. Keeps the most recent 64 entries. +/// +/// Lives in a separate package-private class because Cn1ssDeviceRunnerHelper +/// is an interface and can't hold mutable static state. +/// +/// Storage uses two parallel arrays (hash[i] paired with testName[i]) rather +/// than a HashMap-typed static field. The Cn1ssDeviceRunner header-comment +/// at lines 215-222 documents that "static collections initialised via a +/// static method call ... broke iOS class loading -- Cn1ssDeviceRunner +/// failed to load before runSuite() could even log a single starting +/// test=... entry, leaving the suite to time out at the 300s end-marker +/// deadline." The first attempt at this tracker used `private static final +/// Map hashToTest = new LinkedHashMap<>()` and reproduced +/// exactly that symptom on the iOS Metal CI run -- the simulator booted, +/// installed the app, then never emitted a single CN1SS line and timed +/// out at 30 minutes. Plain primitive arrays of String avoid touching the +/// HashMap class init path during the host class's ``. +final class Cn1ssHashTracker { + private static final int MAX_TRACKED = 64; + private static final String[] hashes = new String[MAX_TRACKED]; + private static final String[] tests = new String[MAX_TRACKED]; + private static int count; + + private Cn1ssHashTracker() { + } + + /// Records the hash for `safeName` and returns the test name that + /// previously emitted the same hash, or null if this is the first time. + /// Caller logs a CN1SS:WARN line when a duplicate is found so the + /// downstream comparator can flag the affected test as a likely + /// stale-frame capture. + /// + /// O(MAX_TRACKED) per call -- 64-entry linear scan is trivial vs the + /// PNG hash itself (which scans every byte of the image). + static synchronized String recordAndCheck(String hashHex, String safeName) { + String previous = null; + for (int i = 0; i < count; i++) { + if (hashHex.equals(hashes[i])) { + previous = tests[i]; + if (safeName.equals(previous)) { + // Same test re-captured (e.g. light->dark sequencing + // chains through the same emitter); not a mixup. + return null; + } + break; + } + } + if (count < MAX_TRACKED) { + hashes[count] = hashHex; + tests[count] = safeName; + count++; + } else { + // Ring-buffer-style: overwrite the oldest entry. We keep + // insertion order roughly via an arraycopy shift; dropping + // exactly MAX_TRACKED entries means each call to this branch + // moves up to 64 references, which is still well below the + // cost of the FNV-1a scan over a 70KB PNG. + System.arraycopy(hashes, 1, hashes, 0, MAX_TRACKED - 1); + System.arraycopy(tests, 1, tests, 0, MAX_TRACKED - 1); + hashes[MAX_TRACKED - 1] = hashHex; + tests[MAX_TRACKED - 1] = safeName; + } + return previous; + } } diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/AbstractChartScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/AbstractChartScreenshotTest.java new file mode 100644 index 0000000000..5d532c821a --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/AbstractChartScreenshotTest.java @@ -0,0 +1,36 @@ +package com.codenameone.examples.hellocodenameone.tests.charts; + +import com.codename1.charts.ChartComponent; +import com.codename1.charts.views.AbstractChart; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.hellocodenameone.tests.BaseTest; + +/// Shared scaffolding for chart screenshot tests. Each subclass returns the +/// `ChartComponent` it wants captured; this class wraps it in a deterministic +/// form so the rendered pixels are reproducible across iOS / Android / JavaSE +/// / JS pipelines and the chart-package render path -- which leans heavily +/// on `Graphics.setTransform` for the chart-coords-to-screen-coords mapping +/// -- has visual coverage. +abstract class AbstractChartScreenshotTest extends BaseTest { + + protected abstract AbstractChart buildChart(); + + protected abstract String screenshotName(); + + /// Subclasses can override to apply pan / zoom / setTransform configuration + /// to the wrapping ChartComponent before it lands on the form. Default is a + /// no-op (untransformed default rendering, which is the most common path). + protected void configureChartComponent(ChartComponent component) { + } + + @Override + public boolean runTest() throws Exception { + Form form = createForm(screenshotName(), new BorderLayout(), screenshotName()); + ChartComponent component = new ChartComponent(buildChart()); + configureChartComponent(component); + form.add(BorderLayout.CENTER, component); + form.show(); + return true; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartBarScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartBarScreenshotTest.java new file mode 100644 index 0000000000..13ee2d314a --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartBarScreenshotTest.java @@ -0,0 +1,57 @@ +package com.codenameone.examples.hellocodenameone.tests.charts; + +import com.codename1.charts.models.CategorySeries; +import com.codename1.charts.models.XYMultipleSeriesDataset; +import com.codename1.charts.renderers.SimpleSeriesRenderer; +import com.codename1.charts.renderers.XYMultipleSeriesRenderer; +import com.codename1.charts.renderers.XYSeriesRenderer; +import com.codename1.charts.util.ColorUtil; +import com.codename1.charts.views.AbstractChart; +import com.codename1.charts.views.BarChart; +import com.codename1.charts.views.BarChart.Type; + +public class ChartBarScreenshotTest extends AbstractChartScreenshotTest { + + @Override + protected AbstractChart buildChart() { + XYMultipleSeriesDataset dataset = new XYMultipleSeriesDataset(); + + CategorySeries q1 = new CategorySeries("Q1"); + q1.add("Region 1", 32); + q1.add("Region 2", 25); + q1.add("Region 3", 18); + q1.add("Region 4", 41); + dataset.addSeries(q1.toXYSeries()); + + CategorySeries q2 = new CategorySeries("Q2"); + q2.add("Region 1", 28); + q2.add("Region 2", 30); + q2.add("Region 3", 24); + q2.add("Region 4", 36); + dataset.addSeries(q2.toXYSeries()); + + XYMultipleSeriesRenderer renderer = new XYMultipleSeriesRenderer(); + renderer.setLabelsTextSize(20); + renderer.setLegendTextSize(20); + renderer.setMargins(new int[]{36, 60, 24, 24}); + renderer.setBarSpacing(0.4); + renderer.setShowGrid(true); + renderer.setXTitle("Region"); + renderer.setYTitle("Value"); + + SimpleSeriesRenderer r1 = new XYSeriesRenderer(); + r1.setColor(ColorUtil.rgb(0x2c, 0xa5, 0x80)); + renderer.addSeriesRenderer(r1); + + SimpleSeriesRenderer r2 = new XYSeriesRenderer(); + r2.setColor(ColorUtil.rgb(0xff, 0xa6, 0x2b)); + renderer.addSeriesRenderer(r2); + + return new BarChart(dataset, renderer, Type.DEFAULT); + } + + @Override + protected String screenshotName() { + return "chart-bar"; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartBubbleScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartBubbleScreenshotTest.java new file mode 100644 index 0000000000..e1287633d2 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartBubbleScreenshotTest.java @@ -0,0 +1,51 @@ +package com.codenameone.examples.hellocodenameone.tests.charts; + +import com.codename1.charts.models.XYMultipleSeriesDataset; +import com.codename1.charts.models.XYValueSeries; +import com.codename1.charts.renderers.XYMultipleSeriesRenderer; +import com.codename1.charts.renderers.XYSeriesRenderer; +import com.codename1.charts.util.ColorUtil; +import com.codename1.charts.views.AbstractChart; +import com.codename1.charts.views.BubbleChart; + +public class ChartBubbleScreenshotTest extends AbstractChartScreenshotTest { + + @Override + protected AbstractChart buildChart() { + XYMultipleSeriesDataset dataset = new XYMultipleSeriesDataset(); + XYValueSeries series = new XYValueSeries("Throughput"); + // Use explicit double literals for every argument: with bare int + // literals Java resolves `series.add(1, 5, 10)` to the inherited + // XYSeries.add(int index, double x, double y) signature (insert at + // an explicit list index) instead of XYValueSeries' three-double + // bubble add. The list is empty when the first call lands at index + // 1, so that picks IndexOutOfBoundsException instead of the chart + // we wanted. + series.add(1d, 5d, 10d); + series.add(2d, 8d, 18d); + series.add(3d, 12d, 9d); + series.add(4d, 15d, 24d); + series.add(5d, 18d, 15d); + series.add(6d, 21d, 30d); + series.add(7d, 24d, 12d); + series.add(8d, 27d, 26d); + dataset.addSeries(series); + + XYMultipleSeriesRenderer renderer = new XYMultipleSeriesRenderer(); + renderer.setLabelsTextSize(20); + renderer.setLegendTextSize(20); + renderer.setMargins(new int[]{36, 60, 24, 24}); + renderer.setShowGrid(true); + + XYSeriesRenderer seriesR = new XYSeriesRenderer(); + seriesR.setColor(ColorUtil.argb(0xc0, 0x9a, 0x4d, 0xff)); + renderer.addSeriesRenderer(seriesR); + + return new BubbleChart(dataset, renderer); + } + + @Override + protected String screenshotName() { + return "chart-bubble"; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartCombinedXYScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartCombinedXYScreenshotTest.java new file mode 100644 index 0000000000..ae981bdcb5 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartCombinedXYScreenshotTest.java @@ -0,0 +1,95 @@ +package com.codenameone.examples.hellocodenameone.tests.charts; + +import com.codename1.charts.models.XYMultipleSeriesDataset; +import com.codename1.charts.models.XYSeries; +import com.codename1.charts.renderers.XYMultipleSeriesRenderer; +import com.codename1.charts.renderers.XYSeriesRenderer; +import com.codename1.charts.util.ColorUtil; +import com.codename1.charts.views.AbstractChart; +import com.codename1.charts.views.BarChart; +import com.codename1.charts.views.CombinedXYChart; +import com.codename1.charts.views.LineChart; +import com.codename1.charts.views.PointStyle; +import com.codename1.charts.views.ScatterChart; + +/// CombinedXYChart layers BarChart, LineChart, and ScatterChart on the same +/// dataset axes -- exercises the multi-renderer dispatch in CombinedXYChart +/// where each child chart's draw is invoked in sequence with the same g +/// state. +public class ChartCombinedXYScreenshotTest extends AbstractChartScreenshotTest { + + @Override + protected AbstractChart buildChart() { + XYMultipleSeriesDataset dataset = new XYMultipleSeriesDataset(); + + XYSeries bars = new XYSeries("Bars"); + bars.add(1, 12); + bars.add(2, 18); + bars.add(3, 15); + bars.add(4, 22); + bars.add(5, 17); + dataset.addSeries(bars); + + XYSeries trend = new XYSeries("Trend"); + trend.add(1, 14); + trend.add(2, 16); + trend.add(3, 19); + trend.add(4, 20); + trend.add(5, 23); + dataset.addSeries(trend); + + XYSeries markers = new XYSeries("Markers"); + markers.add(1, 8); + markers.add(2, 11); + markers.add(3, 13); + markers.add(4, 14); + markers.add(5, 18); + dataset.addSeries(markers); + + XYMultipleSeriesRenderer renderer = new XYMultipleSeriesRenderer(); + renderer.setLabelsTextSize(20); + renderer.setLegendTextSize(20); + renderer.setMargins(new int[]{36, 60, 24, 24}); + renderer.setShowGrid(true); + + XYSeriesRenderer barR = new XYSeriesRenderer(); + barR.setColor(ColorUtil.rgb(0x6c, 0x3a, 0xb6)); + renderer.addSeriesRenderer(barR); + + XYSeriesRenderer trendR = new XYSeriesRenderer(); + trendR.setColor(ColorUtil.rgb(0xee, 0x4a, 0x4a)); + trendR.setLineWidth(3f); + renderer.addSeriesRenderer(trendR); + + XYSeriesRenderer markersR = new XYSeriesRenderer(); + markersR.setColor(ColorUtil.rgb(0x42, 0xa7, 0x6f)); + // ScatterChart paths default the point style to PointStyle.POINT, + // which routes through Canvas.drawPoint() -- the chart-package compat + // shim explicitly throws "Not supported yet." there. CombinedXY + // includes a Scatter chart def that paints on top of the line/bar + // ones, so we'd hit the unimplemented drawPoint and the whole + // suite hangs waiting for done(). Pick CIRCLE explicitly so the + // marker layer renders with a real shape primitive. + markersR.setPointStyle(PointStyle.CIRCLE); + markersR.setFillPoints(true); + renderer.addSeriesRenderer(markersR); + + // CombinedXYChart matches against AbstractChart.getChartType() which + // returns the bare-type string ("Bar", "Line", "Scatter") -- using + // BarChart.TYPE etc. avoids hard-coding a string we'd have to + // remember to keep in sync. + CombinedXYChart.XYCombinedChartDef[] chartDefs = + new CombinedXYChart.XYCombinedChartDef[]{ + new CombinedXYChart.XYCombinedChartDef(BarChart.TYPE, 0), + new CombinedXYChart.XYCombinedChartDef(LineChart.TYPE, 1), + new CombinedXYChart.XYCombinedChartDef(ScatterChart.TYPE, 2) + }; + + return new CombinedXYChart(dataset, renderer, chartDefs); + } + + @Override + protected String screenshotName() { + return "chart-combined-xy"; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartCubicLineScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartCubicLineScreenshotTest.java new file mode 100644 index 0000000000..cf279d53ed --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartCubicLineScreenshotTest.java @@ -0,0 +1,50 @@ +package com.codenameone.examples.hellocodenameone.tests.charts; + +import com.codename1.charts.models.XYMultipleSeriesDataset; +import com.codename1.charts.models.XYSeries; +import com.codename1.charts.renderers.XYMultipleSeriesRenderer; +import com.codename1.charts.renderers.XYSeriesRenderer; +import com.codename1.charts.util.ColorUtil; +import com.codename1.charts.views.AbstractChart; +import com.codename1.charts.views.CubicLineChart; + +/// CubicLineChart smooths the line through the data points using cubic +/// interpolation -- different curve renderer than the LineChart test, so +/// regressions in the curve generation path are caught separately. +public class ChartCubicLineScreenshotTest extends AbstractChartScreenshotTest { + + @Override + protected AbstractChart buildChart() { + XYMultipleSeriesDataset dataset = new XYMultipleSeriesDataset(); + XYSeries series = new XYSeries("Latency"); + series.add(0, 12); + series.add(1, 18); + series.add(2, 14); + series.add(3, 22); + series.add(4, 20); + series.add(5, 28); + series.add(6, 18); + series.add(7, 26); + dataset.addSeries(series); + + XYMultipleSeriesRenderer renderer = new XYMultipleSeriesRenderer(); + renderer.setLabelsTextSize(20); + renderer.setLegendTextSize(20); + renderer.setMargins(new int[]{36, 60, 24, 24}); + renderer.setShowGrid(true); + renderer.setXTitle("t"); + renderer.setYTitle("ms"); + + XYSeriesRenderer seriesRenderer = new XYSeriesRenderer(); + seriesRenderer.setColor(ColorUtil.rgb(0x4f, 0xa8, 0x6e)); + seriesRenderer.setLineWidth(3f); + renderer.addSeriesRenderer(seriesRenderer); + + return new CubicLineChart(dataset, renderer, 0.33f); + } + + @Override + protected String screenshotName() { + return "chart-cubic-line"; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartDoughnutScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartDoughnutScreenshotTest.java new file mode 100644 index 0000000000..fdba8282ae --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartDoughnutScreenshotTest.java @@ -0,0 +1,57 @@ +package com.codenameone.examples.hellocodenameone.tests.charts; + +import com.codename1.charts.models.CategorySeries; +import com.codename1.charts.models.MultipleCategorySeries; +import com.codename1.charts.renderers.DefaultRenderer; +import com.codename1.charts.renderers.SimpleSeriesRenderer; +import com.codename1.charts.util.ColorUtil; +import com.codename1.charts.views.AbstractChart; +import com.codename1.charts.views.DoughnutChart; + +public class ChartDoughnutScreenshotTest extends AbstractChartScreenshotTest { + + @Override + protected AbstractChart buildChart() { + MultipleCategorySeries dataset = new MultipleCategorySeries("Sales"); + dataset.add("2023", + new String[]{"Online", "Retail", "Wholesale"}, + new double[]{40, 35, 25}); + dataset.add("2024", + new String[]{"Online", "Retail", "Wholesale"}, + new double[]{55, 28, 17}); + + DefaultRenderer renderer = new DefaultRenderer(); + renderer.setLabelsTextSize(20); + renderer.setLegendTextSize(20); + renderer.setLabelsColor(ColorUtil.BLACK); + renderer.setShowLabels(true); + + int[] colors = new int[]{ + ColorUtil.rgb(0xb8, 0x40, 0xa6), + ColorUtil.rgb(0x42, 0xa7, 0x6f), + ColorUtil.rgb(0xe9, 0x6e, 0x33), + ColorUtil.rgb(0x6c, 0x3a, 0xb6), + ColorUtil.rgb(0x47, 0xa1, 0xe0), + ColorUtil.rgb(0xf2, 0xb1, 0x40) + }; + for (int color : colors) { + SimpleSeriesRenderer r = new SimpleSeriesRenderer(); + r.setColor(color); + renderer.addSeriesRenderer(r); + } + + // Use synthetic CategorySeries derived from dataset for renderer count + // -- each DoughnutChart segment needs its own renderer instance. + CategorySeries unused = new CategorySeries("colors"); + for (int color : colors) { + unused.add("c", color); + } + + return new DoughnutChart(dataset, renderer); + } + + @Override + protected String screenshotName() { + return "chart-doughnut"; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartLineScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartLineScreenshotTest.java new file mode 100644 index 0000000000..b366ec4b36 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartLineScreenshotTest.java @@ -0,0 +1,66 @@ +package com.codenameone.examples.hellocodenameone.tests.charts; + +import com.codename1.charts.models.XYMultipleSeriesDataset; +import com.codename1.charts.models.XYSeries; +import com.codename1.charts.renderers.XYMultipleSeriesRenderer; +import com.codename1.charts.renderers.XYSeriesRenderer; +import com.codename1.charts.util.ColorUtil; +import com.codename1.charts.views.AbstractChart; +import com.codename1.charts.views.LineChart; + +/// Two-series LineChart with fixed deterministic data so the rendered +/// vertices, axis labels and legend are reproducible across platforms. +/// Drives the default ChartComponent.paint() path (no setTransform on the +/// component) so we have a baseline that catches regressions in the +/// no-transform branch of ChartComponent.paint -- the branch the platform +/// conjugation change does NOT touch. +public class ChartLineScreenshotTest extends AbstractChartScreenshotTest { + + @Override + protected AbstractChart buildChart() { + XYMultipleSeriesDataset dataset = new XYMultipleSeriesDataset(); + XYSeries north = new XYSeries("North"); + north.add(2018, 12); + north.add(2019, 16); + north.add(2020, 22); + north.add(2021, 18); + north.add(2022, 28); + dataset.addSeries(north); + + XYSeries south = new XYSeries("South"); + south.add(2018, 8); + south.add(2019, 11); + south.add(2020, 13); + south.add(2021, 16); + south.add(2022, 19); + dataset.addSeries(south); + + XYMultipleSeriesRenderer renderer = new XYMultipleSeriesRenderer(); + renderer.setLabelsTextSize(20); + renderer.setAxisTitleTextSize(20); + renderer.setLegendTextSize(20); + renderer.setMargins(new int[]{36, 60, 24, 24}); + renderer.setXTitle("Year"); + renderer.setYTitle("Value"); + renderer.setXLabels(5); + renderer.setYLabels(5); + renderer.setShowGrid(true); + + XYSeriesRenderer northRenderer = new XYSeriesRenderer(); + northRenderer.setColor(ColorUtil.rgb(0x0a, 0x66, 0xff)); + northRenderer.setLineWidth(3f); + renderer.addSeriesRenderer(northRenderer); + + XYSeriesRenderer southRenderer = new XYSeriesRenderer(); + southRenderer.setColor(ColorUtil.rgb(0xee, 0x4a, 0x4a)); + southRenderer.setLineWidth(3f); + renderer.addSeriesRenderer(southRenderer); + + return new LineChart(dataset, renderer); + } + + @Override + protected String screenshotName() { + return "chart-line"; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartPieScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartPieScreenshotTest.java new file mode 100644 index 0000000000..0c95340ce3 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartPieScreenshotTest.java @@ -0,0 +1,44 @@ +package com.codenameone.examples.hellocodenameone.tests.charts; + +import com.codename1.charts.models.CategorySeries; +import com.codename1.charts.renderers.DefaultRenderer; +import com.codename1.charts.renderers.SimpleSeriesRenderer; +import com.codename1.charts.util.ColorUtil; +import com.codename1.charts.views.AbstractChart; +import com.codename1.charts.views.PieChart; + +public class ChartPieScreenshotTest extends AbstractChartScreenshotTest { + + @Override + protected AbstractChart buildChart() { + CategorySeries series = new CategorySeries("Tickets"); + series.add("New", 14); + series.add("Open", 26); + series.add("In progress", 38); + series.add("Resolved", 22); + + DefaultRenderer renderer = new DefaultRenderer(); + renderer.setLabelsTextSize(22); + renderer.setLegendTextSize(22); + renderer.setShowLabels(true); + renderer.setLabelsColor(ColorUtil.BLACK); + + int[] colors = new int[]{ + ColorUtil.rgb(0xef, 0x4f, 0x4f), + ColorUtil.rgb(0xf2, 0xb1, 0x40), + ColorUtil.rgb(0x47, 0xa1, 0xe0), + ColorUtil.rgb(0x4d, 0xc6, 0x8f) + }; + for (int color : colors) { + SimpleSeriesRenderer r = new SimpleSeriesRenderer(); + r.setColor(color); + renderer.addSeriesRenderer(r); + } + return new PieChart(series, renderer); + } + + @Override + protected String screenshotName() { + return "chart-pie"; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartRadarScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartRadarScreenshotTest.java new file mode 100644 index 0000000000..46fc7b11e9 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartRadarScreenshotTest.java @@ -0,0 +1,55 @@ +package com.codenameone.examples.hellocodenameone.tests.charts; + +import com.codename1.charts.models.AreaSeries; +import com.codename1.charts.models.CategorySeries; +import com.codename1.charts.renderers.DefaultRenderer; +import com.codename1.charts.renderers.SimpleSeriesRenderer; +import com.codename1.charts.util.ColorUtil; +import com.codename1.charts.views.AbstractChart; +import com.codename1.charts.views.RadarChart; + +/// Mirrors the canonical RadarChartSample (two CategorySeries with five axes). +public class ChartRadarScreenshotTest extends AbstractChartScreenshotTest { + + @Override + protected AbstractChart buildChart() { + AreaSeries dataset = new AreaSeries(); + + CategorySeries may = new CategorySeries("May"); + may.add("Health", 0.8); + may.add("Attack", 0.6); + may.add("Defense", 0.4); + may.add("Critical", 0.2); + may.add("Speed", 1.0); + dataset.addSeries(may); + + CategorySeries chang = new CategorySeries("Chang"); + chang.add("Health", 0.3); + chang.add("Attack", 0.7); + chang.add("Defense", 0.5); + chang.add("Critical", 0.1); + chang.add("Speed", 0.3); + dataset.addSeries(chang); + + DefaultRenderer renderer = new DefaultRenderer(); + renderer.setLegendTextSize(22); + renderer.setLabelsTextSize(20); + renderer.setLabelsColor(ColorUtil.BLACK); + renderer.setShowLabels(true); + + SimpleSeriesRenderer mayR = new SimpleSeriesRenderer(); + mayR.setColor(ColorUtil.MAGENTA); + renderer.addSeriesRenderer(mayR); + + SimpleSeriesRenderer changR = new SimpleSeriesRenderer(); + changR.setColor(ColorUtil.CYAN); + renderer.addSeriesRenderer(changR); + + return new RadarChart(dataset, renderer); + } + + @Override + protected String screenshotName() { + return "chart-radar"; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartRangeBarScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartRangeBarScreenshotTest.java new file mode 100644 index 0000000000..8ba90a7fb2 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartRangeBarScreenshotTest.java @@ -0,0 +1,53 @@ +package com.codenameone.examples.hellocodenameone.tests.charts; + +import com.codename1.charts.models.RangeCategorySeries; +import com.codename1.charts.models.XYMultipleSeriesDataset; +import com.codename1.charts.renderers.XYMultipleSeriesRenderer; +import com.codename1.charts.renderers.XYSeriesRenderer; +import com.codename1.charts.util.ColorUtil; +import com.codename1.charts.views.AbstractChart; +import com.codename1.charts.views.BarChart.Type; +import com.codename1.charts.views.RangeBarChart; + +public class ChartRangeBarScreenshotTest extends AbstractChartScreenshotTest { + + @Override + protected AbstractChart buildChart() { + XYMultipleSeriesDataset dataset = new XYMultipleSeriesDataset(); + + RangeCategorySeries cycleA = new RangeCategorySeries("Cycle A"); + cycleA.add(15, 32); + cycleA.add(20, 38); + cycleA.add(18, 36); + cycleA.add(22, 40); + dataset.addSeries(cycleA.toXYSeries()); + + RangeCategorySeries cycleB = new RangeCategorySeries("Cycle B"); + cycleB.add(8, 26); + cycleB.add(12, 32); + cycleB.add(14, 30); + cycleB.add(11, 27); + dataset.addSeries(cycleB.toXYSeries()); + + XYMultipleSeriesRenderer renderer = new XYMultipleSeriesRenderer(); + renderer.setLabelsTextSize(20); + renderer.setLegendTextSize(20); + renderer.setMargins(new int[]{36, 60, 24, 24}); + renderer.setShowGrid(true); + + XYSeriesRenderer aR = new XYSeriesRenderer(); + aR.setColor(ColorUtil.rgb(0xff, 0x80, 0x33)); + renderer.addSeriesRenderer(aR); + + XYSeriesRenderer bR = new XYSeriesRenderer(); + bR.setColor(ColorUtil.rgb(0x33, 0xa9, 0xff)); + renderer.addSeriesRenderer(bR); + + return new RangeBarChart(dataset, renderer, Type.DEFAULT); + } + + @Override + protected String screenshotName() { + return "chart-range-bar"; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartRotatedScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartRotatedScreenshotTest.java new file mode 100644 index 0000000000..b375f0e923 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartRotatedScreenshotTest.java @@ -0,0 +1,66 @@ +package com.codenameone.examples.hellocodenameone.tests.charts; + +import com.codename1.charts.ChartComponent; +import com.codename1.charts.models.CategorySeries; +import com.codename1.charts.renderers.DefaultRenderer; +import com.codename1.charts.renderers.SimpleSeriesRenderer; +import com.codename1.charts.util.ColorUtil; +import com.codename1.charts.views.AbstractChart; +import com.codename1.charts.views.PieChart; +import com.codename1.ui.Transform; + +/// Pie chart with a 30 degree rotation applied via ChartComponent.setTransform. +/// Rotation is the transformation most sensitive to the +/// `xTranslate`-conjugation: without conjugation the rotation would happen +/// around the screen origin (0, 0) instead of the component centre, +/// producing a wildly translated chart instead of a rotated one. +public class ChartRotatedScreenshotTest extends AbstractChartScreenshotTest { + + @Override + protected AbstractChart buildChart() { + CategorySeries series = new CategorySeries("Slices"); + series.add("Alpha", 30); + series.add("Beta", 25); + series.add("Gamma", 20); + series.add("Delta", 15); + series.add("Epsilon", 10); + + DefaultRenderer renderer = new DefaultRenderer(); + renderer.setLabelsTextSize(20); + renderer.setLegendTextSize(20); + renderer.setShowLabels(true); + renderer.setLabelsColor(ColorUtil.BLACK); + + int[] colors = new int[]{ + ColorUtil.rgb(0xb8, 0x40, 0xa6), + ColorUtil.rgb(0xee, 0x4a, 0x4a), + ColorUtil.rgb(0xf2, 0xb1, 0x40), + ColorUtil.rgb(0x4d, 0xc6, 0x8f), + ColorUtil.rgb(0x47, 0xa1, 0xe0) + }; + for (int color : colors) { + SimpleSeriesRenderer r = new SimpleSeriesRenderer(); + r.setColor(color); + renderer.addSeriesRenderer(r); + } + return new PieChart(series, renderer); + } + + @Override + protected void configureChartComponent(ChartComponent component) { + // 30 degree rotation around component-local (250, 400). On the + // pre-conjugation iOS Metal port the same rotation applied to + // xTranslate-shifted vertex coordinates would rotate the chart + // around the screen origin instead, throwing the visible pie + // outside the screen entirely. With the new uniform conjugation + // this rotates around the component-local anchor on every port. + Transform t = Transform.makeIdentity(); + t.rotate((float) (Math.PI / 6.0), 250f, 400f); + component.setTransform(t); + } + + @Override + protected String screenshotName() { + return "chart-rotated-pie"; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartScatterScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartScatterScreenshotTest.java new file mode 100644 index 0000000000..18a4d83740 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartScatterScreenshotTest.java @@ -0,0 +1,65 @@ +package com.codenameone.examples.hellocodenameone.tests.charts; + +import com.codename1.charts.models.XYMultipleSeriesDataset; +import com.codename1.charts.models.XYSeries; +import com.codename1.charts.renderers.XYMultipleSeriesRenderer; +import com.codename1.charts.renderers.XYSeriesRenderer; +import com.codename1.charts.util.ColorUtil; +import com.codename1.charts.views.AbstractChart; +import com.codename1.charts.views.PointStyle; +import com.codename1.charts.views.ScatterChart; + +public class ChartScatterScreenshotTest extends AbstractChartScreenshotTest { + + @Override + protected AbstractChart buildChart() { + XYMultipleSeriesDataset dataset = new XYMultipleSeriesDataset(); + + XYSeries cohortA = new XYSeries("Cohort A"); + cohortA.add(1, 14); + cohortA.add(2, 16); + cohortA.add(3, 11); + cohortA.add(4, 19); + cohortA.add(5, 15); + cohortA.add(6, 22); + cohortA.add(7, 18); + cohortA.add(8, 24); + dataset.addSeries(cohortA); + + XYSeries cohortB = new XYSeries("Cohort B"); + cohortB.add(1, 4); + cohortB.add(2, 7); + cohortB.add(3, 9); + cohortB.add(4, 6); + cohortB.add(5, 12); + cohortB.add(6, 8); + cohortB.add(7, 14); + cohortB.add(8, 11); + dataset.addSeries(cohortB); + + XYMultipleSeriesRenderer renderer = new XYMultipleSeriesRenderer(); + renderer.setLabelsTextSize(20); + renderer.setLegendTextSize(20); + renderer.setMargins(new int[]{36, 60, 24, 24}); + renderer.setShowGrid(true); + + XYSeriesRenderer aR = new XYSeriesRenderer(); + aR.setColor(ColorUtil.rgb(0xed, 0x3f, 0x3f)); + aR.setPointStyle(PointStyle.CIRCLE); + aR.setFillPoints(true); + renderer.addSeriesRenderer(aR); + + XYSeriesRenderer bR = new XYSeriesRenderer(); + bR.setColor(ColorUtil.rgb(0x3f, 0x65, 0xed)); + bR.setPointStyle(PointStyle.SQUARE); + bR.setFillPoints(true); + renderer.addSeriesRenderer(bR); + + return new ScatterChart(dataset, renderer); + } + + @Override + protected String screenshotName() { + return "chart-scatter"; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartStackedBarScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartStackedBarScreenshotTest.java new file mode 100644 index 0000000000..de992f5c19 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartStackedBarScreenshotTest.java @@ -0,0 +1,69 @@ +package com.codenameone.examples.hellocodenameone.tests.charts; + +import com.codename1.charts.models.CategorySeries; +import com.codename1.charts.models.XYMultipleSeriesDataset; +import com.codename1.charts.renderers.XYMultipleSeriesRenderer; +import com.codename1.charts.renderers.XYSeriesRenderer; +import com.codename1.charts.util.ColorUtil; +import com.codename1.charts.views.AbstractChart; +import com.codename1.charts.views.BarChart; +import com.codename1.charts.views.BarChart.Type; + +/// BarChart in STACKED mode -- the bars are placed at the same X position so +/// the renderer's per-series x-position composition path differs from the +/// side-by-side DEFAULT bars. +public class ChartStackedBarScreenshotTest extends AbstractChartScreenshotTest { + + @Override + protected AbstractChart buildChart() { + XYMultipleSeriesDataset dataset = new XYMultipleSeriesDataset(); + + CategorySeries staff = new CategorySeries("Staff"); + staff.add("Mar", 24); + staff.add("Apr", 30); + staff.add("May", 28); + staff.add("Jun", 33); + dataset.addSeries(staff.toXYSeries()); + + CategorySeries software = new CategorySeries("Software"); + software.add("Mar", 12); + software.add("Apr", 14); + software.add("May", 18); + software.add("Jun", 22); + dataset.addSeries(software.toXYSeries()); + + CategorySeries hardware = new CategorySeries("Hardware"); + hardware.add("Mar", 8); + hardware.add("Apr", 6); + hardware.add("May", 10); + hardware.add("Jun", 12); + dataset.addSeries(hardware.toXYSeries()); + + XYMultipleSeriesRenderer renderer = new XYMultipleSeriesRenderer(); + renderer.setLabelsTextSize(20); + renderer.setLegendTextSize(20); + renderer.setMargins(new int[]{36, 60, 24, 24}); + renderer.setShowGrid(true); + renderer.setXTitle("Month"); + renderer.setYTitle("Cost"); + + XYSeriesRenderer staffR = new XYSeriesRenderer(); + staffR.setColor(ColorUtil.rgb(0x6c, 0x3a, 0xb6)); + renderer.addSeriesRenderer(staffR); + + XYSeriesRenderer softwareR = new XYSeriesRenderer(); + softwareR.setColor(ColorUtil.rgb(0x42, 0xa7, 0x6f)); + renderer.addSeriesRenderer(softwareR); + + XYSeriesRenderer hardwareR = new XYSeriesRenderer(); + hardwareR.setColor(ColorUtil.rgb(0xe9, 0x6e, 0x33)); + renderer.addSeriesRenderer(hardwareR); + + return new BarChart(dataset, renderer, Type.STACKED); + } + + @Override + protected String screenshotName() { + return "chart-bar-stacked"; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartTimeChartScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartTimeChartScreenshotTest.java new file mode 100644 index 0000000000..e884e5368d --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartTimeChartScreenshotTest.java @@ -0,0 +1,49 @@ +package com.codenameone.examples.hellocodenameone.tests.charts; + +import com.codename1.charts.models.TimeSeries; +import com.codename1.charts.models.XYMultipleSeriesDataset; +import com.codename1.charts.renderers.XYMultipleSeriesRenderer; +import com.codename1.charts.renderers.XYSeriesRenderer; +import com.codename1.charts.util.ColorUtil; +import com.codename1.charts.views.AbstractChart; +import com.codename1.charts.views.TimeChart; + +import java.util.Date; + +/// TimeChart with a deterministic series anchored at a fixed epoch -- avoids +/// using `new Date()` so the rendered axis labels match across runs. +public class ChartTimeChartScreenshotTest extends AbstractChartScreenshotTest { + + private static final long ANCHOR_EPOCH_MS = 1709251200000L; // 2024-03-01 UTC + + @Override + protected AbstractChart buildChart() { + XYMultipleSeriesDataset dataset = new XYMultipleSeriesDataset(); + + TimeSeries series = new TimeSeries("Visits"); + long day = 24L * 60L * 60L * 1000L; + double[] values = {120, 134, 142, 158, 145, 168, 180, 175, 192, 205}; + for (int i = 0; i < values.length; i++) { + series.add(new Date(ANCHOR_EPOCH_MS + i * day), values[i]); + } + dataset.addSeries(series); + + XYMultipleSeriesRenderer renderer = new XYMultipleSeriesRenderer(); + renderer.setLabelsTextSize(20); + renderer.setLegendTextSize(20); + renderer.setMargins(new int[]{36, 80, 24, 24}); + renderer.setShowGrid(true); + + XYSeriesRenderer seriesRenderer = new XYSeriesRenderer(); + seriesRenderer.setColor(ColorUtil.rgb(0x14, 0x71, 0xc4)); + seriesRenderer.setLineWidth(3f); + renderer.addSeriesRenderer(seriesRenderer); + + return new TimeChart(dataset, renderer); + } + + @Override + protected String screenshotName() { + return "chart-time"; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartTransformScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartTransformScreenshotTest.java new file mode 100644 index 0000000000..c773337235 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/charts/ChartTransformScreenshotTest.java @@ -0,0 +1,75 @@ +package com.codenameone.examples.hellocodenameone.tests.charts; + +import com.codename1.charts.ChartComponent; +import com.codename1.charts.models.XYMultipleSeriesDataset; +import com.codename1.charts.models.XYSeries; +import com.codename1.charts.renderers.XYMultipleSeriesRenderer; +import com.codename1.charts.renderers.XYSeriesRenderer; +import com.codename1.charts.util.ColorUtil; +import com.codename1.charts.views.AbstractChart; +import com.codename1.charts.views.LineChart; +import com.codename1.ui.Transform; + +/// Exercises `ChartComponent.setTransform(Transform)` -- the code path the +/// translation-conjugation refactor in core / iOS / Android / JavaSE +/// directly touches. ChartComponent's transform is documented to operate in +/// component-local coordinates ("origin at (absoluteX, absoluteY)"); this +/// test applies a non-identity scale-around-centre to verify the rendered +/// chart is centred correctly across all four ports. +public class ChartTransformScreenshotTest extends AbstractChartScreenshotTest { + + @Override + protected AbstractChart buildChart() { + XYMultipleSeriesDataset dataset = new XYMultipleSeriesDataset(); + XYSeries series = new XYSeries("Latency"); + series.add(0, 12); + series.add(1, 14); + series.add(2, 19); + series.add(3, 17); + series.add(4, 24); + series.add(5, 22); + series.add(6, 28); + dataset.addSeries(series); + + XYMultipleSeriesRenderer renderer = new XYMultipleSeriesRenderer(); + renderer.setLabelsTextSize(20); + renderer.setLegendTextSize(20); + renderer.setMargins(new int[]{36, 60, 24, 24}); + renderer.setShowGrid(true); + + XYSeriesRenderer seriesRenderer = new XYSeriesRenderer(); + seriesRenderer.setColor(ColorUtil.rgb(0x6c, 0x3a, 0xb6)); + seriesRenderer.setLineWidth(3f); + renderer.addSeriesRenderer(seriesRenderer); + + return new LineChart(dataset, renderer); + } + + @Override + protected void configureChartComponent(ChartComponent component) { + // Scale of 0.7 around the chart-component centre. ChartComponent + // documents transforms as relative to its (absoluteX, absoluteY) + // origin, so the centre point we anchor on is in component-local + // coords (we use the chart's preferred-size centre approximated by + // the transform's translate). With the platform-side conjugation in + // Graphics.setTransform plus the matching simplification in + // ChartComponent.paint (which dropped its own T(absX) * X * + // T(-absX) compensation), the rendered chart should be a 0.7x + // scaled copy of the untransformed test, centred on the component. + Transform t = Transform.makeIdentity(); + // Anchor scale at component-local (250, 400). We don't know the + // component's actual size here -- the screenshot dimensions are + // platform-dependent -- but ChartComponent uses BorderLayout.CENTER + // so it fills the form, and a fixed anchor at (250, 400) gives a + // deterministic scaled output once the component is laid out. + t.translate(250f, 400f); + t.scale(0.7f, 0.7f); + t.translate(-250f, -400f); + component.setTransform(t); + } + + @Override + protected String screenshotName() { + return "chart-transform"; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/AffineScale.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/AffineScale.java index 9d85c963a1..db4a634a15 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/AffineScale.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/AffineScale.java @@ -10,24 +10,44 @@ public class AffineScale extends AbstractGraphicsScreenshotTest { @Override protected void drawContent(Graphics g, Rectangle bounds) { - if(!g.isAffineSupported()) { - g.drawString("Affine unsupported", 0, 0); + int x = bounds.getX(); + int y = bounds.getY(); + int w = bounds.getWidth(); + int h = bounds.getHeight(); + + g.setColor(0xffffff); + g.fillRect(x, y, w, h); + g.setColor(0x000000); + g.drawRect(x, y, w - 1, h - 1); + + if (!g.isAffineSupported()) { + g.drawString("Affine unsupported", x + 4, y + 4); return; } - float xScale = 0.01f * ((float)bounds.getHeight()); - float yScale = 0.01f * ((float)bounds.getWidth()); - AffineTransform affine = new AffineTransform(); - affine.setToScale(xScale, yScale); + // Same fix as Scale.java: the earlier formula crossed the axes so the + // fill clipped to a thin strip on portrait screens. + float xScale = w / 200f; + float yScale = h / 200f; + + // AffineTransform with matrix [xScale 0 x ; 0 yScale y] -- equivalent + // to translate(x, y) then scale(xScale, yScale). + AffineTransform affine = new AffineTransform( + xScale, 0f, + 0f, yScale, + (float) x, (float) y); Transform transform = affine.toTransform(); - int translateX = (int)(bounds.getX() / xScale); - int translateY = (int)(bounds.getY() / yScale); - transform.translate(translateX, translateY); g.setTransform(transform); - g.fillLinearGradient(0xff0000, 0xff, 0, 0, 100, 100, true); + // Top half of cell. + g.fillLinearGradient(0xff0000, 0x0000ff, 0, 0, 200, 100, true); + + // Mirror X via Transform.scale (composition) and draw the bottom half + // so the gradient runs right-to-left. transform.scale(-1, 1); - g.fillLinearGradient(0xff0000, 0xff, 0, 100, 100, 100, true); + g.setTransform(transform); + g.fillLinearGradient(0xff0000, 0x0000ff, -200, 100, 200, 100, true); + g.resetAffine(); } diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/Scale.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/Scale.java index 907da5dc9a..f34d73aa02 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/Scale.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/Scale.java @@ -1,6 +1,7 @@ package com.codenameone.examples.hellocodenameone.tests.graphics; import com.codename1.ui.Graphics; +import com.codename1.ui.Transform; import com.codename1.ui.geom.Rectangle; import com.codenameone.examples.hellocodenameone.tests.AbstractGraphicsScreenshotTest; @@ -8,23 +9,44 @@ public class Scale extends AbstractGraphicsScreenshotTest { @Override protected void drawContent(Graphics g, Rectangle bounds) { - if(!g.isAffineSupported()) { - g.drawString("Affine unsupported", 0, 0); + int x = bounds.getX(); + int y = bounds.getY(); + int w = bounds.getWidth(); + int h = bounds.getHeight(); + + g.setColor(0xffffff); + g.fillRect(x, y, w, h); + g.setColor(0x000000); + g.drawRect(x, y, w - 1, h - 1); + + if (!g.isAffineSupported()) { + g.drawString("Affine unsupported", x + 4, y + 4); return; } - float xScale = 0.01f * ((float)bounds.getHeight()); - float yScale = 0.01f * ((float)bounds.getWidth()); - g.scale(xScale, yScale); - int translateX = (int)(bounds.getX() / xScale); - int translateY = (int)(bounds.getY() / yScale); - g.translate(translateX, translateY); - g.fillLinearGradient(0xff0000, 0xff, 0, 0, 100, 100, true); - g.scale(-1, 1); - g.fillLinearGradient(0xff0000, 0xff, 0, 100, 100, 100, true); - - g.translate(-translateX, -translateY); - g.resetAffine(); + // The earlier test built a transform via separate g.translate + g.scale + // calls. On the JavaSE port g.translate(int, int) is a no-op (translate + // is expected to be embedded in the native graphics) and on iOS the + // form-graphics path doesn't compose g.scale with the cell offset + // either, so the gradient fill landed off-cell. Build a single + // Transform that combines translate + scale and apply it once. + float xScale = w / 200f; + float yScale = h / 200f; + Transform t = Transform.makeIdentity(); + t.translate(x, y); + t.scale(xScale, yScale); + g.setTransform(t); + + // Top half of cell. + g.fillLinearGradient(0xff0000, 0x0000ff, 0, 0, 200, 100, true); + + // Mirror X via scale(-1, 1) and draw the bottom half so the gradient + // runs right-to-left. + t.scale(-1, 1); + g.setTransform(t); + g.fillLinearGradient(0xff0000, 0x0000ff, -200, 100, 200, 100, true); + + g.setTransform(Transform.makeIdentity()); } @Override diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/TransformCamera.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/TransformCamera.java index e3c8044b35..687fdae1c0 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/TransformCamera.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/TransformCamera.java @@ -9,48 +9,84 @@ public class TransformCamera extends AbstractGraphicsScreenshotTest { @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(0xffffff); + g.fillRect(x, y, w, h); + g.setColor(0x000000); + g.drawRect(x, y, w - 1, h - 1); + if (!Transform.isPerspectiveSupported()) { - g.drawString("Perspective unsupported", bounds.getX(), bounds.getY()); + g.setColor(0xaa0000); + g.drawString("No camera", x + 4, y + 4); + g.setColor(0x884400); + g.fillRect(x + w / 4, y + h / 4, w / 2, h / 2); return; } - float eyeX = 0; - float eyeY = 0; - float eyeZ = 500; - float centerX = 0; - float centerY = 0; - float centerZ = 0; - float upX = 0; - float upY = 1; - float upZ = 0; - - Transform t = Transform.makeCamera(eyeX, eyeY, eyeZ, centerX, centerY, centerZ, upX, upY, upZ); - - // We probably also need a projection matrix for the camera to make sense visually? - // Or does makeCamera include projection? - // Typically makeCamera (lookAt) creates a View matrix. We still need Projection. + // Build Viewport * Perspective * Camera * Translate(model). The + // earlier test passed the raw clip-space output to fillRect; the + // first viewport-mapping attempt used g.setTransform(mvp) which + // depended on the platform's rect rasterizer honouring a 4x4 + // perspective matrix (Android Canvas drops the Z axis on its 3x3 + // Skia matrix and rect rasterization doesn't honour the perspective + // row reliably; iOS Metal mutable graphics gates the entire branch + // off via isPerspectiveTransformSupported = false). Project the 4 + // model corners via transformPoint (which does the homogeneous + // divide on every backend) and draw a 2D polygon, so the rendering + // is uniform across all 4 panes on every platform. + float fovy = (float) (Math.PI / 4); + float aspect = (float) w / (float) h; + float zNear = 1f; + float zFar = 1000f; + float modelZ = -300f; - float fovy = 45f; - float aspect = (float)bounds.getWidth() / bounds.getHeight(); - Transform proj = Transform.makePerspective(fovy, aspect, 0.1f, 1000f); + Transform mvp = Transform.makeIdentity(); + // Viewport: NDC -> cell pixels. + mvp.translate(x + w * 0.5f, y + h * 0.5f); + mvp.scale(w * 0.5f, -h * 0.5f, 1f); + // Perspective projection. + Transform persp = Transform.makePerspective(fovy, aspect, zNear, zFar); + mvp.concatenate(persp); + // Camera elevated on Y, looking down at the model centre. The + // ~5.7 deg downward pitch shifts the rendered quad downward in the + // cell and is visually distinct from TransformPerspective which + // uses an implicit identity view. + Transform camera = Transform.makeCamera( + 0f, 30f, 0f, // eye -- elevated on y + 0f, 0f, modelZ, // looking at the model quad's centre + 0f, 1f, 0f); // up + mvp.concatenate(camera); + // Place the model quad at z=modelZ in world space. + mvp.translate(0, 0, modelZ); - proj.concatenate(t); + // Solid orange quad. The downward camera pitch shifts the quad + // toward the bottom of the cell. + g.setColor(0x884400); + fillProjectedQuad(g, mvp, -50, -50, 100, 100); - g.setTransform(proj); - - g.setColor(0x00ff00); - g.fillRect(-50, -50, 100, 100); - - // Rotate the camera/object slightly to verify 3D - Transform rot = Transform.makeRotation((float)(Math.PI / 4), 0, 1, 0); // Rotate around Y - proj.concatenate(rot); // Apply rotation - g.setTransform(proj); - - g.setColor(0x0000ff); - g.setAlpha(128); - g.fillRect(-50, -50, 100, 100); + // Same quad rotated 36 deg around Y so the foreshortening is + // visible against the camera-tilted base. + Transform rotated = mvp.copy(); + rotated.rotate((float) (Math.PI / 5), 0, 1, 0); + g.setColor(0x0044aa); + g.setAlpha(160); + fillProjectedQuad(g, rotated, -50, -50, 100, 100); + g.setAlpha(255); + } - g.setTransform(Transform.makeIdentity()); + private static void fillProjectedQuad(Graphics g, Transform t, + int mx, int my, int mw, int mh) { + float[] tl = t.transformPoint(new float[]{mx, my, 0}); + float[] tr = t.transformPoint(new float[]{mx + mw, my, 0}); + float[] br = t.transformPoint(new float[]{mx + mw, my + mh, 0}); + float[] bl = t.transformPoint(new float[]{mx, my + mh, 0}); + int[] xs = new int[]{(int) tl[0], (int) tr[0], (int) br[0], (int) bl[0]}; + int[] ys = new int[]{(int) tl[1], (int) tr[1], (int) br[1], (int) bl[1]}; + g.fillPolygon(xs, ys, 4); } @Override diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/TransformPerspective.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/TransformPerspective.java index 4d5fa65239..2117fc7534 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/TransformPerspective.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/TransformPerspective.java @@ -9,40 +9,86 @@ public class TransformPerspective extends AbstractGraphicsScreenshotTest { @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(0xffffff); + g.fillRect(x, y, w, h); + g.setColor(0x000000); + g.drawRect(x, y, w - 1, h - 1); + + // The static Transform.isPerspectiveSupported() check (vs the + // per-graphics g.isPerspectiveTransformSupported() check) returns + // true on every platform that has a working Matrix.makePerspective + // implementation. This is the right gate when we project corners + // ourselves and draw a 2D polygon -- we don't need the per-graphics + // canvas/encoder to support perspective rasterization. if (!Transform.isPerspectiveSupported()) { - g.drawString("Perspective unsupported", bounds.getX(), bounds.getY()); + g.setColor(0xaa0000); + g.drawString("No perspective", x + 4, y + 4); + g.setColor(0x008800); + g.fillRect(x + w / 4, y + h / 4, w / 2, h / 2); return; } - float fovy = 45f; - float aspect = (float)bounds.getWidth() / bounds.getHeight(); - float zNear = 0.1f; + // Build Viewport * Perspective * Translate(model). Earlier the test + // passed the raw clip-space output of makePerspective to fillRect, + // which projected to a sub-pixel region. The first viewport-mapping + // attempt used g.setTransform(mvp) followed by fillRect, but that + // depends on the platform's draw path applying a 4x4 perspective + // matrix to rect rasterization -- Android Canvas converts to a 3x3 + // Skia matrix (drops the Z axis) and rect rasterization on the + // hardware canvas doesn't honour the perspective row reliably, and + // the iOS Metal mutable-image graphics flags isPerspectiveTransform + // Supported = false so the entire perspective branch was skipped. + // Project the 4 model corners via transformPoint (which does the + // homogeneous divide on every backend) and draw a 2D polygon, so + // the rendering is uniform across all 4 panes on every platform. + float fovy = (float) (Math.PI / 4); + float aspect = (float) w / (float) h; + float zNear = 1f; float zFar = 1000f; + float modelZ = -300f; // z position of the centred 100x100 model quad - // This sets the projection matrix - Transform projection = Transform.makePerspective(fovy, aspect, zNear, zFar); - - // Move the object back so it's visible - Transform modelView = Transform.makeTranslation(0, 0, -500); - - // Combine projection and modelview - projection.concatenate(modelView); + Transform mvp = Transform.makeIdentity(); + // Viewport: NDC (-1..1) -> cell pixels. Y is flipped because + // perspective NDC has +y up and screen has +y down. + mvp.translate(x + w * 0.5f, y + h * 0.5f); + mvp.scale(w * 0.5f, -h * 0.5f, 1f); + // Perspective projection. + Transform persp = Transform.makePerspective(fovy, aspect, zNear, zFar); + mvp.concatenate(persp); + // Push the quad into the frustum. + mvp.translate(0, 0, modelZ); - g.setTransform(projection); + // Solid green quad (centred, no rotation) -- foreshortened only by + // the perspective divide. + g.setColor(0x008800); + fillProjectedQuad(g, mvp, -50, -50, 100, 100); - g.setColor(0xff0000); - // Draw a rectangle centered at 0,0 (which should be center of screen due to perspective) - // Wait, perspective projection usually maps 0,0 to center if set up that way, - // but Codename One coordinate system is usually top-left 0,0. - // We probably need to adjust. - - // Let's draw something at the "bounds" location but projected. - // Since we are using makePerspective, it usually implies a camera at 0,0,0 looking down -Z (or similar depending on convention). - // Let's assume standard OpenGL-like behavior where camera is at origin. - - g.fillRect(-50, -50, 100, 100); + // Same quad rotated 36 deg around the Y axis. The left edge moves + // toward the camera (renders larger) and the right edge away + // (renders smaller), so the foreshortening is clearly visible vs + // the unrotated green base. + Transform rotated = mvp.copy(); + rotated.rotate((float) (Math.PI / 5), 0, 1, 0); + g.setColor(0x0000aa); + g.setAlpha(160); + fillProjectedQuad(g, rotated, -50, -50, 100, 100); + g.setAlpha(255); + } - g.setTransform(Transform.makeIdentity()); + private static void fillProjectedQuad(Graphics g, Transform t, + int mx, int my, int mw, int mh) { + float[] tl = t.transformPoint(new float[]{mx, my, 0}); + float[] tr = t.transformPoint(new float[]{mx + mw, my, 0}); + float[] br = t.transformPoint(new float[]{mx + mw, my + mh, 0}); + float[] bl = t.transformPoint(new float[]{mx, my + mh, 0}); + int[] xs = new int[]{(int) tl[0], (int) tr[0], (int) br[0], (int) bl[0]}; + int[] ys = new int[]{(int) tl[1], (int) tr[1], (int) br[1], (int) bl[1]}; + g.fillPolygon(xs, ys, 4); } @Override diff --git a/scripts/ios/screenshots-metal/chart-doughnut.png b/scripts/ios/screenshots-metal/chart-doughnut.png new file mode 100644 index 0000000000..441d8dfb47 Binary files /dev/null and b/scripts/ios/screenshots-metal/chart-doughnut.png differ diff --git a/scripts/ios/screenshots-metal/chart-pie.png b/scripts/ios/screenshots-metal/chart-pie.png new file mode 100644 index 0000000000..a20fe39e9f Binary files /dev/null and b/scripts/ios/screenshots-metal/chart-pie.png differ diff --git a/scripts/ios/screenshots-metal/chart-radar.png b/scripts/ios/screenshots-metal/chart-radar.png new file mode 100644 index 0000000000..19fdb0fedd Binary files /dev/null and b/scripts/ios/screenshots-metal/chart-radar.png differ diff --git a/scripts/ios/screenshots-metal/chart-rotated-pie.png b/scripts/ios/screenshots-metal/chart-rotated-pie.png new file mode 100644 index 0000000000..25ad04e0dc Binary files /dev/null and b/scripts/ios/screenshots-metal/chart-rotated-pie.png differ diff --git a/scripts/ios/screenshots-metal/graphics-transform-camera.png b/scripts/ios/screenshots-metal/graphics-transform-camera.png index 0a1a02f462..4f89c32cfd 100644 Binary files a/scripts/ios/screenshots-metal/graphics-transform-camera.png and b/scripts/ios/screenshots-metal/graphics-transform-camera.png differ diff --git a/scripts/ios/screenshots-metal/graphics-transform-perspective.png b/scripts/ios/screenshots-metal/graphics-transform-perspective.png index f83f02308b..d4c6c744e0 100644 Binary files a/scripts/ios/screenshots-metal/graphics-transform-perspective.png and b/scripts/ios/screenshots-metal/graphics-transform-perspective.png differ diff --git a/scripts/ios/screenshots/chart-doughnut.png b/scripts/ios/screenshots/chart-doughnut.png new file mode 100644 index 0000000000..abc74f0b31 Binary files /dev/null and b/scripts/ios/screenshots/chart-doughnut.png differ diff --git a/scripts/ios/screenshots/chart-pie.png b/scripts/ios/screenshots/chart-pie.png new file mode 100644 index 0000000000..2b35528ef3 Binary files /dev/null and b/scripts/ios/screenshots/chart-pie.png differ diff --git a/scripts/ios/screenshots/chart-radar.png b/scripts/ios/screenshots/chart-radar.png new file mode 100644 index 0000000000..ae4a54ae99 Binary files /dev/null and b/scripts/ios/screenshots/chart-radar.png differ diff --git a/scripts/ios/screenshots/chart-rotated-pie.png b/scripts/ios/screenshots/chart-rotated-pie.png new file mode 100644 index 0000000000..bebb998df8 Binary files /dev/null and b/scripts/ios/screenshots/chart-rotated-pie.png differ diff --git a/scripts/run-ios-ui-tests.sh b/scripts/run-ios-ui-tests.sh index 133cbaf937..941ce6db75 100755 --- a/scripts/run-ios-ui-tests.sh +++ b/scripts/run-ios-ui-tests.sh @@ -785,6 +785,21 @@ for test in "${TEST_NAMES[@]}"; do else ri_log "Primary decode failed for '$test'; trying fallback log" if [ -s "$FALLBACK_LOG" ] && source_label="$(cn1ss_decode_test_png "$test" "$dest" "SIMLOG:$FALLBACK_LOG")"; then + # Without these two lines, tests that needed the fallback log were + # decoded but not added to TEST_OUTPUT_ENTRIES, so the comparator + # silently skipped them -- iOS Metal compared 84 screenshots vs the + # 89 it had streams for, with 5 large transition tests + # (SlideHorizontal*, SlideVertical, SlideFadeTitle, CoverHorizontal) + # missing from the report because their ~288-chunk streams hit + # logcat-style line drops in device-runner.log but survived in the + # syslog fallback. + TEST_OUTPUT_ENTRIES+=("${test}${PAIR_SEP}${dest}") + preview_dest="$SCREENSHOT_PREVIEW_DIR/${test}.jpg" + if preview_source="$(cn1ss_decode_test_preview "$test" "$preview_dest" "SIMLOG:$FALLBACK_LOG")"; then + ri_log "Decoded preview for '$test' from fallback (source=${preview_source}, size: $(cn1ss_file_size "$preview_dest") bytes)" + else + rm -f "$preview_dest" 2>/dev/null || true + fi ri_log "Decoded screenshot for '$test' from fallback (size: $(cn1ss_file_size "$dest") bytes)" else ri_log "FATAL: Failed to extract/decode CN1SS payload for test '$test'"