diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 4d724ea..87d7a2d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,12 +1,12 @@ name: Bug report -description: Something crashes, looks wrong or does not open +description: Something crashes, looks wrong or doesn't open labels: [bug] body: - type: textarea id: what attributes: label: What happened? - description: What did you do, what did you expect, and what happened instead? + description: What did you do, what did you expect, and what happened instead? Screenshots help. validations: required: true - type: input @@ -26,4 +26,4 @@ body: id: file attributes: label: Model file - description: If a specific file is affected, attach it (zip it first) or link to it if you can share it. + description: If one file is affected, attach it (zip it first) or link to it, if you can share it. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85a4d2e..8d75341 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: - name: JVM tests (Robolectric, real native engine) run: | sudo apt-get install -y -q libegl1 libgles2 > /dev/null - ./gradlew :app:testDebugUnitTest --tests '*ViewerFlowTest*' -Pfsv.hostLibDir="$PWD/host-native/native/build-host" + ./gradlew :app:testDebugUnitTest --tests '*ViewerFlowTest*' --tests '*EngineReleaseTest*' --tests '*UpdateCheckTest*' -Pfsv.hostLibDir="$PWD/host-native/native/build-host" - name: Assemble debug APKs run: ./gradlew assembleDebug - uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f95521a..ba62923 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,7 +68,7 @@ jobs: TAG: ${{ needs.version.outputs.tag }} TEST_BUILD: ${{ github.event.inputs.test_build == 'true' }} # Public fingerprint of the Meshwise release certificate (CN=Meshwise, O=doesthings). - EXPECTED_CERT_SHA256: cbefeb0e767375be72d29325f97ec602b4b5d9b8e4eecb9aef3958818bbc5c38 + EXPECTED_CERT_SHA256: 69fe7fdd7748c2faddbef43c66bc8becf719aaaccdd340de42f7f3eb314b4d11 steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 @@ -81,7 +81,7 @@ jobs: - name: Install NDK run: | yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null || true - "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;28.2.13676358" "cmake;3.31.6" > /dev/null + "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;28.2.13676358" "cmake;3.31.6" "build-tools;36.0.0" > /dev/null - name: Signing key env: @@ -91,7 +91,55 @@ jobs: KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} run: | if [ -n "$KEYSTORE_BASE64" ]; then - echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.jks" + # Tolerate how the secret was pasted: line endings, wrapping, spaces, invisible + # characters (BOM, zero-width space) and "-----BEGIN/END ...-----" lines (certutil). + # Opening the keystore and the certificate check below still guard the release. + CLEAN=$(printf '%s\n' "$KEYSTORE_BASE64" | tr -d '\r' | grep -v -- '-----' | tr -cd 'A-Za-z0-9+/=') + DROPPED=$(( $(printf '%s' "$KEYSTORE_BASE64" | wc -c) - ${#CLEAN} )) + if [ "$DROPPED" -gt 0 ]; then echo "Ignored $DROPPED stray bytes (whitespace or invisible characters) in SIGNING_KEYSTORE_BASE64."; fi + if ! printf '%s' "$CLEAN" | base64 -d > "$RUNNER_TEMP/release.jks" 2>/dev/null; then + echo "::error::SIGNING_KEYSTORE_BASE64 is not valid base64 (${#CLEAN} base64 characters). Re-create it with 'base64 -w0 release.jks' and paste it again." + exit 1 + fi + # Same for the other secrets: drop a BOM, zero-width spaces, line breaks and + # surrounding spaces. + tidy() { printf '%s' "$1" | sed -e 's/\xef\xbb\xbf//g; s/\xe2\x80\x8b//g' | tr -d '\r\n' | sed -e 's/^[[:space:]]*//; s/[[:space:]]*$//'; } + STORE_PASSWORD=$(tidy "$STORE_PASSWORD"); KEY_PASSWORD=$(tidy "$KEY_PASSWORD"); KEY_ALIAS=$(tidy "$KEY_ALIAS") + KS="$RUNNER_TEMP/release.jks" + if ! keytool -list -keystore "$KS" -storepass "$STORE_PASSWORD" > /dev/null 2>&1; then + if [ -n "$KEY_PASSWORD" ] && keytool -list -keystore "$KS" -storepass "$KEY_PASSWORD" > /dev/null 2>&1; then + echo "::warning::SIGNING_KEY_PASSWORD opens the keystore but SIGNING_STORE_PASSWORD does not; the two look swapped. Using it for both." + STORE_PASSWORD="$KEY_PASSWORD" + else + WHY=$(keytool -list -keystore "$KS" -storepass "$STORE_PASSWORD" 2>&1 | grep -i 'keytool error' | head -1) + echo "::error::The keystore decoded ($(wc -c < "$KS") bytes, $(file -b "$KS" | cut -c1-40)) but SIGNING_STORE_PASSWORD does not open it. keytool says: $WHY" + exit 1 + fi + fi + ALIASES=$(keytool -list -v -keystore "$KS" -storepass "$STORE_PASSWORD" 2>/dev/null | sed -n 's/^Alias name: //p') + if ! printf '%s\n' "$ALIASES" | grep -qixF -- "$KEY_ALIAS"; then + if [ "$(printf '%s\n' "$ALIASES" | grep -c .)" -eq 1 ]; then + echo "::warning::SIGNING_KEY_ALIAS does not match the key in the keystore; using its only key." + KEY_ALIAS="$ALIASES" + else + echo "::error::SIGNING_KEY_ALIAS does not match any of the $(printf '%s\n' "$ALIASES" | grep -c .) keys in the keystore." + exit 1 + fi + fi + # The key password must unlock the private key. In a PKCS12 keystore it is always + # the store password (and keytool ignores -keypass), so use that. + if keytool -list -keystore "$KS" -storepass "$STORE_PASSWORD" 2>/dev/null | grep -qi '^Keystore type: PKCS12'; then + if [ "$KEY_PASSWORD" != "$STORE_PASSWORD" ]; then echo "PKCS12 keystore: signing with the store password as the key password."; fi + KEY_PASSWORD="$STORE_PASSWORD" + elif ! keytool -certreq -keystore "$KS" -storepass "$STORE_PASSWORD" -alias "$KEY_ALIAS" -keypass "$KEY_PASSWORD" > /dev/null 2>&1; then + if keytool -certreq -keystore "$KS" -storepass "$STORE_PASSWORD" -alias "$KEY_ALIAS" -keypass "$STORE_PASSWORD" > /dev/null 2>&1; then + echo "::warning::SIGNING_KEY_PASSWORD does not unlock the key, but the store password does; using that." + KEY_PASSWORD="$STORE_PASSWORD" + else + echo "::error::SIGNING_KEY_PASSWORD does not unlock the key. Check that secret." + exit 1 + fi + fi { echo "SIGNING_STORE_FILE=$RUNNER_TEMP/release.jks" echo "SIGNING_STORE_PASSWORD=$STORE_PASSWORD" @@ -120,10 +168,17 @@ jobs: - name: Verify signatures run: | - APKSIGNER="$(ls -d "$ANDROID_HOME"/build-tools/*/ | sort -V | tail -1)apksigner" + # A pinned apksigner: the newest one on the runner printed no certificate. + APKSIGNER="$ANDROID_HOME/build-tools/36.0.0/apksigner" for f in dist/*.apk; do - CERT=$("$APKSIGNER" verify --print-certs "$f" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p') - echo "$(basename "$f"): $CERT" + OUT=$("$APKSIGNER" verify --print-certs "$f" 2>&1) || { echo "$OUT"; echo "::error::$(basename "$f") does not verify."; exit 1; } + CERT=$(printf '%s\n' "$OUT" | grep -oiE 'certificate SHA-256 digest: [0-9a-f]{64}' | head -1 | awk '{print tolower($NF)}') + echo "$(basename "$f"): ${CERT:-}" + if [ -z "$CERT" ]; then + echo "$OUT" + echo "::error::Could not read the signing certificate of $(basename "$f")." + exit 1 + fi if [ "$CERT" != "$EXPECTED_CERT_SHA256" ] && [ "$TEST_BUILD" != "true" ]; then echo "::error::$(basename "$f") is not signed with the Meshwise release key." exit 1 @@ -156,7 +211,7 @@ jobs: Every APK is signed with the Meshwise release certificate. You can check it with `apksigner verify --print-certs .apk`. The SHA-256 must be: ``` - CB:EF:EB:0E:76:73:75:BE:72:D2:93:25:F9:7E:C6:02:B4:B5:D9:B8:E4:EE:CB:9A:EF:39:58:81:8B:BC:5C:38 + 69:FE:7F:DD:77:48:C2:FA:DD:BE:F4:3C:66:BC:8B:EC:F7:19:AA:AC:CD:D3:40:DE:42:F7:F3:EB:31:4B:4D:11 ``` File checksums are in `SHA256SUMS.txt`. NOTES diff --git a/CHANGELOG.md b/CHANGELOG.md index ec3f5b1..978e785 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,21 +1,29 @@ # Changelog -All notable changes to this project are documented here. The format follows -[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses +All notable changes to Meshwise are listed here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [semantic versioning](https://semver.org/). ## [1.0.0] - 2026-10-13 +First public release. + ### Added -- Native loaders for binary/ASCII STL, OBJ (+MTL colors), 3MF (multi-object, colors), PLY - (binary/ASCII, vertex colors), OFF and ZIP archives; STEP, IGES and BREP through Open CASCADE - in an isolated process with a tessellation cache. +- Native loaders for binary and ASCII STL, OBJ (with MTL colors), 3MF (multiple objects, + colors), PLY (binary and ASCII, vertex colors), OFF and ZIP archives. STEP, IGES and BREP + through Open CASCADE, in a separate process with a tessellation cache. - OpenGL ES 3 renderer: physically based materials, studio/outdoor/flat lighting, soft shadows, feature edges, wireframe, x-ray, hatched section caps and an interactive section plane. + Big models upload to the GPU without blocking touch input. - Tools: section analysis, explode (automatic and manual with gizmo and undo), parts list with search/isolate/focus, colors and materials, measuring, dimensions overlay, wall-thickness and overhang analysis, mesh check, weight and cost estimate. - Projects (`.meshwise`), export to STL, OBJ, 3MF and PNG, on-device library, recent files with thumbnails, five sample models. -- Ko-fi support links in Settings, About and on the home screen (they open in the browser; the - app itself has no network access). +- Optional update check. On first launch the app asks whether it may check GitHub for new + releases (off unless the user agrees; switch and "Check now" in Settings). It asks the GitHub + releases API at most once a day, sends nothing else, and offers the APK for the device's ABI + with Download, Later or Skip this version. This is the only use of the `INTERNET` permission. +- Launcher icon: a faceted gem with an orange hatched section cut, plus a themed (monochrome) + version, generated by `scripts/make_icon.py`. +- Ko-fi support links in Settings, About and on the home screen (they open in the browser). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0abd3e9..1d60227 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,26 +1,30 @@ # Contributing -Thanks for helping improve Meshwise! Bug reports, model files that load wrongly, and pull -requests are all welcome. +Thanks for helping improve Meshwise! Bug reports, files that load wrongly, and pull requests +are all welcome. ## Reporting a problem -Open an issue with your device model, Android version, the app version (Settings → About) and, -if a file does not open or looks wrong, the file itself or a link to it when you can share it. +[Open an issue](../../issues/new/choose) and include: + +- your phone or tablet model and Android version +- the app version (Settings → About) +- if a file doesn't open or looks wrong: the file, or a link to it, if you can share it ## Development setup -See [Building](README.md#building) and [Testing](README.md#testing) in the README. The fastest -loop for anything in `native/core` is the desktop build: `fsv_tests` for logic and -`fsv_render` to look at rendering changes without a phone. +See [Building](README.md#building) and [Testing](README.md#testing) in the README. + +For anything in `native/core`, the desktop build is the fastest loop: `fsv_tests` checks the +logic and `fsv_render` shows rendering changes without a phone. ## Pull requests -- Keep changes focused; one feature or fix per pull request. -- Run `native/build-host/fsv_tests` and `./gradlew :app:testDebugUnitTest` before pushing. +- Keep each pull request to one feature or fix. +- Run `native/build-host/fsv_tests` and `./gradlew :app:testDebugUnitTest` before you push. - Add a test in `native/tests/tests.cpp` for loader, geometry or analysis changes. -- UI changes: regenerate the README screenshots with `scripts/make_screenshots.sh` when they - change what the screenshots show. +- If a UI change affects what the README screenshots show, regenerate them with + `scripts/make_screenshots.sh`. - Match the surrounding style: C++17 in `native/`, idiomatic Kotlin and Compose in `app/`. -By contributing you agree that your contributions are licensed under the MIT License. +By contributing, you agree that your contributions are licensed under the MIT License. diff --git a/README.md b/README.md index 6593605..93985a8 100644 --- a/README.md +++ b/README.md @@ -10,21 +10,24 @@ Support on Ko-fi

-**Meshwise** opens 3D printing and CAD files on your phone in a fraction of a second and -gives you the tools you actually need to inspect them: section cuts, measurements, wall -thickness, exploded assemblies, weight estimates and more. The heavy lifting is done by a -multi-threaded C++ core and an OpenGL ES 3 renderer; STEP and IGES are read by an embedded -Open CASCADE kernel. No ads, no account, and no network permission: your files never leave the device. - -> **Honest note:** Meshwise is vibe coded. It was built with a lot of help from an AI coding -> assistant. It works well enough, it's backed by automated tests, and I just wanted to share a -> good free tool with whoever needs one. If something breaks, please +**Meshwise** is a fast 3D model viewer for Android. Open 3D printing and CAD files on your +phone and check them before you print: section cuts, measurements, wall thickness, exploded +assemblies, weight estimates and more. + +- **Free and open source** (MIT). No ads, no account, no paywall. +- **STEP and IGES included**, through the Open CASCADE kernel. +- **Private.** No tracking, and your files never leave your phone. The only network use is an + optional update check that is off until you turn it on. + +> **Honest note:** Meshwise is vibe coded: I built it with a lot of help from an AI coding +> assistant. It works well enough, it has automated tests, and I just wanted to share a good +> free tool with whoever needs one. If something breaks, please > [open an issue](../../issues/new/choose). ## Download -Grab the latest APK from the [**Releases**](../../releases/latest) page and open it on your -phone (you may need to allow installs from your browser or file manager). +Get the latest APK from the [**Releases**](../../releases/latest) page and open it on your +phone. You may need to allow installs from your browser or file manager. | APK | For | |---|---| @@ -33,12 +36,13 @@ phone (you may need to allow installs from your browser or file manager). | `Meshwise-x.y.z-x86_64.apk` | Chromebooks and emulators | | `Meshwise-x.y.z-universal.apk` | Any device (largest download) | -Requires Android 8.0 (API 26) or newer with OpenGL ES 3.0, which covers essentially all devices -in use today. +Meshwise needs Android 8.0 (API 26) or newer with OpenGL ES 3.0. Almost every device in use +today has both. -Every release APK is signed with the same Meshwise certificate, so updates install over each -other. To check a download, run `apksigner verify --print-certs` on it; the SHA-256 must be -`CB:EF:EB:0E:76:73:75:BE:72:D2:93:25:F9:7E:C6:02:B4:B5:D9:B8:E4:EE:CB:9A:EF:39:58:81:8B:BC:5C:38`. +Every release is signed with the same Meshwise certificate, so a new version installs over the +old one. If you turn on the optional update check, the app tells you when a new version is out +(see [Privacy](#privacy)). To check a download, run `apksigner verify --print-certs` on it. The SHA-256 must be +`69:FE:7F:DD:77:48:C2:FA:DD:BE:F4:3C:66:BC:8B:EC:F7:19:AA:AC:CD:D3:40:DE:42:F7:F3:EB:31:4B:4D:11`. ## Screenshots @@ -71,19 +75,19 @@ other. To check a download, run `apksigner verify --print-certs` on it; the SHA- - STL (binary and ASCII), OBJ with MTL colors, 3MF (multiple objects, colors), PLY (binary and ASCII, vertex colors), OFF - STEP, IGES and BREP CAD files with assembly structure, part names and colors (Open CASCADE) - ZIP archives: every model inside is loaded and laid out side by side -- Opens from any app: Files, Google Drive, Gmail, Telegram, WhatsApp, browsers ("Open with" and "Share") -- Recent files with rendered thumbnails, an on-device library, and five sample models to try +- Open files from other apps with "Open with" or "Share" (file managers, cloud drives, email, chat apps) +- Recent files with thumbnails, an on-device library, and five sample models to try -**A fast, good-looking viewer** -- Smooth orbit, pan and pinch-zoom with inertia; zoom toward your fingers; double-tap to focus a point +**A fast, clear viewer** +- Orbit, pan and pinch-zoom with inertia; zoom toward your fingers; double-tap to focus on a point - View cube, standard views, perspective or orthographic projection, turntable mode - Physically based materials (matte, satin, glossy, resin, rubber, ceramic, steel, aluminium, chrome, gold, silver, copper), studio, outdoor and flat lighting, soft shadows on a grid floor -- Crease-aware smooth shading, feature edges, wireframe and axis overlays, light and dark themes, Material You colors +- Smooth shading that keeps sharp edges crisp, feature edges, wireframe and axes, light and dark themes, Material You colors **Inspect and analyze** - **Section analysis**: cut along X, Y or Z, flip the side, type an exact cut value, solid hatched caps - **Measure**: point-to-point distance (with ΔX/ΔY/ΔZ), circle through three points (radius, diameter, center), angle, point coordinates and normals; copy results to the clipboard -- **Dimensions**: tap the info bar for an X/Y/Z bounding-box overlay +- **Dimensions**: tap the info bar to show the X/Y/Z size of the model - **Wall thickness**: gradient or threshold heat map and callouts at the thinnest walls - **Overhang** analysis with an adjustable angle, and a **mesh check** for open and non-manifold edges, flipped normals, degenerate faces and shell count - **Weight estimate**: PLA, PETG, ABS, ASA, TPU, nylon, resin and more, with infill, wall count, filament length and cost @@ -94,25 +98,26 @@ other. To check a download, run `apksigner verify --print-certs` on it; the SHA- - Paint parts individually or all at once, and assign materials per part **Save and share** -- Save projects (camera, colors, materials, explode layout, section) and reopen them later +- Save projects (`.meshwise`: camera, colors, materials, explode layout, section) and reopen them later - Export STL, OBJ (with part names), 3MF (with names and colors) or high-resolution PNG snapshots - Share any export straight to another app ## Performance The C++ core memory-maps the file, parses it without copies, welds vertices with a hash of -exact positions, and builds normals, topology and the BVH on all cores. Measured with the -bundled `fsv_bench` tool on a 4-core x86_64 cloud VM (a recent phone is roughly 2–3× slower): +exact positions, and builds normals, topology and the BVH on all cores. These numbers come from +the bundled `fsv_bench` tool on a **4-core x86_64 cloud VM**, not a phone. Expect a phone to be +slower; real phone timings are welcome in an issue. | Model | Size | Triangles | Load + mesh processing | Topology | BVH (picking) | Wall thickness | |---|---:|---:|---:|---:|---:|---:| | Twisted vase (sample) | 3.1 MB | 66 k | 13 ms | 5 ms | 12 ms | 30 ms | | Trefoil knot, dense | 97.7 MB | 2.05 M | 0.41 s | 0.17 s | 0.57 s | 1.3 s | -Only loading blocks the first frame. Topology, BVH and analysis run in the background while -you are already orbiting the model. The renderer packs each vertex into 24 bytes -(position, 10-bit normal, color, part ID) and only redraws when something changes, so the -GPU and battery sit idle when you are not touching the screen. +Only loading has to finish before the first frame. Topology, BVH and analysis run in the +background while you already orbit the model. The renderer packs each vertex into 24 bytes +(position, 10-bit normal, color, part ID) and only redraws when something changes, so the GPU +and battery rest when you are not touching the screen. ## How it works @@ -137,21 +142,21 @@ GPU and battery sit idle when you are not touching the screen. renderer and the unit tests. - **`native/jni`**: the JNI bridge (`libmeshwise.so`). - **`native/cad`**: Open CASCADE import (`libmeshwise_cad.so`). It runs in its own process, so a - malformed CAD file can never take the viewer down, and its results are cached as compact - FSM meshes so a STEP file opens instantly the second time. + broken CAD file can't crash the viewer. Results are cached as compact FSM meshes, so a STEP + file opens much faster the second time. - **`app/`**: the Kotlin app (Compose Material 3 UI, view model, file handling, projects). ## Building -Requirements: JDK 17+, Android SDK 36, NDK `28.2.13676358`, CMake 3.31.6 (Android Studio can +You need JDK 17+, Android SDK 36, NDK `28.2.13676358` and CMake 3.31.6 (Android Studio can install them), plus `git` and `ninja` for the CAD kernel. ```bash git clone https://github.com/doesthings/Meshwise.git cd Meshwise -# Optional but recommended: build Open CASCADE for STEP/IGES/BREP (once, ~15-30 min per ABI). -# Without it the app still builds; CAD formats are then reported as unavailable. +# Optional but recommended: build Open CASCADE for STEP/IGES/BREP (once, about 20-40 min per ABI). +# Without it the app still builds, but it can't open CAD formats. for abi in arm64-v8a armeabi-v7a x86_64; do scripts/build-occt-android.sh $abi; done ./gradlew assembleDebug # app/build/outputs/apk/debug/ @@ -176,25 +181,31 @@ scripts/make_screenshots.sh ``` CI runs the native tests, a render smoke test, the JVM tests and a full APK build on every push. -Tagging `vX.Y.Z` publishes a release (see [docs/RELEASING.md](docs/RELEASING.md)). +Changing the `VERSION` file on `main` publishes a signed release (see [docs/RELEASING.md](docs/RELEASING.md)). ## Support Meshwise is free, open source and ad-free, and it will stay that way. If it saves you time, -please consider starring the repo or buying me a coffee. It keeps development going. +you can star the repo or buy me a coffee. Both help keep development going.

Star on GitHub Buy me a coffee on Ko-fi

-Bug reports and model files that don't open correctly are just as valuable: please +Bug reports help just as much, especially files that don't open or look wrong: [open an issue](../../issues/new/choose). ## Privacy -Meshwise has **no internet permission**. It reads only the files you open, keeps recents, -thumbnails, projects and the library in its private storage, and collects nothing. +Meshwise collects nothing. It reads only the files you open and keeps recent files, +thumbnails, projects and the library in its private storage. Your files never leave your phone. + +The app's only network use is the **optional update check**. On first launch Meshwise asks +whether it may check for updates; the answer is no until you say yes, and you can change it in +Settings at any time. When it's on, the app asks GitHub for the latest release at most once a +day. It sends nothing else, and nothing is downloaded until you tap **Download**. The Ko-fi +and GitHub buttons open your browser. ## License diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 1ab9648..79fb908 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -12,12 +12,13 @@ the following components. ## Open CASCADE Technology (LGPL 2.1) -OCCT is used unmodified. It is built from the upstream `V7_8_1` tag by -[`scripts/build-occt-android.sh`](scripts/build-occt-android.sh) and statically linked into -`libmeshwise_cad.so`, which contains only the CAD import bridge (`native/cad/`). To exercise your -LGPL rights you can rebuild OCCT (modified or not) with that script, rebuild the app with -`./gradlew assembleRelease`, and install the result. The full corresponding source of this -app and the build scripts are in this repository. +OCCT is used unmodified. [`scripts/build-occt-android.sh`](scripts/build-occt-android.sh) +builds it from the upstream `V7_8_1` tag, and it is statically linked into +`libmeshwise_cad.so`, which holds only the CAD import bridge (`native/cad/`). + +To use your LGPL rights, rebuild OCCT (changed or not) with that script, rebuild the app with +`./gradlew assembleRelease`, and install the result. The full source of the app and the build +scripts are in this repository. License text: and the exception: . diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b95615c..ec2ac07 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -132,6 +132,9 @@ android { test.systemProperty("robolectric.dependency.repo.id", "mirror") } test.maxHeapSize = "3g" + // The JNI library can only be loaded by one classloader per JVM, and test classes + // with different Robolectric configs get different classloaders. + test.forkEvery = 1 } } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 87ad6a6..74a2a19 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,9 @@ + + + ? { + val core = text.trim().removePrefix("v").removePrefix("V").takeWhile { it.isDigit() || it == '.' } + val parts = core.split('.').filter { it.isNotEmpty() } + if (parts.isEmpty() || parts.size > 4) return null + return parts.map { it.toIntOrNull() ?: return null } + } + + /** True when [candidate] is a higher version than [current]; unparsable versions never are. */ + fun isNewer(candidate: String, current: String): Boolean { + val a = parseVersion(candidate) ?: return false + val b = parseVersion(current) ?: return false + for (i in 0 until maxOf(a.size, b.size)) { + val x = a.getOrElse(i) { 0 } + val y = b.getOrElse(i) { 0 } + if (x != y) return x > y + } + return false + } + + /** + * Reads GitHub's "latest release" JSON. Returns the release if it is newer than + * [currentVersion], or null. Drafts and pre-releases never count. + */ + fun parseLatest(json: String, currentVersion: String, abis: List = Build.SUPPORTED_ABIS.toList()): UpdateInfo? { + val o = JSONObject(json) + if (o.optBoolean("draft") || o.optBoolean("prerelease")) return null + val tag = o.optString("tag_name") + if (!isNewer(tag, currentVersion)) return null + val version = tag.removePrefix("v").removePrefix("V") + val page = o.optString("html_url").takeIf { it.startsWith("https://github.com/") } + ?: "https://github.com/doesthings/Meshwise/releases/latest" + val apks = buildMap { + val assets = o.optJSONArray("assets") + for (i in 0 until (assets?.length() ?: 0)) { + val a = assets!!.getJSONObject(i) + val name = a.optString("name") + val url = a.optString("browser_download_url") + if (name.endsWith(".apk") && url.startsWith("https://github.com/")) put(name, url) + } + } + // Same naming as the release workflow: Meshwise--.apk + val download = (abis + "universal").firstNotNullOfOrNull { abi -> apks["Meshwise-$version-$abi.apk"] } ?: page + return UpdateInfo(version, download, page) + } + + /** Asks GitHub for the latest release. Blocking; throws IOException when GitHub can't be reached. */ + fun fetchLatest(currentVersion: String): UpdateInfo? { + val conn = URL(LATEST_RELEASE_API).openConnection() as HttpURLConnection + try { + conn.connectTimeout = 10_000 + conn.readTimeout = 10_000 + conn.useCaches = false + conn.setRequestProperty("Accept", "application/vnd.github+json") + conn.setRequestProperty("User-Agent", "Meshwise") + return when (val code = conn.responseCode) { + 200 -> parseLatest(conn.inputStream.bufferedReader().use { it.readText() }, currentVersion) + 404 -> null // no published release yet + else -> throw IOException("GitHub answered $code") + } + } finally { + conn.disconnect() + } + } +} diff --git a/app/src/main/java/io/github/doesthings/meshwise/ui/AppRoot.kt b/app/src/main/java/io/github/doesthings/meshwise/ui/AppRoot.kt index 2008f05..fc7f67e 100644 --- a/app/src/main/java/io/github/doesthings/meshwise/ui/AppRoot.kt +++ b/app/src/main/java/io/github/doesthings/meshwise/ui/AppRoot.kt @@ -9,12 +9,21 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput @@ -22,6 +31,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.github.doesthings.meshwise.R +import io.github.doesthings.meshwise.data.UPDATES_NOT_ASKED +import io.github.doesthings.meshwise.ui.components.openUrl import io.github.doesthings.meshwise.ui.home.HomeScreen import io.github.doesthings.meshwise.ui.library.LibraryScreen import io.github.doesthings.meshwise.ui.settings.AboutScreen @@ -38,6 +50,7 @@ fun AppRoot(vm: ViewerViewModel) { val model by vm.model.collectAsStateWithLifecycle() val loading by vm.loading.collectAsStateWithLifecycle() val settings by vm.settings.collectAsStateWithLifecycle() + val update by vm.update.collectAsStateWithLifecycle() val snackbar = remember { SnackbarHostState() } val context = LocalContext.current @@ -84,6 +97,26 @@ fun AppRoot(vm: ViewerViewModel) { loading?.let { LoadingOverlay(it, onCancel = vm::cancelLoading) } + UpdateDialogs(vm, askNow = screen == Screen.Home && loading == null && settings.updateChecks == UPDATES_NOT_ASKED) + update?.let { info -> + AlertDialog( + onDismissRequest = { vm.dismissUpdate(skip = false) }, + title = { Text(stringResource(R.string.update_available_title, info.version)) }, + text = { Text(stringResource(R.string.update_available_body, vm.currentVersion)) }, + confirmButton = { + Button(onClick = { openUrl(context, info.downloadUrl); vm.dismissUpdate(skip = false) }) { + Text(stringResource(R.string.update_download)) + } + }, + dismissButton = { + Row { + TextButton(onClick = { vm.dismissUpdate(skip = true) }) { Text(stringResource(R.string.update_skip)) } + TextButton(onClick = { vm.dismissUpdate(skip = false) }) { Text(stringResource(R.string.update_later)) } + } + }, + ) + } + SnackbarHost( snackbar, modifier = Modifier.align(Alignment.BottomCenter).navigationBarsPadding().padding(bottom = 96.dp), @@ -97,3 +130,17 @@ fun Modifier.blockTouches(): Modifier = this.pointerInput(Unit) { while (true) awaitPointerEvent() } } + +/** Asks once whether Meshwise may check GitHub for updates. Closing the dialog asks again next launch. */ +@Composable +private fun UpdateDialogs(vm: ViewerViewModel, askNow: Boolean) { + var dismissed by rememberSaveable { mutableStateOf(false) } + if (!askNow || dismissed) return + AlertDialog( + onDismissRequest = { dismissed = true }, + title = { Text(stringResource(R.string.update_ask_title)) }, + text = { Text(stringResource(R.string.update_ask_body)) }, + confirmButton = { Button(onClick = { vm.setUpdateChecks(true) }) { Text(stringResource(R.string.update_ask_yes)) } }, + dismissButton = { TextButton(onClick = { vm.setUpdateChecks(false) }) { Text(stringResource(R.string.update_ask_no)) } }, + ) +} diff --git a/app/src/main/java/io/github/doesthings/meshwise/ui/components/Support.kt b/app/src/main/java/io/github/doesthings/meshwise/ui/components/Support.kt index 1f91513..9c30473 100644 --- a/app/src/main/java/io/github/doesthings/meshwise/ui/components/Support.kt +++ b/app/src/main/java/io/github/doesthings/meshwise/ui/components/Support.kt @@ -43,7 +43,7 @@ const val KOFI_URL = "https://ko-fi.com/doesthings" /** Ko-fi's brand coral. */ val KofiColor = Color(0xFFFF5E5B) -/** Opens [url] in the browser. The app has no network access of its own; links leave the app. */ +/** Opens [url] in the browser. Links leave the app; its only own network use is the optional update check. */ fun openUrl(context: Context, url: String) { try { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) diff --git a/app/src/main/java/io/github/doesthings/meshwise/ui/settings/SettingsScreen.kt b/app/src/main/java/io/github/doesthings/meshwise/ui/settings/SettingsScreen.kt index 7803607..624d0bf 100644 --- a/app/src/main/java/io/github/doesthings/meshwise/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/io/github/doesthings/meshwise/ui/settings/SettingsScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material.icons.rounded.Code import androidx.compose.material.icons.rounded.Gavel import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider @@ -43,6 +44,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.doesthings.meshwise.BuildConfig +import io.github.doesthings.meshwise.data.UPDATES_ON import io.github.doesthings.meshwise.R import io.github.doesthings.meshwise.data.Units import io.github.doesthings.meshwise.ui.components.REPO_URL @@ -121,6 +123,12 @@ fun SettingsScreen(vm: ViewerViewModel) { LinkRow(Icons.Rounded.Info, stringResource(R.string.clear_cache)) { vm.clearCache() } LinkRow(Icons.Rounded.Info, stringResource(R.string.clear_recents)) { vm.clearRecents() } } + SectionLabel(stringResource(R.string.updates)) + Card { + SwitchRow(stringResource(R.string.update_checks), s.updateChecks == UPDATES_ON, { v -> vm.setUpdateChecks(v) }) + Text(stringResource(R.string.update_checks_summary), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + LinkRow(Icons.Rounded.Refresh, stringResource(R.string.update_check_now)) { vm.checkForUpdates(manual = true) } + } SectionLabel(stringResource(R.string.support)) SupportCard() SectionLabel(stringResource(R.string.about)) @@ -154,8 +162,9 @@ fun AboutScreen(vm: ViewerViewModel) { Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) Text(stringResource(R.string.version, BuildConfig.VERSION_NAME), style = MaterialTheme.typography.bodyMedium) Text( - "A fast, native 3D model viewer for Android: OpenGL ES 3 rendering, a multi-threaded C++ mesh pipeline and an " + - "embedded Open CASCADE kernel for STEP and IGES.", + "A fast, free and open-source 3D model viewer for 3D printing and CAD files. No ads, no account and no " + + "tracking; your files never leave your phone. Built on a C++ core with OpenGL ES 3, and Open CASCADE " + + "for STEP and IGES.", style = MaterialTheme.typography.bodyMedium, ) LinkRow(Icons.Rounded.Code, stringResource(R.string.source_code)) { openUrl(context, REPO_URL) } diff --git a/app/src/main/java/io/github/doesthings/meshwise/viewer/ViewerViewModel.kt b/app/src/main/java/io/github/doesthings/meshwise/viewer/ViewerViewModel.kt index 5d5b54d..1b78acf 100644 --- a/app/src/main/java/io/github/doesthings/meshwise/viewer/ViewerViewModel.kt +++ b/app/src/main/java/io/github/doesthings/meshwise/viewer/ViewerViewModel.kt @@ -7,6 +7,7 @@ import android.util.Log import androidx.core.content.FileProvider import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import io.github.doesthings.meshwise.BuildConfig import io.github.doesthings.meshwise.MeshwiseApp import io.github.doesthings.meshwise.R import io.github.doesthings.meshwise.cad.CadCrashException @@ -20,6 +21,10 @@ import io.github.doesthings.meshwise.data.PartState import io.github.doesthings.meshwise.data.ProjectState import io.github.doesthings.meshwise.data.RecentFile import io.github.doesthings.meshwise.data.SettingsValues +import io.github.doesthings.meshwise.data.UPDATES_OFF +import io.github.doesthings.meshwise.data.UPDATES_ON +import io.github.doesthings.meshwise.data.UpdateInfo +import io.github.doesthings.meshwise.data.Updates import io.github.doesthings.meshwise.data.baseName import io.github.doesthings.meshwise.data.sha1 import io.github.doesthings.meshwise.engine.CameraFrame @@ -150,6 +155,56 @@ class ViewerViewModel(application: Application) : AndroidViewModel(application) } } + // ------------------------------------------------------------------ updates + + private val _update = MutableStateFlow(null) + /** A newer release to offer, or null. */ + val update: StateFlow = _update.asStateFlow() + + /** Fetches the newest release newer than the given version; replaceable in tests. */ + var updateSource: (currentVersion: String) -> UpdateInfo? = Updates::fetchLatest + var currentVersion: String = BuildConfig.VERSION_NAME + var now: () -> Long = System::currentTimeMillis + private var updateJob: Job? = null + + init { + if (settings.value.updateChecks == UPDATES_ON) checkForUpdates(manual = false) + } + + /** The answer to "Check for updates?" (first launch, or the switch in Settings). */ + fun setUpdateChecks(on: Boolean) { + updateSettings { it.copy(updateChecks = if (on) UPDATES_ON else UPDATES_OFF) } + if (on) checkForUpdates(manual = false) + } + + /** + * Asks GitHub for the latest release. Automatic checks run at most once a day, stay silent + * on errors and skip a version the user chose to skip; a manual check always runs and + * reports what it found. + */ + fun checkForUpdates(manual: Boolean) { + val s = settings.value + if (!manual && (s.updateChecks != UPDATES_ON || now() - s.lastUpdateCheck < Updates.CHECK_INTERVAL_MS)) return + if (updateJob?.isActive == true) return + updateJob = viewModelScope.launch { + val result = withContext(Dispatchers.IO) { runCatching { updateSource(currentVersion) } } + updateSettings { it.copy(lastUpdateCheck = now()) } + val info = result.getOrNull() + when { + result.isFailure -> if (manual) emit(app.getString(R.string.update_check_failed)) + info == null -> if (manual) emit(app.getString(R.string.update_none, currentVersion)) + manual || info.version != settings.value.skippedUpdate -> _update.value = info + } + } + } + + /** Closes the update prompt; [skip] stops automatic checks from offering this version again. */ + fun dismissUpdate(skip: Boolean) { + val info = _update.value ?: return + if (skip) updateSettings { it.copy(skippedUpdate = info.version) } + _update.value = null + } + // ------------------------------------------------------------------ GL host fun attachGl(host: GlHost) { @@ -775,10 +830,14 @@ class ViewerViewModel(application: Application) : AndroidViewModel(application) job?.isActive != false }) if (r != null) { - _thickness.update { - it.copy(computing = false, result = r, thresholdValue = if (it.result == null) defaultThreshold(r.min) else it.thresholdValue) + // Publish the result and switch the shading together, so nobody sees the + // result with the old shading mode. + withContext(Dispatchers.Main) { + _thickness.update { + it.copy(computing = false, result = r, thresholdValue = if (it.result == null) defaultThreshold(r.min) else it.thresholdValue) + } + applyAnalyzeMode() } - withContext(Dispatchers.Main) { applyAnalyzeMode() } } else { _thickness.update { it.copy(computing = false) } } diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..eb3d00c --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 23f2000..864d409 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,17 +1,27 @@ + - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml index 4d03a89..8792e55 100644 --- a/app/src/main/res/drawable/ic_launcher_monochrome.xml +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -1,11 +1,19 @@ + - - - - + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index c78bee3..8fde456 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,6 @@ - + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index c78bee3..8fde456 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,6 +1,6 @@ - + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 3078dab..55407b2 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,5 +1,4 @@ #1E272C - #1C262C diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 36ff1e5..2b3e368 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,8 +8,8 @@ Remove all entries from the recent files list? Your files are not deleted. Library Models saved inside the app - Open 3D Model - STL · OBJ · 3MF · PLY · STEP · IGES · BREP · ZIP + Open 3D model + STL · OBJ · 3MF · PLY · OFF · STEP · IGES · BREP · ZIP Nothing opened yet Open a model from your phone, or try one of the samples below. Samples @@ -24,8 +24,8 @@ Unpacking archive… Cancel Could not open %1$s - The CAD converter stopped unexpectedly while reading this file. - STEP/IGES import is not available on this device architecture. + The CAD converter stopped while reading this file. It may be damaged or too complex. + STEP, IGES and BREP files can\'t be opened on this device\'s processor type. Fit view @@ -66,8 +66,8 @@ Tap three points on a round edge or hole Tap three points: start, vertex, end Tap a point to read its coordinates - Measured (tap for new) - %1$d more point(s) + Tap to measure again + Tap %1$d more point(s) Radius (R) Diameter (D) Center (XYZ) @@ -77,7 +77,7 @@ Normal Copy Copied to clipboard - These points are in a line — pick points around the circle + These points are in a straight line. Pick points spread around the circle. Weight estimate @@ -93,7 +93,7 @@ All Visible Selected - The mesh is not closed; volume and weight are approximate. + The mesh has holes, so volume and weight are approximate. Color @@ -219,17 +219,34 @@ Feature edges - Your library is empty. Save models here from the Save menu to keep them on this device. + Your library is empty. To keep a copy of a model on this device, use Save & export → Save to library. Delete Rename Import file Delete %1$s from the library? OK + + Updates + Check for updates + Once a day, asks GitHub for the latest version number. Nothing else is sent. + Check now + Check for updates? + Meshwise can check GitHub once a day for a new version and ask you before anything is downloaded. It only asks for the latest version number and sends nothing about you or your files.\n\nYou can change this any time in Settings. + Check for updates + No thanks + Meshwise %1$s is available + You have version %1$s. Download the new version from GitHub? It installs over this one and keeps your files, projects and settings. + Download + Later + Skip this version + You have the latest version (%1$s) + Couldn\'t reach GitHub. Check your connection and try again. + Support Enjoying Meshwise? - Meshwise is free and open source, with no ads and no tracking. If it saves you time, consider buying me a coffee. It keeps development going. + Meshwise is free and open source, with no ads and no tracking. If it saves you time, you can buy me a coffee. It helps keep development going. Buy me a coffee Star on GitHub Support Meshwise on Ko-fi diff --git a/app/src/test/java/io/github/doesthings/meshwise/EngineReleaseTest.kt b/app/src/test/java/io/github/doesthings/meshwise/EngineReleaseTest.kt new file mode 100644 index 0000000..1ac0d43 --- /dev/null +++ b/app/src/test/java/io/github/doesthings/meshwise/EngineReleaseTest.kt @@ -0,0 +1,84 @@ +package io.github.doesthings.meshwise + +import io.github.doesthings.meshwise.engine.CameraState +import io.github.doesthings.meshwise.engine.Engine +import io.github.doesthings.meshwise.engine.ExportFormat +import io.github.doesthings.meshwise.engine.MaterialPreset +import io.github.doesthings.meshwise.engine.RenderOptions +import io.github.doesthings.meshwise.engine.StandardView +import io.github.doesthings.meshwise.engine.Vec3 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** UI and GL callbacks can still arrive after the engine is released; none of them may crash. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class EngineReleaseTest { + @Before + fun setUp() { + assumeTrue("JVM build of libmeshwise.so missing", TestSupport.nativeAvailable()) + } + + @Test + fun everyCallIsSafeAfterRelease() { + val e = Engine() + e.release() + assertEquals(0L, e.handle) + e.release() + + e.onSurfaceCreated() + e.onSurfaceChanged(100, 100) + assertFalse(e.drawFrame()) + assertNull(e.snapshot(16, 16, false)) + assertNull(e.thumbnail(16)) + + e.clearModel() + assertFalse(e.buildAnalysis(null)) + assertNull(e.meshCheck()) + assertNull(e.computeThickness(null)) + e.clearScalars() + assertNull(e.overhang(45f)) + + e.orbit(1f, 1f) + e.pan(1f, 1f) + e.zoom(1.1f, 0f, 0f) + e.fling(1f, 1f) + e.stopMotion() + e.setStandardView(StandardView.Top) + e.setOrientation(0.5f, 0.5f) + e.fitView() + e.focusPart(0) + e.focusPoint(Vec3(0f, 0f, 0f), 1f) + e.setOrtho(true) + e.setViewInsets(0f, 0f) + e.setTurntable(true) + e.setSensitivity(1f) + e.camera() + e.setCamera(CameraState(Vec3(0f, 0f, 0f), 10f, 0f, 0f, false), false) + e.cameraFrame() + assertNull(e.pick(1f, 1f)) + + e.setPartColor(0, 0) + e.setPartVisible(0, false) + e.setPartSelected(0, true) + e.setPartHighlight(0, true) + e.setPartGhost(0, true) + e.setPartMaterial(0, MaterialPreset.entries.first()) + e.setPartVertexColors(0, true) + e.setPartOffset(0, Vec3(1f, 1f, 1f)) + e.partOffset(0) + e.setExplode(0.5f) + e.resetParts() + + e.setOptions(RenderOptions()) + e.sceneBounds() + assertFalse(e.export(-1, ExportFormat.STL, null, false)) + } +} diff --git a/app/src/test/java/io/github/doesthings/meshwise/ScreenshotTest.kt b/app/src/test/java/io/github/doesthings/meshwise/ScreenshotTest.kt index 6e75953..07cda2a 100644 --- a/app/src/test/java/io/github/doesthings/meshwise/ScreenshotTest.kt +++ b/app/src/test/java/io/github/doesthings/meshwise/ScreenshotTest.kt @@ -11,6 +11,7 @@ import com.github.takahirom.roborazzi.captureRoboImage import io.github.doesthings.meshwise.TestSupport.awaitUntil import io.github.doesthings.meshwise.data.ModelSource import io.github.doesthings.meshwise.data.RecentFile +import io.github.doesthings.meshwise.data.UPDATES_OFF import io.github.doesthings.meshwise.data.sha1 import io.github.doesthings.meshwise.engine.MaterialPreset import io.github.doesthings.meshwise.engine.Vec3 @@ -53,6 +54,7 @@ class ScreenshotTest { fun setUp() { assumeTrue(TestSupport.nativeAvailable()) app.recents.clear() + app.settings.update { it.copy(updateChecks = UPDATES_OFF) } vm = ViewerViewModel(app) vm.density = 3f vm.engine.onSurfaceChanged(1080, 2400) diff --git a/app/src/test/java/io/github/doesthings/meshwise/UpdateCheckTest.kt b/app/src/test/java/io/github/doesthings/meshwise/UpdateCheckTest.kt new file mode 100644 index 0000000..ceaa623 --- /dev/null +++ b/app/src/test/java/io/github/doesthings/meshwise/UpdateCheckTest.kt @@ -0,0 +1,113 @@ +package io.github.doesthings.meshwise + +import androidx.test.core.app.ApplicationProvider +import io.github.doesthings.meshwise.TestSupport.awaitUntil +import io.github.doesthings.meshwise.data.UPDATES_NOT_ASKED +import io.github.doesthings.meshwise.data.UPDATES_OFF +import io.github.doesthings.meshwise.data.UPDATES_ON +import io.github.doesthings.meshwise.data.UpdateInfo +import io.github.doesthings.meshwise.data.Updates +import io.github.doesthings.meshwise.viewer.ViewerViewModel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException +import java.util.concurrent.atomic.AtomicInteger + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class UpdateCheckTest { + private val release = """ + {"tag_name": "v1.2.0", "html_url": "https://github.com/doesthings/Meshwise/releases/tag/v1.2.0", + "draft": false, "prerelease": false, "assets": [ + {"name": "Meshwise-1.2.0-arm64-v8a.apk", "browser_download_url": "https://github.com/doesthings/Meshwise/releases/download/v1.2.0/Meshwise-1.2.0-arm64-v8a.apk"}, + {"name": "Meshwise-1.2.0-universal.apk", "browser_download_url": "https://github.com/doesthings/Meshwise/releases/download/v1.2.0/Meshwise-1.2.0-universal.apk"}, + {"name": "SHA256SUMS.txt", "browser_download_url": "https://github.com/doesthings/Meshwise/releases/download/v1.2.0/SHA256SUMS.txt"} + ]} + """.trimIndent() + + @Test + fun comparesVersions() { + assertEquals(listOf(1, 0, 1), Updates.parseVersion("v1.0.1")) + assertEquals(listOf(1, 0, 1), Updates.parseVersion("1.0.1-debug")) + assertNull(Updates.parseVersion("latest")) + assertTrue(Updates.isNewer("v1.0.1", "1.0.0")) + assertTrue(Updates.isNewer("v1.10.0", "1.9.9")) + assertTrue(Updates.isNewer("v2.0", "1.9.9")) + assertFalse(Updates.isNewer("v1.0.0", "1.0.0")) + assertFalse(Updates.isNewer("v1.0.0", "1.0.1-debug")) + assertFalse(Updates.isNewer("nightly", "1.0.0")) + } + + @Test + fun picksTheApkForThisDevice() { + val arm = Updates.parseLatest(release, "1.0.1", listOf("arm64-v8a", "armeabi-v7a"))!! + assertEquals("1.2.0", arm.version) + assertTrue(arm.downloadUrl.endsWith("Meshwise-1.2.0-arm64-v8a.apk")) + val x86 = Updates.parseLatest(release, "1.0.1", listOf("x86_64"))!! + assertTrue(x86.downloadUrl.endsWith("Meshwise-1.2.0-universal.apk")) + val noAssets = Updates.parseLatest("""{"tag_name": "v1.2.0", "html_url": "https://github.com/doesthings/Meshwise/releases/tag/v1.2.0"}""", "1.0.1")!! + assertEquals(noAssets.pageUrl, noAssets.downloadUrl) + } + + @Test + fun ignoresOlderDraftAndPrereleases() { + assertNull(Updates.parseLatest(release, "1.2.0")) + assertNull(Updates.parseLatest(release.replace("\"draft\": false", "\"draft\": true"), "1.0.1")) + assertNull(Updates.parseLatest(release.replace("\"prerelease\": false", "\"prerelease\": true"), "1.0.1")) + } + + @Test + fun viewModelFlow() { + assumeTrue("JVM build of libmeshwise.so missing", TestSupport.nativeAvailable()) + val vm = ViewerViewModel(ApplicationProvider.getApplicationContext()) + val calls = AtomicInteger() + var clock = 1_000_000_000L + var answer: () -> UpdateInfo? = { UpdateInfo("1.2.0", "https://example.invalid/a.apk", "https://example.invalid/") } + vm.updateSource = { calls.incrementAndGet(); answer() } + vm.currentVersion = "1.0.1" + vm.now = { clock } + + // Nothing happens until the user says yes. + assertEquals(UPDATES_NOT_ASKED, vm.settings.value.updateChecks) + vm.checkForUpdates(manual = false) + assertEquals(0, calls.get()) + + vm.setUpdateChecks(true) + assertEquals(UPDATES_ON, vm.settings.value.updateChecks) + awaitUntil { vm.update.value != null } + assertEquals("1.2.0", vm.update.value!!.version) + assertEquals(1, calls.get()) + + // Skipping hides it from automatic checks, which also wait a day. + vm.dismissUpdate(skip = true) + assertNull(vm.update.value) + vm.checkForUpdates(manual = false) + assertEquals(1, calls.get()) + clock += Updates.CHECK_INTERVAL_MS + 1 + vm.checkForUpdates(manual = false) + awaitUntil { calls.get() == 2 && vm.settings.value.lastUpdateCheck == clock } + assertNull(vm.update.value) + + // A manual check still offers it, and reports errors. + vm.checkForUpdates(manual = true) + awaitUntil { vm.update.value != null } + vm.dismissUpdate(skip = false) + answer = { throw IOException("offline") } + vm.checkForUpdates(manual = true) + awaitUntil { calls.get() == 4 } + assertNull(vm.update.value) + + vm.setUpdateChecks(false) + assertEquals(UPDATES_OFF, vm.settings.value.updateChecks) + clock += Updates.CHECK_INTERVAL_MS + 1 + vm.checkForUpdates(manual = false) + assertEquals(4, calls.get()) + } +} diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 0354dbc..68785d1 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,29 +1,33 @@ # Releasing -Releases are built by [`.github/workflows/release.yml`](../.github/workflows/release.yml). -It builds Open CASCADE for every ABI (cached after the first run), assembles the release APKs, -checks that they are signed with the Meshwise key and publishes them, with SHA-256 checksums, -on the repository's **Releases** page. +The [Release workflow](../.github/workflows/release.yml) does all the work: -To publish a new version: +1. builds Open CASCADE for every ABI (cached after the first run), +2. builds the release APKs, +3. checks that every APK is signed with the Meshwise key, +4. publishes the APKs and `SHA256SUMS.txt` on the **Releases** page. -1. Update `CHANGELOG.md` and add `docs/release-notes/vX.Y.Z.md` (it becomes the release text). -2. Change the version in the `VERSION` file and push to `main`. +## Publish a new version -The **Release** workflow notices the new version, builds and verifies the APKs and publishes -release `vX.Y.Z` (creating the tag). Pushing a `vX.Y.Z` tag or running the workflow by hand -from the Actions tab works too. +1. Add a `CHANGELOG.md` entry and `docs/release-notes/vX.Y.Z.md` (this becomes the release text). +2. Put the new version in the `VERSION` file. +3. Push to `main`. -The version code is derived from the version (`major*10000 + minor*100 + patch`, times ten -plus an ABI digit for the split APKs), so every release installs over the previous one. +The workflow sees the new version and publishes release `vX.Y.Z`, creating the tag. If that tag +already exists, it does nothing. Pushing a `vX.Y.Z` tag, or running the workflow by hand from +the Actions tab, also works. + +The version code comes from the version (`major*10000 + minor*100 + patch`, times ten, plus an +ABI digit for the split APKs), so each release installs over the previous one. ## Signing key (one-time setup) -A release is only published when every APK is signed with the Meshwise release key: the -workflow checks the certificate against `EXPECTED_CERT_SHA256` in `release.yml` and fails -otherwise, including when the signing secrets are missing. For a quick unsigned check, run the -workflow manually with **Test build** ticked; the APKs are then attached to the run as an -artifact and nothing is published. +A release is only published when every APK is signed with the Meshwise release key. The +workflow compares each certificate with `EXPECTED_CERT_SHA256` in `release.yml` and fails if +any differs, or if the signing secrets are missing. + +To try a build without publishing, run the workflow by hand with **Test build** ticked. The APKs +are then attached to the run as an artifact. The project's key already exists (owner's private backup). To create a key for a fork: @@ -42,9 +46,9 @@ Then add these repository secrets under **Settings → Secrets and variables → | `SIGNING_KEY_ALIAS` | `meshwise` (or the alias you chose) | | `SIGNING_KEY_PASSWORD` | key password | -Keep `release.jks` and its passwords somewhere safe and **never commit them**: losing the key -means users cannot update, and leaking it lets anyone publish updates in your name. A fork -with its own key must also update `EXPECTED_CERT_SHA256` in `release.yml`. +Keep `release.jks` and its passwords somewhere safe and **never commit them**. If you lose the +key, users can't update. If it leaks, anyone can publish updates in your name. A fork with its +own key must also change `EXPECTED_CERT_SHA256` in `release.yml`. ## Local release builds @@ -57,5 +61,4 @@ keyAlias=meshwise keyPassword=... ``` -and run `./gradlew assembleRelease -Pfsv.versionName=1.1.0`. Without the file the release -build falls back to the debug key. +Then run `./gradlew assembleRelease`. Without the file, the release build uses the debug key. diff --git a/docs/images/hero.png b/docs/images/hero.png index 2f00a7a..d19a5f8 100644 Binary files a/docs/images/hero.png and b/docs/images/hero.png differ diff --git a/docs/images/icon.png b/docs/images/icon.png new file mode 100644 index 0000000..b59eb1e Binary files /dev/null and b/docs/images/icon.png differ diff --git a/docs/release-notes/v1.0.0.md b/docs/release-notes/v1.0.0.md index fdd16c4..c2983e9 100644 --- a/docs/release-notes/v1.0.0.md +++ b/docs/release-notes/v1.0.0.md @@ -2,23 +2,23 @@ ## Meshwise 1.0.0 -**Meshwise** is a fast, free and open-source 3D model viewer for Android. It's built for checking 3D prints and CAD parts on your phone. +**Meshwise** is a fast, free and open-source 3D model viewer for Android. Use it to check 3D prints and CAD parts on your phone. -**Open almost anything:** STL, OBJ, 3MF, PLY, OFF, STEP, IGES, BREP and ZIP archives, straight from Files, Drive, email or any app that shares a model. +**Opens:** STL, OBJ, 3MF, PLY, OFF, STEP, IGES, BREP and ZIP archives. Open them in the app, or from Files, Drive, email or any app that can share a file. **Inspect:** - Section cuts with hatched caps -- Wall-thickness heat map with thinnest-wall callouts +- Wall-thickness heat map that points out the thinnest walls - Overhang view and mesh check -- Measure distance, circle (radius, diameter, center), angle and point coordinates +- Measure distances, circles (radius, diameter, center), angles and points - Weight, filament and cost estimates for 3D printing -**Assemblies:** parts list, exploded view with a drag gizmo, x-ray, per-part colors and materials. +**Assemblies:** parts list, exploded view with a drag gizmo, x-ray, and colors and materials per part. **Save and share:** projects, plus export to STL, OBJ, 3MF and PNG. -No ads, no account, no tracking. The app does not even have network permission. +**Private:** no ads, no account, no tracking, and your files never leave your phone. The only network use is an optional update check: on first launch the app asks whether it may check GitHub for new versions, and it stays off unless you say yes. When on, it asks GitHub for the latest version number at most once a day and sends nothing else; you decide whether to download. -> Honest note: Meshwise is vibe coded (built with a lot of help from an AI coding assistant). It works well enough, and I just wanted to share a good free tool with whoever needs one. Bug reports are very welcome. +> **Honest note:** Meshwise is vibe coded: I built it with a lot of help from an AI coding assistant. It works well enough, and I just wanted to share a good free tool with whoever needs one. Bug reports are very welcome. If Meshwise is useful to you, you can [buy me a coffee on Ko-fi](https://ko-fi.com/doesthings) ☕ diff --git a/docs/screenshots/01_home.png b/docs/screenshots/01_home.png index e1f9b62..1e2f13e 100644 Binary files a/docs/screenshots/01_home.png and b/docs/screenshots/01_home.png differ diff --git a/docs/screenshots/04_measure.png b/docs/screenshots/04_measure.png index 3a0d7c7..b935329 100644 Binary files a/docs/screenshots/04_measure.png and b/docs/screenshots/04_measure.png differ diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 2a1db44..8550b56 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -70,8 +70,8 @@ else() add_executable(fsv_render host/render_main.cpp host/egl_context.cpp host/png.cpp) target_link_libraries(fsv_render PRIVATE fsvcore ${EGL_LIB}) - add_executable(fsv_tests tests/tests.cpp) - target_link_libraries(fsv_tests PRIVATE fsvcore) + add_executable(fsv_tests tests/tests.cpp host/egl_context.cpp) + target_link_libraries(fsv_tests PRIVATE fsvcore ${EGL_LIB}) add_executable(fsv_bench host/bench.cpp) target_link_libraries(fsv_bench PRIVATE fsvcore) diff --git a/native/core/engine.cpp b/native/core/engine.cpp index cfa3d5f..3017757 100644 --- a/native/core/engine.cpp +++ b/native/core/engine.cpp @@ -16,13 +16,14 @@ Engine::Engine() { } void Engine::onSurfaceCreated() { - std::lock_guard lk(mu_); + std::lock_guard gl(glMu_); // A new context: previous GL names are gone. renderer_.invalidate(); glReady_ = renderer_.init(); + if (!glReady_) FSV_LOGE("renderer init failed"); + std::lock_guard lk(mu_); modelDirty_ = true; hadFrame_ = false; - if (!glReady_) FSV_LOGE("renderer init failed"); } void Engine::onSurfaceChanged(int w, int h) { @@ -95,79 +96,123 @@ FrameParams Engine::frameParamsLocked(const Camera& cam) const { return f; } -void Engine::syncGpuLocked() { - if (modelDirty_) { - if (model_) renderer_.uploadModel(*model_); +// Takes what changed from the scene under mu_, then uploads it holding only glMu_. The +// first upload of a big model can take a while; UI calls (touch, setModel, analysis +// results) must not wait for it. Everything is copied in one critical section, so the +// GPU always gets a consistent model/parts pair; a change made during the upload just +// marks the scene dirty again for the next frame. +void Engine::syncGpu() { + std::shared_ptr model; + std::shared_ptr topo; + std::shared_ptr> scalars; + std::vector parts; + bool uploadModel, uploadTopo, uploadScalars, uploadParts; + { + std::lock_guard lk(mu_); + uploadModel = modelDirty_; + if (modelDirty_) topoDirty_ = scalarsDirty_ = partsDirty_ = true; + uploadTopo = topoDirty_; + uploadScalars = scalarsDirty_; + uploadParts = partsDirty_; + modelDirty_ = topoDirty_ = scalarsDirty_ = partsDirty_ = false; + model = model_; + topo = topo_; + scalars = scalars_; + if (uploadParts) buildPartGpu(parts); + } + if (uploadModel) { + if (uploadHook_) uploadHook_(); + if (model) renderer_.uploadModel(*model); else renderer_.clearModel(); - modelDirty_ = false; - topoDirty_ = scalarsDirty_ = partsDirty_ = true; } - if (topoDirty_) { + if (uploadTopo && model) { static const std::vector empty; - if (model_) { - const std::vector& feature = topo_ ? topo_->featureEdges : (model_->cadEdges.empty() ? empty : model_->cadEdges); - renderer_.uploadLines(LineSet::Feature, feature); - renderer_.uploadLines(LineSet::Wire, topo_ ? topo_->allEdges : empty); - renderer_.uploadLines(LineSet::Open, topo_ ? topo_->openEdges : empty); - renderer_.uploadLines(LineSet::NonManifold, topo_ ? topo_->nonManifoldEdges : empty); - } - topoDirty_ = false; + const std::vector& feature = topo ? topo->featureEdges : model->cadEdges; + renderer_.uploadLines(LineSet::Feature, feature); + renderer_.uploadLines(LineSet::Wire, topo ? topo->allEdges : empty); + renderer_.uploadLines(LineSet::Open, topo ? topo->openEdges : empty); + renderer_.uploadLines(LineSet::NonManifold, topo ? topo->nonManifoldEdges : empty); } - if (scalarsDirty_) { - if (scalars_ && model_ && scalars_->size() == model_->vertexCount()) renderer_.uploadScalars(*scalars_); + if (uploadScalars) { + if (scalars && model && scalars->size() == model->vertexCount()) renderer_.uploadScalars(*scalars); else renderer_.clearScalars(); - scalarsDirty_ = false; - } - if (partsDirty_) { - std::vector gpu; - buildPartGpu(gpu); - renderer_.updateParts(gpu); - partsDirty_ = false; } + if (uploadParts) renderer_.updateParts(parts); } bool Engine::drawFrame() { - std::lock_guard lk(mu_); - auto now = std::chrono::steady_clock::now(); - float dt = hadFrame_ ? std::chrono::duration(now - lastFrame_).count() : 0.f; - lastFrame_ = now; - hadFrame_ = true; - bool moving = camera_.update(dt); + std::lock_guard gl(glMu_); + bool moving; + { + std::lock_guard lk(mu_); + auto now = std::chrono::steady_clock::now(); + float dt = hadFrame_ ? std::chrono::duration(now - lastFrame_).count() : 0.f; + lastFrame_ = now; + hadFrame_ = true; + moving = camera_.update(dt); + } if (!glReady_) return false; - syncGpuLocked(); - renderer_.render(frameParamsLocked(camera_), options_, 0); + syncGpu(); + FrameParams f; + RenderOptions o; + { + std::lock_guard lk(mu_); + f = frameParamsLocked(camera_); + o = options_; + // Changed while uploading: draw again soon with the new state. + moving = moving || modelDirty_ || topoDirty_ || scalarsDirty_ || partsDirty_; + } + renderer_.render(f, o, 0); return moving; } bool Engine::snapshot(int w, int h, bool transparent, std::vector& rgba) { - std::lock_guard lk(mu_); + std::lock_guard gl(glMu_); if (!glReady_) return false; - syncGpuLocked(); - Camera cam = camera_; - cam.setViewport(w, h); - RenderOptions o = options_; - o.transparentBackground = transparent; - o.lineScale = options_.lineScale * (float)h / (float)std::max(1, camera_.height()); - return renderer_.renderToPixels(frameParamsLocked(cam), o, w, h, rgba); + syncGpu(); + FrameParams f; + RenderOptions o; + { + std::lock_guard lk(mu_); + Camera cam = camera_; + cam.setViewport(w, h); + f = frameParamsLocked(cam); + o = options_; + o.transparentBackground = transparent; + o.lineScale = options_.lineScale * (float)h / (float)std::max(1, camera_.height()); + } + return renderer_.renderToPixels(f, o, w, h, rgba); } bool Engine::thumbnail(int size, std::vector& rgba) { - std::lock_guard lk(mu_); - if (!glReady_ || !model_) return false; - syncGpuLocked(); - Camera cam; - cam.setViewport(size, size); - Box3 b = sceneBoundsLocked(true); - cam.setScene(b.center(), b.radius()); - cam.setView(StandardView::Iso, false); - cam.fitBox(b, false); - RenderOptions o = options_; + std::lock_guard gl(glMu_); + if (!glReady_) return false; + syncGpu(); + FrameParams f; + RenderOptions o; + { + std::lock_guard lk(mu_); + if (!model_) return false; + Camera cam; + cam.setViewport(size, size); + Box3 b = sceneBoundsLocked(true); + cam.setScene(b.center(), b.radius()); + cam.setView(StandardView::Iso, false); + cam.fitBox(b, false); + f = frameParamsLocked(cam); + o = options_; + } o.section = false; o.mode = ShadingMode::Shaded; o.openEdges = false; o.grid = true; o.lineScale = 1.f; - return renderer_.renderToPixels(frameParamsLocked(cam), o, size, size, rgba); + return renderer_.renderToPixels(f, o, size, size, rgba); +} + +void Engine::setUploadHookForTests(std::function hook) { + std::lock_guard gl(glMu_); + uploadHook_ = std::move(hook); } void Engine::setModel(std::shared_ptr model, bool resetView) { diff --git a/native/core/engine.h b/native/core/engine.h index b58296a..973db0d 100644 --- a/native/core/engine.h +++ b/native/core/engine.h @@ -1,9 +1,14 @@ // Engine: owns the scene (model + analysis data + per-part state), the camera and the // renderer. Public methods are thread-safe; GL work happens in the draw/snapshot calls, // which must run on the GL thread. +// +// Locking: mu_ guards the scene and camera and is only held briefly. glMu_ guards the +// renderer and is held for a whole GL call, including slow GPU uploads. Lock order is +// glMu_ before mu_; nothing takes glMu_ while holding mu_. #pragma once #include +#include #include #include #include @@ -113,9 +118,12 @@ class Engine { uint64_t sceneVersion() const; + // Test hook: runs on the GL thread right before a model upload, with only glMu_ held. + void setUploadHookForTests(std::function hook); + private: void markPartsDirty() { partsDirty_ = true; ++sceneVersion_; } - void syncGpuLocked(); + void syncGpu(); // GL thread, glMu_ held, mu_ not held Box3 sceneBoundsLocked(bool visibleOnly) const; Vec3 partOffsetLocked(int part) const; void buildPartGpu(std::vector& out) const; @@ -124,7 +132,13 @@ class Engine { mutable std::mutex mu_; Camera camera_; + + // GL thread state, guarded by glMu_. + std::mutex glMu_; Renderer renderer_; + bool glReady_ = false; + std::function uploadHook_; + std::shared_ptr model_; std::shared_ptr topo_; std::shared_ptr bvh_; @@ -133,7 +147,6 @@ class Engine { float explode_ = 0.f; RenderOptions options_; bool modelDirty_ = false, topoDirty_ = false, scalarsDirty_ = false, partsDirty_ = false; - bool glReady_ = false; uint64_t sceneVersion_ = 1; std::chrono::steady_clock::time_point lastFrame_; bool hadFrame_ = false; diff --git a/native/jni/jni_core.cpp b/native/jni/jni_core.cpp index 456bcf8..316f9d1 100644 --- a/native/jni/jni_core.cpp +++ b/native/jni/jni_core.cpp @@ -256,6 +256,10 @@ FSV_JNI(jintArray, nativeModelPartColors)(JNIEnv* env, jclass, jlong m) { } FSV_JNI(void, nativeSetModel)(JNIEnv*, jclass, jlong h, jlong m, jboolean resetView) { + if (!h) { + delete M(m); // the engine is gone; don't leak the model + return; + } EngineHandle* eh = E(h); eh->setTopology(nullptr); eh->setThickness(nullptr); @@ -272,6 +276,7 @@ FSV_JNI(void, nativeSetModel)(JNIEnv*, jclass, jlong h, jlong m, jboolean resetV // ---------------------------------------------------------------- analysis FSV_JNI(jboolean, nativeBuildAnalysis)(JNIEnv* env, jclass, jlong h, jobject progress) { + if (!h) return JNI_FALSE; return guarded(env, [&]() -> jboolean { EngineHandle* eh = E(h); auto model = eh->engine.model(); @@ -289,6 +294,7 @@ FSV_JNI(jboolean, nativeBuildAnalysis)(JNIEnv* env, jclass, jlong h, jobject pro // [openEdges, nonManifoldEdges, shells, flippedEdges, degenerateFaces, edges, droppedFaces] FSV_JNI(jlongArray, nativeMeshCheck)(JNIEnv* env, jclass, jlong h) { + if (!h) return nullptr; EngineHandle* eh = E(h); auto model = eh->engine.model(); auto topo = eh->topology(); @@ -303,6 +309,7 @@ FSV_JNI(jlongArray, nativeMeshCheck)(JNIEnv* env, jclass, jlong h) { // [min, max, p05, p95, spotCount, (x, y, z, nx, ny, nz, value, part) * spotCount] FSV_JNI(jfloatArray, nativeComputeThickness)(JNIEnv* env, jclass, jlong h, jobject progress) { + if (!h) return nullptr; return guarded(env, [&]() -> jfloatArray { EngineHandle* eh = E(h); auto model = eh->engine.model(); @@ -329,11 +336,13 @@ FSV_JNI(jfloatArray, nativeComputeThickness)(JNIEnv* env, jclass, jlong h, jobje } FSV_JNI(void, nativeClearScalars)(JNIEnv*, jclass, jlong h) { + if (!h) return; E(h)->setThickness(nullptr); E(h)->engine.setScalars(nullptr); } FSV_JNI(jdoubleArray, nativeOverhang)(JNIEnv* env, jclass, jlong h, jfloat angle) { + if (!h) return nullptr; auto model = E(h)->engine.model(); if (!model) return nullptr; std::vector vis; @@ -359,26 +368,29 @@ FSV_JNI(jfloatArray, nativeCircle3)(JNIEnv* env, jclass, jfloatArray pts) { // ---------------------------------------------------------------- camera -FSV_JNI(void, nativeOrbit)(JNIEnv*, jclass, jlong h, jfloat dx, jfloat dy) { E(h)->engine.orbit(dx, dy); } -FSV_JNI(void, nativePan)(JNIEnv*, jclass, jlong h, jfloat dx, jfloat dy) { E(h)->engine.pan(dx, dy); } -FSV_JNI(void, nativeZoom)(JNIEnv*, jclass, jlong h, jfloat f, jfloat x, jfloat y) { E(h)->engine.zoom(f, x, y); } -FSV_JNI(void, nativeFling)(JNIEnv*, jclass, jlong h, jfloat vx, jfloat vy) { E(h)->engine.fling(vx, vy); } -FSV_JNI(void, nativeStopMotion)(JNIEnv*, jclass, jlong h) { E(h)->engine.stopMotion(); } -FSV_JNI(void, nativeSetStandardView)(JNIEnv*, jclass, jlong h, jint v, jboolean a) { E(h)->engine.setStandardView(v, a == JNI_TRUE); } +FSV_JNI(void, nativeOrbit)(JNIEnv*, jclass, jlong h, jfloat dx, jfloat dy) { if (h) E(h)->engine.orbit(dx, dy); } +FSV_JNI(void, nativePan)(JNIEnv*, jclass, jlong h, jfloat dx, jfloat dy) { if (h) E(h)->engine.pan(dx, dy); } +FSV_JNI(void, nativeZoom)(JNIEnv*, jclass, jlong h, jfloat f, jfloat x, jfloat y) { if (h) E(h)->engine.zoom(f, x, y); } +FSV_JNI(void, nativeFling)(JNIEnv*, jclass, jlong h, jfloat vx, jfloat vy) { if (h) E(h)->engine.fling(vx, vy); } +FSV_JNI(void, nativeStopMotion)(JNIEnv*, jclass, jlong h) { if (h) E(h)->engine.stopMotion(); } +FSV_JNI(void, nativeSetStandardView)(JNIEnv*, jclass, jlong h, jint v, jboolean a) { if (h) E(h)->engine.setStandardView(v, a == JNI_TRUE); } FSV_JNI(void, nativeSetOrientation)(JNIEnv*, jclass, jlong h, jfloat yaw, jfloat pitch, jboolean a) { + if (!h) return; E(h)->engine.setOrientation(yaw, pitch, a == JNI_TRUE); } -FSV_JNI(void, nativeFitView)(JNIEnv*, jclass, jlong h, jboolean a) { E(h)->engine.fitView(a == JNI_TRUE); } -FSV_JNI(void, nativeFocusPart)(JNIEnv*, jclass, jlong h, jint p, jboolean a) { E(h)->engine.focusPart(p, a == JNI_TRUE); } +FSV_JNI(void, nativeFitView)(JNIEnv*, jclass, jlong h, jboolean a) { if (h) E(h)->engine.fitView(a == JNI_TRUE); } +FSV_JNI(void, nativeFocusPart)(JNIEnv*, jclass, jlong h, jint p, jboolean a) { if (h) E(h)->engine.focusPart(p, a == JNI_TRUE); } FSV_JNI(void, nativeFocusPoint)(JNIEnv*, jclass, jlong h, jfloat x, jfloat y, jfloat z, jfloat zoom) { + if (!h) return; E(h)->engine.focusPoint(Vec3(x, y, z), zoom); } -FSV_JNI(void, nativeSetOrtho)(JNIEnv*, jclass, jlong h, jboolean o) { E(h)->engine.setOrtho(o == JNI_TRUE); } +FSV_JNI(void, nativeSetOrtho)(JNIEnv*, jclass, jlong h, jboolean o) { if (h) E(h)->engine.setOrtho(o == JNI_TRUE); } FSV_JNI(void, nativeSetViewInsets)(JNIEnv*, jclass, jlong h, jfloat top, jfloat bottom, jboolean animate) { + if (!h) return; E(h)->engine.setViewInsets(top, bottom, animate == JNI_TRUE); } -FSV_JNI(void, nativeSetTurntable)(JNIEnv*, jclass, jlong h, jboolean o) { E(h)->engine.setTurntable(o == JNI_TRUE); } -FSV_JNI(void, nativeSetSensitivity)(JNIEnv*, jclass, jlong h, jfloat s) { E(h)->engine.setSensitivity(s); } +FSV_JNI(void, nativeSetTurntable)(JNIEnv*, jclass, jlong h, jboolean o) { if (h) E(h)->engine.setTurntable(o == JNI_TRUE); } +FSV_JNI(void, nativeSetSensitivity)(JNIEnv*, jclass, jlong h, jfloat s) { if (h) E(h)->engine.setSensitivity(s); } // out[0..15] view-projection, [16..17] yaw/pitch, [18..19] viewport. Returns a change counter. FSV_JNI(jlong, nativeCameraMatrices)(JNIEnv* env, jclass, jlong h, jfloatArray out) { @@ -399,6 +411,7 @@ FSV_JNI(jfloatArray, nativeGetCamera)(JNIEnv* env, jclass, jlong h) { } FSV_JNI(void, nativeSetCamera)(JNIEnv* env, jclass, jlong h, jfloatArray a, jboolean animate) { + if (!h) return; float v[7]; env->GetFloatArrayRegion(a, 0, 7, v); CameraState s; @@ -424,16 +437,18 @@ FSV_JNI(jint, nativePick)(JNIEnv* env, jclass, jlong h, jfloat x, jfloat y, jflo // ---------------------------------------------------------------- part state -FSV_JNI(void, nativeSetPartColor)(JNIEnv*, jclass, jlong h, jint p, jint argb) { E(h)->engine.setPartColor(p, argbToRgba((uint32_t)argb)); } -FSV_JNI(void, nativeSetPartVisible)(JNIEnv*, jclass, jlong h, jint p, jboolean v) { E(h)->engine.setPartVisible(p, v == JNI_TRUE); } -FSV_JNI(void, nativeSetPartSelected)(JNIEnv*, jclass, jlong h, jint p, jboolean v) { E(h)->engine.setPartSelected(p, v == JNI_TRUE); } -FSV_JNI(void, nativeSetPartHighlight)(JNIEnv*, jclass, jlong h, jint p, jboolean v) { E(h)->engine.setPartHighlight(p, v == JNI_TRUE); } -FSV_JNI(void, nativeSetPartGhost)(JNIEnv*, jclass, jlong h, jint p, jboolean v) { E(h)->engine.setPartGhost(p, v == JNI_TRUE); } +FSV_JNI(void, nativeSetPartColor)(JNIEnv*, jclass, jlong h, jint p, jint argb) { if (h) E(h)->engine.setPartColor(p, argbToRgba((uint32_t)argb)); } +FSV_JNI(void, nativeSetPartVisible)(JNIEnv*, jclass, jlong h, jint p, jboolean v) { if (h) E(h)->engine.setPartVisible(p, v == JNI_TRUE); } +FSV_JNI(void, nativeSetPartSelected)(JNIEnv*, jclass, jlong h, jint p, jboolean v) { if (h) E(h)->engine.setPartSelected(p, v == JNI_TRUE); } +FSV_JNI(void, nativeSetPartHighlight)(JNIEnv*, jclass, jlong h, jint p, jboolean v) { if (h) E(h)->engine.setPartHighlight(p, v == JNI_TRUE); } +FSV_JNI(void, nativeSetPartGhost)(JNIEnv*, jclass, jlong h, jint p, jboolean v) { if (h) E(h)->engine.setPartGhost(p, v == JNI_TRUE); } FSV_JNI(void, nativeSetPartMaterial)(JNIEnv*, jclass, jlong h, jint p, jfloat r, jfloat m, jfloat c) { + if (!h) return; E(h)->engine.setPartMaterial(p, r, m, c); } -FSV_JNI(void, nativeSetPartVertexColors)(JNIEnv*, jclass, jlong h, jint p, jboolean v) { E(h)->engine.setPartVertexColors(p, v == JNI_TRUE); } +FSV_JNI(void, nativeSetPartVertexColors)(JNIEnv*, jclass, jlong h, jint p, jboolean v) { if (h) E(h)->engine.setPartVertexColors(p, v == JNI_TRUE); } FSV_JNI(void, nativeSetPartOffset)(JNIEnv*, jclass, jlong h, jint p, jfloat x, jfloat y, jfloat z) { + if (!h) return; E(h)->engine.setPartManualOffset(p, Vec3(x, y, z)); } // Total offset (explode + manual) of a part. @@ -444,8 +459,8 @@ FSV_JNI(void, nativeGetPartOffset)(JNIEnv* env, jclass, jlong h, jint p, jfloatA float v[6] = {total.x, total.y, total.z, manual.x, manual.y, manual.z}; env->SetFloatArrayRegion(out, 0, 6, v); } -FSV_JNI(void, nativeSetExplode)(JNIEnv*, jclass, jlong h, jfloat f) { E(h)->engine.setExplode(f); } -FSV_JNI(void, nativeResetParts)(JNIEnv*, jclass, jlong h) { E(h)->engine.resetPartStates(); } +FSV_JNI(void, nativeSetExplode)(JNIEnv*, jclass, jlong h, jfloat f) { if (h) E(h)->engine.setExplode(f); } +FSV_JNI(void, nativeResetParts)(JNIEnv*, jclass, jlong h) { if (h) E(h)->engine.resetPartStates(); } // ---------------------------------------------------------------- options @@ -453,6 +468,7 @@ FSV_JNI(void, nativeResetParts)(JNIEnv*, jclass, jlong h) { E(h)->engine.resetPa // sectionAxis, sectionFlip, hatch, sectionPlane, selectColorArgb] // floats: [exposure, scalarMin, scalarMax, threshold, overhangAngle, sectionValue, lineScale] FSV_JNI(void, nativeSetOptions)(JNIEnv* env, jclass, jlong h, jintArray ints, jfloatArray floats) { + if (!h) return; jint iv[15]; jfloat fv[7]; env->GetIntArrayRegion(ints, 0, 15, iv); @@ -496,6 +512,7 @@ FSV_JNI(void, nativeSceneBounds)(JNIEnv* env, jclass, jlong h, jfloatArray out) // format: 0 STL, 1 OBJ, 2 3MF, 3 FSM. partMask may be null (all parts). FSV_JNI(jboolean, nativeExport)(JNIEnv* env, jclass, jlong h, jint fd, jint format, jbooleanArray partMask, jboolean applyOffsets) { + if (!h) return JNI_FALSE; return guarded(env, [&]() -> jboolean { EngineHandle* eh = E(h); auto model = eh->engine.model(); diff --git a/native/tests/tests.cpp b/native/tests/tests.cpp index cf6b65b..f83fc14 100644 --- a/native/tests/tests.cpp +++ b/native/tests/tests.cpp @@ -2,8 +2,11 @@ #include #include #include +#include +#include #include #include +#include #include #include "analysis.h" @@ -14,6 +17,7 @@ #include "exporters.h" #include "loaders.h" #include "zip.h" +#include "../host/egl_context.h" using namespace fsv; @@ -276,6 +280,47 @@ int main() { CHECK(engine.setScalars(nullptr)); }); + // Needs a headless GL context (Mesa); skipped where none is available. + static const bool haveGl = fsvhost::createHeadlessContext(64, 64); + if (!haveGl) std::printf("[skip] engine GL tests: no headless GL context\n"); + + if (haveGl) test("model upload does not block the UI thread", [] { + Engine engine; + engine.onSurfaceCreated(); + engine.onSurfaceChanged(64, 64); + std::shared_ptr a(load(binaryStl(boxTris({0, 0, 0}, {4, 4, 4})), "stl").release()); + std::shared_ptr b(load(binaryStl(boxTris({0, 0, 0}, {8, 8, 8})), "stl").release()); + engine.setModel(a, true); + int uploads = 0; + bool uiDone = false; + std::atomic replaced{false}; + std::thread ui; + engine.setUploadHookForTests([&] { + if (++uploads != 1) return; + // Mid-upload: the UI thread moves the camera and replaces the model. + ui = std::thread([&] { + engine.orbit(0.1f, 0.f); + engine.setModel(b, false); + replaced = true; + }); + auto end = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!replaced && std::chrono::steady_clock::now() < end) std::this_thread::yield(); + uiDone = replaced; + }); + bool again = engine.drawFrame(); + if (ui.joinable()) ui.join(); + CHECK(uploads == 1); + CHECK(uiDone); + CHECK(again); // the scene changed during the upload: another frame is needed + engine.drawFrame(); + CHECK(uploads == 2); // ...and that frame uploads the new model + engine.drawFrame(); + CHECK(uploads == 2); + std::vector px; + CHECK(engine.thumbnail(32, px)); + CHECK(px.size() == 32 * 32 * 4); + }); + test("STL/OBJ exporters", [] { auto a = load(binaryStl(boxTris({0, 0, 0}, {4, 4, 4})), "stl"); auto stl = exportSTL(*a, ExportOptions()); @@ -412,6 +457,7 @@ int main() { CHECK(threw); }); + fsvhost::destroyHeadlessContext(); std::printf("\n%d checks, %d failures\n", gChecks, gFailures); return gFailures == 0 ? 0 : 1; } diff --git a/scripts/compose_readme_images.py b/scripts/compose_readme_images.py index 1641de4..acd8bd5 100644 --- a/scripts/compose_readme_images.py +++ b/scripts/compose_readme_images.py @@ -3,7 +3,7 @@ Input: build/screenshots/*.png (1080x2400, from the Robolectric screenshot tests) Output: docs/screenshots/*.png (rounded phone-style frames, 540 px wide) - docs/images/hero.png (banner with a selection of screens) + docs/images/hero.png (banner with the app icon and a selection of screens) Requires Pillow (pip install pillow). """ @@ -96,13 +96,19 @@ def main(): gd.ellipse([1300, -300, 2600, 700], fill=(208, 188, 255, 60)) gd.ellipse([-300, 600, 900, 1500], fill=(255, 109, 0, 40)) banner.alpha_composite(glow.filter(ImageFilter.GaussianBlur(120))) - d.text((110, 150), "Meshwise", font=font(118, True), fill=(240, 236, 248)) - d.text((114, 300), "Fast, native 3D model viewer for Android", font=font(46), fill=(208, 188, 255)) - lines = ["STL · OBJ · 3MF · PLY · STEP · IGES · BREP · ZIP", + title_x = 110 + icon_path = os.path.join(IMAGES, "icon.png") # from scripts/make_icon.py + if os.path.exists(icon_path): + icon = Image.open(icon_path).convert("RGBA").resize((150, 150), Image.LANCZOS) + banner.alpha_composite(icon, (110, 152)) + title_x = 290 + d.text((title_x, 150), "Meshwise", font=font(118, True), fill=(240, 236, 248)) + d.text((114, 340), "Free, open-source 3D model viewer for Android", font=font(43), fill=(208, 188, 255)) + lines = ["STL · OBJ · 3MF · PLY · OFF · STEP · IGES · BREP · ZIP", "Section caps · Wall thickness · Measure · Explode", "Weight · Materials · Mesh check · Export"] for i, line in enumerate(lines): - d.text((114, 420 + i * 64), line, font=font(36), fill=(200, 204, 210)) + d.text((114, 450 + i * 64), line, font=font(36), fill=(200, 204, 210)) x = 1180 thumbs = picks[:3] for i, n in enumerate(thumbs): diff --git a/scripts/make_icon.py b/scripts/make_icon.py new file mode 100644 index 0000000..e9f4020 --- /dev/null +++ b/scripts/make_icon.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Generates the Meshwise launcher icon from one piece of geometry. + +The mark is a faceted low-poly gem (the mesh) sliced by a section plane: the cut face shows +the orange hatched cap that Meshwise draws in its section view. + +Output: + app/src/main/res/drawable/ic_launcher_foreground.xml adaptive icon foreground + app/src/main/res/drawable/ic_launcher_background.xml adaptive icon background + app/src/main/res/drawable/ic_launcher_monochrome.xml themed icon (Android 13+) + docs/images/icon.png 512 px preview (needs Pillow) + +Coordinates use the 108x108 adaptive icon canvas; launchers show at least the central +circle of radius 33, so the mark stays inside radius ~30. +""" +import math +import os + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +DRAWABLE = os.path.join(ROOT, "app", "src", "main", "res", "drawable") + +C = (52.5, 54.5) +R_OUT = 28.0 # gem outline +R_IN = 13.5 # central facet + +BG_TOP, BG_BOTTOM = "#2B2540", "#141821" # dark slate with a violet tint, like the app +CAP = "#FF8A3D" # section cap orange (app accent) +CAP_DARK = "#C2531A" +HATCH = "#7A2E0B" +OUTLINE = "#141821" +# Lavender facet shades, light (facing the light) to dark. +SHADES = ["#F1EAFF", "#DCCDFF", "#C3AEFA", "#A68DEB", "#8A6FD6", "#6C53B8", "#523E96"] + +LIGHT = (-0.55, -0.83) # from the top left (screen y points down) + +# Section plane: points p with dot(p - C, N) > D are cut away and show the cap. +N = (math.cos(math.radians(35)), math.sin(math.radians(35))) +D = 7.0 + + +def pt(r, deg): + a = math.radians(deg) + return (C[0] + r * math.cos(a), C[1] - r * math.sin(a)) + + +def gem_faces(): + """Icosahedron seen along a 3-fold axis: 1 + 3 + 6 triangles inside a hexagon.""" + inner = {d: pt(R_IN, d) for d in (90, 210, 330)} + outer = {d: pt(R_OUT, d) for d in (30, 90, 150, 210, 270, 330)} + faces = [(inner[90], inner[210], inner[330])] + faces += [(inner[90], inner[210], outer[150]), (inner[210], inner[330], outer[270]), + (inner[330], inner[90], outer[30])] + for d in (90, 210, 330): + faces += [(inner[d], outer[d], outer[(d + 60) % 360]), (inner[d], outer[(d - 60) % 360], outer[d])] + return faces, [outer[d] for d in (90, 150, 210, 270, 330, 30)] + + +def side(p): + return (p[0] - C[0]) * N[0] + (p[1] - C[1]) * N[1] - D + + +def clip(poly, keep_below=True): + """Sutherland-Hodgman clip of a convex polygon against the section plane.""" + out = [] + for i, a in enumerate(poly): + b = poly[(i + 1) % len(poly)] + sa, sb = side(a), side(b) + ina, inb = (sa <= 0) == keep_below, (sb <= 0) == keep_below + if ina: + out.append(a) + if ina != inb: + t = sa / (sa - sb) + out.append((a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t)) + return out + + +def shade(face): + (x0, y0), (x1, y1), (x2, y2) = face + cx, cy = (x0 + x1 + x2) / 3 - C[0], (y0 + y1 + y2) / 3 - C[1] + r = math.hypot(cx, cy) + if r < 1e-6: + lit = 0.75 # the central facet faces the viewer + else: + lit = 0.5 + 0.5 * (cx * LIGHT[0] + cy * LIGHT[1]) / r * min(1.0, r / R_IN) + return SHADES[max(0, min(len(SHADES) - 1, round((1 - lit) * (len(SHADES) - 1))))] + + +def hatch_lines(cap, spacing=3.3): + """45° hatch segments clipped to the convex cap polygon.""" + d = (math.cos(math.radians(-55)), math.sin(math.radians(-55))) + n = (-d[1], d[0]) + proj = [p[0] * n[0] + p[1] * n[1] for p in cap] + segs = [] + k = math.ceil(min(proj) / spacing) + while k * spacing < max(proj): + c = k * spacing + hits = [] + for i, a in enumerate(cap): + b = cap[(i + 1) % len(cap)] + pa, pb = a[0] * n[0] + a[1] * n[1] - c, b[0] * n[0] + b[1] * n[1] - c + if (pa < 0) != (pb < 0): + t = pa / (pa - pb) + hits.append((a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t)) + if len(hits) == 2: + segs.append(tuple(hits)) + k += 1 + return segs + + +def plane_outline(): + """The section plane drawn as a thin frame sticking out past the gem.""" + t = (-N[1], N[0]) + base = (C[0] + N[0] * D, C[1] + N[1] * D) + half, depth = 34.0, 9.0 + # A rectangle seen at an angle: a parallelogram whose short sides lean along the cut. + lean = (t[0] * 0.55 + N[0] * 0.85, t[1] * 0.55 + N[1] * 0.85) + corners = [] + for s1, s2 in ((1, 1), (-1, 1), (-1, -1), (1, -1)): + corners.append((base[0] + t[0] * half * s1 + lean[0] * depth * s2, + base[1] + t[1] * half * s1 + lean[1] * depth * s2)) + return corners + + +def path(poly, close=True): + s = "M" + " L".join(f"{x:.2f},{y:.2f}" for x, y in poly) + return s + (" Z" if close else "") + + +def geometry(): + faces, hexagon = gem_faces() + kept = [(clip(list(f)), shade(f)) for f in faces] + kept = [(p, col) for p, col in kept if len(p) >= 3] + cap = clip(hexagon, keep_below=False) + return kept, cap, hatch_lines(cap), hexagon, plane_outline() + + +def vector(body): + return ('\n' + "\n" + '\n' + body + "\n") + + +def write_xml(): + kept, cap, hatches, hexagon, plane = geometry() + hatch_path = " ".join(f"M{a[0]:.2f},{a[1]:.2f} L{b[0]:.2f},{b[1]:.2f}" for a, b in hatches) + kept_outline = clip(hexagon) + edges = " ".join(path(p) for p, _ in kept) + + fg = " \n" + fg += (f' \n') + fg += " \n" + for p, col in kept: + fg += f' \n' + fg += (f' \n') + fg += " \n" + fg += f' \n' + fg += (f' \n') + fg += (f' \n') + fg += " \n" + fg += (f' \n') + + bg = (' \n' + ' \n' + ' \n' + ' \n \n') + + mono = "" + for p, col in kept: + alpha = 1.0 - SHADES.index(col) / (len(SHADES) - 1) * 0.6 + mono += f' \n' + # Themed icons only use alpha: a half-transparent cap with solid hatch lines. + mono += f' \n' + mono += (f' \n') + + for name, body in (("ic_launcher_foreground", fg), ("ic_launcher_background", bg), ("ic_launcher_monochrome", mono)): + with open(os.path.join(DRAWABLE, name + ".xml"), "w") as f: + f.write(vector(body)) + print(name + ".xml") + + +def write_png(size=512): + from PIL import Image, ImageDraw + + ss = 4 + S = size * ss + k = S / 108.0 + + def sc(poly): + return [(x * k, y * k) for x, y in poly] + + def rgb(h): + return tuple(int(h[i:i + 2], 16) for i in (1, 3, 5)) + + top, bot = rgb(BG_TOP), rgb(BG_BOTTOM) + img = Image.new("RGBA", (S, S)) + px = ImageDraw.Draw(img) + for y in range(S): + t = y / (S - 1) + px.line([(0, y), (S, y)], fill=tuple(round(top[i] + (bot[i] - top[i]) * t) for i in range(3)) + (255,)) + + kept, cap, hatches, hexagon, plane = geometry() + layer = Image.new("RGBA", (S, S), (0, 0, 0, 0)) + dl = ImageDraw.Draw(layer) + dl.polygon(sc(plane), fill=rgb(CAP) + (31,)) + dl.line(sc(plane + plane[:1]), fill=rgb(CAP) + (217,), width=round(0.9 * k), joint="curve") + img = Image.alpha_composite(img, layer) + d = ImageDraw.Draw(img) + for p, col in kept: + d.polygon(sc(p), fill=rgb(col)) + edge = Image.new("RGBA", (S, S), (0, 0, 0, 0)) + de = ImageDraw.Draw(edge) + for p, _ in kept: + de.line(sc(p + p[:1]), fill=rgb(OUTLINE) + (115,), width=round(0.7 * k), joint="curve") + img = Image.alpha_composite(img, edge) + d = ImageDraw.Draw(img) + d.polygon(sc(cap), fill=rgb(CAP)) + hl = Image.new("RGBA", (S, S), (0, 0, 0, 0)) + dh = ImageDraw.Draw(hl) + for a, b in hatches: + dh.line(sc([a, b]), fill=rgb(HATCH) + (115,), width=round(0.8 * k)) + img = Image.alpha_composite(img, hl) + d = ImageDraw.Draw(img) + d.line(sc(cap + cap[:1]), fill=rgb(CAP_DARK), width=round(1.2 * k), joint="curve") + outline = clip(hexagon) + d.line(sc(outline + outline[:1]), fill=rgb(OUTLINE), width=round(1.3 * k), joint="curve") + + # Preview with the usual launcher mask (squircle-ish rounded square), full bleed 108 -> 72 visible. + crop = round(18 * k) + img = img.crop((crop, crop, S - crop, S - crop)).resize((size, size), Image.LANCZOS) + mask = Image.new("L", (size * ss, size * ss), 0) + ImageDraw.Draw(mask).rounded_rectangle([0, 0, size * ss - 1, size * ss - 1], radius=size * ss * 0.23, fill=255) + mask = mask.resize((size, size), Image.LANCZOS) + out = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + out.paste(img, (0, 0), mask) + path_out = os.path.join(ROOT, "docs", "images", "icon.png") + out.save(path_out, optimize=True) + print("docs/images/icon.png") + + +if __name__ == "__main__": + write_xml() + try: + write_png() + except ImportError: + print("Pillow missing: skipped docs/images/icon.png")