diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index b777e26bb..0b528ae10 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -31,7 +31,7 @@ jobs:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
- ref: abb83316d96aa59c1cf64784289e378fe9fa5695
+ ref: 8dcdacd5153e64560d109541a47d806f26f048c0
path: craft-fonts
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
index 2b53ae6cd..0e2388a62 100644
--- a/.github/workflows/packaging-lint.yml
+++ b/.github/workflows/packaging-lint.yml
@@ -54,4 +54,15 @@ jobs:
desktop-file-validate packaging/linux/ai.storyteller.lightcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
- python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.lightcraft.yml')); assert m['id'] == 'ai.storyteller.lightcraft' and m['command'] == 'lightcraft'"
+ python3 - <<'EOF'
+ import yaml
+ src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.lightcraft.yml"))
+ bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.lightcraft.bundle.yml"))
+ for m in (src, bundle):
+ assert m["id"] == "ai.storyteller.lightcraft" and m["command"] == "lightcraft", m["id"]
+ for key in ("runtime", "runtime-version", "sdk", "finish-args"):
+ assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
+ print("Flatpak manifests ok")
+ EOF
+ - name: FreeBSD tarball layout (stub binaries)
+ run: bash packaging/freebsd/package.sh --dry-run
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 69fcf7da9..b9c3a1ea3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,9 +2,11 @@
# are configured) and creates or updates the draft GitHub Release "LightCraft v".
# See docs/releasing.md.
#
-# All jobs run in the `release` environment (restricted to the `release` branch), which holds
-# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
-# warning, never a failed build.
+# The signing jobs (macOS, Windows) and the draft-release job run in the `release` environment
+# (restricted to the `release` branch), which holds the signing secrets. Every secret is optional:
+# missing ones produce unsigned artifacts and a warning, never a failed build. The jobs that sign
+# nothing (version, Linux, Flatpak, FreeBSD, web) run without it, so a dispatch on any branch is
+# a dry run of them; the release job needs the signed jobs, so a branch run never drafts a release.
name: Release
on:
@@ -36,7 +38,7 @@ jobs:
version:
name: version
runs-on: ubuntu-latest
- environment: release
+ # No `environment: release` here or on the other jobs that sign nothing: no release secrets.
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
@@ -75,7 +77,7 @@ jobs:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
- ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
+ ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
@@ -133,7 +135,7 @@ jobs:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
- ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
+ ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
@@ -174,7 +176,6 @@ jobs:
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
- environment: release
strategy:
fail-fast: false
matrix:
@@ -191,14 +192,14 @@ jobs:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
- ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
+ ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- name: Install build and packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
- desktop-file-utils appstream python3-yaml
+ desktop-file-utils appstream python3-yaml zsync
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
@@ -223,6 +224,9 @@ jobs:
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
+ # Update information is embedded, and the .zsync for delta updates sits beside it.
+ "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
+ test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@v4
with:
@@ -230,11 +234,98 @@ jobs:
path: dist/release/*
if-no-files-found: error
+ flatpak:
+ name: Flatpak ${{ matrix.arch }}
+ needs: [version, linux]
+ # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
+ # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
+ runs-on: ${{ matrix.runner }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { arch: x86_64, runner: ubuntu-24.04 }
+ - { arch: aarch64, runner: ubuntu-24.04-arm }
+ env:
+ LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
+ LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/download-artifact@v4
+ with:
+ name: release-linux-${{ matrix.arch }}
+ path: dist/linux
+ - name: Install flatpak-builder
+ run: |
+ sudo apt-get update
+ # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
+ # `appstreamcli compose`, which renders the scalable icon and fails without it.
+ sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
+ # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
+ sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
+ - name: Build the Flatpak bundle
+ run: packaging/linux/flatpak-bundle.sh "dist/linux/lightcraft-$LIGHTCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
+ - uses: actions/upload-artifact@v4
+ with:
+ name: release-flatpak-${{ matrix.arch }}
+ path: dist/release/*.flatpak
+ if-no-files-found: error
+
+ freebsd:
+ name: FreeBSD x86_64
+ needs: version
+ # GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
+ # .github/workflows/freebsd.yml.
+ runs-on: ubuntu-latest
+ timeout-minutes: 180
+ env:
+ LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
+ LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
+ steps:
+ - uses: actions/checkout@v4
+ # Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ repository: storytold/craft-fonts
+ ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
+ path: craft-fonts
+ - name: Build and package in a FreeBSD VM (tar.gz)
+ # vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
+ uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
+ with:
+ release: "14.3"
+ usesh: true
+ cpu: 4
+ mem: 12288
+ # Passed into the VM by name; never templated into the script below.
+ envs: "LIGHTCRAFT_VERSION LIGHTCRAFT_BUILD_DATE LIGHTCRAFT_BUILD_SHA INPUT_VERSION"
+ # The FreeBSD CI packages, plus bash for the packaging script.
+ prepare: |
+ pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
+ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
+ run: |
+ set -e
+ . "$HOME/.cargo/env"
+ git config --global --add safe.directory "$PWD"
+ # Build outside the checkout so only dist/ is copied back out of the VM (copying the
+ # target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
+ export CARGO_TARGET_DIR=/var/tmp/lightcraft-target CARGO_BUILD_JOBS=4
+ if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$LIGHTCRAFT_VERSION"; fi
+ # craft-fonts was checked out next to the sources and synced into the VM with them.
+ export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
+ bash packaging/freebsd/package.sh
+ tar -tzf dist/release/lightcraft-*-freebsd-x86_64.tar.gz \
+ | grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$|/share/doc/lightcraft/LICENSE-MIT$'
+ - uses: actions/upload-artifact@v4
+ with:
+ name: release-freebsd-x86_64
+ path: dist/release/*
+ if-no-files-found: error
+
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
- environment: release
env:
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -245,7 +336,7 @@ jobs:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
- ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
+ ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
@@ -276,7 +367,7 @@ jobs:
release:
name: Draft GitHub Release
- needs: [version, macos, windows, linux, web]
+ needs: [version, macos, windows, linux, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
diff --git a/AGENTS.md b/AGENTS.md
index 4c6ddfdea..3ab653c77 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -63,7 +63,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
- **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not.
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL);
- Japanese fonts come from craft-fonts (below).
+ Japanese and Chinese fonts come from craft-fonts (below). **One exception (maintainer decision, 2026-10-07): Noto
+ Sans/Serif CJK** (Google-branded, OFL, co-developed with Adobe as Source Han) is allowed via craft-fonts for Chinese
+ text, because nearly every OFL Chinese face derives from it. Use it unmodified under its OFL; this does not open the
+ door to Source Han under Adobe's name or to any other Adobe asset.
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
@@ -72,7 +75,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
- (`theme::font_definitions`) and the export watermark renderer use its Japanese faces as fallbacks after Inter. Unset,
+ (`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset,
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
without it; the FreeBSD CI job runs them with it. Rules: craftrules
diff --git a/CLAUDE.md b/CLAUDE.md
index eb5b9430b..bdf7eceb5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -63,7 +63,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
- **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not.
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL);
- Japanese fonts come from craft-fonts (below).
+ Japanese and Chinese fonts come from craft-fonts (below). **One exception (maintainer decision, 2026-10-07): Noto
+ Sans/Serif CJK** (Google-branded, OFL, co-developed with Adobe as Source Han) is allowed via craft-fonts for Chinese
+ text, because nearly every OFL Chinese face derives from it. Use it unmodified under its OFL; this does not open the
+ door to Source Han under Adobe's name or to any other Adobe asset.
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
@@ -72,7 +75,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
- (`theme::font_definitions`) and the export watermark renderer use its Japanese faces as fallbacks after Inter. Unset,
+ (`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset,
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
without it; the FreeBSD CI job runs them with it. Rules: craftrules
diff --git a/Cargo.lock b/Cargo.lock
index dae7db484..86d4bca64 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2779,7 +2779,7 @@ dependencies = [
[[package]]
name = "lightcraft"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"eframe",
"egui",
@@ -2797,7 +2797,7 @@ dependencies = [
[[package]]
name = "lightcraft-catalog"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"crc32fast",
"lightcraft-develop",
@@ -2812,7 +2812,7 @@ dependencies = [
[[package]]
name = "lightcraft-cli"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"dhat",
"lightcraft-codecs",
@@ -2827,7 +2827,7 @@ dependencies = [
[[package]]
name = "lightcraft-codecs"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"bytemuck",
"image",
@@ -2852,7 +2852,7 @@ dependencies = [
[[package]]
name = "lightcraft-color"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"lightcraft-geom",
"proptest",
@@ -2861,7 +2861,7 @@ dependencies = [
[[package]]
name = "lightcraft-develop"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"lightcraft-color",
"lightcraft-geom",
@@ -2873,9 +2873,10 @@ dependencies = [
[[package]]
name = "lightcraft-engine"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"ab_glyph",
+ "harfrust",
"libc",
"lightcraft-catalog",
"lightcraft-codecs",
@@ -2891,18 +2892,20 @@ dependencies = [
"lightcraft-raw",
"lightcraft-scenes",
"lightcraft-segment",
+ "lightcraft-tiff",
"log",
"miniz_oxide 0.8.9",
"rayon",
"serde",
"serde_json",
"thiserror 2.0.21",
+ "unicode-segmentation",
"web-time",
]
[[package]]
name = "lightcraft-fetch"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"log",
"rustls",
@@ -2913,7 +2916,7 @@ dependencies = [
[[package]]
name = "lightcraft-geom"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"proptest",
"serde",
@@ -2921,7 +2924,7 @@ dependencies = [
[[package]]
name = "lightcraft-gpu"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"bytemuck",
"lightcraft-color",
@@ -2937,7 +2940,7 @@ dependencies = [
[[package]]
name = "lightcraft-mcp"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-engine",
@@ -2948,7 +2951,7 @@ dependencies = [
[[package]]
name = "lightcraft-merge"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-color",
@@ -2964,7 +2967,7 @@ dependencies = [
[[package]]
name = "lightcraft-meta"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"lightcraft-geom",
"lightcraft-tiff",
@@ -2976,7 +2979,7 @@ dependencies = [
[[package]]
name = "lightcraft-pipeline"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"image",
"lightcraft-color",
@@ -2992,7 +2995,7 @@ dependencies = [
[[package]]
name = "lightcraft-preview"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"jpeg-encoder",
"lightcraft-raster",
@@ -3004,7 +3007,7 @@ dependencies = [
[[package]]
name = "lightcraft-raster"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"libc",
"lightcraft-color",
@@ -3016,7 +3019,7 @@ dependencies = [
[[package]]
name = "lightcraft-raw"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"jpeg-encoder",
"lightcraft-color",
@@ -3028,6 +3031,7 @@ dependencies = [
"proptest",
"rayon",
"serde",
+ "sha2",
"thiserror 2.0.21",
"zune-core",
"zune-jpeg",
@@ -3035,7 +3039,7 @@ dependencies = [
[[package]]
name = "lightcraft-scenes"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"image",
"lightcraft-color",
@@ -3046,7 +3050,7 @@ dependencies = [
[[package]]
name = "lightcraft-segment"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"candle-core",
"candle-nn",
@@ -3059,11 +3063,11 @@ dependencies = [
[[package]]
name = "lightcraft-sysmem"
-version = "0.3.0"
+version = "0.4.0"
[[package]]
name = "lightcraft-tiff"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"proptest",
"serde",
@@ -3072,7 +3076,7 @@ dependencies = [
[[package]]
name = "lightcraft-ui-egui"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"egui",
"lightcraft-catalog",
@@ -3094,7 +3098,7 @@ dependencies = [
[[package]]
name = "lightcraft-web"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"crc32fast",
"eframe",
@@ -6482,7 +6486,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"brotli",
"flate2",
diff --git a/Cargo.toml b/Cargo.toml
index 0c4b7d053..77b206669 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
-version = "0.3.0"
+version = "0.4.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
diff --git a/README.md b/README.md
index f1f2e4bc8..5aacbee0d 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,7 @@
Status ·
Quick start ·
Roadmap ·
+ Downloads ·
Crafting Apps
@@ -257,7 +258,7 @@ lightcraft --control 7980 ~/Pictures/trip
## Fast, native, private
-- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans,
+- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2/CR3, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans,
Panasonic RW2 / Leica RWL, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP,
PSD composites and JPEG XL open today.
- **Scene-referred & wide-gamut.** Linear Rec.2020 float internally, Bradford-adapted white balance, gamut mapping
@@ -287,8 +288,8 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
- **As a day-to-day Lightroom replacement we're nearer 60–70%.** It's great for JPEG/DNG and most Nikon / Sony /
older-Canon raws on one machine.
- **The biggest gaps:**
- - **camera colour calibration:** raws other than DNG develop with a neutral colour matrix today, so colour is muted;
- - **CR3 and compressed Fujifilm / Olympus raws:** these open as embedded previews only;
+ - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
+ - **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
- **AI masks and denoise:** subject and sky selection are classical heuristics;
- **HDR, video and the Classic Print / Book / Map modules.**
- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
@@ -301,9 +302,9 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
| Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ |
| Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ |
-| Camera colour: DNG files use their own matrices | ✅ · our own calibration for other raws ⬜ (top priority; ARW, NEF and RW2 start from a look fitted to their own JPEG, other raws from a neutral fallback) |
+| Camera colour: DNG files use their own matrices | ✅ DNG · 🟡 own Sony/Fujifilm profiles; measured calibration database missing |
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
-| RAW: DNG, CR2, ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | ✅ · CR3, compressed RAF/ORF decode ⬜ |
+| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF decode ⬜ |
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 |
| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ |
@@ -311,7 +312,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
| XMP sidecars (read/write, auto-write), reading `crs:` develop settings, preset files (`.lcpreset`, XMP presets) | ✅ |
-| Optics (distortion, vignetting, auto + manual CA, defringe, DNG-embedded lens corrections), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
+| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files and Panasonic / Leica RW2 / RWL distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ |
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
| AI: segmentation masks, AI denoise, super resolution, faces; HDR editing; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) |
@@ -352,9 +353,23 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
-**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語) or **Settings → General →
+**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Deutsch) or **Settings → General →
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
+**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
+(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`, by default `~/.config/lightcraft/logs/`; macOS
+`~/Library/Application Support/LightCraft/logs/`; Windows `%APPDATA%\LightCraft\logs\`), never in the library. A
+launch from a desktop menu or the Dock has no terminal, so attach this file to a bug report (**Help ▸ Open Log
+Folder** shows it in the file manager). Each start moves the
+previous log to `lightcraft.1.log` and that one to `lightcraft.2.log`, so the log of a run that crashed survives the
+next start; the file stops growing at 16 MiB, `--version` and `--help` write none, and runs with
+`LIGHTCRAFT_NO_PREFS` log to standard error only. By default LightCraft's own crates log at `info` and everything else
+at `warn`. `LIGHTCRAFT_LOG=info` or `debug` works as before (that level for LightCraft's own crates, warnings and
+errors from the rest; any other value: warnings and errors only) and wins over `RUST_LOG`, which otherwise replaces
+the default with env_logger-style directives such as `RUST_LOG=debug` or `RUST_LOG=warn,lightcraft_pipeline=trace` (a
+directive ending in `*` covers every target starting with it, as in `lightcraft*=debug`). Panics are recorded there
+too, and still in `lightcraft-panics.log` in the temp folder. The logger is `apps/lightcraft/src/logging.rs`.
+
The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI
(`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md).
@@ -414,6 +429,53 @@ Questions, ideas or a bug you'd like to talk through first? Bring them to [Disco
+## Downloads
+
+**New to LightCraft?** Download it from the [LightCraft page on getartcraft.com](https://getartcraft.com/apps/lightcraft). That's the easiest way to install it.
+
+**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/lightcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/lightcraft/releases) has earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
+
+### Windows
+
+| Build | Installer | Portable |
+|---|---|---|
+| x64 (64-bit Intel/AMD) | `lightcraft--windows-x64.msi` | `lightcraft--windows-x64-portable.zip` |
+| arm64 (Snapdragon and other ARM PCs) | `lightcraft--windows-arm64.msi` | `lightcraft--windows-arm64-portable.zip` |
+| x86 (32-bit) | `lightcraft--windows-x86.msi` | `lightcraft--windows-x86-portable.zip` |
+
+Installers and executables are code-signed.
+
+### macOS
+
+| Build | File | Notes |
+|---|---|---|
+| App, universal (Apple silicon + Intel) | `lightcraft--macos-universal.dmg` | Signed and notarized |
+| Command-line tool, universal | `lightcraft-cli--macos-universal.zip` | Signed and notarized |
+
+### Linux
+
+| Format | x86_64 | aarch64 (ARM64) | Notes |
+|---|---|---|---|
+| AppImage | `lightcraft--linux-x86_64.AppImage` | `lightcraft--linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
+| Flatpak | `lightcraft--linux-x86_64.flatpak` | `lightcraft--linux-aarch64.flatpak` | Sandboxed; `flatpak install --user ` |
+| Debian/Ubuntu | `lightcraft--linux-x86_64.deb` | `lightcraft--linux-aarch64.deb` | |
+| Fedora/RHEL/openSUSE | `lightcraft--linux-x86_64.rpm` | `lightcraft--linux-aarch64.rpm` | |
+| Tarball | `lightcraft--linux-x86_64.tar.gz` | `lightcraft--linux-aarch64.tar.gz` | Unpack anywhere |
+
+### FreeBSD
+
+| Build | File |
+|---|---|
+| x86_64 | `lightcraft--freebsd-x86_64.tar.gz` |
+
+### Web (WebAssembly)
+
+| Build | File | Notes |
+|---|---|---|
+| Static site | `lightcraft-web-.zip` | Runs in a modern browser; host it on any static server |
+
+
+
## The Crafting Apps
LightCraft is one of the **Crafting Apps**: free, open-source creative tools from the
diff --git a/ROADMAP.md b/ROADMAP.md
index aeb07567b..5e793e8be 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -6,11 +6,11 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
## Where we stand
-*Honest assessment, 2026-10-05. Agents: read this before picking work. The checklist in
+*Honest assessment, 2026-10-08. Agents: read this before picking work. The checklist in
[`docs/parity.md`](docs/parity.md) counts features that **exist**; this section is about whether a photographer can
**switch** from Lightroom. Update it when a gap below closes.*
-**In one line:** the checklist says **79%** (P0 98.5%, P1 95.9%, P2 39%), but measured by whether a working
+**In one line:** the checklist says **79.7%** (P0 98.2%, P1 95.6%, P2 41.6%), but measured by whether a working
photographer could replace Lightroom without noticing, we are at roughly **60–70%**. The remaining gap is mostly
**quality of results and camera coverage**, not missing buttons.
@@ -22,14 +22,14 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| Dimension | Estimate | What's true today | Biggest gaps |
|---|---:|---|---|
-| **Feature checklist** | 79% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
-| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
-| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF and RW2 have a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
+| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
+| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
+| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
-| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese and Japanese UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
+| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese and German UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
### By kind of user
@@ -37,7 +37,8 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|---|---:|---|
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); Panasonic compacts and kit zooms also miss their embedded distortion correction |
-| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only (CR3: the full-size JPEG with full metadata) |
+| Fujifilm raw shooter | ~65% | Uncompressed and lossless/lossy compressed RAF decoded; camera colour calibration remains missing. Verified on 13 bodies, including 14/16-bit GFX; see `docs/raf-compression.md` |
+| Canon CR3 / Olympus compressed-raw shooter | ~45% | CR3 lossless Bayer and version 0x100/0x200 C-RAW develop from sensor data (M50, R100, R8 verified pixel for pixel); other CRX variants and compressed ORF open as embedded JPEG previews. Camera colour and broader model verification remain |
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
| Relies on AI (masks, denoise) | ~25% | Object / Describe masks via optional SAM 3 (download not yet hosted); no AI denoise |
@@ -46,9 +47,10 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
- matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF and Panasonic RW2 have the file-local fit
- (matrix + tone curve from their own JPEG); generalise it to the other makes' raws (PEF, ORF…), then validate fidelity.
-2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, NEF
+ matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2
+ and Pentax PEF have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet
+ checked on the corpus); then ORF, then validate fidelity.
+2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, NEF
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
no LGPL dependency); compressed NEF (#86) is the template.
3. **Verified camera coverage** (LR-IMP-CAMERA-COVERAGE, P0): a CC0 sample per model in the corpus, each decoded and
@@ -67,6 +69,10 @@ Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29
Local roots / cleanup, grid and catalog performance at 85k photos (#35, #37), failed-save reporting, GPU export
hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help and folder templates (#31, #32).
+Image navigation now supports native pinch zoom and two-finger pan in Detail, Compare and Reference views.
+The whole-image preview still has a configurable size cap (2,560 px default), so 100% on larger photos does not
+yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-VIEW-ZOOM).
+
## Milestones
**Status legend:** ✅ done · 🚧 in progress · ⬜ not started
@@ -84,24 +90,24 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) |
-| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF and RW2 preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) |
+| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (CR3 lossless CRX and C-RAW, RAF uncompressed and lossless/lossy compressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF, RW2 and RAF preview fitting; measured database still missing), CR3, compressed ORF ⬜) |
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
-| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese localisation 🟡; accessibility and further locales ⬜) |
+| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/German localisation 🟡; accessibility and further locales ⬜) |
-## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02)
+## Parity estimate (feature count updated 2026-10-08; effort estimate from 2026-10-02)
**By feature count** — from `docs/parity.md` (one row per Lightroom feature, menu item and shortcut; `cargo xtask
parity` prints this line on every run, so it stays current):
| Scope | Weighted completion | Rows | 2026-10-02 |
|---|---:|---:|---:|
-| P0 (core) | **98.5%** | 200 | 98.7% |
-| P1 (important parity) | **95.9%** | 147 | 94.4% |
-| P2 (later / AI / niche, incl. Classic modules) | **39.2%** | 158 | 29.4% |
-| **All in-scope rows** | **79.2%** | 505 | 75.6% |
+| P0 (core) | **98.2%** | 200 | 98.7% |
+| P1 (important parity) | **95.6%** | 149 | 94.4% |
+| P2 (later / AI / niche, incl. Classic modules) | **41.6%** | 160 | 29.4% |
+| **All in-scope rows** | **79.7%** | 509 | 75.6% |
The 2026-10-05 count includes three new 🟡 rows that make quality gaps visible (camera colour calibration, verified
camera coverage, render fidelity), which is why P0 dipped slightly.
@@ -118,7 +124,7 @@ trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel
| Work package | Tracker rows | Agent-hours | Risk |
|---|---|---:|---|
| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
-| Raw codecs: CR3 (CRX), compressed ORF / RAF, NEF lossy-after-split, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
+| Raw codecs: CR3 (CRX), compressed ORF, NEF lossy-after-split, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
| Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection |
| Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium |
| AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training |
@@ -165,31 +171,45 @@ The milestone estimates in the table above were made before work started and are
Sony ILCE-7M4 downsized lossless ARW (subsampled YCbCr 4:2:0 / 4:2:2 tiles) now decodes to linear RGB;
private 3:2 & 4:3 examples verified through native rendering & full-resolution 16-bit export.
+CR3 lossless Bayer and version 0x100/0x200 C-RAW sensor decoding is verified pixel-exactly on Canon M50, R100 and R8; any other CR3, or one that fails to decode, opens from its embedded JPEG as before. See [`docs/cr3.md`](docs/cr3.md) for coding limits, reference provenance and colour limitations.
+
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
-maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed (Bayer and
-X-Trans), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
+maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and
+X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from
-118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Every
-supported container also yields its embedded JPEG preview (CR3 too), and the engine shows that preview for raw variants
-it can't decode yet.
+118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Supported containers yield their embedded JPEG preview when present (including CR3). The engine uses it for RAW variants it cannot decode yet; HEVC-only CR3 previews are not decoded.
Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete):
- **Nikon "lossy after split" NEF** (non-zero split row in maker note `0x0096`, e.g. some D3400/D5000/D5200/D5500/D5600
files): rows above the split decode with the regular lossy table; from the split row on a different code is used
that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
-- **Olympus compressed ORF**, **Fujifilm compressed RAF**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
+- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
-**Camera colour matrices:** ARW, NEF and RW2 files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
+**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2 and PEF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
own chart-based calibration (M11.4). Adobe matrices are never used.
+## Lightroom Classic catalog migration
+
+File → Import Lightroom Catalog… reads `.lrcat` plus committed WAL pages directly through a pure-Rust
+SQLite reader. Originals stay in place; ratings, flags, labels, XMP metadata, hierarchical keywords,
+collections/sets, virtual copies and supported develop settings migrate into LightCraft. Existing edits
+are preserved by default, reimport identities are persistent, and source settings/history/snapshots are
+archived before catalog mutation. Missing originals remain available for relinking.
+
+Rendering is approximate: unsupported Adobe profiles/AI/process settings are reported and archived;
+history/snapshots remain source data, and smart collections import current membership. The Lightroom
+database is read-only; LightCraft owns subsequent edits. See `docs/lightroom-catalog-import.md`.
+
## Log
- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging ecosystem) complete.
- 2026-09-30 (later): app running with the full Lightroom-style UI; pipeline v0; DNG/CR2/ARW; README showcase. ≈ 8 h elapsed.
- 2026-10-02: parity estimate added (xtask parity prints weighted completion); milestone statuses refreshed.
+- 2026-10-08: Fujifilm camera colour: guarded JPEG matching and relative WB, plus bundled X-H2S/X-T4 profiles from 201/545 photos. On 150 withheld files the pooled profiles improve mean ΔE slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); dark X-H2S samples 8.74 → 2.25. Measured calibration remains open.
+- 2026-10-08: Fujifilm lossless/lossy compressed RAF: pure-Rust striped decoder; full sensor arrays verified against a black-box reference on 17 CC0 files and 7 supplied X-H2S / X-T4 files (13 camera bodies, 14/16-bit Bayer and X-Trans). Camera colour calibration remains a separate gap.
- 2026-10-01: RAW II formats: RAF (uncompressed Bayer + X-Trans), RW2 (packed), PEF (incl. Huffman), ORF (uncompressed); embedded previews for every container incl. CR3; raw corpus test with 37 CC0 samples.
- 2026-10-03 – 10-05: community PRs (#42–#51, #60) and 25+ issues landed: compressed NEF, Move import, folder templates,
Local roots and cleanup, 85k-photo grid and catalog performance, background compaction, failed-save errors, GPU
@@ -198,6 +218,7 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
- 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format (compressed 4/6/8, packed 2/5/7,
16-bit words), black levels, in-camera aspect crops and mapped-out defects established on 178 CC0 files from 118
bodies; RW2 gets the file-local camera look (accepted on 140 of 174 files with a preview).
+- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact full-sensor regressions on M50/R100/R8; any CR3 decode error falls back to the embedded JPEG; guarded CR3 camera-JPEG colour fitting with independent framing validation (`docs/cr3.md`). Unverified CRX variants and broader model verification remain.
## Chinese and Japanese interfaces, and text watermarks
diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs
index b9e2641c9..6c5e6e442 100644
--- a/apps/lightcraft-cli/src/main.rs
+++ b/apps/lightcraft-cli/src/main.rs
@@ -71,6 +71,8 @@ USAGE:
per line: {\"method\": \"ui.set\", \"params\": {\"view\": \"detail\"}}.
Replies go to stdout. `ui.screenshot` without a path writes -o (then
OUT-2.png, OUT-3.png…); `ui.settle {timeoutMs?}` waits for renders.
+ A failed request (\"ok\": false) does not stop the script, but the exit
+ status is non-zero when any request failed.
-o, --output OUT PNG path (a final screenshot is written here if the script took none)
--size WxH window size in points (default 1600x1000)
--scale S pixels per point (default 1)
@@ -87,7 +89,7 @@ USAGE:
lightcraft-cli controls [--json] list every develop control id with its range
lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
Fit a colour profile per camera model from raw files and their embedded camera JPEGs
- (Sony ARW, Nikon NEF): up to N files spread over the folders (default 300; 0 = all), pooled per
+ (Sony ARW, Nikon NEF, Fujifilm RAF): up to N files spread over the folders (default 300; 0 = all), pooled per
model, written as .json to DIR (default: the profiles folder LightCraft reads,
/camera-profiles, or $LIGHTCRAFT_CAMERA_PROFILES). Raws of a profiled model then
take their colour from the profile and only their tone from their own JPEG.
@@ -100,6 +102,14 @@ static ALLOC: dhat::Alloc = dhat::Alloc;
/// Why `--library DIR` can't be opened; for a library open in another program, how to work with
/// that one instead (issue #99).
+/// Print the open library's warnings (an unlockable library, damaged settings files) on stderr:
+/// stdout carries command results / MCP protocol messages only.
+fn library_warnings(session: &mut Session, who: &str) {
+ for w in session.take_library_warnings() {
+ eprintln!("{who}: warning: {w}");
+ }
+}
+
fn library_error(dir: &str, e: lightcraft_engine::EngineError) -> String {
match e {
lightcraft_engine::EngineError::LibraryInUse(why) => format!(
@@ -122,6 +132,9 @@ fn main() -> ExitCode {
dhat::Profiler::builder().file_name(file).build()
};
alloc_release::install();
+ // Warnings (a GPU render redone on the CPU, an unknown backend name) on stderr; LIGHTCRAFT_LOG
+ // or RUST_LOG picks another level (#168).
+ lightcraft_engine::logging::install("lightcraft-cli");
let args: Vec = std::env::args().skip(1).collect();
let r = match args.first().map(String::as_str) {
Some("run") => run(&args[1..]),
@@ -154,7 +167,8 @@ fn main() -> ExitCode {
/// Raw files below `path` (or `path` itself), skipping hidden and NAS metadata folders.
fn raw_files(path: &Path, out: &mut Vec, depth: usize) {
- let is_raw = |p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw"].iter().any(|x| e.eq_ignore_ascii_case(x)));
+ let is_raw =
+ |p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw", "raf"].iter().any(|x| e.eq_ignore_ascii_case(x)));
if path.is_file() {
if is_raw(path) {
out.push(path.to_path_buf());
@@ -311,6 +325,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
let mut h = Headless::default();
let r = h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
eprintln!("lightcraft-cli mcp: opened library {dir} ({r:?})");
+ library_warnings(&mut h.session, "lightcraft-cli mcp");
h
}
None if demo => Headless::demo(),
@@ -547,6 +562,7 @@ fn run(args: &[String]) -> Result<(), String> {
Some(dir) => {
let mut h = Headless::default();
h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
+ library_warnings(&mut h.session, "lightcraft-cli");
h
}
None if demo => Headless::demo(),
@@ -659,7 +675,7 @@ fn render(args: &[String]) -> Result<(), String> {
for (k, v) in opts {
p[k] = v;
}
- let mut o = ExportOptions::from_json(&p);
+ let mut o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?;
if p.get("format").is_none() {
let ext = Path::new(&output).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
o.format = ExportFormat::parse(&ext)
@@ -755,6 +771,9 @@ fn snapshot(args: &[String]) -> Result<(), String> {
Some(p.with_file_name(format!("{stem}-{shots}.{ext}")).to_string_lossy().to_string())
};
let mut wrote_output = false;
+ // Issue #166: a scripted request that fails still gets its reply printed and the run carries
+ // on (like `run --keep-going`), but the exit status reports it — CI judges by exit status.
+ let mut failed = 0usize;
if let Some(script) = &script {
let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
let mut out = std::io::stdout().lock();
@@ -787,6 +806,9 @@ fn snapshot(args: &[String]) -> Result<(), String> {
}
_ => h.request(&method, params, timeout),
};
+ if reply["ok"] != true {
+ failed += 1;
+ }
if let Some(o) = reply.as_object_mut() {
o.insert("id".into(), id);
// wall time of the request (incl. the frames it ran), and since the start
@@ -823,7 +845,7 @@ fn snapshot(args: &[String]) -> Result<(), String> {
}
}
eprintln!("lightcraft-cli snapshot: done in {:.2} s ({} frames)", t0.elapsed().as_secs_f64(), h.frames());
- Ok(())
+ if failed > 0 { Err(format!("{failed} scripted request(s) failed")) } else { Ok(()) }
}
fn commands(args: &[String]) -> Result<(), String> {
diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs
index 7cd0710b0..d1b178e49 100644
--- a/apps/lightcraft-cli/tests/cli.rs
+++ b/apps/lightcraft-cli/tests/cli.rs
@@ -19,6 +19,19 @@ fn gradient_png(path: &std::path::Path) {
std::fs::write(path, png).unwrap();
}
+#[test]
+fn calibrate_reads_raf_inputs_and_reports_bad_files() {
+ let input = tmp("invalid.RAF");
+ std::fs::write(&input, b"FUJIFILMCCD-RAW").unwrap();
+ let out = tmp("raf-profiles");
+ let result = Command::new(BIN).args(["calibrate", "--out"]).arg(&out).arg(&input).output().unwrap();
+ assert!(!result.status.success());
+ let stderr = String::from_utf8_lossy(&result.stderr);
+ assert!(stderr.contains("1 of 1 raw files"), "RAF was silently ignored: {stderr}");
+ assert!(stderr.contains("invalid.RAF") && stderr.contains("no profile written"), "{stderr}");
+ assert!(!out.join("X-T4.json").exists(), "bad input must not produce a profile");
+}
+
fn mean(path: &std::path::Path) -> f64 {
let d = lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).unwrap();
let img = d.to_srgb8();
@@ -154,10 +167,14 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
std::fs::write(
&script,
format!(
- "# comment\n{}\n{}\n{}\n{}\n",
+ "# comment\n{}\n{}\n{}\n{}\n{}\n{}\n",
json!({"method": "ui.set", "params": {"view": "photoGrid"}}),
+ // (settled: under load a grid shot taken before its thumbnails is darker than the
+ // dimmed export dialog over them, and the comparison below fails)
+ json!({"method": "ui.settle", "params": {"timeoutMs": 20000}}),
json!({"method": "ui.screenshot"}),
json!({"method": "engine.execute", "params": {"command": "dialog.export"}}),
+ json!({"method": "ui.settle", "params": {"timeoutMs": 20000}}),
json!({"method": "ui.screenshot", "params": {"path": b.to_str().unwrap()}}),
),
)
@@ -168,7 +185,7 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
.unwrap();
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let replies: Vec = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
- assert_eq!(replies.len(), 4);
+ assert_eq!(replies.len(), 6);
assert!(replies.iter().all(|r| r["ok"] == true), "{replies:?}");
for (p, dimmed) in [(&a, false), (&b, true)] {
let d = lightcraft_codecs::decode(&std::fs::read(p).unwrap(), Default::default()).unwrap();
@@ -183,6 +200,39 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
assert_eq!((d.width, d.height), (640, 480));
}
+/// Issue #166: a scripted request that fails makes `snapshot` exit non-zero. The failed reply is
+/// still printed, the later lines still run, and the final `-o` screenshot is still written.
+#[test]
+fn snapshot_script_failure_exits_non_zero() {
+ let script = tmp("snap-fail.jsonl");
+ let out = tmp("snap-fail.png");
+ std::fs::write(
+ &script,
+ format!(
+ "{}\n{}\n",
+ json!({"method": "ui.set", "params": {"view": "grid"}}), // not a view: `photoGrid`, `detail`, …
+ json!({"method": "ui.set", "params": {"view": "photoGrid"}}),
+ ),
+ )
+ .unwrap();
+ let _ = std::fs::remove_file(&out);
+ let o = Command::new(BIN)
+ .args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "320x200"])
+ .output()
+ .unwrap();
+ let stderr = String::from_utf8_lossy(&o.stderr);
+ assert!(!o.status.success(), "a failed scripted request must fail the run\n{stderr}");
+ assert!(stderr.contains("1 scripted request(s) failed"), "{stderr}");
+ let replies: Vec = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
+ assert_eq!(replies.len(), 2, "{replies:?}");
+ assert_eq!(replies[0]["ok"], false, "{replies:?}");
+ assert!(replies[0]["error"].as_str().unwrap_or("").contains("grid"), "{replies:?}");
+ assert_eq!(replies[1]["ok"], true, "{replies:?}");
+ // continue-and-report: the final screenshot is still written
+ let d = lightcraft_codecs::decode(&std::fs::read(&out).unwrap(), Default::default()).unwrap();
+ assert_eq!((d.width, d.height), (320, 200));
+}
+
/// Issue #136: with the GPU switched off from the environment the UI starts and renders on the CPU,
/// without creating a GPU device (no driver is loaded), and says why.
#[test]
@@ -336,3 +386,29 @@ fn connect_failure_hint_names_the_attempted_port() {
assert!(err.contains("--control 18437"), "the hint must name the port it tried: {err}");
assert!(!err.contains("--control 7980"), "{err}");
}
+
+/// The CLI logs warnings on stderr (#168): with `RUST_LOG=warn` an unknown GPU backend name is
+/// reported, and `RUST_LOG=off` silences it. Before, no logger was installed and nothing appeared.
+#[test]
+fn warnings_are_logged_on_stderr() {
+ let input = tmp("log-in.png");
+ gradient_png(&input);
+ let out = tmp("log-out.jpg");
+ let run = |level: &str| {
+ let o = Command::new(BIN)
+ .args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap()])
+ .env("LIGHTCRAFT_GPU_BACKEND", "bogus")
+ .env("RUST_LOG", level)
+ .env_remove("LIGHTCRAFT_LOG")
+ .env_remove("LIGHTCRAFT_GPU")
+ .output()
+ .unwrap();
+ let stderr = String::from_utf8_lossy(&o.stderr).into_owned();
+ assert!(o.status.success(), "{stderr}");
+ stderr
+ };
+ let warn = run("warn");
+ assert!(warn.contains("LIGHTCRAFT_GPU_BACKEND=bogus names no known backend"), "{warn}");
+ let off = run("off");
+ assert!(!off.contains("names no known backend"), "{off}");
+}
diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs
index 2efabebb5..9ffbf170f 100644
--- a/apps/lightcraft-web/src/web.rs
+++ b/apps/lightcraft-web/src/web.rs
@@ -168,6 +168,7 @@ fn services(originals: Originals, backend: Option, files: Files, frozen
let (backup_backend, backup_files) = (backend.clone(), files);
let restore_backend = backend.clone();
Services {
+ picker: None,
backup_library: Some(Box::new(move |session: &mut Session| {
let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
// the photos' own names for the originals in the zip
@@ -207,6 +208,7 @@ fn services(originals: Originals, backend: Option, files: Files, frozen
// Preset files: browser pickers are asynchronous; not wired on the web yet.
pick_preset_files: None,
pick_tracklog: None,
+ pick_lightroom_catalog: None,
save_preset_file: None,
pick_curve_preset_files: None,
save_curve_preset_file: None,
@@ -217,6 +219,7 @@ fn services(originals: Originals, backend: Option, files: Files, frozen
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
})),
reveal: None,
+ log_file: None,
open_with: None,
open_url: Some(Box::new(|url: &str| {
let w = web_sys::window().ok_or("no window")?;
@@ -429,8 +432,17 @@ impl WebApp {
cores.saturating_sub(1).clamp(1, 4)
});
let cache = app.session.media.rendered.clone();
- let workers =
- (n > 0).then(|| Workers::start(n, backend.as_ref().map_or("memory", |b| b.kind()), backend.clone(), index, &cache, cc.egui_ctx.clone()));
+ let workers = (n > 0).then(|| {
+ Workers::start(
+ n,
+ backend.as_ref().map_or("memory", |b| b.kind()),
+ backend.clone(),
+ index,
+ &cache,
+ app.session.cache_bytes(),
+ cc.egui_ctx.clone(),
+ )
+ });
if let Some(w) = &workers {
app.renderer.set_offload(Box::new(w.clone()));
}
@@ -601,11 +613,16 @@ impl eframe::App for WebApp {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
for text in safety::take_notices() {
let now = ctx.input(|i| i.time);
- self.app.ui.toast = Some((text, now + NOTICE_SECS));
+ self.app.ui.toast = Some((text, now + NOTICE_SECS, None));
}
self.run_inbox();
self.import_dropped(ctx);
self.load_originals(ctx);
+ if let Some(w) = &self.workers {
+ // `library.preferences ▸ cacheMb` is read by the session; the worker index prunes on
+ // its own thread, so it gets the budget every frame and applies it on the next store.
+ w.set_budget(self.app.session.cache_bytes());
+ }
self.app.logic(ctx);
self.save();
if let Some(b) = self.bench.as_mut()
diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs
index 0474e2a08..81a76e6c9 100644
--- a/apps/lightcraft-web/src/wire.rs
+++ b/apps/lightcraft-web/src/wire.rs
@@ -53,6 +53,9 @@ pub struct WireJob {
/// The request's diagnostic overlay (`Overlay::to_parts`).
#[serde(default)]
pub overlay: (u8, f64),
+ /// Render only this window `[x, y, w, h]` of the output `max_w × max_h` describes (a zoomed view).
+ #[serde(default)]
+ pub window: Option<[usize; 4]>,
}
impl WireJob {
@@ -83,6 +86,7 @@ impl WireJob {
thumb_cached,
stages: job.stages.is_some().then(|| view.to_string()),
overlay: job.request.overlay.to_parts(),
+ window: job.request.window.map(|w| [w.x, w.y, w.w, w.h]),
}
}
@@ -108,6 +112,7 @@ impl WireJob {
space: lightcraft_engine::pipeline::OutputSpace::Srgb,
depth: lightcraft_engine::pipeline::OutputDepth::U8,
proof: None,
+ window: self.window.map(|[x, y, w, h]| lightcraft_engine::pipeline::PixelWindow { x, y, w, h }),
}
}
@@ -348,6 +353,36 @@ mod tests {
assert!(reopened.contains(&new));
}
+ // Issue #323: a worker given a zoomed view's window renders that window, not the whole frame
+ #[test]
+ fn a_window_job_crosses_the_wire_and_renders_the_same_window() {
+ use lightcraft_engine::pipeline::PixelWindow;
+ let mut s = Session::with_demo();
+ let id = s.visible_cloned()[0];
+ let win = PixelWindow { x: 300, y: 200, w: 160, h: 120 };
+ let job = s.region_job(id, 1920, 1280, win, true).unwrap();
+ let wire = WireJob::from_job(&job, false, "Region");
+ let wire: WireJob = serde_json::from_str(&serde_json::to_string(&wire).unwrap()).unwrap();
+ assert_eq!(wire.request().window, Some(win));
+ let mut core = WorkerCore::default();
+ let a = core.render(&wire, None).unwrap();
+ let b = job.run().rendered.unwrap();
+ assert_eq!((a.image.width, a.image.height), (160, 120));
+ assert_eq!(a.image.data, b.image.data);
+ }
+
+ // a job from before windows existed has none
+ #[test]
+ fn a_job_without_a_window_field_has_no_window() {
+ let mut s = Session::with_demo();
+ let id = s.visible_cloned()[0];
+ let wire = WireJob::from_job(&s.thumb_job(id, 128).unwrap(), false, "Thumb");
+ let mut v = serde_json::to_value(&wire).unwrap();
+ v.as_object_mut().unwrap().remove("window");
+ let old: WireJob = serde_json::from_value(v).unwrap();
+ assert_eq!(old.request().window, None);
+ }
+
#[test]
fn wire_job_renders_like_the_engine() {
let mut s = Session::with_demo();
@@ -393,6 +428,7 @@ mod tests {
thumb_cached: false,
stages: Some("Main".into()),
overlay: (0, 0.0),
+ window: None,
};
let mut core = WorkerCore::default();
assert_eq!(core.needs_original(&job).as_deref(), Some(hash.as_str()));
diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs
index a90ed61e5..52a323289 100644
--- a/apps/lightcraft-web/src/workers.rs
+++ b/apps/lightcraft-web/src/workers.rs
@@ -18,7 +18,7 @@ use std::sync::Arc;
use js_sys::{Array, Object, Reflect, Uint8Array, Uint32Array};
use lightcraft_engine::catalog::PhotoId;
-use lightcraft_engine::media::{DISK_CACHE_BYTES, RenderJob, RenderResult, SourceLevel};
+use lightcraft_engine::media::{RenderJob, RenderResult, SourceLevel};
use lightcraft_engine::pipeline::Rendered;
use lightcraft_preview::{Hash128, PreviewCache};
use lightcraft_raster::{Histogram, Rgba8};
@@ -183,6 +183,9 @@ struct Inner {
/// (photo, level) → worker that last decoded it.
affinity: HashMap<(PhotoId, SourceLevel), usize>,
index: ThumbIndex,
+ /// The session's thumbnail cache budget in bytes. Kept here because the prune happens on a
+ /// worker message, where the session is out of reach; the app refreshes it every frame.
+ budget: u64,
backend: Option,
ctx: egui::Context,
/// Jobs finished by workers / inline (for diagnostics).
@@ -214,7 +217,15 @@ impl Workers {
/// Start `n` workers (none: every job runs inline). `store` is the backend kind for workers.
/// `index` lists the stored thumbnails of `cache`, the session's rendered-thumbnail cache
/// now: watching it from the start, a clear before the first render request still counts.
- pub fn start(n: usize, store: &str, backend: Option, index: ThumbIndex, cache: &Arc, ctx: egui::Context) -> Workers {
+ pub fn start(
+ n: usize,
+ store: &str,
+ backend: Option,
+ index: ThumbIndex,
+ cache: &Arc,
+ budget: u64,
+ ctx: egui::Context,
+ ) -> Workers {
let w = Workers(Rc::new(RefCell::new(Inner {
preview_generation: CacheWatch::new(cache),
workers: Vec::new(),
@@ -222,6 +233,7 @@ impl Workers {
next_id: 0,
affinity: HashMap::new(),
index,
+ budget,
backend,
ctx,
remote_done: 0,
@@ -328,7 +340,10 @@ impl Workers {
}
if stored > 0 {
g.index.insert(&hex, stored);
- let gone = g.index.prune(DISK_CACHE_BYTES);
+ // The session's own budget (`library.preferences ▸ cacheMb`), not the native
+ // default: the browser accepted the setting, so it has to be the one enforced
+ // (#245).
+ let gone = g.index.prune(g.budget);
if !gone.is_empty()
&& let Some(be) = g.backend.clone()
{
@@ -386,6 +401,12 @@ impl Workers {
let ready = g.workers.iter().filter(|w| w.ready && !w.dead).count();
(alive, ready, g.remote_done, g.inline_done)
}
+
+ /// The budget the thumbnail index is pruned to. The app refreshes it from the session every
+ /// frame, so `library.preferences ▸ cacheMb` applies to the next stored thumbnail.
+ pub fn set_budget(&self, bytes: u64) {
+ self.0.borrow_mut().budget = bytes;
+ }
}
impl RenderOffload for Workers {
diff --git a/apps/lightcraft/src/control_server.rs b/apps/lightcraft/src/control_server.rs
index ff16be2f0..16bd5df0f 100644
--- a/apps/lightcraft/src/control_server.rs
+++ b/apps/lightcraft/src/control_server.rs
@@ -28,11 +28,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
- eprintln!("lightcraft: control server failed to bind 127.0.0.1:{port}: {e}");
+ log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
}
};
- eprintln!("lightcraft: control server listening on 127.0.0.1:{port}");
+ log::info!("control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || accept_loop(listener, tx, ctx));
rx
}
diff --git a/apps/lightcraft/src/dialog_filter.rs b/apps/lightcraft/src/dialog_filter.rs
new file mode 100644
index 000000000..87760f799
--- /dev/null
+++ b/apps/lightcraft/src/dialog_filter.rs
@@ -0,0 +1,145 @@
+//! File-dialog filters that match extensions whatever their letter case.
+//!
+//! On Linux, `rfd` turns each extension into a `*.ext` glob for the GTK dialog and for the
+//! desktop portal (`xdg-desktop-portal` FileChooser), and both match globs case-sensitively, so
+//! a filter of `nef` hid `DSC_1234.NEF` (issue #342). Windows and macOS ignore case. Every
+//! dialog filter of the app goes through [`FileDialogExt::add_filter_nocase`] (a test checks
+//! that nothing else calls `add_filter`), so no extension can hit this.
+
+use rfd::FileDialog;
+
+/// The patterns to hand to `rfd` for `exts`, which are plain extensions without the dot.
+///
+/// Windows and macOS take the extension literally and ignore case: lower case, de-duplicated.
+/// Linux (`linux`): `rfd` formats each entry as `*.{entry}`, which GTK 3 and the portal match
+/// as a glob, so an entry may carry glob syntax. Each extension gives the lower-case form, the
+/// UPPER-case form (kept in case a portal backend does not do `[...]` classes) and a bracket
+/// form such as `[nN][eE][fF]` that matches every mix (`.Nef`, `.nEf`, ...).
+pub fn dialog_extensions_for(exts: &[&str], linux: bool) -> Vec {
+ let mut out: Vec = Vec::new();
+ let mut push = |s: String| {
+ if !s.is_empty() && !out.contains(&s) {
+ out.push(s);
+ }
+ };
+ for ext in exts {
+ let ext = ext.trim_start_matches('.');
+ let lower = ext.to_lowercase();
+ push(lower.clone());
+ if !linux {
+ continue;
+ }
+ push(ext.to_uppercase());
+ // `[` `]` `*` `?` in an extension would already be glob syntax: leave those alone
+ if lower.chars().any(|c| c.is_alphabetic()) && !lower.contains(['[', ']', '*', '?', '\\']) {
+ push(
+ lower
+ .chars()
+ .map(|c| {
+ let up: String = c.to_uppercase().collect();
+ if up.chars().count() == 1 && up != c.to_string() { format!("[{c}{up}]") } else { c.to_string() }
+ })
+ .collect(),
+ );
+ }
+ }
+ out
+}
+
+/// [`dialog_extensions_for`] for the platform this is built for.
+pub fn dialog_extensions(exts: &[&str]) -> Vec {
+ dialog_extensions_for(exts, cfg!(all(unix, not(target_os = "macos"))))
+}
+
+/// `add_filter` that matches the extensions in any letter case.
+pub trait FileDialogExt {
+ fn add_filter_nocase(self, name: impl Into, exts: &[&str]) -> Self;
+}
+
+impl FileDialogExt for FileDialog {
+ fn add_filter_nocase(self, name: impl Into, exts: &[&str]) -> Self {
+ self.add_filter(name, &dialog_extensions(exts))
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::unwrap_used)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn other_platforms_get_lower_case_once() {
+ assert_eq!(dialog_extensions_for(&["NEF", "nef", ".Jpg", "jpg", ""], false), ["nef", "jpg"]);
+ }
+
+ #[test]
+ fn linux_gets_lower_upper_and_any_mix() {
+ assert_eq!(dialog_extensions_for(&["nef"], true), ["nef", "NEF", "[nN][eE][fF]"]);
+ // digits have no case; a duplicate adds nothing
+ assert_eq!(dialog_extensions_for(&["mp4", "MP4"], true), ["mp4", "MP4", "[mM][pP]4"]);
+ assert_eq!(dialog_extensions_for(&["7z"], true), ["7z", "7Z", "7[zZ]"]);
+ assert_eq!(dialog_extensions_for(&["123"], true), ["123"]);
+ }
+
+ #[test]
+ fn linux_glob_syntax_is_not_wrapped_again() {
+ assert_eq!(dialog_extensions_for(&["[nN]ef"], true), ["[nn]ef", "[NN]EF"]);
+ }
+
+ /// The Linux patterns match like GTK's glob (`*.{entry}`): checked against a tiny matcher of
+ /// the subset used here (verified against real GTK 3.24 `GtkFileFilter` in the PR notes).
+ fn glob_ext(entry: &str, ext: &str) -> bool {
+ let mut pat = entry.chars().peekable();
+ let mut s = ext.chars();
+ while let Some(p) = pat.next() {
+ let Some(c) = s.next() else { return false };
+ if p == '[' {
+ let mut hit = false;
+ for q in pat.by_ref() {
+ if q == ']' {
+ break;
+ }
+ hit |= q == c;
+ }
+ if !hit {
+ return false;
+ }
+ } else if p != c {
+ return false;
+ }
+ }
+ s.next().is_none()
+ }
+
+ #[test]
+ fn linux_patterns_match_every_case_mix() {
+ let pats = dialog_extensions_for(&["nef", "cr2", "xmp"], true);
+ let matches = |name: &str| pats.iter().any(|p| glob_ext(p, name));
+ for ok in ["nef", "NEF", "Nef", "nEF", "CR2", "Cr2", "XMP", "xMp"] {
+ assert!(matches(ok), "{ok}");
+ }
+ for bad in ["nex", "ne", "nefs", "jpg", "cr3"] {
+ assert!(!matches(bad), "{bad}");
+ }
+ }
+
+ /// The app's own extension list (the one the folder scan uses) is covered whole.
+ #[test]
+ fn import_extensions_are_all_covered_in_both_cases() {
+ let pats = dialog_extensions_for(lightcraft_engine::import::EXTENSIONS, true);
+ for e in lightcraft_engine::import::EXTENSIONS {
+ assert!(pats.iter().any(|p| glob_ext(p, &e.to_uppercase())), "{e}");
+ assert!(pats.iter().any(|p| p == e), "{e}");
+ }
+ }
+
+ /// Every dialog filter goes through the helper: `main.rs` must not call rfd's own
+ /// `add_filter`.
+ #[test]
+ fn no_dialog_bypasses_the_helper() {
+ for (file, src) in [("main.rs", include_str!("main.rs")), ("control_server.rs", include_str!("control_server.rs"))] {
+ assert!(!src.contains(".add_filter("), "{file} calls rfd's add_filter; use add_filter_nocase");
+ }
+ assert!(include_str!("main.rs").contains(".add_filter_nocase("));
+ }
+}
diff --git a/apps/lightcraft/src/logging.rs b/apps/lightcraft/src/logging.rs
new file mode 100644
index 000000000..718392f35
--- /dev/null
+++ b/apps/lightcraft/src/logging.rs
@@ -0,0 +1,689 @@
+//! The desktop app's logger: `log::` records go to standard error and to a log file.
+//!
+//! It replaces the stderr-only `StderrLog` that `main.rs` used to install. A launch from a
+//! desktop menu or the Dock has no terminal, so the file is what a bug report can attach:
+//! `/logs/lightcraft.log`, next to `ui.json` (`config_dir` in `main.rs`; Linux
+//! `$XDG_CONFIG_HOME/lightcraft/logs`, by default `~/.config/lightcraft/logs`). Never in the
+//! library folder. Each start moves the previous log to `lightcraft.1.log` (and that one to `.2`),
+//! so the log of a run that crashed survives the next launch. Runs with `LIGHTCRAFT_NO_PREFS`
+//! (tests, scripts) log to standard error only, so they don't rotate away the user's own logs.
+//!
+//! Levels ([`filter_spec`]): `info` for LightCraft's own crates, `warn` for everything else (wgpu
+//! and naga are chatty). `LIGHTCRAFT_LOG` keeps the meaning it had with the old logger: `info` or
+//! `debug` lowers LightCraft's own crates to that level, any other value means warnings and errors
+//! only. Without it, `RUST_LOG` replaces the default with env_logger-style directives: `debug`,
+//! `warn,lightcraft_pipeline=trace`, `wgpu_core=info`. A directive ending in `*` matches every
+//! target that starts with it (`lightcraft*=debug`).
+//!
+//! Records logged before the settings folder is known are kept (up to [`MAX_PENDING`]) and
+//! written once the file is attached. Writing never panics: a file that can't be created or
+//! written leaves standard error as the only sink. Panics that escape everything are added to the
+//! file by [`AppLogger::record_panic`] (the default hook already printed them on standard error).
+//!
+//! This is the desktop app's logger only; `lightcraft-cli` doesn't share a process with it.
+
+use std::fs::File;
+use std::io::Write;
+use std::path::{Path, PathBuf};
+use std::sync::{Mutex, OnceLock, PoisonError, TryLockError};
+use std::time::SystemTime;
+
+use log::LevelFilter;
+
+/// The current log file's name inside the log folder.
+pub const LOG_FILE: &str = "lightcraft.log";
+/// How many previous logs are kept (`lightcraft.1.log` … `lightcraft..log`).
+pub const KEEP: usize = 2;
+/// The log file stops growing past this size (a runaway warning can't fill the disk).
+pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
+/// Records kept in memory until the log file is attached.
+pub const MAX_PENDING: usize = 512;
+/// The built-in filter when neither `LIGHTCRAFT_LOG` nor `RUST_LOG` says otherwise.
+pub const DEFAULT_FILTER: &str = "warn,lightcraft*=info";
+
+/// The filter directives for this run from `LIGHTCRAFT_LOG` and `RUST_LOG` (blank = unset).
+///
+/// `LIGHTCRAFT_LOG` wins when set and means what it did for the old stderr logger: `info` or
+/// `debug` for LightCraft's own crates (warnings and errors from everything else), any other value
+/// warnings and errors only. Else `RUST_LOG` as env_logger directives, else [`DEFAULT_FILTER`].
+pub fn filter_spec(lightcraft_log: Option<&str>, rust_log: Option<&str>) -> String {
+ let set = |v: Option<&str>| v.map(str::trim).filter(|v| !v.is_empty()).map(str::to_owned);
+ if let Some(level) = set(lightcraft_log) {
+ return match level.as_str() {
+ "debug" => "warn,lightcraft*=debug".to_owned(),
+ "info" => "warn,lightcraft*=info".to_owned(),
+ _ => "warn".to_owned(),
+ };
+ }
+ set(rust_log).unwrap_or_else(|| DEFAULT_FILTER.to_owned())
+}
+
+/// Per-target level filter parsed from env_logger-style directives.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Filter {
+ default: LevelFilter,
+ directives: Vec<(String, LevelFilter)>,
+}
+
+impl Filter {
+ /// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
+ /// directive names log errors only.
+ pub fn parse(spec: &str) -> Filter {
+ let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
+ for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
+ match part.split_once('=') {
+ Some((name, level)) => {
+ let name = name.trim();
+ if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
+ f.directives.push((name.to_owned(), level));
+ }
+ }
+ None => match parse_level(part) {
+ Some(level) => f.default = level,
+ // A bare target name: everything from it (env_logger does the same).
+ None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
+ },
+ }
+ }
+ f
+ }
+
+ /// The level that applies to `target` (the most specific matching directive wins).
+ pub fn level_for(&self, target: &str) -> LevelFilter {
+ self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
+ }
+
+ /// The most verbose level any target can reach (for `log::set_max_level`).
+ pub fn max(&self) -> LevelFilter {
+ self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
+ }
+}
+
+fn parse_level(s: &str) -> Option {
+ s.trim().parse().ok()
+}
+
+/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
+/// `name*` matches every target starting with `name`.
+fn matches(name: &str, target: &str) -> bool {
+ match name.strip_suffix('*') {
+ Some(prefix) => target.starts_with(prefix),
+ None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
+ }
+}
+
+/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
+pub fn timestamp(t: SystemTime) -> String {
+ let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
+ let secs = since.as_secs();
+ let (days, rem) = (secs / 86_400, secs % 86_400);
+ let (y, m, d) = civil_from_days(days);
+ format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
+}
+
+/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
+/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
+/// value can overflow it.
+fn civil_from_days(days: u64) -> (u64, u64, u64) {
+ let z = days.saturating_add(719_468);
+ let era = z / 146_097;
+ let doe = z % 146_097;
+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ let mp = (5 * doy + 2) / 153;
+ let d = doy - (153 * mp + 2) / 5 + 1;
+ let m = if mp < 10 { mp + 3 } else { mp - 9 };
+ let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
+ (y, m, d)
+}
+
+fn numbered(dir: &Path, n: usize) -> PathBuf {
+ if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("lightcraft.{n}.log")) }
+}
+
+/// Shift the previous logs up one (`.log` → `.1.log` → … → `..log`, the oldest dropped)
+/// and create a fresh, empty log file in `dir` (created if missing).
+pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
+ std::fs::create_dir_all(dir)?;
+ for n in (1..=KEEP).rev() {
+ match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
+ Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
+ _ => {}
+ }
+ }
+ let path = numbered(dir, 0);
+ let file = File::create(&path)?;
+ Ok((path, file))
+}
+
+/// One formatted log line: ` [] : \n`.
+pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
+ let message = message.strip_suffix('\n').unwrap_or(message);
+ format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
+}
+
+/// Where formatted lines go besides standard error: kept in memory until a file is attached,
+/// then appended to it until it reaches its size cap.
+pub struct Sink {
+ file: Option,
+ pending: Vec,
+ dropped: usize,
+ written: u64,
+ max_bytes: u64,
+ capped: bool,
+}
+
+impl Sink {
+ pub fn new(max_bytes: u64) -> Sink {
+ Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
+ }
+
+ /// Append `line` to the file, or keep it until [`Sink::attach`].
+ pub fn write(&mut self, line: &str) {
+ if self.file.is_none() {
+ if self.capped {
+ // No file is coming ([`Sink::no_file`]): standard error had the line.
+ } else if self.pending.len() < MAX_PENDING {
+ self.pending.push(line.to_owned());
+ } else {
+ self.dropped = self.dropped.saturating_add(1);
+ }
+ return;
+ }
+ self.append(line);
+ }
+
+ /// Start writing to `file` (a fresh one: the size cap starts over), first the lines kept so far.
+ pub fn attach(&mut self, file: File) {
+ self.file = Some(file);
+ self.written = 0;
+ self.capped = false;
+ for line in std::mem::take(&mut self.pending) {
+ self.append(&line);
+ }
+ if self.dropped > 0 {
+ let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
+ self.dropped = 0;
+ self.append(¬e);
+ }
+ }
+
+ /// There will be no log file: forget the lines kept for it and keep no more.
+ pub fn no_file(&mut self) {
+ self.file = None;
+ self.pending = Vec::new();
+ self.dropped = 0;
+ self.capped = true;
+ }
+
+ fn append(&mut self, line: &str) {
+ if self.capped {
+ return;
+ }
+ let Some(file) = self.file.as_mut() else { return };
+ let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
+ let text = if self.written.saturating_add(len) > self.max_bytes {
+ self.capped = true;
+ format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
+ } else {
+ self.written = self.written.saturating_add(len);
+ line.to_owned()
+ };
+ // A full disk or a vanished file must not take the app down: drop the file sink.
+ if file.write_all(text.as_bytes()).is_err() {
+ self.file = None;
+ self.capped = true;
+ }
+ }
+}
+
+/// The installed logger: a [`Filter`], standard error and a [`Sink`].
+pub struct AppLogger {
+ filter: Filter,
+ stderr: bool,
+ sink: Mutex,
+}
+
+impl AppLogger {
+ pub fn new(filter: Filter, stderr: bool) -> AppLogger {
+ AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
+ }
+
+ /// Rotate the logs in `dir` and send records to the fresh file; returns its path. On an error
+ /// standard error stays the only sink.
+ pub fn attach_dir(&self, dir: &Path) -> Result {
+ // A thread that panicked while logging leaves the sink usable: take it back.
+ let mut sink = self.sink.lock().unwrap_or_else(PoisonError::into_inner);
+ match rotate(dir) {
+ Ok((path, file)) => {
+ sink.attach(file);
+ Ok(path)
+ }
+ Err(e) => {
+ sink.no_file();
+ Err(format!("{}: {e}", dir.display()))
+ }
+ }
+ }
+
+ /// No log file for this run (`LIGHTCRAFT_NO_PREFS`): log to standard error only.
+ pub fn no_file(&self) {
+ self.sink.lock().unwrap_or_else(PoisonError::into_inner).no_file();
+ }
+
+ /// Add a panic report to the log file only (the default panic hook has printed it on standard
+ /// error), whatever the filter. Skipped when the sink is busy: the panic may have happened on
+ /// this very thread while it held the lock, and waiting would hang the app.
+ pub fn record_panic(&self, thread: &str, report: &str) {
+ let line = format_line(×tamp(SystemTime::now()), log::Level::Error, thread, "lightcraft", report);
+ let mut sink = match self.sink.try_lock() {
+ Ok(sink) => sink,
+ Err(TryLockError::Poisoned(p)) => p.into_inner(),
+ Err(TryLockError::WouldBlock) => return,
+ };
+ sink.write(&line);
+ }
+}
+
+impl log::Log for AppLogger {
+ fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
+ metadata.level() <= self.filter.level_for(metadata.target())
+ }
+
+ fn log(&self, record: &log::Record<'_>) {
+ if !self.enabled(record.metadata()) {
+ return;
+ }
+ // Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock.
+ let thread = std::thread::current();
+ let line =
+ format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
+ if self.stderr {
+ // No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting.
+ let _ = std::io::stderr().write_all(line.as_bytes());
+ }
+ self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
+ }
+
+ fn flush(&self) {}
+}
+
+/// Install the logger (filter from [`filter_spec`]); `None` when another logger was installed
+/// first. Call [`AppLogger::attach_dir`] (or [`AppLogger::no_file`]) once the arguments are parsed.
+pub fn install() -> Option<&'static AppLogger> {
+ static LOGGER: OnceLock = OnceLock::new();
+ let var = |name| std::env::var(name).ok();
+ let filter = Filter::parse(&filter_spec(var("LIGHTCRAFT_LOG").as_deref(), var("RUST_LOG").as_deref()));
+ let max = filter.max();
+ let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
+ log::set_logger(logger).ok()?;
+ log::set_max_level(max);
+ Some(logger)
+}
+
+/// Chain a panic hook after the current one (the engine guard's: standard error plus
+/// `lightcraft-panics.log` in the temp folder) that also puts the panic in the log file.
+pub fn record_panics(logger: &'static AppLogger) {
+ let previous = std::panic::take_hook();
+ std::panic::set_hook(Box::new(move |info| {
+ previous(info);
+ let thread = std::thread::current();
+ let at = info.location().map(|l| format!(" at {}:{}", l.file(), l.line())).unwrap_or_default();
+ let report = format!("panic{at}: {}", lightcraft_engine::guard::panic_message(info.payload()));
+ logger.record_panic(thread.name().unwrap_or("?"), &report);
+ }));
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+ use std::time::{Duration, UNIX_EPOCH};
+
+ fn temp_dir(tag: &str) -> PathBuf {
+ static N: AtomicUsize = AtomicUsize::new(0);
+ let d = std::env::temp_dir().join(format!("lightcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
+ let _ = std::fs::remove_dir_all(&d);
+ d
+ }
+
+ fn read(path: &Path) -> String {
+ std::fs::read_to_string(path).expect("read log")
+ }
+
+ fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) {
+ use log::Log;
+ logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
+ }
+
+ /// The logger this replaced (`StderrLog`): warnings and errors from every target, `info` and
+ /// `debug` only from LightCraft's own crates, and only when `LIGHTCRAFT_LOG` asked for them.
+ fn old_stderr_log_enabled(lightcraft_log: &str, level: log::Level, target: &str) -> bool {
+ let max = match lightcraft_log {
+ "debug" => LevelFilter::Debug,
+ "info" => LevelFilter::Info,
+ _ => LevelFilter::Warn,
+ };
+ level <= max && (level <= log::Level::Warn || target.starts_with("lightcraft"))
+ }
+
+ #[test]
+ fn the_default_filter_shows_lightcraft_info_and_other_crates_warnings() {
+ let f = Filter::parse(DEFAULT_FILTER);
+ assert_eq!(f.level_for("lightcraft"), LevelFilter::Info);
+ assert_eq!(f.level_for("lightcraft::control_server"), LevelFilter::Info);
+ assert_eq!(f.level_for("lightcraft_engine::guard"), LevelFilter::Info);
+ assert_eq!(f.level_for("lightcraft_ui_egui::render"), LevelFilter::Info);
+ assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
+ assert_eq!(f.level_for("naga"), LevelFilter::Warn);
+ assert_eq!(f.max(), LevelFilter::Info);
+ assert_eq!(filter_spec(None, None), DEFAULT_FILTER);
+ // An empty variable is the same as an unset one.
+ assert_eq!(filter_spec(Some(""), Some(" ")), DEFAULT_FILTER);
+ }
+
+ /// `LIGHTCRAFT_LOG` keeps working exactly as it did with the old stderr-only logger.
+ #[test]
+ fn lightcraft_log_keeps_its_meaning() {
+ let targets =
+ ["lightcraft", "lightcraft::control_server", "lightcraft_engine::segment", "lightcraft_gpu", "wgpu_core::device", "naga", "eframe"];
+ let levels = [log::Level::Error, log::Level::Warn, log::Level::Info, log::Level::Debug, log::Level::Trace];
+ for value in ["info", "debug", "warn", "error", "verbose", "INFO"] {
+ let f = Filter::parse(&filter_spec(Some(value), None));
+ for target in targets {
+ for level in levels {
+ assert_eq!(level <= f.level_for(target), old_stderr_log_enabled(value, level, target), "LIGHTCRAFT_LOG={value} {level} {target}");
+ }
+ }
+ }
+ assert_eq!(Filter::parse(&filter_spec(Some("debug"), None)).max(), LevelFilter::Debug);
+ assert_eq!(Filter::parse(&filter_spec(Some("warn"), None)).max(), LevelFilter::Warn);
+ }
+
+ #[test]
+ fn rust_log_replaces_the_default_and_lightcraft_log_wins_over_it() {
+ assert_eq!(filter_spec(None, Some("warn,wgpu_core=info")), "warn,wgpu_core=info");
+ let f = Filter::parse(&filter_spec(None, Some("debug")));
+ assert_eq!(f.level_for("eframe"), LevelFilter::Debug);
+ // The app's own variable is the more specific choice.
+ let f = Filter::parse(&filter_spec(Some("info"), Some("trace")));
+ assert_eq!(f.level_for("eframe"), LevelFilter::Warn);
+ assert_eq!(f.level_for("lightcraft_engine"), LevelFilter::Info);
+ }
+
+ #[test]
+ fn directives_follow_env_logger_and_the_most_specific_one_wins() {
+ let f = Filter::parse("info,wgpu_core=error,lightcraft_pipeline=trace,lightcraft_pipeline::tiles=off");
+ assert_eq!(f.level_for("eframe"), LevelFilter::Info);
+ assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
+ assert_eq!(f.level_for("lightcraft_pipeline::stage"), LevelFilter::Trace);
+ assert_eq!(f.level_for("lightcraft_pipeline::tiles"), LevelFilter::Off);
+ assert_eq!(f.max(), LevelFilter::Trace);
+ // A module name is matched at `::` boundaries, not as a bare prefix.
+ assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
+ // A trailing `*` is a prefix.
+ assert_eq!(Filter::parse("lightcraft*=debug").level_for("lightcraft_raw::cr2"), LevelFilter::Debug);
+ assert_eq!(Filter::parse("lightcraft*=debug").level_for("eframe"), LevelFilter::Error);
+ // A bare target name sets that target to the most verbose level, as env_logger does.
+ assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
+ assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
+ // Levels are case-insensitive and surrounding blanks are ignored.
+ assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("lightcraft"), LevelFilter::Debug);
+ assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("eframe"), LevelFilter::Warn);
+ }
+
+ #[test]
+ fn hostile_specs_never_panic_and_fall_back_sensibly() {
+ let long = "x".repeat(100_000);
+ let many = "a=info,".repeat(10_000);
+ for spec in [
+ "",
+ ",,,",
+ "=",
+ "==",
+ "=debug",
+ "lightcraft=",
+ "nonsense=loud",
+ "🦀=info",
+ "*",
+ "*=",
+ "=*",
+ "a=b=c",
+ "warn,,lightcraft=DEBUG",
+ "\0",
+ "é::ü=trace",
+ &long,
+ &many,
+ ] {
+ let f = Filter::parse(spec);
+ for target in ["", "lightcraft", "🦀", "a::b", "*"] {
+ let _ = f.level_for(target);
+ }
+ let _ = f.max();
+ let _ = filter_spec(Some(spec), Some(spec));
+ }
+ assert_eq!(Filter::parse("warn,,lightcraft=DEBUG").level_for("lightcraft"), LevelFilter::Debug);
+ // An unknown level is skipped: that target keeps the default.
+ assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
+ assert_eq!(Filter::parse("lightcraft=").level_for("lightcraft"), LevelFilter::Error);
+ assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error);
+ assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info);
+ }
+
+ #[test]
+ fn timestamps_are_utc_with_milliseconds() {
+ assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
+ assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
+ // Leap day, and the day after it.
+ assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
+ assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
+ assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
+ // 2000 is a leap year (divisible by 400), 2100 is not.
+ assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
+ assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
+ // Before the epoch (a clock set wrong) clamps instead of panicking.
+ assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
+ }
+
+ #[test]
+ fn a_line_carries_time_level_thread_target_and_message() {
+ assert_eq!(
+ format_line("2026-10-08T07:59:17.728Z", log::Level::Info, "main", "lightcraft", "library /photos: 12 photos"),
+ "2026-10-08T07:59:17.728Z INFO [main] lightcraft: library /photos: 12 photos\n"
+ );
+ // A multi-line message keeps its lines; the record still ends in one newline.
+ assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
+ }
+
+ #[test]
+ fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
+ let dir = temp_dir("rotate");
+ for run in 1..=4 {
+ let (path, _file) = rotate(&dir).expect("rotate");
+ assert_eq!(path, dir.join(LOG_FILE));
+ assert_eq!(read(&path), "");
+ std::fs::write(&path, format!("run {run}")).expect("write");
+ }
+ assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
+ assert_eq!(read(&dir.join("lightcraft.1.log")), "run 3");
+ assert_eq!(read(&dir.join("lightcraft.2.log")), "run 2");
+ assert!(!dir.join("lightcraft.3.log").exists(), "only {KEEP} old logs are kept");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
+ let dir = temp_dir("blocked");
+ std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
+ std::fs::write(&dir, "a file where the directory should be").expect("block");
+ assert!(rotate(&dir).is_err());
+ let _ = std::fs::remove_file(&dir);
+ }
+
+ #[test]
+ fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
+ let dir = temp_dir("pending");
+ let (path, file) = rotate(&dir).expect("rotate");
+ let mut sink = Sink::new(MAX_FILE_BYTES);
+ sink.write("early 1\n");
+ sink.write("early 2\n");
+ sink.attach(file);
+ sink.write("late\n");
+ assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
+ let dir = temp_dir("bounded");
+ let (path, file) = rotate(&dir).expect("rotate");
+ let mut sink = Sink::new(MAX_FILE_BYTES);
+ for i in 0..MAX_PENDING + 5 {
+ sink.write(&format!("line {i}\n"));
+ }
+ sink.attach(file);
+ let text = read(&path);
+ assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
+ assert!(text.contains("5 earlier log lines were dropped"), "{text}");
+ assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn exactly_a_full_pending_buffer_drops_nothing() {
+ let dir = temp_dir("full");
+ let (path, file) = rotate(&dir).expect("rotate");
+ let mut sink = Sink::new(MAX_FILE_BYTES);
+ for i in 0..MAX_PENDING {
+ sink.write(&format!("line {i}\n"));
+ }
+ sink.attach(file);
+ let text = read(&path);
+ assert_eq!(text.lines().count(), MAX_PENDING);
+ assert!(!text.contains("dropped"), "{text}");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn without_a_file_nothing_is_kept_in_memory() {
+ let mut sink = Sink::new(MAX_FILE_BYTES);
+ sink.write("early\n");
+ sink.no_file();
+ sink.write("late\n");
+ assert!(sink.pending.is_empty() && sink.dropped == 0);
+ }
+
+ #[test]
+ fn the_file_stops_at_its_size_cap_with_one_note() {
+ let dir = temp_dir("cap");
+ let (path, file) = rotate(&dir).expect("rotate");
+ let mut sink = Sink::new(100);
+ sink.attach(file);
+ for i in 0..50 {
+ sink.write(&format!("line {i:02} with some padding\n"));
+ }
+ let text = read(&path);
+ assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
+ assert_eq!(text.matches("log file reached").count(), 1, "{text}");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
+ use log::Log;
+ let dir = temp_dir("logger");
+ let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
+ record(&logger, log::Level::Info, "lightcraft", "before the settings directory");
+ record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
+ let path = logger.attach_dir(&dir).expect("attach");
+ assert_eq!(path, dir.join(LOG_FILE));
+ record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
+ record(&logger, log::Level::Error, "lightcraft_engine::guard", "`develop.reset` failed unexpectedly: boom");
+ record(&logger, log::Level::Debug, "lightcraft", "below info");
+ let text = read(&path);
+ let lines: Vec<&str> = text.lines().collect();
+ assert_eq!(lines.len(), 3, "{text}");
+ assert!(lines[0].contains(" INFO [") && lines[0].ends_with("lightcraft: before the settings directory"), "{text}");
+ assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}");
+ assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("lightcraft_engine::guard: `develop.reset` failed unexpectedly: boom"), "{text}");
+ assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("lightcraft_ui_egui").build()));
+ assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
+ // Attaching again rotates: the first log becomes `.1`.
+ logger.attach_dir(&dir).expect("attach again");
+ assert_eq!(read(&dir.join("lightcraft.1.log")), text);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn records_from_many_threads_are_whole_lines() {
+ let dir = temp_dir("threads");
+ let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
+ let path = logger.attach_dir(&dir).expect("attach");
+ let workers: Vec<_> = (0..8)
+ .map(|t| {
+ let l = std::sync::Arc::clone(&logger);
+ std::thread::spawn(move || {
+ for i in 0..100 {
+ record(&l, log::Level::Info, "lightcraft", &format!("thread {t} record {i}"));
+ }
+ })
+ })
+ .collect();
+ for w in workers {
+ w.join().expect("worker");
+ }
+ let text = read(&path);
+ assert_eq!(text.lines().count(), 800);
+ assert!(text.lines().all(|l| l.contains(" lightcraft: thread ")), "{text}");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
+ let dir = temp_dir("unusable");
+ std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
+ std::fs::write(&dir, "not a directory").expect("block");
+ let logger = AppLogger::new(Filter::parse("info"), false);
+ record(&logger, log::Level::Info, "lightcraft", "early");
+ assert!(logger.attach_dir(&dir).is_err());
+ record(&logger, log::Level::Error, "x", "still fine");
+ // Nothing waits in memory for a file that won't come.
+ assert!(logger.sink.lock().expect("sink").pending.is_empty());
+ let _ = std::fs::remove_file(&dir);
+ }
+
+ #[test]
+ fn a_poisoned_sink_still_logs() {
+ let dir = temp_dir("poison");
+ let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
+ let l = std::sync::Arc::clone(&logger);
+ let _ = std::thread::spawn(move || {
+ let _guard = l.sink.lock().expect("lock");
+ panic!("poison the sink");
+ })
+ .join();
+ assert!(logger.sink.is_poisoned());
+ let path = logger.attach_dir(&dir).expect("attach");
+ record(&logger, log::Level::Warn, "lightcraft", "after the poison");
+ assert!(read(&path).contains("lightcraft: after the poison"));
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// The panic hook's report lands in the file, and never deadlocks when the panic happened
+ /// while this thread held the sink.
+ #[test]
+ fn a_panic_report_goes_to_the_file_and_never_deadlocks() {
+ let dir = temp_dir("panic");
+ let logger = AppLogger::new(Filter::parse("off"), false);
+ let path = logger.attach_dir(&dir).expect("attach");
+ logger.record_panic("render", "panicked at crates/x/src/lib.rs:3:5:\nboom");
+ {
+ let _held = logger.sink.lock().expect("lock");
+ logger.record_panic("main", "while the sink is held");
+ }
+ let text = read(&path);
+ assert!(text.contains(" ERROR [render] lightcraft: panicked at crates/x/src/lib.rs:3:5:\nboom\n"), "{text}");
+ assert!(!text.contains("while the sink is held"), "{text}");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+}
diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs
index 4bb1c6ff9..4c7bb4242 100644
--- a/apps/lightcraft/src/main.rs
+++ b/apps/lightcraft/src/main.rs
@@ -24,9 +24,12 @@
mod alloc_release;
mod control_server;
+mod dialog_filter;
+mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
+use dialog_filter::FileDialogExt as _;
use lightcraft_engine::Session;
use lightcraft_ui_egui::panels::library_problem::LibraryProblem;
use lightcraft_ui_egui::{LightcraftApp, Services, UiState};
@@ -63,10 +66,10 @@ impl eframe::App for App {
}
fn on_exit(&mut self) {
if let Err(e) = self.1.save(&self.0) {
- eprintln!("lightcraft: {e}");
+ log::error!("{e}");
}
if let Err(e) = self.0.session.close_library() {
- eprintln!("lightcraft: saving the library failed: {e}");
+ log::error!("saving the library failed: {e}");
}
}
}
@@ -93,10 +96,23 @@ fn gpu_marker_path() -> Option {
/// Arm the GPU crash sentinel; if the previous launch left it behind (it died inside the GPU
/// driver while creating the device), the notice to show — GPU rendering then starts off.
-fn gpu_crash_check() -> Option {
- let marker = gpu_marker_path();
- let left = marker.as_deref().and_then(lightcraft_engine::gpu::backend::take_init_marker);
- lightcraft_engine::gpu::backend::set_init_marker(marker);
+///
+/// A `--memory` session writes nothing (issue #169): it neither arms the sentinel — which would
+/// create the settings folder — nor removes a marker it finds. It still honours one, so the GPU
+/// starts off there too, and the next ordinary launch reports and clears it.
+fn gpu_crash_check(in_memory: bool) -> Option {
+ gpu_crash_check_at(gpu_marker_path(), in_memory)
+}
+
+fn gpu_crash_check_at(marker: Option, in_memory: bool) -> Option {
+ use lightcraft_engine::gpu::backend;
+ let left = if in_memory {
+ marker.as_deref().and_then(backend::read_init_marker)
+ } else {
+ let left = marker.as_deref().and_then(backend::take_init_marker);
+ backend::set_init_marker(marker);
+ left
+ };
left.map(|what| gpu_crash_notice(&what))
}
@@ -112,19 +128,28 @@ fn config_dir() -> Option {
lightcraft_engine::camera_profiles::config_dir()
}
+/// `/ui.json`, the saved UI state and app settings — `None` when nothing is read or
+/// written: `LIGHTCRAFT_NO_PREFS` (tests, scripts) or no home folder.
+fn prefs_path() -> Option {
+ if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
+ return None;
+ }
+ config_dir().map(|d| d.join("ui.json"))
+}
+
/// The saved UI state and app settings (`/ui.json`), if any, and a warning for the user
/// when the file exists but can't be used (issue #103): a damaged file is kept as
/// `ui.json.corrupt-` first, so the next save can't lose the library location in it;
/// one that can't be read at all is not written this session (`keep_file`).
-fn load_prefs() -> (Option, Option, bool) {
- if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
- return (None, None, false);
- }
- let Some(path) = config_dir().map(|d| d.join("ui.json")) else { return (None, None, false) };
- load_prefs_at(&path)
+///
+/// A `--memory` session reads the settings (so it looks like the user's app) but writes nothing
+/// (issue #164), so a damaged file is left as it is there, not set aside.
+fn load_prefs(in_memory: bool) -> (Option, Option, bool) {
+ let Some(path) = prefs_path() else { return (None, None, false) };
+ load_prefs_at(&path, !in_memory)
}
-fn load_prefs_at(path: &std::path::Path) -> (Option, Option, bool) {
+fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option, Option, bool) {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (None, None, false),
@@ -135,6 +160,10 @@ fn load_prefs_at(path: &std::path::Path) -> (Option, Option, bo
};
match serde_json::from_slice::(&bytes) {
Ok(ui) => (Some(ui.sanitized()), None, false),
+ Err(e) if !set_aside_damaged => {
+ let msg = format!("The app settings ({}) are damaged ({e}). Defaults are used; nothing is saved in this session.", path.display());
+ (None, Some(msg), true)
+ }
Err(e) => {
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let keep = path.with_file_name(format!("ui.json.corrupt-{secs}"));
@@ -177,8 +206,15 @@ fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
/// Saves `ui.json` (app settings, incl. the library to open at launch): as soon as the library
/// changes, every few seconds when anything else changed, and at exit.
+///
+/// Nothing is written while the session is one that promises to save nothing: `--memory`
+/// (`path` is `None`; issue #164), or the one running because the library couldn't be opened — the
+/// question window and Continue Without Saving (issue #100; `app.openLibrary` ends it, and the
+/// library it opened is saved then).
#[derive(Default)]
struct PrefsWriter {
+ /// Where to write; `None` writes nothing (`--memory`, `LIGHTCRAFT_NO_PREFS`, no home folder).
+ path: Option,
written: Vec,
library: String,
checked: f64,
@@ -190,19 +226,26 @@ struct PrefsWriter {
impl PrefsWriter {
fn save(&mut self, app: &LightcraftApp) -> Result<(), String> {
- if self.keep_file || std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
+ self.save_ui(&app.ui, app.library_problem.is_some())
+ }
+
+ /// Write `ui` if it changed since the last write; `temporary` (the library couldn't be
+ /// opened) saves nothing.
+ fn save_ui(&mut self, ui: &UiState, temporary: bool) -> Result<(), String> {
+ if self.keep_file || temporary {
return Ok(());
}
- let Some(d) = config_dir() else { return Ok(()) };
- let bytes = serde_json::to_vec_pretty(&app.ui).map_err(|e| e.to_string())?;
+ let Some(path) = self.path.clone() else { return Ok(()) };
+ let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?;
if bytes == self.written {
return Ok(());
}
- std::fs::create_dir_all(&d)
- .and_then(|()| write_atomic(&d.join("ui.json"), &bytes))
+ path.parent()
+ .map_or(Ok(()), std::fs::create_dir_all)
+ .and_then(|()| write_atomic(&path, &bytes))
.map_err(|e| format!("saving the app settings failed: {e}"))?;
self.written = bytes;
- self.library = app.ui.settings.library_path.clone();
+ self.library = ui.settings.library_path.clone();
Ok(())
}
@@ -216,8 +259,8 @@ impl PrefsWriter {
match self.save(app) {
Ok(()) => self.failing = false,
Err(e) => {
- eprintln!("lightcraft: {e}");
if !self.failing {
+ log::warn!("{e}");
app.notices.push(format!("{e}. LightCraft keeps trying."));
}
self.failing = true;
@@ -228,8 +271,61 @@ impl PrefsWriter {
}
}
-fn services() -> Services {
+/// The Windows command that opens `url` in the default browser. `explorer ` opened File
+/// Explorer instead for links with a query or fragment, like the Map button's OpenStreetMap link
+/// (issue #234). The URL protocol handler hands the URL to the browser as is; no shell runs, so
+/// `?`, `&` and `#` need no escaping.
+fn windows_open_url_command(url: &str) -> std::process::Command {
+ let mut c = std::process::Command::new("rundll32");
+ c.args(["url.dll,FileProtocolHandler", url]);
+ c
+}
+
+/// The platform services; `ctx` repaints when a file dialog closes, and `log_file` is
+/// for Help ▸ Open Log Folder.
+fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services {
Services {
+ // Commands' file dialogs run on their own thread (#191): a dialog on the UI thread
+ // stopped the window answering the compositor, which then reported the app as hung.
+ picker: Some(Box::new(move |req: lightcraft_ui_egui::pick::PickRequest| {
+ use lightcraft_ui_egui::pick::PickKind;
+ let (tx, rx) = std::sync::mpsc::channel();
+ let repaint = ctx.clone();
+ std::thread::Builder::new()
+ .name("file-dialog".into())
+ .spawn(move || {
+ let mut d = rfd::FileDialog::new();
+ if let Some(t) = &req.title {
+ d = d.set_title(t);
+ }
+ if let Some((name, exts)) = &req.filter {
+ d = d.add_filter_nocase(name.clone(), exts);
+ }
+ if let Some(n) = &req.file_name {
+ d = d.set_file_name(n);
+ }
+ let s = |p: std::path::PathBuf| p.to_string_lossy().to_string();
+ let paths: Vec = match req.kind {
+ PickKind::Files => d.pick_files().unwrap_or_default().into_iter().map(s).collect(),
+ PickKind::File => d.pick_file().map(s).into_iter().collect(),
+ PickKind::Folder => d.pick_folder().map(s).into_iter().collect(),
+ PickKind::Save => d.save_file().map(s).into_iter().collect(),
+ };
+ // the app may have quit meanwhile: nobody to tell
+ let _ = tx.send(paths);
+ repaint.request_repaint();
+ })
+ .map_err(|e| format!("could not start the file dialog: {e}"))?;
+ Ok(rx)
+ })),
+ log_file: log_file.map(|p| p.display().to_string()),
+ pick_lightroom_catalog: Some(Box::new(|| {
+ rfd::FileDialog::new()
+ .set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog"))
+ .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Lightroom Classic Catalog"), &["lrcat"])
+ .pick_file()
+ .map(|p| p.to_string_lossy().to_string())
+ })),
pick_folder: Some(Box::new(|| {
rfd::FileDialog::new().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
})),
@@ -262,7 +358,7 @@ fn services() -> Services {
let status = if cfg!(target_os = "macos") {
std::process::Command::new("open").arg(url).status()
} else if cfg!(target_os = "windows") {
- std::process::Command::new("explorer").arg(url).status()
+ windows_open_url_command(url).status()
} else {
std::process::Command::new("xdg-open").arg(url).status()
};
@@ -272,7 +368,8 @@ fn services() -> Services {
let status = if cfg!(target_os = "macos") {
std::process::Command::new("open").args(["-R", path]).status()
} else if cfg!(target_os = "windows") {
- std::process::Command::new("explorer").arg(format!("/select,{path}")).status()
+ let win_path = path.replace('/', "\\");
+ std::process::Command::new("explorer").arg(format!("/select,{win_path}")).status()
} else {
let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| ".".into());
std::process::Command::new("xdg-open").arg(dir).status()
@@ -283,13 +380,7 @@ fn services() -> Services {
})),
pick_files: Some(Box::new(|| {
rfd::FileDialog::new()
- .add_filter(
- lightcraft_ui_egui::i18n::tr("Photos"),
- &[
- "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw",
- "pef", "psd", "jxl", "gif", "bmp",
- ],
- )
+ .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS)
.pick_files()
.unwrap_or_default()
.into_iter()
@@ -299,7 +390,7 @@ fn services() -> Services {
pick_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Import Presets"))
- .add_filter(
+ .add_filter_nocase(
lightcraft_ui_egui::i18n::tr("Presets & Profiles"),
&["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"],
)
@@ -312,7 +403,7 @@ fn services() -> Services {
pick_tracklog: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Auto-Tag from Tracklog"))
- .add_filter(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"])
+ .add_filter_nocase(lightcraft_ui_egui::i18n::tr("GPS Track Log"), &["gpx"])
.pick_file()
.map(|p| vec![p.to_string_lossy().to_string()])
.unwrap_or_default()
@@ -320,7 +411,7 @@ fn services() -> Services {
save_preset_file: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Export Presets"))
- .add_filter(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
+ .add_filter_nocase(lightcraft_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
@@ -328,7 +419,7 @@ fn services() -> Services {
pick_curve_preset_files: Some(Box::new(|| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets"))
- .add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"])
+ .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve", "json"])
.pick_files()
.unwrap_or_default()
.into_iter()
@@ -338,7 +429,7 @@ fn services() -> Services {
save_curve_preset_file: Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.set_title(lightcraft_ui_egui::i18n::tr("Export Point Curve Presets"))
- .add_filter(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"])
+ .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Point Curve Presets"), &["lccurve"])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
@@ -366,7 +457,7 @@ fn open_session(in_memory: bool, dir: Option, seed_demo: boo
}
let unopened = || Session::new().with_fs().with_system_clock();
let Some(dir) = dir else {
- eprintln!("lightcraft: no library location (set --library or LIGHTCRAFT_LIBRARY)");
+ log::warn!("no library location (set --library or LIGHTCRAFT_LIBRARY)");
return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it.")));
};
let t0 = std::time::Instant::now();
@@ -374,8 +465,8 @@ fn open_session(in_memory: bool, dir: Option, seed_demo: boo
match s.open_library(&dir, seed_demo) {
Ok(r) => {
let (replayed, torn) = (r.replayed, r.torn_bytes);
- eprintln!(
- "lightcraft: library {}: {} photos, {replayed} log records replayed{} ({:.1} ms)",
+ log::info!(
+ "library {}: {} photos, {replayed} log records replayed{} ({:.1} ms)",
dir.display(),
s.catalog.len(),
if torn > 0 { ", torn tail repaired" } else { "" },
@@ -384,7 +475,7 @@ fn open_session(in_memory: bool, dir: Option, seed_demo: boo
(s, None)
}
Err(e) => {
- eprintln!("lightcraft: can't open library {}: {e}", dir.display());
+ log::error!("can't open library {}: {e}", dir.display());
(unopened(), Some(LibraryProblem::new(dir.to_string_lossy(), e.to_string())))
}
}
@@ -409,41 +500,65 @@ ENVIRONMENT:
LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: /models/sam3;
optional: LightCraft offers to download it when first needed)
LIGHTCRAFT_SAM3_MIRRORS=URL,… where to download the SAM 3 model from (base URLs, tried in order)
+ LIGHTCRAFT_LOG=info|debug more log detail from LightCraft itself (default: info from LightCraft, warnings
+ from other crates); else RUST_LOG=. Records go to stderr and to
+ /logs/lightcraft.log (previous runs: lightcraft.1.log, lightcraft.2.log)
";
-/// Warnings and errors (failed commands, AI mask analysis) on stderr; `LIGHTCRAFT_LOG=info`
-/// (or `debug`) shows more.
-struct StderrLog(log::LevelFilter);
-
-impl log::Log for StderrLog {
- fn enabled(&self, m: &log::Metadata) -> bool {
- m.level() <= self.0 && (m.level() <= log::Level::Warn || m.target().starts_with("lightcraft"))
- }
- fn log(&self, r: &log::Record) {
- if self.enabled(r.metadata()) {
- eprintln!("lightcraft: {} {}: {}", r.level(), r.target(), r.args());
- }
- }
- fn flush(&self) {}
+/// The launch arguments' files and folders to import (absolute), and a notice for each one that
+/// is not imported (see [`lightcraft_engine::import::launch_path`]; issue #374).
+fn launch_imports(args: &[String]) -> (Vec, Vec) {
+ let cwd = std::env::current_dir().ok();
+ let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from);
+ launch_imports_in(args, cwd.as_deref(), home.as_deref())
}
-fn install_log() {
- let level = match std::env::var("LIGHTCRAFT_LOG").unwrap_or_default().as_str() {
- "debug" => log::LevelFilter::Debug,
- "info" => log::LevelFilter::Info,
- _ => log::LevelFilter::Warn,
- };
- static LOGGER: std::sync::OnceLock = std::sync::OnceLock::new();
- if log::set_logger(LOGGER.get_or_init(|| StderrLog(level))).is_ok() {
- log::set_max_level(level);
+fn launch_imports_in(args: &[String], cwd: Option<&std::path::Path>, home: Option<&std::path::Path>) -> (Vec, Vec) {
+ use lightcraft_engine::import::{LaunchPath, launch_path};
+ let (mut files, mut notices) = (Vec::new(), Vec::new());
+ for a in args {
+ match launch_path(a, cwd, home, lightcraft_engine::devices::dcim_in) {
+ Some(LaunchPath::Import(p)) => files.push(p),
+ Some(LaunchPath::Refused { path, why }) => {
+ log::warn!("not importing {path} given at launch: {why}");
+ notices
+ .push(format!("{path} was not imported: it is {why}. To import from it, choose a folder inside it with File ▸ Import Photos…"));
+ }
+ None => {}
+ }
}
+ (files, notices)
+}
+
+/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`).
+/// None with `LIGHTCRAFT_NO_PREFS` (tests, scripts), so those runs don't rotate away the user's logs.
+fn log_dir() -> Option {
+ if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
+ return None;
+ }
+ config_dir().map(|d| d.join("logs"))
+}
+
+/// The control port `value` names; a value that isn't a port is logged and ignored (`what` is
+/// the option or variable it came from). Logged before the log file opens, so it is kept for it.
+fn control_port_from(what: &str, value: Option) -> Option {
+ let value = value?;
+ let port = value.trim().parse().ok();
+ if port.is_none() {
+ log::warn!("{what} {value:?} is not a port number; no control server");
+ }
+ port
}
fn main() -> eframe::Result {
+ // First, so every start-up record is kept for the log file (`logging`).
+ let logger = logging::install();
lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log"));
- install_log();
+ if let Some(logger) = logger {
+ logging::record_panics(logger);
+ }
alloc_release::install();
- let mut control_port: Option = std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
+ let mut control_port = control_port_from("LIGHTCRAFT_CONTROL_PORT", std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().filter(|v| !v.is_empty()));
let mut files = Vec::new();
let mut seed_demo = true;
let mut in_memory = false;
@@ -451,7 +566,7 @@ fn main() -> eframe::Result {
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
- "--control" => control_port = args.next().and_then(|p| p.parse().ok()),
+ "--control" => control_port = control_port_from("--control", Some(args.next().unwrap_or_default())),
"--library" => library_dir = args.next().map(std::path::PathBuf::from),
"--no-demo" => seed_demo = false,
"--memory" | "--demo" => in_memory = true,
@@ -470,7 +585,31 @@ fn main() -> eframe::Result {
_ => files.push(a),
}
}
- let (prefs, prefs_warning, keep_prefs_file) = load_prefs();
+ // The log file: opened after the arguments, so `--version`, `--help` and a usage error leave
+ // no file behind. Records logged until now are written to it first.
+ let mut log_file = None;
+ if let Some(logger) = logger {
+ // a --memory session writes nothing (issue #164), a log file included
+ match log_dir().filter(|_| !in_memory) {
+ Some(dir) => match logger.attach_dir(&dir) {
+ Ok(path) => {
+ log::info!("LightCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display());
+ log_file = Some(path);
+ }
+ // The file sink has given up by now, so this goes to standard error only.
+ Err(e) => log::warn!("no log file: {e}"),
+ },
+ None => logger.no_file(),
+ }
+ }
+ // what the arguments name, resolved now: an empty or relative argument never becomes a drive
+ // root, and a whole drive or home folder is not walked (issue #374)
+ let (files, launch_notices) = launch_imports(&files);
+ let (prefs, prefs_warning, keep_prefs_file) = load_prefs(in_memory);
+ // the saved language from the start, so a failed start is reported in it too
+ if let Some(ui) = &prefs {
+ lightcraft_ui_egui::i18n::set_language(ui.language);
+ }
// --library, else the library last opened from Settings, else the default location
let library_dir = library_dir.or_else(|| {
prefs
@@ -482,7 +621,7 @@ fn main() -> eframe::Result {
let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir);
// GPU compute: off if the preference says so, or if the last launch died creating the device
// (issue #136) — before anything can create it
- let gpu_crash = gpu_crash_check();
+ let gpu_crash = gpu_crash_check(in_memory);
let gpu_on = prefs.as_ref().is_none_or(|u| u.settings.gpu) && gpu_crash.is_none();
lightcraft_engine::gpu::set_enabled(gpu_on);
let options = eframe::NativeOptions {
@@ -500,7 +639,9 @@ fn main() -> eframe::Result {
wgpu_options: window_wgpu_options(),
..Default::default()
};
- eframe::run_native(
+ // the app's copy: `log_file` is still named in the message of a start that fails
+ let app_log_file = log_file.clone();
+ let started = eframe::run_native(
"LightCraft",
options,
Box::new(move |cc| {
@@ -511,15 +652,17 @@ fn main() -> eframe::Result {
std::env::var_os("LIGHTCRAFT_SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3")));
// the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt"));
- let mut app = LightcraftApp::new(session, services());
+ let mut app = LightcraftApp::new(session, services(cc.egui_ctx.clone(), app_log_file.as_deref()));
if let Some(ui) = prefs {
app.ui = ui;
}
lightcraft_ui_egui::i18n::set_language(app.ui.language);
app.integrated_titlebar = cfg!(target_os = "macos");
app.notices.extend(prefs_warning);
+ app.notices.extend(launch_notices);
// what's on disk now: only changes are written
let writer = PrefsWriter {
+ path: if in_memory { None } else { prefs_path() },
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
library: app.ui.settings.library_path.clone(),
keep_file: keep_prefs_file,
@@ -539,7 +682,12 @@ fn main() -> eframe::Result {
p.pending_import = files;
app.library_problem = Some(p);
} else if !files.is_empty() {
- let _ = app.run("library.import", serde_json::json!({"paths": files}));
+ // listed and read on the import worker once the window shows (issue #374: never
+ // here, before the first frame, where a large folder kept the window from
+ // appearing)
+ if let Err(e) = lightcraft_ui_egui::import::start_paths(&mut app, files) {
+ log::warn!("import at launch: {e}");
+ }
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
}
// native menu bar generated from the command registry (macOS; elsewhere the menus are
@@ -553,7 +701,28 @@ fn main() -> eframe::Result {
menu,
)))
}),
- )
+ );
+ if let Err(e) = &started {
+ startup_failed(&e.to_string(), log_file.as_deref());
+ }
+ started
+}
+
+/// The window could not start (e.g. no usable graphics device): say so in the log and in a native
+/// message box naming the log file, instead of exiting without a trace — the release builds have
+/// no console on Windows, and an app started from Finder shows no standard error (issue #260).
+fn startup_failed(error: &str, log_file: Option<&std::path::Path>) {
+ log::error!("LightCraft could not open its window: {error}");
+ if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
+ return; // tests and scripts: no dialog to click away
+ }
+ let (title, text) = lightcraft_ui_egui::i18n::startup_failed_message(error, log_file.map(|p| p.display().to_string()).as_deref());
+ let _ = rfd::MessageDialog::new()
+ .set_level(rfd::MessageLevel::Error)
+ .set_title(title)
+ .set_description(text)
+ .set_buttons(rfd::MessageButtons::Ok)
+ .show();
}
#[cfg(test)]
@@ -568,6 +737,24 @@ mod tests {
d
}
+ /// Issue #374: launch arguments that would walk a whole drive or home folder are reported,
+ /// not imported; empty ones are ignored; the rest are imported as absolute paths.
+ #[test]
+ fn launch_arguments_never_walk_a_drive_or_the_home_folder() {
+ let home = dir("launch");
+ std::fs::create_dir_all(home.join("Trip")).unwrap();
+ let args: Vec = ["", " ", ".", "Trip", &home.to_string_lossy()].iter().map(|s| s.to_string()).collect();
+ let (files, notices) = launch_imports_in(&args, Some(&home), Some(&home));
+ assert_eq!(files, [home.join("Trip").to_string_lossy().to_string()]);
+ assert_eq!(notices.len(), 2, "{notices:?}");
+ assert!(notices.iter().all(|n| n.contains("was not imported")), "{notices:?}");
+ // the working folder is a drive root (a launcher's choice): `.` is never the whole drive
+ let root = home.ancestors().last().unwrap().to_path_buf();
+ let (files, notices) = launch_imports_in(&[".".to_string()], Some(&root), None);
+ assert!(files.iter().all(|f| f.ends_with("DCIM")) && files.len() + notices.len() == 1, "{files:?} {notices:?}");
+ let _ = std::fs::remove_dir_all(&home);
+ }
+
/// Issue #103: a damaged ui.json (which holds the library location) is kept aside and
/// reported, not silently replaced by the defaults.
#[test]
@@ -575,7 +762,7 @@ mod tests {
let d = dir("damaged");
let path = d.join("ui.json");
std::fs::write(&path, br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#).unwrap();
- let (ui, warning, keep) = load_prefs_at(&path);
+ let (ui, warning, keep) = load_prefs_at(&path, true);
assert!(ui.is_none() && !keep);
let warning = warning.unwrap();
assert!(warning.contains("damaged") && warning.contains("ui.json.corrupt-"), "{warning}");
@@ -583,7 +770,80 @@ mod tests {
let kept: Vec<_> = std::fs::read_dir(&d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();
assert!(kept.len() == 1 && kept[0].starts_with("ui.json.corrupt-"), "{kept:?}");
// a missing file is just "no settings yet"
- assert_eq!(load_prefs_at(&path).1, None);
+ assert_eq!(load_prefs_at(&path, true).1, None);
+ let _ = std::fs::remove_dir_all(&d);
+ }
+
+ /// Issue #164: a `--memory` session reads the settings but writes nothing — a damaged file is
+ /// reported and left where it is, not set aside.
+ #[test]
+ fn memory_session_leaves_a_damaged_ui_json_alone() {
+ let d = dir("damaged-memory");
+ let path = d.join("ui.json");
+ let bytes = br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#;
+ std::fs::write(&path, bytes).unwrap();
+ let (ui, warning, keep) = load_prefs_at(&path, false);
+ assert!(ui.is_none() && keep);
+ let warning = warning.unwrap();
+ assert!(warning.contains("damaged") && warning.contains("nothing is saved"), "{warning}");
+ assert_eq!(std::fs::read(&path).unwrap(), bytes);
+ assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1);
+ let _ = std::fs::remove_dir_all(&d);
+ }
+
+ /// Issue #164: the writer saves changed settings to its file — and nothing at all without one
+ /// (`--memory`) or while the session is the temporary one offered when the library can't open.
+ #[test]
+ fn prefs_writer_writes_nothing_in_a_memory_or_temporary_session() {
+ let d = dir("writer");
+ let path = d.join("sub").join("ui.json");
+ let mut ui = UiState::default();
+ // an ordinary session: a change is written, the folder created on the way
+ let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() };
+ ui.thumb_size += 50.0;
+ w.save_ui(&ui, false).unwrap();
+ let saved = std::fs::read(&path).unwrap();
+ assert!(String::from_utf8_lossy(&saved).contains("thumbSize"), "{}", String::from_utf8_lossy(&saved));
+ // the temporary session (library problem): a further change is not written…
+ ui.thumb_size += 50.0;
+ w.save_ui(&ui, true).unwrap();
+ assert_eq!(std::fs::read(&path).unwrap(), saved);
+ // …until a library opens
+ w.save_ui(&ui, false).unwrap();
+ assert_ne!(std::fs::read(&path).unwrap(), saved);
+ // --memory: no path, so no file and no folder, whatever changes
+ let d2 = dir("writer-memory");
+ let _ = std::fs::remove_dir_all(&d2);
+ let mut w = PrefsWriter { path: None, ..Default::default() };
+ ui.thumb_size += 50.0;
+ w.save_ui(&ui, false).unwrap();
+ assert!(w.written.is_empty() && !d2.exists());
+ let _ = std::fs::remove_dir_all(&d);
+ }
+
+ /// Issue #169: a `--memory` session neither arms the GPU crash sentinel (which would create
+ /// the settings folder) nor removes a marker a crashed launch left; it still reports it, and
+ /// the next ordinary launch finds it, reports it and clears it.
+ #[test]
+ fn memory_session_leaves_the_gpu_marker_and_folder_alone() {
+ let d = dir("gpu-marker-memory");
+ let _ = std::fs::remove_dir_all(&d);
+ let m = d.join("gpu-init.marker");
+ // nothing left behind: no notice, and the folder is not created
+ assert_eq!(gpu_crash_check_at(Some(m.clone()), true), None);
+ assert!(!d.exists(), "a --memory session must not create the settings folder");
+ // a marker left by a crashed launch: reported, kept
+ std::fs::create_dir_all(&d).unwrap();
+ std::fs::write(&m, "GPU device creation started (backends METAL)").unwrap();
+ let notice = gpu_crash_check_at(Some(m.clone()), true).unwrap();
+ assert!(notice.contains("GPU rendering is now off"), "{notice}");
+ assert!(m.exists(), "the marker stays for the next ordinary launch");
+ // the next ordinary launch reports it once and removes it
+ assert!(gpu_crash_check_at(Some(m.clone()), false).is_some());
+ lightcraft_engine::gpu::backend::set_init_marker(None);
+ assert!(!m.exists());
+ assert_eq!(gpu_crash_check_at(Some(m.clone()), false), None);
+ lightcraft_engine::gpu::backend::set_init_marker(None);
let _ = std::fs::remove_dir_all(&d);
}
@@ -616,6 +876,27 @@ mod tests {
assert!(!b.is_empty());
}
+ /// Issue #234: Windows opens links with the URL protocol handler (the default browser), not
+ /// File Explorer, and passes the whole map link, `?`, `&` and `#` included, as one argument.
+ #[test]
+ fn windows_links_open_in_the_browser() {
+ let url = "https://www.openstreetmap.org/?mlat=48.856600&mlon=2.352200#map=15/48.856600/2.352200";
+ let c = windows_open_url_command(url);
+ assert_eq!(c.get_program(), "rundll32");
+ let args: Vec<&std::ffi::OsStr> = c.get_args().collect();
+ assert_eq!(args, ["url.dll,FileProtocolHandler", url]);
+ }
+
+ #[test]
+ fn a_control_port_that_is_not_a_number_is_ignored() {
+ assert_eq!(control_port_from("--control", Some("7980".into())), Some(7980));
+ assert_eq!(control_port_from("--control", Some(" 18001 ".into())), Some(18001));
+ assert_eq!(control_port_from("--control", Some("nope".into())), None);
+ assert_eq!(control_port_from("--control", Some(String::new())), None);
+ assert_eq!(control_port_from("--control", Some("70000".into())), None);
+ assert_eq!(control_port_from("LIGHTCRAFT_CONTROL_PORT", None), None);
+ }
+
#[test]
fn ui_json_is_written_atomically() {
let d = dir("atomic");
@@ -630,7 +911,7 @@ mod tests {
assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}");
let ui = UiState::default();
write_atomic(&path, &serde_json::to_vec_pretty(&ui).unwrap()).unwrap();
- let (loaded, warning, _) = load_prefs_at(&path);
+ let (loaded, warning, _) = load_prefs_at(&path, true);
assert!(loaded.is_some() && warning.is_none());
let _ = std::fs::remove_dir_all(&d);
}
diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs
index 557c9abfb..647f7e27b 100644
--- a/apps/lightcraft/src/native_menu.rs
+++ b/apps/lightcraft/src/native_menu.rs
@@ -2,9 +2,12 @@
//! the in-window menus ([`lightcraft_ui_egui::menubar`]): the registry's menu paths, shortcuts,
//! enabled and checked state, and live labels ("Undo Exposure").
//!
-//! Key handling: a native key equivalent consumes the key press before egui sees it, so
-//! - shortcuts installed natively are listed in `LightcraftApp::native_shortcuts` and skipped by
-//! the egui shortcut handler (nothing fires twice);
+//! Key handling: AppKit offers a key press to the menu bar only when it carries ⌘ or ⌃, or is a
+//! function key (F1…). Every other key (`E`, `1`, `⇧P`, `⌥Y`…) goes straight to the window, whose
+//! winit view always takes it, so a menu item never fires from it. Hence
+//! - shortcuts the menu bar really receives ([`menu_delivers`]) are listed in
+//! `LightcraftApp::native_shortcuts` and skipped by the egui shortcut handler (nothing fires
+//! twice); the others stay on their menu items for display and egui runs them;
//! - while a text field has keyboard focus, accelerators without ⌘ (`G`, `1`, `Delete`…) and the
//! text-editing ones (⌘A/⌘C/⌘V/⌘X/⌘Z) are removed, so typing and text editing work; they come
//! back when the field loses focus.
@@ -51,6 +54,9 @@ pub struct NativeMenu {
/// Structure of the installed menu (ids, kinds, submenu names); a change rebuilds it.
structure: String,
text_focus: bool,
+ /// The keyboard shortcuts editor is recording a key: every accelerator is off so the key
+ /// press reaches it.
+ capturing: bool,
}
/// `Cmd+Shift+Z` → a muda accelerator (`None` for keys we leave to egui, e.g. Escape).
@@ -146,6 +152,18 @@ fn yields_to_text(sc: &str) -> bool {
!sc.contains("Cmd") && !sc.contains("Ctrl") || TEXT_EDIT.contains(&sc)
}
+/// Whether AppKit hands this shortcut to the menu bar: only key presses with ⌘ or ⌃, and function
+/// keys. A plain `C` or `⇧P` never reaches it (the window's view takes the key), so egui has to run
+/// those even though their menu item shows the key.
+fn menu_delivers(sc: &str) -> bool {
+ sc.split('+').any(|part| matches!(part, "Cmd" | "Ctrl") || part.strip_prefix('F').is_some_and(|n| n.parse::().is_ok()))
+}
+
+/// The shortcuts the menu bar runs itself, for `LightcraftApp::native_shortcuts`.
+fn owned_by_menu<'a>(installed: impl Iterator, text_focus: bool) -> HashSet {
+ installed.filter(|sc| menu_delivers(sc) && !(text_focus && yields_to_text(sc))).map(str::to_string).collect()
+}
+
/// `&` marks a mnemonic in muda labels.
fn label_text(s: &str) -> String {
lightcraft_ui_egui::i18n::tr(s).replace('&', "&&")
@@ -196,7 +214,7 @@ impl NativeMenu {
let _ = tx.send(e.id.0);
repaint.request_repaint();
}));
- let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false };
+ let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false, capturing: false };
m.rebuild(app);
app.native_menu = true;
m
@@ -266,6 +284,7 @@ impl NativeMenu {
}
self.menu.init_for_nsapp();
self.text_focus = false;
+ self.capturing = false;
self.publish_shortcuts(app);
}
@@ -329,13 +348,9 @@ impl NativeMenu {
/// Tell the egui shortcut handler which shortcuts the menu bar currently owns.
fn publish_shortcuts(&self, app: &mut LightcraftApp) {
- app.native_shortcuts = self
- .items
- .values()
- .filter(|i| i.accel.is_some())
- .filter_map(|i| i.shortcut.clone())
- .filter(|sc| !(self.text_focus && yields_to_text(sc)))
- .collect::>();
+ let installed = self.items.values().filter(|i| i.accel.is_some()).filter_map(|i| i.shortcut.as_deref());
+ // (none while the keymap editor records a shortcut: every key goes to it)
+ app.native_shortcuts = if self.capturing { HashSet::new() } else { owned_by_menu(installed, self.text_focus) };
app.native_shortcuts.insert(SETTINGS_KEY.to_string());
}
@@ -397,14 +412,16 @@ impl NativeMenu {
walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::>(), &mut self.items);
let focus = ctx.egui_wants_keyboard_input();
- if focus != self.text_focus {
+ let capturing = app.recording_shortcut.is_some();
+ if focus != self.text_focus || capturing != self.capturing {
self.text_focus = focus;
+ self.capturing = capturing;
for it in self.items.values() {
let Some(sc) = it.shortcut.as_deref() else { continue };
- if it.accel.is_none() || !yields_to_text(sc) {
+ if it.accel.is_none() {
continue;
}
- let accel = if focus { None } else { it.accel };
+ let accel = if capturing || (focus && yields_to_text(sc)) { None } else { it.accel };
let _ = match &it.handle {
Handle::Plain(h) => h.set_accelerator(accel),
Handle::Check(h) => h.set_accelerator(accel),
@@ -467,4 +484,39 @@ mod tests {
assert!(accelerator(sc).is_some(), "{sc}");
}
}
+
+ /// Single keys shown in the menu bar (E, C, ⇧P, ratings…) never reach it on macOS, so egui must
+ /// keep handling them; ⌘ / ⌃ combinations and function keys are the menu bar's own.
+ #[test]
+ fn single_key_shortcuts_stay_with_egui() {
+ assert!(menu_delivers("Cmd+Shift+H") && menu_delivers("Ctrl+H") && menu_delivers("F2") && menu_delivers("Cmd+F11"));
+ assert!(!menu_delivers("E") && !menu_delivers("Shift+P") && !menu_delivers("Alt+Y") && !menu_delivers("1") && !menu_delivers("F"));
+ let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
+ fn all(n: &[MenuNode], out: &mut Vec) {
+ for x in n {
+ match x {
+ MenuNode::Item { shortcut: Some(s), .. } if accelerator(s).is_some() => out.push(s.clone()),
+ MenuNode::Submenu { children, .. } => all(children, out),
+ _ => {}
+ }
+ }
+ }
+ let mut installed = Vec::new();
+ for (_, v) in menu_bar(&app) {
+ all(&v, &mut installed);
+ }
+ for sc in ["E", "C", "Shift+P", "Cmd+Shift+H", "F2"] {
+ assert!(installed.iter().any(|s| s == sc), "{sc} is in the menu bar");
+ }
+ let owned = owned_by_menu(installed.iter().map(String::as_str), false);
+ for sc in ["E", "C", "Shift+P", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "P", "U"] {
+ assert!(installed.iter().any(|s| s == sc), "{sc} is displayed in the menu");
+ assert!(!owned.contains(sc), "{sc} must be left to egui");
+ }
+ for sc in ["Cmd+Shift+H", "F2"] {
+ assert!(owned.contains(sc), "{sc} is run by the menu bar");
+ }
+ // while typing, the menu bar gives up F2 too (the text field has it)
+ assert!(!owned_by_menu(installed.iter().map(String::as_str), true).contains("F2"));
+ }
}
diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md
index 208cc39cd..202eb27ec 100644
--- a/assets/ATTRIBUTION.md
+++ b/assets/ATTRIBUTION.md
@@ -11,6 +11,8 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `assets/fonts/Inter-*.ttf` (Regular, Medium, SemiBold) | Inter | Rasmus Andersson | https://github.com/rsms/inter | SIL Open Font License 1.1 (`assets/fonts/OFL-Inter.txt`) | 2026-09-30 | none |
| `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` |
| `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none |
+| `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via ``, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
+| `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none |
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
diff --git a/assets/camera-profiles/X-H2S.json b/assets/camera-profiles/X-H2S.json
new file mode 100644
index 000000000..59cd498d0
--- /dev/null
+++ b/assets/camera-profiles/X-H2S.json
@@ -0,0 +1,2111 @@
+{
+ "version": 1,
+ "model": "X-H2S",
+ "files": 201,
+ "samples": 722133,
+ "matrix": [
+ [1.2458876370972227, -0.3237571865289368, 0.062138487539840526],
+ [-0.08719440905566955, 1.1839386569330799, -0.0901410829920053],
+ [-0.09235873400444561, -0.6687925210227519, 1.7553435681239122]
+ ],
+ "hue_sat": {
+ "hue_divisions": 72,
+ "sat_divisions": 5,
+ "val_divisions": 5,
+ "data": [
+ [0.0, 1.0, 1.0],
+ [8.728679, 1.0206304, 1.0],
+ [4.3200827, 0.9860159, 1.0],
+ [1.2806753, 1.0042948, 1.0],
+ [0.15212817, 1.0054297, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.898361, 1.0119873, 1.0],
+ [3.6430912, 0.98571527, 1.0],
+ [0.54247886, 1.0148934, 1.0],
+ [-0.28426996, 1.0097318, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.7296433, 1.0000898, 1.0],
+ [1.1935737, 1.0203358, 1.0],
+ [-0.5411634, 1.0170898, 1.0],
+ [-0.88588536, 0.9812703, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.8998873, 0.98929864, 1.0],
+ [0.34247065, 1.0188023, 1.0],
+ [-0.70872885, 1.0079176, 1.0],
+ [-1.3457271, 0.97846293, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.7941706, 1.0029006, 1.0],
+ [0.090435915, 1.0282906, 1.0],
+ [-0.9954686, 1.0179157, 1.0],
+ [-1.8290771, 0.98781705, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.6052246, 1.0248086, 1.0],
+ [-0.16740479, 1.0330777, 1.0],
+ [-1.1437633, 1.0250585, 1.0],
+ [-1.6554425, 1.005749, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.6925323, 1.0300746, 1.0],
+ [-0.18578847, 1.0318998, 1.0],
+ [-1.167182, 1.0260776, 1.0],
+ [-1.8700649, 1.013574, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.3137257, 1.026785, 1.0],
+ [0.27151796, 1.026643, 1.0],
+ [-1.0162086, 1.0205064, 1.0],
+ [-1.555533, 1.0105867, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.3468738, 1.0230751, 1.0],
+ [0.98764545, 1.02391, 1.0],
+ [-0.4541654, 1.0172719, 1.0],
+ [-1.3170965, 1.0072583, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.2349977, 1.0158701, 1.0],
+ [0.95254457, 1.0192627, 1.0],
+ [-0.34923795, 1.0145884, 1.0],
+ [-1.1261563, 1.0043564, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.7663198, 1.0100601, 1.0],
+ [1.4589342, 1.0104818, 1.0],
+ [-0.23215643, 1.0029329, 1.0],
+ [-1.0081172, 0.9913272, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.838287, 1.020117, 1.0],
+ [2.1576033, 1.0143449, 1.0],
+ [0.8445625, 0.99050206, 1.0],
+ [0.42045176, 0.9640189, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.444411, 1.0406119, 1.0],
+ [2.0267544, 1.0230625, 1.0],
+ [1.115889, 0.97797954, 1.0],
+ [0.7384578, 0.92473024, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.2759016, 1.0458477, 1.0],
+ [1.8555862, 1.0206501, 1.0],
+ [1.1933137, 0.97871643, 1.0],
+ [0.86039966, 0.922511, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.155127, 1.0392207, 1.0],
+ [1.3352914, 1.0169463, 1.0],
+ [1.1311455, 0.9890133, 1.0],
+ [1.274365, 0.9731691, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.030139316, 1.0277171, 1.0],
+ [-0.1536572, 1.0162318, 1.0],
+ [0.75929344, 0.9932093, 1.0],
+ [1.2689896, 0.98043805, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.4671046, 1.0425197, 1.0],
+ [-0.62572336, 1.0360416, 1.0],
+ [1.11761, 1.0142438, 1.0],
+ [1.6834764, 0.99684334, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.7358959, 1.0736592, 1.0],
+ [-0.26553202, 1.0607176, 1.0],
+ [1.8074034, 1.0324123, 1.0],
+ [2.1980815, 1.0121138, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.240199, 1.087688, 1.0],
+ [-0.6343778, 1.0749793, 1.0],
+ [1.2906064, 1.047931, 1.0],
+ [1.5339005, 1.0187962, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.0441365, 1.0897394, 1.0],
+ [-1.5529838, 1.085552, 1.0],
+ [-0.05806478, 1.0628853, 1.0],
+ [0.27003333, 1.0148032, 1.0],
+ [0.0, 1.0, 1.0],
+ [-6.358739, 1.1044925, 1.0],
+ [-3.0616302, 1.0820334, 1.0],
+ [-1.1493404, 1.0458001, 1.0],
+ [-0.044788126, 1.0036042, 1.0],
+ [0.0, 1.0, 1.0],
+ [-7.314695, 1.1287619, 1.0],
+ [-3.2918382, 1.0697256, 1.0],
+ [-0.6346949, 1.0162474, 1.0],
+ [-0.015576859, 1.0004573, 1.0],
+ [0.0, 1.0, 1.0],
+ [-7.820848, 1.1305687, 1.0],
+ [-2.4455993, 1.0498186, 1.0],
+ [-0.19949687, 1.0041214, 1.0],
+ [-0.0027498177, 1.0000476, 1.0],
+ [0.0, 1.0, 1.0],
+ [-7.5484633, 1.1130339, 1.0],
+ [-2.1600857, 1.0477536, 1.0],
+ [-0.11881551, 1.0030932, 1.0],
+ [-0.0005820335, 1.0000135, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.035158, 1.1102359, 1.0],
+ [-2.1405637, 1.0350051, 1.0],
+ [-0.084177606, 1.0013684, 1.0],
+ [-0.00032260726, 1.0000027, 1.0],
+ [0.0, 1.0, 1.0],
+ [-7.6952314, 1.0635511, 1.0],
+ [-1.064063, 1.0112427, 1.0],
+ [-0.008954739, 0.99997956, 1.0],
+ [
+ -1.8368839e-05,
+ 0.99999607,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-9.061495, 1.0261137, 1.0],
+ [-1.4261262, 1.0039278, 1.0],
+ [-0.013760631, 1.0001167, 1.0],
+ [
+ -1.3433922e-05,
+ 1.0000004,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-9.943509, 0.99616253, 1.0],
+ [-2.180308, 0.9981994, 1.0],
+ [-0.032691147, 0.99998057, 1.0],
+ [
+ -4.4223594e-05,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-10.810746, 0.98468745, 1.0],
+ [-2.652356, 0.9928594, 1.0],
+ [-0.043256707, 0.9998229, 1.0],
+ [
+ -5.016976e-05,
+ 0.9999997,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-10.338566, 0.96656805, 1.0],
+ [-3.6949215, 0.9859071, 1.0],
+ [-0.102222234, 0.9995505, 1.0],
+ [-0.00015470105, 0.9999992, 1.0],
+ [0.0, 1.0, 1.0],
+ [-12.014268, 0.9764745, 1.0],
+ [-6.8061976, 0.9887849, 1.0],
+ [-0.31399488, 0.9994232, 1.0],
+ [-0.0005296378, 0.9999988, 1.0],
+ [0.0, 1.0, 1.0],
+ [-13.442714, 0.9890778, 1.0],
+ [-9.357521, 0.99581265, 1.0],
+ [-0.5783634, 0.9992511, 1.0],
+ [-0.0011424923, 0.99999434, 1.0],
+ [0.0, 1.0, 1.0],
+ [-14.343322, 0.98522204, 1.0],
+ [-9.916013, 0.9911534, 1.0],
+ [-0.5881451, 0.9987247, 1.0],
+ [-0.0013036833, 0.9999904, 1.0],
+ [0.0, 1.0, 1.0],
+ [-13.69773, 0.9788999, 1.0],
+ [-8.763287, 0.9857234, 1.0],
+ [-0.47650328, 0.9978328, 1.0],
+ [-0.001562948, 0.9999825, 1.0],
+ [0.0, 1.0, 1.0],
+ [-12.636312, 0.9741085, 1.0],
+ [-6.735057, 0.9883704, 1.0],
+ [-0.2865094, 0.99915266, 1.0],
+ [-0.00070323877, 0.9999954, 1.0],
+ [0.0, 1.0, 1.0],
+ [-11.292832, 0.97211325, 1.0],
+ [-5.0721035, 0.9860957, 1.0],
+ [-0.18372871, 0.99921757, 1.0],
+ [-0.00035769286, 0.9999973, 1.0],
+ [0.0, 1.0, 1.0],
+ [-9.231987, 0.9687564, 1.0],
+ [-3.7118556, 0.98878354, 1.0],
+ [-0.12847522, 0.99951947, 1.0],
+ [-0.00029055856, 0.99999857, 1.0],
+ [0.0, 1.0, 1.0],
+ [-9.55005, 0.95274824, 1.0],
+ [-3.3982742, 0.98425007, 1.0],
+ [-0.10297954, 0.99951214, 1.0],
+ [-0.00026502798, 0.9999987, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.93991, 0.92654765, 1.0],
+ [-3.4434712, 0.972461, 1.0],
+ [-0.15123777, 0.99885994, 1.0],
+ [-0.00096332224, 0.99999297, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.364199, 0.89885736, 1.0],
+ [-2.9382148, 0.96036947, 1.0],
+ [-0.15202415, 0.99773234, 1.0],
+ [-0.0011220135, 0.9999822, 1.0],
+ [0.0, 1.0, 1.0],
+ [-6.8977294, 0.8749669, 1.0],
+ [-3.0306811, 0.9445574, 1.0],
+ [-0.31894, 0.9935104, 1.0],
+ [-0.0031635074, 0.99992883, 1.0],
+ [0.0, 1.0, 1.0],
+ [-6.643872, 0.86401826, 1.0],
+ [-2.5939164, 0.94932747, 1.0],
+ [-0.23428063, 0.9957458, 1.0],
+ [-0.0025634407, 0.99994916, 1.0],
+ [0.0, 1.0, 1.0],
+ [-7.108995, 0.8512457, 1.0],
+ [-2.3661106, 0.9332001, 1.0],
+ [-0.08739928, 0.99674493, 1.0],
+ [-0.00063831237, 0.9999771, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.545464, 0.89238304, 1.0],
+ [-2.0838609, 0.93224514, 1.0],
+ [-0.011354147, 0.9921285, 1.0],
+ [0.0021260942, 0.99993163, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.3110693, 0.930202, 1.0],
+ [1.2572228, 0.9713286, 1.0],
+ [1.7645134, 0.9932074, 1.0],
+ [0.10183909, 0.9997941, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.30794027, 0.9202272, 1.0],
+ [2.0130813, 0.9786798, 1.0],
+ [1.9920294, 0.9952443, 1.0],
+ [0.14857435, 0.9998033, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.46019936, 0.93292373, 1.0],
+ [1.2379118, 0.97167575, 1.0],
+ [1.1637435, 1.0017741, 1.0],
+ [0.11729451, 1.0008876, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.30377665, 0.95070875, 1.0],
+ [0.92464393, 0.99626124, 1.0],
+ [3.7344003, 1.0415727, 1.0],
+ [1.4934452, 1.0185289, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.6724653, 0.905078, 1.0],
+ [0.8874332, 0.97638196, 1.0],
+ [4.093791, 1.045553, 1.0],
+ [1.6834058, 1.0226531, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.17155, 0.822445, 1.0],
+ [-0.51097, 0.88816243, 1.0],
+ [0.56960315, 0.99968165, 1.0],
+ [0.080809385, 1.0010427, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.2161565, 0.81127447, 1.0],
+ [-0.40078506, 0.86335325, 1.0],
+ [0.068287045, 0.9943868, 1.0],
+ [0.0020908245, 1.0000015, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.4894366, 0.7998197, 1.0],
+ [-0.84341663, 0.8705174, 1.0],
+ [0.040301893, 0.99392956, 1.0],
+ [0.0006503587, 0.99992245, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.780393, 0.78455186, 1.0],
+ [-0.33670017, 0.8948624, 1.0],
+ [0.007884285, 0.99553126, 1.0],
+ [-0.0005095186, 0.99989825, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.9800856, 0.77555025, 1.0],
+ [-0.36861885, 0.92584366, 1.0],
+ [-0.015655512, 0.99895173, 1.0],
+ [-0.000318789, 0.9999976, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.9218316, 0.80435187, 1.0],
+ [-0.36963376, 0.9587903, 1.0],
+ [-0.020300027, 0.9996703, 1.0],
+ [-0.00028825158, 1.0000017, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.56449103, 0.8612327, 1.0],
+ [0.17715399, 0.9788929, 1.0],
+ [0.0057121958, 1.0000331, 1.0],
+ [
+ -3.5405683e-05,
+ 1.0000029,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [2.8323374, 0.89369875, 1.0],
+ [1.0687087, 0.9809675, 1.0],
+ [0.034107335, 0.9997653, 1.0],
+ [0.000120967496, 1.0000002, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.5763772, 0.93426466, 1.0],
+ [0.66850424, 0.99200565, 1.0],
+ [0.022399126, 0.9997675, 1.0],
+ [0.0001369294, 0.9999979, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.6716483, 0.9446645, 1.0],
+ [0.60636437, 0.99354786, 1.0],
+ [0.0044138604, 1.0000226, 1.0],
+ [
+ -1.8536522e-05,
+ 1.0000005,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [2.2337399, 0.9710475, 1.0],
+ [0.42016056, 0.9981193, 1.0],
+ [0.012040432, 1.0000653, 1.0],
+ [
+ 3.5203393e-05,
+ 1.0000005,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [2.295084, 0.97946674, 1.0],
+ [0.29066184, 0.9992383, 1.0],
+ [0.0036088976, 1.0000501, 1.0],
+ [
+ 4.384876e-06,
+ 1.0000002,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [1.125425, 0.9786852, 1.0],
+ [0.20953342, 0.99828696, 1.0],
+ [0.004922661, 0.99999815, 1.0],
+ [
+ 1.0277095e-05,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [1.2043383, 0.9779079, 1.0],
+ [0.090610325, 0.99730194, 1.0],
+ [-0.00017681491, 0.99997425, 1.0],
+ [
+ -4.1671146e-06,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [3.1792917, 0.9613604, 1.0],
+ [0.35856792, 0.99540615, 1.0],
+ [0.0028171474, 0.9999634, 1.0],
+ [
+ -2.471271e-06,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [4.075094, 0.9539685, 1.0],
+ [0.5917587, 0.9927337, 1.0],
+ [0.005103707, 0.99990827, 1.0],
+ [
+ -3.1705735e-05,
+ 0.9999999,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [3.5010443, 0.9536551, 1.0],
+ [0.20673071, 0.98983246, 1.0],
+ [-0.032583736, 0.99970436, 1.0],
+ [-0.00028767975, 0.99999875, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.2023478, 0.96730983, 1.0],
+ [0.6703333, 0.9937887, 1.0],
+ [-0.020854607, 1.0000542, 1.0],
+ [-0.00025753156, 1.0000008, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.3636384, 0.9527599, 1.0],
+ [0.81285733, 0.9896896, 1.0],
+ [0.017544644, 0.9998711, 1.0],
+ [0.000114887676, 1.0000008, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.417876, 0.9383751, 1.0],
+ [1.1611464, 0.98046106, 1.0],
+ [0.018655559, 0.9995545, 1.0],
+ [-0.0002014685, 0.9999993, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.7440786, 0.9787412, 1.0],
+ [1.8940457, 0.9993158, 1.0],
+ [0.10403728, 1.0014251, 1.0],
+ [-0.0007007577, 1.0000236, 1.0],
+ [0.0, 1.0, 1.0],
+ [9.234887, 0.9963478, 1.0],
+ [2.5921743, 1.0027596, 1.0],
+ [-0.3284632, 1.0038372, 1.0],
+ [-0.043428678, 1.0003042, 1.0],
+ [0.0, 1.0, 1.0],
+ [11.043382, 1.0092762, 1.0],
+ [3.6184309, 0.99392426, 1.0],
+ [-0.3205221, 1.0031682, 1.0],
+ [-0.059132684, 1.0006497, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.97402, 1.0034658, 1.0],
+ [-0.6740785, 0.9949814, 1.0],
+ [-1.0243659, 1.0299757, 1.0],
+ [0.10299488, 1.034073, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.5237284, 0.9901157, 1.0],
+ [1.2369099, 1.005908, 1.0],
+ [-0.32235593, 1.0317756, 1.0],
+ [-0.8128157, 1.0050477, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.642822, 0.9752414, 1.0],
+ [0.046472717, 1.0169463, 1.0],
+ [-1.0525423, 1.0133541, 1.0],
+ [-1.3776581, 0.9691162, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.2084394, 0.972613, 1.0],
+ [-0.25541478, 1.0089694, 1.0],
+ [-1.0787785, 1.0010968, 1.0],
+ [-2.0386431, 0.965853, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.3990965, 0.9883769, 1.0],
+ [-0.23538761, 1.011664, 1.0],
+ [-1.0311636, 1.0079699, 1.0],
+ [-2.0373921, 0.98409766, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.3430843, 1.0004642, 1.0],
+ [-0.35947296, 1.0128645, 1.0],
+ [-1.0458986, 1.0115215, 1.0],
+ [-1.5997602, 1.0003799, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.5115094, 1.0079246, 1.0],
+ [-0.35197452, 1.0135599, 1.0],
+ [-1.0123414, 1.0121523, 1.0],
+ [-1.5962777, 1.0062771, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.2201242, 1.0069045, 1.0],
+ [-0.05537218, 1.0074033, 1.0],
+ [-0.9185825, 1.0038182, 1.0],
+ [-1.3931786, 0.99976593, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.6776147, 1.0080688, 1.0],
+ [0.7692571, 1.0131639, 1.0],
+ [-0.5027828, 1.0076567, 1.0],
+ [-1.2549909, 0.9997216, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.5154178, 0.996633, 1.0],
+ [0.9251866, 1.0093694, 1.0],
+ [-0.16704215, 1.0091307, 1.0],
+ [-0.9250501, 1.0001701, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.786738, 0.98252994, 1.0],
+ [1.17775, 0.9951249, 1.0],
+ [-0.040805407, 0.995746, 1.0],
+ [-0.8885909, 0.9822554, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.4449787, 0.9717951, 1.0],
+ [1.5592619, 0.9895185, 1.0],
+ [0.8975454, 0.97139394, 1.0],
+ [0.8434216, 0.91258097, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.3327093, 0.96855474, 1.0],
+ [1.2922856, 0.9951774, 1.0],
+ [1.1538825, 0.95140374, 1.0],
+ [1.0445648, 0.882907, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.3042054, 0.97626907, 1.0],
+ [1.2261003, 1.0032405, 1.0],
+ [1.1920351, 0.95930433, 1.0],
+ [1.0248796, 0.87122154, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.1218151, 0.99428594, 1.0],
+ [0.9402701, 1.0072844, 1.0],
+ [1.2057712, 0.9790373, 1.0],
+ [1.715661, 0.9378413, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.66689795, 0.9936543, 1.0],
+ [-0.3043008, 1.0076888, 1.0],
+ [0.8821287, 0.98223394, 1.0],
+ [1.5125725, 0.9544597, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.1180842, 1.007544, 1.0],
+ [-0.6135006, 1.0289868, 1.0],
+ [1.3090985, 1.0115852, 1.0],
+ [2.0874233, 0.9913464, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.3278608, 1.03237, 1.0],
+ [-0.34648758, 1.0507596, 1.0],
+ [1.7782654, 1.0327485, 1.0],
+ [2.468833, 1.0156778, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.748517, 1.0421953, 1.0],
+ [-0.9545988, 1.0644007, 1.0],
+ [1.0075394, 1.0452629, 1.0],
+ [1.8186473, 1.0213822, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.6511426, 1.04099, 1.0],
+ [-2.212496, 1.0738522, 1.0],
+ [-0.5295884, 1.058841, 1.0],
+ [0.39001313, 1.0159221, 1.0],
+ [0.0, 1.0, 1.0],
+ [-7.2649274, 1.0555432, 1.0],
+ [-4.1198425, 1.08102, 1.0],
+ [-2.3006728, 1.0733076, 1.0],
+ [-0.51555675, 1.0098453, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.993211, 1.0847929, 1.0],
+ [-5.831633, 1.0979817, 1.0],
+ [-3.0940223, 1.066904, 1.0],
+ [-0.9517784, 0.9851261, 1.0],
+ [0.0, 1.0, 1.0],
+ [-10.3772, 1.0938902, 1.0],
+ [-6.41151, 1.0909004, 1.0],
+ [-1.8480648, 1.0164669, 1.0],
+ [-0.16703343, 0.9933557, 1.0],
+ [0.0, 1.0, 1.0],
+ [-11.117343, 1.0931859, 1.0],
+ [-5.9912677, 1.0973685, 1.0],
+ [-0.5732509, 1.0049413, 1.0],
+ [0.08346253, 0.995802, 1.0],
+ [0.0, 1.0, 1.0],
+ [-12.642463, 1.1002907, 1.0],
+ [-6.1474624, 1.0736011, 1.0],
+ [-0.3243047, 0.9991013, 1.0],
+ [0.016711053, 0.99943656, 1.0],
+ [0.0, 1.0, 1.0],
+ [-13.478187, 1.0329441, 1.0],
+ [-4.5098143, 0.9993377, 1.0],
+ [-0.088909596, 0.99232316, 1.0],
+ [0.0015094848, 0.9998537, 1.0],
+ [0.0, 1.0, 1.0],
+ [-14.786204, 0.9694178, 1.0],
+ [-6.1401577, 0.9893014, 1.0],
+ [-0.16727397, 1.0001292, 1.0],
+ [-0.0004984068, 1.0, 1.0],
+ [0.0, 1.0, 1.0],
+ [-15.986527, 0.92521626, 1.0],
+ [-7.2159486, 0.9676191, 1.0],
+ [-0.17465894, 0.99944806, 1.0],
+ [-0.00039394363, 0.9999998, 1.0],
+ [0.0, 1.0, 1.0],
+ [-16.760332, 0.91941845, 1.0],
+ [-7.364063, 0.9667283, 1.0],
+ [-0.14445107, 0.9989603, 1.0],
+ [0.0004303302, 0.9999847, 1.0],
+ [0.0, 1.0, 1.0],
+ [-15.557542, 0.91452426, 1.0],
+ [-7.8338785, 0.9618465, 1.0],
+ [-0.3343677, 0.9981042, 1.0],
+ [
+ -4.918289e-05,
+ 0.99998575,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-15.201462, 0.9339844, 1.0],
+ [-10.613217, 0.96940356, 1.0],
+ [-1.136429, 0.99680275, 1.0],
+ [-0.0021724077, 0.99999255, 1.0],
+ [0.0, 1.0, 1.0],
+ [-15.266676, 0.95900613, 1.0],
+ [-12.692006, 0.9837428, 1.0],
+ [-2.1463373, 0.9947698, 1.0],
+ [-0.005627853, 0.9999566, 1.0],
+ [0.0, 1.0, 1.0],
+ [-16.444113, 0.95752037, 1.0],
+ [-13.965291, 0.97963464, 1.0],
+ [-2.2071412, 0.9926996, 1.0],
+ [-0.0063773347, 0.99993426, 1.0],
+ [0.0, 1.0, 1.0],
+ [-16.428102, 0.944903, 1.0],
+ [-13.466559, 0.9741484, 1.0],
+ [-1.5537314, 0.99316627, 1.0],
+ [-0.0054058074, 0.99994093, 1.0],
+ [0.0, 1.0, 1.0],
+ [-15.743757, 0.9297916, 1.0],
+ [-11.245777, 0.96999633, 1.0],
+ [-1.0017846, 0.9963959, 1.0],
+ [-0.0027330904, 0.99998176, 1.0],
+ [0.0, 1.0, 1.0],
+ [-14.582258, 0.92690057, 1.0],
+ [-9.555166, 0.9580048, 1.0],
+ [-0.91683203, 0.9949841, 1.0],
+ [-0.0023285556, 0.9999827, 1.0],
+ [0.0, 1.0, 1.0],
+ [-13.66012, 0.9144587, 1.0],
+ [-8.907967, 0.96176255, 1.0],
+ [-0.75339365, 0.9969989, 1.0],
+ [-0.0020785667, 0.99999136, 1.0],
+ [0.0, 1.0, 1.0],
+ [-14.766155, 0.8885144, 1.0],
+ [-8.151366, 0.95314914, 1.0],
+ [-0.43513343, 0.9979274, 1.0],
+ [-0.0013458556, 0.99999374, 1.0],
+ [0.0, 1.0, 1.0],
+ [-12.756684, 0.84383404, 1.0],
+ [-8.069312, 0.9194366, 1.0],
+ [-0.73601234, 0.9940366, 1.0],
+ [-0.006278685, 0.99995303, 1.0],
+ [0.0, 1.0, 1.0],
+ [-12.3996105, 0.78645676, 1.0],
+ [-7.4046135, 0.88479775, 1.0],
+ [-0.60771656, 0.99156505, 1.0],
+ [-0.005061967, 0.9999345, 1.0],
+ [0.0, 1.0, 1.0],
+ [-9.395499, 0.7727399, 1.0],
+ [-6.246277, 0.86766297, 1.0],
+ [-0.77940494, 0.98375356, 1.0],
+ [-0.007498043, 0.9998306, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.069854, 0.7712122, 1.0],
+ [-5.7335796, 0.8622934, 1.0],
+ [-0.7198741, 0.98598284, 1.0],
+ [-0.008054674, 0.9998355, 1.0],
+ [0.0, 1.0, 1.0],
+ [-7.1986036, 0.78911334, 1.0],
+ [-4.2859526, 0.8467513, 1.0],
+ [-0.30990627, 0.98300517, 1.0],
+ [-0.001539077, 0.99987894, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.362009, 0.862973, 1.0],
+ [-1.2797484, 0.89488745, 1.0],
+ [0.57525235, 0.9574352, 1.0],
+ [0.020433621, 0.9993585, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.44913286, 0.92161065, 1.0],
+ [2.1760025, 0.9585874, 1.0],
+ [3.1253667, 0.97889954, 1.0],
+ [0.5430722, 0.998097, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.5043082, 0.92482764, 1.0],
+ [2.4694016, 0.9740288, 1.0],
+ [2.8363268, 0.99162847, 1.0],
+ [0.7368365, 0.9988949, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.2908894, 0.9381854, 1.0],
+ [1.9291751, 0.9809296, 1.0],
+ [3.2904375, 1.0165812, 1.0],
+ [1.4833065, 1.0123935, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.42789957, 0.9608102, 1.0],
+ [2.0859613, 1.0199693, 1.0],
+ [5.4975877, 1.0738639, 1.0],
+ [5.1542764, 1.0715116, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.041113988, 0.91577923, 1.0],
+ [2.5786953, 1.0219108, 1.0],
+ [5.4007845, 1.0863035, 1.0],
+ [5.081802, 1.0821114, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.7529762, 0.8233519, 1.0],
+ [0.38908187, 0.9149488, 1.0],
+ [2.9440103, 1.0415642, 1.0],
+ [1.2238824, 1.0184511, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.0758475, 0.8046038, 1.0],
+ [-0.122891106, 0.8494543, 1.0],
+ [0.6972854, 0.9855657, 1.0],
+ [0.0899615, 0.9960086, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.1830697, 0.79670805, 1.0],
+ [-0.57822895, 0.83725864, 1.0],
+ [0.27999595, 0.9634356, 1.0],
+ [0.011383898, 0.99835503, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.842725, 0.77273744, 1.0],
+ [-0.43526837, 0.85823804, 1.0],
+ [-0.0893142, 0.9760571, 1.0],
+ [-0.014267705, 0.99880594, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.5974367, 0.7690728, 1.0],
+ [-0.6484193, 0.92034054, 1.0],
+ [-0.26290956, 1.0013702, 1.0],
+ [-0.007495878, 1.0000707, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.6498926, 0.7932041, 1.0],
+ [-0.8644455, 0.94679594, 1.0],
+ [-0.3005925, 1.001227, 1.0],
+ [-0.0067892326, 1.0000719, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.1335323, 0.85572964, 1.0],
+ [0.07130498, 0.97290087, 1.0],
+ [-0.052292693, 1.0024012, 1.0],
+ [-0.0014092727, 1.0000482, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.0666177, 0.8645106, 1.0],
+ [2.0084338, 0.9645754, 1.0],
+ [0.19086616, 0.99913764, 1.0],
+ [0.0013613411, 1.000007, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.092513, 0.8992678, 1.0],
+ [2.0034046, 0.97106105, 1.0],
+ [0.21043883, 0.99723476, 1.0],
+ [0.0017143496, 0.9999766, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.0797167, 0.9267911, 1.0],
+ [0.22682886, 0.9934631, 1.0],
+ [-0.04511432, 1.0006658, 1.0],
+ [-0.0003056895, 1.000005, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.0111675, 0.94898635, 1.0],
+ [1.3779145, 1.0007775, 1.0],
+ [0.07663839, 1.0008199, 1.0],
+ [0.00024265566, 1.0000049, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.3374429, 0.95532244, 1.0],
+ [0.7815224, 0.9982896, 1.0],
+ [0.02490354, 1.0002367, 1.0],
+ [
+ 7.3306524e-05,
+ 1.0000012,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [1.6006174, 0.96363807, 1.0],
+ [0.8803053, 0.99620026, 1.0],
+ [0.03250935, 1.0000377, 1.0],
+ [
+ 7.561181e-05,
+ 1.0000002,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [1.2088186, 0.9428956, 1.0],
+ [-0.21422692, 0.9855742, 1.0],
+ [-0.030162197, 0.9994372, 1.0],
+ [-0.00015745845, 0.99999803, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.1379385, 0.9243572, 1.0],
+ [0.5781135, 0.98177475, 1.0],
+ [-0.04092876, 0.9992675, 1.0],
+ [-0.00032025157, 0.9999967, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.595079, 0.90528804, 1.0],
+ [2.2092948, 0.97474945, 1.0],
+ [0.051008992, 0.9995308, 1.0],
+ [-0.000108188826, 0.9999993, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.917749, 0.9147641, 1.0],
+ [-0.3735081, 0.9730886, 1.0],
+ [-0.24302472, 0.9982874, 1.0],
+ [-0.002041071, 0.99998975, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.282022, 0.9485167, 1.0],
+ [-0.5685607, 0.985533, 1.0],
+ [-0.31762916, 0.9988836, 1.0],
+ [-0.002783568, 0.99999034, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.8673153, 0.9184925, 1.0],
+ [0.37535796, 0.9620167, 1.0],
+ [0.03746142, 0.9971347, 1.0],
+ [0.0030534149, 1.0000186, 1.0],
+ [0.0, 1.0, 1.0],
+ [7.4104285, 0.91220593, 1.0],
+ [0.87249917, 0.95596325, 1.0],
+ [-0.48440197, 0.99392086, 1.0],
+ [-0.019409077, 0.9999061, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.944506, 0.9487564, 1.0],
+ [0.5558285, 0.9802875, 1.0],
+ [-1.4248892, 1.0021002, 1.0],
+ [-0.15107189, 1.0004932, 1.0],
+ [0.0, 1.0, 1.0],
+ [7.4704447, 0.9741639, 1.0],
+ [-2.879535, 1.0111853, 1.0],
+ [-4.005509, 1.0224288, 1.0],
+ [-2.128151, 1.013158, 1.0],
+ [0.0, 1.0, 1.0],
+ [7.3452177, 1.0056307, 1.0],
+ [-3.1270623, 1.0118408, 1.0],
+ [-4.1959133, 1.0224493, 1.0],
+ [-2.526543, 1.0163975, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.885383, 0.89095396, 1.0],
+ [-6.0336304, 0.9250444, 1.0],
+ [-4.8112493, 0.9800796, 1.0],
+ [-1.296847, 1.0041145, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.1661447, 0.89456373, 1.0],
+ [-2.9906945, 0.92001104, 1.0],
+ [-1.5090454, 0.99253356, 1.0],
+ [-1.2905122, 0.96820384, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.34511527, 0.87384003, 1.0],
+ [-1.0981119, 0.935078, 1.0],
+ [-1.5757431, 0.97081584, 1.0],
+ [-1.8983483, 0.9408328, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.63075507, 0.87999564, 1.0],
+ [-0.70884484, 0.933653, 1.0],
+ [-1.3498812, 0.95319027, 1.0],
+ [-2.5344388, 0.93276036, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.7205443, 0.90210545, 1.0],
+ [0.062813476, 0.93098074, 1.0],
+ [-0.51643556, 0.9495486, 1.0],
+ [-1.4865465, 0.9489686, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.76368576, 0.89917886, 1.0],
+ [0.10259387, 0.9347847, 1.0],
+ [-0.40181014, 0.95541286, 1.0],
+ [-0.8396916, 0.9608325, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.0973828, 0.91251415, 1.0],
+ [0.22598426, 0.94344103, 1.0],
+ [-0.28614137, 0.9616241, 1.0],
+ [-0.73642546, 0.9707769, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.713114, 0.91217285, 1.0],
+ [0.56806993, 0.93098885, 1.0],
+ [-0.07742618, 0.949184, 1.0],
+ [-0.55610317, 0.9599895, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.2137406, 0.9127354, 1.0],
+ [1.1848869, 0.9180361, 1.0],
+ [0.20251377, 0.93949586, 1.0],
+ [-0.40885153, 0.94527155, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.0143402, 0.91241056, 1.0],
+ [1.3650892, 0.9393329, 1.0],
+ [0.6422094, 0.9600322, 1.0],
+ [0.011677059, 0.95110327, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.0051758, 0.8948882, 1.0],
+ [1.3498341, 0.9142505, 1.0],
+ [0.893142, 0.93700206, 1.0],
+ [0.27319187, 0.9396592, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.1864032, 0.887447, 1.0],
+ [1.0613244, 0.89364576, 1.0],
+ [1.2507672, 0.89813834, 1.0],
+ [1.0013293, 0.86100835, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.5468271, 0.87609977, 1.0],
+ [0.2019898, 0.90091765, 1.0],
+ [1.2255975, 0.87712115, 1.0],
+ [1.2425985, 0.83051956, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.6023275, 0.8365268, 1.0],
+ [-0.24021581, 0.90386933, 1.0],
+ [0.9349438, 0.87908584, 1.0],
+ [0.80752313, 0.8075074, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.221242, 0.8157836, 1.0],
+ [-0.39425692, 0.92637694, 1.0],
+ [0.8056268, 0.93297124, 1.0],
+ [1.3850795, 0.88745916, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.3293705, 0.8298025, 1.0],
+ [-1.2209973, 0.939168, 1.0],
+ [0.27317744, 0.9352172, 1.0],
+ [0.54171324, 0.923213, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.3448424, 0.8548377, 1.0],
+ [-1.4867651, 0.96641356, 1.0],
+ [0.6099653, 0.9759955, 1.0],
+ [0.59837306, 0.95445424, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.492541, 0.88700557, 1.0],
+ [-1.374813, 0.9962723, 1.0],
+ [1.030289, 1.0119152, 1.0],
+ [1.5211387, 0.99829376, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.779272, 0.918289, 1.0],
+ [-1.9571413, 0.99485767, 1.0],
+ [-0.3243052, 0.98670727, 1.0],
+ [0.08840849, 0.96744764, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.946428, 0.92973804, 1.0],
+ [-3.30334, 0.94437987, 1.0],
+ [-2.2054818, 0.92658573, 1.0],
+ [-1.9096534, 0.9025644, 1.0],
+ [0.0, 1.0, 1.0],
+ [-7.0771604, 0.938371, 1.0],
+ [-4.9650874, 0.9262841, 1.0],
+ [-5.804127, 0.87206984, 1.0],
+ [-10.723357, 0.7699574, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.103424, 0.9506851, 1.0],
+ [-7.6311193, 0.9232969, 1.0],
+ [-13.356695, 0.7412918, 1.0],
+ [-18.471521, 0.6411481, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.404529, 0.9482244, 1.0],
+ [-5.100853, 0.9020949, 1.0],
+ [-3.5057256, 0.7494264, 1.0],
+ [-5.474798, 0.7397798, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.278467, 0.9477641, 1.0],
+ [-1.6004895, 0.8717935, 1.0],
+ [3.8334162, 0.7214794, 1.0],
+ [3.0950382, 0.8104765, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.883697, 0.95728064, 1.0],
+ [0.22832659, 0.8919602, 1.0],
+ [3.9137595, 0.8611687, 1.0],
+ [1.0353252, 0.95515287, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.987924, 0.9267598, 1.0],
+ [-0.7117046, 0.9237348, 1.0],
+ [1.0760044, 0.9687251, 1.0],
+ [0.19669743, 0.9952294, 1.0],
+ [0.0, 1.0, 1.0],
+ [-11.256816, 0.895007, 1.0],
+ [-3.5309718, 0.9615622, 1.0],
+ [-0.049140226, 0.9978403, 1.0],
+ [0.003940524, 0.9998941, 1.0],
+ [0.0, 1.0, 1.0],
+ [-12.83839, 0.8533601, 1.0],
+ [-3.8871167, 0.9450403, 1.0],
+ [-0.04521785, 0.99777806, 1.0],
+ [0.0017813657, 0.99995136, 1.0],
+ [0.0, 1.0, 1.0],
+ [-10.2904825, 0.87262124, 1.0],
+ [-0.73294914, 0.93938565, 1.0],
+ [0.94196904, 0.9771655, 1.0],
+ [0.050941736, 0.9988109, 1.0],
+ [0.0, 1.0, 1.0],
+ [-10.646717, 0.87451744, 1.0],
+ [-1.9415165, 0.94662905, 1.0],
+ [0.6730928, 0.9824702, 1.0],
+ [0.04025293, 0.99905807, 1.0],
+ [0.0, 1.0, 1.0],
+ [-11.218059, 0.87239087, 1.0],
+ [-4.397181, 0.9521776, 1.0],
+ [-0.14049566, 0.9981121, 1.0],
+ [0.00026845204, 0.99998254, 1.0],
+ [0.0, 1.0, 1.0],
+ [-10.286129, 0.89371806, 1.0],
+ [-5.3461366, 0.96262884, 1.0],
+ [-0.24486518, 0.9982041, 1.0],
+ [-0.00065676763, 0.9999931, 1.0],
+ [0.0, 1.0, 1.0],
+ [-9.945648, 0.8883441, 1.0],
+ [-5.1836414, 0.97065026, 1.0],
+ [-0.20957097, 0.99866295, 1.0],
+ [-0.0005872021, 0.9999922, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.575455, 0.85818356, 1.0],
+ [-3.7089288, 0.96788406, 1.0],
+ [-0.118012555, 0.99915797, 1.0],
+ [-0.0004706569, 0.99999267, 1.0],
+ [0.0, 1.0, 1.0],
+ [-6.362028, 0.81099313, 1.0],
+ [-1.9053118, 0.9541133, 1.0],
+ [-0.02172425, 0.9980922, 1.0],
+ [0.00013715065, 0.9999891, 1.0],
+ [0.0, 1.0, 1.0],
+ [-6.124928, 0.8288149, 1.0],
+ [-2.1002314, 0.9534652, 1.0],
+ [-0.041706882, 0.99746007, 1.0],
+ [0.00017853655, 0.9999867, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.7694325, 0.8521812, 1.0],
+ [-2.324253, 0.9688911, 1.0],
+ [-0.09758008, 0.9992199, 1.0],
+ [-0.0002588657, 0.999998, 1.0],
+ [0.0, 1.0, 1.0],
+ [-6.6154294, 0.8325412, 1.0],
+ [-1.5618141, 0.9723158, 1.0],
+ [-0.043864716, 0.999613, 1.0],
+ [-0.00013398798, 0.9999991, 1.0],
+ [0.0, 1.0, 1.0],
+ [-6.2191544, 0.77018803, 1.0],
+ [-1.651971, 0.9547857, 1.0],
+ [-0.07113765, 0.9991722, 1.0],
+ [-0.00058576465, 0.99999535, 1.0],
+ [0.0, 1.0, 1.0],
+ [-6.46673, 0.72007173, 1.0],
+ [-2.0999687, 0.9299423, 1.0],
+ [-0.060667057, 0.99887276, 1.0],
+ [-0.00039300308, 0.9999954, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.3606741, 0.70400625, 1.0],
+ [-1.1170449, 0.90795213, 1.0],
+ [-0.03983348, 0.9983942, 1.0],
+ [-0.0002703255, 0.99999326, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.6689389, 0.665112, 1.0],
+ [-1.5962267, 0.8485116, 1.0],
+ [-0.071202874, 0.9962268, 1.0],
+ [-0.00039650878, 0.9999874, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.192851, 0.6639008, 1.0],
+ [-1.9858385, 0.77799946, 1.0],
+ [-0.08988286, 0.9801069, 1.0],
+ [0.0011868065, 0.99986833, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.2615559, 0.7249733, 1.0],
+ [-0.09431905, 0.7996679, 1.0],
+ [0.35236925, 0.902335, 1.0],
+ [0.014255428, 0.9975748, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.5921153, 0.8544056, 1.0],
+ [2.169442, 0.8899757, 1.0],
+ [1.4621226, 0.8872571, 1.0],
+ [0.09632246, 0.9899705, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.8741682, 0.87441283, 1.0],
+ [2.3036768, 0.93104565, 1.0],
+ [1.8774738, 0.9538973, 1.0],
+ [0.09678561, 0.99769336, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.6764153, 0.8590117, 1.0],
+ [2.3436446, 0.93695617, 1.0],
+ [2.9313862, 0.9975119, 1.0],
+ [0.7593898, 1.0038697, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.5126485, 0.8899826, 1.0],
+ [3.1817915, 0.9964198, 1.0],
+ [4.897578, 1.0497705, 1.0],
+ [3.8805265, 1.0446904, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.2394414, 0.8644259, 1.0],
+ [3.212901, 1.0230495, 1.0],
+ [4.632083, 1.0699781, 1.0],
+ [3.9237301, 1.0605509, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.5773077, 0.82117635, 1.0],
+ [1.2358187, 0.97362995, 1.0],
+ [2.1459916, 1.0216151, 1.0],
+ [0.7130695, 1.0066472, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.42648524, 0.82499254, 1.0],
+ [0.69195044, 0.9376137, 1.0],
+ [0.50218654, 0.9798264, 1.0],
+ [0.12657197, 0.9915114, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.7465619, 0.86613846, 1.0],
+ [0.25071248, 0.94280666, 1.0],
+ [0.06618124, 0.98196954, 1.0],
+ [0.008201971, 0.9988316, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.30600023, 0.8721118, 1.0],
+ [0.4314889, 0.94861966, 1.0],
+ [0.009828173, 0.9878695, 1.0],
+ [-0.006427021, 0.9995776, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.5700442, 0.89965767, 1.0],
+ [-0.05676093, 0.9809169, 1.0],
+ [-0.15799609, 1.001165, 1.0],
+ [-0.005288328, 1.0000738, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.82145816, 0.9085153, 1.0],
+ [-0.31160617, 0.9824265, 1.0],
+ [-0.2175388, 1.0011835, 1.0],
+ [-0.004642329, 1.0000556, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.453476, 0.92736363, 1.0],
+ [0.20573618, 0.9796587, 1.0],
+ [-0.02427894, 0.9999128, 1.0],
+ [-0.00065988675, 1.0000101, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.7588439, 0.9440933, 1.0],
+ [0.684478, 0.9797199, 1.0],
+ [0.072028, 0.9987622, 1.0],
+ [0.0004616782, 0.9999967, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.706155, 0.96692014, 1.0],
+ [0.67578906, 0.99002194, 1.0],
+ [0.055119734, 0.99926174, 1.0],
+ [0.0003961114, 0.99999535, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.7588563, 0.9780419, 1.0],
+ [-0.09298713, 0.99886334, 1.0],
+ [-0.01754928, 1.0001751, 1.0],
+ [-0.00010249701, 1.0000014, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.8756794, 0.9665713, 1.0],
+ [0.21679287, 0.9911038, 1.0],
+ [0.010155896, 0.9995159, 1.0],
+ [
+ 2.8347398e-05,
+ 0.9999979,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.7446922, 0.9719108, 1.0],
+ [0.13179037, 0.9942989, 1.0],
+ [0.005878921, 0.99972135, 1.0],
+ [
+ 2.3439836e-05,
+ 0.99999875,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-0.03203987, 0.9929243, 1.0],
+ [0.038900487, 0.99922925, 1.0],
+ [0.0021857598, 0.9999744, 1.0],
+ [
+ 5.484122e-06,
+ 0.9999999,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.4139118, 0.96163833, 1.0],
+ [-0.21519373, 0.9870976, 1.0],
+ [-0.020248298, 0.9992978, 1.0],
+ [
+ -9.694598e-05,
+ 0.99999726,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.1697552, 0.9555298, 1.0],
+ [-0.3862619, 0.98755735, 1.0],
+ [-0.044676315, 0.9992296, 1.0],
+ [-0.0002547122, 0.9999961, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.12476056, 0.9306088, 1.0],
+ [0.16077553, 0.9884132, 1.0],
+ [0.004473155, 0.9998182, 1.0],
+ [
+ -1.2910279e-05,
+ 0.9999996,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.34852365, 0.9528473, 1.0],
+ [-0.21723595, 0.9923173, 1.0],
+ [-0.032549698, 0.99970585, 1.0],
+ [-0.00026074066, 0.99999833, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.93782365, 0.9629782, 1.0],
+ [-0.97855735, 0.9888486, 1.0],
+ [-0.09944643, 0.9992393, 1.0],
+ [-0.0006539717, 0.99999535, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.6884682, 0.9228505, 1.0],
+ [-1.57592, 0.95246685, 1.0],
+ [-0.1084121, 0.9967328, 1.0],
+ [0.00042106112, 0.9999968, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.49395004, 0.9275926, 1.0],
+ [-1.7809842, 0.9529799, 1.0],
+ [-0.6778947, 0.9934386, 1.0],
+ [-0.02107622, 0.99988353, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.09461049, 0.91591424, 1.0],
+ [-2.842822, 0.9585913, 1.0],
+ [-2.0642266, 1.0008389, 1.0],
+ [-0.19923098, 1.0007129, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.4209429, 0.9632619, 1.0],
+ [-4.301751, 1.0129267, 1.0],
+ [-4.282477, 1.0219837, 1.0],
+ [-2.4989915, 1.014272, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.8516593, 0.94976884, 1.0],
+ [-4.918388, 0.9980298, 1.0],
+ [-4.6032376, 1.0179507, 1.0],
+ [-2.9246483, 1.0153408, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.434877, 0.8649714, 1.0],
+ [-6.194555, 0.86495477, 1.0],
+ [-2.6064827, 0.9455794, 1.0],
+ [0.18676785, 0.99121183, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.0596585, 0.88212174, 1.0],
+ [-0.39083886, 0.8480833, 1.0],
+ [7.699114, 0.8111516, 1.0],
+ [4.9935384, 0.8855417, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.1838806, 0.8388054, 1.0],
+ [7.2978683, 0.7670886, 1.0],
+ [8.46766, 0.78871816, 1.0],
+ [2.179444, 0.9128828, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.2858877, 0.8069495, 1.0],
+ [1.8508788, 0.7823992, 1.0],
+ [0.85906374, 0.84280854, 1.0],
+ [-0.8319525, 0.9454967, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.1529174, 0.7913341, 1.0],
+ [1.0985173, 0.77566886, 1.0],
+ [0.7970306, 0.8064273, 1.0],
+ [-0.13033624, 0.95262057, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.2466996, 0.7730009, 1.0],
+ [1.4481469, 0.79517776, 1.0],
+ [1.4872804, 0.8149948, 1.0],
+ [0.50854456, 0.9299336, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.0708513, 0.77292246, 1.0],
+ [1.9574028, 0.80550534, 1.0],
+ [1.8944045, 0.8277315, 1.0],
+ [0.885336, 0.9109165, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.8570101, 0.7737381, 1.0],
+ [2.1413271, 0.81192267, 1.0],
+ [1.4845073, 0.8555065, 1.0],
+ [0.67854315, 0.9129586, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.5446448, 0.7237353, 1.0],
+ [3.1861176, 0.7315414, 1.0],
+ [2.4211752, 0.7813792, 1.0],
+ [1.090997, 0.862102, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.3204086, 0.69664717, 1.0],
+ [3.060574, 0.7266127, 1.0],
+ [2.3519804, 0.81517905, 1.0],
+ [0.86642855, 0.92962444, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.7798967, 0.6820568, 1.0],
+ [2.8921554, 0.7140203, 1.0],
+ [3.4070027, 0.7793045, 1.0],
+ [1.9032202, 0.9059005, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.8094573, 0.6692679, 1.0],
+ [3.514479, 0.6703009, 1.0],
+ [3.2144916, 0.7359185, 1.0],
+ [0.7362445, 0.8973399, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.8017239, 0.7016305, 1.0],
+ [4.4307218, 0.6483909, 1.0],
+ [1.597159, 0.63064265, 1.0],
+ [-0.7608446, 0.6738216, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.70194286, 0.71021265, 1.0],
+ [4.076452, 0.64545816, 1.0],
+ [2.4849653, 0.58867955, 1.0],
+ [-0.3030934, 0.5878087, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.423018, 0.7011179, 1.0],
+ [0.867048, 0.7857347, 1.0],
+ [1.8778415, 0.7980667, 1.0],
+ [0.994016, 0.74506015, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.9815702, 0.71286356, 1.0],
+ [-1.3673462, 0.86698985, 1.0],
+ [-0.31344816, 0.9293362, 1.0],
+ [-0.5929167, 0.9181718, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.8956604, 0.7347033, 1.0],
+ [-1.5237117, 0.8810682, 1.0],
+ [-1.8345463, 0.87915176, 1.0],
+ [-4.8288293, 0.8226873, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.5750155, 0.75573695, 1.0],
+ [-2.32776, 0.83855045, 1.0],
+ [-7.9848466, 0.7422793, 1.0],
+ [-11.596745, 0.70366794, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.497558, 0.7963771, 1.0],
+ [-2.6339295, 0.79546416, 1.0],
+ [-6.573479, 0.6898624, 1.0],
+ [-10.560926, 0.66375583, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.5946136, 0.8173761, 1.0],
+ [-3.3373203, 0.80128264, 1.0],
+ [-1.2990564, 0.73161983, 1.0],
+ [-0.411279, 0.7264181, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.839973, 0.827455, 1.0],
+ [-8.91897, 0.7633143, 1.0],
+ [-13.195236, 0.68692213, 1.0],
+ [-15.08099, 0.667959, 1.0],
+ [0.0, 1.0, 1.0],
+ [-7.8307505, 0.81448054, 1.0],
+ [-18.429901, 0.6384616, 1.0],
+ [-21.627077, 0.5857334, 1.0],
+ [-21.719206, 0.57701963, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.3877606, 0.87045765, 1.0],
+ [-11.953717, 0.6845442, 1.0],
+ [-14.84228, 0.6076244, 1.0],
+ [-14.956815, 0.59963316, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.2047, 0.89726675, 1.0],
+ [-0.18282485, 0.7400877, 1.0],
+ [-0.45509633, 0.6334427, 1.0],
+ [-4.008627, 0.66572005, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.6257489, 0.8809391, 1.0],
+ [4.4138246, 0.7427594, 1.0],
+ [6.3582187, 0.7134476, 1.0],
+ [2.7748961, 0.7984047, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.90505534, 0.92275494, 1.0],
+ [4.2922206, 0.83137786, 1.0],
+ [4.8596277, 0.8543205, 1.0],
+ [1.9056705, 0.939401, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.7767366, 0.95586765, 1.0],
+ [0.3932802, 0.9633871, 1.0],
+ [0.60861015, 0.97952, 1.0],
+ [0.036355883, 0.9975114, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.3862658, 0.93128735, 1.0],
+ [1.6583297, 0.9430399, 1.0],
+ [1.3840419, 0.9670389, 1.0],
+ [0.106311135, 0.99748856, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.2226677, 0.91598487, 1.0],
+ [4.22652, 0.9187779, 1.0],
+ [1.5662466, 0.96636087, 1.0],
+ [0.076074705, 0.99824005, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.6022197, 0.9403912, 1.0],
+ [1.2155503, 0.962949, 1.0],
+ [0.8285263, 0.9807966, 1.0],
+ [0.044100095, 0.99897105, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.7198387, 0.9403748, 1.0],
+ [-0.6352572, 0.9795508, 1.0],
+ [-0.052131, 0.998167, 1.0],
+ [0.0001652719, 0.9999738, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.5542123, 0.9565107, 1.0],
+ [-0.109918736, 0.9894566, 1.0],
+ [-0.009283989, 0.999551, 1.0],
+ [
+ -6.8360925e-05,
+ 0.9999978,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-0.7696542, 0.95847034, 1.0],
+ [-0.15155797, 0.9900539, 1.0],
+ [-0.06100524, 0.9982844, 1.0],
+ [-0.0020035189, 0.9999522, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.1908454, 0.9578101, 1.0],
+ [-1.193139, 0.9622795, 1.0],
+ [-0.66812485, 0.9839621, 1.0],
+ [-0.022401229, 0.99947536, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.7816994, 0.93443125, 1.0],
+ [0.6398307, 0.96510935, 1.0],
+ [-0.044041365, 0.9943015, 1.0],
+ [-0.003998923, 0.9998783, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.28142613, 0.93776274, 1.0],
+ [0.8375012, 0.9702105, 1.0],
+ [0.101512685, 0.997176, 1.0],
+ [0.00067303015, 0.9999818, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.8797605, 0.95135915, 1.0],
+ [-0.126102, 0.993933, 1.0],
+ [-0.003883867, 0.99978, 1.0],
+ [
+ -8.009816e-06,
+ 0.99999905,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-2.6810584, 0.86311865, 1.0],
+ [-0.17959136, 0.9858264, 1.0],
+ [-0.0015349581, 0.9998344, 1.0],
+ [
+ -8.907976e-06,
+ 0.99999964,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-5.445669, 0.7283394, 1.0],
+ [-0.6088305, 0.9556886, 1.0],
+ [-0.01624582, 0.9992147, 1.0],
+ [
+ -9.05368e-05,
+ 0.9999974,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-6.5090322, 0.71607465, 1.0],
+ [-0.7856189, 0.9618718, 1.0],
+ [-0.0063526426, 0.99975485, 1.0],
+ [
+ -1.7714505e-05,
+ 0.9999995,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-1.7068195, 0.77349, 1.0],
+ [-0.24131422, 0.9724856, 1.0],
+ [-0.0030667144, 0.99979204, 1.0],
+ [
+ -4.3706127e-06,
+ 0.9999998,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-1.1626506, 0.7329441, 1.0],
+ [-0.49740094, 0.9462425, 1.0],
+ [-0.013326823, 0.999034, 1.0],
+ [
+ -2.82295e-05,
+ 0.99999803,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-0.86920273, 0.6843783, 1.0],
+ [-0.8369342, 0.8680424, 1.0],
+ [-0.055886846, 0.99398565, 1.0],
+ [-0.00019886444, 0.99997324, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.0519063, 0.67568576, 1.0],
+ [-1.380502, 0.7593411, 1.0],
+ [-0.23168321, 0.93081456, 1.0],
+ [-0.0014318114, 0.99881375, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.6460104, 0.7239269, 1.0],
+ [-0.27629834, 0.769188, 1.0],
+ [-0.002961831, 0.8593207, 1.0],
+ [0.0041828407, 0.993566, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.37849802, 0.775351, 1.0],
+ [0.36891446, 0.8593253, 1.0],
+ [0.03710465, 0.9495408, 1.0],
+ [0.0007436436, 0.99869233, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.4106183, 0.7918468, 1.0],
+ [0.9571653, 0.9164912, 1.0],
+ [0.2806054, 0.98760474, 1.0],
+ [0.026692087, 0.99853516, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.68634075, 0.8532397, 1.0],
+ [1.1827903, 0.9357391, 1.0],
+ [0.869629, 0.95853925, 1.0],
+ [0.24507365, 0.9850702, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.6320396, 0.9290106, 1.0],
+ [0.90500677, 0.964906, 1.0],
+ [0.9593051, 0.9864873, 1.0],
+ [0.21237753, 0.9966006, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.048774928, 0.95484895, 1.0],
+ [-0.77230346, 0.96596956, 1.0],
+ [-0.36607814, 0.9833947, 1.0],
+ [-0.027992101, 0.9972649, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.07467206, 0.9574848, 1.0],
+ [-0.46401745, 0.9480488, 1.0],
+ [-0.7730967, 0.85603, 1.0],
+ [-0.13825144, 0.9314603, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.20196562, 0.9510938, 1.0],
+ [0.4332324, 0.8811578, 1.0],
+ [0.7512844, 0.86460304, 1.0],
+ [0.38913283, 0.9498416, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.07075552, 0.9259933, 1.0],
+ [1.2225338, 0.89603615, 1.0],
+ [0.39722675, 0.97127604, 1.0],
+ [0.020897377, 0.99866724, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.8001552, 0.9517485, 1.0],
+ [1.2267344, 0.9665981, 1.0],
+ [0.074866876, 0.99737835, 1.0],
+ [0.00035822866, 0.9999817, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.1735111, 0.9407677, 1.0],
+ [0.55925053, 0.9782085, 1.0],
+ [0.014851813, 0.99942917, 1.0],
+ [
+ -2.6381907e-05,
+ 0.9999996,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-0.491996, 0.96088326, 1.0],
+ [-0.10901659, 0.9911518, 1.0],
+ [-0.0014201105, 0.9998647, 1.0],
+ [
+ -7.0408996e-06,
+ 0.9999999,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.9082432, 0.97429425, 1.0],
+ [0.14978711, 0.9955272, 1.0],
+ [0.0022982752, 0.99993473, 1.0],
+ [
+ 5.9549325e-06,
+ 0.9999999,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.3517478, 0.99564123, 1.0],
+ [0.06255331, 0.99933046, 1.0],
+ [0.0009568419, 0.9999891, 1.0],
+ [
+ 2.612675e-06,
+ 0.99999994,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.74196255, 1.0120044, 1.0],
+ [0.081937216, 1.0010135, 1.0],
+ [0.00051152834, 0.99999976, 1.0],
+ [
+ -1.3411326e-07,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.2244991, 0.99848604, 1.0],
+ [0.034186296, 0.9979303, 1.0],
+ [0.001426202, 0.9998331, 1.0],
+ [
+ 6.448161e-06,
+ 0.99999917,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.5212085, 0.99461675, 1.0],
+ [0.039274894, 0.9988406, 1.0],
+ [0.00067081454, 0.9999321, 1.0],
+ [
+ 2.6707798e-06,
+ 0.9999997,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.42774403, 1.0063812, 1.0],
+ [0.02219702, 1.0003272, 1.0],
+ [
+ 5.577047e-05,
+ 1.0000001,
+ 1.0
+ ],
+ [
+ -3.6048577e-09,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.1645134, 0.9992673, 1.0],
+ [0.006846053, 0.999736, 1.0],
+ [-0.00019761767, 0.99998826, 1.0],
+ [
+ -1.0566964e-06,
+ 0.99999994,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-0.0024718763, 0.99704987, 1.0],
+ [-0.005746108, 0.99963886, 1.0],
+ [-0.0005149844, 0.99998856, 1.0],
+ [
+ -2.8267918e-06,
+ 0.99999994,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.01222888, 0.99399185, 1.0],
+ [-0.0011779555, 0.99944973, 1.0],
+ [
+ -4.2567906e-05,
+ 0.99999565,
+ 1.0
+ ],
+ [
+ -2.0611503e-07,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.4803884, 0.9846379, 1.0],
+ [0.026714282, 0.9990032, 1.0],
+ [
+ -4.8978454e-06,
+ 0.99999505,
+ 1.0
+ ],
+ [
+ -7.071696e-07,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.06271327, 0.9943767, 1.0],
+ [-0.012053411, 0.99946314, 1.0],
+ [-0.0010432169, 0.99998796, 1.0],
+ [
+ -4.96212e-06,
+ 0.99999994,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.030970754, 0.9985594, 1.0],
+ [-0.044691127, 0.99863976, 1.0],
+ [-0.002984863, 0.999931, 1.0],
+ [
+ -1.1928371e-05,
+ 0.99999976,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.13066156, 0.99966115, 1.0],
+ [-0.03325378, 0.99870265, 1.0],
+ [-0.012181685, 0.9998752, 1.0],
+ [-0.00035528053, 0.999998, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.006449914, 0.99292916, 1.0],
+ [-0.10845718, 0.99747235, 1.0],
+ [-0.06701636, 1.0000327, 1.0],
+ [-0.0040320284, 1.0000145, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.2692093, 0.9923292, 1.0],
+ [-1.1617728, 1.0010196, 1.0],
+ [-1.1378351, 1.0052049, 1.0],
+ [-0.107098795, 1.0005559, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.1599848, 0.94598794, 1.0],
+ [-3.3414283, 0.9492445, 1.0],
+ [-1.9137881, 0.9936844, 1.0],
+ [-0.16074239, 1.0003223, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.12844217, 0.99728847, 1.0],
+ [-0.06282799, 0.9969152, 1.0],
+ [0.1619825, 0.9955758, 1.0],
+ [0.11950376, 0.9972032, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.35873035, 0.9887963, 1.0],
+ [1.8258559, 0.95673186, 1.0],
+ [4.998067, 0.88979626, 1.0],
+ [2.386227, 0.9458393, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.864645, 0.9789599, 1.0],
+ [3.940015, 0.91087675, 1.0],
+ [5.492297, 0.8787958, 1.0],
+ [1.6959858, 0.961089, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.0089777, 0.9739419, 1.0],
+ [1.5740615, 0.9393496, 1.0],
+ [0.7091527, 0.9656322, 1.0],
+ [0.058232725, 0.9972437, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.45170605, 0.9703701, 1.0],
+ [0.8162551, 0.9256496, 1.0],
+ [0.38978133, 0.9669807, 1.0],
+ [0.015846139, 0.99831927, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.8393841, 0.93710715, 1.0],
+ [2.4146032, 0.8306746, 1.0],
+ [1.9834396, 0.877878, 1.0],
+ [0.15351856, 0.990385, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.5492482, 0.916484, 1.0],
+ [3.833322, 0.7736226, 1.0],
+ [3.7509248, 0.7980663, 1.0],
+ [0.42413434, 0.97344464, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.5753381, 0.9001595, 1.0],
+ [1.4414841, 0.89536625, 1.0],
+ [0.870943, 0.9281202, 1.0],
+ [0.78198856, 0.9027962, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.626003, 0.8573594, 1.0],
+ [1.744929, 0.8717852, 1.0],
+ [0.45063186, 0.95120394, 1.0],
+ [0.49293122, 0.93314904, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.8157206, 0.79073906, 1.0],
+ [1.8466086, 0.8735757, 1.0],
+ [0.3013591, 0.97690207, 1.0],
+ [0.38631627, 0.9668622, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.498729, 0.75619996, 1.0],
+ [4.230379, 0.81058335, 1.0],
+ [1.221213, 0.9268467, 1.0],
+ [1.0963763, 0.9156937, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.932148, 0.7442637, 1.0],
+ [6.609813, 0.7236575, 1.0],
+ [2.5758474, 0.83543426, 1.0],
+ [0.2271159, 0.97122496, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.9551125, 0.7783704, 1.0],
+ [6.2856493, 0.6595728, 1.0],
+ [2.7745192, 0.6280471, 1.0],
+ [2.0338192, 0.6878951, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.0266984, 0.87015027, 1.0],
+ [4.2741194, 0.7685621, 1.0],
+ [3.0171409, 0.6355655, 1.0],
+ [2.7988803, 0.59572875, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.79312897, 0.9363041, 1.0],
+ [0.91887796, 0.95661134, 1.0],
+ [0.67517465, 0.9250825, 1.0],
+ [0.37668255, 0.869786, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.437517, 0.9439974, 1.0],
+ [0.038780373, 0.9556593, 1.0],
+ [0.267114, 0.9353768, 1.0],
+ [1.6325206, 0.92748356, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.43791065, 0.9496331, 1.0],
+ [-1.3674537, 0.87884694, 1.0],
+ [-2.1990373, 0.8128939, 1.0],
+ [-1.9359235, 0.82100236, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.76871, 0.9194859, 1.0],
+ [-1.2029064, 0.84553814, 1.0],
+ [-5.327206, 0.77611315, 1.0],
+ [-8.786878, 0.7737814, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.2917868, 0.9232655, 1.0],
+ [-1.4194634, 0.878315, 1.0],
+ [-5.7113304, 0.78513825, 1.0],
+ [-11.315231, 0.7345054, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.282819, 0.963271, 1.0],
+ [-0.7401497, 0.92713356, 1.0],
+ [0.003420251, 0.86226225, 1.0],
+ [-1.9279886, 0.87573004, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.4500295, 0.96384203, 1.0],
+ [-3.9545982, 0.9027838, 1.0],
+ [-4.0920296, 0.87749696, 1.0],
+ [-5.584249, 0.82730573, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.7861502, 0.91505295, 1.0],
+ [-9.366375, 0.7534717, 1.0],
+ [-12.683253, 0.7126221, 1.0],
+ [-13.721524, 0.69018114, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.63505775, 0.9360138, 1.0],
+ [-5.646698, 0.7840974, 1.0],
+ [-11.826459, 0.6914667, 1.0],
+ [-12.043715, 0.64471924, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.20236929, 0.98128676, 1.0],
+ [-0.9277988, 0.9150858, 1.0],
+ [0.17753038, 0.775079, 1.0],
+ [3.4420626, 0.68469423, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.59386843, 0.9422246, 1.0],
+ [1.948908, 0.89293414, 1.0],
+ [1.7242484, 0.8869361, 1.0],
+ [1.5372764, 0.90924776, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.17006855, 0.9595094, 1.0],
+ [0.9244115, 0.9223015, 1.0],
+ [-2.5031877, 0.88388723, 1.0],
+ [-1.2975974, 0.9453137, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.43563363, 0.9744029, 1.0],
+ [1.7651839, 0.9294651, 1.0],
+ [0.3709067, 0.95219886, 1.0],
+ [-0.23438755, 0.991713, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.8836351, 0.92381245, 1.0],
+ [3.3614104, 0.8902626, 1.0],
+ [2.6299663, 0.9327052, 1.0],
+ [0.20268272, 0.9949902, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.03636021, 0.96812797, 1.0],
+ [0.9191721, 0.98153824, 1.0],
+ [0.49294955, 0.9890738, 1.0],
+ [0.035599157, 0.9991702, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.36295575, 0.98267245, 1.0],
+ [-0.65720224, 0.9795889, 1.0],
+ [-0.06744636, 0.9974913, 1.0],
+ [0.00014511583, 0.9999672, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.7780958, 0.92661333, 1.0],
+ [-3.7994225, 0.90834874, 1.0],
+ [-0.5296, 0.98757935, 1.0],
+ [-0.004139361, 0.99990284, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.4182173, 0.94612914, 1.0],
+ [-0.2263425, 0.96315044, 1.0],
+ [-0.060515344, 0.9972809, 1.0],
+ [-0.00054953585, 0.9999832, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.2474353, 0.96370727, 1.0],
+ [0.050504524, 0.9636764, 1.0],
+ [-0.08704806, 0.9956043, 1.0],
+ [-0.0032428538, 0.9999153, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.20877357, 0.9770072, 1.0],
+ [-1.6285077, 0.9417298, 1.0],
+ [-1.0749032, 0.9740643, 1.0],
+ [-0.036731955, 0.9991393, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.5124754, 0.97665536, 1.0],
+ [0.05648161, 0.97246367, 1.0],
+ [-0.20485052, 0.99366885, 1.0],
+ [-0.007456854, 0.99982077, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.0751122, 0.9738437, 1.0],
+ [-1.0979657, 0.9731047, 1.0],
+ [-0.07396637, 0.99813634, 1.0],
+ [-0.00031904137, 0.9999918, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.81148493, 0.9809923, 1.0],
+ [-0.79679507, 0.98153543, 1.0],
+ [-0.052809343, 0.9987658, 1.0],
+ [-0.00021324423, 0.999995, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.70272887, 0.97712904, 1.0],
+ [-0.23628409, 0.99248105, 1.0],
+ [-0.017436834, 0.99954605, 1.0],
+ [
+ -9.100488e-05,
+ 0.9999978,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-2.112568, 0.92536825, 1.0],
+ [-1.793664, 0.952624, 1.0],
+ [-0.15880403, 0.99615294, 1.0],
+ [-0.0008353359, 0.9999803, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.0425283, 0.9575445, 1.0],
+ [-0.37356365, 0.9898527, 1.0],
+ [-0.027834127, 0.99933815, 1.0],
+ [-0.00014425212, 0.9999966, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.5929818, 0.9799135, 1.0],
+ [-0.06785171, 0.9980503, 1.0],
+ [-0.0006129217, 0.99998415, 1.0],
+ [
+ -7.431623e-07,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-1.0108821, 0.97179604, 1.0],
+ [-0.12948345, 0.99659497, 1.0],
+ [-0.0011306758, 0.9999685, 1.0],
+ [
+ -7.843986e-07,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-0.1479146, 0.98383874, 1.0],
+ [-0.026573375, 0.9968805, 1.0],
+ [-0.0007648126, 0.9999066, 1.0],
+ [
+ -2.9774496e-06,
+ 0.99999964,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-0.42330608, 0.9586368, 1.0],
+ [-0.117418036, 0.97570026, 1.0],
+ [-0.006413941, 0.9977138, 1.0],
+ [
+ -4.5600293e-05,
+ 0.9999782,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-0.482407, 0.9569524, 1.0],
+ [-0.5354113, 0.95456654, 1.0],
+ [-0.05666969, 0.99288625, 1.0],
+ [-0.00037526168, 0.9998962, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.2456969, 0.96252, 1.0],
+ [-1.9524531, 0.9508849, 1.0],
+ [-0.2124356, 0.9941987, 1.0],
+ [-0.0013579516, 0.9999511, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.12160804, 0.98543537, 1.0],
+ [-0.27839974, 0.983876, 1.0],
+ [-0.14200354, 0.99454284, 1.0],
+ [-0.012517998, 0.9993907, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.08368426, 0.9217887, 1.0],
+ [-1.2399791, 0.8625204, 1.0],
+ [-1.6894629, 0.9284568, 1.0],
+ [-0.18025236, 0.99156326, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.5968979, 0.94195247, 1.0],
+ [0.19960582, 0.85841644, 1.0],
+ [-0.6310517, 0.9365722, 1.0],
+ [-0.0635393, 0.99637383, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.7087493, 0.96301836, 1.0],
+ [-7.374809, 0.8499901, 1.0],
+ [-4.2648234, 0.88394064, 1.0],
+ [-0.40903544, 0.97660697, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.3982448, 0.97629553, 1.0],
+ [-3.0891116, 0.7622017, 1.0],
+ [-2.504011, 0.6110344, 1.0],
+ [-0.68706274, 0.7084606, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.5045769, 0.84708446, 1.0],
+ [1.9575346, 0.64301115, 1.0],
+ [2.4821124, 0.6190746, 1.0],
+ [2.1545558, 0.7520362, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.1298854, 0.77756673, 1.0],
+ [5.5874057, 0.67800367, 1.0],
+ [2.4975712, 0.8394186, 1.0],
+ [0.17680934, 0.98891705, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.144584, 0.8652469, 1.0],
+ [4.7808557, 0.8791068, 1.0],
+ [0.3867177, 0.98648083, 1.0],
+ [0.0025304207, 0.999883, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.867331, 0.8436379, 1.0],
+ [2.2105703, 0.93576556, 1.0],
+ [0.06521808, 0.99830455, 1.0],
+ [0.00012717527, 0.999997, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.0654385, 0.91793096, 1.0],
+ [0.26658812, 0.9786744, 1.0],
+ [0.0042792116, 0.9996769, 1.0],
+ [
+ 4.5931947e-06,
+ 0.9999997,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [1.074042, 0.9739217, 1.0],
+ [0.176678, 0.99581814, 1.0],
+ [0.0017133928, 0.9999589, 1.0],
+ [
+ 1.0157613e-06,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-1.01776, 0.9737646, 1.0],
+ [-0.09971152, 0.9972118, 1.0],
+ [-0.00057499425, 0.999982, 1.0],
+ [
+ -1.9621856e-07,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-0.4001637, 0.9907639, 1.0],
+ [-0.03467, 0.9991947, 1.0],
+ [-0.0001814811, 0.99999577, 1.0],
+ [
+ -5.804475e-08,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.042297646, 1.0002224, 1.0],
+ [0.0034461436, 1.0000255, 1.0],
+ [
+ 2.1965232e-05,
+ 0.99999976,
+ 1.0
+ ],
+ [
+ 2.5862306e-08,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.1646268, 0.9987905, 1.0],
+ [0.010173208, 0.99992174, 1.0],
+ [
+ 4.039547e-05,
+ 0.9999995,
+ 1.0
+ ],
+ [
+ 1.7013736e-08,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.48721975, 0.9939957, 1.0],
+ [0.028629638, 0.9996232, 1.0],
+ [0.0001032999, 0.99999857, 1.0],
+ [
+ 2.3236245e-08,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.26688322, 0.99534684, 1.0],
+ [0.01570361, 0.9997175, 1.0],
+ [
+ 5.7026642e-05,
+ 0.9999989,
+ 1.0
+ ],
+ [
+ 1.27192274e-08,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.0029496485, 0.999937, 1.0],
+ [0.00017224046, 0.9999962, 1.0],
+ [
+ 5.5217765e-07,
+ 1.0,
+ 1.0
+ ],
+ [
+ -2.9122849e-10,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.047425542, 0.99909365, 1.0],
+ [0.0026520186, 0.9999489, 1.0],
+ [
+ 9.205303e-06,
+ 0.9999998,
+ 1.0
+ ],
+ [
+ 1.95425e-09,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.56498164, 0.98940337, 1.0],
+ [0.03208864, 0.9993928, 1.0],
+ [0.00011148855, 0.99999785, 1.0],
+ [
+ 2.4027702e-08,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.26117882, 0.99926835, 1.0],
+ [0.014332054, 0.9999543, 1.0],
+ [
+ 4.8232454e-05,
+ 0.9999998,
+ 1.0
+ ],
+ [
+ 8.686053e-09,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.7915088, 1.0046446, 1.0],
+ [0.04312717, 1.0002536, 1.0],
+ [0.00014175105, 1.0000008, 1.0],
+ [
+ 2.4375671e-08,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.14177647, 0.99992174, 1.0],
+ [0.0075591076, 0.9999955, 1.0],
+ [
+ 2.2167036e-05,
+ 0.99999994,
+ 1.0
+ ],
+ [
+ -8.405434e-08,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-0.06938606, 0.9985813, 1.0],
+ [-0.0027954027, 0.9999414, 1.0],
+ [
+ -2.462243e-05,
+ 0.9999999,
+ 1.0
+ ],
+ [
+ -1.0864502e-06,
+ 1.0,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-0.23859352, 0.995206, 1.0],
+ [-0.010182198, 0.99980026, 1.0],
+ [-0.00044199303, 1.0000005, 1.0],
+ [
+ -2.9140341e-05,
+ 1.0000001,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-0.04641313, 0.99916106, 1.0],
+ [-0.020731159, 0.9994961, 1.0],
+ [-0.0036757442, 0.99992925, 1.0],
+ [-0.0001021458, 0.9999989, 1.0]
+ ],
+ "srgb_value": true
+ }
+}
diff --git a/assets/camera-profiles/X-T4.json b/assets/camera-profiles/X-T4.json
new file mode 100644
index 000000000..92f8dd3ef
--- /dev/null
+++ b/assets/camera-profiles/X-T4.json
@@ -0,0 +1,1863 @@
+{
+ "version": 1,
+ "model": "X-T4",
+ "files": 545,
+ "samples": 1951939,
+ "matrix": [
+ [1.1956707020637634, -0.20567495430470473, -0.001118128888055736],
+ [-0.07689679823427831, 1.1536404662027415, -0.07257103371689055],
+ [0.012366539151109635, -0.8454877840052113, 1.8346862279899163]
+ ],
+ "hue_sat": {
+ "hue_divisions": 72,
+ "sat_divisions": 5,
+ "val_divisions": 5,
+ "data": [
+ [0.0, 1.0, 1.0],
+ [8.488724, 1.1072181, 1.0],
+ [6.027911, 1.1025184, 1.0],
+ [5.126879, 1.1056141, 1.0],
+ [2.7562134, 1.0608389, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.9790807, 1.0982808, 1.0],
+ [4.307873, 1.0916511, 1.0],
+ [2.7828488, 1.0824455, 1.0],
+ [1.8936595, 1.0677147, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.7833548, 1.0808423, 1.0],
+ [2.821706, 1.0722075, 1.0],
+ [1.2096823, 1.0580945, 1.0],
+ [0.3733568, 1.0406352, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.533599, 1.0569755, 1.0],
+ [1.7481736, 1.0532024, 1.0],
+ [0.47299206, 1.0389953, 1.0],
+ [-0.8960845, 1.0057793, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.2647514, 1.037265, 1.0],
+ [0.57080513, 1.0367802, 1.0],
+ [-0.491963, 1.0290552, 1.0],
+ [-1.6263541, 1.0026131, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.1541393, 1.0179027, 1.0],
+ [-0.6506915, 1.019257, 1.0],
+ [-1.7829689, 1.0136136, 1.0],
+ [-3.3493202, 0.9885524, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.7049778, 0.99872667, 1.0],
+ [-1.5122603, 0.9989183, 1.0],
+ [-2.9810934, 0.99024343, 1.0],
+ [-5.581267, 0.9519912, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.690269, 0.9788303, 1.0],
+ [-1.8288996, 0.97644824, 1.0],
+ [-3.569127, 0.96698815, 1.0],
+ [-5.301545, 0.9436633, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.675697, 0.9631066, 1.0],
+ [-1.8190717, 0.9620807, 1.0],
+ [-3.9894445, 0.94959897, 1.0],
+ [-5.4966874, 0.93077904, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.015561, 0.9440323, 1.0],
+ [-1.5841109, 0.9433394, 1.0],
+ [-3.7623508, 0.9329007, 1.0],
+ [-4.8697934, 0.91531616, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.7726275, 0.9228537, 1.0],
+ [-0.8683006, 0.90954083, 1.0],
+ [-3.5469055, 0.89416665, 1.0],
+ [-4.6822357, 0.8788996, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.8594914, 0.9101041, 1.0],
+ [-0.8338466, 0.89342636, 1.0],
+ [-2.8553827, 0.8670687, 1.0],
+ [-3.9147246, 0.842152, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.91838557, 0.9096098, 1.0],
+ [-0.22313553, 0.8916189, 1.0],
+ [-2.1062787, 0.84502786, 1.0],
+ [-4.6637325, 0.79085016, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.7867746, 0.9109965, 1.0],
+ [0.5220267, 0.88554436, 1.0],
+ [-1.6875609, 0.8377822, 1.0],
+ [-2.5638952, 0.8012038, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.608996, 0.9175911, 1.0],
+ [0.51752204, 0.87700754, 1.0],
+ [-0.87092155, 0.8299583, 1.0],
+ [-0.6205537, 0.8152296, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.60483766, 0.92897344, 1.0],
+ [0.48028103, 0.86985594, 1.0],
+ [0.6325482, 0.83478713, 1.0],
+ [1.2977381, 0.8260493, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.3837581, 0.9366167, 1.0],
+ [-0.17185253, 0.8691589, 1.0],
+ [1.6557579, 0.8430431, 1.0],
+ [3.0675054, 0.8435281, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.8841403, 0.9447056, 1.0],
+ [-0.14756455, 0.88780934, 1.0],
+ [2.745795, 0.8501864, 1.0],
+ [4.020532, 0.83667177, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.4971426, 0.9572608, 1.0],
+ [0.27770972, 0.9148666, 1.0],
+ [3.7949884, 0.8749599, 1.0],
+ [5.156509, 0.8579785, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.1615067, 0.9798452, 1.0],
+ [0.7165952, 0.9488004, 1.0],
+ [3.0187547, 0.91653454, 1.0],
+ [2.6265273, 0.9383036, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.221659, 1.0131094, 1.0],
+ [0.13435012, 0.97644806, 1.0],
+ [1.240259, 0.96669924, 1.0],
+ [0.052574184, 0.99833775, 1.0],
+ [0.0, 1.0, 1.0],
+ [-6.439757, 1.0429982, 1.0],
+ [-0.75906193, 0.98914695, 1.0],
+ [0.6928731, 0.98318547, 1.0],
+ [0.014971263, 0.9996664, 1.0],
+ [0.0, 1.0, 1.0],
+ [-7.9659386, 1.0793759, 1.0],
+ [-1.7617505, 1.0095686, 1.0],
+ [0.22509712, 0.9961085, 1.0],
+ [0.004788822, 0.99993336, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.894038, 1.111571, 1.0],
+ [-2.515549, 1.037413, 1.0],
+ [-0.13141948, 1.0021963, 1.0],
+ [-0.00069948833, 1.000002, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.479235, 1.1109792, 1.0],
+ [-1.8521774, 1.0352653, 1.0],
+ [-0.016141478, 1.001208, 1.0],
+ [
+ -4.3190834e-05,
+ 1.0000027,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-8.549912, 1.0822867, 1.0],
+ [-2.329735, 1.0172695, 1.0],
+ [-0.078001685, 0.9996079, 1.0],
+ [-0.0005751441, 0.9999892, 1.0],
+ [0.0, 1.0, 1.0],
+ [-10.067066, 1.0514193, 1.0],
+ [-2.9787543, 1.0056072, 1.0],
+ [-0.068143584, 0.999506, 1.0],
+ [-0.0004552759, 0.9999932, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.974208, 1.0131854, 1.0],
+ [-2.0702703, 0.9893971, 1.0],
+ [-0.0065514506, 0.9995635, 1.0],
+ [
+ -1.1568422e-05,
+ 0.9999987,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-9.526247, 0.9756266, 1.0],
+ [-2.8014424, 0.9778582, 1.0],
+ [-0.027753195, 0.9994349, 1.0],
+ [
+ -2.2518001e-05,
+ 0.99999917,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-9.626008, 0.9605885, 1.0],
+ [-3.5689998, 0.97556823, 1.0],
+ [-0.06463481, 0.9995251, 1.0],
+ [
+ -8.669659e-05,
+ 0.99999934,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-9.666746, 0.9549443, 1.0],
+ [-3.2697423, 0.9675152, 1.0],
+ [-0.077041924, 0.99725926, 1.0],
+ [-0.0004004262, 0.99997205, 1.0],
+ [0.0, 1.0, 1.0],
+ [-10.91483, 0.93973595, 1.0],
+ [-4.0554028, 0.9567627, 1.0],
+ [-0.19969644, 0.994213, 1.0],
+ [-0.0015807059, 0.99994254, 1.0],
+ [0.0, 1.0, 1.0],
+ [-10.917845, 0.9374126, 1.0],
+ [-3.937062, 0.9639455, 1.0],
+ [-0.10804518, 0.99682367, 1.0],
+ [-0.0003749917, 0.9999778, 1.0],
+ [0.0, 1.0, 1.0],
+ [-10.878841, 0.9248424, 1.0],
+ [-4.854392, 0.9475052, 1.0],
+ [-0.3740493, 0.99154884, 1.0],
+ [-0.0027292988, 0.9999195, 1.0],
+ [0.0, 1.0, 1.0],
+ [-11.24207, 0.9229496, 1.0],
+ [-5.3360095, 0.955147, 1.0],
+ [-0.303692, 0.9946303, 1.0],
+ [-0.0016415697, 0.9999533, 1.0],
+ [0.0, 1.0, 1.0],
+ [-12.459016, 0.92287266, 1.0],
+ [-6.2727904, 0.9671372, 1.0],
+ [-0.26351833, 0.9981922, 1.0],
+ [-0.00071758503, 0.9999905, 1.0],
+ [0.0, 1.0, 1.0],
+ [-14.29214, 0.91273934, 1.0],
+ [-7.047706, 0.96466416, 1.0],
+ [-0.29217795, 0.9984951, 1.0],
+ [-0.0011865492, 0.9999872, 1.0],
+ [0.0, 1.0, 1.0],
+ [-15.4999695, 0.87166667, 1.0],
+ [-7.790251, 0.9259767, 1.0],
+ [-0.44737285, 0.9925323, 1.0],
+ [-0.0028230716, 0.9999344, 1.0],
+ [0.0, 1.0, 1.0],
+ [-16.484762, 0.81370276, 1.0],
+ [-7.1786904, 0.8921729, 1.0],
+ [-0.2108328, 0.9926036, 1.0],
+ [
+ 8.717539e-05,
+ 0.9999606,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-16.285719, 0.7696234, 1.0],
+ [-8.000221, 0.86041737, 1.0],
+ [-0.25902525, 0.9928877, 1.0],
+ [
+ 3.6861604e-06,
+ 0.99996454,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-14.937389, 0.7474696, 1.0],
+ [-8.215962, 0.8344298, 1.0],
+ [-0.29087847, 0.9932387, 1.0],
+ [-0.0004977129, 0.9999889, 1.0],
+ [0.0, 1.0, 1.0],
+ [-13.858105, 0.7273871, 1.0],
+ [-8.646881, 0.80850846, 1.0],
+ [-0.6027111, 0.98492134, 1.0],
+ [-0.0006273363, 0.9999705, 1.0],
+ [0.0, 1.0, 1.0],
+ [-12.701234, 0.724614, 1.0],
+ [-7.9289045, 0.8156208, 1.0],
+ [-0.8558639, 0.97617847, 1.0],
+ [0.0064291214, 0.9999933, 1.0],
+ [0.0, 1.0, 1.0],
+ [-11.146729, 0.7225693, 1.0],
+ [-6.5765376, 0.82539093, 1.0],
+ [-1.0245144, 0.96958196, 1.0],
+ [0.013567956, 1.0000076, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.884148, 0.7417882, 1.0],
+ [-4.3417435, 0.87207294, 1.0],
+ [-0.85739046, 0.98081857, 1.0],
+ [-0.0033849054, 0.999951, 1.0],
+ [0.0, 1.0, 1.0],
+ [-6.049297, 0.8116962, 1.0],
+ [-1.4851714, 0.9950118, 1.0],
+ [0.49591282, 1.0545783, 1.0],
+ [1.0066286, 1.0165282, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.6716075, 0.83988357, 1.0],
+ [-1.0457978, 0.99501514, 1.0],
+ [3.4836724, 1.0588785, 1.0],
+ [6.21208, 1.0532575, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.4979618, 0.86866015, 1.0],
+ [-1.4233817, 0.9817547, 1.0],
+ [1.8918269, 1.0534106, 1.0],
+ [4.6594706, 1.0482085, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.3181837, 0.8540786, 1.0],
+ [-2.0863492, 0.9573726, 1.0],
+ [-0.3537899, 1.0474013, 1.0],
+ [0.934481, 1.0493503, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.0748718, 0.7988069, 1.0],
+ [-1.966728, 0.8981509, 1.0],
+ [-0.28818163, 1.0324, 1.0],
+ [1.1813463, 1.0708457, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.8137631, 0.7544842, 1.0],
+ [-1.7292049, 0.86010194, 1.0],
+ [-0.7809513, 1.0063258, 1.0],
+ [-0.045840506, 1.0474083, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.5037665, 0.7084344, 1.0],
+ [-1.2390419, 0.79690814, 1.0],
+ [-0.40586868, 0.9500636, 1.0],
+ [-0.25943267, 1.0091549, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.5305736, 0.6992817, 1.0],
+ [-0.84799874, 0.7868287, 1.0],
+ [-0.26535538, 0.9460887, 1.0],
+ [-0.26857254, 1.0045226, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.7894933, 0.696515, 1.0],
+ [-0.087908834, 0.79940414, 1.0],
+ [-0.40865606, 0.9581447, 1.0],
+ [-0.26790136, 1.0027667, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.0415573, 0.69235516, 1.0],
+ [2.7619398, 0.79386014, 1.0],
+ [0.7820542, 0.95513344, 1.0],
+ [-0.07779682, 1.000791, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.733248, 0.70772463, 1.0],
+ [5.005565, 0.8130484, 1.0],
+ [1.6844413, 0.95230424, 1.0],
+ [0.0011346041, 0.999593, 1.0],
+ [0.0, 1.0, 1.0],
+ [7.1720886, 0.72739035, 1.0],
+ [6.0618978, 0.83798486, 1.0],
+ [1.9718703, 0.959339, 1.0],
+ [0.01772261, 0.99964523, 1.0],
+ [0.0, 1.0, 1.0],
+ [8.698657, 0.7538606, 1.0],
+ [6.9594636, 0.85568684, 1.0],
+ [2.449368, 0.96119237, 1.0],
+ [0.03495715, 0.99959546, 1.0],
+ [0.0, 1.0, 1.0],
+ [9.8315, 0.8065308, 1.0],
+ [7.1267076, 0.8956971, 1.0],
+ [2.3986976, 0.97244316, 1.0],
+ [0.03165836, 0.9996826, 1.0],
+ [0.0, 1.0, 1.0],
+ [10.795148, 0.85122234, 1.0],
+ [7.7294583, 0.93523824, 1.0],
+ [3.1217756, 0.9856404, 1.0],
+ [0.057580743, 0.9998344, 1.0],
+ [0.0, 1.0, 1.0],
+ [10.398136, 0.8843981, 1.0],
+ [7.9121175, 0.9568833, 1.0],
+ [3.1463122, 0.99417585, 1.0],
+ [0.05408725, 1.0000057, 1.0],
+ [0.0, 1.0, 1.0],
+ [10.596379, 0.89801466, 1.0],
+ [8.130247, 0.9633411, 1.0],
+ [3.3988564, 0.9945132, 1.0],
+ [0.05017191, 1.0000184, 1.0],
+ [0.0, 1.0, 1.0],
+ [10.625336, 0.9014762, 1.0],
+ [7.98765, 0.958355, 1.0],
+ [3.875602, 0.9900766, 1.0],
+ [0.07159707, 0.99994385, 1.0],
+ [0.0, 1.0, 1.0],
+ [10.804855, 0.9026359, 1.0],
+ [8.302903, 0.9591842, 1.0],
+ [4.20411, 0.9905614, 1.0],
+ [0.10111151, 0.9999875, 1.0],
+ [0.0, 1.0, 1.0],
+ [11.296136, 0.91712755, 1.0],
+ [8.392348, 0.9658389, 1.0],
+ [4.6424003, 0.9887021, 1.0],
+ [0.13342388, 0.9997721, 1.0],
+ [0.0, 1.0, 1.0],
+ [11.479186, 0.93038946, 1.0],
+ [8.325115, 0.96937376, 1.0],
+ [4.8091784, 0.9889414, 1.0],
+ [0.14431237, 0.99973685, 1.0],
+ [0.0, 1.0, 1.0],
+ [11.55654, 0.93699944, 1.0],
+ [8.343578, 0.97098297, 1.0],
+ [4.5094857, 0.9892612, 1.0],
+ [0.11552952, 0.9997806, 1.0],
+ [0.0, 1.0, 1.0],
+ [11.673957, 0.95636433, 1.0],
+ [9.015992, 0.9808939, 1.0],
+ [5.2865705, 0.99124783, 1.0],
+ [0.15847602, 0.99981487, 1.0],
+ [0.0, 1.0, 1.0],
+ [11.381441, 0.97744256, 1.0],
+ [9.125418, 0.99072415, 1.0],
+ [5.898516, 0.99096805, 1.0],
+ [0.22721227, 0.99941134, 1.0],
+ [0.0, 1.0, 1.0],
+ [11.39636, 0.99879706, 1.0],
+ [8.978779, 1.001649, 1.0],
+ [5.9936876, 0.99107313, 1.0],
+ [0.47186422, 0.99851125, 1.0],
+ [0.0, 1.0, 1.0],
+ [10.871172, 1.0297832, 1.0],
+ [8.347715, 1.0252823, 1.0],
+ [5.9642944, 1.0091741, 1.0],
+ [0.6254432, 1.0003536, 1.0],
+ [0.0, 1.0, 1.0],
+ [9.91249, 1.0697851, 1.0],
+ [7.514703, 1.0643711, 1.0],
+ [6.2715187, 1.0660433, 1.0],
+ [1.3713064, 1.0171726, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.9206777, 1.0631331, 1.0],
+ [5.4058423, 1.0743114, 1.0],
+ [4.9153433, 1.0924674, 1.0],
+ [3.5594451, 1.0767422, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.296089, 1.0543188, 1.0],
+ [3.7133682, 1.0690163, 1.0],
+ [2.6709373, 1.0747231, 1.0],
+ [1.8147451, 1.0627362, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.072669, 1.0297099, 1.0],
+ [2.2282882, 1.0441604, 1.0],
+ [1.04859, 1.0460346, 1.0],
+ [0.09582316, 1.0271128, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.131797, 0.99783087, 1.0],
+ [1.3904452, 1.0142667, 1.0],
+ [0.21789947, 1.0169302, 1.0],
+ [-1.3347801, 0.99124795, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.1123412, 0.9821653, 1.0],
+ [0.47084513, 0.9980239, 1.0],
+ [-0.4589091, 1.0047126, 1.0],
+ [-2.6004968, 0.97831535, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.2340026, 0.9715437, 1.0],
+ [-0.52651185, 0.9844928, 1.0],
+ [-1.4068123, 0.99132365, 1.0],
+ [-3.105275, 0.9731577, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.8440855, 0.9556401, 1.0],
+ [-1.2418174, 0.96413547, 1.0],
+ [-2.314578, 0.97029686, 1.0],
+ [-4.555726, 0.9510563, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.8005227, 0.9413437, 1.0],
+ [-1.6244266, 0.9424764, 1.0],
+ [-2.8907351, 0.94735694, 1.0],
+ [-4.531418, 0.938058, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.84144706, 0.9381546, 1.0],
+ [-1.8843055, 0.9437712, 1.0],
+ [-3.7513957, 0.93906224, 1.0],
+ [-5.2546344, 0.9245488, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.1232786, 0.91807526, 1.0],
+ [-1.6892345, 0.927733, 1.0],
+ [-3.5301387, 0.9231299, 1.0],
+ [-4.4934497, 0.90747577, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.7078936, 0.88705426, 1.0],
+ [-1.1164439, 0.8847049, 1.0],
+ [-3.1222675, 0.881657, 1.0],
+ [-4.24306, 0.8661209, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.3021957, 0.866729, 1.0],
+ [-1.4405441, 0.8674825, 1.0],
+ [-2.6025193, 0.8471425, 1.0],
+ [-2.9510996, 0.78768075, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.6495463, 0.84840906, 1.0],
+ [-1.1817076, 0.8663429, 1.0],
+ [-2.0115397, 0.8132654, 1.0],
+ [-3.20892, 0.75081503, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.08545443, 0.8417304, 1.0],
+ [-0.41332915, 0.86041677, 1.0],
+ [-1.7788253, 0.8134252, 1.0],
+ [-2.212696, 0.76719844, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.047263395, 0.8536788, 1.0],
+ [-0.28024718, 0.85070366, 1.0],
+ [-1.1217487, 0.80729675, 1.0],
+ [-0.9744078, 0.782097, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.5738716, 0.8675571, 1.0],
+ [-0.26259437, 0.8452582, 1.0],
+ [0.15979776, 0.8179859, 1.0],
+ [0.7517238, 0.8056368, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.153183, 0.8773336, 1.0],
+ [-0.59632957, 0.845526, 1.0],
+ [1.2569299, 0.83155745, 1.0],
+ [2.6460948, 0.83558846, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.344423, 0.89766663, 1.0],
+ [-0.4878973, 0.86092067, 1.0],
+ [2.0878763, 0.8382237, 1.0],
+ [3.5387704, 0.8289335, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.6364002, 0.91960084, 1.0],
+ [-0.20694885, 0.88383955, 1.0],
+ [2.819625, 0.86090726, 1.0],
+ [4.849336, 0.84782916, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.991286, 0.9524578, 1.0],
+ [0.28421873, 0.9292402, 1.0],
+ [2.098149, 0.8972288, 1.0],
+ [2.5357125, 0.92512876, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.56505, 0.9899462, 1.0],
+ [0.38352713, 0.9732783, 1.0],
+ [1.4336663, 0.9498769, 1.0],
+ [-2.1187909, 0.9426851, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.3135715, 1.0177003, 1.0],
+ [-0.23551735, 0.99192464, 1.0],
+ [1.1284361, 0.97364134, 1.0],
+ [-0.43181676, 0.97194505, 1.0],
+ [0.0, 1.0, 1.0],
+ [-6.6075764, 1.0397655, 1.0],
+ [-1.0467725, 1.0000273, 1.0],
+ [0.6370652, 0.9854795, 1.0],
+ [0.66872966, 0.9790045, 1.0],
+ [0.0, 1.0, 1.0],
+ [-7.4358993, 1.0649753, 1.0],
+ [-2.0699987, 1.0154411, 1.0],
+ [0.22289409, 0.9912232, 1.0],
+ [0.68752974, 0.9844199, 1.0],
+ [0.0, 1.0, 1.0],
+ [-7.9629416, 1.0769956, 1.0],
+ [-2.8259487, 1.0380164, 1.0],
+ [0.17933619, 0.9962173, 1.0],
+ [0.24746062, 0.9950691, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.186394, 1.0412176, 1.0],
+ [-3.2612817, 1.0117664, 1.0],
+ [0.16850522, 0.9932496, 1.0],
+ [0.2498481, 0.9976146, 1.0],
+ [0.0, 1.0, 1.0],
+ [-9.472168, 0.9964655, 1.0],
+ [-4.180181, 0.9870781, 1.0],
+ [-0.0750533, 0.9970658, 1.0],
+ [0.012545125, 0.9995864, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.411514, 0.9593027, 1.0],
+ [-2.9796257, 0.97248125, 1.0],
+ [-0.018407283, 0.9964052, 1.0],
+ [-0.04716424, 0.9984274, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.102845, 0.922432, 1.0],
+ [-3.318696, 0.95474064, 1.0],
+ [-0.020426346, 0.9982194, 1.0],
+ [-0.001956191, 0.9996863, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.526674, 0.89696586, 1.0],
+ [-4.260207, 0.9472845, 1.0],
+ [-0.11072428, 0.99930483, 1.0],
+ [-0.00018295995, 0.9999972, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.737394, 0.89237434, 1.0],
+ [-3.8464966, 0.9399867, 1.0],
+ [-0.07978496, 0.99751663, 1.0],
+ [-0.00027686163, 0.9999805, 1.0],
+ [0.0, 1.0, 1.0],
+ [-9.886306, 0.8757939, 1.0],
+ [-4.6172514, 0.92852616, 1.0],
+ [-0.1890676, 0.9948679, 1.0],
+ [-0.001193005, 0.999959, 1.0],
+ [0.0, 1.0, 1.0],
+ [-10.259174, 0.8847142, 1.0],
+ [-5.02464, 0.9355583, 1.0],
+ [-0.14631894, 0.996452, 1.0],
+ [-0.00036141326, 0.9999813, 1.0],
+ [0.0, 1.0, 1.0],
+ [-9.869882, 0.8831311, 1.0],
+ [-5.362167, 0.93398, 1.0],
+ [-0.34822685, 0.9924211, 1.0],
+ [-0.0020351785, 0.9999336, 1.0],
+ [0.0, 1.0, 1.0],
+ [-10.093717, 0.8826345, 1.0],
+ [-5.5686555, 0.93492806, 1.0],
+ [-0.21373157, 0.9918344, 1.0],
+ [-0.0002989677, 0.99993354, 1.0],
+ [0.0, 1.0, 1.0],
+ [-11.619686, 0.8809789, 1.0],
+ [-7.5092616, 0.9418452, 1.0],
+ [-0.3233299, 0.9970841, 1.0],
+ [0.001180963, 0.9999634, 1.0],
+ [0.0, 1.0, 1.0],
+ [-13.424544, 0.8696788, 1.0],
+ [-8.506053, 0.93757474, 1.0],
+ [-0.2895198, 0.99745727, 1.0],
+ [0.004820106, 0.99991095, 1.0],
+ [0.0, 1.0, 1.0],
+ [-14.621148, 0.8231381, 1.0],
+ [-9.321265, 0.88769966, 1.0],
+ [-0.47915274, 0.99181235, 1.0],
+ [-0.0022949055, 0.99993384, 1.0],
+ [0.0, 1.0, 1.0],
+ [-15.68596, 0.7722066, 1.0],
+ [-8.95147, 0.8554953, 1.0],
+ [-0.21178812, 0.9913755, 1.0],
+ [0.0008263445, 0.9999525, 1.0],
+ [0.0, 1.0, 1.0],
+ [-15.074831, 0.728671, 1.0],
+ [-9.225928, 0.8146718, 1.0],
+ [-0.2110684, 0.9879508, 1.0],
+ [0.0014654138, 0.9999165, 1.0],
+ [0.0, 1.0, 1.0],
+ [-12.948474, 0.7042668, 1.0],
+ [-9.001779, 0.7873532, 1.0],
+ [-0.40309152, 0.9885863, 1.0],
+ [-0.00052242767, 0.9999782, 1.0],
+ [0.0, 1.0, 1.0],
+ [-10.635279, 0.68401897, 1.0],
+ [-7.905069, 0.7697101, 1.0],
+ [-0.7297403, 0.9731825, 1.0],
+ [-0.00023977055, 0.99993455, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.394399, 0.6700475, 1.0],
+ [-6.597605, 0.77691966, 1.0],
+ [-1.2440937, 0.95707667, 1.0],
+ [0.0048901793, 0.9999025, 1.0],
+ [0.0, 1.0, 1.0],
+ [-7.0069995, 0.65731025, 1.0],
+ [-5.66303, 0.7795728, 1.0],
+ [-1.7324567, 0.9417374, 1.0],
+ [-0.008971522, 0.9993332, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.887643, 0.6666744, 1.0],
+ [-3.5603712, 0.8430337, 1.0],
+ [-1.0138998, 0.9716629, 1.0],
+ [-0.04949627, 0.9985733, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.3985267, 0.7247905, 1.0],
+ [-1.2181568, 0.9698552, 1.0],
+ [0.889513, 1.0484955, 1.0],
+ [3.5816274, 1.0411543, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.7034328, 0.7695667, 1.0],
+ [-0.7939945, 0.97303194, 1.0],
+ [4.931476, 1.0582556, 1.0],
+ [7.859906, 1.0638645, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.0874662, 0.82422346, 1.0],
+ [-1.2831166, 0.97711766, 1.0],
+ [2.3523912, 1.0499644, 1.0],
+ [6.217497, 1.0628635, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.7996006, 0.83586675, 1.0],
+ [-1.5887228, 0.9804846, 1.0],
+ [-0.12895624, 1.0526341, 1.0],
+ [1.8886707, 1.0742506, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.4595652, 0.7937858, 1.0],
+ [-1.5304415, 0.9256194, 1.0],
+ [0.49791723, 1.0557373, 1.0],
+ [1.8103733, 1.0766511, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.8303367, 0.74952114, 1.0],
+ [-1.2938573, 0.8971074, 1.0],
+ [-0.48455238, 1.0300556, 1.0],
+ [0.37591517, 1.0647826, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.7799921, 0.69642293, 1.0],
+ [-0.50904167, 0.8235331, 1.0],
+ [-0.7142233, 0.98446524, 1.0],
+ [-0.95801145, 1.0281906, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.12392041, 0.689838, 1.0],
+ [0.14127903, 0.8031821, 1.0],
+ [-0.71944845, 0.98211265, 1.0],
+ [-1.0947827, 1.0201542, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.6446894, 0.6899457, 1.0],
+ [0.84962654, 0.8166399, 1.0],
+ [-0.5199237, 0.9733036, 1.0],
+ [-0.75620574, 1.0087903, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.0502963, 0.68614894, 1.0],
+ [3.0366545, 0.81591415, 1.0],
+ [0.6730178, 0.9554072, 1.0],
+ [-0.007607221, 0.9978117, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.0329676, 0.6941915, 1.0],
+ [5.0321407, 0.81927717, 1.0],
+ [1.6395164, 0.94279826, 1.0],
+ [-0.03492225, 0.9970755, 1.0],
+ [0.0, 1.0, 1.0],
+ [7.5941086, 0.71911955, 1.0],
+ [5.390384, 0.8432774, 1.0],
+ [1.8999803, 0.94711256, 1.0],
+ [0.0023088262, 0.9986426, 1.0],
+ [0.0, 1.0, 1.0],
+ [8.8637085, 0.74631906, 1.0],
+ [6.2989063, 0.85505736, 1.0],
+ [2.735379, 0.94543207, 1.0],
+ [0.06463228, 0.9988073, 1.0],
+ [0.0, 1.0, 1.0],
+ [9.588615, 0.7898098, 1.0],
+ [6.376941, 0.8845687, 1.0],
+ [2.3718834, 0.9601361, 1.0],
+ [0.013906611, 0.9995857, 1.0],
+ [0.0, 1.0, 1.0],
+ [10.782804, 0.83546484, 1.0],
+ [7.3467307, 0.92574006, 1.0],
+ [3.498415, 0.9810678, 1.0],
+ [0.10678408, 0.99972445, 1.0],
+ [0.0, 1.0, 1.0],
+ [10.249238, 0.86850566, 1.0],
+ [7.6262197, 0.94297904, 1.0],
+ [4.036777, 0.9895052, 1.0],
+ [0.16037866, 0.99999046, 1.0],
+ [0.0, 1.0, 1.0],
+ [9.931091, 0.8846399, 1.0],
+ [7.519002, 0.94786286, 1.0],
+ [4.074718, 0.9868774, 1.0],
+ [0.13248277, 1.0000677, 1.0],
+ [0.0, 1.0, 1.0],
+ [9.689591, 0.8865812, 1.0],
+ [7.432243, 0.9456806, 1.0],
+ [4.7070894, 0.983843, 1.0],
+ [0.18639432, 0.9999364, 1.0],
+ [0.0, 1.0, 1.0],
+ [10.043292, 0.8922917, 1.0],
+ [7.8223534, 0.9484012, 1.0],
+ [5.358868, 0.9835407, 1.0],
+ [0.31019974, 1.0000116, 1.0],
+ [0.0, 1.0, 1.0],
+ [10.045664, 0.9007508, 1.0],
+ [7.642868, 0.95041484, 1.0],
+ [5.4579797, 0.9804828, 1.0],
+ [0.39148968, 0.99986535, 1.0],
+ [0.0, 1.0, 1.0],
+ [9.769167, 0.9075519, 1.0],
+ [7.2386246, 0.95056725, 1.0],
+ [5.2662854, 0.976752, 1.0],
+ [0.4462296, 0.9994238, 1.0],
+ [0.0, 1.0, 1.0],
+ [9.814191, 0.9142886, 1.0],
+ [7.2126784, 0.94775087, 1.0],
+ [5.001264, 0.9686987, 1.0],
+ [0.38073996, 0.9989886, 1.0],
+ [0.0, 1.0, 1.0],
+ [10.10585, 0.9291066, 1.0],
+ [7.8851285, 0.9544348, 1.0],
+ [6.079623, 0.96844214, 1.0],
+ [0.6109284, 0.9989012, 1.0],
+ [0.0, 1.0, 1.0],
+ [9.871696, 0.94608027, 1.0],
+ [7.800825, 0.96270525, 1.0],
+ [6.1262703, 0.9673819, 1.0],
+ [0.84064543, 0.997365, 1.0],
+ [0.0, 1.0, 1.0],
+ [9.842098, 0.9636502, 1.0],
+ [7.603165, 0.9709367, 1.0],
+ [5.927902, 0.9726488, 1.0],
+ [2.3444896, 0.9899, 1.0],
+ [0.0, 1.0, 1.0],
+ [9.367189, 0.9913651, 1.0],
+ [7.1136265, 0.9881401, 1.0],
+ [5.5312037, 0.9773815, 1.0],
+ [2.3738406, 0.9883841, 1.0],
+ [0.0, 1.0, 1.0],
+ [8.445051, 1.028042, 1.0],
+ [6.4724793, 1.0249939, 1.0],
+ [5.7201777, 1.02951, 1.0],
+ [2.6472864, 1.022244, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.8764477, 0.92064744, 1.0],
+ [4.063755, 0.9517052, 1.0],
+ [3.4358697, 0.9500948, 1.0],
+ [1.6742893, 0.9403114, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.8074338, 0.9376684, 1.0],
+ [3.1544921, 0.99583703, 1.0],
+ [2.8396516, 1.019307, 1.0],
+ [2.761816, 0.9831139, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.9735732, 0.9055372, 1.0],
+ [1.964001, 0.96994215, 1.0],
+ [1.1794817, 1.0004675, 1.0],
+ [0.23656735, 0.9900725, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.4316142, 0.85408896, 1.0],
+ [1.4779971, 0.9194293, 1.0],
+ [0.36998236, 0.96382165, 1.0],
+ [-0.78495336, 0.9628938, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.7202682, 0.85409456, 1.0],
+ [0.85657984, 0.90512234, 1.0],
+ [-0.034063853, 0.94638556, 1.0],
+ [-2.4795413, 0.9543113, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.1699636, 0.85612476, 1.0],
+ [0.2226863, 0.8977348, 1.0],
+ [-0.55164605, 0.9310963, 1.0],
+ [-2.9155931, 0.9300699, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.9195899, 0.8302417, 1.0],
+ [-0.01082096, 0.86319506, 1.0],
+ [-0.8897206, 0.90112203, 1.0],
+ [-2.8909357, 0.91573393, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.70681113, 0.81225044, 1.0],
+ [-0.2829643, 0.83714634, 1.0],
+ [-1.2980901, 0.8815819, 1.0],
+ [-2.9096456, 0.90339696, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.45244727, 0.8261228, 1.0],
+ [-0.86100143, 0.8436378, 1.0],
+ [-2.2569923, 0.88340265, 1.0],
+ [-3.456668, 0.8849395, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.58151424, 0.81086206, 1.0],
+ [-1.0969541, 0.8453545, 1.0],
+ [-2.3675127, 0.877735, 1.0],
+ [-3.063623, 0.8733827, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.1315448, 0.76973885, 1.0],
+ [-0.7705917, 0.7837816, 1.0],
+ [-1.8659682, 0.8216326, 1.0],
+ [-2.8834462, 0.8402058, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.9416674, 0.7494247, 1.0],
+ [-1.3754656, 0.76372516, 1.0],
+ [-1.4642401, 0.7619152, 1.0],
+ [-1.5173858, 0.72746414, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.9809289, 0.73669344, 1.0],
+ [-1.8224959, 0.76059735, 1.0],
+ [-1.6169965, 0.69803905, 1.0],
+ [-3.6714156, 0.63129187, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.4930844, 0.7037204, 1.0],
+ [-1.7829679, 0.7479987, 1.0],
+ [-1.4971536, 0.70428216, 1.0],
+ [-3.4572604, 0.5994394, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.0422633, 0.6668609, 1.0],
+ [-2.1922023, 0.74247324, 1.0],
+ [-1.6100665, 0.78744763, 1.0],
+ [-1.2063087, 0.78941995, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.2440093, 0.6231938, 1.0],
+ [-2.0041919, 0.73567647, 1.0],
+ [-0.86709636, 0.8052706, 1.0],
+ [-0.4469063, 0.8399889, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.5079556, 0.6042883, 1.0],
+ [-2.3472598, 0.7395057, 1.0],
+ [-0.054738186, 0.82054764, 1.0],
+ [0.41392553, 0.85697615, 1.0],
+ [0.0, 1.0, 1.0],
+ [-6.4233527, 0.63508, 1.0],
+ [-1.9719031, 0.75168645, 1.0],
+ [0.4196573, 0.8112182, 1.0],
+ [1.0182172, 0.85615927, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.9163876, 0.7051664, 1.0],
+ [-1.4782107, 0.7433952, 1.0],
+ [-0.40683872, 0.8031918, 1.0],
+ [-0.4788465, 0.8863584, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.908668, 0.785487, 1.0],
+ [-0.9831386, 0.79933167, 1.0],
+ [-4.1048765, 0.81978315, 1.0],
+ [-11.719547, 0.7330626, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.066866, 0.8465281, 1.0],
+ [-1.0374848, 0.8885559, 1.0],
+ [-13.052467, 0.68214613, 1.0],
+ [-21.323591, 0.57109445, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.7582388, 0.8883443, 1.0],
+ [0.5189229, 0.91708815, 1.0],
+ [-0.7883722, 0.7302985, 1.0],
+ [-6.2000527, 0.6300695, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.8354056, 0.9174532, 1.0],
+ [2.34739, 0.910805, 1.0],
+ [9.42942, 0.74594164, 1.0],
+ [11.120875, 0.67275065, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.2180405, 0.92904836, 1.0],
+ [1.5843687, 0.9051751, 1.0],
+ [12.040191, 0.7458105, 1.0],
+ [14.777283, 0.7015992, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.2773137, 0.935868, 1.0],
+ [1.4081348, 0.9290241, 1.0],
+ [9.324613, 0.8047278, 1.0],
+ [8.094886, 0.833392, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.0836408, 0.9049924, 1.0],
+ [5.6842046, 0.8789433, 1.0],
+ [10.288447, 0.81913996, 1.0],
+ [6.26537, 0.92870164, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.6201372, 0.9030173, 1.0],
+ [2.3488736, 0.92669946, 1.0],
+ [3.109163, 0.9226741, 1.0],
+ [0.50225174, 0.9823943, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.0695627, 0.8845679, 1.0],
+ [1.9500862, 0.95027864, 1.0],
+ [1.1459787, 0.8927759, 1.0],
+ [-1.3981293, 0.94081604, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.2040372, 0.8723052, 1.0],
+ [1.018672, 0.96575177, 1.0],
+ [1.4323145, 0.95249796, 1.0],
+ [-0.18759486, 0.9809426, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.4124901, 0.850615, 1.0],
+ [-0.05903271, 0.97407734, 1.0],
+ [0.12506364, 0.9955236, 1.0],
+ [-0.016985215, 0.9992087, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.8181849, 0.844787, 1.0],
+ [-0.22574311, 0.9738625, 1.0],
+ [0.1223641, 0.9969789, 1.0],
+ [0.0039951545, 0.99990684, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.4693308, 0.8397684, 1.0],
+ [-0.39281702, 0.9756446, 1.0],
+ [0.02598722, 0.999133, 1.0],
+ [0.0010384705, 0.999975, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.6781962, 0.85489655, 1.0],
+ [-0.38995895, 0.9785057, 1.0],
+ [-0.0037281574, 0.999837, 1.0],
+ [
+ 1.6256523e-06,
+ 0.9999995,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-3.335379, 0.87124074, 1.0],
+ [-0.48180598, 0.9825704, 1.0],
+ [-0.005992216, 0.99981517, 1.0],
+ [
+ -1.742749e-05,
+ 0.9999991,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-4.184813, 0.85200375, 1.0],
+ [-0.6187777, 0.9787277, 1.0],
+ [0.0022274007, 0.9996338, 1.0],
+ [0.00046732937, 0.9999927, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.3020663, 0.83856326, 1.0],
+ [-0.4638571, 0.97142124, 1.0],
+ [0.7337303, 0.990346, 1.0],
+ [0.0751591, 0.9990356, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.3410516, 0.80858886, 1.0],
+ [0.40069363, 0.95397055, 1.0],
+ [2.218948, 0.9716582, 1.0],
+ [0.2358686, 0.99697757, 1.0],
+ [0.0, 1.0, 1.0],
+ [-6.416645, 0.7545048, 1.0],
+ [-1.2956624, 0.939975, 1.0],
+ [0.12502326, 0.9976219, 1.0],
+ [0.013717032, 0.9998224, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.273487, 0.72121274, 1.0],
+ [-1.9356983, 0.91340506, 1.0],
+ [0.12353294, 0.9965626, 1.0],
+ [0.004541849, 0.99992204, 1.0],
+ [0.0, 1.0, 1.0],
+ [-8.028335, 0.684168, 1.0],
+ [-1.6612952, 0.8862172, 1.0],
+ [0.61427313, 0.9880001, 1.0],
+ [0.020742713, 0.99964684, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.686304, 0.6284216, 1.0],
+ [-2.6316142, 0.840866, 1.0],
+ [0.030721515, 0.9969365, 1.0],
+ [0.0030909944, 0.9999094, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.3644114, 0.5920495, 1.0],
+ [-2.1064131, 0.7581428, 1.0],
+ [0.08250783, 0.9909103, 1.0],
+ [0.004092879, 0.99986243, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.6098745, 0.564251, 1.0],
+ [-3.0388198, 0.6718815, 1.0],
+ [-0.6501952, 0.9627276, 1.0],
+ [-0.006046299, 0.9997709, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.3306668, 0.5407966, 1.0],
+ [-4.1029615, 0.6064727, 1.0],
+ [-2.8782537, 0.8844327, 1.0],
+ [-0.073128805, 0.9976695, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.6157587, 0.5336421, 1.0],
+ [-4.0551357, 0.6346509, 1.0],
+ [-4.1803465, 0.81513524, 1.0],
+ [-0.43286413, 0.9813739, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.80694, 0.55238813, 1.0],
+ [-2.9400527, 0.75945127, 1.0],
+ [-1.957085, 0.91473925, 1.0],
+ [0.23186429, 0.98100156, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.2804413, 0.5958404, 1.0],
+ [-2.4060252, 0.811464, 1.0],
+ [2.1061044, 0.98812366, 1.0],
+ [5.310169, 1.0376091, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.648095, 0.6832738, 1.0],
+ [-2.096382, 0.89832383, 1.0],
+ [-0.57513076, 0.9792816, 1.0],
+ [1.4691317, 1.0191329, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.191342, 0.7055293, 1.0],
+ [-1.838788, 0.9088253, 1.0],
+ [-0.9401996, 0.9815046, 1.0],
+ [1.2051747, 1.0288774, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.2887207, 0.6609743, 1.0],
+ [-1.1732396, 0.80482507, 1.0],
+ [0.4986351, 0.99515104, 1.0],
+ [1.5360615, 1.0385249, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.40449002, 0.6293669, 1.0],
+ [-0.38308343, 0.7883763, 1.0],
+ [-0.03740224, 0.9558314, 1.0],
+ [0.6230026, 1.0127206, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.6413921, 0.6115583, 1.0],
+ [1.5963948, 0.72960037, 1.0],
+ [0.6866751, 0.8849578, 1.0],
+ [0.2589341, 0.9375699, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.4193525, 0.6249198, 1.0],
+ [2.7416816, 0.7095477, 1.0],
+ [1.3268055, 0.84555423, 1.0],
+ [0.3328897, 0.9368017, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.1844013, 0.6277898, 1.0],
+ [2.8372567, 0.71899444, 1.0],
+ [1.2267267, 0.85043055, 1.0],
+ [0.04637693, 0.97700346, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.5027733, 0.6327071, 1.0],
+ [3.217072, 0.7416387, 1.0],
+ [1.577103, 0.8646953, 1.0],
+ [0.19375536, 0.9870552, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.149598, 0.64911604, 1.0],
+ [4.1019955, 0.74544936, 1.0],
+ [1.431085, 0.8806098, 1.0],
+ [0.047591608, 0.993709, 1.0],
+ [0.0, 1.0, 1.0],
+ [7.1056666, 0.67726934, 1.0],
+ [4.2305317, 0.78717184, 1.0],
+ [1.067521, 0.92705023, 1.0],
+ [0.015493356, 0.997994, 1.0],
+ [0.0, 1.0, 1.0],
+ [7.474384, 0.7088058, 1.0],
+ [3.5543373, 0.80445033, 1.0],
+ [0.19845833, 0.95335525, 1.0],
+ [-0.025593635, 0.999446, 1.0],
+ [0.0, 1.0, 1.0],
+ [7.1915207, 0.7416353, 1.0],
+ [3.4266174, 0.83173305, 1.0],
+ [-0.5223778, 0.9669094, 1.0],
+ [-0.15612096, 0.99790776, 1.0],
+ [0.0, 1.0, 1.0],
+ [7.5696697, 0.77016944, 1.0],
+ [3.989671, 0.85700655, 1.0],
+ [-1.2651324, 0.954754, 1.0],
+ [-0.73263675, 0.9871268, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.1636243, 0.7912714, 1.0],
+ [3.4775562, 0.8605731, 1.0],
+ [0.2865357, 0.98138404, 1.0],
+ [-0.07876425, 0.9983868, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.3284674, 0.805469, 1.0],
+ [3.75536, 0.8683642, 1.0],
+ [0.60794705, 0.98088616, 1.0],
+ [0.008510712, 0.9998971, 1.0],
+ [0.0, 1.0, 1.0],
+ [7.4278216, 0.8018612, 1.0],
+ [4.286087, 0.863598, 1.0],
+ [0.393694, 0.9731425, 1.0],
+ [-0.026216365, 0.999259, 1.0],
+ [0.0, 1.0, 1.0],
+ [7.8491583, 0.8168845, 1.0],
+ [4.428734, 0.8584228, 1.0],
+ [-0.6927958, 0.9467803, 1.0],
+ [-0.17571971, 0.9967572, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.8759727, 0.80086964, 1.0],
+ [4.1140614, 0.8347237, 1.0],
+ [1.008583, 0.9550637, 1.0],
+ [0.01853103, 0.99938357, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.002123, 0.7990564, 1.0],
+ [3.352922, 0.81814086, 1.0],
+ [1.1990601, 0.94414854, 1.0],
+ [0.047310542, 0.9994817, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.735746, 0.79181194, 1.0],
+ [2.7306662, 0.7838909, 1.0],
+ [0.5309012, 0.8979575, 1.0],
+ [-0.041853912, 0.99701124, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.215762, 0.7899453, 1.0],
+ [3.104759, 0.7658282, 1.0],
+ [1.124937, 0.8786092, 1.0],
+ [0.021710817, 0.997453, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.1507196, 0.79735833, 1.0],
+ [3.023819, 0.7685839, 1.0],
+ [1.5032152, 0.8632985, 1.0],
+ [0.11779032, 0.9967827, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.1006694, 0.80814075, 1.0],
+ [3.1378238, 0.78737235, 1.0],
+ [1.8207941, 0.86908215, 1.0],
+ [-1.1852233, 0.9505832, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.903391, 0.8252152, 1.0],
+ [2.9287477, 0.79821754, 1.0],
+ [0.5213138, 0.83226186, 1.0],
+ [-3.60744, 0.8885766, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.579378, 0.86621565, 1.0],
+ [3.267353, 0.85239756, 1.0],
+ [1.4851551, 0.86720455, 1.0],
+ [-0.043647174, 0.96538955, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.251857, 0.8048092, 1.0],
+ [3.868587, 0.73451906, 1.0],
+ [7.095376, 0.7049663, 1.0],
+ [4.2607813, 0.8634737, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.7544513, 0.81845486, 1.0],
+ [6.617525, 0.80338174, 1.0],
+ [12.432594, 0.7366481, 1.0],
+ [8.489516, 0.81540203, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.860258, 0.80569935, 1.0],
+ [4.0649204, 0.84190506, 1.0],
+ [8.932321, 0.78642964, 1.0],
+ [4.9175606, 0.85279024, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.7026193, 0.7538319, 1.0],
+ [2.5008564, 0.812968, 1.0],
+ [3.5890124, 0.8474743, 1.0],
+ [2.7400997, 0.859288, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.3664002, 0.7394628, 1.0],
+ [1.7941258, 0.78985816, 1.0],
+ [1.1166536, 0.8809234, 1.0],
+ [0.11488261, 0.9726579, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.4183512, 0.7125459, 1.0],
+ [1.8356653, 0.76030463, 1.0],
+ [1.5251784, 0.8337058, 1.0],
+ [-0.23323414, 0.96463525, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.1229203, 0.6823615, 1.0],
+ [1.7064534, 0.7226445, 1.0],
+ [1.2662644, 0.78341854, 1.0],
+ [-0.34037447, 0.921522, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.922368, 0.670744, 1.0],
+ [1.3548895, 0.71063024, 1.0],
+ [0.73747265, 0.767286, 1.0],
+ [-0.32575443, 0.8069876, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.468624, 0.6560331, 1.0],
+ [1.1733623, 0.7006847, 1.0],
+ [0.91913277, 0.77642107, 1.0],
+ [1.3170806, 0.78835416, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.7764182, 0.62865984, 1.0],
+ [1.8569264, 0.67932403, 1.0],
+ [0.09585123, 0.8226712, 1.0],
+ [0.114856094, 0.89764315, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.6984649, 0.5723032, 1.0],
+ [3.1778703, 0.5982749, 1.0],
+ [1.6580566, 0.7152358, 1.0],
+ [0.38139626, 0.8871746, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.49222204, 0.556624, 1.0],
+ [2.705092, 0.5625561, 1.0],
+ [-0.2695512, 0.56588423, 1.0],
+ [-2.8712876, 0.6261747, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.27390626, 0.6263597, 1.0],
+ [4.712818, 0.58389944, 1.0],
+ [-1.3854016, 0.5315325, 1.0],
+ [-6.950099, 0.5193624, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.50358576, 0.6688169, 1.0],
+ [4.557341, 0.7064863, 1.0],
+ [-0.214069, 0.5653314, 1.0],
+ [-4.7740607, 0.5246774, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.4929795, 0.6411706, 1.0],
+ [-1.6451489, 0.8176667, 1.0],
+ [0.34974566, 0.8819101, 1.0],
+ [-0.32462633, 0.8140249, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.7058835, 0.59305847, 1.0],
+ [-1.4331628, 0.818264, 1.0],
+ [-1.1053259, 0.91194034, 1.0],
+ [-3.1190734, 0.90261114, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.1035285, 0.5795477, 1.0],
+ [-2.1045144, 0.7871938, 1.0],
+ [-6.331526, 0.7170312, 1.0],
+ [-10.788408, 0.664596, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.7934265, 0.6102454, 1.0],
+ [-3.6331925, 0.7699751, 1.0],
+ [-3.415193, 0.68833, 1.0],
+ [-3.7475498, 0.64053464, 1.0],
+ [0.0, 1.0, 1.0],
+ [-5.1393547, 0.6529689, 1.0],
+ [-5.753634, 0.708333, 1.0],
+ [-5.8755636, 0.6708419, 1.0],
+ [-3.0788336, 0.67883736, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.6082006, 0.7062166, 1.0],
+ [-6.588885, 0.72459567, 1.0],
+ [-7.484304, 0.6874229, 1.0],
+ [-13.227061, 0.65968275, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.7472286, 0.78321165, 1.0],
+ [-7.7237554, 0.7579945, 1.0],
+ [-14.800508, 0.60856223, 1.0],
+ [-19.890417, 0.54714346, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.57658, 0.81595886, 1.0],
+ [4.2668858, 0.7361184, 1.0],
+ [1.7692925, 0.6172541, 1.0],
+ [-2.8298097, 0.5611174, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.9543127, 0.84595275, 1.0],
+ [9.600064, 0.7089187, 1.0],
+ [13.987817, 0.61992806, 1.0],
+ [14.091181, 0.5941112, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.4278858, 0.898544, 1.0],
+ [3.9224489, 0.8269029, 1.0],
+ [14.653015, 0.6571721, 1.0],
+ [15.524843, 0.62407255, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.7066278, 0.91855973, 1.0],
+ [4.9186754, 0.8462665, 1.0],
+ [14.977205, 0.6851345, 1.0],
+ [11.15482, 0.7066212, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.253572, 0.9393247, 1.0],
+ [5.97083, 0.88211775, 1.0],
+ [9.293864, 0.8153296, 1.0],
+ [3.8026578, 0.90733314, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.71835244, 0.9160959, 1.0],
+ [2.0100238, 0.91115075, 1.0],
+ [3.6926022, 0.90838015, 1.0],
+ [0.7498695, 0.97529143, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.24475421, 0.8969049, 1.0],
+ [1.8341327, 0.92656446, 1.0],
+ [3.3835375, 0.8649656, 1.0],
+ [0.05131399, 0.9339182, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.3472424, 0.9435528, 1.0],
+ [1.3106259, 0.955991, 1.0],
+ [-1.6902066, 0.89264774, 1.0],
+ [-2.4834325, 0.9318436, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.1349813, 0.96349823, 1.0],
+ [3.2832685, 0.92533123, 1.0],
+ [1.5165656, 0.94987315, 1.0],
+ [-0.21866378, 0.9915875, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.1588628, 0.96242386, 1.0],
+ [4.3031096, 0.8986765, 1.0],
+ [2.426284, 0.9446792, 1.0],
+ [0.0851209, 0.998018, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.21821694, 0.97361916, 1.0],
+ [1.0029128, 0.96765107, 1.0],
+ [0.6752989, 0.98369634, 1.0],
+ [0.024960788, 0.9994141, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.142324, 0.9429116, 1.0],
+ [-0.08796144, 0.9916399, 1.0],
+ [-0.021721277, 0.99964666, 1.0],
+ [
+ 5.6598337e-06,
+ 0.9999943,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [0.42995253, 0.95589715, 1.0],
+ [-0.1302341, 0.99092823, 1.0],
+ [-0.014143473, 0.99967444, 1.0],
+ [
+ -6.91973e-05,
+ 0.9999987,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-0.6089942, 0.95499057, 1.0],
+ [-0.2533017, 0.9897083, 1.0],
+ [-0.01806424, 0.9994577, 1.0],
+ [0.00011731918, 0.9999946, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.41291016, 0.9776058, 1.0],
+ [0.20236667, 0.99470246, 1.0],
+ [0.38761485, 0.9949851, 1.0],
+ [0.038843628, 0.99950147, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.44329658, 0.9657365, 1.0],
+ [0.7018292, 0.98709005, 1.0],
+ [1.1931813, 0.98478085, 1.0],
+ [0.12222669, 0.9984329, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.563516, 0.94921166, 1.0],
+ [-0.04889182, 0.9925632, 1.0],
+ [0.071504414, 0.9989917, 1.0],
+ [0.007119859, 0.99990845, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.4962281, 0.8972278, 1.0],
+ [0.15459283, 0.9727671, 1.0],
+ [0.23805064, 0.99562365, 1.0],
+ [0.0077146757, 0.9998695, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.4667382, 0.8505024, 1.0],
+ [2.129667, 0.9446288, 1.0],
+ [1.1396877, 0.98031807, 1.0],
+ [0.037179574, 0.9993321, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.781338, 0.73189, 1.0],
+ [1.1288502, 0.93383217, 1.0],
+ [0.53486246, 0.9789267, 1.0],
+ [0.12766436, 0.9938904, 1.0],
+ [0.0, 1.0, 1.0],
+ [-6.5518723, 0.62532294, 1.0],
+ [0.68554205, 0.8983733, 1.0],
+ [0.57966214, 0.96441585, 1.0],
+ [0.20091358, 0.98821115, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.343184, 0.5767734, 1.0],
+ [-1.3167001, 0.8343934, 1.0],
+ [0.050232526, 0.99427885, 1.0],
+ [0.0041727684, 0.9994736, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.2084956, 0.5238724, 1.0],
+ [-3.5061476, 0.6398532, 1.0],
+ [-0.76263064, 0.96318024, 1.0],
+ [-0.01846399, 0.9992613, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.0521426, 0.50757915, 1.0],
+ [-4.9473753, 0.56081027, 1.0],
+ [-3.688444, 0.8328823, 1.0],
+ [-0.18685617, 0.99239963, 1.0],
+ [0.0, 1.0, 1.0],
+ [-4.2548413, 0.50943226, 1.0],
+ [-4.365784, 0.6119638, 1.0],
+ [-3.4715931, 0.8569653, 1.0],
+ [-0.3267715, 0.9877998, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.9077795, 0.5238228, 1.0],
+ [-3.707032, 0.67760634, 1.0],
+ [-2.9548562, 0.87688464, 1.0],
+ [-0.83595866, 0.9781673, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.8748026, 0.59341663, 1.0],
+ [-3.0543437, 0.8072005, 1.0],
+ [-3.223465, 0.8836812, 1.0],
+ [-2.364663, 0.9595582, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.5147824, 0.5750547, 1.0],
+ [-2.7698386, 0.72502124, 1.0],
+ [-2.2416823, 0.87920475, 1.0],
+ [-0.027188452, 0.96920735, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.3069018, 0.5413889, 1.0],
+ [-1.3765919, 0.6018836, 1.0],
+ [-0.23360418, 0.9007077, 1.0],
+ [0.44479728, 0.97555995, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.36504295, 0.5780787, 1.0],
+ [0.102457404, 0.6328473, 1.0],
+ [0.9009599, 0.8177817, 1.0],
+ [0.7650351, 0.900801, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.2459548, 0.6080276, 1.0],
+ [2.6340177, 0.6246803, 1.0],
+ [2.156162, 0.7627034, 1.0],
+ [1.6283973, 0.8479486, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.3978112, 0.6188074, 1.0],
+ [2.9135118, 0.5810156, 1.0],
+ [2.4169562, 0.67806387, 1.0],
+ [0.88490367, 0.9132394, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.6887186, 0.6619631, 1.0],
+ [2.912676, 0.61564636, 1.0],
+ [2.531864, 0.7293305, 1.0],
+ [0.44374037, 0.97021365, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.5004687, 0.7542064, 1.0],
+ [2.6793096, 0.72422296, 1.0],
+ [1.9161915, 0.8289456, 1.0],
+ [0.20282242, 0.98497564, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.6729927, 0.81156904, 1.0],
+ [1.9421659, 0.77600074, 1.0],
+ [0.16385932, 0.8949159, 1.0],
+ [0.00030535206, 0.99532956, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.78105825, 0.8560677, 1.0],
+ [0.10841931, 0.88307595, 1.0],
+ [-0.057357736, 0.968077, 1.0],
+ [-0.0019446412, 0.9990688, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.46066833, 0.862668, 1.0],
+ [-0.5855417, 0.8481995, 1.0],
+ [-0.05946241, 0.96510565, 1.0],
+ [-0.00071739085, 0.9993665, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.67381376, 0.87415904, 1.0],
+ [0.24980347, 0.875317, 1.0],
+ [-0.49778527, 0.9727575, 1.0],
+ [-0.17107673, 0.996763, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.0746922, 0.91508603, 1.0],
+ [-1.2298028, 0.89923114, 1.0],
+ [-3.1673076, 0.9375756, 1.0],
+ [-1.0668249, 0.9814089, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.26751196, 0.9166898, 1.0],
+ [-0.44020677, 0.9547969, 1.0],
+ [-0.52170914, 0.9885321, 1.0],
+ [-0.13126421, 0.99768424, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.475818, 0.9037874, 1.0],
+ [0.21531126, 0.94464564, 1.0],
+ [-0.20985661, 0.99219394, 1.0],
+ [-0.009496927, 0.99976933, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.0757535, 0.907198, 1.0],
+ [-1.7803211, 0.9137079, 1.0],
+ [-1.6718693, 0.9619583, 1.0],
+ [-0.09668204, 0.9980284, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.4640148, 0.89358455, 1.0],
+ [-2.225918, 0.8979506, 1.0],
+ [-3.6723483, 0.93562967, 1.0],
+ [-0.34880057, 0.99417984, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.519818, 0.8728914, 1.0],
+ [-0.15754203, 0.9012406, 1.0],
+ [-0.82398623, 0.97935987, 1.0],
+ [-0.07315472, 0.998723, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.08069864, 0.8122788, 1.0],
+ [-1.1186706, 0.80712515, 1.0],
+ [-0.32798508, 0.97505164, 1.0],
+ [-0.013122095, 0.99961483, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.58201814, 0.7335289, 1.0],
+ [-2.675131, 0.7052787, 1.0],
+ [-3.4144542, 0.8866787, 1.0],
+ [-0.34905022, 0.99161583, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.20724466, 0.6997621, 1.0],
+ [-1.3870329, 0.6642682, 1.0],
+ [-2.3293695, 0.8806666, 1.0],
+ [-0.22909772, 0.9941094, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.38550782, 0.6612821, 1.0],
+ [-2.025744, 0.6200057, 1.0],
+ [-1.6411146, 0.8548279, 1.0],
+ [-0.15217084, 0.9952627, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.2272899, 0.6092909, 1.0],
+ [-2.3132815, 0.57801414, 1.0],
+ [-4.862611, 0.7366766, 1.0],
+ [-4.110943, 0.9027854, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.5921729, 0.6219872, 1.0],
+ [-1.8060966, 0.59441036, 1.0],
+ [-5.2765656, 0.6901705, 1.0],
+ [-5.4457603, 0.8478828, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.6031663, 0.71592474, 1.0],
+ [-3.0251799, 0.6357208, 1.0],
+ [-2.314684, 0.7075568, 1.0],
+ [-0.55420774, 0.92152005, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.9571505, 0.96764106, 1.0],
+ [2.2270002, 0.93239284, 1.0],
+ [4.9888797, 0.8701656, 1.0],
+ [5.671471, 0.871926, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.6529764, 0.9643956, 1.0],
+ [3.7584913, 0.91487896, 1.0],
+ [8.399242, 0.8215937, 1.0],
+ [7.2129803, 0.84500617, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.71301705, 0.95700616, 1.0],
+ [3.5324132, 0.91654754, 1.0],
+ [7.481569, 0.83848864, 1.0],
+ [3.3779297, 0.9219365, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.1027052, 0.9415878, 1.0],
+ [3.9730468, 0.90671736, 1.0],
+ [2.484185, 0.9380085, 1.0],
+ [0.4668634, 0.98106366, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.8107373, 0.95316005, 1.0],
+ [2.2031755, 0.9406371, 1.0],
+ [0.9073857, 0.97643065, 1.0],
+ [0.074082, 0.9965095, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.9055933, 0.9425168, 1.0],
+ [2.002828, 0.9310888, 1.0],
+ [1.4750477, 0.9285675, 1.0],
+ [0.8887092, 0.93962455, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.262398, 0.89178324, 1.0],
+ [4.3024693, 0.8579417, 1.0],
+ [3.0110278, 0.89283866, 1.0],
+ [0.7838177, 0.94552124, 1.0],
+ [0.0, 1.0, 1.0],
+ [3.9675722, 0.85747033, 1.0],
+ [2.8633819, 0.8719699, 1.0],
+ [1.8338549, 0.8609559, 1.0],
+ [-0.20613429, 0.8572672, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.608068, 0.8017892, 1.0],
+ [2.561957, 0.8876735, 1.0],
+ [0.74627113, 0.9300394, 1.0],
+ [-0.3343548, 0.9052658, 1.0],
+ [0.0, 1.0, 1.0],
+ [6.1944766, 0.7262175, 1.0],
+ [4.09076, 0.79534775, 1.0],
+ [0.22131784, 0.9268037, 1.0],
+ [-0.41391882, 0.8326499, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.064076, 0.70480764, 1.0],
+ [6.553729, 0.66739255, 1.0],
+ [1.9781996, 0.8487641, 1.0],
+ [-0.121733144, 0.9128515, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.1924176, 0.7348373, 1.0],
+ [6.2945476, 0.59355253, 1.0],
+ [2.6655605, 0.603478, 1.0],
+ [-0.39269662, 0.750913, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.218477, 0.84765875, 1.0],
+ [6.473049, 0.63901764, 1.0],
+ [2.3956764, 0.55744296, 1.0],
+ [-4.668969, 0.5518637, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.4455693, 0.9488281, 1.0],
+ [2.86164, 0.8824624, 1.0],
+ [-0.83002204, 0.66669524, 1.0],
+ [-5.9115415, 0.5832427, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.36723047, 0.958581, 1.0],
+ [-0.9645545, 0.9544808, 1.0],
+ [-0.22951192, 0.9522579, 1.0],
+ [-0.5863564, 0.93541694, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.2512479, 0.9246585, 1.0],
+ [-0.7575997, 0.8948588, 1.0],
+ [-0.021703921, 0.8694443, 1.0],
+ [-1.218604, 0.917733, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.7749454, 0.96086174, 1.0],
+ [-1.9861422, 0.88336587, 1.0],
+ [-2.37847, 0.7608838, 1.0],
+ [-4.573041, 0.7435837, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.47515392, 0.96518725, 1.0],
+ [-1.1331214, 0.9392888, 1.0],
+ [-1.6403626, 0.82869226, 1.0],
+ [-0.24246822, 0.77455837, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.11114399, 0.92392385, 1.0],
+ [-0.93227565, 0.86591965, 1.0],
+ [0.2098795, 0.77408874, 1.0],
+ [4.1791086, 0.76218206, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.3893674, 0.9528764, 1.0],
+ [0.30993137, 0.88314533, 1.0],
+ [0.1185184, 0.7693184, 1.0],
+ [-5.573007, 0.7812261, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.3838659, 0.979484, 1.0],
+ [-1.1585885, 0.9468169, 1.0],
+ [-6.0387793, 0.80375, 1.0],
+ [-10.658616, 0.6974271, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.61331856, 0.96465194, 1.0],
+ [3.4350507, 0.88050884, 1.0],
+ [4.5260396, 0.7807682, 1.0],
+ [4.9431553, 0.6703685, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.6757144, 0.9519377, 1.0],
+ [3.6062489, 0.79651386, 1.0],
+ [8.332298, 0.73199075, 1.0],
+ [11.87797, 0.70557594, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.2273861, 0.95738363, 1.0],
+ [-3.0259614, 0.86301917, 1.0],
+ [4.467369, 0.7776754, 1.0],
+ [10.518545, 0.73643816, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.0061618, 0.9638054, 1.0],
+ [-0.028563451, 0.8975071, 1.0],
+ [4.998733, 0.8193289, 1.0],
+ [0.36073107, 0.7914785, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.01982086, 0.9933504, 1.0],
+ [0.64965045, 0.97897077, 1.0],
+ [-0.023847675, 0.92317396, 1.0],
+ [-5.3040524, 0.8655476, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.7715526, 0.9489036, 1.0],
+ [-1.1035258, 0.9647486, 1.0],
+ [-0.073881075, 0.9852907, 1.0],
+ [-0.5774172, 0.98273665, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.47609085, 0.9533993, 1.0],
+ [-0.4830082, 0.9705133, 1.0],
+ [0.31816888, 0.98172605, 1.0],
+ [0.09138787, 0.9937235, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.7656475, 0.9784108, 1.0],
+ [1.1379092, 0.9705946, 1.0],
+ [-0.40117097, 0.9814611, 1.0],
+ [-0.43116137, 0.98941934, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.96292067, 0.9768109, 1.0],
+ [1.9278172, 0.95556206, 1.0],
+ [0.4385697, 0.9872155, 1.0],
+ [-0.031007234, 0.9986333, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.5708252, 0.977658, 1.0],
+ [0.44023803, 0.94770473, 1.0],
+ [0.6502315, 0.98133624, 1.0],
+ [0.023992987, 0.99942213, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.115171, 0.97515327, 1.0],
+ [-1.192545, 0.96181446, 1.0],
+ [0.07483103, 0.9923174, 1.0],
+ [0.0070394175, 0.99980354, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.0444499, 0.98199964, 1.0],
+ [-2.1357777, 0.9839715, 1.0],
+ [-0.24349906, 0.99832034, 1.0],
+ [-0.0015810649, 0.9999879, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.7282641, 0.9509198, 1.0],
+ [-1.8230197, 0.96066856, 1.0],
+ [-0.12977597, 0.9974213, 1.0],
+ [-0.00060932315, 0.99998885, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.1258417, 0.9449696, 1.0],
+ [-1.9602667, 0.95090944, 1.0],
+ [-0.17851818, 0.9957969, 1.0],
+ [-0.00095068174, 0.9999777, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.020462122, 0.9958111, 1.0],
+ [-0.103778236, 0.99696887, 1.0],
+ [-0.007417411, 0.999726, 1.0],
+ [0.00020599048, 0.99999535, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.00027978048, 0.9997287, 1.0],
+ [0.0051376326, 0.9999058, 1.0],
+ [0.008358893, 0.99989253, 1.0],
+ [0.0008256137, 0.9999894, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.002835088, 0.9997184, 1.0],
+ [-0.00021424032, 0.9999535, 1.0],
+ [0.0004976028, 0.99999297, 1.0],
+ [
+ 4.8389087e-05,
+ 0.9999994,
+ 1.0
+ ],
+ [0.0, 1.0, 1.0],
+ [-0.015410396, 0.99877673, 1.0],
+ [0.010854519, 0.9995375, 1.0],
+ [0.0058286395, 0.9998951, 1.0],
+ [0.00017566227, 0.999997, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.15632677, 0.99270207, 1.0],
+ [0.5055092, 0.9882981, 1.0],
+ [0.09153723, 0.99786544, 1.0],
+ [0.0029066182, 0.9998918, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.3650484, 0.975003, 1.0],
+ [1.0115135, 0.9615986, 1.0],
+ [0.085873, 0.96660244, 1.0],
+ [0.11893891, 0.99087006, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.7944044, 0.95407045, 1.0],
+ [-1.6605172, 0.94695765, 1.0],
+ [-2.2631047, 0.9047574, 1.0],
+ [-0.049214106, 0.97751004, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.1152909, 0.90077907, 1.0],
+ [-0.33290938, 0.98415893, 1.0],
+ [-0.4078292, 0.98850363, 1.0],
+ [-0.035879742, 0.9983943, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.5095966, 0.73562944, 1.0],
+ [-1.7563529, 0.92154866, 1.0],
+ [-0.6108345, 0.9851728, 1.0],
+ [-0.017620184, 0.9995598, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.7723317, 0.584941, 1.0],
+ [-3.2452734, 0.78533214, 1.0],
+ [-1.7837398, 0.94404143, 1.0],
+ [-0.27508318, 0.9898979, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.912875, 0.56384224, 1.0],
+ [-2.3403068, 0.76557755, 1.0],
+ [-2.328987, 0.90948534, 1.0],
+ [-0.72994906, 0.9717644, 1.0],
+ [0.0, 1.0, 1.0],
+ [-3.6593647, 0.61010647, 1.0],
+ [-2.3331697, 0.79162896, 1.0],
+ [-1.2066433, 0.8530142, 1.0],
+ [-0.5269507, 0.9523624, 1.0],
+ [0.0, 1.0, 1.0],
+ [-2.3541603, 0.7243888, 1.0],
+ [-2.905786, 0.7482802, 1.0],
+ [-2.578955, 0.7870731, 1.0],
+ [-0.48410028, 0.9308829, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.8553971, 0.7095888, 1.0],
+ [-3.4988222, 0.7074026, 1.0],
+ [-2.3901432, 0.84288883, 1.0],
+ [-2.6604743, 0.8977006, 1.0],
+ [0.0, 1.0, 1.0],
+ [-1.1934421, 0.68950033, 1.0],
+ [-1.7103893, 0.6494678, 1.0],
+ [-2.905974, 0.830583, 1.0],
+ [-3.459951, 0.88124573, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.9005052, 0.7782541, 1.0],
+ [0.25286996, 0.58092237, 1.0],
+ [3.6827495, 0.60284424, 1.0],
+ [1.3592215, 0.71437454, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.7498083, 0.6792007, 1.0],
+ [5.0188756, 0.5581819, 1.0],
+ [6.5940504, 0.59141374, 1.0],
+ [5.501629, 0.72167873, 1.0],
+ [0.0, 1.0, 1.0],
+ [11.003128, 0.6373507, 1.0],
+ [10.752663, 0.5938981, 1.0],
+ [5.730028, 0.70469797, 1.0],
+ [1.2751229, 0.91109717, 1.0],
+ [0.0, 1.0, 1.0],
+ [8.977964, 0.6975431, 1.0],
+ [8.752965, 0.70448804, 1.0],
+ [5.0871115, 0.8300587, 1.0],
+ [1.5494138, 0.9552021, 1.0],
+ [0.0, 1.0, 1.0],
+ [5.414441, 0.82363, 1.0],
+ [3.8264759, 0.86136043, 1.0],
+ [1.5306748, 0.9412156, 1.0],
+ [0.11795472, 0.99586946, 1.0],
+ [0.0, 1.0, 1.0],
+ [4.8325124, 0.9293107, 1.0],
+ [1.3051184, 0.8975878, 1.0],
+ [-0.40878016, 0.95939577, 1.0],
+ [-0.022907255, 0.99856335, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.3105308, 0.95905125, 1.0],
+ [-0.39071774, 0.9465973, 1.0],
+ [-0.46491575, 0.9753931, 1.0],
+ [-0.017971309, 0.9991197, 1.0],
+ [0.0, 1.0, 1.0],
+ [1.1686826, 0.94627494, 1.0],
+ [-0.10396043, 0.96546596, 1.0],
+ [-0.020627985, 0.9919678, 1.0],
+ [-0.00016111118, 0.99983495, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.9874247, 0.97295517, 1.0],
+ [-0.12256643, 0.97974455, 1.0],
+ [-0.035051867, 0.996143, 1.0],
+ [-0.0033689938, 0.99987954, 1.0],
+ [0.0, 1.0, 1.0],
+ [2.1092582, 0.97678125, 1.0],
+ [-0.4759864, 0.9802004, 1.0],
+ [-0.21539624, 0.99497736, 1.0],
+ [-0.023042068, 0.9995811, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.024750752, 0.9861515, 1.0],
+ [-0.19931263, 0.99379665, 1.0],
+ [-0.05572195, 0.9987022, 1.0],
+ [-0.0031600085, 0.9999404, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.7919422, 0.9732687, 1.0],
+ [-0.09644, 0.9860887, 1.0],
+ [-0.18857616, 0.9951589, 1.0],
+ [-0.008815362, 0.9997931, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.6597246, 0.9796911, 1.0],
+ [-1.5101999, 0.96022296, 1.0],
+ [-1.2019504, 0.97245467, 1.0],
+ [-0.0584563, 0.99866176, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.50696546, 0.98583317, 1.0],
+ [-0.6666319, 0.9827294, 1.0],
+ [-0.8286432, 0.98483706, 1.0],
+ [-0.065979086, 0.9988241, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.67447394, 0.99499726, 1.0],
+ [-0.10373823, 0.9929153, 1.0],
+ [-0.19383939, 0.9964306, 1.0],
+ [-0.016325893, 0.9997172, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.009086539, 0.99369854, 1.0],
+ [-0.13390036, 0.9848741, 1.0],
+ [-0.047612753, 0.9982364, 1.0],
+ [-0.0026982238, 0.99993587, 1.0],
+ [0.0, 1.0, 1.0],
+ [0.25034446, 0.95816684, 1.0],
+ [-1.1009699, 0.93954855, 1.0],
+ [-1.2868019, 0.968089, 1.0],
+ [-0.10685055, 0.99749947, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.17949252, 0.957553, 1.0],
+ [-1.1589056, 0.9355635, 1.0],
+ [-1.1646432, 0.97002524, 1.0],
+ [-0.09039519, 0.9978748, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.26302215, 0.9246536, 1.0],
+ [-1.1882069, 0.8875307, 1.0],
+ [-0.49334013, 0.97781765, 1.0],
+ [-0.041776404, 0.99893105, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.36667982, 0.84665173, 1.0],
+ [-1.4176713, 0.7761855, 1.0],
+ [-1.7322344, 0.91246706, 1.0],
+ [-0.9948195, 0.97528285, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.33352765, 0.8812866, 1.0],
+ [-1.3768619, 0.7867589, 1.0],
+ [-2.1349912, 0.8586197, 1.0],
+ [-1.0613863, 0.96087015, 1.0],
+ [0.0, 1.0, 1.0],
+ [-0.16149376, 0.95193124, 1.0],
+ [-0.5565226, 0.8689309, 1.0],
+ [-0.71723574, 0.8644273, 1.0],
+ [-0.036870863, 0.9551357, 1.0]
+ ],
+ "srgb_value": true
+ }
+}
diff --git a/crates/catalog/src/folders.rs b/crates/catalog/src/folders.rs
new file mode 100644
index 000000000..61a8f8c4a
--- /dev/null
+++ b/crates/catalog/src/folders.rs
@@ -0,0 +1,226 @@
+//! The library's folder tree: where on disk the imported photos live.
+//!
+//! A *folder* here is a folder holding at least one photo of the library (imported, not deleted,
+//! not merely browsed in Local; demo scenes have no folder). The tree lists those folders and the
+//! ones above them, never a folder that holds nothing of the library, with how many photos each
+//! holds — its subfolders included, like a year in By Date counts its months.
+//!
+//! Every folder is a row of its own, the ones above the imported photos included
+//! (`/Users` → `me` → `Pictures`), so a row is exactly the folder it names. A folder spelled two
+//! ways (`/` or `\`, repeated or trailing separators, `.`) is one folder (see
+//! [`crate::query::folder_key`]).
+//!
+//! Each disk is a *volume*, a top-level row of its own: a drive letter (`D:`), a Windows share
+//! (`//server/share`), a mounted disk or share (macOS `/Volumes/tokyo`, Linux `/media/me/usb`,
+//! `/mnt/disk`, `/run/user/1000/gvfs/smb-share:…`, `/net/host`) or, for everything else, the
+//! startup disk (`/`). Only the spelling of the stored paths tells them apart: no disk is
+//! touched, so a share that is asleep or gone lists and counts like one that is there, and the
+//! sidebar never waits on the network.
+//!
+//! Limits of reading only the stored spelling of a path: file names are compared as written
+//! (Windows folds case, other systems do not), so on a case-insensitive disk `/Volumes/tokyo` and
+//! `/Volumes/TOKYO` are two volumes here; and one place reached by two spellings
+//! (`/Volumes/Macintosh HD/Users/me` and `/Users/me`, `/media/me/usb` and `/run/media/me/usb`) is
+//! two rows. Each row is still consistent: choosing it shows exactly the photos it counts.
+//!
+//! This is the library's own view of its photos; browsing any folder on disk without importing
+//! is Local's job (see [`crate::local`]).
+
+use std::collections::{BTreeMap, HashMap};
+
+use serde::Serialize;
+
+use crate::query::{folder_key, folder_within, split_path};
+use crate::{Catalog, Source};
+
+/// Folder levels kept per photo. No real path comes near it; a deeper one is counted at its
+/// ancestor this many levels down, which bounds the tree's depth (and so every walk of it)
+/// whatever a catalog holds.
+pub const MAX_DEPTH: usize = 64;
+
+/// One row of the library's folder tree.
+#[derive(Clone, Debug, PartialEq, Serialize)]
+pub struct FolderNode {
+ /// What the row says: the folder's name; for a volume, its name (`nas`, `D:`,
+ /// `//server/share`, `/` for the startup disk).
+ pub name: String,
+ /// The folder the row stands for, written out from the names of the photos' paths (forward
+ /// slashes, no verbatim prefix); hand it back to [`crate::Filter::library_folder`] to see its
+ /// photos.
+ pub path: String,
+ /// Photos in this folder or any folder inside it.
+ pub count: usize,
+ /// Photos directly in this folder.
+ pub own: usize,
+ /// A disk rather than a folder in one; its `path` is where it is mounted.
+ pub volume: bool,
+ /// Whether choosing the row (`Filter::library_folder`) shows exactly its `count` photos. Not
+ /// so for the startup disk and for folders that hold other disks (`/Volumes`, `/mnt`…): their
+ /// path covers those disks' photos too, so choosing them is not offered.
+ pub selectable: bool,
+ pub children: Vec,
+}
+
+/// A folder while the tree is being built (children by identity key).
+struct Level {
+ name: String,
+ path: String,
+ own: usize,
+ volume: bool,
+ children: Vec,
+}
+
+/// Where a stored folder path lies: its volume and the folder names below it.
+struct Placed {
+ mount: String,
+ volume: String,
+ names: Vec,
+}
+
+/// A gvfs mount's name made readable: `smb-share:server=tokyo,share=photos` → `photos on tokyo`;
+/// any other (`sftp:host=nas`) as it is.
+fn mount_name(n: &str) -> String {
+ let field = |key: &str| {
+ let rest = n.strip_prefix("smb-share:")?;
+ rest.split(',').find_map(|kv| kv.strip_prefix(key)?.strip_prefix('=')).filter(|v| !v.is_empty())
+ };
+ match (field("share"), field("server")) {
+ (Some(share), Some(server)) => format!("{share} on {server}"),
+ _ => n.to_string(),
+ }
+}
+
+/// The volume `dir` lies on and the folder names below it, read the way [`folder_key`] reads a
+/// path (so a row and the filter that chooses it never disagree). `None` for a path that is not
+/// absolute or names no share.
+fn place(dir: &str) -> Option {
+ let sp = split_path(dir, false);
+ let names: Vec<&str> = sp.parts.iter().map(String::as_str).collect();
+ let named = |mount: String, volume: &str, used: usize| (mount, volume.to_string(), used);
+ let (mount, volume, used) = if sp.prefix.starts_with("//") {
+ // `//server/share`: a server alone (no share) is no place to keep photos
+ if sp.prefix.ends_with('/') {
+ return None;
+ }
+ named(sp.prefix.clone(), &sp.prefix, 0)
+ } else if !sp.prefix.is_empty() {
+ named(sp.prefix.clone(), &sp.prefix, 0)
+ } else if sp.absolute {
+ match names.as_slice() {
+ ["Volumes", n, ..] => named(format!("/Volumes/{n}"), n, 2),
+ ["run", "media", u, n, ..] => named(format!("/run/media/{u}/{n}"), n, 4),
+ ["media", u, n, ..] => named(format!("/media/{u}/{n}"), n, 3),
+ ["mnt", n, ..] => named(format!("/mnt/{n}"), n, 2),
+ // Linux desktops mount network places per user, naming server and share
+ ["run", "user", uid, "gvfs", n, ..] => named(format!("/run/user/{uid}/gvfs/{n}"), &mount_name(n), 5),
+ // NFS automounts: Linux `/net/`, macOS `/Network/Servers/`
+ ["net", host, ..] => named(format!("/net/{host}"), host, 2),
+ ["Network", "Servers", host, ..] => named(format!("/Network/Servers/{host}"), host, 3),
+ _ => named("/".to_string(), "/", 0),
+ }
+ } else {
+ return None;
+ };
+ Some(Placed { mount, volume, names: names.iter().skip(used).take(MAX_DEPTH).map(|n| n.to_string()).collect() })
+}
+
+/// The disk or share (`/Volumes/nas`, `D:`, `/` for the startup disk) a file-backed photo is on,
+/// from its stored path alone.
+pub fn volume_of(p: &crate::Photo) -> Option {
+ let Source::File { path } = &p.source else { return None };
+ let dir = path.rfind(['/', '\\']).and_then(|i| path.get(..i.max(1)))?;
+ place(dir).map(|pl| folder_key(&pl.mount))
+}
+
+/// Whether `path` is a whole disk or share (`/`, `C:\`, `\\srv\share`, `/Volumes/nas`), not a
+/// folder in one.
+pub fn is_disk_root(path: &str) -> bool {
+ place(path).is_some_and(|p| p.names.is_empty())
+}
+
+/// Whether `path` is the startup disk's root (`/`), whose path also covers every other disk.
+pub fn is_startup_disk(path: &str) -> bool {
+ place(path).is_some_and(|p| p.names.is_empty() && p.mount == "/")
+}
+
+/// A folder named by its last two names (`photos/travel`), so two folders called `travel` are
+/// told apart; the path itself when it has no names (`/`).
+pub fn folder_label(path: &str) -> String {
+ let names: Vec<&str> = path.split(['/', '\\']).filter(|n| !n.is_empty()).collect();
+ if names.is_empty() { path.to_string() } else { names.iter().skip(names.len().saturating_sub(2)).copied().collect::>().join("/") }
+}
+
+impl Catalog {
+ /// The library's volumes with their folders and photo counts: volumes by name
+ /// (case-insensitive), each folder with its subfolders the same way.
+ pub fn folder_tree(&self) -> Vec {
+ // photos per containing folder, as spelled (sorted, so the spelling that names a folder
+ // never depends on hash order)
+ let mut dirs: BTreeMap<&str, usize> = BTreeMap::new();
+ for p in self.photos().filter(|p| p.in_library()) {
+ if let Source::File { path } = &p.source
+ && let Some(i) = path.rfind(['/', '\\'])
+ // a photo straight in the root has an empty folder part: the root itself
+ && let Some(dir) = path.get(..i.max(1))
+ {
+ *dirs.entry(dir).or_default() += 1;
+ }
+ }
+ let mut levels: HashMap = HashMap::new();
+ let mut roots: Vec = Vec::new();
+ for (dir, n) in dirs {
+ let Some(placed) = place(dir) else { continue };
+ let vkey = folder_key(&placed.mount);
+ if !levels.contains_key(&vkey) {
+ levels.insert(
+ vkey.clone(),
+ Level { name: placed.volume.clone(), path: placed.mount.clone(), own: 0, volume: true, children: Vec::new() },
+ );
+ roots.push(vkey.clone());
+ }
+ let mut prefix = if placed.mount == "/" { String::new() } else { placed.mount.clone() };
+ let mut parent = vkey;
+ for name in &placed.names {
+ prefix.push('/');
+ prefix.push_str(name);
+ let key = folder_key(&prefix);
+ if !levels.contains_key(&key) {
+ levels.insert(key.clone(), Level { name: name.clone(), path: prefix.clone(), own: 0, volume: false, children: Vec::new() });
+ if let Some(l) = levels.get_mut(&parent) {
+ l.children.push(key.clone());
+ }
+ }
+ parent = key;
+ }
+ if let Some(l) = levels.get_mut(&parent) {
+ l.own += n;
+ }
+ }
+ let mut tree: Vec = roots.iter().filter_map(|k| build(k, &levels)).collect();
+ sort(&mut tree);
+ // a folder that holds another disk's mount covers that disk's photos too
+ let mounts: Vec = tree.iter().filter(|v| v.path != "/").map(|v| v.path.clone()).collect();
+ mark_selectable(&mut tree, &mounts);
+ tree
+ }
+}
+
+/// The node for `key` with everything below it (at most [`MAX_DEPTH`] deep).
+fn build(key: &str, levels: &HashMap) -> Option {
+ let l = levels.get(key)?;
+ let mut children: Vec = l.children.iter().filter_map(|k| build(k, levels)).collect();
+ sort(&mut children);
+ let count = l.own + children.iter().map(|c| c.count).sum::();
+ Some(FolderNode { name: l.name.clone(), path: l.path.clone(), count, own: l.own, volume: l.volume, selectable: true, children })
+}
+
+fn mark_selectable(nodes: &mut [FolderNode], mounts: &[String]) {
+ for n in nodes {
+ n.selectable = !(n.volume && n.path == "/") && (n.volume || !mounts.iter().any(|m| folder_within(m, &n.path)));
+ mark_selectable(&mut n.children, mounts);
+ }
+}
+
+fn sort(v: &mut [FolderNode]) {
+ v.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()).then_with(|| a.name.cmp(&b.name)));
+}
diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs
index 208a86d09..2934c7a60 100644
--- a/crates/catalog/src/lib.rs
+++ b/crates/catalog/src/lib.rs
@@ -14,6 +14,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod dates;
+pub mod folders;
pub mod journal;
pub mod keywords;
pub mod local;
@@ -29,6 +30,7 @@ use std::collections::BTreeMap;
use std::sync::Arc;
pub use dates::{DateRun, GroupBy};
+pub use folders::FolderNode;
pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming};
pub use keywords::KeywordNode;
use lightcraft_develop::DevelopSettings;
@@ -612,6 +614,22 @@ impl Catalog {
Op::Batch { ops }
}
+ /// [`Self::delete_permanently_ops`] for several photos at once. One op list built against the
+ /// catalog as it is now: albums lose all of them in one write each, and stacks are shortened
+ /// (or dissolved) once, however many of their photos go.
+ pub fn delete_photos_permanently_ops(&self, ids: &[PhotoId]) -> Op {
+ let gone: std::collections::HashSet = ids.iter().copied().collect();
+ let mut ops: Vec = self
+ .albums
+ .values()
+ .filter(|a| a.photos.iter().any(|p| gone.contains(p)))
+ .map(|a| Op::SetAlbumPhotos { id: a.id, photos: a.photos.iter().copied().filter(|p| !gone.contains(p)).collect() })
+ .collect();
+ ops.extend(self.remove_from_stacks_ops(ids));
+ ops.extend(gone.iter().map(|id| Op::RemovePhoto { id: *id }));
+ Op::Batch { ops }
+ }
+
// ---- persistence
/// Full snapshot as JSON.
@@ -655,6 +673,8 @@ mod tests;
#[cfg(test)]
mod tests_background;
#[cfg(test)]
+mod tests_folders;
+#[cfg(test)]
mod tests_format_version;
#[cfg(test)]
mod tests_journal;
diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs
index fe8d1b059..ae53b486a 100644
--- a/crates/catalog/src/model.rs
+++ b/crates/catalog/src/model.rs
@@ -2,7 +2,7 @@
use std::sync::Arc;
-use lightcraft_develop::DevelopSettings;
+use lightcraft_develop::{DevelopSettings, WbMode};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
@@ -336,12 +336,13 @@ impl Photo {
pub fn develops_raw(&self) -> bool {
self.kind == MediaKind::Raw && self.preview_only.is_none()
}
- /// The current ARW, NEF and RW2 readers have vendor WB multipliers but no measured camera
+ /// The current ARW, NEF, RW2, RAF, CR3, CR2 and PEF readers have vendor WB multipliers but no measured camera
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and
/// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files).
pub fn relative_wb(&self) -> bool {
- self.develops_raw() && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
+ self.develops_raw()
+ && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
}
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
/// default preset applied on top of them ([`Photo::import_look`]).
@@ -370,7 +371,12 @@ impl Photo {
}
/// Edited by the user: settings differ from what import gave the photo.
pub fn is_edited(&self) -> bool {
- *self.develop != self.import_defaults() && !self.develop.is_unedited()
+ let mut defaults = self.import_defaults();
+ // As Shot reads the source's current WB; stored numbers can predate a decoder update.
+ if self.develop.wb.mode == WbMode::AsShot && defaults.wb.mode == WbMode::AsShot {
+ defaults.wb = self.develop.wb;
+ }
+ *self.develop != defaults && !self.develop.is_unedited()
}
/// Capture time if known, else import time (sort key).
pub fn date(&self) -> &str {
@@ -430,7 +436,7 @@ mod edited_tests {
}
#[test]
- fn sony_and_nikon_raws_use_relative_white_balance() {
+ fn supported_raws_use_relative_white_balance() {
for (name, format, relative) in [
("a.arw", "ARW", true),
("a.nef", "NEF", true),
@@ -438,6 +444,8 @@ mod edited_tests {
("a.rw2", "RW2", true),
("a.rwl", "RWL", true),
("a.raw", "RAW", true),
+ ("a.raf", "rAf", true),
+ ("a.cr3", "cr3", true),
("a.dng", "DNG", false),
] {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00");
@@ -446,6 +454,12 @@ mod edited_tests {
assert_eq!(p.relative_wb(), relative, "{format}");
let wb = p.camera_defaults().wb;
assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (5200.0, 4.0) }, "{format}");
+ p.develop = Arc::new(DevelopSettings::for_raw(5200.0, 4.0));
+ assert!(!p.is_edited(), "{format}: old As Shot values are not an edit");
+ let mut custom = (*p.develop).clone();
+ custom.wb.mode = WbMode::Custom;
+ p.develop = Arc::new(custom);
+ assert!(p.is_edited(), "{format}: Custom WB is still an edit");
// shown from the embedded preview: a rendered image, not a relative-WB raw
p.preview_only = Some("unsupported".into());
assert!(!p.relative_wb());
diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs
index d06e13700..47223d5d8 100644
--- a/crates/catalog/src/query.rs
+++ b/crates/catalog/src/query.rs
@@ -37,6 +37,10 @@ pub struct Filter {
pub deleted: bool,
/// Capture date prefix (`2026`, `2026-04`, `2026-04-12`).
pub date: Option,
+ /// A folder the library's photos were imported from: those photos and the ones imported from
+ /// the folders inside it (see [`crate::folders`]). Photos only browsed in Local are never part
+ /// of it, unlike [`Filter::folder`].
+ pub library_folder: Option,
/// A keyword; hierarchical keywords match their children too (`travel` finds `travel|italy`).
pub keyword: Option,
/// A person: photos with a named face region of this name (case-insensitive), as read from XMP.
@@ -186,6 +190,7 @@ impl Filter {
("camera", &self.camera),
("lens", &self.lens),
("date", &self.date),
+ ("folder", &self.library_folder),
("imported", &self.imported),
] {
if let Some(x) = val {
@@ -213,6 +218,19 @@ impl Filter {
}
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
+ self.matches_in(p, cat, self.library_root().as_deref())
+ }
+
+ /// [`Filter::library_folder`] as a folder identity ([`folder_key`]); `None` without a choice.
+ fn library_root(&self) -> Option {
+ // as given: a folder may be named with spaces at its edge, and trimming would make it
+ // another folder
+ self.library_folder.as_deref().filter(|d| !d.trim().is_empty()).map(folder_key)
+ }
+
+ /// [`Filter::matches`] with the library folder already turned into its identity, so a query
+ /// over a big library does that once, not once per photo.
+ fn matches_in(&self, p: &Photo, cat: &Catalog, root: Option<&str>) -> bool {
if p.deleted != self.deleted {
return false;
}
@@ -285,6 +303,11 @@ impl Filter {
{
return false;
}
+ if let Some(root) = root
+ && !photo_in_root(p, root)
+ {
+ return false;
+ }
if let Some(d) = &self.imported
&& !p.imported.starts_with(d.as_str())
{
@@ -317,7 +340,8 @@ impl Filter {
impl Catalog {
/// Photos matching `filter`, in `sort` order (ties broken by id for stability).
pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec {
- let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches(p, self)).collect();
+ let root = filter.library_root();
+ let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect();
v.sort_by(|a, b| {
let o = match sort.key {
SortKey::CaptureDate => a.date().cmp(b.date()),
@@ -384,7 +408,8 @@ impl Catalog {
pub fn people_in(&self, filter: &Filter) -> Vec {
let filter = Filter { person: None, ..filter.clone() };
let mut m: std::collections::HashMap = Default::default();
- for p in self.photos().filter(|p| filter.matches(p, self)) {
+ let root = filter.library_root();
+ for p in self.photos().filter(|p| filter.matches_in(p, self, root.as_deref())) {
let mut seen: Vec = Vec::new();
for r in p.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) {
let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue };
@@ -438,12 +463,18 @@ pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool {
!rest.is_empty() && (deep || !rest.contains(['/', '\\']))
}
-/// A folder path's identity, for telling whether two spellings name the same folder: `/` and
-/// `\\` are both separators, repeated separators, `.` and a trailing separator are dropped,
-/// `..` is resolved lexically, a Windows verbatim prefix (`\\?\`) is removed and the drive
-/// letter lower-cased; on Windows (case-insensitive file names) the whole path is lower-cased.
-/// No file-system access: the paths compared should already be absolute.
-pub fn folder_key(path: &str) -> String {
+/// A path read the way [`folder_key`] reads it: the part `..` can't climb above (`//server/share`,
+/// `c:`; empty for `/` and relative paths), the names below it, and whether it is absolute.
+pub(crate) struct SplitPath {
+ pub prefix: String,
+ pub parts: Vec,
+ pub absolute: bool,
+}
+
+/// Both separators, repeated separators (also between a server and its share), `.` and a
+/// trailing separator dropped, `..` resolved lexically, a Windows verbatim prefix removed;
+/// `lower_drive` lower-cases a drive letter (the identity does, a display name wants it as is).
+pub(crate) fn split_path(path: &str, lower_drive: bool) -> SplitPath {
let mut s = path.replace('\\', "/");
if let Some(rest) = s.strip_prefix("//?/").or_else(|| s.strip_prefix("//./")) {
s = match rest.strip_prefix("UNC/") {
@@ -451,28 +482,39 @@ pub fn folder_key(path: &str) -> String {
None => rest.to_string(),
};
}
- // the part `..` can't climb above: `//server/share`, `c:` or `/`
let (prefix, rest) = if let Some(unc) = s.strip_prefix("//").filter(|u| !u.is_empty() && !u.starts_with('/')) {
- let mut it = unc.splitn(3, '/');
- let (server, share, rest) = (it.next().unwrap_or(""), it.next().unwrap_or(""), it.next().unwrap_or(""));
- (format!("//{server}/{share}"), rest.to_string())
- } else if s.len() >= 2 && s.as_bytes()[1] == b':' && s.as_bytes()[0].is_ascii_alphabetic() {
- (s[..2].to_ascii_lowercase(), s[2..].to_string())
+ let mut segs = unc.split('/').filter(|x| !x.is_empty());
+ let (server, share) = (segs.next().unwrap_or(""), segs.next().unwrap_or(""));
+ (format!("//{server}/{share}"), segs.collect::>().join("/"))
+ } else if let (true, Some(drive), Some(rest)) = (matches!(s.as_bytes(), [a, b':', ..] if a.is_ascii_alphabetic()), s.get(..2), s.get(2..)) {
+ (if lower_drive { drive.to_ascii_lowercase() } else { drive.to_string() }, rest.to_string())
} else {
(String::new(), s.clone())
};
let absolute = rest.starts_with('/') || !prefix.is_empty();
- let mut parts: Vec<&str> = Vec::new();
+ let mut parts: Vec = Vec::new();
for c in rest.split('/') {
match c {
"" | "." => {}
- ".." if parts.last().is_some_and(|p| *p != "..") => {
+ ".." if parts.last().is_some_and(|p| p != "..") => {
parts.pop();
}
".." if absolute => {}
- c => parts.push(c),
+ c => parts.push(c.to_string()),
}
}
+ SplitPath { prefix, parts, absolute }
+}
+
+/// A folder path's identity, for telling whether two spellings name the same folder: `/` and
+/// `\\` are both separators, repeated separators, `.` and a trailing separator are dropped,
+/// `..` is resolved lexically, a Windows verbatim prefix (`\\?\`) is removed and the drive
+/// letter lower-cased; on Windows (case-insensitive file names) the whole path is lower-cased.
+/// File names are otherwise compared as written, so on a case-insensitive disk elsewhere
+/// (macOS, a Samba share) two spellings that differ in case are two folders here.
+/// No file-system access: the paths compared should already be absolute.
+pub fn folder_key(path: &str) -> String {
+ let SplitPath { prefix, parts, absolute } = split_path(path, true);
let key = if absolute { format!("{prefix}/{}", parts.join("/")) } else { parts.join("/") };
if cfg!(windows) { key.to_lowercase() } else { key }
}
@@ -480,8 +522,51 @@ pub fn folder_key(path: &str) -> String {
/// Whether `path` is folder `root` or lies somewhere inside it, however either is spelled
/// (see [`folder_key`]).
pub fn folder_within(path: &str, root: &str) -> bool {
- let (p, r) = (folder_key(path), folder_key(root));
- p == r || (p.starts_with(&r) && (r.ends_with('/') || p[r.len()..].starts_with('/')))
+ key_within(&folder_key(path), &folder_key(root))
+}
+
+/// The names below folder `root` that lead to `path`, as the caller spelled them (`None` when
+/// `path` is not at or inside `root`; empty when it is `root`). Compared by [`folder_key`], but
+/// the names keep their case, which the key lowers on Windows.
+pub fn folder_rest(path: &str, root: &str) -> Option> {
+ if !key_within(&folder_key(path), &folder_key(root)) {
+ return None;
+ }
+ let depth = split_path(root, false).parts.len();
+ Some(split_path(path, false).parts.into_iter().skip(depth).collect())
+}
+
+/// Whether a library photo (not one only browsed in Local) lies in the folder whose
+/// [`folder_key`] is `root`. A plain POSIX path (no `\`, `.`, `..`, repeated or trailing
+/// separators) is its own identity, so it is read without building a key for it: the check runs
+/// for every photo of the library on each refresh, and this keeps it cheap.
+pub(crate) fn photo_in_root(p: &Photo, root: &str) -> bool {
+ if p.local {
+ return false;
+ }
+ let crate::Source::File { path } = &p.source else { return false };
+ let plain = path.starts_with('/')
+ && !path.starts_with("//")
+ && !path.ends_with('/')
+ && !path.contains(['\\'])
+ && !path.contains("//")
+ && !path.contains("/.");
+ if !cfg!(windows) && plain && root.starts_with('/') && !root.starts_with("//") {
+ let r = root.trim_end_matches('/');
+ return path.strip_prefix(r).is_some_and(|rest| rest.starts_with('/'));
+ }
+ crate::local::folder_of(p).is_some_and(|f| key_within(&f, root))
+}
+
+/// [`folder_within`] for two paths already turned into their [`folder_key`].
+pub(crate) fn key_within(p: &str, r: &str) -> bool {
+ // `.` or `a/..` name no folder: not "everything"
+ if r.is_empty() {
+ return false;
+ }
+ // a disk's root key ends in `/` (`c:/`, `//srv/share/`), a folder's does not: same folder
+ let (p, r) = (p.trim_end_matches('/'), r.trim_end_matches('/'));
+ p == r || p.strip_prefix(r).is_some_and(|rest| rest.starts_with('/'))
}
/// The kind of Photo Merge result a file is, from the name merges give it (`IMG_1-HDR.dng`,
diff --git a/crates/catalog/src/tests_folders.rs b/crates/catalog/src/tests_folders.rs
new file mode 100644
index 000000000..556f77def
--- /dev/null
+++ b/crates/catalog/src/tests_folders.rs
@@ -0,0 +1,404 @@
+//! The library's folder tree: where the imported photos live on disk (see [`crate::folders`]).
+//!
+//! Scenarios, in the words of someone browsing their library:
+//!
+//! * Given photos imported from several folders, the tree lists those folders and no others,
+//! each with how many photos it holds, under the disk (volume) they are on.
+//! * A folder's count includes the folders inside it.
+//! * Every folder is a row of its own, the ones above the imported photos included.
+//! * Photos only browsed in Local, deleted photos and demo scenes are not "imported from" anywhere.
+//! * However a folder is spelled, it is one folder.
+//! * Choosing a folder shows what was imported from it and from the folders inside it — exactly
+//! as many photos as the row says.
+
+use crate::*;
+
+fn add(c: &mut Catalog, path: &str) -> PhotoId {
+ add_with(c, path, |_| {})
+}
+
+fn add_with(c: &mut Catalog, path: &str, tweak: impl FnOnce(&mut Photo)) -> PhotoId {
+ let id = c.alloc_photo_id();
+ let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
+ let mut p = Photo::new(id, Source::File { path: path.into() }, name, "JPEG", 60, 40, "2026-01-01T10:00:00");
+ tweak(&mut p);
+ c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
+ id
+}
+
+fn library(paths: &[&str]) -> Catalog {
+ let mut c = Catalog::new();
+ paths.iter().for_each(|p| {
+ add(&mut c, p);
+ });
+ c
+}
+
+/// `(name, count, own)` of each node, depth first.
+fn outline(nodes: &[FolderNode]) -> Vec<(String, usize, usize)> {
+ fn walk(n: &FolderNode, out: &mut Vec<(String, usize, usize)>) {
+ out.push((n.name.clone(), n.count, n.own));
+ n.children.iter().for_each(|c| walk(c, out));
+ }
+ let mut out = Vec::new();
+ nodes.iter().for_each(|n| walk(n, &mut out));
+ out
+}
+
+fn row(name: &str, count: usize, own: usize) -> (String, usize, usize) {
+ (name.to_string(), count, own)
+}
+
+fn rows(paths: &[&str]) -> Vec<(String, usize, usize)> {
+ outline(&library(paths).folder_tree())
+}
+
+#[test]
+fn an_empty_library_has_no_folders() {
+ assert!(Catalog::new().folder_tree().is_empty());
+}
+
+#[test]
+fn only_folders_with_imported_photos_are_listed_with_their_counts() {
+ let c = library(&["/pics/trip/a.jpg", "/pics/trip/b.jpg", "/pics/home/c.jpg"]);
+ let tree = c.folder_tree();
+ assert_eq!(outline(&tree), vec![row("/", 3, 0), row("pics", 3, 0), row("home", 1, 1), row("trip", 2, 2)]);
+ assert!(tree[0].volume && !tree[0].children[0].volume);
+ assert_eq!(tree[0].children[0].children[1].path, "/pics/trip", "a row knows its folder, for choosing it");
+}
+
+#[test]
+fn a_folders_count_includes_the_folders_inside_it() {
+ let r = rows(&["/pics/trip/a.jpg", "/pics/trip/day1/b.jpg", "/pics/trip/day1/c.jpg", "/pics/trip/day2/d.jpg"]);
+ assert_eq!(r, vec![row("/", 4, 0), row("pics", 4, 0), row("trip", 4, 1), row("day1", 2, 2), row("day2", 1, 1)]);
+}
+
+#[test]
+fn every_folder_is_a_row_of_its_own_even_when_it_holds_nothing_itself() {
+ let c = library(&["/Users/me/Pictures/2024/a.jpg", "/Users/me/Pictures/2025/b.jpg", "/Volumes/nas/photos/c.jpg"]);
+ let tree = c.folder_tree();
+ assert_eq!(
+ outline(&tree),
+ vec![
+ row("/", 2, 0),
+ row("Users", 2, 0),
+ row("me", 2, 0),
+ row("Pictures", 2, 0),
+ row("2024", 1, 1),
+ row("2025", 1, 1),
+ row("nas", 1, 0),
+ row("photos", 1, 1)
+ ]
+ );
+ assert_eq!(tree[0].children[0].children[0].children[0].path, "/Users/me/Pictures", "a row is exactly the folder it names");
+}
+
+#[test]
+fn a_folder_with_photos_of_its_own_is_not_folded_into_its_child() {
+ assert_eq!(rows(&["/pics/a.jpg", "/pics/trip/b.jpg"]), vec![row("/", 2, 0), row("pics", 2, 1), row("trip", 1, 1)]);
+}
+
+#[test]
+fn browsed_deleted_and_demo_photos_are_not_imported_from_anywhere() {
+ let mut c = Catalog::new();
+ add(&mut c, "/pics/kept/a.jpg");
+ add_with(&mut c, "/pics/browsed/b.jpg", |p| p.local = true);
+ add_with(&mut c, "/pics/gone/c.jpg", |p| p.deleted = true);
+ let id = c.alloc_photo_id();
+ let demo = Photo::new(id, Source::Demo { scene: 1 }, "demo", "JPEG", 60, 40, "2026-01-01T10:00:00");
+ c.apply(Op::AddPhoto { photo: Box::new(demo) }).unwrap();
+ assert_eq!(outline(&c.folder_tree()), vec![row("/", 1, 0), row("pics", 1, 0), row("kept", 1, 1)]);
+}
+
+#[test]
+fn however_a_folder_is_spelled_it_is_one_folder() {
+ let r = rows(&["/pics/trip/a.jpg", "/pics//trip/./b.jpg", "/pics\\trip\\c.jpg", "/pics/x/../trip/d.jpg"]);
+ assert_eq!(r, vec![row("/", 4, 0), row("pics", 4, 0), row("trip", 4, 4)]);
+}
+
+#[test]
+fn each_disk_is_a_volume_of_its_own() {
+ // macOS, Linux and Windows habits for where disks appear
+ let r = rows(&["/Volumes/nas/p/1.jpg", "/media/me/usb/2.jpg", "/run/media/me/card/3.jpg", "/mnt/disk/4.jpg"]);
+ assert_eq!(r, vec![row("card", 1, 1), row("disk", 1, 1), row("nas", 1, 0), row("p", 1, 1), row("usb", 1, 1)]);
+ let r = rows(&["D:\\Photos\\2024\\a.jpg", "D:\\Photos\\2025\\b.jpg", "C:\\x\\c.jpg"]);
+ assert_eq!(r, vec![row("C:", 1, 0), row("x", 1, 1), row("D:", 2, 0), row("Photos", 2, 0), row("2024", 1, 1), row("2025", 1, 1)]);
+}
+
+#[test]
+fn a_network_share_is_a_volume_and_two_spellings_are_one() {
+ let r = rows(&["\\\\srv\\s1\\a\\1.jpg", "\\\\srv\\s2\\b\\2.jpg"]);
+ assert_eq!(r, vec![row("//srv/s1", 1, 0), row("a", 1, 1), row("//srv/s2", 1, 0), row("b", 1, 1)]);
+ // the verbatim form Windows APIs give is the same share
+ let r = rows(&["\\\\?\\UNC\\srv\\s1\\a\\1.jpg", "\\\\srv\\s1\\a\\2.jpg"]);
+ assert_eq!(r, vec![row("//srv/s1", 2, 0), row("a", 2, 2)]);
+}
+
+#[test]
+fn a_samba_share_mounted_on_a_mac_is_a_volume_named_after_the_share() {
+ // Finder mounts every share under /Volumes, and a second mount of the same share gets a suffix
+ let r = rows(&["/Volumes/tokyo/Photos/2024/a.jpg", "/Volumes/tokyo/Photos/2025/b.jpg", "/Volumes/tokyo-1/old/c.jpg", "/Volumes/東京 NAS/d.jpg"]);
+ assert_eq!(
+ r,
+ vec![
+ row("tokyo", 2, 0),
+ row("Photos", 2, 0),
+ row("2024", 1, 1),
+ row("2025", 1, 1),
+ row("tokyo-1", 1, 0),
+ row("old", 1, 1),
+ row("東京 NAS", 1, 1)
+ ]
+ );
+ let tree = library(&["/Volumes/tokyo/Photos/a.jpg"]).folder_tree();
+ assert_eq!((tree[0].path.as_str(), tree[0].volume), ("/Volumes/tokyo", true), "choosing the share shows everything on it");
+}
+
+#[test]
+fn shares_the_other_systems_mount_are_volumes_too() {
+ // Linux desktops: gvfs mounts per user, with the server and share in the mount's name
+ let gvfs = "/run/user/1000/gvfs/smb-share:server=tokyo,share=photos";
+ let r = rows(&[&format!("{gvfs}/2024/a.jpg"), "/run/user/1000/gvfs/sftp:host=nas/b.jpg"]);
+ assert_eq!(r, vec![row("photos on tokyo", 1, 0), row("2024", 1, 1), row("sftp:host=nas", 1, 1)]);
+ // NFS automounts: Linux /net/, macOS /Network/Servers/
+ let r = rows(&["/net/tokyo/export/a.jpg", "/Network/Servers/osaka/vol/b.jpg"]);
+ assert_eq!(r, vec![row("osaka", 1, 0), row("vol", 1, 1), row("tokyo", 1, 0), row("export", 1, 1)]);
+ // Windows: a share by name or by address, the WSL file system, a mapped drive
+ let r = rows(&[r"\\tokyo.local\photos\a.jpg", r"\\192.168.1.5\scans\b.jpg", r"\\wsl$\Ubuntu\home\me\c.jpg", r"Z:\d.jpg"]);
+ assert_eq!(
+ r,
+ vec![
+ row("//192.168.1.5/scans", 1, 1),
+ row("//tokyo.local/photos", 1, 1),
+ row("//wsl$/Ubuntu", 1, 0),
+ row("home", 1, 0),
+ row("me", 1, 1),
+ row("Z:", 1, 1)
+ ]
+ );
+}
+
+#[test]
+fn a_share_that_is_not_mounted_costs_nothing() {
+ // the tree is made from the catalog's own records: it never looks at a disk, so a share that
+ // is asleep or gone lists (and counts) exactly like one that is there
+ let c = library(&["/Volumes/definitely-not-mounted/a/b.jpg", r"\\unreachable.invalid\share\c.jpg"]);
+ let tree = c.folder_tree();
+ assert_eq!(tree.iter().map(|n| n.count).sum::(), 2);
+}
+
+#[test]
+fn a_photo_in_the_root_of_a_disk_counts_for_the_disk() {
+ assert_eq!(rows(&["/x.jpg", "/d/y.jpg"]), vec![row("/", 2, 1), row("d", 1, 1)]);
+ assert_eq!(rows(&["C:\\x.jpg"]), vec![row("C:", 1, 1)]);
+}
+
+#[test]
+fn photos_with_no_folder_are_left_out() {
+ assert!(rows(&["x.jpg", ""]).is_empty());
+}
+
+#[test]
+fn folders_are_listed_by_name_ignoring_case() {
+ let names: Vec = rows(&["/p/b/1.jpg", "/p/A/2.jpg", "/p/c/3.jpg"]).into_iter().skip(2).map(|r| r.0).collect();
+ assert_eq!(names, ["A", "b", "c"]);
+}
+
+#[test]
+fn absurdly_deep_paths_neither_crash_nor_lose_photos() {
+ let deep = format!("/{}/a.jpg", vec!["d"; 5000].join("/"));
+ let tree = library(&[&deep]).folder_tree();
+ assert_eq!(tree.iter().map(|n| n.count).sum::(), 1);
+ assert!(outline(&tree).len() <= crate::folders::MAX_DEPTH + 2);
+}
+
+#[test]
+fn choosing_a_folder_shows_what_was_imported_from_it_and_below() {
+ let mut c = Catalog::new();
+ let trip = add(&mut c, "/pics/trip/a.jpg");
+ let day = add(&mut c, "/pics/trip/day1/b.jpg");
+ let sibling = add(&mut c, "/pics/trip2/c.jpg"); // shares the name's start, not the folder
+ let browsed = add_with(&mut c, "/pics/trip/d.jpg", |p| p.local = true);
+ let f = Filter { library_folder: Some("/pics//trip/".into()), ..Default::default() };
+ let shown = |id: PhotoId| f.matches(c.photo(id).unwrap(), &c);
+ assert!(shown(trip) && shown(day));
+ assert!(!shown(sibling), "/pics/trip2 is another folder");
+ assert!(!shown(browsed), "browsed photos are not part of the library's folders");
+ assert_eq!(f.describe(), "folder /pics//trip/");
+}
+
+#[test]
+fn recently_deleted_shows_the_deleted_photos_of_the_chosen_folder() {
+ let mut c = Catalog::new();
+ let gone = add_with(&mut c, "/pics/trip/a.jpg", |p| p.deleted = true);
+ let kept = add(&mut c, "/pics/trip/b.jpg");
+ let f = Filter { library_folder: Some("/pics/trip".into()), deleted: true, ..Default::default() };
+ assert!(f.matches(c.photo(gone).unwrap(), &c));
+ assert!(!f.matches(c.photo(kept).unwrap(), &c));
+}
+
+#[test]
+fn an_empty_folder_choice_is_no_choice() {
+ let c = library(&["/pics/a.jpg"]);
+ let f = Filter { library_folder: Some(" ".into()), ..Default::default() };
+ assert_eq!(c.query(&f, &Sort::default()).len(), 1);
+}
+
+#[test]
+fn every_row_shows_exactly_as_many_photos_as_it_says() {
+ let c = library(&[
+ "/Users/me/Pictures/2024/a.jpg",
+ "/Users/me/Pictures/2024/b.jpg",
+ "/Users/me/Pictures/2025/c.jpg",
+ "/Users/me/Pictures/d.jpg",
+ "/Volumes/nas/photos/e.jpg",
+ "/Volumes/nas/f.jpg",
+ "/x.jpg",
+ "/a/../b/g.jpg",
+ "D:\\Photos\\2024\\h.jpg",
+ "d:/Photos/i.jpg",
+ "\\\\srv\\s1\\a\\j.jpg",
+ "\\\\?\\UNC\\srv\\s2\\k.jpg",
+ "/pics/trip/l.jpg",
+ "/pics/trip2/m.jpg",
+ "/Volumes/tokyo/Photos/2024/n.jpg",
+ "/Volumes/tokyo/o.jpg",
+ "/run/user/1000/gvfs/smb-share:server=tokyo,share=photos/p.jpg",
+ "/net/osaka/export/q.jpg",
+ r"\\wsl$\Ubuntu\home\me\r.jpg",
+ // odd spellings the tree and the filter must read the same way
+ "//server/share/../other/1.jpg",
+ "//server/share/ok/2.jpg",
+ "//a b//A/f.jpg",
+ r"\\wsl$\Ubuntu\..\a\1.jpg",
+ // a folder that holds other disks as well as photos of its own
+ "/Volumes/1.jpg",
+ "/mnt/2.jpg",
+ "/mnt/disk/3.jpg",
+ ]);
+ fn check(c: &Catalog, nodes: &[FolderNode], seen: &mut usize) {
+ for n in nodes {
+ // the startup volume's row only opens: its path "/" would cover every other disk
+ if n.selectable {
+ let f = Filter { library_folder: Some(n.path.clone()), ..Default::default() };
+ assert_eq!(c.query(&f, &Sort::default()).len(), n.count, "{} ({})", n.name, n.path);
+ *seen += 1;
+ }
+ check(c, &n.children, seen);
+ }
+ }
+ let mut seen = 0;
+ check(&c, &c.folder_tree(), &mut seen);
+ assert!(seen > 10, "{seen} rows checked");
+}
+
+fn find<'a>(nodes: &'a [FolderNode], path: &str) -> Option<&'a FolderNode> {
+ nodes.iter().find_map(|n| if n.path == path { Some(n) } else { find(&n.children, path) })
+}
+
+#[test]
+fn a_row_that_would_cover_other_disks_is_not_offered_for_choosing() {
+ let tree = library(&["/Users/me/a.jpg", "/Volumes/1.jpg", "/Volumes/tokyo/x.jpg", "/mnt/2.jpg"]).folder_tree();
+ let sel = |p: &str| find(&tree, p).map(|n| n.selectable);
+ assert_eq!(sel("/"), Some(false), "the startup disk's path covers every disk");
+ assert_eq!(sel("/Volumes"), Some(false), "it holds the tokyo disk");
+ assert_eq!(sel("/Volumes/tokyo"), Some(true));
+ assert_eq!(sel("/Users/me"), Some(true));
+ assert_eq!(sel("/mnt"), Some(true), "no disk is mounted below it in this library");
+}
+
+#[test]
+fn the_tree_and_the_filter_read_odd_spellings_the_same_way() {
+ // `..` cannot climb above a share, and repeated separators between server and share are one
+ let tree = library(&["//server/share/../other/1.jpg", "//a b//A/f.jpg"]).folder_tree();
+ assert_eq!(outline(&tree), vec![row("//a b/A", 1, 1), row("//server/share", 1, 0), row("other", 1, 1)]);
+}
+
+#[test]
+fn a_path_that_names_no_folder_chooses_nothing() {
+ let c = library(&["/pics/a.jpg", "C:\\b\\c.jpg"]);
+ for dot in [".", "./", "a/..", "../.."] {
+ let f = Filter { library_folder: Some(dot.into()), ..Default::default() };
+ assert_eq!(c.query(&f, &Sort::default()).len(), 0, "{dot:?} is no folder, not everything");
+ }
+}
+
+#[test]
+fn a_folder_is_labelled_by_its_last_two_names() {
+ use crate::folders::folder_label;
+ assert_eq!(folder_label("/Volumes/tokyo/photos/travel"), "photos/travel");
+ assert_eq!(folder_label("/Volumes/tokyo/"), "Volumes/tokyo");
+ assert_eq!(folder_label(r"C:\Photos\2024"), "Photos/2024");
+ assert_eq!(folder_label("/"), "/");
+ assert_eq!(folder_label(""), "");
+}
+
+#[test]
+fn disk_roots_are_told_from_folders() {
+ use crate::folders::{is_disk_root, is_startup_disk};
+ for p in ["/", "C:", "C:\\", r"\\?\C:\", r"\\srv\share", "/Volumes/nas", "/mnt/disk", "/net/host"] {
+ assert!(is_disk_root(p), "{p}");
+ }
+ for p in ["/Volumes", "/Users", "C:\\Photos", r"\\srv\share\x", "/Volumes/nas/p", "."] {
+ assert!(!is_disk_root(p), "{p}");
+ }
+ assert!(is_startup_disk("/") && is_startup_disk("//") && !is_startup_disk("/Volumes/nas") && !is_startup_disk("C:"));
+}
+
+#[test]
+fn a_folder_name_with_spaces_at_its_edge_is_its_own_folder() {
+ // legal on macOS and Linux: `/p/shoot ` is not `/p/shoot`
+ let c = library(&["/p/shoot /a.jpg", "/p/shoot/b.jpg", "/p/other/c.jpg"]);
+ let tree = c.folder_tree();
+ let shoot_space = find(&tree, "/p/shoot ").expect("its own row");
+ let shoot = find(&tree, "/p/shoot").expect("and the other");
+ for (node, want) in [(shoot_space, "a.jpg"), (shoot, "b.jpg")] {
+ let f = Filter { library_folder: Some(node.path.clone()), ..Default::default() };
+ let ids = c.query(&f, &Sort::default());
+ let names: Vec<&str> = ids.iter().filter_map(|id| c.photo(*id)).map(|p| p.file_name.as_str()).collect();
+ assert_eq!(names, [want], "{:?} shows its own photo", node.path);
+ }
+}
+
+#[test]
+fn a_local_photo_never_counts_as_imported_even_when_a_disk_folder_is_also_shown() {
+ let mut c = Catalog::new();
+ let browsed = add_with(&mut c, "/pics/trip/b.jpg", |p| p.local = true);
+ let f = Filter { folder: Some("/pics/trip".into()), library_folder: Some("/pics/trip".into()), ..Default::default() };
+ assert!(!f.matches(c.photo(browsed).unwrap(), &c));
+}
+
+#[test]
+fn the_quick_check_agrees_with_the_careful_one() {
+ use crate::query::{folder_key, key_within, photo_in_root};
+ let paths = [
+ "/a/b/c.jpg",
+ "/a/b/d/e.jpg",
+ "/a/bc/x.jpg",
+ "/a/./b/x.jpg",
+ "/a/../a/b/x.jpg",
+ "/a//b/x.jpg",
+ "/a\\b\\x.jpg",
+ "/a/b ",
+ "/a/b /x.jpg",
+ "/.hidden/x.jpg",
+ "/a/.b/x.jpg",
+ "/x.jpg",
+ "//srv/share/x.jpg",
+ "C:\\a\\b\\x.jpg",
+ "relative/a/b/x.jpg",
+ "/Volumes/tokyo/a/x.jpg",
+ "/日本/写真/x.jpg",
+ ];
+ let roots = ["/", "/a", "/a/b", "/a/b/", "/a/bc", "/a/b ", "/Volumes/tokyo", "//srv/share", "C:\\a", "/日本", "relative", "/a/../a/b"];
+ for p in paths {
+ let mut c = Catalog::new();
+ let id = add(&mut c, p);
+ let photo = c.photo(id).unwrap();
+ for r in roots {
+ let root = folder_key(r);
+ let slow = crate::local::folder_of(photo).is_some_and(|f| key_within(&f, &root));
+ assert_eq!(photo_in_root(photo, &root), slow, "{p:?} in {r:?}");
+ }
+ }
+}
diff --git a/crates/codecs/README.md b/crates/codecs/README.md
index d826b5ec1..18743ad03 100644
--- a/crates/codecs/README.md
+++ b/crates/codecs/README.md
@@ -14,6 +14,7 @@ let d: Decoded = decode(&bytes, DecodeOptions::default())?;
let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford)
let fast = decode(&bytes, DecodeOptions::fit(2048, 2048))?; // JPEG: DCT-domain 1/2..1/8 scaling
let t: Thumbnail = decode_thumbnail(&bytes, 256)?; // embedded EXIF/MPF preview or scaled decode
+let h: Header = read_header(&bytes)?; // stored width/height + orientation, no pixels decoded
let icc = icc::write_named(NamedSpace::DisplayP3); // or icc::write_matrix_trc(&space, &Trc::…)
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(&xmp) };
@@ -56,6 +57,10 @@ or CMYK profile converted by the CMS straight to linear Rec.2020), `Container` (
EXIF `R03` Adobe RGB hint), `Untagged` (assumed sRGB; untagged float data assumed linear),
`IccUnsupported` (profile present but unusable → **sRGB fallback**), `Naive` (CMYK without profile).
+`decode_jpeg_with_fallback(bytes, opts, space)` lets an enclosing RAW container supply the colour
+space of a JPEG with no ICC or EXIF metadata. It applies the transfer curve before linear-light
+resizing; a JPEG carrying its own metadata retains its existing interpretation.
+
ICC profiles are parsed with `moxcms` (v2/v4, `curv`/`para` TRCs, `chad`); sRGB, Display P3,
Adobe RGB (1998), ProPhoto (ROMM) and Rec.2020 are recognised by colorants. `icc::write_matrix_trc`
emits v4 display profiles (D50 PCS, Bradford `chad`) for export embedding.
diff --git a/crates/codecs/src/icc.rs b/crates/codecs/src/icc.rs
index 651122592..ec9e8efe3 100644
--- a/crates/codecs/src/icc.rs
+++ b/crates/codecs/src/icc.rs
@@ -146,7 +146,19 @@ pub fn write_matrix_trc(space: &RgbSpace, trc: &Trc) -> Vec {
};
p.description = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), desc)]));
p.copyright = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), "No copyright, use freely".into())]));
- align_tags(&p.encode().unwrap_or_default())
+ align_tags(&fixed_date(p.encode().unwrap_or_default()))
+}
+
+/// Zero the header's creation date/time (ICC.1 §7.2.8, bytes 24..36) so a profile's bytes depend
+/// only on its colour space and curve. `moxcms` stamps the wall clock there on every `encode`
+/// (ignoring `ColorProfile::creation_date_time`), which made two exports of one photo differ in
+/// the embedded profile and nowhere else, so output hashes could not be compared (#180). Readers
+/// do not interpret the field; all zeros is the conventional "unspecified" value.
+fn fixed_date(mut b: Vec) -> Vec {
+ if let Some(date) = b.get_mut(24..36) {
+ date.fill(0);
+ }
+ b
}
/// Re-lay a profile's tag data on 4-byte boundaries (ICC.1 §7.3.1). The encoder packs tags back to
@@ -300,6 +312,23 @@ mod tests {
let _ = SRGB;
}
+ /// Two profiles for one space are byte-identical, and the header date is fixed: exports of the
+ /// same photo with the same settings hash the same (#180).
+ #[test]
+ fn written_profiles_are_deterministic() {
+ for n in NamedSpace::ALL {
+ let a = write_named(n);
+ let b = write_named(n);
+ assert_eq!(a, b, "{n:?}");
+ assert_eq!(&a[24..36], &[0u8; 12], "{n:?} header date/time");
+ // the profile ID is "not computed", not a hash of a timestamp
+ assert_eq!(&a[84..100], &[0u8; 16], "{n:?} profile ID");
+ }
+ let lin = write_matrix_trc(&PROPHOTO, &Trc::Linear);
+ assert_eq!(lin, write_matrix_trc(&PROPHOTO, &Trc::Linear));
+ assert_eq!(&lin[24..36], &[0u8; 12]);
+ }
+
#[test]
fn garbage_is_none() {
assert!(parse(&[]).is_none());
diff --git a/crates/codecs/src/jpeg.rs b/crates/codecs/src/jpeg.rs
index 0096f9059..92409af35 100644
--- a/crates/codecs/src/jpeg.rs
+++ b/crates/codecs/src/jpeg.rs
@@ -105,12 +105,67 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option {
}
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
+ decode_with_fallback(bytes, opts, None)
+}
+
+/// Stored dimensions and EXIF orientation from the markers, without decoding the scan data. The
+/// file is refused, as a decode would refuse it, when it has no frame header, an unsupported frame
+/// type or component count, or when its markers don't run to an end-of-image marker after the
+/// image data (a truncated file). Damage *inside* the entropy-coded data can't be seen this way.
+pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
+ let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
+ if m.components == 0 {
+ return Err(Error::Malformed(F, "no frame header".into()));
+ }
+ check_size(F, m.width as u64, m.height as u64, &DecodeOptions::default())?;
+ if !matches!(m.sof, 0xC0..=0xC3) {
+ return Err(Error::Unsupported(F, "arithmetic-coded or hierarchical JPEG"));
+ }
+ if !matches!(m.components, 1 | 3 | 4) {
+ return Err(Error::Malformed(F, format!("{} colour components", m.components)));
+ }
+ if m.first_scan_components == 0 || !reaches_eoi(bytes) {
+ return Err(Error::Malformed(F, "truncated: the image data doesn't end with an end-of-image marker".into()));
+ }
+ let orientation = m.exif.as_deref().map(exif::summarize).unwrap_or_default().orientation.unwrap_or(1);
+ Ok((m.width, m.height, orientation))
+}
+
+/// Whether the markers after SOI, followed through every segment and scan, reach an EOI marker
+/// (the first one: the primary image's, ahead of any MPF images appended to the file). Segments
+/// are skipped by their length; in scan data `FF 00` (stuffing), `FF FF` (fill) and RSTn are not
+/// markers that end it.
+fn reaches_eoi(b: &[u8]) -> bool {
+ let mut p = 2usize;
+ loop {
+ // entropy-coded data (or garbage between segments, as `parse_markers` tolerates it)
+ let Some(skip) = b.get(p..).and_then(find_ff) else { return false };
+ p = p.saturating_add(skip);
+ let Some(&marker) = b.get(p.saturating_add(1)) else { return false };
+ p = match marker {
+ 0xD9 => return true,
+ 0xFF => p.saturating_add(1),
+ 0x00 | 0x01 | 0xD0..=0xD8 => p.saturating_add(2),
+ _ => {
+ let Some(len) = b.get(p.saturating_add(2)..p.saturating_add(4)).map(|l| u16::from_be_bytes([l[0], l[1]]) as usize) else {
+ return false;
+ };
+ if len < 2 {
+ return false;
+ }
+ p.saturating_add(2).saturating_add(len)
+ }
+ };
+ }
+}
+
+pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: Option) -> Result {
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
if m.components == 0 {
return Err(Error::Malformed(F, "no frame header".into()));
}
check_size(F, m.width as u64, m.height as u64, opts)?;
- let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= 2 * mw || m.height >= 2 * mh));
+ let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= mw.saturating_mul(2) || m.height >= mh.saturating_mul(2)));
// zune-jpeg handles the common cases fastest; jpeg-decoder covers DCT scaling, CMYK/YCCK,
// 12-bit and non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling).
let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components;
@@ -119,7 +174,10 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
} else {
decode_zune(bytes, &m)?
};
- let meta = Meta { icc: m.icc, exif: m.exif, xmp: m.xmp, ..Default::default() };
+ // A raw container may carry the colour declaration of an otherwise untagged preview.
+ // Apply it before linearization/resizing; existing JPEG metadata always wins.
+ let hint = fallback.filter(|_| m.icc.is_none() && m.exif.is_none()).map(|space| crate::SourceSpace::named(space, crate::SpaceOrigin::Container));
+ let meta = Meta { icc: m.icc, exif: m.exif, xmp: m.xmp, hint, ..Default::default() };
finish(F, raw, meta, (m.width, m.height), opts)
}
@@ -168,6 +226,21 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>)
Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf, bit_depth: depth })
}
+/// The first `0xFF` byte, eight bytes at a time (scan data has one every few hundred bytes: this
+/// is most of [`header`]'s time).
+fn find_ff(b: &[u8]) -> Option {
+ const ONES: u64 = 0x0101_0101_0101_0101;
+ let (words, tail) = b.as_chunks::<8>();
+ for (i, w) in words.iter().enumerate() {
+ // a zero byte in !w is an 0xFF byte in w
+ let x = !u64::from_ne_bytes(*w);
+ if x.wrapping_sub(ONES) & !x & (ONES << 7) != 0 {
+ return w.iter().position(|&v| v == 0xFF).map(|j| i * 8 + j);
+ }
+ }
+ tail.iter().position(|&v| v == 0xFF).map(|j| words.len() * 8 + j)
+}
+
/// The box to request from `jpeg-decoder::scale` so the decoded image still covers `mw × mh` once
/// fitted with the source aspect ratio.
fn scaled_request(w: u32, h: u32, mw: u32, mh: u32) -> (u16, u16) {
@@ -246,12 +319,75 @@ fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
mod tests {
use super::*;
+ #[test]
+ fn container_fallback_matches_tagged_jpeg_before_resizing_and_keeps_own_metadata() {
+ use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, Samples, SpaceOrigin, encode_jpeg};
+ let pixels: Vec = (0..16 * 16).flat_map(|i| if i % 2 == 0 { [120, 180, 90] } else { [30, 100, 200] }).collect();
+ let encode = |icc, exif| {
+ encode_jpeg(
+ &EncodeImage::new(16, 16, 3, Samples::U8(&pixels)),
+ 100,
+ ChromaSubsampling::S444,
+ &EncodeMeta { icc, exif, ..Default::default() },
+ )
+ .unwrap()
+ };
+ let untagged = encode(None, None);
+ let adobe = crate::icc::write_named(NamedSpace::AdobeRgb);
+ let tagged = encode(Some(&adobe), None);
+ for opts in [DecodeOptions::default(), DecodeOptions::fit(3, 3)] {
+ let with = crate::decode_jpeg_with_fallback(&untagged, opts, NamedSpace::AdobeRgb).unwrap();
+ let expected = crate::decode(&tagged, opts).unwrap();
+ assert_eq!(with.space.origin, SpaceOrigin::Container);
+ let (a, b) = (with.to_working(), expected.to_working());
+ for (p, q) in a.data.iter().zip(&b.data) {
+ assert!(p.iter().zip(q).all(|(a, b)| (a - b).abs() < 0.0001), "{p:?} != {q:?}");
+ }
+ }
+ let srgb = crate::icc::write_named(NamedSpace::Srgb);
+ let exif = crate::exif::minimal_exif(6);
+ for bytes in [encode(Some(&srgb), None), encode(None, Some(&exif)), encode(Some(b"bad ICC"), None)] {
+ let opts = DecodeOptions::fit(5, 5);
+ let with = crate::decode_jpeg_with_fallback(&bytes, opts, NamedSpace::AdobeRgb).unwrap();
+ let expected = crate::decode(&bytes, opts).unwrap();
+ assert_eq!(with.space, expected.space);
+ assert_eq!(with.image, expected.image);
+ assert_eq!(with.orientation, expected.orientation);
+ }
+ assert!(crate::decode_jpeg_with_fallback(b"bad JPEG", DecodeOptions::default(), NamedSpace::AdobeRgb).is_err());
+ }
+
#[test]
fn scaled_request_covers() {
assert_eq!(scaled_request(6000, 4000, 256, 256), (256, 171));
assert_eq!(scaled_request(100, 100, 256, 256), (100, 100));
}
+ #[test]
+ fn find_ff_matches_a_plain_search() {
+ let mut b: Vec = (0..300u32).map(|i| (i * 7 % 255) as u8).collect();
+ for at in [None, Some(0), Some(7), Some(8), Some(15), Some(203), Some(296), Some(299)] {
+ if let Some(i) = at {
+ b[i] = 0xFF;
+ }
+ for start in [0, 1, 5, 9, 290, 299, 300] {
+ let s = &b[start..];
+ assert_eq!(find_ff(s), s.iter().position(|&v| v == 0xFF), "{at:?} from {start}");
+ }
+ }
+ }
+
+ #[test]
+ fn unbounded_fit_box_decodes_at_full_size() {
+ // a full-size load asks for the largest box (`usize::MAX` saturated to `u32::MAX`): no DCT scaling,
+ // and no overflow working out whether to scale
+ use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
+ let pixels = vec![128u8; 32 * 24 * 3];
+ let jpeg = encode_jpeg(&EncodeImage::new(32, 24, 3, Samples::U8(&pixels)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
+ let d = crate::decode(&jpeg, DecodeOptions::fit(u32::MAX, u32::MAX)).unwrap();
+ assert_eq!((d.width, d.height), (32, 24));
+ }
+
#[test]
fn markers_on_garbage() {
assert!(parse_markers(b"").is_none());
diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs
index 708aa9b79..15990329a 100644
--- a/crates/codecs/src/lib.rs
+++ b/crates/codecs/src/lib.rs
@@ -6,6 +6,8 @@
//! profiles (matrix/TRC exactly, LUT/CMYK via the `moxcms` CMS), 16-bit and float precision are
//! preserved. EXIF/XMP/ICC blobs are passed through untouched; orientation is reported, never applied.
//! - [`to_working`] converts a decode result to linear Rec.2020 D65 (the pipeline working space).
+//! - [`read_header`] reads a file's stored dimensions and orientation from its headers, without
+//! decoding pixels (import probes).
//! - [`decode_thumbnail`] is the fast path for grid thumbnails (EXIF thumbnail or DCT-scaled decode).
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and (native, feature `avif`) AVIF, embedding
//! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export.
@@ -156,6 +158,60 @@ pub fn decode(bytes: &[u8], opts: DecodeOptions) -> Result {
std::panic::catch_unwind(|| decode_unguarded(bytes, format, &opts)).unwrap_or_else(|_| Err(Error::Malformed(format, "decoder panicked".into())))
}
+/// What a file's headers say about its image: enough to catalogue it without decoding pixels.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct Header {
+ pub format: Format,
+ /// Full-resolution stored dimensions, orientation **not** applied ([`Decoded::source_width`]).
+ pub width: u32,
+ pub height: u32,
+ /// EXIF/TIFF orientation 1..=8, 1 when absent ([`Decoded::orientation`]).
+ pub orientation: u16,
+}
+
+/// Read the stored dimensions and orientation of any format [`decode`] supports, agreeing with
+/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP and PSD are read from
+/// their headers (markers, chunks, IFD, resources). GIF, BMP and JPEG XL fall back to a small
+/// decode.
+///
+/// The header readers also refuse what a decode would refuse before reaching the pixels (an
+/// unsupported layout, compression or frame type) and files whose image data is cut short
+/// (truncated: the data runs past the end of the file, or a JPEG's markers don't reach its
+/// end-of-image marker). Damage *inside* compressed data that is all present is only found by a
+/// decode. Malformed input yields an error, never a panic (as for [`decode`], a panic inside a
+/// third-party parser is caught on unwinding targets).
+pub fn read_header(bytes: &[u8]) -> Result {
+ let format = sniff(bytes).ok_or(Error::UnknownFormat)?;
+ std::panic::catch_unwind(|| read_header_unguarded(bytes, format))
+ .unwrap_or_else(|_| Err(Error::Malformed(format, "header reader panicked".into())))
+}
+
+/// [`read_header`] without the panic guard (for fuzzing our own code paths).
+#[doc(hidden)]
+pub fn read_header_unguarded(bytes: &[u8], format: Format) -> Result {
+ let (width, height, orientation) = match format {
+ Format::Jpeg => jpeg::header(bytes)?,
+ Format::Png => png_codec::header(bytes)?,
+ Format::Tiff => tiff_codec::header(bytes)?,
+ Format::WebP => webp::header(bytes)?,
+ Format::Psd => psd::header(bytes)?,
+ // rare here (or, for JPEG XL, without a header reader yet): a small decode, as before
+ _ => {
+ let d = decode_unguarded(bytes, format, &DecodeOptions::fit(64, 64))?;
+ (d.source_width, d.source_height, d.orientation)
+ }
+ };
+ Ok(Header { format, width, height, orientation })
+}
+
+/// Decode a JPEG preview with its enclosing container's colour space as a fallback.
+/// A JPEG carrying ICC or EXIF metadata keeps its own interpretation. The fallback is applied
+/// before transfer decoding and linear-light resizing, never to already-resampled pixels.
+pub fn decode_jpeg_with_fallback(bytes: &[u8], opts: DecodeOptions, fallback: NamedSpace) -> Result {
+ std::panic::catch_unwind(|| jpeg::decode_with_fallback(bytes, &opts, Some(fallback)))
+ .unwrap_or_else(|_| Err(Error::Malformed(Format::Jpeg, "decoder panicked".into())))
+}
+
/// [`decode`] without the panic guard (for fuzzing our own code paths).
#[doc(hidden)]
pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result {
diff --git a/crates/codecs/src/png_codec.rs b/crates/codecs/src/png_codec.rs
index 529d120d9..600f60b05 100644
--- a/crates/codecs/src/png_codec.rs
+++ b/crates/codecs/src/png_codec.rs
@@ -57,6 +57,75 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
finish(F, raw, meta, (w, h), opts)
}
+/// Stored dimensions and EXIF orientation (`eXIf`, wherever it is) from the chunk headers, without
+/// inflating the image data. Refused, as a decode would refuse it: an invalid IHDR, an indexed
+/// image without a palette, no IDAT, or a file that ends before the chunk after the last IDAT
+/// begins (truncated image data). Chunk CRCs and the deflate stream are not checked.
+pub(crate) fn header(b: &[u8]) -> Result<(u32, u32, u16)> {
+ if !b.starts_with(b"\x89PNG\r\n\x1a\n") {
+ return Err(err("bad signature"));
+ }
+ let be32 = |p: usize| b.get(p..p.checked_add(4)?).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]]));
+ if be32(8) != Some(13) || b.get(12..16) != Some(b"IHDR") {
+ return Err(err("missing IHDR"));
+ }
+ let ihdr = b.get(16..29).ok_or_else(|| err("truncated IHDR"))?;
+ let (w, h) = (u32::from_be_bytes([ihdr[0], ihdr[1], ihdr[2], ihdr[3]]), u32::from_be_bytes([ihdr[4], ihdr[5], ihdr[6], ihdr[7]]));
+ let (depth, color, compression, filter, interlace) = (ihdr[8], ihdr[9], ihdr[10], ihdr[11], ihdr[12]);
+ if w > i32::MAX as u32 || h > i32::MAX as u32 {
+ return Err(err("dimensions out of range"));
+ }
+ check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
+ let depth_ok = match color {
+ 0 => matches!(depth, 1 | 2 | 4 | 8 | 16),
+ 3 => matches!(depth, 1 | 2 | 4 | 8),
+ 2 | 4 | 6 => matches!(depth, 8 | 16),
+ _ => false,
+ };
+ if !depth_ok || compression != 0 || filter != 0 || interlace > 1 {
+ return Err(err("invalid IHDR"));
+ }
+ let (mut p, mut palette, mut idat, mut after_idat, mut exif) = (8usize, false, false, false, None);
+ while let Some(len) = be32(p) {
+ let Some(kind) = p.checked_add(4).and_then(|k| b.get(k..k.checked_add(4)?)) else { break };
+ if idat && kind != b"IDAT" {
+ // a chunk follows the image data: the IDAT run is complete
+ after_idat = true;
+ }
+ if kind == b"IEND" {
+ break;
+ }
+ let start = p.saturating_add(8);
+ let data = (len <= i32::MAX as u32).then(|| start.checked_add(len as usize).and_then(|end| b.get(start..end))).flatten();
+ let Some(data) = data else {
+ if after_idat {
+ break; // a damaged chunk after the image data: a decode ignores it too
+ }
+ return Err(err("truncated"));
+ };
+ match kind {
+ b"PLTE" => palette = true,
+ b"IDAT" if after_idat => {} // a stray IDAT after other chunks: the first run is the image
+ b"IDAT" => {
+ if color == 3 && !palette {
+ return Err(err("indexed image without a palette"));
+ }
+ idat = true;
+ }
+ b"eXIf" if exif.is_none() => exif = Some(data.strip_prefix(b"Exif\0\0").unwrap_or(data)),
+ _ => {}
+ }
+ p = start.saturating_add(data.len()).saturating_add(4);
+ }
+ if !idat {
+ return Err(err("no image data"));
+ }
+ if !after_idat {
+ return Err(err("truncated image data"));
+ }
+ Ok((w, h, exif.map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1)))
+}
+
fn container_hint(info: &png::Info) -> Option {
if let Some(c) = &info.coding_independent_code_points {
let named = match c.color_primaries {
diff --git a/crates/codecs/src/psd.rs b/crates/codecs/src/psd.rs
index de668915a..3d26984ef 100644
--- a/crates/codecs/src/psd.rs
+++ b/crates/codecs/src/psd.rs
@@ -69,8 +69,8 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
let (icc, exif, xmp) = resources(res);
// Layer and mask information: only the layer count sign matters (merged transparency).
- let lm_len = if psb { r.u64()? as usize } else { r.u32()? as usize };
- let lm = r.take(lm_len)?;
+ let lm_len = if psb { r.u64()? } else { u64::from(r.u32()?) };
+ let lm = r.take(usize::try_from(lm_len).map_err(|_| err("truncated"))?)?;
let merged_transparency = if lm.len() >= if psb { 10 } else { 6 } {
let off = if psb { 8 } else { 4 };
let count = i16::from_be_bytes([lm[off], lm[off + 1]]);
@@ -88,33 +88,6 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
1 => return Err(Error::Unsupported(F, "1-bit bitmap PSD")),
_ => return Err(err("bad depth")),
};
- let plane = width * height * bpc;
- let row_bytes = width * bpc;
- let mut planes: Vec> = Vec::with_capacity(channels);
- match compression {
- 0 => {
- for _ in 0..channels {
- planes.push(r.take(plane)?.to_vec());
- }
- }
- 1 => {
- let counts_len = channels * height;
- let mut counts = Vec::with_capacity(counts_len);
- for _ in 0..counts_len {
- counts.push(if psb { r.u32()? as usize } else { r.u16()? as usize });
- }
- for c in 0..channels {
- let mut out = Vec::with_capacity(plane);
- for y in 0..height {
- let src = r.take(counts[c * height + y])?;
- unpackbits(src, row_bytes, &mut out);
- }
- planes.push(out);
- }
- }
- _ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
- }
-
let (model, n_color) = match mode {
1 | 8 => (Model::Gray, 1),
2 => (Model::Rgb, 1),
@@ -128,50 +101,106 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
return Err(err("too few channels"));
}
let alpha = merged_transparency && channels > n_color && mode != 2;
+ // Only the colour (+ alpha) planes are decoded; extra channels (spot colours, masks) are
+ // stored after them and never touched.
let used = n_color + alpha as usize;
- let n = width * height;
+ let too_large = || Error::TooLarge(width as u64, height as u64);
+ let n = width.checked_mul(height).ok_or_else(too_large)?;
+ let row_bytes = width.checked_mul(bpc).ok_or_else(too_large)?;
+ let plane = n.checked_mul(bpc).ok_or_else(too_large)?;
+ let mut planes: Vec> = Vec::with_capacity(used);
+ match compression {
+ 0 => {
+ // Raw planes are borrowed from the input, so their size is bounded by the file.
+ for _ in 0..used {
+ planes.push(r.take(plane)?.to_vec());
+ }
+ }
+ 1 => {
+ // Row byte-count table: one entry per row per channel (all channels, since the data
+ // follows it). Taken from the input before anything is allocated from it.
+ let entry = if psb { 4 } else { 2 };
+ let table_len = channels.checked_mul(height).and_then(|c| c.checked_mul(entry)).ok_or_else(|| err("truncated"))?;
+ let table = r.take(table_len)?;
+ let count = |i: usize| -> usize {
+ let at = i.saturating_mul(entry);
+ match table.get(at..at.saturating_add(entry)) {
+ Some(&[a, b]) => usize::from(u16::from_be_bytes([a, b])),
+ Some(&[a, b, c, d]) => u32::from_be_bytes([a, b, c, d]) as usize,
+ _ => 0,
+ }
+ };
+ // PackBits expands at most 64× (a 2-byte repeat run yields 128 bytes), so a row needs
+ // at least this many encoded bytes. Requiring it (and the data being present) before
+ // allocating bounds the decoded planes by the file size: a tiny file can no longer
+ // declare gigabytes of zero-padded or repeat-run rows.
+ let min_row = row_bytes.div_ceil(128).saturating_mul(2);
+ let mut data_len = 0usize;
+ for i in 0..used.saturating_mul(height) {
+ let c = count(i);
+ if c < min_row {
+ return Err(err("RLE row too short for its width"));
+ }
+ data_len = data_len.saturating_add(c);
+ }
+ if data_len > bytes.len().saturating_sub(r.p) {
+ return Err(err("truncated"));
+ }
+ for c in 0..used {
+ let mut out = zeroed::(plane, too_large)?;
+ for (y, row) in out.chunks_exact_mut(row_bytes).enumerate() {
+ let src = r.take(count(c * height + y))?;
+ unpackbits(src, row)?;
+ }
+ planes.push(out);
+ }
+ }
+ _ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
+ }
+ let samples = n.checked_mul(used).ok_or_else(too_large)?;
// Interleave to device samples.
let buf = if mode == 2 {
// Indexed → 8-bit RGB via the 768-byte palette.
- if color_mode_data.len() < 768 || depth != 8 {
+ let (Some(pal), true) = (color_mode_data.get(..768), depth == 8) else {
return Err(err("bad indexed palette"));
- }
- let mut v = Vec::with_capacity(n * 3);
- for &i in &planes[0] {
+ };
+ let idx = planes.first().ok_or_else(|| err("missing plane"))?;
+ let mut v = zeroed::(n.checked_mul(3).ok_or_else(too_large)?, too_large)?;
+ for (o, &i) in v.as_chunks_mut::<3>().0.iter_mut().zip(idx) {
let i = i as usize;
- v.extend_from_slice(&[color_mode_data[i], color_mode_data[256 + i], color_mode_data[512 + i]]);
+ *o = [pal[i], pal[256 + i], pal[512 + i]];
}
Buf::U8(v)
} else {
+ let cmyk = model == Model::Cmyk;
match bpc {
1 => {
- let mut v = vec![0u8; n * used];
- for (c, p) in planes.iter().take(used).enumerate() {
- for i in 0..n {
- let x = p[i];
+ let mut v = zeroed::(samples, too_large)?;
+ for (c, p) in planes.iter().enumerate() {
+ for (o, &x) in v.iter_mut().skip(c).step_by(used).zip(p) {
// PSD CMYK stores 255 = no ink; our CMYK convention is 0 = no ink.
- v[i * used + c] = if model == Model::Cmyk && c < 4 { 255 - x } else { x };
+ *o = if cmyk && c < 4 { 255 - x } else { x };
}
}
Buf::U8(v)
}
2 => {
- let mut v = vec![0u16; n * used];
- for (c, p) in planes.iter().take(used).enumerate() {
- for i in 0..n {
- let x = u16::from_be_bytes([p[2 * i], p[2 * i + 1]]);
- v[i * used + c] = if model == Model::Cmyk && c < 4 { 65535 - x } else { x };
+ let mut v = zeroed::(samples, too_large)?;
+ for (c, p) in planes.iter().enumerate() {
+ for (o, s) in v.iter_mut().skip(c).step_by(used).zip(p.as_chunks::<2>().0) {
+ let x = u16::from_be_bytes(*s);
+ *o = if cmyk && c < 4 { 65535 - x } else { x };
}
}
Buf::U16(v)
}
_ => {
- let mut v = vec![0f32; n * used];
- for (c, p) in planes.iter().take(used).enumerate() {
- for i in 0..n {
- let x = f32::from_be_bytes([p[4 * i], p[4 * i + 1], p[4 * i + 2], p[4 * i + 3]]);
- v[i * used + c] = if model == Model::Cmyk && c < 4 { 1.0 - x } else { x };
+ let mut v = zeroed::(samples, too_large)?;
+ for (c, p) in planes.iter().enumerate() {
+ for (o, s) in v.iter_mut().skip(c).step_by(used).zip(p.as_chunks::<4>().0) {
+ let x = f32::from_be_bytes(*s);
+ *o = if cmyk && c < 4 { 1.0 - x } else { x };
}
}
Buf::F32(v)
@@ -182,25 +211,111 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (width as u32, height as u32), opts)
}
-/// PackBits: append exactly `row_bytes` bytes (zero-padded if the run data is short).
-fn unpackbits(mut src: &[u8], row_bytes: usize, out: &mut Vec) {
- let target = out.len() + row_bytes;
- while out.len() < target && !src.is_empty() {
- let h = src[0] as i8;
- src = &src[1..];
- if h >= 0 {
- let n = (h as usize + 1).min(src.len()).min(target - out.len());
- out.extend_from_slice(&src[..n]);
- src = &src[(h as usize + 1).min(src.len())..];
- } else if h != -128 {
- let n = ((1 - h as isize) as usize).min(target - out.len());
- if let Some(&b) = src.first() {
- out.extend(std::iter::repeat_n(b, n));
- src = &src[1..];
+/// Stored dimensions and EXIF orientation (resource 1058) from the header and image resources,
+/// without reading the merged image data. Refused, as a decode would refuse it: an unsupported
+/// depth, colour mode or compression, or merged image data that runs past the end of the file
+/// (truncated). The run-length data itself is not unpacked.
+pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
+ let mut r = Rd { b: bytes, p: 0 };
+ if r.take(4)? != b"8BPS" {
+ return Err(err("bad signature"));
+ }
+ let psb = match r.u16()? {
+ 1 => false,
+ 2 => true,
+ _ => return Err(err("bad version")),
+ };
+ r.take(6)?;
+ let channels = r.u16()? as usize;
+ let height = r.u32()? as usize;
+ let width = r.u32()? as usize;
+ let depth = r.u16()?;
+ let mode = r.u16()?;
+ check_size(F, width as u64, height as u64, &DecodeOptions::default())?;
+ if channels == 0 || channels > 56 {
+ return Err(err("bad channel count"));
+ }
+ let cm_len = r.u32()? as usize;
+ let color_mode_data = r.take(cm_len)?;
+ let res_len = r.u32()? as usize;
+ let (_, exif, _) = resources(r.take(res_len)?);
+ let lm_len = if psb { r.u64()? as usize } else { r.u32()? as usize };
+ r.take(lm_len)?;
+ let compression = r.u16()?;
+ let bpc = match depth {
+ 8 => 1usize,
+ 16 => 2,
+ 32 => 4,
+ 1 => return Err(Error::Unsupported(F, "1-bit bitmap PSD")),
+ _ => return Err(err("bad depth")),
+ };
+ let n_color = match mode {
+ 1 | 8 | 7 | 2 => 1,
+ 3 => 3,
+ 4 => 4,
+ 9 => return Err(Error::Unsupported(F, "Lab PSD")),
+ _ => return Err(err("unknown colour mode")),
+ };
+ if channels < n_color {
+ return Err(err("too few channels"));
+ }
+ if mode == 2 && (color_mode_data.len() < 768 || depth != 8) {
+ return Err(err("bad indexed palette"));
+ }
+ let rest = bytes.len().saturating_sub(r.p);
+ let need = match compression {
+ 0 => width.checked_mul(height).and_then(|n| n.checked_mul(bpc)).and_then(|n| n.checked_mul(channels)),
+ 1 => {
+ // the row byte counts, then the rows they count
+ let rows = channels.checked_mul(height).ok_or_else(|| err("truncated"))?;
+ let table = rows.checked_mul(if psb { 4 } else { 2 }).filter(|&t| t <= rest).ok_or_else(|| err("truncated"))?;
+ let mut total = table;
+ for _ in 0..rows {
+ total = total.saturating_add(if psb { r.u32()? as usize } else { r.u16()? as usize });
}
+ Some(total)
+ }
+ _ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
+ };
+ if need.is_none_or(|n| n > rest) {
+ return Err(err("truncated"));
+ }
+ let orientation = exif.as_deref().map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1);
+ Ok((width as u32, height as u32, orientation))
+}
+
+/// A zero-filled buffer whose allocation failure is an error, not an abort.
+fn zeroed(len: usize, too_large: impl Fn() -> Error) -> Result> {
+ let mut v = Vec::new();
+ v.try_reserve_exact(len).map_err(|_| too_large())?;
+ v.resize(len, T::default());
+ Ok(v)
+}
+
+/// PackBits: decode one row into `row` (pre-zeroed, so short run data leaves zero padding).
+/// A run that would write past the end of the row is an error; trailing bytes are ignored.
+fn unpackbits(mut src: &[u8], row: &mut [u8]) -> Result<()> {
+ let mut at = 0;
+ while at < row.len() {
+ let Some((&h, rest)) = src.split_first() else { break };
+ src = rest;
+ let h = h as i8;
+ if h >= 0 {
+ let n = h as usize + 1;
+ let lit = src.get(..n).unwrap_or(src);
+ let dst = row.get_mut(at..at + lit.len()).ok_or_else(|| err("RLE row overruns its width"))?;
+ dst.copy_from_slice(lit);
+ at += lit.len();
+ src = src.get(n..).unwrap_or(&[]);
+ } else if h != -128 {
+ let n = (1 - h as isize) as usize;
+ let Some((&b, rest)) = src.split_first() else { break };
+ src = rest;
+ row.get_mut(at..at + n).ok_or_else(|| err("RLE row overruns its width"))?.fill(b);
+ at += n;
}
}
- out.resize(target, 0);
+ Ok(())
}
fn resources(mut b: &[u8]) -> (Option>, Option>, Option) {
@@ -234,11 +349,89 @@ mod tests {
#[test]
fn packbits() {
- let mut out = Vec::new();
- unpackbits(&[0xFE, 0xAA, 0x02, 0x80, 0x00, 0x2A], 6, &mut out);
- assert_eq!(out, vec![0xAA, 0xAA, 0xAA, 0x80, 0x00, 0x2A]);
- let mut out = Vec::new();
- unpackbits(&[0x05, 1], 4, &mut out);
- assert_eq!(out, vec![1, 0, 0, 0]);
+ let mut out = [0u8; 6];
+ unpackbits(&[0xFE, 0xAA, 0x02, 0x80, 0x00, 0x2A], &mut out).unwrap();
+ assert_eq!(out, [0xAA, 0xAA, 0xAA, 0x80, 0x00, 0x2A]);
+ // Short run data is zero-padded.
+ let mut out = [0u8; 4];
+ unpackbits(&[0x01, 1], &mut out).unwrap();
+ assert_eq!(out, [1, 0, 0, 0]);
+ // Truncated literal run: copies what is there.
+ let mut out = [0u8; 4];
+ unpackbits(&[0x03, 7, 8], &mut out).unwrap();
+ assert_eq!(out, [7, 8, 0, 0]);
+ }
+
+ #[test]
+ fn packbits_overrun_is_an_error() {
+ // Repeat run of 128 into a 4-byte row.
+ let mut out = [0u8; 4];
+ assert!(unpackbits(&[0x81, 0xAA], &mut out).is_err());
+ // Literal run of 6 into a 4-byte row.
+ assert!(unpackbits(&[0x05, 1, 2, 3, 4, 5, 6], &mut out).is_err());
+ }
+
+ /// PSD header + empty colour-mode/resources/layer sections + compression id.
+ fn header(channels: u16, w: u32, h: u32, depth: u16, mode: u16, compression: u16) -> Vec {
+ let mut b = b"8BPS".to_vec();
+ b.extend_from_slice(&1u16.to_be_bytes());
+ b.extend_from_slice(&[0; 6]);
+ b.extend_from_slice(&channels.to_be_bytes());
+ b.extend_from_slice(&h.to_be_bytes());
+ b.extend_from_slice(&w.to_be_bytes());
+ b.extend_from_slice(&depth.to_be_bytes());
+ b.extend_from_slice(&mode.to_be_bytes());
+ b.extend_from_slice(&[0; 12]); // colour mode data, resources, layer & mask: all empty
+ b.extend_from_slice(&compression.to_be_bytes());
+ b
+ }
+
+ #[test]
+ fn huge_rle_with_truncated_count_table_is_rejected_before_allocating() {
+ // 1 × 2^30 px, 56 channels: the row table alone would be 56 Gi entries; the file has 4 bytes.
+ let mut b = header(56, 1, 1 << 30, 32, 3, 1);
+ b.extend_from_slice(&[0, 0, 0, 0]);
+ let e = decode(&b, &DecodeOptions::default()).unwrap_err();
+ assert!(matches!(e, Error::Malformed(_, ref m) if m == "truncated"), "{e:?}");
+ }
+
+ #[test]
+ fn huge_rle_of_empty_rows_is_rejected_before_allocating() {
+ // 2^30 × 1 px RGB at 32 bits: every row count is 0, which used to zero-pad 3 × 4 GiB.
+ let mut b = header(3, 1 << 30, 1, 32, 3, 1);
+ b.extend_from_slice(&[0; 6]);
+ let e = decode(&b, &DecodeOptions::default()).unwrap_err();
+ assert!(matches!(e, Error::Malformed(_, ref m) if m.contains("too short")), "{e:?}");
+ // 1 Gpx RGB 8-bit whose (plausible) row counts claim more data than the file holds.
+ let mut b = header(3, 1 << 20, 1 << 10, 8, 3, 1);
+ b.extend_from_slice(&[0xFF; 3 * 1024 * 2]);
+ let e = decode(&b, &DecodeOptions::default()).unwrap_err();
+ assert!(matches!(e, Error::Malformed(_, ref m) if m == "truncated"), "{e:?}");
+ }
+
+ #[test]
+ fn rle_repeat_run_past_row_width_is_an_error() {
+ // 4 × 1 gray 8-bit; the single row is a 128-byte repeat run.
+ let mut b = header(1, 4, 1, 8, 1, 1);
+ b.extend_from_slice(&2u16.to_be_bytes());
+ b.extend_from_slice(&[0x81, 0x55]);
+ let e = decode(&b, &DecodeOptions::default()).unwrap_err();
+ assert!(matches!(e, Error::Malformed(_, ref m) if m.contains("overruns")), "{e:?}");
+ }
+
+ #[test]
+ fn rle_gray16_and_extra_channels_decode() {
+ // 2 × 2 gray 16-bit with an extra (unused) channel whose data is absent: only the used
+ // plane is decoded. Row: repeat run of 4 bytes 0x80 -> samples 0x8080.
+ let mut b = header(2, 2, 2, 16, 1, 1);
+ for _ in 0..4 {
+ b.extend_from_slice(&2u16.to_be_bytes());
+ }
+ b.extend_from_slice(&[0xFD, 0x80, 0xFD, 0x80]);
+ let d = decode(&b, &DecodeOptions::default()).unwrap();
+ assert_eq!((d.width, d.height), (2, 2));
+ let p = d.image.get(1, 1);
+ let q = d.image.get(0, 0);
+ assert!(p[0] > 0.0 && (p[0] - q[0]).abs() < 1e-6, "{p:?}");
}
}
diff --git a/crates/codecs/src/tiff_codec.rs b/crates/codecs/src/tiff_codec.rs
index 3166c2dc4..b077c214a 100644
--- a/crates/codecs/src/tiff_codec.rs
+++ b/crates/codecs/src/tiff_codec.rs
@@ -1,5 +1,6 @@
-//! TIFF decode via the `tiff` crate: 8/16/32-bit integer, 16/32/64-bit float, gray/RGB/CMYK/palette,
-//! alpha (associated or not), ICC (34675), XMP (700), orientation (274). First image only.
+//! TIFF decode via the `tiff` crate: 1/2/4/8/16/32/64-bit integer, 16/32/64-bit float, gray/RGB/CMYK,
+//! alpha (associated or not), ICC (34675), XMP (700), orientation (274). First image only. Palette
+//! (indexed-colour) images are refused by the `tiff` crate itself, on every read path.
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
use crate::{DecodeOptions, Decoded, Error, Format, Result};
@@ -14,10 +15,7 @@ fn err(e: impl std::fmt::Display) -> Error {
}
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
- let mut limits = Limits::default();
- limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize;
- limits.ifd_value_size = 32 << 20;
- let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits);
+ let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits(opts));
let (w, h) = d.dimensions().map_err(err)?;
check_size(F, w as u64, h as u64, opts)?;
let ct = d.colortype().map_err(err)?;
@@ -25,26 +23,12 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
let icc = tag_bytes(&mut d, Tag::IccProfile);
let xmp = tag_bytes(&mut d, Tag::from_u16_exhaustive(700)).map(|v| String::from_utf8_lossy(&v).trim_end_matches('\0').to_string());
let orientation = d.get_tag_u32(Tag::Orientation).ok().map(|v| v as u16);
- let photometric = d.get_tag_u32(Tag::PhotometricInterpretation).ok();
let planar = d.get_tag_u32(Tag::PlanarConfiguration).ok() == Some(2);
let extra = d.get_tag_u16_vec(Tag::ExtraSamples).ok().unwrap_or_default();
let colormap = d.get_tag_u16_vec(Tag::ColorMap).ok();
let compression = d.get_tag_u32(Tag::Compression).ok();
- let (model, n_color, n_samples, bits) = match ct {
- ColorType::Gray(b) => (Model::Gray, 1, 1, b),
- ColorType::GrayA(b) => (Model::Gray, 1, 2, b),
- ColorType::RGB(b) => (Model::Rgb, 3, 3, b),
- ColorType::RGBA(b) => (Model::Rgb, 3, 4, b),
- ColorType::CMYK(b) => (Model::Cmyk, 4, 4, b),
- ColorType::CMYKA(b) => (Model::Cmyk, 4, 5, b),
- ColorType::Palette(b) => (Model::Rgb, 1, 1, b),
- // JPEG-compressed YCbCr is converted to RGB by the JPEG decoder.
- ColorType::YCbCr(b) if compression == Some(7) => (Model::Rgb, 3, 3, b),
- ColorType::Multiband { bit_depth, num_samples } if num_samples >= 3 => (Model::Rgb, 3, num_samples as usize, bit_depth),
- ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth),
- other => return Err(Error::Unsupported(F, unsupported_name(other))),
- };
+ let (model, n_color, n_samples, bits) = layout(ct, compression, planar)?;
let has_alpha = n_samples > n_color && !matches!(ct, ColorType::Palette(_));
let premultiplied = has_alpha && extra.first() == Some(&1);
@@ -52,6 +36,7 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
let (wu, hu) = (w as usize, h as usize);
let n = wu * hu;
let mut buf = match res {
+ DecodingResult::U8(v) if bits < 8 => Buf::U8(unpack(&v, wu, hu, n_samples, bits)?),
DecodingResult::U8(v) => Buf::U8(v),
DecodingResult::U16(v) => Buf::U16(v),
// Rare integer widths: keep 16 significant bits (plenty for display-referred data).
@@ -94,14 +79,8 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
return finish(F, raw, meta, (w, h), opts);
}
- // WhiteIsZero → invert.
- if photometric == Some(0) {
- match &mut buf {
- Buf::U8(v) => v.iter_mut().for_each(|x| *x = 255 - *x),
- Buf::U16(v) => v.iter_mut().for_each(|x| *x = 65535 - *x),
- Buf::F32(v) => v.iter_mut().for_each(|x| *x = 1.0 - *x),
- }
- }
+ // WhiteIsZero needs nothing here: the `tiff` crate inverts those samples as it reads them (and
+ // refuses WhiteIsZero layouts it can't invert), so inverting again would show a negative.
// Drop extra samples beyond colour + one alpha.
let keep = n_color + has_alpha as usize;
@@ -113,6 +92,67 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
finish(F, raw, meta, (w, h), opts)
}
+fn limits(opts: &DecodeOptions) -> Limits {
+ let mut limits = Limits::default();
+ limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize;
+ limits.ifd_value_size = 32 << 20;
+ limits
+}
+
+/// The sample layout of a colour type we can decode: (model, colour samples, samples per pixel,
+/// bits per sample).
+fn layout(ct: ColorType, compression: Option, planar: bool) -> Result<(Model, usize, usize, u8)> {
+ let l = match ct {
+ ColorType::Gray(b) => (Model::Gray, 1, 1, b),
+ ColorType::GrayA(b) => (Model::Gray, 1, 2, b),
+ ColorType::RGB(b) => (Model::Rgb, 3, 3, b),
+ ColorType::RGBA(b) => (Model::Rgb, 3, 4, b),
+ ColorType::CMYK(b) => (Model::Cmyk, 4, 4, b),
+ ColorType::CMYKA(b) => (Model::Cmyk, 4, 5, b),
+ ColorType::Palette(b) => (Model::Rgb, 1, 1, b),
+ // JPEG-compressed YCbCr is converted to RGB by the JPEG decoder.
+ ColorType::YCbCr(b) if compression == Some(7) => (Model::Rgb, 3, 3, b),
+ ColorType::Multiband { bit_depth, num_samples } if num_samples >= 3 => (Model::Rgb, 3, num_samples as usize, bit_depth),
+ ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth),
+ other => return Err(Error::Unsupported(F, unsupported_name(other))),
+ };
+ if l.3 < 8 && planar && l.2 > 1 {
+ return Err(Error::Unsupported(F, "planar TIFF with samples narrower than a byte"));
+ }
+ Ok(l)
+}
+
+/// Stored dimensions and orientation from the first IFD, without reading the image data. Refused,
+/// as a decode would refuse it: a layout or compression we can't decode, or strips/tiles that
+/// start past the end of the file (truncated). The compressed data itself is not checked.
+pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
+ let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits(&DecodeOptions::default()));
+ let (w, h) = d.dimensions().map_err(err)?;
+ check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
+ let ct = d.colortype().map_err(err)?;
+ let compression = d.get_tag_u32(Tag::Compression).ok();
+ let planar = d.get_tag_u32(Tag::PlanarConfiguration).ok() == Some(2);
+ layout(ct, compression, planar)?;
+ // what this build of the `tiff` crate decompresses: none, CCITT G4, LZW, JPEG, Deflate, PackBits
+ if !matches!(compression.unwrap_or(1), 1 | 4 | 5 | 7 | 8 | 32773 | 32946) {
+ return Err(Error::Unsupported(F, "compression method"));
+ }
+ let chunks = |offsets: Tag, counts: Tag, d: &mut Decoder>| {
+ Some((d.get_tag_u64_vec(offsets).ok()?, d.get_tag_u64_vec(counts).ok()?))
+ };
+ let (offsets, counts) = chunks(Tag::StripOffsets, Tag::StripByteCounts, &mut d)
+ .or_else(|| chunks(Tag::TileOffsets, Tag::TileByteCounts, &mut d))
+ .ok_or_else(|| err("no image data"))?;
+ // Only where each chunk starts: some writers overstate StripByteCounts, and the `tiff` crate
+ // decodes such files (it stops once it has the pixels), so a chunk's end proves nothing.
+ let len = bytes.len() as u64;
+ if offsets.is_empty() || offsets.iter().zip(&counts).any(|(&o, &c)| c > 0 && o >= len) {
+ return Err(err("truncated: image data past the end of the file"));
+ }
+ let orientation = d.get_tag_u32(Tag::Orientation).ok().map(|v| v as u16).filter(|o| (1..=8).contains(o)).unwrap_or(1);
+ Ok((w, h, orientation))
+}
+
/// A BYTE/UNDEFINED tag as bytes (the decoder may surface unknown byte tags as unsigned values).
fn tag_bytes(d: &mut Decoder, tag: Tag) -> Option> {
use tiff::decoder::ifd::Value;
@@ -140,6 +180,29 @@ fn unsupported_name(ct: ColorType) -> &'static str {
}
}
+/// Samples narrower than a byte (1-bit bilevel and fax scans, 2- and 4-bit gray) arrive packed, most
+/// significant bits first, each row padded to a whole byte. Spread them out one per byte, scaled to
+/// 0–255 like any 8-bit sample.
+fn unpack(packed: &[u8], width: usize, height: usize, samples: usize, bits: u8) -> Result> {
+ if !matches!(bits, 1 | 2 | 4) {
+ return Err(Error::Unsupported(F, "TIFF samples of 3, 5, 6 or 7 bits"));
+ }
+ let bits = bits as usize;
+ let per_row = width.checked_mul(samples).ok_or_else(|| err("row too wide"))?;
+ let row_bytes = per_row.checked_mul(bits).ok_or_else(|| err("row too wide"))?.div_ceil(8);
+ if row_bytes == 0 || packed.len() / row_bytes < height {
+ return Err(err("sample buffer too short"));
+ }
+ let max = (1u8 << bits) - 1;
+ let scale = 255 / max; // 255, 85, 17: exact
+ let mut out = Vec::with_capacity(per_row.saturating_mul(height));
+ for row in packed.chunks_exact(row_bytes).take(height) {
+ let values = row.iter().flat_map(|&b| (1..=8 / bits).map(move |i| (b >> (8 - bits * i)) & max));
+ out.extend(values.take(per_row).map(|v| v * scale));
+ }
+ Ok(out)
+}
+
fn interleave(buf: Buf, n: usize, s: usize) -> Buf {
fn go(v: Vec, n: usize, s: usize) -> Vec {
if v.len() < n * s {
diff --git a/crates/codecs/src/webp.rs b/crates/codecs/src/webp.rs
index 8da561017..189146ab8 100644
--- a/crates/codecs/src/webp.rs
+++ b/crates/codecs/src/webp.rs
@@ -27,3 +27,30 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
let raw = Raw { width: w as usize, height: h as usize, model: Model::Rgb, alpha, premultiplied: false, buf: Buf::U8(buf), bit_depth: 8 };
finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (w, h), opts)
}
+
+/// Stored dimensions and EXIF orientation from the RIFF chunks and the bitstream header, without
+/// decoding the image. Refused, as a decode would refuse it: no image chunk (`VP8 `, `VP8L` or an
+/// animation frame), or one that runs past the end of the file (truncated). The compressed data
+/// itself is not checked.
+pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
+ let mut p = 12usize;
+ loop {
+ let Some(h) = bytes.get(p..p.saturating_add(8)) else { return Err(err("no image data")) };
+ let size = u32::from_le_bytes([h[4], h[5], h[6], h[7]]) as usize;
+ let end = p.saturating_add(8).saturating_add(size);
+ if matches!(&h[0..4], b"VP8 " | b"VP8L" | b"ANMF") {
+ if end > bytes.len() {
+ return Err(err("truncated image data"));
+ }
+ break;
+ }
+ p = end.saturating_add(size & 1);
+ }
+ let mut d = image_webp::WebPDecoder::new(std::io::Cursor::new(bytes)).map_err(err)?;
+ d.set_memory_limit(bytes.len());
+ let (w, h) = d.dimensions();
+ check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
+ let exif = d.exif_metadata().ok().flatten();
+ let exif = exif.as_deref().map(|e| e.strip_prefix(b"Exif\0\0").unwrap_or(e));
+ Ok((w, h, exif.map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1)))
+}
diff --git a/crates/codecs/tests/fuzz.rs b/crates/codecs/tests/fuzz.rs
index cf858d6b5..15b236b0c 100644
--- a/crates/codecs/tests/fuzz.rs
+++ b/crates/codecs/tests/fuzz.rs
@@ -30,8 +30,10 @@ fn try_all(bytes: &[u8]) {
if let Some(f) = sniff(bytes) {
let _ = decode_unguarded(bytes, f, &small_opts());
let _ = decode_unguarded(bytes, f, &DecodeOptions { max_size: Some((8, 8)), max_pixels: 1 << 22 });
+ let _ = read_header_unguarded(bytes, f);
}
let _ = decode(bytes, small_opts());
+ let _ = read_header(bytes);
let _ = decode_thumbnail_with(bytes, &ThumbnailOptions { max_pixels: small_opts().max_pixels, ..ThumbnailOptions::new(16) });
let _ = lightcraft_codecs::icc::parse(bytes);
}
diff --git a/crates/codecs/tests/header.rs b/crates/codecs/tests/header.rs
new file mode 100644
index 000000000..6ff12d08b
--- /dev/null
+++ b/crates/codecs/tests/header.rs
@@ -0,0 +1,366 @@
+//! `read_header` (import probes) agrees with a decode on dimensions and orientation, refuses what a
+//! decode refuses (truncated files included), and returns errors — never panics — on hostile input.
+
+use lightcraft_codecs::exif::minimal_exif;
+use lightcraft_codecs::*;
+use lightcraft_raster::Rgba8;
+
+fn pixels(w: usize, h: usize) -> Rgba8 {
+ Rgba8::from_fn(w, h, |x, y| [(x * 37 % 256) as u8, (y * 53 % 256) as u8, ((x ^ y) * 9 % 256) as u8, (128 + x % 100) as u8])
+}
+
+fn meta(exif: &Option>) -> EncodeMeta<'_> {
+ EncodeMeta { exif: exif.as_deref(), xmp: Some(""), ..Default::default() }
+}
+
+/// A TIFF with an Orientation tag (`encode_tiff` writes none), via the `tiff` crate's encoder.
+fn tiff_oriented(img: &Rgba8, orientation: u16) -> Vec {
+ use tiff::encoder::{TiffEncoder, colortype};
+ let rgb: Vec = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
+ let mut cur = std::io::Cursor::new(Vec::new());
+ let mut enc = TiffEncoder::new(&mut cur).unwrap();
+ let mut im = enc.new_image::(img.width as u32, img.height as u32).unwrap();
+ im.encoder().write_tag(tiff::tags::Tag::Orientation, orientation).unwrap();
+ im.write_data(&rgb).unwrap();
+ cur.into_inner()
+}
+
+/// A minimal 8-bit RGB PSD (raw or PackBits-literal merged data) with an EXIF resource.
+fn psd(w: usize, h: usize, orientation: Option, rle: bool) -> Vec {
+ let mut b = Vec::new();
+ b.extend_from_slice(b"8BPS");
+ b.extend_from_slice(&1u16.to_be_bytes());
+ b.extend_from_slice(&[0; 6]);
+ b.extend_from_slice(&3u16.to_be_bytes());
+ b.extend_from_slice(&(h as u32).to_be_bytes());
+ b.extend_from_slice(&(w as u32).to_be_bytes());
+ b.extend_from_slice(&8u16.to_be_bytes());
+ b.extend_from_slice(&3u16.to_be_bytes());
+ b.extend_from_slice(&0u32.to_be_bytes()); // colour mode data
+ let mut res = Vec::new();
+ if let Some(o) = orientation {
+ let exif = minimal_exif(o);
+ res.extend_from_slice(b"8BIM");
+ res.extend_from_slice(&1058u16.to_be_bytes());
+ res.extend_from_slice(&[0, 0]); // empty pascal name, padded
+ res.extend_from_slice(&(exif.len() as u32).to_be_bytes());
+ res.extend_from_slice(&exif);
+ if exif.len() % 2 == 1 {
+ res.push(0);
+ }
+ }
+ b.extend_from_slice(&(res.len() as u32).to_be_bytes());
+ b.extend_from_slice(&res);
+ b.extend_from_slice(&0u32.to_be_bytes()); // layer and mask info
+ b.extend_from_slice(&(rle as u16).to_be_bytes());
+ let plane = |c: usize| (0..w * h).map(move |i| ((i * (c + 3) * 11) % 256) as u8);
+ if rle {
+ // every row as literal runs of at most 128 bytes (PackBits' longest)
+ let row_len = w + w.div_ceil(128);
+ for _ in 0..3 * h {
+ b.extend_from_slice(&(row_len as u16).to_be_bytes());
+ }
+ for c in 0..3 {
+ let p: Vec = plane(c).collect();
+ for row in p.chunks(w) {
+ for run in row.chunks(128) {
+ b.push(run.len() as u8 - 1);
+ b.extend_from_slice(run);
+ }
+ }
+ }
+ } else {
+ for c in 0..3 {
+ b.extend(plane(c));
+ }
+ }
+ b
+}
+
+/// Generated files of every format with a header reader, plus a fallback one (BMP via `image`).
+fn fixtures(w: usize, h: usize) -> Vec<(String, Vec)> {
+ let img = pixels(w, h);
+ let e = EncodeImage::rgba8(&img);
+ let rgb: Vec = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
+ let rgb_e = EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&rgb));
+ let u16s: Vec = (0..w * h * 3).map(|i| (i * 331) as u16).collect();
+ let mut out = Vec::new();
+ for o in [None, Some(1), Some(6), Some(8)] {
+ let exif = o.map(minimal_exif);
+ let tag = format!("o{}", o.unwrap_or(0));
+ out.push((format!("jpeg 420 {tag}"), encode_jpeg(&rgb_e, 85, ChromaSubsampling::S420, &meta(&exif)).unwrap()));
+ out.push((format!("jpeg 444 {tag}"), encode_jpeg(&rgb_e, 85, ChromaSubsampling::S444, &meta(&exif)).unwrap()));
+ out.push((format!("png rgba {tag}"), encode_png(&e, &meta(&exif)).unwrap()));
+ out.push((format!("png 16-bit {tag}"), encode_png(&EncodeImage::new(w as u32, h as u32, 3, Samples::U16(&u16s)), &meta(&exif)).unwrap()));
+ out.push((format!("webp {tag}"), encode_webp_lossless(&e, &meta(&exif)).unwrap()));
+ out.push((format!("psd {tag}"), psd(w, h, o, false)));
+ out.push((format!("psd rle {tag}"), psd(w, h, o, true)));
+ out.push((format!("tiff {tag}"), tiff_oriented(&img, o.unwrap_or(1))));
+ }
+ out.push(("tiff lzw".into(), encode_tiff(&e, TiffCompression::Lzw, &EncodeMeta::default()).unwrap()));
+ out.push(("tiff packbits".into(), encode_tiff(&e, TiffCompression::PackBits, &EncodeMeta::default()).unwrap()));
+ let mut bmp = Vec::new();
+ image::codecs::bmp::BmpEncoder::new(&mut bmp).encode(&rgb, w as u32, h as u32, image::ExtendedColorType::Rgb8).unwrap();
+ out.push(("bmp".into(), bmp));
+ out
+}
+
+/// What the import probe learnt before it read headers: a small decode.
+fn decoded(bytes: &[u8]) -> Result {
+ let d = decode(bytes, DecodeOptions::fit(64, 64))?;
+ Ok(Header { format: d.format, width: d.source_width, height: d.source_height, orientation: d.orientation })
+}
+
+#[test]
+fn header_matches_decode() {
+ for (w, h) in [(160, 96), (96, 160), (7, 5)] {
+ for (name, bytes) in fixtures(w, h) {
+ let header = read_header(&bytes).unwrap_or_else(|e| panic!("{name} {w}×{h}: {e}"));
+ assert_eq!(header, decoded(&bytes).unwrap(), "{name} {w}×{h}");
+ assert_eq!((header.width, header.height), (w as u32, h as u32), "{name}");
+ }
+ }
+ let o = |name: &str| fixtures(16, 8).into_iter().find(|f| f.0 == name).map(|f| read_header(&f.1).unwrap().orientation).unwrap();
+ assert_eq!((o("jpeg 420 o6"), o("png rgba o8"), o("webp o6"), o("psd o8"), o("tiff o6"), o("jpeg 444 o0")), (6, 8, 6, 8, 6, 1));
+}
+
+/// Whenever the decode the probe used to run accepts a file (here: every cut of every fixture),
+/// the header agrees with it: reading headers never refuses a file that decodes. The reverse
+/// is checked for truncation, which the header readers detect.
+#[test]
+fn truncated_files_agree_with_decode() {
+ // 160 px wide: large JPEGs take the DCT-scaled (strict) decode path the probe took for photos
+ for (name, bytes) in fixtures(160, 96) {
+ let n = bytes.len();
+ let mut cuts: Vec = (0..100).map(|i| n * i / 100).collect();
+ cuts.extend(n.saturating_sub(64)..n);
+ for cut in cuts {
+ let part = &bytes[..cut];
+ if let Ok(d) = decoded(part) {
+ assert_eq!(read_header(part).ok(), Some(d), "{name} cut at {cut}/{n}");
+ }
+ }
+ for cut in [n / 4, n / 2, n * 9 / 10] {
+ assert!(read_header(&bytes[..cut]).is_err(), "{name}: cut at {cut}/{n} accepted");
+ }
+ }
+}
+
+#[test]
+fn truncation_details() {
+ let f = fixtures(160, 96);
+ let get = |name: &str| f.iter().find(|x| x.0 == name).map(|x| x.1.clone()).unwrap();
+ let jpeg = get("jpeg 420 o6");
+ assert!(read_header(&jpeg[..jpeg.len() - 2]).is_err(), "a JPEG without its EOI is truncated");
+ let mut trailing = jpeg.clone();
+ trailing.extend_from_slice(&[0; 100]);
+ assert_eq!(read_header(&trailing).unwrap().orientation, 6, "bytes after the EOI are fine");
+ let png = get("png rgba o6");
+ assert!(read_header(&png[..png.len() - 2]).is_ok(), "a damaged IEND is ignored, as by a decode");
+ assert!(read_header(&png[..png.len() - 12]).is_err(), "nothing after the image data: truncated");
+}
+
+/// eXIf after the image data counts, as it does for a decode (which reads the whole file).
+#[test]
+fn png_exif_after_idat() {
+ let img = pixels(9, 4);
+ let plain = encode_png(&EncodeImage::rgba8(&img), &EncodeMeta::default()).unwrap();
+ let exif = minimal_exif(6);
+ let mut chunk = (exif.len() as u32).to_be_bytes().to_vec();
+ chunk.extend_from_slice(b"eXIf");
+ chunk.extend_from_slice(&exif);
+ chunk.extend_from_slice(&[0; 4]); // CRC: not checked by either reader
+ let iend = plain.len() - 12;
+ let mut b = plain[..iend].to_vec();
+ b.extend_from_slice(&chunk);
+ b.extend_from_slice(&plain[iend..]);
+ assert_eq!(read_header(&b).unwrap().orientation, 6);
+}
+
+fn jpeg_segments(sof: u8, w: u16, h: u16, comps: u8) -> Vec {
+ let mut b = vec![0xFF, 0xD8, 0xFF, sof, 0, 8 + 3 * comps as u16 as u8, 8];
+ b.extend_from_slice(&h.to_be_bytes());
+ b.extend_from_slice(&w.to_be_bytes());
+ b.push(comps);
+ for c in 0..comps {
+ b.extend_from_slice(&[c + 1, 0x11, 0]);
+ }
+ b.extend_from_slice(&[0xFF, 0xDA, 0, 8, 1, 1, 0, 0, 63, 0]);
+ b.extend_from_slice(&[0x12, 0xFF, 0x00, 0x34, 0xFF, 0xD0, 0x56]);
+ b.extend_from_slice(&[0xFF, 0xD9]);
+ b
+}
+
+fn png_ihdr(w: u32, h: u32, depth: u8, color: u8) -> Vec {
+ let mut b = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
+ b.extend_from_slice(&w.to_be_bytes());
+ b.extend_from_slice(&h.to_be_bytes());
+ b.extend_from_slice(&[depth, color, 0, 0, 0, 0, 0, 0, 0]);
+ b
+}
+
+fn png_chunk(b: &mut Vec, kind: &[u8; 4], len: u32, data: &[u8]) {
+ b.extend_from_slice(&len.to_be_bytes());
+ b.extend_from_slice(kind);
+ b.extend_from_slice(data);
+ b.extend_from_slice(&[0; 4]);
+}
+
+#[test]
+fn hostile_headers_are_errors() {
+ // a well-formed synthetic JPEG header is read
+ assert_eq!(read_header(&jpeg_segments(0xC0, 33, 21, 3)).unwrap(), Header { format: Format::Jpeg, width: 33, height: 21, orientation: 1 });
+ let jpeg_bad = [
+ jpeg_segments(0xC0, 0, 21, 3), // zero width
+ jpeg_segments(0xC0, 33, 0, 3), // zero height (DNL)
+ jpeg_segments(0xC0, 65535, 65535, 3), // 4.3 Gpx
+ jpeg_segments(0xC9, 33, 21, 3), // arithmetic coding
+ jpeg_segments(0xC5, 33, 21, 3), // hierarchical
+ jpeg_segments(0xC0, 33, 21, 2), // two components
+ jpeg_segments(0xC0, 33, 21, 3)[..30].to_vec(),
+ vec![0xFF, 0xD8, 0xFF, 0xE1, 0, 0], // segment length 0
+ vec![0xFF, 0xD8, 0xFF, 0xE1, 0xFF, 0xFF], // segment past the end
+ vec![0xFF, 0xD8, 0xFF, 0xD9],
+ ];
+ for (i, b) in jpeg_bad.iter().enumerate() {
+ assert!(read_header(b).is_err(), "JPEG case {i}");
+ }
+ let mut no_sos = jpeg_segments(0xC0, 33, 21, 3);
+ no_sos.truncate(2 + 2 + 17);
+ no_sos.extend_from_slice(&[0xFF, 0xD9]);
+ assert!(read_header(&no_sos).is_err(), "JPEG without a scan");
+
+ let mut ok = png_ihdr(5, 3, 8, 2);
+ png_chunk(&mut ok, b"IDAT", 1, &[0]);
+ png_chunk(&mut ok, b"IEND", 0, &[]);
+ assert_eq!(read_header(&ok).unwrap(), Header { format: Format::Png, width: 5, height: 3, orientation: 1 });
+ let png_case = |ihdr: Vec, chunks: &[(&[u8; 4], u32, &[u8])]| {
+ let mut b = ihdr;
+ for (k, l, d) in chunks {
+ png_chunk(&mut b, k, *l, d);
+ }
+ b
+ };
+ let idat: (&[u8; 4], u32, &[u8]) = (b"IDAT", 1, &[0]);
+ let iend: (&[u8; 4], u32, &[u8]) = (b"IEND", 0, &[]);
+ let png_bad = [
+ png_case(png_ihdr(0, 3, 8, 2), &[idat, iend]),
+ png_case(png_ihdr(5, 0, 8, 2), &[idat, iend]),
+ png_case(png_ihdr(0x8000_0000, 1, 8, 2), &[idat, iend]),
+ png_case(png_ihdr(65536, 65536, 8, 2), &[idat, iend]),
+ png_case(png_ihdr(5, 3, 4, 2), &[idat, iend]), // RGB at 4 bits
+ png_case(png_ihdr(5, 3, 8, 5), &[idat, iend]), // no colour type 5
+ png_case(png_ihdr(5, 3, 8, 3), &[idat, iend]), // indexed without PLTE
+ png_case(png_ihdr(5, 3, 8, 2), &[iend]), // no IDAT
+ png_case(png_ihdr(5, 3, 8, 2), &[idat]), // nothing after the image data
+ png_case(png_ihdr(5, 3, 8, 2), &[(b"IDAT", 0xFFFF_FFFF, &[0]), iend]),
+ png_case(png_ihdr(5, 3, 8, 2), &[(b"tEXt", 0x7FFF_FFFF, &[0]), idat, iend]),
+ png_ihdr(5, 3, 8, 2)[..20].to_vec(),
+ b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDX\0\0\0\x05\0\0\0\x03\x08\x02\0\0\0".to_vec(),
+ ];
+ for (i, b) in png_bad.iter().enumerate() {
+ assert!(read_header(b).is_err(), "PNG case {i}");
+ }
+
+ let tiff = encode_tiff(&EncodeImage::rgba8(&pixels(6, 4)), TiffCompression::None, &EncodeMeta::default()).unwrap();
+ let ifd = u32::from_le_bytes([tiff[4], tiff[5], tiff[6], tiff[7]]) as usize;
+ let mut tiff_bad = vec![b"II*\0\xff\xff\xff\x7f".to_vec(), b"II*\0\x08\0\0\0".to_vec(), b"II*\0\x04\0\0\0".to_vec()];
+ // cut short after the IFD
+ assert!(ifd < tiff.len() / 2, "the IFD comes before the image data");
+ tiff_bad.push(tiff[..tiff.len() - 10].to_vec());
+ for (i, b) in tiff_bad.iter().enumerate() {
+ assert!(read_header(b).is_err(), "TIFF case {i}");
+ }
+ // a strip offset pointing past the end of the file
+ let t = |b: &[u8], p: usize| u16::from_le_bytes([b[p], b[p + 1]]);
+ let n = t(&tiff, ifd) as usize;
+ let mut far = tiff.clone();
+ for e in 0..n {
+ let p = ifd + 2 + e * 12;
+ if t(&far, p) == 273 && t(&far, p + 2) == 4 && u32::from_le_bytes([far[p + 4], far[p + 5], far[p + 6], far[p + 7]]) == 1 {
+ far[p + 8..p + 12].copy_from_slice(&0x7FFF_0000u32.to_le_bytes());
+ }
+ }
+ assert_ne!(far, tiff, "the strip offset was found");
+ assert!(read_header(&far).is_err(), "strip past the end");
+
+ let webp_bad = [
+ b"RIFF\0\0\0\0WEBP".to_vec(),
+ b"RIFF\0\0\0\0WEBPVP8L\xff\xff\xff\xff\x2f".to_vec(),
+ b"RIFF\0\0\0\0WEBPXXXX\xff\xff\xff\xff".to_vec(),
+ b"RIFF\0\0\0\0WEBPVP8 \x04\0\0\0\0\0\0\0".to_vec(),
+ ];
+ for (i, b) in webp_bad.iter().enumerate() {
+ assert!(read_header(b).is_err(), "WebP case {i}");
+ }
+
+ let good = psd(4, 3, Some(6), true);
+ assert_eq!(read_header(&good).unwrap().orientation, 6);
+ let set = |pos: usize, v: &[u8]| {
+ let mut b = good.clone();
+ b[pos..pos + v.len()].copy_from_slice(v);
+ b
+ };
+ let res_len = u32::from_be_bytes([good[30], good[31], good[32], good[33]]) as usize;
+ let compression = 34 + res_len + 4;
+ let mut psd_bad = vec![
+ set(12, &0u16.to_be_bytes()), // no channels
+ set(12, &57u16.to_be_bytes()), // too many
+ set(12, &2u16.to_be_bytes()), // too few for RGB
+ set(14, &0x7FFF_FFFFu32.to_be_bytes()), // absurd height
+ set(18, &0u32.to_be_bytes()), // zero width
+ set(22, &1u16.to_be_bytes()), // 1-bit
+ set(22, &7u16.to_be_bytes()), // bad depth
+ set(24, &9u16.to_be_bytes()), // Lab
+ set(24, &77u16.to_be_bytes()), // unknown mode
+ set(24, &2u16.to_be_bytes()), // indexed without a palette
+ set(compression, &2u16.to_be_bytes()), // ZIP
+ set(26, &0xFFFF_FFFFu32.to_be_bytes()), // colour mode data past the end
+ set(30, &0xFFFF_FFF0u32.to_be_bytes()), // resources past the end
+ ];
+ // a 1 × 2^30 RLE image declared in a tiny file: refused without reading 2^30 row counts
+ let mut tall = good.clone();
+ tall[14..18].copy_from_slice(&(1u32 << 30).to_be_bytes());
+ tall[18..22].copy_from_slice(&1u32.to_be_bytes());
+ psd_bad.push(tall);
+ psd_bad.push(good[..good.len() - 1].to_vec());
+ for (i, b) in psd_bad.iter().enumerate() {
+ assert!(read_header(b).is_err(), "PSD case {i}");
+ }
+
+ for b in [&b""[..], b"\xFF\xD8\xFF", b"\x89PNG\r\n\x1a\n", b"II*\0", b"MM\0*", b"8BPS\0\x01", b"GIF89a", b"not an image at all"] {
+ assert!(read_header(b).is_err(), "{b:?}");
+ }
+}
+
+/// The same with a few random bytes changed: whenever the decode accepts the damaged file, the
+/// header agrees with it (deterministic seed).
+#[test]
+fn mutated_files_agree_with_decode() {
+ let mut s: u64 = 0x1234_5678_9abc_def1;
+ let mut rnd = || {
+ s ^= s << 13;
+ s ^= s >> 7;
+ s ^= s << 17;
+ s
+ };
+ let (mut decodable, mut disagreements) = (0, Vec::new());
+ for (name, bytes) in fixtures(160, 96) {
+ for _ in 0..300 {
+ let mut b = bytes.clone();
+ for _ in 0..1 + rnd() % 3 {
+ let i = rnd() as usize % b.len();
+ b[i] = rnd() as u8;
+ }
+ if let Ok(d) = decoded(&b) {
+ decodable += 1;
+ let h = read_header(&b);
+ if h.as_ref().ok() != Some(&d) {
+ disagreements.push(format!("{name}: decode {d:?}, header {h:?}"));
+ }
+ }
+ }
+ }
+ assert!(decodable > 1000, "{decodable}");
+ assert!(disagreements.is_empty(), "{disagreements:#?}");
+}
diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs
index 4bce59de8..a700ddf6c 100644
--- a/crates/codecs/tests/roundtrip.rs
+++ b/crates/codecs/tests/roundtrip.rs
@@ -245,6 +245,80 @@ fn tiff_cmyk_and_orientation() {
assert!(red[0] > 0.99 && red[1] < 0.01 && red[2] < 0.01, "{red:?}");
}
+/// A one-strip, uncompressed, single-sample TIFF (little-endian) whose samples are `bits` wide and
+/// already packed into `strip`, one padded row after another.
+fn packed_tiff(width: u16, height: u16, bits: u16, photometric: u16, strip: &[u8]) -> Vec {
+ const STRIP_AT: u32 = 8 + 2 + 9 * 12 + 4;
+ let short = |tag: u16, v: u16| [&tag.to_le_bytes()[..], &3u16.to_le_bytes(), &1u32.to_le_bytes(), &v.to_le_bytes(), &[0, 0]].concat();
+ let long = |tag: u16, v: u32| [&tag.to_le_bytes()[..], &4u16.to_le_bytes(), &1u32.to_le_bytes(), &v.to_le_bytes()].concat();
+ let mut b = b"II*\0".to_vec();
+ b.extend(8u32.to_le_bytes());
+ b.extend(9u16.to_le_bytes());
+ b.extend(short(256, width));
+ b.extend(short(257, height));
+ b.extend(short(258, bits));
+ b.extend(short(259, 1));
+ b.extend(short(262, photometric));
+ b.extend(long(273, STRIP_AT));
+ b.extend(short(277, 1));
+ b.extend(short(278, height));
+ b.extend(long(279, strip.len() as u32));
+ b.extend(0u32.to_le_bytes());
+ assert_eq!(b.len(), STRIP_AT as usize);
+ b.extend_from_slice(strip);
+ b
+}
+
+/// WhiteIsZero (photometric 0) gray: the lowest sample is white. The `tiff` crate already inverts these
+/// samples, and a second inversion here used to show every WhiteIsZero scan as a negative.
+#[test]
+fn tiff_white_is_zero_is_inverted_once() {
+ // Two pixels: the lowest sample, then the highest, at 8 and 16 bits.
+ for (bits, strip) in [(8, vec![0u8, 255]), (16, [0u16.to_le_bytes(), 65535u16.to_le_bytes()].concat())] {
+ for (photometric, first) in [(0, 1.0), (1, 0.0)] {
+ let d = decode(&packed_tiff(2, 1, bits, photometric, &strip), DecodeOptions::default()).unwrap();
+ let (a, b) = (d.image.get(0, 0)[1], d.image.get(1, 0)[1]);
+ assert!((a - first).abs() < 1e-6 && (b - (1.0 - first)).abs() < 1e-6, "{bits}-bit, photometric {photometric}: {a} {b}");
+ }
+ }
+}
+
+/// 1-, 2- and 4-bit samples (bilevel scans, fax images, low-depth gray) arrive packed eight, four or
+/// two to a byte with each row padded to a byte; they used to be rejected as "sample buffer too short".
+#[test]
+fn tiff_samples_narrower_than_a_byte() {
+ let gray = |d: &Decoded, x: usize, y: usize| d.image.get(x, y)[1];
+
+ // 10 × 2 bilevel: rows are 2 bytes, the last 6 bits of each are padding (set, to prove they are ignored).
+ // Row 0 alternates set/clear bits; row 1 is clear but for its last pixel.
+ let rows = [0b1010_1010, 0b1011_1111, 0b0000_0000, 0b0111_1111];
+ // What a set bit decodes to: white for BlackIsZero (1), black for WhiteIsZero (0).
+ for (photometric, set_bit) in [(1, 1.0), (0, 0.0)] {
+ let d = decode(&packed_tiff(10, 2, 1, photometric, &rows), DecodeOptions::default()).unwrap();
+ assert_eq!((d.image.width, d.image.height, d.bit_depth), (10, 2, 1));
+ assert!(d.grayscale);
+ let clear_bit = 1.0 - set_bit;
+ for x in 0..10 {
+ let want = (if x % 2 == 0 { set_bit } else { clear_bit }, if x == 9 { set_bit } else { clear_bit });
+ assert!((gray(&d, x, 0) - want.0).abs() < 1e-6, "row 0, x {x}, photometric {photometric}");
+ assert!((gray(&d, x, 1) - want.1).abs() < 1e-6, "row 1, x {x}, photometric {photometric}");
+ }
+ }
+
+ // 2-bit: 0, 1, 2, 3 → 0, 85, 170, 255. 4-bit: 0, 15, 8 → 0, 255, 136 (the row's last nibble is padding).
+ let d = decode(&packed_tiff(4, 1, 2, 1, &[0b00_01_10_11]), DecodeOptions::default()).unwrap();
+ for (x, v) in [0, 85, 170, 255].into_iter().enumerate() {
+ assert!((gray(&d, x, 0) - srgb_to_linear(v as f32 / 255.0)).abs() < 1e-6, "2-bit x {x}");
+ }
+ let d = decode(&packed_tiff(3, 1, 4, 1, &[0x0F, 0x8F]), DecodeOptions::default()).unwrap();
+ for (x, v) in [0, 255, 136].into_iter().enumerate() {
+ assert!((gray(&d, x, 0) - srgb_to_linear(v as f32 / 255.0)).abs() < 1e-6, "4-bit x {x}");
+ }
+
+ // A strip shorter than its rows is an error, never a panic.
+ assert!(decode(&packed_tiff(10, 2, 1, 1, &rows[..3]), DecodeOptions::default()).is_err());
+}
+
#[test]
fn webp_lossless_exact() {
let img = gradient(31, 19);
diff --git a/crates/color/src/cct.rs b/crates/color/src/cct.rs
index abd241dde..9cb2b96c5 100644
--- a/crates/color/src/cct.rs
+++ b/crates/color/src/cct.rs
@@ -2,7 +2,9 @@
//!
//! The Planckian locus uses the Kim et al. (2002) cubic approximation (1667–25000 K); outside that
//! range it is extended linearly in mired space. Tint is a signed offset perpendicular to the locus in
-//! CIE 1960 (u, v): **positive tint = magenta** (below the locus), `Duv = −tint / TINT_SCALE`.
+//! CIE 1960 (u, v). Tint describes the **correction**: positive adds magenta, negative adds green.
+//! A positive correction neutralises a green illuminant above the locus, so its source white uses
+//! `Duv = tint / TINT_SCALE`. Bradford adaptation removes that source cast.
use crate::{Mat3, RgbSpace, Xy, bradford};
@@ -65,11 +67,12 @@ fn locus_normal(t: f64) -> (f64, f64) {
if ny < 0.0 { (-nx, -ny) } else { (nx, ny) }
}
-/// Chromaticity of the white point for temperature `t` (K) and `tint`.
+/// Source illuminant chromaticity for temperature `t` (K) and correction `tint`.
+/// Positive tint means a green source white, whose correction adds magenta to the image.
pub fn temp_tint_to_xy(t: f64, tint: f64) -> Xy {
let (u, v) = locus_uv(t);
let (nu, nv) = locus_normal(t);
- let duv = -tint / TINT_SCALE;
+ let duv = tint / TINT_SCALE;
uv_to_xy(u + nu * duv, v + nv * duv)
}
@@ -104,7 +107,7 @@ pub fn xy_to_temp_tint(p: Xy) -> (f64, f64) {
let (lu, lv) = locus_uv(t);
let (nu, nv) = locus_normal(t);
let duv = (u - lu) * nu + (v - lv) * nv;
- (t, -duv * TINT_SCALE)
+ (t, duv * TINT_SCALE)
}
/// Matrix (linear RGB in `space` → same space) that white-balances a scene lit by `src` so that it
@@ -141,10 +144,18 @@ mod tests {
let warm = temp_tint_to_xy(3000.0, 0.0);
let cool = temp_tint_to_xy(9000.0, 0.0);
assert!(warm.x > cool.x);
- // positive tint is magenta: lower v
+ // A positive magenta correction specifies a greener source illuminant: higher v.
let (_, v0) = xy_to_uv(temp_tint_to_xy(5000.0, 0.0));
let (_, v1) = xy_to_uv(temp_tint_to_xy(5000.0, 50.0));
- assert!(v1 < v0);
+ assert!(v1 > v0);
+ }
+
+ /// Daylight lies above the Planckian locus, so D65 reads as a small positive tint, as
+ /// Lightroom's Daylight preset (5500 K, +10) has it (#188).
+ #[test]
+ fn daylight_has_a_positive_tint() {
+ let (_, tint) = xy_to_temp_tint(crate::D65);
+ assert!((5.0..15.0).contains(&tint), "{tint}");
}
#[test]
diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs
index ff4daedd9..bfce1893e 100644
--- a/crates/develop/src/lib.rs
+++ b/crates/develop/src/lib.rs
@@ -68,6 +68,32 @@ impl DevelopSettings {
!self.disabled_sections.iter().any(|s| s == section)
}
+ /// The settings as rendered: an Edit-panel section whose eye is off contributes nothing (issue
+ /// #316). Light (with its tone curve), Color (white balance, presence, the mixers, colour grading
+ /// and point colour) and Detail are reset to their defaults here; Effects, Optics, Geometry and
+ /// Calibration the pipeline switches off where it applies them. Borrowed when every section is on.
+ pub fn effective(&self) -> std::borrow::Cow<'_, DevelopSettings> {
+ let off = |section: &str| !self.section_enabled(section);
+ if !(off("light") || off("color") || off("detail")) {
+ return std::borrow::Cow::Borrowed(self);
+ }
+ let mut d = self.clone();
+ if off("light") {
+ d.reset_section(Section::Light);
+ d.reset_section(Section::Curve);
+ }
+ if off("color") {
+ for section in [Section::Color, Section::Mixer, Section::BwMix, Section::Grading, Section::PointColor] {
+ d.reset_section(section);
+ }
+ d.point_colors.clear();
+ }
+ if off("detail") {
+ d.reset_section(Section::Detail);
+ }
+ std::borrow::Cow::Owned(d)
+ }
+
pub fn set_section_enabled(&mut self, section: &str, on: bool) {
self.disabled_sections.retain(|s| s != section);
if !on {
diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs
index 800f229b5..910cd07dd 100644
--- a/crates/develop/src/settings.rs
+++ b/crates/develop/src/settings.rs
@@ -158,6 +158,7 @@ impl WbMode {
pub struct WhiteBalance {
pub mode: WbMode,
pub temp: f64,
+ /// Correction direction: negative adds green, positive adds magenta.
pub tint: f64,
}
@@ -539,8 +540,9 @@ impl Default for Optics {
}
}
-/// Lens corrections embedded in a DNG file (`OpcodeList3`: `WarpRectilinear`, `FixVignetteRadial`), converted
-/// to the oriented, default-cropped image. This is camera/file data (stored on the photo record, not in the develop
+/// Lens corrections embedded in the file, converted to the oriented, default-cropped image: a DNG's `OpcodeList3`
+/// (`WarpRectilinear`, `FixVignetteRadial`), or a raw reader's equivalent of the camera's own correction (Panasonic /
+/// Leica RW2 distortion, `lightcraft_raw`'s `vendor/rw2.rs`). This is camera/file data (stored on the photo record, not in the develop
/// settings); "Enable Profile Corrections" applies it, scaled by the profile distortion/vignetting amounts.
/// LightCraft never uses Adobe LCP lens profiles.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index c2d4e8a80..a916d1806 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -30,6 +30,9 @@ serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
ab_glyph = { workspace = true }
+# Apply the font’s vertical substitutions and origins to Japanese watermarks.
+harfrust = { version = "0.12", default-features = false, features = ["std"] }
+unicode-segmentation = "1.13"
lightcraft-segment = { workspace = true, optional = true }
[features]
@@ -38,3 +41,4 @@ sam = ["dep:lightcraft-segment"]
[dev-dependencies]
libc = { workspace = true }
+lightcraft-tiff = { workspace = true }
diff --git a/crates/engine/examples/render_bench.rs b/crates/engine/examples/render_bench.rs
index ac6a9a60d..55dc2b11d 100644
--- a/crates/engine/examples/render_bench.rs
+++ b/crates/engine/examples/render_bench.rs
@@ -93,7 +93,9 @@ fn main() {
for path in &args {
let bytes = std::fs::read(path).expect("read");
println!("{path}");
- let raw = lightcraft_raw::decode(&bytes).expect("decode");
+ let mut raw = lightcraft_raw::decode(&bytes).expect("decode");
+ // as the loader does: lens corrections are applied by the pipeline, not in the raw develop
+ raw.opcodes.list3.retain(|op| !op.is_lens_correction());
println!(" {}×{} cpp {} cfa {:?}", raw.width, raw.height, raw.cpp, raw.cfa.as_ref().map(|c| c.name()));
println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode"))));
println!(" {:<36} {}", "normalize:", best(n, || drop(raw.normalized().expect("norm"))));
diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs
index cd0d475d2..55ea2d808 100644
--- a/crates/engine/src/camera_preview.rs
+++ b/crates/engine/src/camera_preview.rs
@@ -1,5 +1,5 @@
//! Estimate the starting look of a raw without a camera colour matrix (Sony ARW, Nikon NEF, Panasonic
-//! RW2) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
+//! RW2, Fujifilm RAF, Canon CR3) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
//! never output pixels or a replacement for RAW editing.
//! A global matrix can't follow the camera's hue-dependent rendering (the best matrix rendered a
//! lime shirt olive that the camera kept lime): a hue/saturation table fitted to the residuals
@@ -31,7 +31,10 @@ pub(crate) const PROFILE_PROXY: usize = 192;
/// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the
/// same formats by file extension.
pub(crate) fn file_local_look(format: RawFormat) -> bool {
- matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2)
+ matches!(
+ format,
+ RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Raf | RawFormat::Cr3 | RawFormat::Cr2 | RawFormat::Pef
+ )
}
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option {
@@ -40,13 +43,13 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
}
let (sensor, reference) = proxies(raw, bytes, transform, PROXY)?;
// A camera profile pooled from many photos knows colours this photo shows too little of;
- // only the tone and chroma curves are fitted per photo (DRO and picture styles vary).
+ // try its colour first and fit tone/chroma per photo (DRO and picture styles vary).
let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get);
let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone())));
let look = fit_pairs_with(&sensor, &reference, colour)?;
if lightcraft_pipeline::profiling() {
eprintln!(
- "[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile {}",
+ "[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile available {}",
raw.format,
look.matrix.0,
look.tone,
@@ -60,9 +63,8 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s
/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020).
fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f)> {
- let jpeg = lightcraft_raw::embedded_preview(bytes)?;
let edge = (2 * size).max(384) as u32;
- let decoded = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 }).ok()?;
+ let decoded = crate::files::decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 })?;
let mut reference = decoded.to_working();
let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height));
if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 {
@@ -84,14 +86,161 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz
}
// Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution.
let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2;
- let sensor = sensor_proxy(raw, k, edge as usize)?;
+ let mut sensor = sensor_proxy(raw, k, edge as usize)?;
+ let gain = 2f32.powf(transform.baseline_exposure as f32);
+ let to_working = |p: [f32; 3]| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain));
+ if raw.format == RawFormat::Cr3 {
+ // Orient the camera JPEG and the sensor identically before collecting pixel correspondences.
+ sensor = sensor.into_oriented(raw.orientation);
+ reference = reference.into_oriented(raw.orientation);
+ sensor.map_in_place(to_working);
+ // Canon's JPEG can be cropped differently from the sensor. Estimate only that common
+ // framing from edge directions, independently of the subsequent colour fit.
+ sensor = align_cr3_framing(sensor, &reference);
+ }
let mut sensor = fit(&sensor, size, size, Filter::Box);
let reference = fit(&reference, sensor.width, sensor.height, Filter::Box);
- let gain = 2f32.powf(transform.baseline_exposure as f32);
- sensor.map_in_place(|p| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain)));
+ if raw.format != RawFormat::Cr3 {
+ sensor.map_in_place(to_working);
+ }
Some((sensor, reference))
}
+#[derive(Clone, Copy, Debug)]
+struct Cr3Framing {
+ scale: f32,
+ /// Translation as a fraction of the oriented frame's width and height.
+ offset: [f32; 2],
+}
+
+impl Cr3Framing {
+ const IDENTITY: Self = Self { scale: 1.0, offset: [0.0; 2] };
+
+ fn source(self, x: f32, y: f32, w: usize, h: usize) -> (f32, f32) {
+ let (w, h) = (w as f32, h as f32);
+ ((x - w * 0.5) * self.scale + w * (0.5 + self.offset[0]), (y - h * 0.5) * self.scale + h * (0.5 + self.offset[1]))
+ }
+}
+
+/// Register the JPEG's remaining framing on a fixed proxy. Edge directions survive different
+/// white balance and monotone camera tone; no target RGB values train this three-parameter fit.
+/// A separate third of the edges must confirm a substantial improvement and a close match.
+/// A weak, flat or unrelated preview leaves the sensor framing unchanged.
+fn estimate_cr3_framing(sensor: &Rgb32f, reference: &Rgb32f) -> Option {
+ if sensor.width.checked_mul(sensor.height)? != sensor.data.len() || reference.width.checked_mul(reference.height)? != reference.data.len() {
+ return None;
+ }
+ let sensor = fit(sensor, PROXY, PROXY, Filter::Box);
+ let (w, h) = (sensor.width, sensor.height);
+ if w < 16 || h < 16 {
+ return None;
+ }
+ let reference = fit(reference, w, h, Filter::Box);
+ if (reference.width, reference.height) != (w, h) {
+ return None;
+ }
+ let mut gradients = Rgb32f::new(w, h);
+ let mut edges = Vec::new();
+ for y in 1..h - 1 {
+ for x in 1..w - 1 {
+ let derivative = |image: &Rgb32f| {
+ [
+ luminance_2020(image.data[y * w + x + 1]) - luminance_2020(image.data[y * w + x - 1]),
+ luminance_2020(image.data[(y + 1) * w + x]) - luminance_2020(image.data[(y - 1) * w + x]),
+ ]
+ };
+ let d = derivative(&sensor);
+ gradients.data[y * w + x] = [d[0], d[1], 0.0];
+ let d = derivative(&reference);
+ let magnitude = d[0].hypot(d[1]);
+ let value = luminance_2020(reference.data[y * w + x]);
+ if (0.02..0.9).contains(&value) && magnitude.is_finite() && magnitude > 0.03 {
+ edges.push((x as f32 + 0.5, y as f32 + 0.5, d, magnitude, (x * 17 + y * 11) % 3 == 0));
+ }
+ }
+ }
+ if edges.len() < 192 {
+ return None;
+ }
+ let score = |framing: Cr3Framing, held_out: bool| -> Option {
+ let (mut agreement, mut weight, mut valid, mut total) = (0.0, 0.0, 0usize, 0usize);
+ for &(x, y, target, magnitude, holdout) in &edges {
+ if holdout != held_out {
+ continue;
+ }
+ total += 1;
+ let (sx, sy) = framing.source(x, y, w, h);
+ if sx < 1.5 || sy < 1.5 || sx > w as f32 - 1.5 || sy > h as f32 - 1.5 {
+ continue;
+ }
+ let sample = gradients.sample_bilinear(sx, sy);
+ let length = sample[0].hypot(sample[1]);
+ if !length.is_finite() || length <= 0.01 {
+ continue;
+ }
+ let k = f64::from(magnitude.min(0.25));
+ agreement += k * f64::from((sample[0] * target[0] + sample[1] * target[1]) / (length * magnitude));
+ weight += k;
+ valid += 1;
+ }
+ (valid >= 64 && valid * 5 >= total * 4 && weight > 0.0).then_some(agreement / weight)
+ };
+ let before = score(Cr3Framing::IDENTITY, false)?;
+ let mut best = (before, Cr3Framing::IDENTITY);
+ // At most 10% zoom and two proxy pixels of translation; a coarse search followed by one
+ // local refinement prevents unbounded optimisation on the photograph's contents.
+ for s in -10..=10 {
+ for tx in -4..=4 {
+ for ty in -4..=4 {
+ let framing = Cr3Framing { scale: 1.0 + s as f32 * 0.01, offset: [tx as f32 * 0.5 / w as f32, ty as f32 * 0.5 / h as f32] };
+ if let Some(value) = score(framing, false)
+ && value > best.0
+ {
+ best = (value, framing);
+ }
+ }
+ }
+ }
+ let coarse = best.1;
+ for s in -5..=5 {
+ for tx in -4..=4 {
+ for ty in -4..=4 {
+ let framing = Cr3Framing {
+ scale: coarse.scale + s as f32 * 0.002,
+ offset: [coarse.offset[0] + tx as f32 * 0.125 / w as f32, coarse.offset[1] + ty as f32 * 0.125 / h as f32],
+ };
+ if !(0.9..=1.1).contains(&framing.scale) || framing.offset[0].abs() > 2.0 / w as f32 || framing.offset[1].abs() > 2.0 / h as f32 {
+ continue;
+ }
+ if let Some(value) = score(framing, false)
+ && value > best.0
+ {
+ best = (value, framing);
+ }
+ }
+ }
+ }
+ let (held_before, held_after) = (score(Cr3Framing::IDENTITY, true)?, score(best.1, true)?);
+ if best.0 < 0.9 || best.0 < before + 0.04 || held_after < 0.9 || held_after < held_before + 0.04 {
+ return None;
+ }
+ if lightcraft_pipeline::profiling() {
+ eprintln!("[profile] CR3 JPEG framing {:?}, held-out edge agreement {held_before:.4} -> {held_after:.4}", best.1);
+ }
+ Some(best.1)
+}
+
+fn align_cr3_framing(sensor: Rgb32f, reference: &Rgb32f) -> Rgb32f {
+ let Some(framing) = estimate_cr3_framing(&sensor, reference) else { return sensor };
+ let (w, h) = (sensor.width, sensor.height);
+ let mut aligned = Rgb32f::new(w, h);
+ for (i, pixel) in aligned.data.iter_mut().enumerate() {
+ let (x, y) = framing.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h);
+ *pixel = if x >= 0.5 && y >= 0.5 && x <= w as f32 - 0.5 && y <= h as f32 - 0.5 { sensor.sample_bilinear(x, y) } else { [f32::NAN; 3] };
+ }
+ aligned
+}
+
/// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the
/// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for formats with their own
/// colour matrices, other files or unusable previews.
@@ -102,7 +251,7 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option Option {
fit_pairs_with(sensor, reference, None)
}
+/// Largest luminance ratio within the 3×3 neighbourhood of pixel `i` (1 on flat areas, large on edges).
+fn local_contrast(image: &Rgb32f, i: usize) -> f32 {
+ let (w, h) = (image.width, image.height);
+ if w == 0 {
+ return f32::INFINITY;
+ }
+ let (x, y) = (i % w, i / w);
+ let (mut lo, mut hi) = (f32::INFINITY, 0f32);
+ for row in y.saturating_sub(1)..(y + 2).min(h) {
+ for col in x.saturating_sub(1)..(x + 2).min(w) {
+ let l = image.data.get(row * w + col).map_or(0.0, |p| luminance_2020(*p));
+ lo = lo.min(l);
+ hi = hi.max(l);
+ }
+ }
+ if hi.is_finite() { hi / lo.max(1e-4) } else { f32::INFINITY }
+}
+
/// Training pairs (sensor → JPEG, unclipped midtones) and the wider set including highlights
-/// (for the chroma curve); `None` when too few, or the photo has too little colour.
+/// (for the chroma curve); `None` when too few, or the photo has too little colour. With
+/// `edge_limit`, pixels whose 3×3 neighbourhood in either proxy spans a larger luminance ratio
+/// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated
+/// colours (see [`EDGE_CONTRAST`]).
type Pairs = Vec<([f64; 3], [f64; 3])>;
-fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<(Pairs, Pairs)> {
+fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option) -> Option<(Pairs, Pairs)> {
if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() {
return None;
}
@@ -161,7 +331,10 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<(Pairs, Pairs)>
// Highlights too (camera JPEGs bleach colours toward white there), for the chroma curve only.
let mut bright = Vec::new();
let mut colour = 0;
- for (input, output) in sensor.data.iter().zip(&reference.data) {
+ for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() {
+ if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) {
+ continue;
+ }
let y = luminance_2020(*output);
if !input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5) || !output.iter().all(|v| v.is_finite() && *v >= 0.0) {
continue;
@@ -174,7 +347,7 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<(Pairs, Pairs)>
}
let min = output.iter().copied().fold(f32::INFINITY, f32::min);
let max = output.iter().copied().fold(0.0, f32::max);
- colour += usize::from(max - min > 0.05);
+ colour += usize::from(max - min > min_chroma);
pairs.push((input.map(f64::from), output.map(f64::from)));
}
(pairs.len() >= 256 && colour >= pairs.len() / 20).then_some((pairs, bright))
@@ -215,11 +388,36 @@ fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option {
matrix.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0).then_some(matrix)
}
+/// Neighbourhood luminance ratio above which a pixel counts as an edge for the second attempt
+/// of [`fit_pairs_with`]. Camera JPEGs are often lens-corrected (Sony "Distortion Comp.: Auto",
+/// compacts and kit zooms): on a 51 mm ILCE-7RM2 shot of a glass façade the JPEG is up to 15 px
+/// of 1440 (about one proxy pixel) off the raw, and the fit failed the gate on the mismatched
+/// window frames alone (held-out RMS 0.111; 0.059 for the same shot with distortion correction
+/// off). Away from edges: 0.049. Issue #232.
+const EDGE_CONTRAST: f32 = 3.0;
+
/// The photo's look: its own matrix (with and without a hue/saturation table), or the given
/// colour model (a camera profile's, in the sensor proxy's space), each completed with a tone
-/// and chroma curve fitted to this photo.
+/// and chroma curve fitted to this photo. When the fit on all pixels fails the acceptance gates,
+/// it is tried once more on the pixels away from edges (same gates): there colour pairs stay
+/// valid when the camera JPEG's geometry differs slightly from the raw's.
fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option {
- let (pairs, bright) = collect_pairs(sensor, reference)?;
+ let has_profile = colour.is_some();
+ // A different picture style can make a camera profile fail the gates. Preserve the
+ // photo's own colour fit before resorting to the neutral fallback.
+ fit_candidate(sensor, reference, colour).or_else(|| has_profile.then(|| fit_candidate(sensor, reference, None)).flatten())
+}
+
+/// One candidate colour model, fitted on all pixels and, when that fails the gates, once more on
+/// the pixels away from edges (issue #232).
+fn fit_candidate(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option {
+ fit_pairs_on(sensor, reference, colour.clone(), None).or_else(|| fit_pairs_on(sensor, reference, colour, Some(EDGE_CONTRAST)))
+}
+
+fn fit_pairs_on(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>, edge_limit: Option) -> Option {
+ // A known colour model needs enough signal for tone fitting, not a scene rich enough to
+ // learn a new colour matrix. Still reject monochrome references.
+ let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit)?;
let candidates = match colour {
Some(given) => vec![given],
None => {
@@ -270,9 +468,10 @@ fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt
.sum();
if lightcraft_pipeline::profiling() {
eprintln!(
- "[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels)",
+ "[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels{})",
(before / samples as f64).sqrt(),
- (after / samples as f64).sqrt()
+ (after / samples as f64).sqrt(),
+ if edge_limit.is_some() { ", away from edges" } else { "" }
);
}
if samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) {
@@ -740,7 +939,7 @@ mod tests {
let mut pool = Vec::new();
for seed in 0..4 {
let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5);
- pool.extend(collect_pairs(&sensor, &reference).unwrap().0);
+ pool.extend(collect_pairs(&sensor, &reference, 0.05, None).unwrap().0);
}
let (matrix, _) = fit_profile(&pool).unwrap();
// the camera's colours (luminance-normalised: the tone curve sets brightness)
@@ -767,6 +966,48 @@ mod tests {
assert!(error.sqrt() < 0.02, "this photo's own tone is followed: RMS {}", error.sqrt());
}
+ #[test]
+ fn profile_fits_tone_without_learning_colour_from_a_dull_scene() {
+ let matrix = Mat3([[2.0, -0.02, 0.0], [0.0, 2.0, 0.0], [0.0, -0.03, 2.0]]);
+ let mut sensor = Rgb32f::new(64, 48);
+ let mut reference = sensor.clone();
+ for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
+ *src = [0.02 + (i % 997) as f32 * 0.00045; 3];
+ let p = matrix.apply_f32(*src);
+ let y = luminance_2020(p);
+ *dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y);
+ }
+ assert!(fit_pairs(&sensor, &reference).is_none(), "not enough colour to learn a matrix");
+ let look = fit_pairs_with(&sensor, &reference, Some((matrix, None))).unwrap();
+ assert_eq!(look.matrix, matrix);
+ reference.map_in_place(|p| [luminance_2020(p); 3]);
+ assert!(fit_pairs_with(&sensor, &reference, Some((matrix, None))).is_none(), "monochrome JPEGs remain rejected");
+ }
+
+ #[test]
+ fn rejected_profile_preserves_a_usable_per_photo_fit() {
+ let matrix = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
+ let wrong = Mat3([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]);
+ let mut sensor = Rgb32f::new(64, 64);
+ let mut reference = sensor.clone();
+ let mut seed = 0x2545_f491_u32;
+ for (src, dst) in sensor.data.iter_mut().zip(&mut reference.data) {
+ *src = std::array::from_fn(|_| {
+ seed ^= seed << 13;
+ seed ^= seed >> 17;
+ seed ^= seed << 5;
+ 0.05 + (seed % 1000) as f32 * 0.0003
+ });
+ let p = matrix.apply_f32(*src);
+ let y = luminance_2020(p);
+ *dst = p.map(|v| (v * (1.0 - (-2.5 * y).exp()) / y).clamp(0.0, 0.999));
+ }
+ let per_photo = fit_pairs(&sensor, &reference).unwrap();
+ let look = fit_pairs_with(&sensor, &reference, Some((wrong, None))).unwrap();
+ assert_eq!(look.matrix, per_photo.matrix, "a rejected camera profile must not suppress the per-photo fit");
+ assert_eq!(look.tone, per_photo.tone);
+ }
+
#[test]
fn hue_sat_passes_black_and_non_finite_pixels_through() {
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
@@ -778,9 +1019,73 @@ mod tests {
}
#[test]
- fn sony_nikon_and_panasonic_raws_get_a_file_local_look() {
- assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2].into_iter().all(file_local_look));
- assert!(![RawFormat::Dng, RawFormat::Cr2, RawFormat::Raf].into_iter().any(file_local_look));
+ fn supported_raws_get_a_file_local_look() {
+ // every decoded raw without a colour matrix of its own (issue #310: CR2 rendered flat with the fallback)
+ let local = [RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Raf, RawFormat::Cr3, RawFormat::Cr2, RawFormat::Pef];
+ assert!(local.into_iter().all(file_local_look));
+ // DNG carries its own colour model
+ assert!(!file_local_look(RawFormat::Dng));
+ }
+
+ #[test]
+ fn cr3_camera_framing_is_measured_independently_of_tone_and_rejects_unrelated_edges() {
+ let (w, h) = (192, 128);
+ let mut sensor = Rgb32f::new(w, h);
+ for (i, pixel) in sensor.data.iter_mut().enumerate() {
+ let (x, y) = ((i % w) as f32, (i / w) as f32);
+ let value = 0.35 + 0.13 * (x * 0.15 + y * 0.22).sin() + 0.1 * (x * 0.33 - y * 0.14).sin() + 0.07 * (x * 0.09 - y * 0.31).cos();
+ *pixel = [value; 3];
+ }
+ let expected = Cr3Framing { scale: 0.96, offset: [0.01, -0.006] };
+ let mut reference = Rgb32f::new(w, h);
+ for (i, pixel) in reference.data.iter_mut().enumerate() {
+ let (x, y) = expected.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h);
+ *pixel = sensor.sample_bilinear(x, y).map(|v| v.powf(0.7));
+ }
+ let measured = estimate_cr3_framing(&sensor, &reference).expect("coherent held-out edges locate camera framing despite nonlinear tone");
+ assert!((measured.scale - expected.scale).abs() < 0.006, "{measured:?}");
+ assert!((measured.offset[0] - expected.offset[0]).abs() < 0.003, "{measured:?}");
+ assert!((measured.offset[1] - expected.offset[1]).abs() < 0.003, "{measured:?}");
+ assert!(estimate_cr3_framing(&sensor, &sensor).is_none(), "already aligned pixels stay unchanged");
+ reference.data.fill([0.2; 3]);
+ assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "flat previews cannot establish framing");
+ for (i, pixel) in reference.data.iter_mut().enumerate() {
+ let (x, y) = ((i % w) as f32, (i / w) as f32);
+ *pixel = [0.35 + 0.13 * (x * 0.41 - y * 0.07).sin() + 0.1 * (x * 0.04 + y * 0.39).cos(); 3];
+ }
+ assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "unrelated edges cannot change the framing");
+ }
+
+ /// The guarded fit is assessed on real sensor data, never on the JPEG fallback. A corpus
+ /// download or an unfinished codec cannot make this test claim a successful RAW calibration.
+ #[test]
+ fn corpus_cr3_gets_a_framing_aligned_camera_look() {
+ let path = std::env::var_os("LIGHTCRAFT_CORPUS")
+ .map(std::path::PathBuf::from)
+ .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
+ .join("raw/cr3-canon-r100-raw.cr3");
+ let Ok(bytes) = std::fs::read(&path) else {
+ eprintln!("skip: {} absent", path.display());
+ return;
+ };
+ let raw = match lightcraft_raw::decode(&bytes) {
+ Ok(raw) => raw,
+ Err(lightcraft_raw::RawError::Unsupported(why)) => {
+ eprintln!("skip CR3 sensor decoder: {why}");
+ return;
+ }
+ Err(error) => panic!("CR3 corpus failed to decode: {error}"),
+ };
+ assert_eq!(raw.format, RawFormat::Cr3);
+ assert_eq!(raw.info().developed_size(), (6000, 4000));
+ let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
+ let look = fit_preview(&raw, &bytes, &transform);
+ eprintln!("R100 guarded colour fit accepted: {}", look.is_some());
+ assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment");
+ let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
+ assert!(info.raw && info.relative_wb);
+ assert_eq!((info.as_shot_temp, info.as_shot_tint), (6500.0, 0.0));
+ assert_eq!(info.camera_tone.is_some(), look.is_some());
}
/// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white
@@ -818,6 +1123,90 @@ mod tests {
assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0);
}
+ /// Issue #232 (skipped without the corpus): the public ILCE-7RM2 sample's camera JPEG is
+ /// distortion-corrected, and on its glass façade the fit on all pixels misses the gate
+ /// (held-out RMS 0.113): it used to open grey with the neutral fallback.
+ #[test]
+ fn corpus_arw_with_a_lens_corrected_preview_gets_a_camera_look() {
+ let path = std::env::var_os("LIGHTCRAFT_CORPUS")
+ .map(std::path::PathBuf::from)
+ .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
+ .join("raw/arw-sony-a7rm2-12bit-uncompressed.arw");
+ let Ok(bytes) = std::fs::read(&path) else {
+ eprintln!("skip: {} absent", path.display());
+ return;
+ };
+ let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
+ assert!(info.camera_tone.is_some(), "no camera look fitted");
+ }
+
+ /// Issue #232: an ILCE-7RM2 camera JPEG is lens-corrected ("Distortion Comp.: Auto"), so on a
+ /// façade of window frames the raw and its JPEG are a pixel or two apart. The colours agree
+ /// everywhere away from the edges, and the look must still be found there.
+ #[test]
+ fn fits_a_slightly_misaligned_camera_jpeg_away_from_edges() {
+ let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
+ let (w, h, block, shift) = (96, 64, 8, 2);
+ // Patches of 8×8 pixels, alternately dark and bright (frames and panes), varied in colour.
+ let scene = |x: usize, y: usize| -> [f32; 3] {
+ let (bx, by) = (x / block, y / block);
+ let ev = if (bx + by) % 2 == 0 { 0.04 + (bx % 3) as f32 * 0.01 } else { 0.3 + (by % 4) as f32 * 0.08 };
+ [ev * (0.7 + ((bx * 7 + by * 3) % 11) as f32 * 0.05), ev, ev * (0.7 + ((bx * 5 + by) % 13) as f32 * 0.04)]
+ };
+ let camera = |p: [f32; 3]| {
+ let q = known.apply_f32(p);
+ let y = luminance_2020(q);
+ q.map(|v| v * (1.0 - (-2.5 * y).exp()) / y)
+ };
+ let mut sensor = Rgb32f::new(w, h);
+ let mut reference = sensor.clone();
+ for y in 0..h {
+ for x in 0..w {
+ sensor.data[y * w + x] = scene(x, y);
+ // The JPEG shows the scene `shift` pixels to the right of where the raw has it.
+ reference.data[y * w + x] = camera(scene(x.saturating_sub(shift), y));
+ }
+ }
+ let fit = fit_pairs(&sensor, &reference).expect("look fitted away from the misaligned edges");
+ // The look itself is right: on the aligned scene it reproduces the camera.
+ let tone = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0);
+ let (mut error, mut n) = (0.0, 0);
+ for y in 0..h {
+ for x in 0..w {
+ let p = displayed(fit.matrix.apply(scene(x, y).map(f64::from)), &tone);
+ let t = camera(scene(x, y));
+ error += (0..3).map(|c| (p[c] - f64::from(t[c])).powi(2)).sum::();
+ n += 3;
+ }
+ }
+ assert!((error / n as f64).sqrt() < 0.03, "{}", (error / n as f64).sqrt());
+ // Unrelated colours stay rejected away from edges too (covered below); a flat JPEG as well.
+ reference.data.fill([0.2; 3]);
+ assert!(fit_pairs(&sensor, &reference).is_none());
+ }
+
+ /// X-Trans and 16-bit Bayer RAFs use the same fixed fit at thumbnail and export sizes.
+ /// The X-T20 fails the gates on all pixels (held-out RMS 0.114 against 0.10) and passes away
+ /// from edges (0.056, issue #232), so it is fitted too. Skips cleanly without these CC0
+ /// corpus files.
+ #[test]
+ fn corpus_raf_colour_and_white_balance() {
+ let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
+ .map(std::path::PathBuf::from)
+ .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
+ .join("raw");
+ for (name, accepted) in [("raf-fuji-xt2-865.raf", true), ("raf-fuji-gfx100s-4503.raf", true), ("raf-fuji-xt20-compressed.raf", true)] {
+ let Ok(bytes) = std::fs::read(dir.join(name)) else { continue };
+ let header = crate::files::probe_bytes(name, &bytes).unwrap();
+ assert_eq!(header.as_shot_wb, Some((6500.0, 0.0)), "{name}");
+ let (_, small) = crate::files::load_bytes(&bytes, 400).unwrap();
+ let (_, large) = crate::files::load_bytes(&bytes, 1200).unwrap();
+ assert_eq!(small.camera_tone.is_some(), accepted, "{name}");
+ assert_eq!(small.camera_tone, large.camera_tone, "{name}: resolution changed the fit");
+ assert!(small.raw && small.relative_wb && small.as_shot_temp == 6500.0 && small.as_shot_tint == 0.0, "{name}");
+ }
+ }
+
#[test]
fn rejects_monochrome_invalid_and_unrelated_previews() {
let mut sensor = Rgb32f::new(32, 32);
diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs
index 29efa4b88..97fb8ef2f 100644
--- a/crates/engine/src/camera_profiles.rs
+++ b/crates/engine/src/camera_profiles.rs
@@ -1,6 +1,6 @@
//! Camera colour profiles of our own, pooled from many photos of one camera model.
//!
-//! A raw file without colour matrices (Sony ARW, Nikon NEF) gets its look fitted to its own embedded camera
+//! A raw file without colour matrices (Sony ARW, Nikon NEF, Fujifilm RAF) gets its look fitted to its own embedded camera
//! JPEG (`camera_preview`), but one photo shows too little of some colours: a lime shirt covering a
//! few dozen proxy pixels next to a hillside of foliage at the same hue. `lightcraft-cli calibrate`
//! pools the colour pairs of many photos per model and fits one matrix and hue/saturation/value
@@ -24,7 +24,11 @@ const MAX_FILE: u64 = 4 << 20;
/// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`):
/// `(model, JSON)`.
-pub const BUNDLED: &[(&str, &str)] = &[("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json"))];
+pub const BUNDLED: &[(&str, &str)] = &[
+ ("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")),
+ ("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")),
+ ("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")),
+];
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CameraProfile {
@@ -196,10 +200,12 @@ pub struct Pool {
const PAIRS_PER_FILE: usize = 4000;
impl Pool {
- /// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW or
- /// NEF without colour matrices, no usable camera JPEG, too little colour).
+ /// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW,
+ /// NEF or RAF without colour matrices, no usable camera JPEG, too little colour).
pub fn add(&mut self, bytes: &[u8]) -> Result