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..6f5d38db9e 100644 --- a/CodenameOne/src/com/codename1/charts/compat/Canvas.java +++ b/CodenameOne/src/com/codename1/charts/compat/Canvas.java @@ -68,13 +68,34 @@ public void getClipBounds(Rectangle mRect) { } - private void applyPaint(Paint paint) { - applyPaint(paint, false); + private int applyPaint(Paint paint) { + return applyPaint(paint, false); } - private void applyPaint(Paint paint, boolean forText) { - //Log.p("Applyingn paint : "+paint); + /// Applies the chart-package Paint (color + alpha + optional typeface) + /// onto `g` and returns the alpha value that was on `g` before this call. + /// + /// The return value is critical: callers MUST pass it to `g.setAlpha(...)` + /// after each draw. `Graphics.concatenateAlpha(int)` is *multiplicative* + /// -- alpha = oldAlpha * newAlpha / 255 -- and the chart's grid-line color + /// is `argb(75, 200, 200, 200)`, so a single grid-line draw collapses + /// alpha to 75/255 ~ 29%. After three or four grid-line draws alpha + /// underflows to 0, and every later draw on the same Graphics is invisible + /// (paint-color alpha 255 short-circuits in concatenateAlpha so it can + /// never recover from 0). On iOS Metal+GL this manifested as XYChart + /// screenshots rendering as 45927-byte blank PNGs while RoundChart + /// (pie/doughnut/radar) -- whose paint chain runs through paths that hit + /// `g.setAlpha(...)` directly -- rendered fine. + /// + /// Restoring alpha to the entry value after each draw keeps each + /// `drawXxx` operation alpha-isolated, which is the contract Android's + /// real `Canvas` already enforces (its `Paint.alpha` is per-draw, not + /// accumulated). Other CN1 ports masked the cascade because their + /// renderers don't latch alpha across draws the way the iOS GL/Metal + /// op-queue does, but the bug was always present at the API level. + private int applyPaint(Paint paint, boolean forText) { + int oldAlpha = g.getAlpha(); g.setColor(paint.getColor()); int alpha = ColorUtil.alpha(paint.getColor()); g.concatenateAlpha(alpha); @@ -92,39 +113,44 @@ private void applyPaint(Paint paint, boolean forText) { g.setFont(null); } } - - + return oldAlpha; } public void drawRect(float left, float top, float right, float bottom, Paint paint) { - applyPaint(paint); - Paint.Style style = paint.getStyle(); - if (Paint.Style.FILL.equals(style)) { - //Log.p("Filling it"); - g.fillRect((int) left, (int) top, (int) right - (int) left, (int) bottom - (int) top); - } else if (Paint.Style.STROKE.equals(style)) { - g.drawRect((int) left, (int) top, (int) right - (int) left, (int) bottom - (int) top); - } else if (Paint.Style.FILL_AND_STROKE.equals(style)) { - g.fillRect((int) left, (int) top, (int) right - (int) left, (int) bottom - (int) top); - //g.drawRect((int)left+bounds.getX(), (int)top+bounds.getY(), (int)right-(int)left, (int)bottom-(int)top); + int oldAlpha = applyPaint(paint); + try { + Paint.Style style = paint.getStyle(); + if (Paint.Style.FILL.equals(style)) { + //Log.p("Filling it"); + g.fillRect((int) left, (int) top, (int) right - (int) left, (int) bottom - (int) top); + } else if (Paint.Style.STROKE.equals(style)) { + g.drawRect((int) left, (int) top, (int) right - (int) left, (int) bottom - (int) top); + } else if (Paint.Style.FILL_AND_STROKE.equals(style)) { + g.fillRect((int) left, (int) top, (int) right - (int) left, (int) bottom - (int) top); + //g.drawRect((int)left+bounds.getX(), (int)top+bounds.getY(), (int)right-(int)left, (int)bottom-(int)top); + } + } finally { + g.setAlpha(oldAlpha); } - - } public void drawText(String string, float x, float y, Paint paint) { - applyPaint(paint, true); - int offX = 0; - int offY = 0; - if (paint.getTextAlign() == Component.CENTER) { - offX = -g.getFont().stringWidth(string) / 2; - } else if (paint.getTextAlign() == Component.RIGHT) { - offX = -g.getFont().stringWidth(string); - } - int h = g.getFont().getAscent(); + int oldAlpha = applyPaint(paint, true); + try { + int offX = 0; + int offY = 0; + if (paint.getTextAlign() == Component.CENTER) { + offX = -g.getFont().stringWidth(string) / 2; + } else if (paint.getTextAlign() == Component.RIGHT) { + offX = -g.getFont().stringWidth(string); + } + int h = g.getFont().getAscent(); - g.drawString(string, (int) x + offX, (int) y - h + offY); + g.drawString(string, (int) x + offX, (int) y - h + offY); + } finally { + g.setAlpha(oldAlpha); + } } public int getHeight() { @@ -154,32 +180,41 @@ private Stroke getStroke(Paint paint) { public void drawPath(Shape p, Paint paint) { - - applyPaint(paint); - Paint.Style style = paint.getStyle(); - if (style.equals(Paint.Style.FILL)) { - g.fillShape(p); - //g.drawShape(p, getStroke(paint)); - } else if (style.equals(Paint.Style.STROKE)) { - g.drawShape(p, getStroke(paint)); - } else if (style.equals(Paint.Style.FILL_AND_STROKE)) { - g.fillShape(p); - g.drawShape(p, getStroke(paint)); + int oldAlpha = applyPaint(paint); + try { + Paint.Style style = paint.getStyle(); + if (style.equals(Paint.Style.FILL)) { + g.fillShape(p); + //g.drawShape(p, getStroke(paint)); + } else if (style.equals(Paint.Style.STROKE)) { + g.drawShape(p, getStroke(paint)); + } else if (style.equals(Paint.Style.FILL_AND_STROKE)) { + g.fillShape(p); + g.drawShape(p, getStroke(paint)); + } + } finally { + g.setAlpha(oldAlpha); } - } public void drawLine(float x1, float y1, float x2, float y2, Paint paint) { - applyPaint(paint); - g.drawLine((int) x1, (int) y1, (int) x2, (int) y2); + int oldAlpha = applyPaint(paint); + try { + g.drawLine((int) x1, (int) y1, (int) x2, (int) y2); + } finally { + g.setAlpha(oldAlpha); + } } 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) { @@ -203,20 +238,23 @@ public void drawCircle(float cx, float cy, float r, Paint paint) { public void drawArc(Rectangle2D oval, float currentAngle, float sweepAngle, boolean useCenter, Paint paint) { - applyPaint(paint); - Paint.Style style = paint.getStyle(); - if (Paint.Style.FILL.equals(style)) { - g.fillArc((int) Math.round(oval.getX()), (int) Math.round(oval.getY()), (int) Math.round(oval.getWidth()), (int) Math.round(oval.getHeight()), -(int) Math.floor(currentAngle), -(int) Math.ceil(sweepAngle)); + int oldAlpha = applyPaint(paint); + try { + Paint.Style style = paint.getStyle(); + if (Paint.Style.FILL.equals(style)) { + g.fillArc((int) Math.round(oval.getX()), (int) Math.round(oval.getY()), (int) Math.round(oval.getWidth()), (int) Math.round(oval.getHeight()), -(int) Math.floor(currentAngle), -(int) Math.ceil(sweepAngle)); - } else if (Paint.Style.STROKE.equals(style)) { - g.drawArc((int) Math.round(oval.getX()), (int) Math.round(oval.getY()), (int) Math.round(oval.getWidth()), (int) Math.round(oval.getHeight()), -(int) Math.floor(currentAngle), -(int) Math.ceil(sweepAngle)); + } else if (Paint.Style.STROKE.equals(style)) { + g.drawArc((int) Math.round(oval.getX()), (int) Math.round(oval.getY()), (int) Math.round(oval.getWidth()), (int) Math.round(oval.getHeight()), -(int) Math.floor(currentAngle), -(int) Math.ceil(sweepAngle)); - } else if (Paint.Style.FILL_AND_STROKE.equals(style)) { - g.fillArc((int) Math.round(oval.getX()), (int) Math.round(oval.getY()), (int) Math.round(oval.getWidth()), (int) Math.round(oval.getHeight()), -(int) Math.floor(currentAngle), -(int) Math.ceil(sweepAngle)); - g.drawArc((int) Math.round(oval.getX()), (int) Math.round(oval.getY()), (int) Math.round(oval.getWidth()), (int) Math.round(oval.getHeight()), -(int) Math.floor(currentAngle), -(int) Math.ceil(sweepAngle)); + } else if (Paint.Style.FILL_AND_STROKE.equals(style)) { + g.fillArc((int) Math.round(oval.getX()), (int) Math.round(oval.getY()), (int) Math.round(oval.getWidth()), (int) Math.round(oval.getHeight()), -(int) Math.floor(currentAngle), -(int) Math.ceil(sweepAngle)); + g.drawArc((int) Math.round(oval.getX()), (int) Math.round(oval.getY()), (int) Math.round(oval.getWidth()), (int) Math.round(oval.getHeight()), -(int) Math.floor(currentAngle), -(int) Math.ceil(sweepAngle)); + } + } finally { + g.setAlpha(oldAlpha); } - } public void drawArcWithGradient(Rectangle2D oval, float currentAngle, float sweepAngle, boolean useCenter, Paint paint, GradientDrawable gradient) { @@ -228,19 +266,21 @@ public void drawPoint(Float get, Float get0, Paint paint) { } public void drawRoundRect(Rectangle2D rect, float rx, float ry, Paint mPaint) { - applyPaint(mPaint); - Paint.Style style = mPaint.getStyle(); - if (Paint.Style.FILL.equals(style)) { - g.fillRoundRect((int) rect.getX(), (int) rect.getY(), (int) (rect.getWidth()), (int) (rect.getHeight()), (int) rx, (int) ry); - } else if (Paint.Style.STROKE.equals(style)) { + int oldAlpha = applyPaint(mPaint); + try { + Paint.Style style = mPaint.getStyle(); + if (Paint.Style.FILL.equals(style)) { + g.fillRoundRect((int) rect.getX(), (int) rect.getY(), (int) (rect.getWidth()), (int) (rect.getHeight()), (int) rx, (int) ry); + } else if (Paint.Style.STROKE.equals(style)) { + g.drawRoundRect((int) rect.getX(), (int) rect.getY(), (int) (rect.getWidth()), (int) (rect.getHeight()), (int) rx, (int) ry); + } else if (Paint.Style.FILL_AND_STROKE.equals(style)) { + g.fillRoundRect((int) rect.getX(), (int) rect.getY(), (int) (rect.getWidth()), (int) (rect.getHeight()), (int) rx, (int) ry); + g.drawRoundRect((int) rect.getX(), (int) rect.getY(), getWidth(), (int) (rect.getHeight()), (int) rx, (int) ry); + } g.drawRoundRect((int) rect.getX(), (int) rect.getY(), (int) (rect.getWidth()), (int) (rect.getHeight()), (int) rx, (int) ry); - } else if (Paint.Style.FILL_AND_STROKE.equals(style)) { - g.fillRoundRect((int) rect.getX(), (int) rect.getY(), (int) (rect.getWidth()), (int) (rect.getHeight()), (int) rx, (int) ry); - g.drawRoundRect((int) rect.getX(), (int) rect.getY(), getWidth(), (int) (rect.getHeight()), (int) rx, (int) ry); + } finally { + g.setAlpha(oldAlpha); } - g.drawRoundRect((int) rect.getX(), (int) rect.getY(), (int) (rect.getWidth()), (int) (rect.getHeight()), (int) rx, (int) ry); - - } public void drawBitmap(Image img, float left, float top, Paint paint) { 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..eae5a0fe01 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -1517,7 +1517,7 @@ public boolean isTranslationSupported() { return false; } - /// Translates the X/Y location for drawing on the underlying surface. Translation +/// 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 /// `translate(-getTranslateX(), -getTranslateY())` diff --git a/CodenameOne/src/com/codename1/ui/Graphics.java b/CodenameOne/src/com/codename1/ui/Graphics.java index 6f24824d71..4c5f6c65fe 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 has + /// `isTranslationSupported() == false` (every active port today: iOS, + /// Android, JavaSE, JavaScript), 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,16 @@ 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) { + Transform composed = Transform.makeTranslation(xTranslate, yTranslate); + composed.concatenate(userTransform); + composed.translate(-xTranslate, -yTranslate); + impl.setTransform(nativeGraphics, composed); + } } } @@ -1129,6 +1147,9 @@ public void transform(Transform transform) { /// /// - #setTransform public Transform getTransform() { + if (userTransform != null) { + return userTransform.copy(); + } return impl.getTransform(nativeGraphics); } @@ -1160,7 +1181,27 @@ 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 Android Skia / JavaSE Graphics2D semantics. + if (transform != null && !transform.isIdentity() + && (xTranslate != 0 || yTranslate != 0)) { + 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 +1210,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 +1621,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/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index b274648770..b644fafd04 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -9257,7 +9257,7 @@ public void drawShape(Object graphics, com.codename1.ui.geom.Shape shape, com.co public boolean isTransformSupported(){ 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/webapp/port.js b/Ports/JavaScriptPort/src/main/webapp/port.js index 02a398cec0..7efdcdb4f1 100644 --- a/Ports/JavaScriptPort/src/main/webapp/port.js +++ b/Ports/JavaScriptPort/src/main/webapp/port.js @@ -3818,19 +3818,38 @@ function emitCn1ssChunks(base64, testName, channelName) { const prefix = "CN1SS" + channel; const test = normalizeCn1ssTestName(testName); const streamKey = channel + "|" + test; - let nextIndex = cn1ssChunkIndexByStream[streamKey] || 0; + // The chunk index must be the byte offset within the emitted base64 + // stream, not a sequential counter. Cn1ssChunkTools (and the iOS / Android + // / JavaSE Cn1ssDeviceRunnerHelper.emitChannel emitters) read the index + // back as an offset to verify the reassembled stream covers + // [0, total_b64_len) with no gaps or overlaps. The previous sequential + // counter made every JS chunk look like it overlapped its predecessor by + // chunkSize - 1 bytes at offset 1, so the consumer rejected every JS + // screenshot as "incomplete chunk stream" and the JS pipeline silently + // dropped every chart / graphics test. cn1ssChunkIndexByStream still + // tracks the running byte offset so callers that emit a single test in + // multiple emitCn1ssChunks(...) bursts produce a contiguous stream. + const startOffset = cn1ssChunkIndexByStream[streamKey] || 0; const chunkSize = 8000; for (let offset = 0; offset < base64.length; offset += chunkSize) { const payload = base64.substring(offset, offset + chunkSize); - const index = String(nextIndex++).padStart(6, "0"); + const index = String(startOffset + offset).padStart(6, "0"); emitDiagLine(prefix + ":" + test + ":" + index + ":" + payload); } - cn1ssChunkIndexByStream[streamKey] = nextIndex; + cn1ssChunkIndexByStream[streamKey] = startOffset + base64.length; if (base64.length === 0) { - const index = String(nextIndex).padStart(6, "0"); + const index = String(startOffset).padStart(6, "0"); emitDiagLine(prefix + ":" + test + ":" + index + ":"); - cn1ssChunkIndexByStream[streamKey] = nextIndex + 1; } + // Emit the Java-side end-of-channel summary so Cn1ssChunkTools' + // readTotalBase64Length() can verify the reassembled length matches what + // the emitter advertised. Without this the integrity check is best-effort + // (we still detect gaps/overlaps via the offset walk) but a stream that + // truncates after its last chunk would only be caught by the PNG trailer + // check downstream, which is platform-specific. + emitDiagLine("CN1SS:INFO:test=" + test + + " chunks=" + Math.max(1, Math.ceil(base64.length / chunkSize)) + + " total_b64_len=" + base64.length); // Emit END marker matching the Java emitChannel convention so the // downstream cn1ss_list_tests / cn1ss_decode helpers can detect the stream. emitDiagLine(prefix + ":END:" + test); diff --git a/Ports/iOSPort/nativeSources/CN1Metalcompat.m b/Ports/iOSPort/nativeSources/CN1Metalcompat.m index 446937ddf3..7e79569fa9 100644 --- a/Ports/iOSPort/nativeSources/CN1Metalcompat.m +++ b/Ports/iOSPort/nativeSources/CN1Metalcompat.m @@ -275,9 +275,13 @@ static void drawQuad(CN1MetalPipeline pipeline, const float *texcoords, // may be NULL simd_float4 color, id texture) { - if (activeEncoder == nil || pipelineCache == nil) return; + if (activeEncoder == nil || pipelineCache == nil) { + return; + } id state = [pipelineCache pipelineFor:pipeline]; - if (state == nil) return; + if (state == nil) { + return; + } bindPipelineStateIfChanged(state); // buffer(0): positions (8 floats = 4 x (x,y)) @@ -744,15 +748,21 @@ void CN1MetalDrawGradient(int type, int startColor, int endColor, // --------------- Alpha mask rendering (path-based shapes) --------------- id CN1MetalCreateAlphaMaskTexture(const uint8_t *bytes, int width, int height) { - if (bytes == NULL || width <= 0 || height <= 0) return nil; + if (bytes == NULL || width <= 0 || height <= 0) { + return nil; + } id device = CN1MetalDevice(); - if (device == nil) return nil; + if (device == nil) { + return nil; + } MTLTextureDescriptor *desc = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatR8Unorm width:width height:height mipmapped:NO]; desc.usage = MTLTextureUsageShaderRead; id tex = [device newTextureWithDescriptor:desc]; - if (tex == nil) return nil; + if (tex == nil) { + return nil; + } [tex replaceRegion:MTLRegionMake2D(0, 0, width, height) mipmapLevel:0 withBytes:bytes @@ -762,7 +772,9 @@ void CN1MetalDrawGradient(int type, int startColor, int endColor, void CN1MetalDrawAlphaMask(id texture, int color, int alpha, int x, int y, int width, int height) { - if (texture == nil) return; + if (texture == nil) { + return; + } // The AlphaMask fragment shader (cn1_fs_alpha_mask) does: // float a = sample(tex).r; // return float4(color.rgb * a, color.a * a); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 5d46605911..84b2235f6a 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -1558,7 +1558,7 @@ public void setColor(Object graphics, int RGB) { ((NativeGraphics)graphics).color = RGB; } - public void setAlpha(Object graphics, int alpha) { + public void setAlpha(Object graphics, int alpha) { ((NativeGraphics)graphics).alpha = alpha; } @@ -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,12 @@ 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; } - - - 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..765b46e632 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..21167ec1e1 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..2443f65c66 Binary files /dev/null and b/scripts/android/screenshots/chart-bubble.png differ diff --git a/scripts/android/screenshots/chart-combined-xy.png b/scripts/android/screenshots/chart-combined-xy.png new file mode 100644 index 0000000000..edeb1fdcf1 Binary files /dev/null and b/scripts/android/screenshots/chart-combined-xy.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..b50cbce4c7 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..e8c26ac68d 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..3cbd24da6c Binary files /dev/null and b/scripts/android/screenshots/chart-range-bar.png differ diff --git a/scripts/android/screenshots/chart-rotated-pie.png b/scripts/android/screenshots/chart-rotated-pie.png new file mode 100644 index 0000000000..ee8a6e3cb0 Binary files /dev/null and b/scripts/android/screenshots/chart-rotated-pie.png differ diff --git a/scripts/android/screenshots/chart-scatter.png b/scripts/android/screenshots/chart-scatter.png new file mode 100644 index 0000000000..b241e2a394 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..13e639ee52 Binary files /dev/null and b/scripts/android/screenshots/chart-time.png differ diff --git a/scripts/android/screenshots/chart-transform.png b/scripts/android/screenshots/chart-transform.png new file mode 100644 index 0000000000..dc8791474b Binary files /dev/null and b/scripts/android/screenshots/chart-transform.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-large-stroke-dirty-clip.png b/scripts/android/screenshots/graphics-large-stroke-dirty-clip.png new file mode 100644 index 0000000000..c66d013294 Binary files /dev/null and b/scripts/android/screenshots/graphics-large-stroke-dirty-clip.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..a62561c108 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,21 @@ 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.graphics.LargeStrokeDirtyClipTest; +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 +134,39 @@ private static int testTimeoutMs() { new TransformRotation(), new TransformPerspective(), new TransformCamera(), + // Standalone repro for the iOS form-Graphics dirty-region + // clipping edge case that makes the XY chart screenshot tests + // come back blank: a single Component in BorderLayout.CENTER + // whose paint() draws a large stroked GeneralPath via + // g.drawShape(...). If iOS captures a non-blank PNG with the + // polyline visible the bug is specific to ChartComponent's + // paint cycle; if it captures a blank PNG we have a minimal + // reproduction the iOS-port fix can iterate against without + // spinning up the entire chart-package. + new LargeStrokeDirtyClipTest(), + // 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 +383,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/LargeStrokeDirtyClipTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/LargeStrokeDirtyClipTest.java new file mode 100644 index 0000000000..82d473e4d5 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/LargeStrokeDirtyClipTest.java @@ -0,0 +1,139 @@ +package com.codenameone.examples.hellocodenameone.tests.graphics; + +import com.codename1.ui.Component; +import com.codename1.ui.Form; +import com.codename1.ui.Graphics; +import com.codename1.ui.Stroke; +import com.codename1.ui.geom.GeneralPath; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.hellocodenameone.tests.BaseTest; + +/// Regression test for the chart Canvas alpha-leak fix (commit 4e3f8b47b). +/// Draws the chart-line dataset's two stroked polylines + four opaque-black +/// margin fillRects (mirroring XYChart.draw's drawSeries + drawBackground +/// margin-mask sequence) using raw `g.drawShape` / `g.fillRect` instead of +/// the chart-package compat Canvas. Sized by `getWidth()` / `getHeight()` +/// so it captures the same chart-line geometry on every pipeline (iOS +/// 1179x2556, Android emulator 320x640, JS, JavaSE) and isn't sensitive +/// to platform resolution. +public class LargeStrokeDirtyClipTest extends BaseTest { + + @Override + public boolean runTest() throws Exception { + Form form = createForm(screenshotName(), new BorderLayout(), screenshotName()); + // Single component in BorderLayout.CENTER -- mirrors the chart-line + // scenario exactly: form is shown, the painter draws once during + // the slide-in transition, and after that nothing else queues a + // repaint. No UITimer-driven heartbeat. If iOS captures a blank + // PNG from this minimal setup we've reproduced the chart-line + // failure without the chart-package; if it captures the polyline, + // ChartComponent is doing something specific that other Component + // subclasses don't. + form.add(BorderLayout.CENTER, new LargeStrokeComponent()); + form.show(); + return true; + } + + protected String screenshotName() { + return "graphics-large-stroke-dirty-clip"; + } + + private static final class LargeStrokeComponent extends Component { + + @Override + public void paint(Graphics g) { + super.paint(g); + if (!g.isShapeSupported()) { + return; + } + // Mirror ChartComponent.paint's prologue: stash and force AA on. + boolean oldAA = g.isAntiAliased(); + g.setAntiAliased(true); + + // Use relative coords so the test renders correctly across every + // platform pipeline: iOS at 1179x2556 native, Android emulator at + // 320x640, JS at desktop dimensions, JavaSE at simulator. The + // earlier hard-coded iPhone coords drew off-screen on Android. + int x = getX(); + int y = getY(); + int viewW = getWidth(); + int viewH = getHeight(); + float marginTop = viewH * 0.014f; // matches chart-line frame proportions on iPhone + float marginBottom = viewH * 0.009f; + float marginLeft = viewW * 0.051f; + float marginRight = viewW * 0.020f; + float dataLeft = x + marginLeft; + float dataTop = y + marginTop; + float dataRight = x + viewW - marginRight; + float dataBottom = y + viewH - marginBottom; + float dataW = dataRight - dataLeft; + float dataH = dataBottom - dataTop; + // chart-line dataset: north (12,16,22,18,28), south (8,11,13,16,19). + // Mapping the 5 (x,y) pairs into [dataLeft..dataRight] x + // [dataBottom..dataTop] reproduces the non-monotonic Y path that + // the iOS Stroker / alpha-mask path receives from chart-line, + // independent of native screen resolution. + float xStep = dataW / 4f; + float yScale = dataH / 20f; + float baseline = dataBottom; + + GeneralPath p1 = new GeneralPath(); + p1.moveTo(dataLeft, baseline - (12 - 8) * yScale); + p1.lineTo(dataLeft + xStep, baseline - (16 - 8) * yScale); + p1.lineTo(dataLeft + 2 * xStep, baseline - (22 - 8) * yScale); + p1.lineTo(dataLeft + 3 * xStep, baseline - (18 - 8) * yScale); + p1.lineTo(dataLeft + 4 * xStep, baseline - (28 - 8) * yScale); + + GeneralPath p2 = new GeneralPath(); + p2.moveTo(dataLeft, baseline - (8 - 8) * yScale); + p2.lineTo(dataLeft + xStep, baseline - (11 - 8) * yScale); + p2.lineTo(dataLeft + 2 * xStep, baseline - (13 - 8) * yScale); + p2.lineTo(dataLeft + 3 * xStep, baseline - (16 - 8) * yScale); + p2.lineTo(dataLeft + 4 * xStep, baseline - (19 - 8) * yScale); + + // Same setColor + concatenateAlpha + drawShape pattern that + // Canvas.applyPaint / canvas.drawPath uses for chart-line's + // polylines, with the chart's BEVEL join and CAP_BUTT cap. + int color1 = 0xff0a66ff; + g.setColor(color1); + int alpha1 = (color1 >>> 24) & 0xff; + if (alpha1 == 0) { + alpha1 = 255; + } + g.concatenateAlpha(alpha1); + g.drawShape(p1, new Stroke(3f, Stroke.CAP_BUTT, Stroke.JOIN_BEVEL, 1f)); + + int color2 = 0xffee4a4a; + g.setColor(color2); + int alpha2 = (color2 >>> 24) & 0xff; + if (alpha2 == 0) { + alpha2 = 255; + } + g.concatenateAlpha(alpha2); + g.drawShape(p2, new Stroke(3f, Stroke.CAP_BUTT, Stroke.JOIN_BEVEL, 1f)); + + // Mirror XYChart.draw's 4 unconditional margin fillRects with + // marginsColor == NO_COLOR (0). ColorUtil.IColor(0) maps alpha 0 + // -> 255 (chart-package historical "0 means opaque" rule), so + // applyPaint emits setColor(0) + concatenateAlpha(255) + + // fillRect, i.e. four opaque-black strips around the data area. + g.setColor(0); + int marginAlpha = 0; + if (marginAlpha == 0) { + marginAlpha = 255; + } + g.concatenateAlpha(marginAlpha); + // bottom strip (under data area) + g.fillRect(x, (int) dataBottom, viewW, viewH - (int) (dataBottom - y)); + // top strip + g.fillRect(x, y, viewW, (int) marginTop); + // left strip (HORIZONTAL orientation default) + g.fillRect(x, y, (int) (dataLeft - x), viewH); + // right strip + g.fillRect((int) dataRight, y, (int) marginRight, viewH); + + g.setAntiAliased(oldAA); + } + } + +} 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-bar-stacked.png b/scripts/ios/screenshots-metal/chart-bar-stacked.png new file mode 100644 index 0000000000..8d555370b8 Binary files /dev/null and b/scripts/ios/screenshots-metal/chart-bar-stacked.png differ diff --git a/scripts/ios/screenshots-metal/chart-bar.png b/scripts/ios/screenshots-metal/chart-bar.png new file mode 100644 index 0000000000..f08f90290f Binary files /dev/null and b/scripts/ios/screenshots-metal/chart-bar.png differ diff --git a/scripts/ios/screenshots-metal/chart-bubble.png b/scripts/ios/screenshots-metal/chart-bubble.png new file mode 100644 index 0000000000..e845bdbcea Binary files /dev/null and b/scripts/ios/screenshots-metal/chart-bubble.png differ diff --git a/scripts/ios/screenshots-metal/chart-combined-xy.png b/scripts/ios/screenshots-metal/chart-combined-xy.png new file mode 100644 index 0000000000..6e8f556c00 Binary files /dev/null and b/scripts/ios/screenshots-metal/chart-combined-xy.png differ diff --git a/scripts/ios/screenshots-metal/chart-cubic-line.png b/scripts/ios/screenshots-metal/chart-cubic-line.png new file mode 100644 index 0000000000..3503f6d8a3 Binary files /dev/null and b/scripts/ios/screenshots-metal/chart-cubic-line.png differ 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-line.png b/scripts/ios/screenshots-metal/chart-line.png new file mode 100644 index 0000000000..74ad9e378d Binary files /dev/null and b/scripts/ios/screenshots-metal/chart-line.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-range-bar.png b/scripts/ios/screenshots-metal/chart-range-bar.png new file mode 100644 index 0000000000..0c7d75c9ce Binary files /dev/null and b/scripts/ios/screenshots-metal/chart-range-bar.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/chart-scatter.png b/scripts/ios/screenshots-metal/chart-scatter.png new file mode 100644 index 0000000000..3ec2731e63 Binary files /dev/null and b/scripts/ios/screenshots-metal/chart-scatter.png differ diff --git a/scripts/ios/screenshots-metal/chart-time.png b/scripts/ios/screenshots-metal/chart-time.png new file mode 100644 index 0000000000..d4fa21afd7 Binary files /dev/null and b/scripts/ios/screenshots-metal/chart-time.png differ diff --git a/scripts/ios/screenshots-metal/chart-transform.png b/scripts/ios/screenshots-metal/chart-transform.png new file mode 100644 index 0000000000..5c12e5558e Binary files /dev/null and b/scripts/ios/screenshots-metal/chart-transform.png differ diff --git a/scripts/ios/screenshots-metal/graphics-affine-scale.png b/scripts/ios/screenshots-metal/graphics-affine-scale.png index 049689f9e3..3fae5f0a62 100644 Binary files a/scripts/ios/screenshots-metal/graphics-affine-scale.png and b/scripts/ios/screenshots-metal/graphics-affine-scale.png differ diff --git a/scripts/ios/screenshots-metal/graphics-large-stroke-dirty-clip.png b/scripts/ios/screenshots-metal/graphics-large-stroke-dirty-clip.png new file mode 100644 index 0000000000..2d61261cec Binary files /dev/null and b/scripts/ios/screenshots-metal/graphics-large-stroke-dirty-clip.png differ diff --git a/scripts/ios/screenshots-metal/graphics-scale.png b/scripts/ios/screenshots-metal/graphics-scale.png index d3031cd421..a270cd19d9 100644 Binary files a/scripts/ios/screenshots-metal/graphics-scale.png and b/scripts/ios/screenshots-metal/graphics-scale.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-bar-stacked.png b/scripts/ios/screenshots/chart-bar-stacked.png new file mode 100644 index 0000000000..018aaa560f Binary files /dev/null and b/scripts/ios/screenshots/chart-bar-stacked.png differ diff --git a/scripts/ios/screenshots/chart-bar.png b/scripts/ios/screenshots/chart-bar.png new file mode 100644 index 0000000000..250400220b Binary files /dev/null and b/scripts/ios/screenshots/chart-bar.png differ diff --git a/scripts/ios/screenshots/chart-bubble.png b/scripts/ios/screenshots/chart-bubble.png new file mode 100644 index 0000000000..caf5396bef Binary files /dev/null and b/scripts/ios/screenshots/chart-bubble.png differ diff --git a/scripts/ios/screenshots/chart-combined-xy.png b/scripts/ios/screenshots/chart-combined-xy.png new file mode 100644 index 0000000000..ae72e584cf Binary files /dev/null and b/scripts/ios/screenshots/chart-combined-xy.png differ diff --git a/scripts/ios/screenshots/chart-cubic-line.png b/scripts/ios/screenshots/chart-cubic-line.png new file mode 100644 index 0000000000..8c3cf2d1f2 Binary files /dev/null and b/scripts/ios/screenshots/chart-cubic-line.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-line.png b/scripts/ios/screenshots/chart-line.png new file mode 100644 index 0000000000..65d031d467 Binary files /dev/null and b/scripts/ios/screenshots/chart-line.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-range-bar.png b/scripts/ios/screenshots/chart-range-bar.png new file mode 100644 index 0000000000..8a42a4959c Binary files /dev/null and b/scripts/ios/screenshots/chart-range-bar.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/ios/screenshots/chart-scatter.png b/scripts/ios/screenshots/chart-scatter.png new file mode 100644 index 0000000000..8eff1889a8 Binary files /dev/null and b/scripts/ios/screenshots/chart-scatter.png differ diff --git a/scripts/ios/screenshots/chart-time.png b/scripts/ios/screenshots/chart-time.png new file mode 100644 index 0000000000..1a87f1849a Binary files /dev/null and b/scripts/ios/screenshots/chart-time.png differ diff --git a/scripts/ios/screenshots/chart-transform.png b/scripts/ios/screenshots/chart-transform.png new file mode 100644 index 0000000000..6b8a3254cf Binary files /dev/null and b/scripts/ios/screenshots/chart-transform.png differ diff --git a/scripts/ios/screenshots/graphics-affine-scale.png b/scripts/ios/screenshots/graphics-affine-scale.png index 9b2c5a9d0a..06d434a117 100644 Binary files a/scripts/ios/screenshots/graphics-affine-scale.png and b/scripts/ios/screenshots/graphics-affine-scale.png differ diff --git a/scripts/ios/screenshots/graphics-large-stroke-dirty-clip.png b/scripts/ios/screenshots/graphics-large-stroke-dirty-clip.png new file mode 100644 index 0000000000..cb932e063f Binary files /dev/null and b/scripts/ios/screenshots/graphics-large-stroke-dirty-clip.png differ diff --git a/scripts/ios/screenshots/graphics-scale.png b/scripts/ios/screenshots/graphics-scale.png index 5d63b57345..e8349303ee 100644 Binary files a/scripts/ios/screenshots/graphics-scale.png and b/scripts/ios/screenshots/graphics-scale.png differ diff --git a/scripts/ios/screenshots/graphics-transform-camera.png b/scripts/ios/screenshots/graphics-transform-camera.png index f94ab27692..fa2154ca38 100644 Binary files a/scripts/ios/screenshots/graphics-transform-camera.png and b/scripts/ios/screenshots/graphics-transform-camera.png differ diff --git a/scripts/ios/screenshots/graphics-transform-perspective.png b/scripts/ios/screenshots/graphics-transform-perspective.png index 0c7e563059..c6a2e1314a 100644 Binary files a/scripts/ios/screenshots/graphics-transform-perspective.png and b/scripts/ios/screenshots/graphics-transform-perspective.png differ diff --git a/scripts/javascript/screenshots/chart-bar-stacked.png b/scripts/javascript/screenshots/chart-bar-stacked.png new file mode 100644 index 0000000000..12e42007a4 Binary files /dev/null and b/scripts/javascript/screenshots/chart-bar-stacked.png differ diff --git a/scripts/javascript/screenshots/chart-bar.png b/scripts/javascript/screenshots/chart-bar.png new file mode 100644 index 0000000000..6481990bc6 Binary files /dev/null and b/scripts/javascript/screenshots/chart-bar.png differ diff --git a/scripts/javascript/screenshots/chart-bubble.png b/scripts/javascript/screenshots/chart-bubble.png new file mode 100644 index 0000000000..49a1405b73 Binary files /dev/null and b/scripts/javascript/screenshots/chart-bubble.png differ diff --git a/scripts/javascript/screenshots/chart-combined-xy.png b/scripts/javascript/screenshots/chart-combined-xy.png new file mode 100644 index 0000000000..ac76a99b89 Binary files /dev/null and b/scripts/javascript/screenshots/chart-combined-xy.png differ diff --git a/scripts/javascript/screenshots/chart-cubic-line.png b/scripts/javascript/screenshots/chart-cubic-line.png new file mode 100644 index 0000000000..7ecf0dfcb0 Binary files /dev/null and b/scripts/javascript/screenshots/chart-cubic-line.png differ diff --git a/scripts/javascript/screenshots/chart-doughnut.png b/scripts/javascript/screenshots/chart-doughnut.png new file mode 100644 index 0000000000..3f9ae77aff Binary files /dev/null and b/scripts/javascript/screenshots/chart-doughnut.png differ diff --git a/scripts/javascript/screenshots/chart-line.png b/scripts/javascript/screenshots/chart-line.png new file mode 100644 index 0000000000..dfd4eb4a4e Binary files /dev/null and b/scripts/javascript/screenshots/chart-line.png differ diff --git a/scripts/javascript/screenshots/chart-pie.png b/scripts/javascript/screenshots/chart-pie.png new file mode 100644 index 0000000000..0cdde22c9f Binary files /dev/null and b/scripts/javascript/screenshots/chart-pie.png differ diff --git a/scripts/javascript/screenshots/chart-radar.png b/scripts/javascript/screenshots/chart-radar.png new file mode 100644 index 0000000000..bd41657187 Binary files /dev/null and b/scripts/javascript/screenshots/chart-radar.png differ diff --git a/scripts/javascript/screenshots/chart-range-bar.png b/scripts/javascript/screenshots/chart-range-bar.png new file mode 100644 index 0000000000..04b8dfdc5b Binary files /dev/null and b/scripts/javascript/screenshots/chart-range-bar.png differ diff --git a/scripts/javascript/screenshots/chart-rotated-pie.png b/scripts/javascript/screenshots/chart-rotated-pie.png new file mode 100644 index 0000000000..e19d8e7ca0 Binary files /dev/null and b/scripts/javascript/screenshots/chart-rotated-pie.png differ diff --git a/scripts/javascript/screenshots/chart-scatter.png b/scripts/javascript/screenshots/chart-scatter.png new file mode 100644 index 0000000000..41abb16a8e Binary files /dev/null and b/scripts/javascript/screenshots/chart-scatter.png differ diff --git a/scripts/javascript/screenshots/chart-time.png b/scripts/javascript/screenshots/chart-time.png new file mode 100644 index 0000000000..56128fc93c Binary files /dev/null and b/scripts/javascript/screenshots/chart-time.png differ diff --git a/scripts/javascript/screenshots/chart-transform.png b/scripts/javascript/screenshots/chart-transform.png new file mode 100644 index 0000000000..091698ac3b Binary files /dev/null and b/scripts/javascript/screenshots/chart-transform.png differ diff --git a/scripts/javascript/screenshots/graphics-large-stroke-dirty-clip.png b/scripts/javascript/screenshots/graphics-large-stroke-dirty-clip.png new file mode 100644 index 0000000000..b887bf316a Binary files /dev/null and b/scripts/javascript/screenshots/graphics-large-stroke-dirty-clip.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'"