← Apps launched · Noctrodex

Native Android · Gerber RS-274X · Excellon

GerberScope

Open the fab package on your phone and see what the fab will see — before you pay for it.

Drag to rotate · 4-layer stack-up
SHEET 01

The job

dev.gerberscope.app

A fab package is a pile of files that mean nothing until someone renders them.

You export a ZIP from KiCad, Altium or EasyEDA and send it to a fabricator. If a layer is misnamed, an inner layer is out of order, or a drill file is in the wrong units, you find out days later — in a box of wrong boards.

GerberScope opens that ZIP on the device you already have. It parses the Gerbers and the Excellon drill, classifies every file, renders the board, and runs pre-fab sanity checks. No WebView, no network, no upload. The app declares no INTERNET permission at all — files never leave the phone.

Board read
40.00 × 30.00 mm
Copper layers
4
Holes
67
Apertures
14
Classified
100%

Figures from the demo package that ships with the app’s test tooling — a synthetic 4-layer board, read on a Motorola moto g(60). No customer artwork appears anywhere on this page.

SHEET 02

Architecture

2 modules

The engine knows nothing about Android.

:core is a pure-JVM library — parsers, classification, geometry, DFM checks, diagnostics — with zero Android imports. That single constraint is what makes the hard part testable: 73 JVM tests run in seconds on a laptop with no emulator, no device and no SDK.

:app is Jetpack Compose on top: SAF ingest, the Canvas renderer, and the screens. It carries a deliberate dependency budget — core-ktx, lifecycle, activity-compose, the Compose BOM and coroutines. The 3D work below added no new dependencies at all.

ModuleTypeHoldsProof
:corePure JVMRS-274X + Excellon parsers, classifier, geometry, DFM checks73 tests
:appAndroidSAF ingest, Compose UI, Canvas renderer, GL renderer, exporton device

Internal geometry is millimetres as Double throughout. Arcs flatten to polylines at parse time at ~0.004 mm sagitta; circular flashes stay exact circles. Layer classification detects the toolchain first, then applies that vendor's rules — and the rule tables are JSON data, not code, so a new vendor is a data change.

SHEET 03

First compile

2 errors

The UI had never been compiled once.

The Compose module was written in a sandbox with no Android SDK — complete source, never built. Two errors stood between it and an APK, and only one of them was ordinary.

The interesting one

Kotlin's Android plugin died on NoClassDefFoundError: com/android/build/gradle/api/BaseVariant — a class that demonstrably was in the resolved AGP jar.

Not a version conflict: a classloader one. The root build put the Kotlin plugin on the build classpath, while :app requested AGP with its own version in its plugins {} block, which gets a separate scope. Kotlin's plugin simply could not see AGP. Resolving both on one buildscript classpath fixed it — with the Android half kept conditional on an SDK being present, so the pure-JVM tests still run in a sandbox with no Google Maven access.

The ordinary one

Three items(...) calls in the checks screen with no import androidx.compose.foundation.lazy.items.

Everything else compiled as written — nativeCanvas, saveLayer, PorterDuff CLEAR, detectTransformGestures, the icon names, edge-to-edge. The predicted API drift never materialised.

SHEET 04

Defects found by running it

6 fixed

Compiling is not working.

Every defect below survived code review and the compiler. All six were found by driving the real app — four on an emulator, and the two that mattered most on the phone.

WhereDefectRoot cause
core/modelA .gbrjob was flagged unparsed, filed under Needs review, and raised “1 file(s) could not be parsed”The loader had a job-aware check and threw it away; the model recomputed it job-blindwrong
layer_rules.jsonA KiCad .drl flagged at 50% under Needs review on a package where every other file scored 100%KiCad's job file never lists drill files, and the KiCad rule table had no drill pattern — so it fell through to the generic tablewrong
BoardScreenVisibility chips for layers the view never draws — 4 dead controls of 13Chip row listed every layer with geometry; the compositor draws one side's setdead UI
GerberScopeAppRotating the phone threw you back to the first tabremember where rememberSaveable was neededstate loss
MainViewModelA recent entry that could never reopenOne-shot URI grant recorded as if it were persistabledead entry
LayersScreen“Open” wrapping mid-word on a long file nameUnbounded name column squeezing the buttoncosmetic

The one that was really a bug

The job file row in the layer list, showing a neutral job file badge instead of a red unparsed badge.
After. A .gbrjob carries no geometry by design — and it is the very thing that classifies every other file at 100%. Reporting “no geometry” as “failed to parse” made a clean package look broken.
Checks screen reporting zero warnings, with the drill table below.
Warnings (0). A package that previously reported a phantom parse failure now reports honestly clean — drill table, hole count and aperture list intact.
Exported PNG of the reference board at 2000 by 1440 pixels.
One compositor, three outputs. This exported PNG, the on-screen board and the 3D face textures all come from the same code path, so they cannot drift apart.

A measurement worth keeping: a suspected renderer stall turned out to be nothing. A steady-state pan measured 5.8% janky frames — then the same build measured 43.7% and 33.7%. The emulator's software rasteriser was the variable, not the code, so the optimisation it seemed to call for was never written.

SHEET 05

Seeing the stack

3 renderers

A stack-up is a physical thing. It should look like one.

Layer order is the thing you must verify and most often get wrong, and a list of checkboxes hides it. Three views make it physical — and none of them added a dependency.

Layers screen showing the stack-up as lit slabs with FR4 cores between copper layers.
Cross-section. Each layer is a slab lit from above-left, copper thicker than mask and silk, FR4 cores between coppers. Reordering an inner layer moves the slab.
Exploded 3D view showing eight board layers separated in perspective.
Exploded. Drag to spin, slider to separate. Real perspective from android.graphics.Camera; each plate drawn by the same compositor as the flat view.
Photoreal 3D render of the board with glossy soldermask and copper traces.
Solid. A lit slab with mask gloss and a real fibreglass edge — 24 vertices, two textures and one Blinn-Phong shader in GLES 2.0.

Two things the hardware corrected that reasoning had not.

The explode looked right in theory as a camera z-offset — on screen it merely nested the plates at slightly different scales, because Camera composes its translate before the rotation. It became a screen-space offset instead.

And the underside rendered physically correctly: in shadow, and unreadable. A bottom layer you cannot inspect is useless, so a fill light now bounces up at it — accuracy traded for the thing the view is for.

The solid board viewed from underneath, showing bottom copper legible under fill lighting.
Underside at tilt −88°. Bottom copper and mask openings legible rather than silhouetted.
SHEET 06

Verification

moto g(60) · API 34

What was actually proven, and what wasn't.

Device
moto g(60)
Android
14 / API 34
GPU
Adreno 618
JVM tests
73 / 73
App crashes
0

Verified by driving it

Not proven

Pinch-zoom and double-tap-to-reset are unverified. adb injects a single pointer, and each synthetic tap lands well outside the 300 ms double-tap window, so both register as ordinary taps. They need a human finger, and saying otherwise would be a guess dressed as a result.

End of package