diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 1190843222..7b6f8ad2f2 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -1517,6 +1517,22 @@ public boolean isTranslationSupported() { return false; } + /// When `#isTranslationSupported()` returns false, Graphics.java accumulates + /// xTranslate/yTranslate locally and bakes them into the vertex coordinates + /// passed to the impl's fill primitives. If the impl's render path then + /// applies the user's setTransform matrix on top of those already-translated + /// vertices (e.g. iOS Metal's GPU vertex shader does + /// `projection * modelView * userTransform * pos`), the translation is + /// double-counted for any non-translation matrix and the output ends up + /// off-screen. Override this and return true so Graphics.setTransform + /// conjugates the user's matrix with T(xTranslate, yTranslate) before + /// passing it to the impl, restoring translate-independent semantics. + /// Default: false (Android's setTransform path produces visible output + /// without the conjugation, so opting in there would change pixels). + public boolean isSetTransformTranslationConjugationRequired() { + return false; + } + /// Translates the X/Y location for drawing on the underlying surface. Translation /// is incremental so the new value will be added to the current translation and /// in order to reset translation we have to invoke diff --git a/CodenameOne/src/com/codename1/ui/Graphics.java b/CodenameOne/src/com/codename1/ui/Graphics.java index 6f24824d71..d95b7cee38 100644 --- a/CodenameOne/src/com/codename1/ui/Graphics.java +++ b/CodenameOne/src/com/codename1/ui/Graphics.java @@ -59,6 +59,12 @@ public final class Graphics { private int xTranslate; private int yTranslate; private Transform translation; + /// User's last setTransform() argument (or null if identity). On platforms + /// where impl.isTranslationSupported()=false the impl actually stores a + /// translation-conjugated version of this matrix so the user-visible + /// transform is independent of g.translate(); getTransform() must therefore + /// return the original. + private Transform userTransform; private GeneralPath tmpClipShape; /// A buffer shape to use when we need to transform a shape private int color; @@ -137,6 +143,17 @@ public void translate(int x, int y) { } else { xTranslate += x; yTranslate += y; + // The conjugation in setTransform() depends on the current + // xTranslate/yTranslate. If the user accumulated more translation + // after a non-identity setTransform on a platform that requires + // conjugation (iOS), re-conjugate so the impl's baked matrix + // stays consistent with the new translation. + if (userTransform != null && impl.isSetTransformTranslationConjugationRequired()) { + Transform composed = Transform.makeTranslation(xTranslate, yTranslate); + composed.concatenate(userTransform); + composed.translate(-xTranslate, -yTranslate); + impl.setTransform(nativeGraphics, composed); + } } } @@ -1129,6 +1146,9 @@ public void transform(Transform transform) { /// /// - #setTransform public Transform getTransform() { + if (userTransform != null) { + return userTransform.copy(); + } return impl.getTransform(nativeGraphics); } @@ -1160,7 +1180,37 @@ public Transform getTransform() { /// /// - #setTransform(com.codename1.ui.geom.Matrix, int, int) public void setTransform(Transform transform) { - impl.setTransform(nativeGraphics, transform); + // Some platforms accumulate xTranslate/yTranslate in Graphics.java + // (because impl.isTranslationSupported()=false) AND apply the user's + // setTransform matrix on top of the xTranslate-shifted vertex + // coordinates in the GPU pipeline -- this double-counts the + // translation for any non-translation matrix (rotate, scale, shear) + // and throws output off-screen. iOS Metal in particular surfaces the + // bug starkly: graphics-affine-scale's screen-mode top cells render + // blank because translate(18,18)*scale(2.65,5.36) * (1134,279) lands + // at (3023,1513), outside the 1170×2532 framebuffer. To make the + // user-visible setTransform consistent with platforms that fold + // translate into the canvas matrix (Android), conjugate the user's + // matrix with T(xTranslate, yTranslate) so its effect is independent + // of any prior g.translate() calls. Other platforms whose impl + // already gives setTransform that semantics (Android Skia, where the + // canvas matrix concat happens at draw time and accumulates with the + // user's matrix in a way that produced "shift but not vanish" before + // this fix) opt out by leaving + // isSetTransformTranslationConjugationRequired() false so the path + // is purely additive for the platforms that need it. + if (transform != null && !transform.isIdentity() + && (xTranslate != 0 || yTranslate != 0) + && impl.isSetTransformTranslationConjugationRequired()) { + userTransform = transform.copy(); + Transform composed = Transform.makeTranslation(xTranslate, yTranslate); + composed.concatenate(transform); + composed.translate(-xTranslate, -yTranslate); + impl.setTransform(nativeGraphics, composed); + } else { + userTransform = null; + impl.setTransform(nativeGraphics, transform); + } } /// Loads the provided transform with the current transform applied to this graphics context. @@ -1169,6 +1219,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 +1630,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/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 5d46605911..15363e2ea2 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -2402,9 +2402,15 @@ public void setTransform(Object graphics, Transform transform) { ng.transform = transform == null ? null : transform.copy(); } ng.transformApplied = false; + // Match the rotate/scale/translate/resetAffine paths: the cached + // clip / inverseClip / inverseTransform are derived from the previous + // transform, so replacing the transform must invalidate them or + // subsequent loadClipBounds / inverseClip calls return stale values. + ng.clipDirty = true; + ng.inverseClipDirty = true; + ng.inverseTransformDirty = true; ng.checkControl(); ng.applyTransform(); - } public void setNativeTransformGlobal(Transform transform){ @@ -4213,15 +4219,26 @@ public void rotate(Object nativeGraphics, float angle, int x, int y) { @Override public boolean isTranslationSupported() { //return true; - // We'll leave this as false until the next iteration... - // ES2 should allow us to do all of this using transforms but + // We'll leave this as false until the next iteration... + // ES2 should allow us to do all of this using transforms but // let's take small steps first return false; } - - - + @Override + public boolean isSetTransformTranslationConjugationRequired() { + // The iOS render path bakes xTranslate/yTranslate into vertex coords + // (since isTranslationSupported() is false) and the GPU then applies + // the user's setTransform matrix on top, double-counting the + // translation for any non-translation matrix and pushing output + // off-screen. Conjugating in Graphics.setTransform restores + // translate-independent semantics on this port. + return true; + } + + + + public void shear(Object nativeGraphics, float x, float y) { ((NativeGraphics)nativeGraphics).shear(x, y); } diff --git a/scripts/android/screenshots/graphics-affine-scale.png b/scripts/android/screenshots/graphics-affine-scale.png index fd989cc55c..5507b58507 100644 Binary files a/scripts/android/screenshots/graphics-affine-scale.png and b/scripts/android/screenshots/graphics-affine-scale.png differ diff --git a/scripts/android/screenshots/graphics-scale.png b/scripts/android/screenshots/graphics-scale.png index e9fde63228..260688e768 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/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/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/graphics/AffineScale.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/AffineScale.java index 9d85c963a1..db4a634a15 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/AffineScale.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/AffineScale.java @@ -10,24 +10,44 @@ public class AffineScale extends AbstractGraphicsScreenshotTest { @Override protected void drawContent(Graphics g, Rectangle bounds) { - if(!g.isAffineSupported()) { - g.drawString("Affine unsupported", 0, 0); + int x = bounds.getX(); + int y = bounds.getY(); + int w = bounds.getWidth(); + int h = bounds.getHeight(); + + g.setColor(0xffffff); + g.fillRect(x, y, w, h); + g.setColor(0x000000); + g.drawRect(x, y, w - 1, h - 1); + + if (!g.isAffineSupported()) { + g.drawString("Affine unsupported", x + 4, y + 4); return; } - float xScale = 0.01f * ((float)bounds.getHeight()); - float yScale = 0.01f * ((float)bounds.getWidth()); - AffineTransform affine = new AffineTransform(); - affine.setToScale(xScale, yScale); + // Same fix as Scale.java: the earlier formula crossed the axes so the + // fill clipped to a thin strip on portrait screens. + float xScale = w / 200f; + float yScale = h / 200f; + + // AffineTransform with matrix [xScale 0 x ; 0 yScale y] -- equivalent + // to translate(x, y) then scale(xScale, yScale). + AffineTransform affine = new AffineTransform( + xScale, 0f, + 0f, yScale, + (float) x, (float) y); Transform transform = affine.toTransform(); - int translateX = (int)(bounds.getX() / xScale); - int translateY = (int)(bounds.getY() / yScale); - transform.translate(translateX, translateY); g.setTransform(transform); - g.fillLinearGradient(0xff0000, 0xff, 0, 0, 100, 100, true); + // Top half of cell. + g.fillLinearGradient(0xff0000, 0x0000ff, 0, 0, 200, 100, true); + + // Mirror X via Transform.scale (composition) and draw the bottom half + // so the gradient runs right-to-left. transform.scale(-1, 1); - g.fillLinearGradient(0xff0000, 0xff, 0, 100, 100, 100, true); + g.setTransform(transform); + g.fillLinearGradient(0xff0000, 0x0000ff, -200, 100, 200, 100, true); + g.resetAffine(); } diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/Scale.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/Scale.java index 907da5dc9a..f34d73aa02 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/Scale.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/Scale.java @@ -1,6 +1,7 @@ package com.codenameone.examples.hellocodenameone.tests.graphics; import com.codename1.ui.Graphics; +import com.codename1.ui.Transform; import com.codename1.ui.geom.Rectangle; import com.codenameone.examples.hellocodenameone.tests.AbstractGraphicsScreenshotTest; @@ -8,23 +9,44 @@ public class Scale extends AbstractGraphicsScreenshotTest { @Override protected void drawContent(Graphics g, Rectangle bounds) { - if(!g.isAffineSupported()) { - g.drawString("Affine unsupported", 0, 0); + int x = bounds.getX(); + int y = bounds.getY(); + int w = bounds.getWidth(); + int h = bounds.getHeight(); + + g.setColor(0xffffff); + g.fillRect(x, y, w, h); + g.setColor(0x000000); + g.drawRect(x, y, w - 1, h - 1); + + if (!g.isAffineSupported()) { + g.drawString("Affine unsupported", x + 4, y + 4); return; } - float xScale = 0.01f * ((float)bounds.getHeight()); - float yScale = 0.01f * ((float)bounds.getWidth()); - g.scale(xScale, yScale); - int translateX = (int)(bounds.getX() / xScale); - int translateY = (int)(bounds.getY() / yScale); - g.translate(translateX, translateY); - g.fillLinearGradient(0xff0000, 0xff, 0, 0, 100, 100, true); - g.scale(-1, 1); - g.fillLinearGradient(0xff0000, 0xff, 0, 100, 100, 100, true); - - g.translate(-translateX, -translateY); - g.resetAffine(); + // The earlier test built a transform via separate g.translate + g.scale + // calls. On the JavaSE port g.translate(int, int) is a no-op (translate + // is expected to be embedded in the native graphics) and on iOS the + // form-graphics path doesn't compose g.scale with the cell offset + // either, so the gradient fill landed off-cell. Build a single + // Transform that combines translate + scale and apply it once. + float xScale = w / 200f; + float yScale = h / 200f; + Transform t = Transform.makeIdentity(); + t.translate(x, y); + t.scale(xScale, yScale); + g.setTransform(t); + + // Top half of cell. + g.fillLinearGradient(0xff0000, 0x0000ff, 0, 0, 200, 100, true); + + // Mirror X via scale(-1, 1) and draw the bottom half so the gradient + // runs right-to-left. + t.scale(-1, 1); + g.setTransform(t); + g.fillLinearGradient(0xff0000, 0x0000ff, -200, 100, 200, 100, true); + + g.setTransform(Transform.makeIdentity()); } @Override diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/TransformCamera.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/TransformCamera.java index e3c8044b35..687fdae1c0 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/TransformCamera.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/TransformCamera.java @@ -9,48 +9,84 @@ public class TransformCamera extends AbstractGraphicsScreenshotTest { @Override protected void drawContent(Graphics g, Rectangle bounds) { + int x = bounds.getX(); + int y = bounds.getY(); + int w = bounds.getWidth(); + int h = bounds.getHeight(); + + g.setColor(0xffffff); + g.fillRect(x, y, w, h); + g.setColor(0x000000); + g.drawRect(x, y, w - 1, h - 1); + if (!Transform.isPerspectiveSupported()) { - g.drawString("Perspective unsupported", bounds.getX(), bounds.getY()); + g.setColor(0xaa0000); + g.drawString("No camera", x + 4, y + 4); + g.setColor(0x884400); + g.fillRect(x + w / 4, y + h / 4, w / 2, h / 2); return; } - float eyeX = 0; - float eyeY = 0; - float eyeZ = 500; - float centerX = 0; - float centerY = 0; - float centerZ = 0; - float upX = 0; - float upY = 1; - float upZ = 0; - - Transform t = Transform.makeCamera(eyeX, eyeY, eyeZ, centerX, centerY, centerZ, upX, upY, upZ); - - // We probably also need a projection matrix for the camera to make sense visually? - // Or does makeCamera include projection? - // Typically makeCamera (lookAt) creates a View matrix. We still need Projection. + // Build Viewport * Perspective * Camera * Translate(model). The + // earlier test passed the raw clip-space output to fillRect; the + // first viewport-mapping attempt used g.setTransform(mvp) which + // depended on the platform's rect rasterizer honouring a 4x4 + // perspective matrix (Android Canvas drops the Z axis on its 3x3 + // Skia matrix and rect rasterization doesn't honour the perspective + // row reliably; iOS Metal mutable graphics gates the entire branch + // off via isPerspectiveTransformSupported = false). Project the 4 + // model corners via transformPoint (which does the homogeneous + // divide on every backend) and draw a 2D polygon, so the rendering + // is uniform across all 4 panes on every platform. + float fovy = (float) (Math.PI / 4); + float aspect = (float) w / (float) h; + float zNear = 1f; + float zFar = 1000f; + float modelZ = -300f; - float fovy = 45f; - float aspect = (float)bounds.getWidth() / bounds.getHeight(); - Transform proj = Transform.makePerspective(fovy, aspect, 0.1f, 1000f); + Transform mvp = Transform.makeIdentity(); + // Viewport: NDC -> cell pixels. + mvp.translate(x + w * 0.5f, y + h * 0.5f); + mvp.scale(w * 0.5f, -h * 0.5f, 1f); + // Perspective projection. + Transform persp = Transform.makePerspective(fovy, aspect, zNear, zFar); + mvp.concatenate(persp); + // Camera elevated on Y, looking down at the model centre. The + // ~5.7 deg downward pitch shifts the rendered quad downward in the + // cell and is visually distinct from TransformPerspective which + // uses an implicit identity view. + Transform camera = Transform.makeCamera( + 0f, 30f, 0f, // eye -- elevated on y + 0f, 0f, modelZ, // looking at the model quad's centre + 0f, 1f, 0f); // up + mvp.concatenate(camera); + // Place the model quad at z=modelZ in world space. + mvp.translate(0, 0, modelZ); - proj.concatenate(t); + // Solid orange quad. The downward camera pitch shifts the quad + // toward the bottom of the cell. + g.setColor(0x884400); + fillProjectedQuad(g, mvp, -50, -50, 100, 100); - g.setTransform(proj); - - g.setColor(0x00ff00); - g.fillRect(-50, -50, 100, 100); - - // Rotate the camera/object slightly to verify 3D - Transform rot = Transform.makeRotation((float)(Math.PI / 4), 0, 1, 0); // Rotate around Y - proj.concatenate(rot); // Apply rotation - g.setTransform(proj); - - g.setColor(0x0000ff); - g.setAlpha(128); - g.fillRect(-50, -50, 100, 100); + // Same quad rotated 36 deg around Y so the foreshortening is + // visible against the camera-tilted base. + Transform rotated = mvp.copy(); + rotated.rotate((float) (Math.PI / 5), 0, 1, 0); + g.setColor(0x0044aa); + g.setAlpha(160); + fillProjectedQuad(g, rotated, -50, -50, 100, 100); + g.setAlpha(255); + } - g.setTransform(Transform.makeIdentity()); + private static void fillProjectedQuad(Graphics g, Transform t, + int mx, int my, int mw, int mh) { + float[] tl = t.transformPoint(new float[]{mx, my, 0}); + float[] tr = t.transformPoint(new float[]{mx + mw, my, 0}); + float[] br = t.transformPoint(new float[]{mx + mw, my + mh, 0}); + float[] bl = t.transformPoint(new float[]{mx, my + mh, 0}); + int[] xs = new int[]{(int) tl[0], (int) tr[0], (int) br[0], (int) bl[0]}; + int[] ys = new int[]{(int) tl[1], (int) tr[1], (int) br[1], (int) bl[1]}; + g.fillPolygon(xs, ys, 4); } @Override diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/TransformPerspective.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/TransformPerspective.java index 4d5fa65239..2117fc7534 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/TransformPerspective.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/TransformPerspective.java @@ -9,40 +9,86 @@ public class TransformPerspective extends AbstractGraphicsScreenshotTest { @Override protected void drawContent(Graphics g, Rectangle bounds) { + int x = bounds.getX(); + int y = bounds.getY(); + int w = bounds.getWidth(); + int h = bounds.getHeight(); + + g.setColor(0xffffff); + g.fillRect(x, y, w, h); + g.setColor(0x000000); + g.drawRect(x, y, w - 1, h - 1); + + // The static Transform.isPerspectiveSupported() check (vs the + // per-graphics g.isPerspectiveTransformSupported() check) returns + // true on every platform that has a working Matrix.makePerspective + // implementation. This is the right gate when we project corners + // ourselves and draw a 2D polygon -- we don't need the per-graphics + // canvas/encoder to support perspective rasterization. if (!Transform.isPerspectiveSupported()) { - g.drawString("Perspective unsupported", bounds.getX(), bounds.getY()); + g.setColor(0xaa0000); + g.drawString("No perspective", x + 4, y + 4); + g.setColor(0x008800); + g.fillRect(x + w / 4, y + h / 4, w / 2, h / 2); return; } - float fovy = 45f; - float aspect = (float)bounds.getWidth() / bounds.getHeight(); - float zNear = 0.1f; + // Build Viewport * Perspective * Translate(model). Earlier the test + // passed the raw clip-space output of makePerspective to fillRect, + // which projected to a sub-pixel region. The first viewport-mapping + // attempt used g.setTransform(mvp) followed by fillRect, but that + // depends on the platform's draw path applying a 4x4 perspective + // matrix to rect rasterization -- Android Canvas converts to a 3x3 + // Skia matrix (drops the Z axis) and rect rasterization on the + // hardware canvas doesn't honour the perspective row reliably, and + // the iOS Metal mutable-image graphics flags isPerspectiveTransform + // Supported = false so the entire perspective branch was skipped. + // Project the 4 model corners via transformPoint (which does the + // homogeneous divide on every backend) and draw a 2D polygon, so + // the rendering is uniform across all 4 panes on every platform. + float fovy = (float) (Math.PI / 4); + float aspect = (float) w / (float) h; + float zNear = 1f; float zFar = 1000f; + float modelZ = -300f; // z position of the centred 100x100 model quad - // This sets the projection matrix - Transform projection = Transform.makePerspective(fovy, aspect, zNear, zFar); - - // Move the object back so it's visible - Transform modelView = Transform.makeTranslation(0, 0, -500); - - // Combine projection and modelview - projection.concatenate(modelView); + Transform mvp = Transform.makeIdentity(); + // Viewport: NDC (-1..1) -> cell pixels. Y is flipped because + // perspective NDC has +y up and screen has +y down. + mvp.translate(x + w * 0.5f, y + h * 0.5f); + mvp.scale(w * 0.5f, -h * 0.5f, 1f); + // Perspective projection. + Transform persp = Transform.makePerspective(fovy, aspect, zNear, zFar); + mvp.concatenate(persp); + // Push the quad into the frustum. + mvp.translate(0, 0, modelZ); - g.setTransform(projection); + // Solid green quad (centred, no rotation) -- foreshortened only by + // the perspective divide. + g.setColor(0x008800); + fillProjectedQuad(g, mvp, -50, -50, 100, 100); - g.setColor(0xff0000); - // Draw a rectangle centered at 0,0 (which should be center of screen due to perspective) - // Wait, perspective projection usually maps 0,0 to center if set up that way, - // but Codename One coordinate system is usually top-left 0,0. - // We probably need to adjust. - - // Let's draw something at the "bounds" location but projected. - // Since we are using makePerspective, it usually implies a camera at 0,0,0 looking down -Z (or similar depending on convention). - // Let's assume standard OpenGL-like behavior where camera is at origin. - - g.fillRect(-50, -50, 100, 100); + // Same quad rotated 36 deg around the Y axis. The left edge moves + // toward the camera (renders larger) and the right edge away + // (renders smaller), so the foreshortening is clearly visible vs + // the unrotated green base. + Transform rotated = mvp.copy(); + rotated.rotate((float) (Math.PI / 5), 0, 1, 0); + g.setColor(0x0000aa); + g.setAlpha(160); + fillProjectedQuad(g, rotated, -50, -50, 100, 100); + g.setAlpha(255); + } - g.setTransform(Transform.makeIdentity()); + private static void fillProjectedQuad(Graphics g, Transform t, + int mx, int my, int mw, int mh) { + float[] tl = t.transformPoint(new float[]{mx, my, 0}); + float[] tr = t.transformPoint(new float[]{mx + mw, my, 0}); + float[] br = t.transformPoint(new float[]{mx + mw, my + mh, 0}); + float[] bl = t.transformPoint(new float[]{mx, my + mh, 0}); + int[] xs = new int[]{(int) tl[0], (int) tr[0], (int) br[0], (int) bl[0]}; + int[] ys = new int[]{(int) tl[1], (int) tr[1], (int) br[1], (int) bl[1]}; + g.fillPolygon(xs, ys, 4); } @Override diff --git a/scripts/ios/screenshots-metal/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/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'"