Merge origin/main into prefs/gpu-adapter-typed-read
@@ -19,10 +19,29 @@ gpu = { max-threads = 4 }
|
||||
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool|background_redraw::|floating_group_titles|swap_arrow::tests::rendered_glyph/)))'
|
||||
test-group = 'gpu'
|
||||
|
||||
# CI (`--profile ci`, .github/workflows/ci.yml). Stops at the first failure so a red PR frees its
|
||||
# runner, fails a test that hangs instead of letting it hold the runner, and repeats every failure
|
||||
# at the end of the log. The default profile's GPU override still applies.
|
||||
# CI (`--profile ci`, .github/workflows/ci.yml). Collect all failures in one integration run
|
||||
# instead of exposing one regression per rebuild. A hanging test still terminates, and every
|
||||
# failure is repeated at the end of the log. The default profile's GPU override still applies.
|
||||
[profile.ci]
|
||||
fail-fast = true
|
||||
fail-fast = false
|
||||
slow-timeout = { period = "60s", terminate-after = 10 }
|
||||
failure-output = "immediate-final"
|
||||
|
||||
# Keep phase timings for the remaining expensive Windows GPU regressions visible. This does
|
||||
# not relax their timeout, isolate failures differently or remove any test cases.
|
||||
# Page eviction and budget borrowing redraw multi-effect documents. On the Windows
|
||||
# CPU-backed adapter it uses all GPU slots, avoiding oversubscription without skipping work.
|
||||
[[profile.ci.overrides]]
|
||||
platform = 'cfg(target_os = "windows")'
|
||||
filter = 'package(photocraft-gpu) & test(/pages_are_evicted_under_the_budget|layer_pages_that_just_miss_borrow_the_effect_map_share/)'
|
||||
threads-required = 4
|
||||
success-output = "immediate"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
platform = 'cfg(target_os = "windows")'
|
||||
filter = 'package(photocraft-gpu) & test(/documents_larger_than_the_texture_limit|layer_effects_update_incrementally|pages_are_evicted_under_the_budget|the_focused_view_stays_resident_after_a_full_refresh/)'
|
||||
success-output = "immediate"
|
||||
# These multi-page effect regressions saturate the Windows software GPU. Reserve two
|
||||
# scheduling slots so four independent devices do not compete for the same CPU renderer.
|
||||
# All cases and the 60-second warning remain unchanged.
|
||||
threads-required = 2
|
||||
|
||||
@@ -11,6 +11,26 @@ on:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
full_sweep:
|
||||
description: "Run every testkit sweep case (runtime comparison and integration validation)"
|
||||
type: boolean
|
||||
default: false
|
||||
color_range:
|
||||
description: "Compare cropped Color Range on 24 MP (Linux, exact outputs)"
|
||||
type: boolean
|
||||
default: false
|
||||
selection_combining:
|
||||
description: "Compare direct GRAY8 selection combining on 36 MP (Linux, exact outputs)"
|
||||
type: boolean
|
||||
default: false
|
||||
brush_tip:
|
||||
description: "Compare cached round-tip rasterization on 24 MP"
|
||||
type: boolean
|
||||
default: false
|
||||
brush_tip_base:
|
||||
description: "Baseline commit for the round-tip comparison"
|
||||
type: string
|
||||
default: "main"
|
||||
selection_morphology:
|
||||
description: "Compare selection morphology on 36 MP (Linux, exact outputs)"
|
||||
type: boolean
|
||||
@@ -22,6 +42,10 @@ on:
|
||||
fuzz_seconds:
|
||||
description: "Seconds per fuzz target"
|
||||
default: "600"
|
||||
dry_mixer:
|
||||
description: "Compare the live Mixer Brush against the origin/main merge base on 24 MP (Linux)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# The token is only used to check out the code: no job writes to the repository.
|
||||
permissions:
|
||||
@@ -30,6 +54,7 @@ permissions:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
PHOTOCRAFT_FULL_SWEEP: ${{ inputs.full_sweep && '1' || '0' }}
|
||||
# No test or fuzz run may print a page: the engine refuses to spool, and if something ever
|
||||
# bypasses that, lp fails loudly on a printer that does not exist.
|
||||
PHOTOCRAFT_NO_PRINT: "1"
|
||||
@@ -133,6 +158,7 @@ jobs:
|
||||
# Cargo.toml's dev profile already keeps line tables only.
|
||||
dev-debug: ""
|
||||
env: |
|
||||
PHOTOCRAFT_FULL_SWEEP=${{ inputs.full_sweep && '1' || '0' }}
|
||||
PHOTOCRAFT_NO_PRINT=1
|
||||
LPDEST=photocraft-no-such-printer
|
||||
timeout-minutes: 100
|
||||
@@ -424,6 +450,36 @@ jobs:
|
||||
name: fuzz-${{ matrix.target.crate }}-${{ matrix.target.name }}
|
||||
path: crates/${{ matrix.target.crate }}/fuzz/artifacts
|
||||
|
||||
color-range-performance:
|
||||
if: inputs.color_range == true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
RAYON_NUM_THREADS: "4"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install Linux build dependencies
|
||||
uses: storytold/craft-common-actions/actions/install-apt-packages@main
|
||||
with:
|
||||
packages: >-
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
|
||||
libgtk-3-dev
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: color-range-perf
|
||||
- name: Compare exact outputs and release timings on 24 MP
|
||||
run: |
|
||||
set -o pipefail
|
||||
cargo test --release -p photocraft-engine --lib color_range_release_comparison -- --ignored --nocapture | tee color-range-perf.txt
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
if: always()
|
||||
with:
|
||||
name: color-range-performance
|
||||
path: color-range-perf.txt
|
||||
|
||||
selection-morphology:
|
||||
if: inputs.selection_morphology == true
|
||||
runs-on: ubuntu-latest
|
||||
@@ -459,7 +515,8 @@ jobs:
|
||||
path: selection-morphology-perf.txt
|
||||
|
||||
selection-morphology-quick-perf:
|
||||
if: inputs.selection_morphology == true
|
||||
if: inputs.selection_morphology == true || inputs.color_range == true || inputs.selection_combining == true || inputs.brush_tip == true || inputs.dry_mixer == true
|
||||
name: quick performance suite
|
||||
# Match perf-nightly.yml: Linux llvmpipe lacks the float render targets needed by
|
||||
# bevel_bench, so its required GPU rows cannot be measured there.
|
||||
runs-on: macos-15
|
||||
@@ -481,3 +538,128 @@ jobs:
|
||||
path: |
|
||||
target/perf/*.json
|
||||
target/perf/summary.md
|
||||
|
||||
selection-combining:
|
||||
if: inputs.selection_combining == true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
RAYON_NUM_THREADS: "4"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: selection-combining-perf
|
||||
- name: Compare exact outputs and release timings on 36 MP
|
||||
run: |
|
||||
set -o pipefail
|
||||
cargo test --release -p photocraft-algo --lib gray8_combining_release_comparison -- --ignored --nocapture | tee selection-combining-perf.txt
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
if: always()
|
||||
with:
|
||||
name: selection-combining-performance
|
||||
path: selection-combining-perf.txt
|
||||
|
||||
brush-tip:
|
||||
if: inputs.brush_tip == true
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
RAYON_NUM_THREADS: "4"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
path: after
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ inputs.brush_tip_base }}
|
||||
path: before
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
workspaces: |
|
||||
before -> target
|
||||
after -> target
|
||||
key: brush-tip-release
|
||||
- name: Build the same benchmark against both versions
|
||||
run: |
|
||||
cp after/crates/paint/examples/big_dab_bench.rs before/crates/paint/examples/big_dab_bench.rs
|
||||
cargo build --release --locked --manifest-path before/Cargo.toml -p photocraft-paint --example big_dab_bench
|
||||
cargo build --release --locked --manifest-path after/Cargo.toml -p photocraft-paint --example big_dab_bench
|
||||
- name: Compare round and transformed tips on 24 MP
|
||||
run: |
|
||||
set -o pipefail
|
||||
for size in 200 2000; do
|
||||
for angle in 0 0.7; do
|
||||
for version in before after; do
|
||||
echo "$version: size=$size angle=$angle" | tee -a brush-tip.txt
|
||||
"$version/target/release/examples/big_dab_bench" "$size" 0.37 "$angle" | tee -a brush-tip.txt
|
||||
done
|
||||
done
|
||||
done
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: brush-tip-performance
|
||||
path: brush-tip.txt
|
||||
dry-mixer:
|
||||
if: inputs.dry_mixer == true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
RAYON_NUM_THREADS: "4"
|
||||
CARGO_PROFILE_RELEASE_LTO: "false"
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Pin baseline to the merge base with origin/main
|
||||
id: baseline
|
||||
run: |
|
||||
git remote add upstream https://github.com/storytold/photocraft.git
|
||||
git fetch --no-tags upstream main
|
||||
base=$(git merge-base HEAD upstream/main)
|
||||
echo "sha=$base" >> "$GITHUB_OUTPUT"
|
||||
git worktree add --detach "$RUNNER_TEMP/photocraft-baseline" "$base"
|
||||
mkdir -p "$RUNNER_TEMP/photocraft-baseline/crates/engine/examples"
|
||||
cp crates/engine/examples/bench_dry_mixer.rs "$RUNNER_TEMP/photocraft-baseline/crates/engine/examples/bench_dry_mixer.rs"
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
- name: Install Linux build dependencies
|
||||
uses: storytold/craft-common-actions/actions/install-apt-packages@main
|
||||
with:
|
||||
packages: >-
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
|
||||
libgtk-3-dev
|
||||
- name: Benchmark baseline and candidate on this runner
|
||||
run: |
|
||||
set -o pipefail
|
||||
echo "baseline=${{ steps.baseline.outputs.sha }}" | tee dry-mixer-perf.txt
|
||||
echo "candidate=${GITHUB_SHA}" | tee -a dry-mixer-perf.txt
|
||||
CARGO_TARGET_DIR="$RUNNER_TEMP/target-baseline" cargo run --release --manifest-path "$RUNNER_TEMP/photocraft-baseline/Cargo.toml" -p photocraft-engine --example bench_dry_mixer | tee baseline-mixer.txt
|
||||
CARGO_TARGET_DIR="$RUNNER_TEMP/target-candidate" cargo run --release -p photocraft-engine --example bench_dry_mixer | tee candidate-mixer.txt
|
||||
cat baseline-mixer.txt candidate-mixer.txt >> dry-mixer-perf.txt
|
||||
python3 - <<'PY'
|
||||
import re
|
||||
def fingerprints(path):
|
||||
rows = open(path).readlines()
|
||||
result = {}
|
||||
for row in rows:
|
||||
match = re.search(r'wet=\s*(\d+)% sample_all=\s*(\w+)\s+size=\s*(\d+).*blake3=(\w+)', row)
|
||||
if match:
|
||||
result[match.group(1, 2, 3)] = match.group(4)
|
||||
return result
|
||||
before, after = fingerprints('baseline-mixer.txt'), fingerprints('candidate-mixer.txt')
|
||||
assert len(before) == 12 and before == after, 'baseline/candidate mixer output or state differs'
|
||||
PY
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
if: always()
|
||||
with:
|
||||
name: dry-mixer-performance
|
||||
path: dry-mixer-perf.txt
|
||||
|
||||
@@ -29,3 +29,27 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- run: bash packaging/linux/verified-download-test.sh
|
||||
|
||||
windows-install-policy:
|
||||
timeout-minutes: 10
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
|
||||
dotnet tool install --tool-path $tools wix --version 5.0.2
|
||||
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
|
||||
$tools >> $env:GITHUB_PATH
|
||||
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
|
||||
foreach ($extension in @($policy.extensions)) {
|
||||
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
|
||||
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
|
||||
}
|
||||
- name: Build and check non-executable installer fixtures
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
|
||||
|
||||
@@ -4,10 +4,13 @@ name: Perf (nightly)
|
||||
# Metal), every night and on demand. It never runs on pull requests, so it can't block one.
|
||||
# It fails when an enforced budget in perf/budgets.toml breaks or a scenario's p50 regresses
|
||||
# more than 15 % against perf/baseline.json from the same machine class (other classes are
|
||||
# never compared). The table goes to the job summary; results.json is uploaded as an artifact.
|
||||
# never compared; a baseline built from several runs is judged against its highest p50). The
|
||||
# table goes to the job summary; results.json is uploaded as the `perf-results` artifact.
|
||||
#
|
||||
# To give this runner class its own baseline: run it by hand with "update_baseline", download
|
||||
# the `perf-baseline` artifact and commit it as perf/baseline.json in a PR.
|
||||
# To give this runner class its own baseline: download the `perf-results` artifact of the last
|
||||
# five or more nightly runs (`gh run download <run id> -n perf-results -D runs/<run id>`), run
|
||||
# `cargo xtask perf --update-baseline --from runs/<id>/results.json ... --allow-contended` (this
|
||||
# runner is never idle) and commit perf/baseline.json and docs/scorecard.md in a PR.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -58,7 +61,8 @@ jobs:
|
||||
run: |
|
||||
args=()
|
||||
if [ "$QUICK" = "true" ]; then args+=(--quick); fi
|
||||
if [ "$UPDATE" = "true" ]; then args+=(--update-baseline); fi
|
||||
# This runner is never idle: a baseline from it is recorded labelled as contended.
|
||||
if [ "$UPDATE" = "true" ]; then args+=(--update-baseline --allow-contended); fi
|
||||
cargo xtask perf "${args[@]}"
|
||||
|
||||
- name: Job summary
|
||||
|
||||
@@ -12,6 +12,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
|
||||
@@ -31,3 +31,5 @@ jobs:
|
||||
with:
|
||||
app-name: photocraft
|
||||
app-dir: PhotoCraft
|
||||
install-relative-path: Programs/ArtCraft/PhotoCraft
|
||||
install-scope: perUser
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md: guide for AI agents and contributors
|
||||
|
||||
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, VectorCraft, EffectCraft, FilmCraft, LightCraft, PdfCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`; the repository is not public — outside contributors should ask a maintainer for the standards relevant to their change). Contribute reusable learnings there; shared Rust components live in [storytold/craft-libs](https://github.com/storytold/craft-libs) and are pinned by immutable git revision. UI libraries stay at L6. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
|
||||
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, VectorCraft, EffectCraft, FilmCraft, LightCraft, PdfCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Shared Rust components live in [storytold/craft-libs](https://github.com/storytold/craft-libs) and are pinned by immutable git revision. UI libraries stay at L6. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
|
||||
|
||||
## 1. Orientation (5 minutes)
|
||||
|
||||
@@ -49,7 +49,7 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | t
|
||||
|
||||
### Never crash (outranks feature work)
|
||||
|
||||
People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. The shared standard is `../craftrules/standards/never-crash.md`.
|
||||
People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it.
|
||||
|
||||
- **Non-test code never panics.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!`. Return the crate's error type and propagate with `?`; use `ok_or(..)?`, `let .. else { return Err(..) }`, `if let`, or `unwrap_or*` where a fallback is truly correct (never one that silently corrupts a document). Unfinished features return an "unsupported" error. The only exception is a provably infallible literal: `#[allow(clippy::expect_used)]` plus `.expect("why it can't fail")`.
|
||||
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop) and on Windows a pointer message hook (`GetPointerPenInfo`). Only its `src/macos.rs` and `src/windows.rs` allow `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s and pointer messages); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
|
||||
@@ -58,11 +58,11 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
|
||||
- **Don't cascade.** Handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)`) and treat thread joins as `Result`s.
|
||||
- **Last-resort guard.** The app shell must catch an escaped panic around command dispatch and file import/export, reports it as an error and keeps the document. It's a safety net, not a licence to panic. Keep `panic = "unwind"`.
|
||||
- **Prove it.** Every crash fix comes with a small synthetic regression test that panicked before the fix.
|
||||
- **Enforced by clippy.** `clippy.toml` allows `unwrap`/`expect`/`panic`/indexing in tests only. Clean crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it.
|
||||
- **Enforced by clippy.** `clippy.toml` allows `unwrap`/`expect`/`panic` in tests only. Clean production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; every new workspace crate must also include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`.
|
||||
|
||||
1. **Everything is a command.** New user-visible behaviour = a command in the engine (`crates/engine/src/*_cmds.rs`, registered in `commands.rs`) with id, label, menu path, shortcut, params doc, `enabled` and `run`, plus tests. The UI, CLI, control channel and MCP all dispatch commands by id. Use the **exact id from `crates/ui-egui/src/menu_catalog.rs`** and the menu item goes live automatically. Only pure view/window state (zoom, panels, screen mode) belongs to the shell (`menus.rs` `UI_COMMANDS`).
|
||||
2. **No format or colour assumptions.** Bit depth (8/16/32f) and colour model (RGB/Gray/CMYK/Lab…) are runtime data. Never introduce a `u8`-only pixel path in public APIs. Never assume sRGB: colour conversions go through `photocraft-cms` (`Transform`, `transform::cached`). Test at several depths.
|
||||
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`). **Never commit font files:** fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: `../craftrules/standards/fonts.md`). PhotoCraft uses it through the optional build input `CRAFT_FONTS_DIR=<craft-fonts checkout>` (read by `crates/text/build.rs`, exposed as `photocraft_text::CRAFT_FONTS`; empty without it, and everything must still work). Build with it: `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo build`; details in `docs/development.md` › Fonts.
|
||||
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`). **Never commit font files:** fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts). PhotoCraft uses it through the optional build input `CRAFT_FONTS_DIR=<craft-fonts checkout>` (read by `crates/text/build.rs`, exposed as `photocraft_text::CRAFT_FONTS`; empty without it, and everything must still work). Build with it: `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo build`; details in `docs/development.md` › Fonts.
|
||||
4. **Tests are the gate.** Every change comes with tests. Format crates use round-trip, synthetic-generator, oracle and fuzz tests. Keep the PSD corpus results and the parity floor (`crates/ui-egui/src/parity.rs`) from regressing.
|
||||
5. **The UI is thin and data-driven.** UI state lives in `ui-egui/src/state.rs` (serde), so the control channel can read and drive it. Colours and radii come from `theme::Tokens`, never hard-coded.
|
||||
6. **Verify UI changes visually.** Render offscreen with `cargo run -p photocraft-ui-egui --example snapshot` (no window, no focus stealing), or launch with `--control` and take `ui.screenshot`. Look at the PNG. Demo images must be public-domain art, never personal photos. When fetching assets, never put a person's name, email or other personal details in requests (User-Agent, headers, URLs); use a generic `Photocraft-dev` User-Agent.
|
||||
@@ -80,7 +80,7 @@ Priorities: important infrastructure first, then low-hanging parity, then the lo
|
||||
dimension, where PhotoCraft is lacking; `docs/gaps.md` ranks the work. `docs/parity-checklist.md`
|
||||
(menu wiring) is not a measure of behaviour. When your work moves a number or closes a gap,
|
||||
update the parity doc and gap entry it belongs to, with the date, and bump their status line
|
||||
and revision history (craftrules `standards/progress-docs.md`).
|
||||
and revision history.
|
||||
1. **Check `docs/scorecard.md`** before picking work: each area's `missing` and `partial` rows,
|
||||
the performance scenarios that are over budget or not measurable yet, and the count of
|
||||
settings that do nothing. Its numbers are measured; prefer them to estimates.
|
||||
@@ -130,7 +130,6 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
|
||||
- `docs/parity-checklist.md`: generated Photoshop menu coverage.
|
||||
- `docs/scorecard.md`: generated scorecard (sources: `scorecard/*.toml`, `perf/budgets.toml`, `perf/baseline.json`, corpus floors, prefs audit).
|
||||
- `docs/releasing.md`: cutting a release (`cargo xtask version`, the `release` branch), signing secrets, packaging scripts in `packaging/`.
|
||||
- `../craftrules/release/playbook.md`: how every storytold app builds signed release binaries (the canonical recipe; `docs/release-playbook.md` just points there); `docs/releasing.md` is PhotoCraft's specifics.
|
||||
- `plan/` (local, gitignored): research, parity plan, execution plan, estimates.
|
||||
- `log/` (local, gitignored): the dev log.
|
||||
|
||||
@@ -150,11 +149,13 @@ If the field has a PSD equivalent, map it in `crates/io` too, and keep unknown P
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
(copy the template at the top of the file)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
@@ -23,13 +23,18 @@ next to them.
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | Czech UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/sv.tsv` | Swedish UI translations | @kler, @davvarn and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/fi.tsv` | Finnish UI translations | @Janiz-zzz, @metuuu and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/da.tsv` | Danish UI translations | @pechnikov2026-star and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål UI translations | @nikolainyegaard, @Bolla76 and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
@@ -39,6 +44,7 @@ next to them.
|
||||
| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/path-op-{new-layer,combine,subtract,intersect,exclude,merge}.svg` | Path Operations glyphs (Pen options bar): a single square for New Layer, and two overlapping squares showing the area each operation keeps (union, back minus front, overlap, everything but the overlap, the merged outline), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/text-warp.svg` | Create Warped Text glyph (Type tool options bar): a T above a bowed arc, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
@@ -90,8 +96,12 @@ The other built-in ICC profiles and the generated LUT looks are produced by code
|
||||
| `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
|
||||
| `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below |
|
||||
| `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 |
|
||||
| `docs/images/embedded-vector-editor-en.png` | Embedded AI/PDF/EPS conversion warning and editor chooser (no artwork) | Don Omondi | Offscreen `smart_svg_ui::tests::capture_embedded_vector_conversion_prompt` with authored bytes and mock services | MIT OR Apache-2.0 |
|
||||
| `docs/images/embedded-vector-tabs-linux.png` | Shared VectorCraft window after a portal editor save and ordinary document launch | Don Omondi | Local Linux GUI acceptance with authored orange rectangle/blue circle SVGs; no private artwork | MIT OR Apache-2.0 |
|
||||
| `docs/images/gradient-overlay-method-*.png` | Gradient Overlay Method selector, before and after (synthetic gradient) | @oRE-o | Rendered offscreen with the `snapshot` example; no external artwork | MIT OR Apache-2.0 |
|
||||
| `docs/images/cpu-pixel-alignment/*.png` | CPU canvas pixel alignment, before and after #8321 | PhotoCraft contributors | Original synthetic noise rendered with the offscreen `snapshot` example; see the adjacent README | MIT OR Apache-2.0, adjacent `LICENSE-MIT` and `LICENSE-APACHE` |
|
||||
| `docs/images/multi-channel-editing/{curves,curves-choices,levels,drawing,mask-view-before,mask-view-after}.png` | Selected colour channel editing (synthetic gradient) | @hidoba | Original synthetic gradient rendered with the offscreen `snapshot` example; adjacent JSON scripts | MIT OR Apache-2.0, adjacent `LICENSE-MIT` and `LICENSE-APACHE` |
|
||||
| `docs/images/multi-channel-editing/great-wave.jpg`, `rgb.png`, `cmyk.png`, `lab.png`, `gallery-channel-preview-{before,after}.png` | *The Great Wave off Kanagawa*, c. 1830–1832, and PhotoCraft channel-selection captures | Katsushika Hokusai (artwork); PhotoCraft contributors (UI) | [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Tsunami_by_hokusai_19th_century.jpg), Metropolitan Museum reproduction; adjacent JSON capture scripts | Public domain artwork (PD-Art), adjacent `LICENSE-PUBLIC-DOMAIN.txt`; UI/scripts MIT OR Apache-2.0 |
|
||||
|
||||
Artwork shown in the screenshots (all public domain, via Wikimedia Commons; details in
|
||||
[`docs/images/SOURCES.md`](docs/images/SOURCES.md)):
|
||||
@@ -129,3 +139,12 @@ next to the files:
|
||||
| `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
|
||||
|
||||
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
|
||||
|
||||
## Embedded-vector editor source
|
||||
|
||||
The external SVG watcher, engine/UI handoff and bounded SVG rendering in this change
|
||||
are adapted from Skyfuller1303’s MIT OR Apache-2.0 implementation in
|
||||
[PhotoCraft #3409](https://github.com/storytold/photocraft/pull/3409), commit
|
||||
`897f20072762fdf0eb688adcdb8d8cda7b38ce66`. Don Omondi’s embedded AI/PDF/EPS
|
||||
extension and current-main integration build on that source. Clipboard and unrelated
|
||||
Shape Tools changes from that branch are not included.
|
||||
|
||||
@@ -323,7 +323,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -1350,17 +1350,17 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -1383,7 +1383,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-jpeg"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -1391,9 +1391,10 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
"roxmltree 0.21.1",
|
||||
"tempfile",
|
||||
@@ -1404,7 +1405,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -1425,13 +1426,14 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
"egui",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"semver",
|
||||
@@ -2096,7 +2098,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3297,7 +3299,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f"
|
||||
dependencies = [
|
||||
"io-lifetimes 3.0.1",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4774,7 +4776,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4961,10 +4963,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"async-io",
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"chrono",
|
||||
"clap",
|
||||
"core-graphics2",
|
||||
@@ -4993,9 +4997,12 @@ dependencies = [
|
||||
"photocraft-raster",
|
||||
"photocraft-tablet",
|
||||
"photocraft-ui-egui",
|
||||
"plist",
|
||||
"resvg",
|
||||
"rfd",
|
||||
"ron",
|
||||
"roxmltree 0.21.1",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
@@ -5011,7 +5018,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-affinity"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -5021,8 +5028,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-algo"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
"photocraft-raster",
|
||||
@@ -5035,7 +5043,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-automation"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"cap-std",
|
||||
@@ -5060,7 +5068,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cli"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -5079,7 +5087,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cms"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"moxcms",
|
||||
"rayon",
|
||||
@@ -5087,7 +5095,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-codecs"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-jpeg",
|
||||
"exr",
|
||||
@@ -5096,6 +5104,7 @@ dependencies = [
|
||||
"image",
|
||||
"image-webp",
|
||||
"jpeg-encoder",
|
||||
"jxl-oxide",
|
||||
"photocraft-heif",
|
||||
"png 0.18.1",
|
||||
"proptest",
|
||||
@@ -5105,11 +5114,12 @@ dependencies = [
|
||||
"tiff",
|
||||
"zune-core 0.5.3",
|
||||
"zune-jpeg 0.5.15",
|
||||
"zune-jpegxl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-color"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"serde",
|
||||
@@ -5117,7 +5127,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-compose"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-cms",
|
||||
@@ -5133,7 +5143,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-doc"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -5144,8 +5154,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-engine"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"chrono",
|
||||
"craft-print",
|
||||
@@ -5185,7 +5196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-format"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"crc32fast",
|
||||
@@ -5208,14 +5219,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-geom"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-gpu"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"half",
|
||||
@@ -5235,19 +5246,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-heif"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"craft-docpath",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"imagesize",
|
||||
"miniz_oxide 0.9.1",
|
||||
"photocraft-affinity",
|
||||
"photocraft-algo",
|
||||
@@ -5277,31 +5289,32 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ipp"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ops"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"photocraft-doc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-paint"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
"photocraft-raster",
|
||||
"rayon",
|
||||
"rustc-hash 2.1.3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-plugins"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -5317,7 +5330,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-psd"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"photocraft-psd",
|
||||
@@ -5328,7 +5341,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raster"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -5339,7 +5352,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raw"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -5354,7 +5367,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-tablet"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.5",
|
||||
@@ -5366,7 +5379,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"serde_json",
|
||||
@@ -5374,7 +5387,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-text"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"parley",
|
||||
@@ -5389,7 +5402,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ui-egui"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"craft-print",
|
||||
@@ -5434,7 +5447,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-vector"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-doc",
|
||||
@@ -5445,7 +5458,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5467,7 +5480,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web-boot"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"js-sys",
|
||||
@@ -5899,7 +5912,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6434,7 +6447,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6503,7 +6516,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7381,10 +7394,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8653,7 +8666,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9183,7 +9196,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
|
||||
@@ -4,13 +4,13 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] }
|
||||
# internal
|
||||
photocraft-geom = { path = "crates/geom" }
|
||||
photocraft-color = { path = "crates/color" }
|
||||
@@ -39,10 +39,11 @@ photocraft-tablet = { path = "crates/tablet" }
|
||||
photocraft-ipp = { path = "crates/ipp" }
|
||||
|
||||
# external
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = "1"
|
||||
@@ -67,6 +68,7 @@ log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
rayon = "1"
|
||||
rustc-hash = "2.1.3"
|
||||
criterion = { version = "0.8", default-features = false }
|
||||
getrandom = "0.3"
|
||||
# Monotonic clock on native and wasm (screenshot deadlines: a wall clock can jump).
|
||||
@@ -76,6 +78,9 @@ web-time = "1"
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
too_many_arguments = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
|
||||
|
||||
@@ -286,6 +286,8 @@ The Flatpak prints through the host's CUPS socket (File › Print talks IPP to i
|
||||
|
||||
The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it.
|
||||
|
||||
To use the bundled CLI, run `./photocraft-<version>-linux-x86_64.AppImage --cli --help`, or `--cli mcp` to start the MCP server on stdio. CLI launches skip desktop integration. A symlink named `photocraft-cli` also selects the CLI; see [`docs/development.md`](docs/development.md#headless-cli).
|
||||
|
||||
On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft-<version>-linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072).
|
||||
|
||||
On macOS, the command-line tool comes as `photocraft-cli-<version>-macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself:
|
||||
|
||||
@@ -123,7 +123,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
|
||||
| Date | Entry |
|
||||
|---|---|
|
||||
| 2026-10-17 | Object Finder classifies background by boundary coverage, retaining large enclosed foregrounds and rejecting surrounding backdrop (#6548); learned segmentation remains open (#2788) |
|
||||
| 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized to the craftrules standard (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. |
|
||||
| 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. |
|
||||
| 2026-10-09 | Remove tool, Rotate View, crop rotation; OpenRaster and Paint.NET; 39 Affinity documents in the corpus |
|
||||
| 2026-10-08 | 0.5.0. Affinity import; Magnetic Lasso, Pattern Stamp, Mixer Brush, Pencil, Patch, Vertical Type on the toolbar |
|
||||
| 2026-10-07 | 0.3.0. 10 UI languages; shortcut audit 214 → 0 failures; TIFF decode 10× faster |
|
||||
@@ -143,4 +143,4 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
|
||||
| 2026-10-10 | minor | Readiness by audience with hours per tier |
|
||||
| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added |
|
||||
| 2026-10-10 | minor | Alpha gate result (passes; stays alpha) in the stage explanation |
|
||||
| 2026-10-10 | major | First version, per craftrules `standards/progress-docs.md` |
|
||||
| 2026-10-10 | major | First version |
|
||||
|
||||
@@ -83,7 +83,10 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) ->
|
||||
h.session.actions.list = set.clone();
|
||||
h.open(input)?;
|
||||
for (id, p) in &actions {
|
||||
if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) {
|
||||
if photocraft_engine::actions_cmds::shell_view_command(id)
|
||||
|| photocraft_engine::actions_cmds::shell_save_command(id)
|
||||
|| photocraft_engine::actions_cmds::noop_step(&h.session, id, p)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let r = h.command_run(id, p.clone()).map_err(|source| CliError::Command { id: id.clone(), source })?;
|
||||
|
||||
@@ -93,6 +93,11 @@ pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) ->
|
||||
}
|
||||
let cmds = command_list(a)?;
|
||||
for (id, params) in cmds {
|
||||
// A step that is a silent no-op in action playback (Deselect with nothing selected, an Arrange that cannot move the layer) is one in a script too (#8802).
|
||||
if photocraft_engine::actions_cmds::noop_step(&h.session, &id, ¶ms) {
|
||||
print_json(out, &json!({"command": id, "result": null}), JsonStyle::Compact)?;
|
||||
continue;
|
||||
}
|
||||
let r = h.command_run(&id, params).map_err(|source| CliError::Command { id: id.clone(), source })?;
|
||||
if let Some(message) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
|
||||
return Err(CliError::NestedFailure { id, message });
|
||||
|
||||
@@ -1421,3 +1421,78 @@ fn control_token_env_vars_are_the_flags_fallbacks() {
|
||||
let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(free_port().to_string()).env("PHOTOCRAFT_CONTROL_TOKEN", ""));
|
||||
assert!(err.contains("photocraft-cli serving on") && err.contains("photocraft-cli control token: "), "{err}");
|
||||
}
|
||||
|
||||
/// #8802: Deselect with nothing selected is skipped in a `run`, as action playback does (#6744),
|
||||
/// so a script that deselects after Reveal Selection still finishes and saves.
|
||||
#[test]
|
||||
fn run_skips_deselect_with_nothing_selected() {
|
||||
let folder = tmp("deselect-run");
|
||||
let native = folder.join("out.pcraft").to_string_lossy().to_string();
|
||||
let Invoke { code, stdout, stderr } = invoke(&[
|
||||
"run",
|
||||
"--new",
|
||||
r#"{"width":16,"height":16}"#,
|
||||
"--cmd",
|
||||
"select.rect",
|
||||
"--params",
|
||||
r#"{"x":0,"y":0,"width":8,"height":16}"#,
|
||||
"--cmd",
|
||||
"layer.new.layer",
|
||||
"--cmd",
|
||||
"layer.layerMask.revealSelection",
|
||||
"--cmd",
|
||||
"select.deselect",
|
||||
"--cmd",
|
||||
"layer.new.layer",
|
||||
"--params",
|
||||
r#"{"name":"after"}"#,
|
||||
"--out",
|
||||
&native,
|
||||
]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
let results: Vec<Value> = stdout.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(results.len(), 5, "every command reports a result: {stdout}");
|
||||
assert_eq!(results[3], json!({"command": "select.deselect", "result": null}));
|
||||
let Invoke { code, stdout, stderr } = invoke(&["info", &native, "--compact"]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
let info: Value = serde_json::from_str(&stdout).unwrap();
|
||||
assert_eq!(info["layers"][0]["name"], "after", "the steps after Deselect ran: {info}");
|
||||
|
||||
// Deselect with a selection still deselects: Inverse afterwards finds nothing to invert.
|
||||
let Invoke { code, stderr, .. } = invoke(&[
|
||||
"run",
|
||||
"--new",
|
||||
r#"{"width":16,"height":16}"#,
|
||||
"--cmd",
|
||||
"select.all",
|
||||
"--cmd",
|
||||
"select.deselect",
|
||||
"--cmd",
|
||||
"select.inverse",
|
||||
"--out",
|
||||
&native,
|
||||
]);
|
||||
assert_eq!(code, 1, "{stderr}");
|
||||
assert!(stderr.contains("select.inverse") && stderr.contains("no selection"), "{stderr}");
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
}
|
||||
|
||||
/// #8802: a batched action that starts with Deselect plays on files with nothing selected, as the
|
||||
/// same action does in the Actions panel (#6744).
|
||||
#[test]
|
||||
fn batch_skips_deselect_with_nothing_selected() {
|
||||
let dir = tmp("deselect-batch");
|
||||
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
|
||||
std::fs::create_dir_all(dir.join("in")).unwrap();
|
||||
let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
std::fs::write(dir.join("steps.json"), json!([["select.deselect", {}], ["image.imageRotation.90cw", {}]]).to_string()).unwrap();
|
||||
let Invoke { code, stdout, stderr } = invoke(&["batch", "--actions", &path("steps.json"), "--in", &path("in"), "--out", &path("out")]);
|
||||
assert_eq!(code, 0, "{stdout}{stderr}");
|
||||
assert!(stdout.contains("1 succeeded, 0 failed"), "{stdout}");
|
||||
let Invoke { code, stdout, stderr } = invoke(&["info", &path("out/a.png"), "--compact"]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
let info: Value = serde_json::from_str(&stdout).unwrap();
|
||||
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "the step after Deselect ran: {info}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ pub fn start() {
|
||||
}
|
||||
let unsaved = Arc::new(AtomicBool::new(false));
|
||||
guard_unload(unsaved.clone());
|
||||
Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets }))
|
||||
Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets, ui_language: "" }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -224,7 +224,10 @@ fn load_font_manifest(served: Served, ctx: egui::Context) {
|
||||
served_fonts::add_fonts(&fonts);
|
||||
// Fetch now the fallbacks of the scripts the browser's languages use (an Arabic reader gets
|
||||
// the Arabic font before typing); the rest is fetched when text or a font menu needs it.
|
||||
served_fonts::request_for_languages(browser_languages().iter().map(String::as_str));
|
||||
// Also the interface language's: `serve_fonts` asked for it once already, maybe before
|
||||
// this manifest arrived, when no fallback was registered yet.
|
||||
let ui_language = photocraft_ui_egui::i18n::current().code();
|
||||
served_fonts::request_for_languages(browser_languages().iter().map(String::as_str).chain([ui_language]));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
@@ -270,6 +273,8 @@ struct WebShell {
|
||||
served: Served,
|
||||
database: Option<web_sys::IdbDatabase>,
|
||||
presets: crate::preset_bridge::Bridge,
|
||||
/// The interface language whose served script font was last asked for.
|
||||
ui_language: &'static str,
|
||||
}
|
||||
|
||||
impl WebShell {
|
||||
@@ -294,6 +299,12 @@ impl WebShell {
|
||||
/// Fetches the served families asked for (picked in a font menu, or needed by a layout) and
|
||||
/// installs the files that arrived.
|
||||
fn serve_fonts(&mut self, ctx: &egui::Context) {
|
||||
// The interface language's script font draws its labels (Arabic, Japanese…).
|
||||
let ui_language = photocraft_ui_egui::i18n::current().code();
|
||||
if ui_language != self.ui_language {
|
||||
self.ui_language = ui_language;
|
||||
served_fonts::request_for_languages([ui_language]);
|
||||
}
|
||||
let requested = served_fonts::take_requests();
|
||||
let (fetch, arrived) = {
|
||||
let mut s = self.served.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
@@ -320,7 +331,7 @@ impl WebShell {
|
||||
}
|
||||
});
|
||||
}
|
||||
served_fonts::install(&mut self.app, arrived);
|
||||
served_fonts::install(&mut self.app, ctx, arrived);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,11 +34,14 @@ egui = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
blake3 = { version = "1.8", default-features = false, features = ["pure"] }
|
||||
chrono = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tempfile = "=3.27.0"
|
||||
roxmltree = "0.21"
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { version = "3.7", features = ["wayland-data-control"] }
|
||||
@@ -69,6 +72,15 @@ muda = { version = "=0.21.2", default-features = false }
|
||||
# Already in Cargo.lock (winit, objc2-app-kit); pinned exactly to the same version.
|
||||
dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
rustix = { version = "=1.1.5", features = ["fs"] }
|
||||
# Reads CFBundleExecutable from a chosen macOS application bundle's Info.plist (XML or binary) for
|
||||
# external vector editing. Already in Cargo.lock (craft-update); built on every Unix so Linux CI
|
||||
# compiles and tests the bundle checks. The event-stream reader (behind plist's "unstable"
|
||||
# feature) lets us cap nesting: building a `Value`, or serde's ignored fields, recurses per level.
|
||||
# Pinned exactly because that API may change in a minor release: review the diff before bumping.
|
||||
plist = { version = "=1.10.3", features = ["enable_unstable_features_that_may_break_with_minor_version_bumps"] }
|
||||
|
||||
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
photocraft-cms = { workspace = true }
|
||||
@@ -110,3 +122,7 @@ harness = false
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for photocraft.exe; build.rs only uses it when targeting Windows.
|
||||
winresource = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
# Opt-in native save-back acceptance transports embedded sources through engine JSON.
|
||||
base64 = "=0.22.1"
|
||||
|
||||
@@ -390,7 +390,7 @@ fn stage_copy(from: &Path, to: &Path) -> std::io::Result<PathBuf> {
|
||||
fn remove_stale_staging(folder: &Path) {
|
||||
let Ok(entries) = std::fs::read_dir(folder) else { return };
|
||||
for entry in entries.flatten() {
|
||||
if !entry.file_name().to_str().is_some_and(|n| side_of(n, STAGED).is_some()) {
|
||||
if entry.file_name().to_str().is_none_or(|n| side_of(n, STAGED).is_none()) {
|
||||
continue;
|
||||
}
|
||||
let removed = match entry.file_type() {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
//! be stamped on the shortcut, or the taskbar would stop matching the two.
|
||||
|
||||
/// Window, taskbar and (when running unbundled) Dock icon. macOS gets the padded 1024 px render
|
||||
/// on Apple's icon grid; elsewhere the tighter 256 px hicolor render reads better at small sizes.
|
||||
/// on Apple's icon grid; elsewhere the 256 px hicolor render of the full tile.
|
||||
pub fn window_icon() -> egui::IconData {
|
||||
match eframe::icon_data::from_png_bytes(PNG) {
|
||||
Ok(icon) => icon,
|
||||
@@ -108,6 +108,16 @@ mod tests {
|
||||
assert!(icon.rgba.chunks(4).any(|p| p[3] > 0), "the icon isn't blank");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_and_linux_icons_keep_the_round_corners() {
|
||||
// rx=112 of 512 leaves (10, 10) of a 256 px render transparent; the old crop filled it (#4478).
|
||||
let png = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png");
|
||||
let icon = eframe::icon_data::from_png_bytes(png).unwrap();
|
||||
assert_eq!(icon.width, 256);
|
||||
assert_eq!(icon.rgba[(10 * 256 + 10) * 4 + 3], 0, "corner is transparent");
|
||||
assert_eq!(icon.rgba[(128 * 256 + 2) * 4 + 3], 255, "the tile is full bleed");
|
||||
}
|
||||
|
||||
/// The `.ico` build.rs embeds: every size Windows asks for, each a valid PNG or BMP image.
|
||||
#[test]
|
||||
fn exe_icon_has_every_shell_size() {
|
||||
|
||||
@@ -16,15 +16,10 @@ use photocraft_ui_egui::ControlRequest;
|
||||
use photocraft_ui_egui::control::REPLY_DEADLINE;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
/// Start serving a listener already bound during startup, before a window is opened.
|
||||
pub fn start(listener: TcpListener, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("photocraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
let port = listener.local_addr().map_or(0, |a| a.port());
|
||||
eprintln!("photocraft: control server listening on {}", listening_addr(&listener, port));
|
||||
std::thread::spawn(move || {
|
||||
let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
|
||||
@@ -152,6 +147,30 @@ mod tests {
|
||||
TcpStream::connect(&logged).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prebound_listener_authenticates_and_dispatches_requests() {
|
||||
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let requests = start(listener, TOKEN.into(), egui::Context::default());
|
||||
let mut stream = TcpStream::connect(address).unwrap();
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut replies = BufReader::new(stream.try_clone().unwrap());
|
||||
writeln!(stream, "{}", json!({"id": 1, "method": "auth", "params": {"token": TOKEN}})).unwrap();
|
||||
let mut line = String::new();
|
||||
replies.read_line(&mut line).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["result"]["authenticated"], true);
|
||||
writeln!(stream, "{}", json!({"id": 2, "method": "test.bound"})).unwrap();
|
||||
let request = requests.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(request.method, "test.bound");
|
||||
request.reply.send(json!({"ok": true, "result": "served"})).unwrap();
|
||||
line.clear();
|
||||
replies.read_line(&mut line).unwrap();
|
||||
let reply: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(reply["id"], 2);
|
||||
assert_eq!(reply["result"], "served");
|
||||
}
|
||||
|
||||
/// #4965: a connection that keeps sending without authenticating is closed at the deadline
|
||||
/// (releasing its connection permit), and nothing it sent is dispatched.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Launch a sandboxed editor through the desktop, exporting only the writable working SVG.
|
||||
//! Never execute another Flatpak's payload inside PhotoCraft or grant access to its home.
|
||||
|
||||
use super::native::OwnedSvg;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||
use std::time::{Duration, Instant};
|
||||
use zbus::blocking::{Connection, Proxy};
|
||||
use zbus::zvariant::{Fd, OwnedObjectPath, OwnedValue, Value};
|
||||
|
||||
/// Ask the desktop (xdg-desktop-portal OpenURI) to let the user pick an editor for the working
|
||||
/// file, on a fresh session-bus connection with a 5-second method timeout. Returns once the
|
||||
/// portal answers; `stop` or a 120-second deadline cancels the wait.
|
||||
pub(super) fn open(owned: &OwnedSvg, stop: &Receiver<()>) -> Result<(), String> {
|
||||
let connection =
|
||||
zbus::blocking::connection::Builder::session().map_err(|e| e.to_string())?.method_timeout(Duration::from_secs(5)).build().map_err(|e| e.to_string())?;
|
||||
open_on(&connection, owned, stop)
|
||||
}
|
||||
|
||||
/// [`open`] on an existing connection: share a checked writable handle (never a pathname) with
|
||||
/// `OpenFile`, always asking the user to choose, and wait for the named request's response.
|
||||
fn open_on(connection: &Connection, owned: &OwnedSvg, stop: &Receiver<()>) -> Result<(), String> {
|
||||
use futures_lite::StreamExt;
|
||||
// Retain the checked handle for the portal call. Writable document-portal access also
|
||||
// supports atomic saves. The watcher keeps using PhotoCraft's original owned pathname.
|
||||
let file = owned.share_handle()?;
|
||||
let unique = connection.unique_name().ok_or("No desktop connection name")?.as_str().trim_start_matches(':').replace('.', "_");
|
||||
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
let token = format!("photocraft_vector_{}_{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{unique}/{token}");
|
||||
let request = Proxy::new(connection, "org.freedesktop.portal.Desktop", path.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
|
||||
// Subscribe before launching: the portal can finish before OpenFile returns.
|
||||
let mut replies = futures_lite::future::block_on(request.inner().receive_signal("Response")).map_err(|e| e.to_string())?;
|
||||
let portal = Proxy::new(connection, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.OpenURI")
|
||||
.map_err(|e| e.to_string())?;
|
||||
// SVG's desktop default may be a viewer or browser. Always let the user select an editor.
|
||||
let options = HashMap::from([("handle_token", Value::from(token.as_str())), ("writable", Value::from(true)), ("ask", Value::from(true))]);
|
||||
let handle: OwnedObjectPath =
|
||||
portal.call("OpenFile", &("", Fd::from(&file), options)).map_err(|e| format!("Cannot open the desktop editor chooser: {e}"))?;
|
||||
if handle.as_str() != path {
|
||||
let returned =
|
||||
Proxy::new(connection, "org.freedesktop.portal.Desktop", handle.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
|
||||
let _: Result<(), _> = returned.call("Close", &());
|
||||
return Err("The desktop portal does not support named requests".into());
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(120);
|
||||
loop {
|
||||
if !matches!(stop.try_recv(), Err(TryRecvError::Empty)) || Instant::now() >= deadline {
|
||||
let _: Result<(), _> = request.call("Close", &());
|
||||
return Err("Vector editor selection cancelled or timed out".into());
|
||||
}
|
||||
let next = futures_lite::future::block_on(futures_lite::future::race(async { Some(replies.next().await) }, async {
|
||||
async_io::Timer::after(Duration::from_millis(50)).await;
|
||||
None
|
||||
}));
|
||||
if let Some(reply) = next {
|
||||
let reply = reply.ok_or("The desktop editor chooser closed without an answer")?;
|
||||
let (code, _): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
|
||||
return match code {
|
||||
0 => Ok(()),
|
||||
1 => Err("Vector editor selection cancelled".into()),
|
||||
_ => Err("The desktop could not open the vector editor".into()),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{BufRead, Read, Seek, Write};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
|
||||
struct Bus(Child);
|
||||
impl Drop for Bus {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
fn bus() -> (Bus, Connection, Connection) {
|
||||
let guard = crate::CHILD_SPAWNS.read().unwrap();
|
||||
let mut child = Command::new("dbus-daemon").args(["--session", "--nofork", "--print-address=1"]).stdout(Stdio::piped()).spawn().unwrap();
|
||||
drop(guard);
|
||||
let mut address = String::new();
|
||||
std::io::BufReader::new(child.stdout.take().unwrap()).read_line(&mut address).unwrap();
|
||||
let server = zbus::blocking::connection::Builder::address(address.trim()).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap();
|
||||
let client = zbus::blocking::connection::Builder::address(address.trim()).unwrap().method_timeout(Duration::from_secs(2)).build().unwrap();
|
||||
(Bus(child), server, client)
|
||||
}
|
||||
|
||||
/// A real D-Bus FD transfer carries only the owned SVG, with explicit writable/chooser
|
||||
/// options. An immediate response cannot race subscription; writing that FD updates the
|
||||
/// original watched path. A rejected chooser never succeeds or launches a fallback.
|
||||
#[test]
|
||||
fn shares_a_writable_working_copy_and_handles_chooser_responses() {
|
||||
for code in [0u32, 1, 2] {
|
||||
let (_bus, server, client) = bus();
|
||||
let owned = OwnedSvg::create(b"<svg/>").unwrap();
|
||||
let messages = zbus::blocking::MessageIterator::from(&server);
|
||||
let worker = std::thread::spawn(move || {
|
||||
for message in messages {
|
||||
let message = message.unwrap();
|
||||
let header = message.header();
|
||||
if header.member().map(|m| m.as_str()) != Some("OpenFile") {
|
||||
continue;
|
||||
}
|
||||
let (parent, fd, options): (String, zbus::zvariant::OwnedFd, HashMap<String, OwnedValue>) = message.body().deserialize().unwrap();
|
||||
assert!(parent.is_empty());
|
||||
assert!(bool::try_from(options["ask"].try_clone().unwrap()).unwrap());
|
||||
assert!(bool::try_from(options["writable"].try_clone().unwrap()).unwrap());
|
||||
let mut file = std::fs::File::from(std::os::fd::OwnedFd::from(fd));
|
||||
let mut bytes = String::new();
|
||||
file.read_to_string(&mut bytes).unwrap();
|
||||
assert_eq!(bytes, "<svg/>");
|
||||
if code == 0 {
|
||||
file.rewind().unwrap();
|
||||
file.write_all(b"<svg >").unwrap();
|
||||
file.sync_all().unwrap();
|
||||
}
|
||||
let token = String::try_from(options["handle_token"].try_clone().unwrap()).unwrap();
|
||||
let sender = header.sender().unwrap().as_str().trim_start_matches(':').replace('.', "_");
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
|
||||
server.reply(&header, &OwnedObjectPath::try_from(path.as_str()).unwrap()).unwrap();
|
||||
server
|
||||
.emit_signal(
|
||||
header.sender().cloned().map(zbus::names::BusName::from),
|
||||
path.as_str(),
|
||||
"org.freedesktop.portal.Request",
|
||||
"Response",
|
||||
&(code, HashMap::<String, Value<'_>>::new()),
|
||||
)
|
||||
.unwrap();
|
||||
// Keep the owner alive until the client has consumed its response.
|
||||
return server;
|
||||
}
|
||||
panic!("OpenFile was not called");
|
||||
});
|
||||
let (_stop, stopped) = mpsc::channel();
|
||||
assert_eq!(open_on(&client, &owned, &stopped).is_ok(), code == 0);
|
||||
let _server = worker.join().unwrap();
|
||||
assert_eq!(std::fs::read(&owned.file).unwrap(), if code == 0 { b"<svg >" } else { b"<svg/>" });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_redirected_or_nonregular_files_before_contacting_the_portal() {
|
||||
let (_bus, _server, client) = bus();
|
||||
let owned = OwnedSvg::create(b"<svg/>").unwrap();
|
||||
let other = tempfile::NamedTempFile::new().unwrap();
|
||||
std::fs::remove_file(&owned.file).unwrap();
|
||||
std::os::unix::fs::symlink(other.path(), &owned.file).unwrap();
|
||||
let (_stop, stopped) = mpsc::channel();
|
||||
assert!(open_on(&client, &owned, &stopped).unwrap_err().contains("regular file"));
|
||||
std::fs::remove_file(&owned.file).unwrap();
|
||||
rustix::fs::mkfifoat(rustix::fs::CWD, &owned.file, rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR).unwrap();
|
||||
assert!(open_on(&client, &owned, &stopped).unwrap_err().contains("regular file"));
|
||||
}
|
||||
#[test]
|
||||
fn stopping_sync_closes_an_unanswered_desktop_request() {
|
||||
let (_bus, server, client) = bus();
|
||||
let owned = OwnedSvg::create(b"<svg/>").unwrap();
|
||||
let messages = zbus::blocking::MessageIterator::from(&server);
|
||||
let (stop, stopped) = mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let mut pending_stop = Some(stop);
|
||||
for message in messages {
|
||||
let message = message.unwrap();
|
||||
let header = message.header();
|
||||
match header.member().map(|member| member.as_str()) {
|
||||
Some("OpenFile") => {
|
||||
let (_, _, options): (String, zbus::zvariant::OwnedFd, HashMap<String, OwnedValue>) = message.body().deserialize().unwrap();
|
||||
let token = String::try_from(options["handle_token"].try_clone().unwrap()).unwrap();
|
||||
let sender = header.sender().unwrap().as_str().trim_start_matches(':').replace('.', "_");
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
|
||||
server.reply(&header, &OwnedObjectPath::try_from(path.as_str()).unwrap()).unwrap();
|
||||
pending_stop.take().unwrap().send(()).unwrap();
|
||||
}
|
||||
Some("Close") => {
|
||||
server.reply(&header, &()).unwrap();
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
panic!("The desktop request was not closed");
|
||||
});
|
||||
assert!(open_on(&client, &owned, &stopped).unwrap_err().contains("cancelled"));
|
||||
worker.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -315,22 +315,31 @@ pub struct AppLogger {
|
||||
filter: Filter,
|
||||
stderr: bool,
|
||||
sink: Mutex<Sink>,
|
||||
/// The file [`AppLogger::attach_dir`] opened, so a failure can tell the user what to attach.
|
||||
path: Mutex<Option<PathBuf>>,
|
||||
/// The lock that marks the attached log file as in use (see [`rotate`]); held until exit.
|
||||
owner: Mutex<Option<File>>,
|
||||
}
|
||||
|
||||
impl AppLogger {
|
||||
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
|
||||
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)), owner: Mutex::new(None) }
|
||||
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)), path: Mutex::new(None), owner: Mutex::new(None) }
|
||||
}
|
||||
|
||||
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
|
||||
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
|
||||
let LogFile { path, file, owner } = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
|
||||
*self.path.lock().unwrap_or_else(PoisonError::into_inner) = Some(path.clone());
|
||||
*self.owner.lock().unwrap_or_else(PoisonError::into_inner) = owner;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// The log file records are written to, once [`AppLogger::attach_dir`] opened one: `None` when
|
||||
/// the file could not be created (standard error is the only sink, see [`install`]).
|
||||
pub fn path(&self) -> Option<PathBuf> {
|
||||
self.path.lock().unwrap_or_else(PoisonError::into_inner).clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl log::Log for AppLogger {
|
||||
@@ -379,6 +388,21 @@ mod tests {
|
||||
d
|
||||
}
|
||||
|
||||
/// The startup-failure dialog names the log file, so the logger has to remember it (#7643).
|
||||
/// A start that couldn't create one keeps `None` and the dialog leaves the line out.
|
||||
#[test]
|
||||
fn path_names_the_attached_log_file() {
|
||||
let _spawns = crate::no_child_spawns();
|
||||
let dir = temp_dir("path");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
assert_eq!(logger.path(), None, "nothing is written before a file is attached");
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(logger.path(), Some(path));
|
||||
drop(logger);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_filter_shows_photocraft_info_and_other_crates_warnings() {
|
||||
let f = Filter::parse(DEFAULT_FILTER);
|
||||
|
||||
@@ -49,6 +49,8 @@ mod mac_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_window;
|
||||
// Pure logic is tested on every platform; only Linux runs the check.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod external_svg;
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
mod linux_libs;
|
||||
mod logging;
|
||||
@@ -66,6 +68,7 @@ mod services;
|
||||
// macOS gets one instance from LaunchServices (open-documents Apple events).
|
||||
#[cfg(any(not(target_os = "macos"), test))]
|
||||
mod single_instance;
|
||||
mod startup_report;
|
||||
// Tablet support (macOS AppKit monitor, Linux X11/Wayland, Windows barrel button).
|
||||
mod tablet;
|
||||
mod ui_state;
|
||||
@@ -75,8 +78,7 @@ mod window_frame;
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_ui_egui::PhotocraftApp;
|
||||
|
||||
/// Matches the `.desktop` file and hicolor icon name, so Wayland docks pick up the icon.
|
||||
const APP_ID: &str = "ai.storyteller.photocraft";
|
||||
use photocraft_ui_egui::os_window::APP_ID;
|
||||
|
||||
/// Windows and Linux: no OS title bar; the app's top bar is the title bar, with its own caption
|
||||
/// buttons and edge resizing (`photocraft_ui_egui::titlebar`), as Photoshop does on Windows. macOS
|
||||
@@ -193,12 +195,156 @@ fn at_least_min_size(mut builder: egui::ViewportBuilder) -> egui::ViewportBuilde
|
||||
builder
|
||||
}
|
||||
|
||||
/// Attach the log file under the settings directory. Called after the arguments are parsed, so
|
||||
/// `--version` and usage errors leave no file behind. Records logged until now are written first.
|
||||
fn attach_log_file(logger: Option<&'static logging::AppLogger>) {
|
||||
if let (Some(logger), Some(dir)) = (logger, services::config_dir()) {
|
||||
match logger.attach_dir(&dir.join("logs")) {
|
||||
Ok(path) => log::info!("PhotoCraft {}, log file {}", photocraft_engine::build_info::long_version(), path.display()),
|
||||
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
|
||||
Err(e) => log::warn!("no log file: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One PhotoCraft per user (#3708). Only with the per-user settings folder (a portable copy's may
|
||||
/// be shared between users), and not for automation, `--new-instance` or per-process options.
|
||||
/// `Break` when the files were handed to the running PhotoCraft and this launch is done.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn claim_single_instance(
|
||||
files: &[String],
|
||||
new_instance: bool,
|
||||
control: bool,
|
||||
unreadable_paths: bool,
|
||||
mut launch_options: Vec<launch_args::PerProcess>,
|
||||
) -> std::ops::ControlFlow<(), Option<single_instance::Primary>> {
|
||||
use std::ops::ControlFlow::{Break, Continue};
|
||||
let new_instance_env = std::env::var("PHOTOCRAFT_NEW_INSTANCE").ok();
|
||||
// A variable documented as the equivalent of a per-launch flag counts as that flag: a
|
||||
// launch that sets only `PHOTOCRAFT_PRESENT_MODE` (say) has per-process options too, so it
|
||||
// keeps its own process instead of handing its files over and being ignored.
|
||||
launch_options.extend_from_slice(&single_instance::env_launch_options());
|
||||
let context = single_instance::LaunchContext {
|
||||
per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform,
|
||||
new_instance_flag: new_instance,
|
||||
new_instance_env: new_instance_env.as_deref(),
|
||||
control,
|
||||
unreadable_paths,
|
||||
launch_options: &launch_options,
|
||||
};
|
||||
match (single_instance::skip_reason(&context), services::config_dir()) {
|
||||
(Some(reason), _) => {
|
||||
log::info!("running as a separate PhotoCraft: {reason}");
|
||||
Continue(None)
|
||||
}
|
||||
(None, None) => Continue(None),
|
||||
(None, Some(dir)) => {
|
||||
let paths: Vec<String> = files.iter().map(|f| single_instance::absolute(f)).collect();
|
||||
match single_instance::claim(&dir, &paths) {
|
||||
single_instance::Launch::Forwarded => {
|
||||
log::info!("handed {} file(s) to the running PhotoCraft", paths.len());
|
||||
Break(())
|
||||
}
|
||||
single_instance::Launch::Primary(primary) => Continue(Some(primary)),
|
||||
single_instance::Launch::Standalone(reason) => {
|
||||
log::warn!("starting a separate PhotoCraft: {reason}");
|
||||
Continue(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The display server to open the window on: Xwayland for a pen on Wayland, which gives this app
|
||||
/// no pen input (#639). Exits with the package to install when its libraries are missing.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_display_session() -> linux_libs::DisplaySession {
|
||||
let session = tablet::display_session();
|
||||
// Preferences › Performance › Linux display server = X11: Xwayland on a Wayland session too,
|
||||
// where native file drops work (winit 0.30 has none on Wayland, #386). Only when Xwayland can
|
||||
// run the window ($DISPLAY set, X11 libraries installed); otherwise the session's own.
|
||||
let session = if session == linux_libs::DisplaySession::Wayland
|
||||
&& gpu_startup::read_display_server(services::prefs_file().as_deref()) == photocraft_engine::prefs::LinuxDisplayServer::X11
|
||||
&& std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty())
|
||||
&& linux_libs::available(linux_libs::DisplaySession::X11)
|
||||
{
|
||||
eprintln!("photocraft: opening the window through Xwayland (Preferences › Performance › Linux display server)");
|
||||
linux_libs::DisplaySession::X11
|
||||
} else {
|
||||
session
|
||||
};
|
||||
// winit and wgpu dlopen the windowing and GPU libraries, and some of those crates panic when
|
||||
// one is missing (issue #201). Name the package to install and exit instead.
|
||||
if let Err(message) = linux_libs::preflight(session) {
|
||||
eprint!("{message}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
session
|
||||
}
|
||||
|
||||
/// The control server's port, token and workspace, when it is on (exits 2 on a bad configuration).
|
||||
fn control_setup(
|
||||
cli_port: Option<u16>,
|
||||
auth: photocraft_automation::args::ControlArgs,
|
||||
automation: &photocraft_automation::args::AutomationArgs,
|
||||
) -> Option<(std::net::TcpListener, String, photocraft_automation::workspace::AuthorizedWorkspace)> {
|
||||
let (port, warning) = control_port_for(cli_port, &integrations(services::prefs_file().as_deref()));
|
||||
if let Some(warning) = &warning {
|
||||
eprint!("{warning}");
|
||||
}
|
||||
let port = port?;
|
||||
let listener = match std::net::TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(listener) => listener,
|
||||
Err(error) => {
|
||||
eprintln!("photocraft: control server failed to bind 127.0.0.1:{port}: {error}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
let (supplied, token_file) = (auth.control_token, auth.control_token_file);
|
||||
let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) {
|
||||
Ok(token) => token,
|
||||
Err(e) => {
|
||||
eprintln!("photocraft: cannot configure control authentication: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
if let Some(path) = token_file {
|
||||
eprintln!("photocraft: control token file: {}", path.display());
|
||||
} else if supplied.is_none() {
|
||||
eprintln!("photocraft: control token: {token}");
|
||||
} else {
|
||||
eprintln!("photocraft: using supplied control token");
|
||||
}
|
||||
let workspace = match automation.workspace() {
|
||||
Ok(workspace) => workspace,
|
||||
Err(error) => {
|
||||
eprintln!("photocraft: cannot configure automation workspace: {error}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
Some((listener, token, workspace))
|
||||
}
|
||||
|
||||
/// Record this start's backend in the GPU startup marker, if the marker is locked.
|
||||
fn write_start_marker(sentinel: &gpu_startup::SharedSentinel, plan: &gpu_startup::Plan) {
|
||||
if let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_mut() {
|
||||
let backend = match &plan.env {
|
||||
Some(v) => format!("env:{v}"),
|
||||
None => plan.backend.name().to_string(),
|
||||
};
|
||||
let marker = gpu_startup::Marker { backend, version: photocraft_engine::build_info::long_version().to_string(), ..Default::default() };
|
||||
if let Err(e) = s.write(marker) {
|
||||
log::warn!("GPU startup marker: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
crash_guard::install_hook();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::PHOTOCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Some(done) = craft_update::self_update::finish(&photocraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Err(e) = done {
|
||||
eprintln!("PhotoCraft update helper failed: {e}");
|
||||
std::process::exit(1);
|
||||
@@ -228,15 +374,7 @@ fn main() -> eframe::Result {
|
||||
mut launch_options,
|
||||
} = launch;
|
||||
|
||||
// The log file lives under the settings directory; opened after the arguments, so `--version`
|
||||
// and usage errors leave no file behind. Records logged until now are written to it first.
|
||||
if let (Some(logger), Some(dir)) = (logger, services::config_dir()) {
|
||||
match logger.attach_dir(&dir.join("logs")) {
|
||||
Ok(path) => log::info!("PhotoCraft {}, log file {}", photocraft_engine::build_info::long_version(), path.display()),
|
||||
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
|
||||
Err(e) => log::warn!("no log file: {e}"),
|
||||
}
|
||||
}
|
||||
attach_log_file(logger);
|
||||
|
||||
// An adapter named in the environment is chosen at startup, which a forwarded launch never reaches.
|
||||
let rendering_mode = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref()).1;
|
||||
@@ -244,114 +382,26 @@ fn main() -> eframe::Result {
|
||||
launch_options.push(launch_args::PerProcess::GpuAdapterEnv);
|
||||
}
|
||||
// One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs
|
||||
// become tabs in its window. Only with the per-user settings folder (a portable copy's may be
|
||||
// shared between users), and not for automation, `--new-instance` or per-process options.
|
||||
// become tabs in its window. Lives until the event loop returns, then `shutdown` lets the next
|
||||
// launch become the primary.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let single_instance = {
|
||||
let new_instance_env = std::env::var("PHOTOCRAFT_NEW_INSTANCE").ok();
|
||||
// A variable documented as the equivalent of a per-launch flag counts as that flag: a
|
||||
// launch that sets only `PHOTOCRAFT_PRESENT_MODE` (say) has per-process options too, so it
|
||||
// keeps its own process instead of handing its files over and being ignored.
|
||||
// Folded in here, inside the block that only non-macOS builds compile, so the binding
|
||||
// above keeps its `mut` off macOS, where nothing is folded in.
|
||||
let mut launch_options = launch_options;
|
||||
launch_options.extend_from_slice(&single_instance::env_launch_options());
|
||||
let context = single_instance::LaunchContext {
|
||||
per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform,
|
||||
new_instance_flag: new_instance,
|
||||
new_instance_env: new_instance_env.as_deref(),
|
||||
control: control_port.is_some(),
|
||||
unreadable_paths: !unreadable_paths.is_empty(),
|
||||
launch_options: &launch_options,
|
||||
};
|
||||
match (single_instance::skip_reason(&context), services::config_dir()) {
|
||||
(Some(reason), _) => {
|
||||
log::info!("running as a separate PhotoCraft: {reason}");
|
||||
None
|
||||
}
|
||||
(None, None) => None,
|
||||
(None, Some(dir)) => {
|
||||
let paths: Vec<String> = files.iter().map(|f| single_instance::absolute(f)).collect();
|
||||
match single_instance::claim(&dir, &paths) {
|
||||
single_instance::Launch::Forwarded => {
|
||||
log::info!("handed {} file(s) to the running PhotoCraft", paths.len());
|
||||
return Ok(());
|
||||
}
|
||||
single_instance::Launch::Primary(primary) => Some(primary),
|
||||
single_instance::Launch::Standalone(reason) => {
|
||||
log::warn!("starting a separate PhotoCraft: {reason}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let single_instance = match claim_single_instance(&files, new_instance, control_port.is_some(), !unreadable_paths.is_empty(), launch_options) {
|
||||
std::ops::ControlFlow::Break(()) => return Ok(()),
|
||||
std::ops::ControlFlow::Continue(primary) => primary,
|
||||
};
|
||||
// Lives until the event loop returns, then `shutdown` lets the next launch become the primary.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let single_instance = &single_instance;
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = (new_instance, &launch_options);
|
||||
|
||||
// The display server to open the window on: Xwayland for a pen on Wayland, which gives this
|
||||
// app no pen input (#639).
|
||||
// Bind and authenticate the authorized control listener before display/GPU initialization.
|
||||
let control = control_setup(control_port, auth, &automation);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let native_session = linux_libs::session_from_env(|name| std::env::var(name).ok());
|
||||
#[cfg(target_os = "linux")]
|
||||
let session = tablet::display_session();
|
||||
// Preferences › Performance › Linux display server = X11: Xwayland on a Wayland session too,
|
||||
// where native file drops work (winit 0.30 has none on Wayland, #386). Only when Xwayland can
|
||||
// run the window ($DISPLAY set, X11 libraries installed); otherwise the session's own.
|
||||
#[cfg(target_os = "linux")]
|
||||
let session = if session == linux_libs::DisplaySession::Wayland
|
||||
&& gpu_startup::read_display_server(services::prefs_file().as_deref()) == photocraft_engine::prefs::LinuxDisplayServer::X11
|
||||
&& std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty())
|
||||
&& linux_libs::available(linux_libs::DisplaySession::X11)
|
||||
{
|
||||
eprintln!("photocraft: opening the window through Xwayland (Preferences › Performance › Linux display server)");
|
||||
linux_libs::DisplaySession::X11
|
||||
} else {
|
||||
session
|
||||
};
|
||||
let session = linux_display_session();
|
||||
|
||||
// winit and wgpu dlopen the windowing and GPU libraries, and some of those crates panic when
|
||||
// one is missing (issue #201). Name the package to install and exit instead.
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Err(message) = linux_libs::preflight(session) {
|
||||
eprint!("{message}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let (control_port, control_pref_warning) = control_port_for(control_port, &integrations(services::prefs_file().as_deref()));
|
||||
if let Some(warning) = &control_pref_warning {
|
||||
eprint!("{warning}");
|
||||
}
|
||||
let control = if let Some(port) = control_port {
|
||||
let (supplied, token_file) = (auth.control_token, auth.control_token_file);
|
||||
let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) {
|
||||
Ok(token) => token,
|
||||
Err(e) => {
|
||||
eprintln!("photocraft: cannot configure control authentication: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
if let Some(path) = token_file {
|
||||
eprintln!("photocraft: control token file: {}", path.display());
|
||||
} else if supplied.is_none() {
|
||||
eprintln!("photocraft: control token: {token}");
|
||||
} else {
|
||||
eprintln!("photocraft: using supplied control token");
|
||||
}
|
||||
let workspace = match automation.workspace() {
|
||||
Ok(workspace) => workspace,
|
||||
Err(error) => {
|
||||
eprintln!("photocraft: cannot configure automation workspace: {error}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
Some((port, token, workspace))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Finder / Dock / Open With deliver files as Apple events, not arguments; catch the one that
|
||||
// launched us as well as later ones. Lives until the event loop returns.
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -409,16 +459,7 @@ fn main() -> eframe::Result {
|
||||
log::warn!("the previous start didn't finish (GPU backend {}, adapter {:?}); {}", m.backend, m.adapter, plan.reason.as_deref().unwrap_or(""));
|
||||
}
|
||||
let sentinel: gpu_startup::SharedSentinel = std::sync::Arc::new(std::sync::Mutex::new(sentinel));
|
||||
if let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_mut() {
|
||||
let backend = match &plan.env {
|
||||
Some(v) => format!("env:{v}"),
|
||||
None => plan.backend.name().to_string(),
|
||||
};
|
||||
let marker = gpu_startup::Marker { backend, version: photocraft_engine::build_info::long_version().to_string(), ..Default::default() };
|
||||
if let Err(e) = s.write(marker) {
|
||||
log::warn!("GPU startup marker: {e}");
|
||||
}
|
||||
}
|
||||
write_start_marker(&sentinel, &plan);
|
||||
let gpu_note: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
|
||||
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
|
||||
let wanted_adapter =
|
||||
@@ -443,6 +484,10 @@ fn main() -> eframe::Result {
|
||||
log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan);
|
||||
let retry_cpu = !safe_gpu && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu;
|
||||
let keep_marker = gpu_startup::keep_marker_after_error(&plan, os);
|
||||
// For the failure dialog below: the plan itself moves into the window closure. With
|
||||
// `WGPU_BACKEND` set (`plan.env`) wgpu picks the backend, so name that rather than `auto`.
|
||||
let started_renderer = plan.env.clone().unwrap_or_else(|| plan.backend.name().to_string());
|
||||
let started_note = plan.reason.clone();
|
||||
let app_created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let created_in_callback = app_created.clone();
|
||||
let started_sentinel = sentinel.clone();
|
||||
@@ -569,8 +614,8 @@ fn main() -> eframe::Result {
|
||||
log::warn!("couldn't remember GPU backend {}: {e}", b.name());
|
||||
}
|
||||
});
|
||||
if let Some((port, token, _)) = control {
|
||||
let rx = control_server::start(port, token, cc.egui_ctx.clone());
|
||||
if let Some((listener, token, _)) = control {
|
||||
let rx = control_server::start(listener, token, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -602,7 +647,14 @@ fn main() -> eframe::Result {
|
||||
// Caps Lock state (X11/Windows/macOS; `None` on native Wayland): the canvas shows the
|
||||
// precise crosshair for painting tools while it is toggled on (#1758).
|
||||
app.services.caps_lock = caps_lock::service(cc);
|
||||
app.services.screen_pick = screen_color::service(cc, app.services.is_wayland);
|
||||
// Pick through the portal on a Wayland desktop even when the window went through
|
||||
// Xwayland (a pen, or the X11 display-server preference): Xwayland's root window has
|
||||
// no desktop pixels to read (#8977).
|
||||
#[cfg(target_os = "linux")]
|
||||
let portal = screen_color::uses_portal(native_session);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let portal = false;
|
||||
app.services.screen_pick = screen_color::service(cc, portal);
|
||||
// Tablet pressure/tilt/eraser (winit drops them): the macOS monitor and the X11 reader
|
||||
// write into the stylus feed. The monitor goes in here, not before the event loop:
|
||||
// AppKit's shared application only exists once winit created it (#759).
|
||||
@@ -680,6 +732,16 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A launch that fails before a window exists leaves nothing on screen, and a desktop launch
|
||||
// has no console for the `Err` above: log it, then say why in a native dialog with the log
|
||||
// file to attach (#7643). A terminal or automation launch can read standard error or the
|
||||
// exit status instead, so it keeps the silent exit.
|
||||
if let Err(error) = &result {
|
||||
log::error!("PhotoCraft could not start: {error}");
|
||||
if startup_report::needs_dialog(control_port.is_some()) {
|
||||
startup_report::show(&error.to_string(), &started_renderer, started_note.as_deref(), logger.and_then(logging::AppLogger::path).as_deref());
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//! Opt-in local acceptance. All artwork paths come from the test environment; originals are read-only.
|
||||
|
||||
use super::{native, vector_conversion};
|
||||
use base64::Engine as _;
|
||||
use photocraft_doc::{Document, LayerContent, LayerId, SmartSource};
|
||||
use photocraft_ui_egui::{ExternalSvgSession, ExternalVectorSource};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Arc, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Exercise the real CLI's same-format Save, the native watcher and PhotoCraft's transaction.
|
||||
#[test]
|
||||
#[ignore = "set VECTORCRAFT_TEST_EDITOR and optionally PHOTO_NATIVE_LOGO_PSD/PHOTO_NATIVE_CONTAINER"]
|
||||
#[allow(clippy::cognitive_complexity, reason = "one opt-in walk through a real editor, step by step")]
|
||||
fn original_format_real_editor_save_watcher_and_psd_roundtrip() {
|
||||
let editor = std::env::var_os("VECTORCRAFT_TEST_EDITOR").expect("VECTORCRAFT_TEST_EDITOR");
|
||||
let editor = Path::new(&editor);
|
||||
let mut photo = photocraft_engine::Session::new();
|
||||
let vector_path = std::env::var_os("PHOTO_NATIVE_VECTOR");
|
||||
let vector_original = vector_path.as_ref().map(|path| std::fs::read(path).unwrap());
|
||||
let original_path = std::env::var_os("PHOTO_NATIVE_LOGO_PSD");
|
||||
let original = original_path.as_ref().map(|path| std::fs::read(path).unwrap());
|
||||
let mut parent_before = None;
|
||||
let id;
|
||||
if let Some(bytes) = &original {
|
||||
photo.add_document(photocraft_io::import("Logo.psd", bytes).unwrap().document, None);
|
||||
if let Ok(name) = std::env::var("PHOTO_NATIVE_CONTAINER") {
|
||||
parent_before = Some(photo.active().unwrap().doc.clone());
|
||||
let layer = find_source(&photo.active().unwrap().doc, &name).expect("nested logo container");
|
||||
photo.execute("layer.smartObjects.editContents", json!({"layer":layer.0})).unwrap();
|
||||
}
|
||||
let name = std::env::var("PHOTO_NATIVE_SOURCE").unwrap_or("Vector Smart Object.ai".into());
|
||||
id = find_source(&photo.active().unwrap().doc, &name).expect("visible embedded vector");
|
||||
} else {
|
||||
photo.execute("file.new", json!({"width":32,"height":24})).unwrap();
|
||||
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><rect x="1" y="1" width="3" height="2" fill="red"/></svg>"#;
|
||||
let placed = photocraft_engine::file_cmds::place_bytes(&mut photo, "Artwork.svg", svg.to_vec(), None, &json!({"smartObject":true})).unwrap();
|
||||
id = LayerId(placed["layer"].as_u64().unwrap());
|
||||
// Generate native sources with our writer, or use a read-only local vector fixture.
|
||||
let (format, bytes) = if let Some(bytes) = &vector_original {
|
||||
let extension = Path::new(vector_path.as_ref().unwrap()).extension().unwrap().to_str().unwrap().to_ascii_lowercase();
|
||||
(extension, bytes.clone())
|
||||
} else {
|
||||
let owned = native::OwnedSvg::create(svg).unwrap();
|
||||
let format = std::env::var("PHOTO_NATIVE_FORMAT").unwrap_or("ai".into());
|
||||
let path = owned.root.join(format!("Input.{format}"));
|
||||
run_commands(
|
||||
editor,
|
||||
&owned.root,
|
||||
vec![
|
||||
("document.open", json!({"path":owned.file})),
|
||||
("document.export", json!({"path":path,"format":format,"preserveEditing":true,"useArtboards":true})),
|
||||
],
|
||||
);
|
||||
(format, std::fs::read(path).unwrap())
|
||||
};
|
||||
let state = photo.active_mut().unwrap();
|
||||
let LayerContent::Smart(sm) = &mut Arc::make_mut(&mut state.doc).layer_mut(id).unwrap().content else { panic!("smart") };
|
||||
sm.source = SmartSource::Embedded { file_name: format!("Artwork.{format}"), bytes: Arc::new(bytes) };
|
||||
}
|
||||
let handoff = photo.execute("layer.smartObjects.exportSvg", json!({"layer":id.0})).unwrap();
|
||||
let source = ExternalVectorSource {
|
||||
bytes: Arc::new(base64::engine::general_purpose::STANDARD.decode(handoff["sourceBase64"].as_str().unwrap()).unwrap()),
|
||||
extension: handoff["extension"].as_str().unwrap().into(),
|
||||
size: serde_json::from_value(handoff["sourceSize"].clone()).unwrap(),
|
||||
};
|
||||
let before = photo.active().unwrap().doc.clone();
|
||||
let (_send, stop) = mpsc::channel();
|
||||
let (owned, initial) = vector_conversion::prepare(editor, &source, &stop, true).unwrap();
|
||||
assert_eq!(owned.file.extension().unwrap().to_str().unwrap(), source.extension);
|
||||
assert!(photo.active().unwrap().doc == before, "preparation must not change the embedded original");
|
||||
let file = owned.file.clone();
|
||||
let root = owned.root.clone();
|
||||
let mut watcher = native::start_native_test(owned, initial.clone(), editor.to_path_buf(), source.clone()).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
assert!(watcher.poll_vector().is_none(), "opening must not accept the regenerated private AI working copy");
|
||||
// A small visible edit, ordinary Save to the already-open file, no conversion to SVG.
|
||||
run_commands(
|
||||
editor,
|
||||
&root,
|
||||
vec![
|
||||
("document.open", json!({"path":file})),
|
||||
("paint.setFill", json!({"color":"#00ff00"})),
|
||||
("shape.rectangle", json!({"x":2,"y":2,"width":3,"height":2})),
|
||||
("file.save", json!({})),
|
||||
],
|
||||
);
|
||||
let saved = std::fs::read(&file).unwrap();
|
||||
eprintln!("Native save: format={}, bytes={}", source.extension, saved.len());
|
||||
assert!(saved != initial, "Save did not update the native working file");
|
||||
assert!(photocraft_io::vector_preview::is_native_source(&saved));
|
||||
assert!(!photocraft_io::svg::is_svg(&saved));
|
||||
let deadline = Instant::now() + Duration::from_secs(90);
|
||||
let snapshot = loop {
|
||||
assert!(Instant::now() < deadline, "saved native vector never reached watcher");
|
||||
if let Some(result) = watcher.poll_vector() {
|
||||
break result.unwrap();
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
};
|
||||
assert!(snapshot.bytes == saved, "watcher snapshot differs from saved native bytes");
|
||||
let params = json!({"document":handoff["document"],"layer":id.0,"contentsId":handoff["contentsId"],"fingerprint":handoff["fingerprint"],"extension":source.extension,"sourceBase64":base64::engine::general_purpose::STANDARD.encode(&saved),"previewSvg":String::from_utf8(snapshot.preview_svg.unwrap()).unwrap()});
|
||||
assert_eq!(photo.execute("layer.smartObjects.updateVectorSource", params).unwrap()["updated"], true);
|
||||
let after = photo.active().unwrap().doc.clone();
|
||||
let LayerContent::Smart(previous) = &before.layer(id).unwrap().content else { panic!("smart") };
|
||||
let LayerContent::Smart(changed) = &after.layer(id).unwrap().content else { panic!("smart") };
|
||||
assert_eq!(changed.transform, previous.transform);
|
||||
assert_eq!(changed.perspective, previous.perspective);
|
||||
assert_eq!(changed.warp, previous.warp);
|
||||
assert_eq!(changed.smart_filters, previous.smart_filters);
|
||||
assert!(embedded(&after, changed).unwrap().1 == saved, "native source was replaced with different bytes");
|
||||
assert!(photo.undo());
|
||||
assert!(photo.active().unwrap().doc == before, "Undo did not restore original source state");
|
||||
assert!(photo.redo());
|
||||
assert!(photo.active().unwrap().doc == after, "Redo did not restore saved native source state");
|
||||
let output = photocraft_io::export(&after, "Roundtrip.psd", &Default::default()).unwrap();
|
||||
let reopened = photocraft_io::import("Roundtrip.psd", &output.bytes).unwrap().document;
|
||||
let layer = find_source(&reopened, &embedded(&after, changed).unwrap().0).unwrap();
|
||||
let LayerContent::Smart(sm) = &reopened.layer(layer).unwrap().content else { panic!("smart") };
|
||||
assert!(embedded(&reopened, sm).unwrap().1 == saved, "PSD reopen did not retain the saved native bytes");
|
||||
assert!(sm.vector_preview == changed.vector_preview, "PSD did not retain the source-bound preview");
|
||||
assert!(
|
||||
sm.transform.m.iter().zip(changed.transform.m).all(|(a, b)| (a - b).abs() < 1e-9),
|
||||
"PSD corner reconstruction changed placement beyond floating-point tolerance"
|
||||
);
|
||||
assert!(photocraft_engine::smart_cmds::render(&reopened, sm).unwrap().as_ref() == changed.cache.as_ref(), "reopened native preview changed appearance");
|
||||
if let Some(parent_before) = parent_before {
|
||||
photo.execute("layer.smartObjects.saveContents", json!({})).unwrap();
|
||||
photo.set_active(0);
|
||||
let parent_after = photo.active().unwrap().doc.clone();
|
||||
assert!(photo.undo());
|
||||
assert!(photo.active().unwrap().doc == parent_before, "parent Undo changed original content");
|
||||
assert!(photo.redo());
|
||||
assert!(photo.active().unwrap().doc == parent_after, "parent Redo changed saved content");
|
||||
let output = photocraft_io::export(&parent_after, "Roundtrip.psd", &Default::default()).unwrap();
|
||||
let reopened = photocraft_io::import("Roundtrip.psd", &output.bytes).unwrap().document;
|
||||
assert!(contains_source(&reopened, &saved, 0), "parent PSD lost its nested native vector");
|
||||
}
|
||||
if let Some(path) = vector_path {
|
||||
assert!(std::fs::read(path).unwrap() == vector_original.unwrap(), "original vector changed")
|
||||
}
|
||||
if let Some(path) = original_path {
|
||||
assert!(std::fs::read(path).unwrap() == original.unwrap(), "original PSD changed")
|
||||
}
|
||||
}
|
||||
|
||||
fn run_commands(editor: &Path, root: &Path, commands: Vec<(&str, Value)>) {
|
||||
let cli = editor.parent().unwrap().join(if cfg!(windows) { "vectorcraft-cli.exe" } else { "vectorcraft-cli" });
|
||||
let output_path = root.join("TestResponses.jsonl");
|
||||
let output = std::fs::File::create(&output_path).unwrap();
|
||||
let mut child = Command::new(cli)
|
||||
.args(["mcp", "--headless", "--automation-read-root"])
|
||||
.arg(root)
|
||||
.arg("--automation-write-root")
|
||||
.arg(root)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(output)
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
let count = commands.len();
|
||||
for (index, (command, params)) in commands.into_iter().enumerate() {
|
||||
writeln!(
|
||||
input,
|
||||
"{}",
|
||||
json!({"jsonrpc":"2.0","id":index+1,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}})
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
drop(input);
|
||||
let deadline = Instant::now() + Duration::from_secs(90);
|
||||
loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
assert!(status.success());
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
panic!("editor test timed out")
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
let responses = std::fs::read_to_string(output_path).unwrap();
|
||||
for id in 1..=count {
|
||||
let response = responses.lines().filter_map(|line| serde_json::from_str::<Value>(line).ok()).find(|r| r["id"] == id).unwrap();
|
||||
assert!(response.get("error").is_none() && response["result"]["isError"] == false, "editor command {id} failed");
|
||||
}
|
||||
}
|
||||
|
||||
fn embedded<'a>(doc: &'a Document, sm: &'a photocraft_doc::SmartObject) -> Option<(String, &'a [u8])> {
|
||||
match &sm.source {
|
||||
SmartSource::Embedded { file_name, bytes } => Some((file_name.clone(), bytes)),
|
||||
SmartSource::Linked { path } => photocraft_io::linked::embedded_payload(&doc.metadata, path),
|
||||
}
|
||||
}
|
||||
fn find_source(doc: &Document, name: &str) -> Option<LayerId> {
|
||||
doc.walk().into_iter().find_map(|(path, _, layer)| {
|
||||
if !doc.shown_at(&path) {
|
||||
return None;
|
||||
}
|
||||
let LayerContent::Smart(sm) = &layer.content else { return None };
|
||||
embedded(doc, sm).filter(|(file, _)| file == name).map(|_| layer.id)
|
||||
})
|
||||
}
|
||||
|
||||
fn contains_source(doc: &Document, saved: &[u8], depth: usize) -> bool {
|
||||
if depth > 4 {
|
||||
return false;
|
||||
}
|
||||
doc.walk().into_iter().any(|(_, _, layer)| {
|
||||
let LayerContent::Smart(sm) = &layer.content else { return false };
|
||||
let Some((name, bytes)) = embedded(doc, sm) else { return false };
|
||||
bytes == saved
|
||||
|| ((bytes.starts_with(b"8BPS") || photocraft_format::is_pcraft(bytes))
|
||||
&& photocraft_io::import(&name, bytes).is_ok_and(|child| contains_source(&child.document, saved, depth + 1)))
|
||||
})
|
||||
}
|
||||
@@ -38,6 +38,12 @@ pub fn service(cc: &eframe::CreationContext<'_>, wayland: bool) -> Option<Servic
|
||||
Pending { receiver: rx, cancelled }
|
||||
}))
|
||||
}
|
||||
/// Whether to pick through the desktop portal: on a Wayland desktop, whichever display server the
|
||||
/// window opened on. Xwayland serves only its own windows, so reading its root window fails (#8977).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn uses_portal(desktop: crate::linux_libs::DisplaySession) -> bool {
|
||||
desktop == crate::linux_libs::DisplaySession::Wayland
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
#[cfg_attr(not(any(target_os = "linux", target_os = "macos", target_os = "windows", test)), allow(dead_code))]
|
||||
struct Placement {
|
||||
@@ -480,6 +486,15 @@ mod tests {
|
||||
let offset = (y * first.width + x) * 4;
|
||||
assert_eq!(&first.rgba[offset..offset + 4], &[255, 128, 64, 255]);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_wayland_desktop_picks_through_the_portal_even_with_an_xwayland_window() {
|
||||
use crate::linux_libs::DisplaySession;
|
||||
// A pen or the X11 display-server preference opens the window through Xwayland, but the
|
||||
// session is still Wayland: the X11 grab fails there with BadMatch (#8977).
|
||||
assert!(uses_portal(DisplaySession::Wayland));
|
||||
assert!(!uses_portal(DisplaySession::X11));
|
||||
}
|
||||
#[test]
|
||||
fn screen_capture_matches_negative_and_hidpi_monitor_geometry() {
|
||||
let m = [Placement { index: 2, x: -3840, y: 0, width: 3840, height: 2160 }];
|
||||
|
||||
@@ -92,6 +92,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
|
||||
("OpenRaster", &["ora"]),
|
||||
("PNG", &["png"]),
|
||||
("JPEG", &["jpg", "jpeg"]),
|
||||
("JPEG XL", &["jxl"]),
|
||||
("WebP", &["webp"]),
|
||||
("TIFF", &["tif", "tiff"]),
|
||||
("Targa", &["tga"]),
|
||||
@@ -634,12 +635,6 @@ fn shell_word(s: &str) -> String {
|
||||
if s.bytes().all(|b| b.is_ascii_alphanumeric() || b"/._-+:,@".contains(&b)) { s.to_string() } else { format!("'{}'", s.replace('\'', r"'\''")) }
|
||||
}
|
||||
|
||||
/// The release-check transport, or `None` inside Flatpak: the sandbox has no network (see the
|
||||
/// manifest's finish-args) and `flatpak update` owns updates there.
|
||||
fn update_transport(flatpak_id: Option<&std::ffi::OsStr>) -> Option<photocraft_engine::updates_cmds::CheckFn> {
|
||||
flatpak_id.is_none().then(crate::updates::service)
|
||||
}
|
||||
|
||||
pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> Services {
|
||||
let clip: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
|
||||
let clip_unreadable: Rc<Cell<bool>> = Rc::default();
|
||||
@@ -704,7 +699,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
inbox: None,
|
||||
open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))),
|
||||
beep: Some(platform_beep),
|
||||
check_updates: update_transport(std::env::var_os("FLATPAK_ID").as_deref()),
|
||||
check_updates: Some(crate::updates::service()),
|
||||
clipboard_set_image: Some({
|
||||
let clip = clip.clone();
|
||||
guarded_clipboard_set(move |w: u32, h: u32, px: &[u8]| {
|
||||
@@ -716,6 +711,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
cb.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(px) }).map_err(|e| e.to_string())
|
||||
})
|
||||
}),
|
||||
open_external_svg: Some(Box::new(crate::external_svg::open)),
|
||||
clipboard_get_image: Some({
|
||||
let clip = clip.clone();
|
||||
let unreadable = clip_unreadable.clone();
|
||||
|
||||
@@ -182,9 +182,8 @@ fn open_filters_keep_literal_extensions_on_windows_and_macos() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatpak_builds_have_no_update_transport() {
|
||||
assert!(update_transport(Some(std::ffi::OsStr::new("ai.storyteller.photocraft"))).is_none());
|
||||
assert!(update_transport(None).is_some());
|
||||
fn native_services_provide_update_transport() {
|
||||
assert!(native(None).check_updates.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -253,7 +252,7 @@ fn every_save_dialog_leads_with_its_own_extension() {
|
||||
app.run("prefs.set", json!({"path": "export.quickExportFormat", "value": format})).unwrap();
|
||||
let _ = invoke(&mut app, "file.export.quickExportAsPng");
|
||||
}
|
||||
for format in ["gif", "png8", "png24", "jpeg", "wbmp"] {
|
||||
for format in ["gif", "png8", "png24", "jpeg", "webp", "wbmp"] {
|
||||
dialog(&mut app, "file.export.saveForWebLegacy", json!({"format": format}));
|
||||
}
|
||||
app.run("slice.new", json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap();
|
||||
@@ -266,13 +265,13 @@ fn every_save_dialog_leads_with_its_own_extension() {
|
||||
let exts: Vec<&str> = asked.iter().filter_map(|s| s.rsplit_once('.').map(|(_, e)| e)).collect();
|
||||
// The extension each save above should suggest, in order.
|
||||
let want = [
|
||||
"psd", // Save As untitled
|
||||
"pcraft jpeg gif bmp psd", // Save As opened .pcraft .jpeg .gif .bmp .dng
|
||||
"psd", // Save a Copy
|
||||
"png jpg webp tif tga", // Export As
|
||||
"png jpg gif webp", // Quick Export
|
||||
"gif png png jpg wbmp html", // Save for Web: gif png8 png24 jpeg wbmp, then with slices
|
||||
"csv pcpresets", // Measurement Log, Export Presets
|
||||
"psd", // Save As untitled
|
||||
"pcraft jpeg gif bmp psd", // Save As opened .pcraft .jpeg .gif .bmp .dng
|
||||
"psd", // Save a Copy
|
||||
"png jpg webp tif tga", // Export As
|
||||
"png jpg gif webp", // Quick Export
|
||||
"gif png png jpg webp wbmp html", // Save for Web: gif png8 png24 jpeg webp wbmp, then with slices
|
||||
"csv pcpresets", // Measurement Log, Export Presets
|
||||
]
|
||||
.join(" ");
|
||||
assert_eq!(exts, want.split(' ').collect::<Vec<_>>());
|
||||
@@ -313,10 +312,15 @@ fn save_as_offers_only_writable_formats_including_ico() {
|
||||
let subtype = name.rsplit('.').next().unwrap_or_default();
|
||||
assert!(!filters.iter().any(|(_, exts)| exts.iter().any(|e| e == subtype)), "{name}: .{subtype} is offered");
|
||||
}
|
||||
// File › Open lists every file Save As writes, also in that format's own type (PDF is written
|
||||
// but not read yet).
|
||||
// File Open lists readable Save As formats in their own type. Export-only formats must
|
||||
// stay absent from both Open filters rather than advertising an unavailable decoder.
|
||||
for (name, exts) in SAVE_FILTERS {
|
||||
for ext in exts.iter().filter(|e| **e != "pdf") {
|
||||
if photocraft_codecs::from_extension(ext).is_some_and(|format| !format.caps().read) {
|
||||
assert!(!OPEN_EXTS.contains(ext), "{name}: export-only .{ext} must not appear in All Formats");
|
||||
assert!(!OPEN_FILTERS.iter().any(|(_, open)| open.contains(ext)), "{name}: export-only .{ext} must not appear in an Open type");
|
||||
continue;
|
||||
}
|
||||
assert!(OPEN_EXTS.contains(ext), "{name}: Open's All Formats lacks .{ext}");
|
||||
assert!(OPEN_FILTERS.iter().any(|(_, open)| open.contains(ext)), "{name}: no Open type lists .{ext}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Telling the user why a launch failed when there is no window to say it in (#7643).
|
||||
//!
|
||||
//! A desktop launch has no console — release builds are GUI-subsystem apps on Windows — so the
|
||||
//! `Err` that ends `main` used to vanish and PhotoCraft "just closed". This shows one native
|
||||
//! dialog with the error, the renderer that was tried and the log file to attach. The text is
|
||||
//! English diagnostics, like Help › System Info, so a bug report reads the same in every language.
|
||||
//!
|
||||
//! Terminal and automation launches keep the old behaviour: they can read standard error or the
|
||||
//! exit status, and a modal dialog would leave a script waiting.
|
||||
|
||||
use std::io::IsTerminal;
|
||||
use std::path::Path;
|
||||
|
||||
/// Whether a failed launch needs the dialog: no terminal under standard error, no control client
|
||||
/// (an agent launch reads the exit status instead of clicking OK), and a screen to put it on.
|
||||
pub fn dialog_wanted(stderr_is_terminal: bool, automation: bool, display: bool) -> bool {
|
||||
!stderr_is_terminal && !automation && display
|
||||
}
|
||||
|
||||
/// Whether this launch asks for the dialog (see [`dialog_wanted`]).
|
||||
pub fn needs_dialog(automation: bool) -> bool {
|
||||
dialog_wanted(std::io::stderr().is_terminal(), automation, has_display())
|
||||
}
|
||||
|
||||
/// Whether there is a screen for a dialog: always on Windows and macOS; on Linux a headless box
|
||||
/// (a CI runner, an SSH session — `startup_without_display` removes both variables) has none.
|
||||
fn has_display() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
["DISPLAY", "WAYLAND_DISPLAY"].iter().any(|key| std::env::var_os(key).is_some_and(|v| !v.is_empty()))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog's text: the error, the renderer that was tried and why it wasn't the preference,
|
||||
/// the version and, when one was opened, the log file.
|
||||
pub fn text(error: &str, renderer: &str, note: Option<&str>, version: &str, log: Option<&Path>) -> String {
|
||||
let mut text = format!("PhotoCraft could not start.\n\n{error}\n\nRenderer: {renderer}");
|
||||
if let Some(note) = note {
|
||||
text.push_str(&format!("\nStartup: {note}"));
|
||||
}
|
||||
text.push_str(&format!("\nVersion: {version}"));
|
||||
if let Some(path) = log {
|
||||
text.push_str(&format!("\nLog file: {}", path.display()));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// Show [`text`] in a native error dialog. Called after the event loop returned, so nothing is
|
||||
/// repainting and the launch was about to fail anyway.
|
||||
pub fn show(error: &str, renderer: &str, note: Option<&str>, log: Option<&Path>) {
|
||||
let version = photocraft_engine::build_info::long_version();
|
||||
let body = text(error, renderer, note, &version, log);
|
||||
log::info!("showing the startup-failure dialog");
|
||||
let _ = rfd::MessageDialog::new()
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.set_title(photocraft_ui_egui::i18n::t("PhotoCraft could not start"))
|
||||
.set_description(body)
|
||||
.set_buttons(rfd::MessageButtons::Ok)
|
||||
.show();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_desktop_launch_asks_the_user_and_a_script_does_not() {
|
||||
assert!(dialog_wanted(false, false, true), "no console: the error would vanish");
|
||||
assert!(!dialog_wanted(true, false, true), "a terminal shows the error itself");
|
||||
assert!(!dialog_wanted(false, true, true), "an automation client reads the exit status");
|
||||
assert!(!dialog_wanted(false, false, false), "a headless launch has no screen for a dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_text_names_the_error_renderer_version_and_log() {
|
||||
let log = Path::new("/home/me/.config/photocraft/logs/photocraft.log");
|
||||
let body = text("no adapter for Vulkan", "dx12", None, "0.15.1 (abc1234)", Some(log));
|
||||
for part in [
|
||||
"could not start",
|
||||
"no adapter for Vulkan",
|
||||
"Renderer: dx12",
|
||||
"Version: 0.15.1 (abc1234)",
|
||||
"Log file: /home/me/.config/photocraft/logs/photocraft.log",
|
||||
] {
|
||||
assert!(body.contains(part), "{part:?} missing from {body:?}");
|
||||
}
|
||||
assert!(!body.contains("Startup:"), "no fallback to explain: {body:?}");
|
||||
// A start whose log file could not be created says so by leaving the line out.
|
||||
assert!(!text("boom", "cpu", None, "0.15.1", None).contains("Log file:"));
|
||||
}
|
||||
|
||||
/// A crash fallback is the confusing case: the app picks a renderer the user never chose, so
|
||||
/// the dialog has to say why as well as which.
|
||||
#[test]
|
||||
fn a_fallback_that_was_not_the_preference_is_explained() {
|
||||
let note = "the previous start didn't finish on vulkan; using dx12";
|
||||
let body = text("device lost", "dx12", Some(note), "0.15.1", None);
|
||||
assert!(body.contains(&format!("Renderer: dx12\nStartup: {note}\n")), "{body:?}");
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,6 @@ pub fn service() -> CheckFn {
|
||||
|
||||
fn check() -> CheckResult {
|
||||
let http = craft_update::Http::new("PhotoCraft/Update")?;
|
||||
let found = craft_update::check_for_update(&craft_update::suite::PHOTOCRAFT, &http, photocraft_engine::build_info::VERSION)?;
|
||||
let found = craft_update::check_for_update(&photocraft_ui_egui::UPDATE_APP, &http, photocraft_engine::build_info::VERSION)?;
|
||||
Ok(found.map(|release| Release { version: release.tag.trim_start_matches(['v', 'V']).to_string(), url: release.html_url }))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
//! Bounded display previews for native vector editing. The working source retains its format.
|
||||
//! No document metadata supplies a process, command line or output path.
|
||||
|
||||
use super::native::OwnedSvg;
|
||||
use photocraft_ui_egui::ExternalVectorSource;
|
||||
use std::io::{Read, Seek, Write};
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Validate the source using a private preview, then expose only an original-format copy.
|
||||
/// `regenerate_ai` is true only when the interactive editor is VectorCraft: other editors
|
||||
/// (Illustrator, a chosen program) get the original AI bytes.
|
||||
pub(super) fn prepare(editor: &Path, source: &ExternalVectorSource, stop: &Receiver<()>, regenerate_ai: bool) -> Result<(OwnedSvg, Vec<u8>), String> {
|
||||
render_preview(editor, source, stop)?;
|
||||
// Foreign AI opens read-only for ordinary Save in VectorCraft. Regenerate only this
|
||||
// private AI working copy with editable AI data; never weaken that source-file protection.
|
||||
// Comparing the watcher with this initial copy prevents an open-only/no-op replacement.
|
||||
let regenerate = regenerate_ai && source.extension == "ai";
|
||||
let initial = if regenerate { run_export(editor, source, stop, "ai")? } else { source.bytes.as_ref().clone() };
|
||||
if regenerate {
|
||||
let prepared = ExternalVectorSource { bytes: std::sync::Arc::new(initial.clone()), extension: source.extension.clone(), size: source.size };
|
||||
render_preview(editor, &prepared, stop)?;
|
||||
}
|
||||
let owned = OwnedSvg::create_format(&initial, &source.extension)?;
|
||||
Ok((owned, initial))
|
||||
}
|
||||
|
||||
/// Render a stable source snapshot inside private MCP roots; never rewrite the editor's file.
|
||||
pub(super) fn render_preview(editor: &Path, source: &ExternalVectorSource, stop: &Receiver<()>) -> Result<Vec<u8>, String> {
|
||||
let converted = if source.extension == "svg" { source.bytes.as_ref().clone() } else { run_export(editor, source, stop, "svg")? };
|
||||
source_svg(&converted, source.size)
|
||||
}
|
||||
|
||||
/// Convert `source` to `format` (`svg` or `ai`) with the `vectorcraft-cli` next to `editor`,
|
||||
/// confined to a private temporary read/write root. Errors on a format/magic mismatch, a
|
||||
/// missing or linked CLI, a failed open/export reply, cancellation via `stop`, or the
|
||||
/// 60-second deadline; the child process is killed and reaped on any failure.
|
||||
fn run_export(editor: &Path, source: &ExternalVectorSource, stop: &Receiver<()>, format: &str) -> Result<Vec<u8>, String> {
|
||||
if !matches!(source.extension.as_str(), "svg" | "pdf" | "ai" | "eps") {
|
||||
return Err("Unsupported embedded vector source type".into());
|
||||
}
|
||||
if !matches!(format, "svg" | "ai")
|
||||
|| !photocraft_io::vector_preview::is_native_source(&source.bytes)
|
||||
|| (source.extension == "pdf" && !source.bytes.starts_with(b"%PDF-"))
|
||||
|| (source.extension == "eps" && source.bytes.starts_with(b"%PDF-"))
|
||||
{
|
||||
return Err("The saved bytes do not match the original vector format".into());
|
||||
}
|
||||
let owned = OwnedSvg::create_format(&source.bytes, format)?;
|
||||
let cli_name = if cfg!(windows) { "vectorcraft-cli.exe" } else { "vectorcraft-cli" };
|
||||
let cli = editor.parent().ok_or("The editor has no installation directory")?.join(cli_name);
|
||||
// Selecting an editor explicitly also selects its adjacent VectorCraft CLI; PATH and the
|
||||
// embedded file name never participate in executable discovery.
|
||||
let metadata =
|
||||
std::fs::symlink_metadata(&cli).map_err(|_| "Embedded AI/PDF/EPS editing needs vectorcraft-cli next to the selected VectorCraft executable")?;
|
||||
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
||||
return Err("VectorCraft CLI must be a regular file".into());
|
||||
}
|
||||
let input = owned.root.join(format!("Source.{}", source.extension));
|
||||
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&input).map_err(|e| format!("Cannot create vector input: {e}"))?;
|
||||
file.write_all(&source.bytes).map_err(|e| format!("Cannot write vector input: {e}"))?;
|
||||
drop(file);
|
||||
let commands = export_commands(&input, &owned.file, format)?;
|
||||
// MCP's headless roots apply to every thread and file reader, including references in
|
||||
// hostile embedded artwork. Ordinary `convert` does not establish those restrictions.
|
||||
let responses_path = owned.root.join("Responses.jsonl");
|
||||
let mut responses_file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&responses_path)
|
||||
.map_err(|e| format!("Cannot create conversion response file: {e}"))?;
|
||||
let responses_output = responses_file.try_clone().map_err(|e| format!("Cannot prepare conversion response handle: {e}"))?;
|
||||
let mut child = Command::new(cli)
|
||||
.args(["mcp", "--headless", "--automation-read-root"])
|
||||
.arg(&owned.root)
|
||||
.arg("--automation-write-root")
|
||||
.arg(&owned.root)
|
||||
.current_dir(&owned.root)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::from(responses_output))
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Cannot start VectorCraft conversion: {e}"))?;
|
||||
let result = (|| {
|
||||
let mut stdin = child.stdin.take().ok_or("VectorCraft conversion input is unavailable")?;
|
||||
for (id, command, params) in commands {
|
||||
let request = serde_json::json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}});
|
||||
writeln!(stdin, "{request}").map_err(|e| format!("Cannot send vector conversion request: {e}"))?;
|
||||
}
|
||||
drop(stdin);
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
if stop.try_recv().is_ok() {
|
||||
return Err("Vector editing cancelled".into());
|
||||
}
|
||||
if let Some(status) = child.try_wait().map_err(|e| format!("Cannot wait for vector conversion: {e}"))? {
|
||||
if !status.success() {
|
||||
return Err("VectorCraft could not convert the embedded vector".into());
|
||||
}
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err("VectorCraft conversion timed out".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
let mut responses = String::new();
|
||||
// Read the retained handle, never reopen a pathname an input processor could replace.
|
||||
responses_file.rewind().map_err(|e| format!("Cannot seek conversion responses: {e}"))?;
|
||||
responses_file.take(1024 * 1024 + 1).read_to_string(&mut responses).map_err(|e| format!("Invalid conversion responses: {e}"))?;
|
||||
validate_responses(&responses)?;
|
||||
let converted = owned.read(&owned.stamp()?)?;
|
||||
if format == "ai" && !converted.starts_with(b"%PDF-") {
|
||||
return Err("VectorCraft did not save a PDF-compatible AI working copy".into());
|
||||
}
|
||||
Ok(converted)
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Build requests only from UTF-8 paths: serializing a non-UTF-8 `Path` inside `json!` panics.
|
||||
#[cfg(all(test, unix))]
|
||||
fn conversion_commands(input: &Path, output: &Path) -> Result<[(u8, &'static str, serde_json::Value); 2], String> {
|
||||
export_commands(input, output, "svg")
|
||||
}
|
||||
|
||||
/// The open (id 1) and export (id 2) requests that [`validate_responses`] checks; AI exports
|
||||
/// preserve editing data. Non-UTF-8 paths return errors because `json!` would panic on them.
|
||||
fn export_commands(input: &Path, output: &Path, format: &str) -> Result<[(u8, &'static str, serde_json::Value); 2], String> {
|
||||
let input = input.to_str().ok_or("The temporary vector input path is not UTF-8")?;
|
||||
let output = output.to_str().ok_or("The temporary vector output path is not UTF-8")?;
|
||||
Ok([
|
||||
(1, "document.open", serde_json::json!({"path": input})),
|
||||
(2, "document.export", serde_json::json!({"path": output, "format": format, "preserveEditing": format == "ai"})),
|
||||
])
|
||||
}
|
||||
|
||||
/// Keep the original PSD source-pixel frame: PDF points and EPS bounds need not equal the
|
||||
/// placed object's stored pixel dimensions. The nested viewport scales vector geometry once.
|
||||
fn source_svg(bytes: &[u8], size: Option<(f64, f64)>) -> Result<Vec<u8>, String> {
|
||||
let text = std::str::from_utf8(bytes).map_err(|_| "Converted SVG is not UTF-8")?;
|
||||
photocraft_io::svg::import_svg_bounded_with_work("Artwork.svg", bytes, 96_000_000).map_err(|e| format!("Converted SVG is unsupported: {e}"))?;
|
||||
let doc = roxmltree::Document::parse(text).map_err(|e| format!("Invalid converted SVG: {e}"))?;
|
||||
let root = doc.root_element();
|
||||
if root.tag_name().name() != "svg" {
|
||||
return Err("VectorCraft did not return SVG".into());
|
||||
}
|
||||
let Some((w, h)) = size else {
|
||||
return Ok(bytes.to_vec());
|
||||
};
|
||||
if !w.is_finite() || !h.is_finite() || w <= 0.0 || h <= 0.0 || w > 16384.0 || h > 16384.0 || w * h > 24_000_000.0 {
|
||||
return Err("The original vector source dimensions exceed the editing limit".into());
|
||||
}
|
||||
// The nested root keeps its own width/height, so the outer viewBox must use the same CSS
|
||||
// pixels: `612pt` is 816 user units at SVG's 96 dpi. Other units are rejected below.
|
||||
let dimension = |key| {
|
||||
let value = root.attribute(key)?;
|
||||
let (number, scale) = value.strip_suffix("pt").map_or((value, 1.0), |number| (number, 4.0 / 3.0));
|
||||
number.parse::<f64>().ok().map(|v| v * scale).filter(|v| v.is_finite() && *v > 0.0)
|
||||
};
|
||||
let (iw, ih) = dimension("width").zip(dimension("height")).ok_or("Converted SVG has no finite dimensions")?;
|
||||
let inner = text.get(root.range()).ok_or("Invalid SVG root range")?;
|
||||
let result =
|
||||
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {iw} {ih}" preserveAspectRatio="none">{inner}</svg>"#)
|
||||
.into_bytes();
|
||||
if result.len() > 16 * 1024 * 1024 {
|
||||
return Err("Converted SVG exceeds 16 MiB".into());
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Require successful replies for both opening the source and exporting the SVG.
|
||||
fn validate_responses(text: &str) -> Result<(), String> {
|
||||
if text.len() > 1024 * 1024 {
|
||||
return Err("VectorCraft conversion responses exceeded the limit".into());
|
||||
}
|
||||
let replies: Vec<serde_json::Value> =
|
||||
text.lines().map(serde_json::from_str).collect::<Result<_, _>>().map_err(|_| "Invalid VectorCraft conversion response")?;
|
||||
for id in [1, 2] {
|
||||
let reply = replies.iter().find(|r| r.get("id").and_then(serde_json::Value::as_u64) == Some(id)).ok_or("Missing VectorCraft conversion response")?;
|
||||
if reply.get("error").is_some() || reply.get("result").and_then(|r| r.get("isError")).and_then(serde_json::Value::as_bool) != Some(false) {
|
||||
return Err("VectorCraft could not open or export the embedded vector; original contents were retained".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_utf8_temporary_paths_return_errors_instead_of_panicking() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let invalid = Path::new(std::ffi::OsStr::from_bytes(b"/tmp/vector-\xff"));
|
||||
let valid = Path::new("/tmp/Artwork.svg");
|
||||
assert!(conversion_commands(invalid, valid).is_err());
|
||||
assert!(conversion_commands(valid, invalid).is_err());
|
||||
let commands = conversion_commands(valid, valid).unwrap();
|
||||
assert_eq!(commands[0].2["path"], "/tmp/Artwork.svg");
|
||||
assert_eq!(commands[1].2["format"], "svg");
|
||||
assert_eq!(commands[1].2["preserveEditing"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converted_vectors_use_the_psd_source_pixel_frame() {
|
||||
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><rect width="8" height="6" fill="red"/></svg>"#;
|
||||
let normalized = source_svg(svg, Some((16.0, 12.0))).unwrap();
|
||||
let parsed = photocraft_io::svg::import_svg_bounded("Artwork.svg", &normalized, 24_000_000).unwrap();
|
||||
assert_eq!(parsed.document.size, photocraft_geom::Size::new(16, 12));
|
||||
assert_eq!(source_svg(svg, None).unwrap(), svg);
|
||||
let fractional = source_svg(svg, Some((16.3, 12.2))).unwrap();
|
||||
let parsed = photocraft_io::svg::import_svg_bounded("Artwork.svg", &fractional, 24_000_000).unwrap();
|
||||
assert_eq!(parsed.document.size, photocraft_geom::Size::new(16, 12));
|
||||
for size in [(f64::NAN, 1.0), (1.0, f64::INFINITY), (0.0, 1.0), (16384.0, 16384.0)] {
|
||||
assert!(source_svg(svg, Some(size)).is_err());
|
||||
}
|
||||
assert!(source_svg(b"%PDF-1.6", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_sized_converted_vectors_fill_the_frame_without_cropping() {
|
||||
// 12pt × 9pt is 16 × 12 CSS pixels: red on the left half, blue on the right half,
|
||||
// with and without a root viewBox. Misreading pt as px would scale by 4/3 and crop.
|
||||
for svg in [
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="12pt" height="9pt" viewBox="0 0 12 9"><rect width="6" height="9" fill="red"/><rect x="6" width="6" height="9" fill="blue"/></svg>"#,
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="12pt" height="9pt"><rect width="8" height="12" fill="red"/><rect x="8" width="8" height="12" fill="blue"/></svg>"#,
|
||||
] {
|
||||
let normalized = source_svg(svg.as_bytes(), Some((16.0, 12.0))).unwrap();
|
||||
let raster = photocraft_io::svg::rasterize_bounded(&normalized, 1.0, 24_000_000).unwrap();
|
||||
assert_eq!((raster.rect.width(), raster.rect.height()), (16, 12));
|
||||
let (x0, y0) = (raster.rect.x0, raster.rect.y0);
|
||||
let is = |x: i32, y: i32, channel: usize| {
|
||||
let px = raster.get(x0 + x, y0 + y);
|
||||
px[3] > 0.99 && px[channel] > 0.99 && px[2 - channel] < 0.01
|
||||
};
|
||||
for y in [0, 6, 11] {
|
||||
assert!(is(0, y, 0) && is(6, y, 0), "left half must be red: {svg}");
|
||||
assert!(is(9, y, 2) && is(15, y, 2), "right half must be blue and uncropped: {svg}");
|
||||
}
|
||||
}
|
||||
assert!(source_svg(br#"<svg xmlns="http://www.w3.org/2000/svg" width="12mm" height="9mm"/>"#, Some((16.0, 12.0))).is_err());
|
||||
}
|
||||
|
||||
/// A stand-in `vectorcraft-cli` that logs every request beside itself and answers the open
|
||||
/// and export requests, writing a small SVG (or, for `ai`, a regenerated PDF) to the export path.
|
||||
#[cfg(unix)]
|
||||
const FAKE_CLI: &str = r#"#!/bin/sh
|
||||
log="$(dirname "$0")/requests.log"
|
||||
while IFS= read -r line; do
|
||||
printf '%s\n' "$line" >> "$log"
|
||||
case "$line" in
|
||||
*document.export*)
|
||||
out=$(printf '%s' "$line" | sed -n 's/.*"path":"\([^"]*\)".*/\1/p')
|
||||
case "$line" in
|
||||
*'"format":"ai"'*) printf '%%PDF-1.7 regenerated' > "$out" ;;
|
||||
*) printf '<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"/>' > "$out" ;;
|
||||
esac ;;
|
||||
esac
|
||||
done
|
||||
printf '{"id":1,"result":{"isError":false}}\n{"id":2,"result":{"isError":false}}\n'
|
||||
"#;
|
||||
|
||||
/// Illustrator or another chosen editor gets the original AI; only VectorCraft's own working
|
||||
/// copy is regenerated (VectorCraft opens a foreign AI read-only).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ai_working_copy_is_regenerated_only_for_vectorcraft() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let editor = directory.path().join("Editor");
|
||||
std::fs::write(&editor, b"#!/bin/sh\n").unwrap();
|
||||
let cli = directory.path().join("vectorcraft-cli");
|
||||
std::fs::write(&cli, FAKE_CLI).unwrap();
|
||||
std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let original = b"%PDF-1.7\noriginal AI".to_vec();
|
||||
let source = ExternalVectorSource { bytes: std::sync::Arc::new(original.clone()), extension: "ai".into(), size: None };
|
||||
let (_send, stop) = std::sync::mpsc::channel();
|
||||
let (owned, initial) = prepare(&editor, &source, &stop, false).unwrap();
|
||||
assert_eq!(initial, original);
|
||||
assert_eq!(std::fs::read(&owned.file).unwrap(), original, "the other editor opens the original AI");
|
||||
let log = std::fs::read_to_string(directory.path().join("requests.log")).unwrap();
|
||||
assert!(log.contains(r#""format":"svg""#), "the preview is still converted: {log}");
|
||||
assert!(!log.contains(r#""format":"ai""#), "no AI re-export for another editor: {log}");
|
||||
let (_owned, regenerated) = prepare(&editor, &source, &stop, true).unwrap();
|
||||
assert_eq!(regenerated, b"%PDF-1.7 regenerated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_requires_successful_open_and_export_replies() {
|
||||
let good = r#"{"id":1,"result":{"isError":false}}
|
||||
{"id":2,"result":{"isError":false}}"#;
|
||||
assert!(validate_responses(good).is_ok());
|
||||
for bad in [
|
||||
"",
|
||||
r#"{"id":2,"result":{"isError":false}}"#,
|
||||
r#"{"id":1,"result":{"isError":true}}
|
||||
{"id":2,"result":{"isError":false}}"#,
|
||||
r#"{"id":1,"error":{"message":"denied"}}"#,
|
||||
] {
|
||||
assert!(validate_responses(bad).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod live_vectorcraft_tests {
|
||||
use super::*;
|
||||
use std::sync::{Arc, mpsc};
|
||||
|
||||
/// Opt-in executable test uses only our own PostScript, never user artwork or Adobe assets.
|
||||
#[test]
|
||||
#[ignore = "set VECTORCRAFT_TEST_EDITOR to a local VectorCraft executable"]
|
||||
fn real_vectorcraft_converts_eps_and_rejects_failed_open_without_blank_replacement() {
|
||||
let editor = std::env::var_os("VECTORCRAFT_TEST_EDITOR").expect("VECTORCRAFT_TEST_EDITOR");
|
||||
let editor = Path::new(&editor);
|
||||
let (_send, stop) = mpsc::channel();
|
||||
let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 8 6\n0 0 1 setrgbcolor 0 0 8 6 rectfill\nshowpage\n%%EOF\n";
|
||||
let source = ExternalVectorSource { bytes: Arc::new(eps.to_vec()), extension: "eps".into(), size: Some((16.0, 12.0)) };
|
||||
let (owned, initial) = prepare(editor, &source, &stop, true).unwrap();
|
||||
assert_eq!(initial, *source.bytes);
|
||||
assert_eq!(owned.file.extension().unwrap(), "eps");
|
||||
assert_eq!(std::fs::read(&owned.file).unwrap(), *source.bytes);
|
||||
let svg = render_preview(editor, &source, &stop).unwrap();
|
||||
assert!(owned.file.exists());
|
||||
let raster = photocraft_io::svg::rasterize_bounded(&svg, 1.0, 24_000_000).unwrap();
|
||||
assert_eq!((raster.rect.width(), raster.rect.height()), (16, 12));
|
||||
let invalid = ExternalVectorSource { bytes: Arc::new(b"%PDF-1.6\nnot a PDF".to_vec()), extension: "pdf".into(), size: None };
|
||||
assert!(prepare(editor, &invalid, &stop, true).is_err());
|
||||
let root = owned.root.clone();
|
||||
drop(owned);
|
||||
assert!(!root.exists());
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,7 @@
|
||||
//! status 0 believes the control server is running when it is not.
|
||||
//!
|
||||
//! These tests run the real binary as a subprocess and assert the exit code,
|
||||
//! covering both failure paths in `main.rs`: control authentication and the
|
||||
//! automation workspace.
|
||||
//! covering control authentication, the automation workspace and listener binding.
|
||||
|
||||
/// A syntactically valid 64-hex-char token, so the workspace test reaches the
|
||||
/// workspace check instead of failing on token validation first.
|
||||
@@ -28,7 +27,7 @@ fn run_with(args: &[&str], vars: &[(&str, &str)]) -> Run {
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-control-exit-{}-{n}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let output = std::process::Command::new(crate::binary())
|
||||
let mut child = std::process::Command::new(crate::binary())
|
||||
.args(args)
|
||||
.env("PHOTOCRAFT_CONFIG_DIR", &dir)
|
||||
// Isolate from the host environment so the test is reproducible.
|
||||
@@ -41,12 +40,38 @@ fn run_with(args: &[&str], vars: &[(&str, &str)]) -> Run {
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.env_remove("WAYLAND_SOCKET")
|
||||
.envs(vars.iter().copied())
|
||||
.output()
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.expect("failed to run photocraft binary");
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
let timed_out = loop {
|
||||
if child.try_wait().unwrap().is_some() {
|
||||
break false;
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
child.kill().unwrap();
|
||||
break true;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
};
|
||||
let output = child.wait_with_output().unwrap();
|
||||
let gpu_started = dir.join("gpu-starting.json").exists();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
assert!(!timed_out, "PhotoCraft did not exit: {}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(!gpu_started, "control setup errors must exit before GPU startup");
|
||||
Run { status: output.status, stderr: String::from_utf8_lossy(&output.stderr).into_owned() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_occupied_control_port_exits_with_status_2() {
|
||||
let held = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = held.local_addr().unwrap().port().to_string();
|
||||
let Run { status, stderr } = run_photocraft(&["--control", &port, "--control-token", VALID_TOKEN]);
|
||||
assert_eq!(status.code(), Some(2), "occupied control port must exit 2, stderr: {stderr}");
|
||||
assert!(stderr.contains(&format!("failed to bind 127.0.0.1:{port}")), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_control_token_exits_with_status_2() {
|
||||
// `validate_token` rejects a token that is not exactly 64 hex characters.
|
||||
|
||||
@@ -17,14 +17,13 @@ Exactly three colours:
|
||||
## Geometry
|
||||
|
||||
A 512-unit tile (`viewBox="0 0 512 512"`), rounded square with `rx=112`, no border. The macOS
|
||||
renders pad it onto Apple's 824/1024 icon grid; the Windows and Linux renders crop 22 units off
|
||||
each side so the figure reads at 16–48 px.
|
||||
renders pad it onto Apple's 824/1024 icon grid; the Windows and Linux renders use the whole tile,
|
||||
as the other Crafting Apps do.
|
||||
|
||||
## Provenance
|
||||
|
||||
The owner's original drawing, made in ArtCraft (2880 px, engraving style, keyed to the palette),
|
||||
vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py`. The source PNG stays in
|
||||
craftrules at `assets/app-icons/photocraft/source.png`. License: see `LICENSE.txt`.
|
||||
vectorised. License: see `LICENSE.txt`.
|
||||
|
||||
## Files
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 784 B After Width: | Height: | Size: 812 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 90 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 3h12v6h6v12H9v-6H3z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 266 B |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 3h12v12H3zM9 9h12v12H9z" fill="currentColor" fill-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 290 B |
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="12" height="12" />
|
||||
<rect x="9" y="9" width="12" height="12" />
|
||||
<path d="M9 9h6v6H9z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 347 B |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 3h12v6h6v12H9v-6H3z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 246 B |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="4" y="4" width="16" height="16" rx="1" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 281 B |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 3h12v6H9v6H3z" fill="currentColor" />
|
||||
<rect x="9" y="9" width="12" height="12" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 306 B |
@@ -38,13 +38,13 @@ The release packages ship `photocraft-cli` alongside the desktop app, so no buil
|
||||
|
||||
| Install | CLI |
|
||||
|---|---|
|
||||
| Windows (MSI) | `C:\Program Files\PhotoCraft\photocraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` |
|
||||
| Windows (MSI) | `%LOCALAPPDATA%\Programs\ArtCraft\PhotoCraft\photocraft-cli.exe` by default (`C:\Program Files\ArtCraft\PhotoCraft\photocraft-cli.exe` for an all-users install, under `Program Files (x86)` instead for the 32-bit build, or wherever you installed it otherwise), not on `PATH` |
|
||||
| Linux (deb, rpm) | `/usr/bin/photocraft-cli` |
|
||||
| macOS | the separate `photocraft-cli-<version>-macos-<arch>.zip` release asset (the `.app` holds only the desktop app) |
|
||||
|
||||
```sh
|
||||
# Windows, default install folder
|
||||
claude mcp add photocraft -- "C:\Program Files\PhotoCraft\photocraft-cli.exe" mcp --automation-read-root <dir> --automation-write-root <dir>
|
||||
# Windows (PowerShell), default install folder
|
||||
claude mcp add photocraft -- "$env:LOCALAPPDATA\Programs\ArtCraft\PhotoCraft\photocraft-cli.exe" mcp --automation-read-root <dir> --automation-write-root <dir>
|
||||
# Linux, or macOS with the CLI unzipped onto PATH
|
||||
claude mcp add photocraft -- photocraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>
|
||||
```
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
# Raster formats
|
||||
|
||||
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate.
|
||||
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate. Lossless JPEG XL (.jxl) export uses the pure-Rust `zune-jpegxl` encoder for 8/16-bit gray, RGB and RGBA data; gray+alpha is expanded to RGBA at the same bit depth. JPEG XL import is tracked separately in #2868.
|
||||
|
||||
## Layered TIFF
|
||||
|
||||
A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: up to sixteen extra samples (spot colours and alpha channels, plus a Quick Mask) are written in the IFD and round-trip, with Photoshop image resources for the channel name, ink colour and solidity; Lab documents are saved flat, with their extra samples. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`.
|
||||
|
||||
## JPEG XL
|
||||
|
||||
Save As, Export As and the CLI can write a lossless, single-image `.jxl` bare
|
||||
codestream at 8 or 16 bits per sample. Gray, RGB and RGBA are encoded
|
||||
natively. Grayscale with alpha is expanded to RGBA at the original bit depth
|
||||
to preserve both grayscale and alpha samples. For 16-bit RGBA the encoder's
|
||||
incorrect default 8-bit alpha header is corrected to explicitly describe
|
||||
16-bit alpha, with an independent decoder oracle checking alpha values.
|
||||
The bare codestream has no ICC, EXIF, XMP or DPI boxes; the export path
|
||||
converts tagged RGB to sRGB and surfaces metadata losses as fidelity warnings.
|
||||
JPEG XL opening remains under review in #2868.
|
||||
|
||||
## WebP
|
||||
|
||||
Lossless WebP (VP8L) is encoded and decoded by `image-webp`. Lossy WebP is written by PhotoCraft's own VP8 key-frame encoder in `crates/codecs/src/codecs/vp8`, a clean-room implementation of RFC 6386: BT.601 4:2:0 conversion, 16×16 and 4×4 intra prediction chosen per macroblock by reconstruction error, the forward DCT and Walsh-Hadamard transforms paired with the bit-exact inverse transforms, quantisation on the RFC's `dc_qlookup`/`ac_qlookup` tables, token coding with per-frame probability updates, and skipped macroblocks for flat areas. `EncodeOptions::webp_lossless` picks the encoder and `webp_quality` (0–100, the libwebp scale) sets the quantiser; alpha travels losslessly in an `ALPH` chunk and ICC/EXIF/XMP in their own chunks of the extended (`VP8X`) container. The decoder applies the frame's loop filter; no C library is involved on either side.
|
||||
|
||||
@@ -5,3 +5,4 @@ allow-indexing-slicing-in-tests = true
|
||||
disallowed-types = [
|
||||
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
|
||||
]
|
||||
cognitive-complexity-threshold = 30
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
# Contributor names for the About window.
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
# Copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
@@ -205,6 +202,9 @@ sync_github_name = true
|
||||
[people.fjamesprice]
|
||||
sync_github_name = true
|
||||
|
||||
[people.mockydevs]
|
||||
real_name = "Don Omondi"
|
||||
|
||||
[people.ha1des]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -265,6 +265,11 @@ sync_github_name = false
|
||||
[people.drunkeninja42]
|
||||
real_name = "Kushagra Sharma"
|
||||
|
||||
[people.hidoba]
|
||||
real_name = "Vlad Grankovsky"
|
||||
display_name = "Vlad Grankovsky"
|
||||
sync_github_name = false
|
||||
|
||||
[people.WebWork-rrs]
|
||||
display_name = "Ratul Paul"
|
||||
|
||||
@@ -282,3 +287,11 @@ sync_github_name = false
|
||||
[people.jasallen]
|
||||
real_name = "Jason DeBoever"
|
||||
sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.SimoneRecchia]
|
||||
real_name = "Simone Recchia"
|
||||
display_name = "Simone Recchia"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -23,10 +23,7 @@ impl Stream {
|
||||
/// The raw slot count of an object's array field (including null slots), for charging
|
||||
/// traversal work before nulls are filtered out. `None` when the field is not an array.
|
||||
pub fn array_len(&self, id: ObjId, tag: &[u8; 4]) -> Option<usize> {
|
||||
match self.field(id, tag)? {
|
||||
Value::Array(a) => Some(a.len()),
|
||||
_ => None,
|
||||
}
|
||||
self.field(id, tag)?.array_len()
|
||||
}
|
||||
|
||||
/// A child object (shared, linked or inline); `None` for null or missing.
|
||||
|
||||
@@ -153,8 +153,8 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
|
||||
let fields: Vec<&Value> = s.positional(c).collect();
|
||||
// The corner arrays are scanned for every occurrence of a shared curve, so charge
|
||||
// them like records before they are walked.
|
||||
if let Some(Value::Array(idx)) = fields.get(1)
|
||||
&& let Err(reason) = r.geometry_budget.records(idx.len())
|
||||
if let Some(len) = fields.get(1).and_then(|v| v.array_len())
|
||||
&& let Err(reason) = r.geometry_budget.records(len)
|
||||
{
|
||||
r.warn(reason);
|
||||
return None;
|
||||
@@ -170,17 +170,23 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
|
||||
Value::Bool(b) => *b,
|
||||
_ => return None,
|
||||
};
|
||||
let Value::Array(records) = fields.next()? else { return None };
|
||||
seen = seen.checked_add(records.len())?;
|
||||
let records = fields.next()?;
|
||||
let len = records.array_len()?;
|
||||
seen = seen.checked_add(len)?;
|
||||
if seen > MAX_NODES {
|
||||
r.warn("curves with more than four million nodes");
|
||||
return None;
|
||||
}
|
||||
if let Err(reason) = r.geometry_budget.records(records.len()) {
|
||||
if let Err(reason) = r.geometry_budget.records(len) {
|
||||
r.warn(reason);
|
||||
return None;
|
||||
}
|
||||
let anchors = anchors(records)?;
|
||||
let anchors = match records {
|
||||
Value::Array(a) => anchors(a.iter().map(record)),
|
||||
Value::Records { size: 18, data } => anchors(data.as_chunks::<18>().0.iter().map(record18)),
|
||||
// Records of another width are no curve nodes.
|
||||
_ => anchors(std::iter::repeat_n(None, len)),
|
||||
}?;
|
||||
let radii = if index == 0 { corners.as_ref() } else { None };
|
||||
if let Some(sp) = subpath(&anchors, closed, radii) {
|
||||
path.subpaths.push(sp);
|
||||
@@ -202,22 +208,24 @@ struct Anchor {
|
||||
|
||||
fn record(v: &Value) -> Option<(Point, u8)> {
|
||||
let Value::Bytes(b) = v else { return None };
|
||||
if b.len() != 18 {
|
||||
return None;
|
||||
}
|
||||
let x = f64::from_le_bytes(b.get(0..8)?.try_into().ok()?);
|
||||
let y = f64::from_le_bytes(b.get(8..16)?.try_into().ok()?);
|
||||
record18(b.as_slice().try_into().ok()?)
|
||||
}
|
||||
|
||||
/// One 18-byte curve node: f64 x, f64 y, u8 kind, u8 role.
|
||||
fn record18(b: &[u8; 18]) -> Option<(Point, u8)> {
|
||||
let x = f64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]);
|
||||
let y = f64::from_le_bytes([b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]]);
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some((Point { x, y }, *b.get(17)?))
|
||||
Some((Point { x, y }, b[17]))
|
||||
}
|
||||
|
||||
fn anchors(records: &[Value]) -> Option<Vec<Anchor>> {
|
||||
fn anchors(records: impl ExactSizeIterator<Item = Option<(Point, u8)>>) -> Option<Vec<Anchor>> {
|
||||
let mut out: Vec<Anchor> = Vec::with_capacity(records.len() / 3 + 1);
|
||||
let mut pending_in = None;
|
||||
for (i, v) in records.iter().enumerate() {
|
||||
let (p, role) = record(v)?;
|
||||
for (i, v) in records.enumerate() {
|
||||
let (p, role) = v?;
|
||||
match role {
|
||||
0 => {
|
||||
out.push(Anchor { p, inh: pending_in.take(), out: None, record: i });
|
||||
|
||||
@@ -300,6 +300,9 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
|
||||
let mut archive = Archive::open(bytes, limits)?;
|
||||
let doc = archive.read("doc.dat")?;
|
||||
let s = stream::parse(&doc)?;
|
||||
// The parsed stream owns copies of everything it needs: do not hold the inflated bytes
|
||||
// (hundreds of MB for a large vector document) while the model is built.
|
||||
drop(doc);
|
||||
Reader::new(&s, &mut archive, limits).document()
|
||||
}
|
||||
|
||||
|
||||
@@ -129,11 +129,16 @@ const MAX_STOPS: usize = 1024;
|
||||
fn floats(v: &Value) -> Option<Vec<f64>> {
|
||||
match v {
|
||||
Value::Floats(f) => Some(f.clone()),
|
||||
Value::Bytes(b) if b.len() % 4 == 0 => Some(b.as_chunks::<4>().0.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect()),
|
||||
Value::Bytes(b) if b.len() % 4 == 0 => Some(f32_samples(b)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Little-endian `f32` samples widened to `f64`.
|
||||
fn f32_samples(b: &[u8]) -> Vec<f64> {
|
||||
b.as_chunks::<4>().0.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect()
|
||||
}
|
||||
|
||||
/// Spot colours defined through spot colours. A spot colour (`Pant`) is read through its RGB
|
||||
/// equivalent, else its base colour, which in real files hold plain colours. Colours are shared
|
||||
/// objects, so one can link to itself, and with both links followed at every level a colour read
|
||||
@@ -241,6 +246,9 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Pai
|
||||
let cols = s.objs(grad, b"Cols");
|
||||
let posn: Vec<Vec<f64>> = match s.field(grad, b"Posn") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(floats).take(MAX_STOPS).collect(),
|
||||
Some(Value::Records { size, data }) if *size % 4 == 0 && *size > 0 => {
|
||||
data.chunks_exact(usize::from(*size)).map(f32_samples).take(MAX_STOPS).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let mut stops = Vec::new();
|
||||
|
||||
@@ -53,6 +53,23 @@ pub enum Value {
|
||||
/// `None` is a null object.
|
||||
Obj(Option<ObjId>),
|
||||
Array(Vec<Value>),
|
||||
/// An array of fixed-size records (`0x2c`, curve nodes) held in one buffer: `size` bytes each.
|
||||
/// Counts as one value per record against [`MAX_VALUES`], like the array of `Bytes` it replaces.
|
||||
Records {
|
||||
size: u16,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Value {
|
||||
/// Slot count of an array field (including null slots); `None` for a scalar.
|
||||
pub fn array_len(&self) -> Option<usize> {
|
||||
match self {
|
||||
Value::Array(a) => Some(a.len()),
|
||||
Value::Records { size, data } => Some(data.len() / usize::from(*size).max(1)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -73,39 +90,50 @@ pub struct Object {
|
||||
pub chain: Vec<Tag>,
|
||||
/// Fields in stream order; positional fields have tag 0.
|
||||
pub fields: Vec<(Tag, Value)>,
|
||||
/// First-occurrence index of each non-positional tag, so `get` is O(1). A shared object
|
||||
/// can be referenced by many sibling layers, and each reference used to rescan all of its
|
||||
/// fields; indexing once keeps repeated lookups from multiplying into quadratic work.
|
||||
index: std::collections::HashMap<Tag, usize>,
|
||||
/// Index for objects with more than [`SCAN`] fields: (tag, index of its first occurrence)
|
||||
/// sorted by tag, so `get` is a binary search. A shared object can be referenced by many
|
||||
/// sibling layers, and each reference used to rescan all of its fields; indexing once keeps
|
||||
/// repeated lookups from multiplying into quadratic work. Smaller objects have no index
|
||||
/// (and no per-object hash map to build): scanning a handful of fields is cheaper.
|
||||
index: Vec<(Tag, u32)>,
|
||||
}
|
||||
|
||||
/// Objects with at most this many fields are scanned instead of indexed.
|
||||
const SCAN: usize = 12;
|
||||
|
||||
fn build_index(fields: &[(Tag, Value)]) -> Vec<(Tag, u32)> {
|
||||
if fields.len() <= SCAN {
|
||||
return Vec::new();
|
||||
}
|
||||
// Positional fields share tag 0 and are not looked up by tag. Sorting by (tag, index) and
|
||||
// keeping the first of each tag preserves first-match semantics.
|
||||
let mut index: Vec<(Tag, u32)> = fields.iter().zip(0u32..).filter(|((t, _), _)| t.0 != 0).map(|((t, _), i)| (*t, i)).collect();
|
||||
index.sort_unstable();
|
||||
index.dedup_by_key(|e| e.0);
|
||||
index
|
||||
}
|
||||
|
||||
impl Object {
|
||||
pub fn new(class: Tag, kind: Kind, chain: Vec<Tag>, fields: Vec<(Tag, Value)>) -> Self {
|
||||
let mut index = std::collections::HashMap::with_capacity(fields.len());
|
||||
for (i, (t, _)) in fields.iter().enumerate() {
|
||||
// Positional fields share tag 0 and are not looked up by tag; keep first-match
|
||||
// semantics for real tags.
|
||||
if t.0 != 0 {
|
||||
index.entry(*t).or_insert(i);
|
||||
}
|
||||
}
|
||||
let index = build_index(&fields);
|
||||
Object { class, kind, chain, fields, index }
|
||||
}
|
||||
|
||||
pub fn get(&self, tag: Tag) -> Option<&Value> {
|
||||
self.index.get(&tag).map(|&i| &self.fields[i].1)
|
||||
if tag.0 == 0 {
|
||||
return None;
|
||||
}
|
||||
if self.fields.len() <= SCAN {
|
||||
return self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v);
|
||||
}
|
||||
let at = self.index.binary_search_by_key(&tag, |e| e.0).ok()?;
|
||||
self.index.get(at).and_then(|&(_, i)| self.fields.get(i as usize)).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
/// Replaces the fields (the root object is filled in after it is pushed) and rebuilds the
|
||||
/// tag index so `get` stays correct.
|
||||
fn set_fields(&mut self, fields: Vec<(Tag, Value)>) {
|
||||
self.index.clear();
|
||||
self.index.reserve(fields.len());
|
||||
for (i, (t, _)) in fields.iter().enumerate() {
|
||||
if t.0 != 0 {
|
||||
self.index.entry(*t).or_insert(i);
|
||||
}
|
||||
}
|
||||
self.index = build_index(&fields);
|
||||
self.fields = fields;
|
||||
}
|
||||
}
|
||||
@@ -336,11 +364,13 @@ impl Parser<'_> {
|
||||
if size == 0 || n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
|
||||
return Err(Error::Malformed("array longer than its data"));
|
||||
}
|
||||
let mut v = self.vec(n)?;
|
||||
for _ in 0..n {
|
||||
v.push(Value::Bytes(self.c.take(size)?.to_vec()));
|
||||
}
|
||||
Ok(Value::Array(v))
|
||||
// Still one value per record against the budget, but a single allocation.
|
||||
self.spend(n)?;
|
||||
let bytes = self.c.take(n * size)?;
|
||||
let mut data = Vec::new();
|
||||
data.try_reserve_exact(bytes.len()).map_err(|_| Error::Limit("not enough memory for the document stream"))?;
|
||||
data.extend_from_slice(bytes);
|
||||
Ok(Value::Records { size: size as u16, data })
|
||||
}
|
||||
0x32 => {
|
||||
let n = self.count(1)?;
|
||||
|
||||
@@ -757,3 +757,42 @@ mod spot_colours {
|
||||
assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
/// Object lookup and record arrays against the structures they replaced.
|
||||
mod stream_storage {
|
||||
use crate::stream::{self, Kind, Object, Tag, Value};
|
||||
use crate::synth::{self, F, tag};
|
||||
|
||||
/// The per-object map `Object::get` used before: first occurrence of each real tag.
|
||||
fn reference(fields: &[(Tag, Value)], t: Tag) -> Option<&Value> {
|
||||
let mut index = std::collections::HashMap::new();
|
||||
for (i, (ft, _)) in fields.iter().enumerate() {
|
||||
if ft.0 != 0 {
|
||||
index.entry(*ft).or_insert(i);
|
||||
}
|
||||
}
|
||||
index.get(&t).map(|&i| &fields[i].1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_keeps_first_match_for_every_object_size() {
|
||||
for n in 0..60u32 {
|
||||
// Tags repeat (n % 7 distinct tags) and every fifth field is positional (tag 0).
|
||||
let fields: Vec<(Tag, Value)> = (0..n).map(|i| (if i % 5 == 0 { Tag(0) } else { Tag(100 + i % 7) }, Value::UInt(u64::from(i)))).collect();
|
||||
let object = Object::new(Tag(1), Kind::Inline, vec![Tag(1)], fields.clone());
|
||||
for t in 0..120 {
|
||||
assert_eq!(object.get(Tag(t)), reference(&fields, Tag(t)), "{n} fields, tag {t}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fixed_size_array_is_one_buffer_and_one_value_per_record() {
|
||||
let records: Vec<u8> = (0..18 * 5).map(|i| i as u8).collect();
|
||||
let bytes = synth::stream(&[(tag(b"Nods"), F::Records(18, records.chunks(18).map(<[u8]>::to_vec).collect()))]);
|
||||
let s = stream::parse(&bytes).unwrap();
|
||||
let root = s.object(s.root).unwrap();
|
||||
assert_eq!(root.get(Tag::of(b"Nods")), Some(&Value::Records { size: 18, data: records }));
|
||||
assert_eq!(root.get(Tag::of(b"Nods")).and_then(Value::array_len), Some(5));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
|
||||
_ => bounds.x0,
|
||||
};
|
||||
if !artistic {
|
||||
if s.objs(frame, b"ColW").len() > 1 || matches!(s.field(frame, b"ColW"), Some(Value::Array(a)) if a.len() > 1) {
|
||||
if s.objs(frame, b"ColW").len() > 1 || s.array_len(frame, b"ColW").is_some_and(|n| n > 1) {
|
||||
r.warn("text frames with several columns (imported as one column)");
|
||||
}
|
||||
if s.class(id) == Some(crate::stream::Tag::of(b"TxtC")) {
|
||||
|
||||
@@ -23,3 +23,8 @@ rayon = { workspace = true }
|
||||
[dev-dependencies]
|
||||
photocraft-testkit = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
criterion = { workspace = true }
|
||||
|
||||
[[bench]]
|
||||
name = "blur"
|
||||
harness = false
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! `cargo bench -p photocraft-algo`
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use photocraft_algo::{FilterParams, apply};
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
fn textured(n: u32) -> Surface {
|
||||
let r = Rect::new(0, 0, n as i32, n as i32);
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
let bytes: Vec<u8> = (0..n).flat_map(|y| (0..n).flat_map(move |x| [(x ^ y) as u8, (x / 3) as u8, (y * 7) as u8, 255])).collect();
|
||||
s.write_interleaved(r, &bytes);
|
||||
s
|
||||
}
|
||||
|
||||
/// Gaussian Blur's direct-kernel path (radius up to 4; larger radii use box passes), including
|
||||
/// the variance-matched kernels below radius 1 (#8544).
|
||||
fn gaussian_direct(c: &mut Criterion) {
|
||||
let n = 2048;
|
||||
let s = textured(n);
|
||||
let area = Rect::new(0, 0, n as i32, n as i32);
|
||||
for radius in [0.3f32, 0.8, 3.0] {
|
||||
let p = FilterParams::GaussianBlur { radius };
|
||||
c.bench_function(&format!("gaussian_blur_{radius}px_2048"), |b| b.iter(|| apply(&s, &p, area, area, None)));
|
||||
}
|
||||
}
|
||||
|
||||
criterion_group! {
|
||||
name = benches;
|
||||
config = Criterion::default().sample_size(10);
|
||||
targets = gaussian_direct
|
||||
}
|
||||
criterion_main!(benches);
|
||||
@@ -4,7 +4,10 @@
|
||||
//!
|
||||
//! `--json` writes the report `cargo xtask perf` reads: `{"rows": [{"name", "samples_ms"}]}`.
|
||||
//! `--quick` runs the list on a 1500×1000 layer instead, for `cargo xtask perf --quick`.
|
||||
use photocraft_algo::*;
|
||||
use photocraft_algo::{
|
||||
BlurPath, BlurQuality, BlurShape, DepthSource, Distribution, FieldPin, FilterParams, IrisPin, Light, SmartBlurMode, SpinPin, TextureChannel, apply,
|
||||
output_area,
|
||||
};
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
@@ -122,6 +122,24 @@ fn main() {
|
||||
};
|
||||
wideangle::apply(&s, r, &p, Interp::Bicubic)
|
||||
});
|
||||
// The mesh solve alone, on the dialog's 800 x 533 proxy.
|
||||
let proxy = Rect::new(0, 0, 800, 533);
|
||||
let solve_case = |constraints: Vec<Constraint>| {
|
||||
let p = WideAngle { model: WideModel::Fisheye, focal_length: 12.0, constraints, ..Default::default() };
|
||||
wideangle::solve(&p, proxy)
|
||||
};
|
||||
time("wide angle solve: 800x533 proxy, H + V", &only, || {
|
||||
solve_case(vec![
|
||||
Constraint { a: [60.0, 80.0], b: [740.0, 80.0], orientation: Orientation::Horizontal },
|
||||
Constraint { a: [100.0, 60.0], b: [100.0, 470.0], orientation: Orientation::Vertical },
|
||||
])
|
||||
});
|
||||
time("wide angle solve: 800x533 proxy, 2 free", &only, || {
|
||||
solve_case(vec![
|
||||
Constraint { a: [60.0, 80.0], b: [740.0, 95.0], orientation: Orientation::Free },
|
||||
Constraint { a: [100.0, 60.0], b: [110.0, 470.0], orientation: Orientation::Free },
|
||||
])
|
||||
});
|
||||
time("hdr: merge 3 exposures + local adaptation", &only, || {
|
||||
let shots: Vec<Vec<[f32; 4]>> =
|
||||
[0.5f32, 1.0, 2.0].iter().map(|k| px.iter().map(|q| [(q[0] * k).min(1.0), (q[1] * k).min(1.0), (q[2] * k).min(1.0), 1.0]).collect()).collect();
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Stack Mode Entropy timings and exact output hashes, including lookup preparation.
|
||||
//! Run the same example on both revisions: `cargo run --release -p photocraft-algo --example
|
||||
//! bench_stack_entropy -- [--quick] [--reps N] [--threads N] [--json path]`.
|
||||
//! Full cases use 24/36 MP; quick cases use 1/16 as many pixels. The deep-stack fallback is
|
||||
//! deliberately small. Input generation, output hashing and deallocation are outside timings.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_algo::stack::{Stat, combine};
|
||||
use photocraft_testkit::perf::{self, Summary};
|
||||
use serde_json::json;
|
||||
|
||||
struct Options {
|
||||
quick: bool,
|
||||
reps: usize,
|
||||
threads: Option<usize>,
|
||||
json: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn options() -> Result<Options, String> {
|
||||
let mut o = Options { quick: false, reps: 5, threads: None, json: None };
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--quick" => o.quick = true,
|
||||
"--reps" => o.reps = args.next().ok_or("--reps needs an integer")?.parse().map_err(|_| "invalid --reps")?,
|
||||
"--threads" => o.threads = Some(args.next().ok_or("--threads needs an integer")?.parse().map_err(|_| "invalid --threads")?),
|
||||
"--json" => o.json = Some(args.next().ok_or("--json needs a path")?.into()),
|
||||
_ => return Err(format!("unknown option: {arg}")),
|
||||
}
|
||||
}
|
||||
if !(1..=25).contains(&o.reps) || o.threads.is_some_and(|n| !(1..=64).contains(&n)) {
|
||||
return Err("expected 1..=25 repetitions and 1..=64 threads".into());
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
fn benchmark(o: &Options) -> Result<(), String> {
|
||||
let mut rows = Vec::new();
|
||||
let cases = [
|
||||
("3 layers opaque 24 MP", 3, 24_000_000, false),
|
||||
("7 layers varying alpha 24 MP", 7, 24_000_000, true),
|
||||
("3 layers opaque 36 MP", 3, 36_000_000, false),
|
||||
("257 layers varying alpha", 257, 8192, true),
|
||||
];
|
||||
for (name, count, pixels, masked) in cases {
|
||||
let pixels = if o.quick && pixels > 8192 { pixels / 16 } else { pixels };
|
||||
let layers: Vec<Vec<[f32; 4]>> = (0..count)
|
||||
.map(|k| {
|
||||
(0..pixels)
|
||||
.map(|i| {
|
||||
[
|
||||
((i * 31 + k * 17) % 256) as f32 / 255.0,
|
||||
((i / 7 + k * 29) % 32) as f32 / 31.0,
|
||||
((i + k * 13) % 1024) as f32 / 255.0 - 0.5,
|
||||
if masked && k > i % (count + 1) { 0.0 } else { 1.0 },
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
// Warm the worker pool and input pages before collecting samples.
|
||||
std::hint::black_box(combine(&layers, Stat::Entropy));
|
||||
let mut samples = Vec::with_capacity(o.reps);
|
||||
let mut hash = 0u64;
|
||||
for rep in 0..o.reps {
|
||||
let start = Instant::now();
|
||||
let out = std::hint::black_box(combine(&layers, Stat::Entropy));
|
||||
samples.push(start.elapsed().as_secs_f64() * 1000.0);
|
||||
if rep == 0 {
|
||||
hash = out.iter().flatten().fold(0xcbf29ce484222325, |h, v| (h ^ u64::from(v.to_bits())).wrapping_mul(0x100000001b3));
|
||||
}
|
||||
}
|
||||
let p50 = Summary::of(&samples).map_or(0.0, |s| s.p50);
|
||||
println!("{name}: {pixels} pixels, {p50:.3} ms p50, hash {hash:016x}, samples_ms={samples:?}");
|
||||
let mut row = perf::row(name, &samples, perf::process_peak_rss_bytes(), None);
|
||||
row["output_hash"] = json!(format!("{hash:016x}"));
|
||||
row["pixels"] = json!(pixels);
|
||||
rows.push(row);
|
||||
}
|
||||
if let Some(path) = &o.json {
|
||||
let report = perf::report("bench_stack_entropy", json!({"quick": o.quick, "threads": o.threads}), rows, None);
|
||||
std::fs::write(path, serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?).map_err(|e| format!("write {}: {e}", path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let o = options()?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(threads) = o.threads {
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().map_err(|e| e.to_string())?;
|
||||
return pool.install(|| benchmark(&o));
|
||||
}
|
||||
benchmark(&o)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if let Err(e) = run() {
|
||||
eprintln!("{e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ use photocraft_geom::Rect;
|
||||
use crate::Ctx;
|
||||
use crate::artistic::GalleryEffect;
|
||||
use crate::artistic::GalleryFilter as F;
|
||||
use crate::cutout;
|
||||
use crate::fxutil::{box_blur_n, cell_point, gauss_blur_n, gauss_box_radii, rgba, set_rgba};
|
||||
use crate::image::Image;
|
||||
use crate::noise::hash01;
|
||||
@@ -409,7 +410,8 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
|
||||
let g = |k: &str| e.get(k);
|
||||
match e.filter {
|
||||
F::ColoredPencil => 2,
|
||||
F::Cutout => gs(cutout_sigma(e)),
|
||||
// Traced over the whole image before tiling (see [`Prepared`]).
|
||||
F::Cutout => 0,
|
||||
F::DryBrush | F::Fresco => kuwahara_reach(1 + g("brushSize") as usize) + gs(1.0) + 2,
|
||||
F::FilmGrain | F::DiffuseGlow | F::ChalkCharcoal | F::Reticulation | F::Grain | F::MosaicTiles | F::Texturizer => 0,
|
||||
F::RoughPastels => gs(0.6 + 1.2) + ((2.0 + g("strokeLength")) / 2.0) as i32 + 3,
|
||||
@@ -451,8 +453,8 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
fn cutout_sigma(e: &GalleryEffect) -> f32 {
|
||||
0.5 + e.get("edgeSimplicity") * (1.1 - 0.3 * e.get("edgeFidelity"))
|
||||
fn cutout_settings(e: &GalleryEffect) -> cutout::Settings {
|
||||
cutout::Settings { levels: e.get("numberOfLevels").round().clamp(2.0, 8.0) as usize, simplicity: e.get("edgeSimplicity"), fidelity: e.get("edgeFidelity") }
|
||||
}
|
||||
/// Halftone Pattern's screen spacing in pixels (about 3.5 at Size 1, 25.5 at 12).
|
||||
fn halftone_cell(e: &GalleryEffect) -> f32 {
|
||||
@@ -541,6 +543,54 @@ pub(crate) fn run(effects: &[GalleryEffect], src: &Image, out: Rect, ctx: &Ctx)
|
||||
for e in effects {
|
||||
apply(e, &mut win, ctx);
|
||||
}
|
||||
finish(&win, src, out, ctx)
|
||||
}
|
||||
|
||||
/// Whether a stack needs the whole image at once (Cutout traces it).
|
||||
pub(crate) fn is_global(effects: &[GalleryEffect]) -> bool {
|
||||
effects.iter().any(|e| e.filter == F::Cutout)
|
||||
}
|
||||
|
||||
/// A stack with a Cutout, prepared once for all tiles: the effects up to the last Cutout run
|
||||
/// over the whole image and it is traced; each tile then only fills in the traced shapes and
|
||||
/// runs the effects after it.
|
||||
pub(crate) struct Prepared {
|
||||
traced: cutout::Traced,
|
||||
rest: Vec<GalleryEffect>,
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
/// `None` for a stack that has no Cutout (it runs tile by tile as usual).
|
||||
pub(crate) fn new(effects: &[GalleryEffect], src: &Image, ctx: &Ctx) -> Option<Prepared> {
|
||||
let last = effects.iter().rposition(|e| e.filter == F::Cutout)?;
|
||||
let r = src.rect;
|
||||
let (w, h) = (r.width() as usize, r.height() as usize);
|
||||
let mut win = Win { r, w, h, c: src.data.chunks_exact(src.ch).map(|px| rgb(rgba(ctx, px))).collect() };
|
||||
for e in effects.get(..last).unwrap_or_default() {
|
||||
apply(e, &mut win, ctx);
|
||||
}
|
||||
let traced = cutout::trace(&win.c, r, &cutout_settings(effects.get(last)?));
|
||||
Some(Prepared { traced, rest: effects.get(last + 1..).unwrap_or_default().to_vec() })
|
||||
}
|
||||
|
||||
/// The stack's result over `out` (`src` is the image it was prepared from).
|
||||
pub(crate) fn run(&self, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
|
||||
let r = out.inflate(reach(&self.rest)).intersect(&src.rect);
|
||||
let (w, h) = (r.width() as usize, r.height() as usize);
|
||||
if w == 0 || h == 0 {
|
||||
return src.crop(out);
|
||||
}
|
||||
let mut win = Win { r, w, h, c: self.traced.render(r) };
|
||||
for e in &self.rest {
|
||||
apply(e, &mut win, ctx);
|
||||
}
|
||||
finish(&win, src, out, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
/// `out` of the source with its colour from `win` (alpha kept).
|
||||
fn finish(win: &Win, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let mut res = src.crop(out);
|
||||
let ow = out.width() as usize;
|
||||
for (i, px) in res.chunks_exact_mut(n).enumerate() {
|
||||
@@ -555,13 +605,20 @@ pub(crate) fn run(effects: &[GalleryEffect], src: &Image, out: Rect, ctx: &Ctx)
|
||||
res
|
||||
}
|
||||
|
||||
/// Applies one gallery effect to the working window in place.
|
||||
/// Applies one gallery effect to the working window in place. Each category handles its own
|
||||
/// filters and reports whether the effect was one of them.
|
||||
fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
||||
let handled =
|
||||
apply_artistic(e, win) || apply_brush_strokes(e, win) || apply_distort(e, win) || apply_sketch(e, win, ctx) || apply_stylize_texture(e, win, ctx);
|
||||
debug_assert!(handled, "every gallery filter belongs to a category");
|
||||
}
|
||||
|
||||
/// Artistic filters: the painterly looks of the gallery.
|
||||
fn apply_artistic(e: &GalleryEffect, win: &mut Win) -> bool {
|
||||
let g = |k: &str| e.get(k);
|
||||
let (w, h) = (win.w, win.h);
|
||||
let (fg, bg) = (rgb(e.foreground), rgb(e.background));
|
||||
match e.filter {
|
||||
// ---------------- Artistic ----------------
|
||||
F::ColoredPencil => {
|
||||
let pw = g("pencilWidth");
|
||||
let pressure = g("strokePressure") / 15.0;
|
||||
@@ -578,16 +635,7 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
||||
win.c[i] = mix(paper, map(win.c[i], |v| v * 0.88), cov);
|
||||
}
|
||||
}
|
||||
F::Cutout => {
|
||||
blur3(&mut win.c, w, h, cutout_sigma(e));
|
||||
let levels = g("numberOfLevels").round();
|
||||
for p in &mut win.c {
|
||||
// Flat tones per luminance level, keeping each region's hue.
|
||||
let l = lum(p).max(1e-4);
|
||||
let k = posterize(l, levels) / l;
|
||||
*p = map(*p, |v| posterize(clamp01(v * k), levels * 2.0));
|
||||
}
|
||||
}
|
||||
F::Cutout => win.c = cutout::trace(&win.c, win.r, &cutout_settings(e)).render(win.r),
|
||||
F::DryBrush | F::Fresco => {
|
||||
let fresco = e.filter == F::Fresco;
|
||||
kuwahara(win, 1 + g("brushSize") as usize);
|
||||
@@ -745,7 +793,16 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
||||
win.c[i] = map(saturate(win.c[i], 1.2), |v| clamp01(v * k + n).powf(1.1 + sh * 0.6));
|
||||
}
|
||||
}
|
||||
// ---------------- Brush Strokes ----------------
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Brush Strokes filters.
|
||||
fn apply_brush_strokes(e: &GalleryEffect, win: &mut Win) -> bool {
|
||||
let g = |k: &str| e.get(k);
|
||||
let (w, h) = (win.w, win.h);
|
||||
match e.filter {
|
||||
F::AccentedEdges => {
|
||||
let mut l = win.lum();
|
||||
blur1(&mut l, w, h, 0.4 + g("smoothness") * 0.3);
|
||||
@@ -859,7 +916,17 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
||||
*p = map(*p, |v| clamp01((v - 0.5) * (1.0 + con) + 0.5) * (1.0 - ink * 0.9 * (0.5 + con)));
|
||||
}
|
||||
}
|
||||
// ---------------- Distort ----------------
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Distort filters.
|
||||
fn apply_distort(e: &GalleryEffect, win: &mut Win) -> bool {
|
||||
let g = |k: &str| e.get(k);
|
||||
let (w, h) = (win.w, win.h);
|
||||
let bg = rgb(e.background);
|
||||
match e.filter {
|
||||
F::DiffuseGlow => {
|
||||
let (gr, ga, ca) = (g("graininess") / 10.0, g("glowAmount") / 20.0, g("clearAmount") / 20.0);
|
||||
let thr = 0.2 + ca * 0.7;
|
||||
@@ -903,7 +970,17 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
||||
win.c[i] = src.sample(x as f32 + 0.5 + dx, y as f32 + 0.5 + dy);
|
||||
}
|
||||
}
|
||||
// ---------------- Sketch ----------------
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Sketch filters (foreground is ink, background is paper).
|
||||
fn apply_sketch(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) -> bool {
|
||||
let g = |k: &str| e.get(k);
|
||||
let (w, h) = (win.w, win.h);
|
||||
let (fg, bg) = (rgb(e.foreground), rgb(e.background));
|
||||
match e.filter {
|
||||
F::BasRelief => {
|
||||
let mut l = win.lum();
|
||||
let orig = l.clone();
|
||||
@@ -1107,7 +1184,17 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
||||
win.c[i] = map(win.c[i], |v| (v * (0.85 + 0.25 * fib) - 0.5) * (co * 1.6 + 0.2) + 0.5 + (br - 0.5) * 0.8);
|
||||
}
|
||||
}
|
||||
// ---------------- Stylize ----------------
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Stylize and Texture filters.
|
||||
fn apply_stylize_texture(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) -> bool {
|
||||
let g = |k: &str| e.get(k);
|
||||
let (w, h) = (win.w, win.h);
|
||||
let (fg, bg) = (rgb(e.foreground), rgb(e.background));
|
||||
match e.filter {
|
||||
F::GlowingEdges => {
|
||||
blur3(&mut win.c, w, h, 0.3 + g("smoothness") * 0.25);
|
||||
let mut e3 = vec![[0.0f32; 3]; win.c.len()];
|
||||
@@ -1123,7 +1210,6 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
||||
*p = map(*q, |v| clamp01(v * k));
|
||||
}
|
||||
}
|
||||
// ---------------- Texture ----------------
|
||||
F::Craquelure => {
|
||||
let (sp, dep, bri) = (g("crackSpacing"), g("crackDepth") / 10.0, g("crackBrightness") / 10.0);
|
||||
let cell = sp * 1.3 + 1.0;
|
||||
@@ -1239,7 +1325,9 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
||||
}
|
||||
}
|
||||
F::Texturizer => texturize(win, e.choice("texture"), g("scaling"), g("relief"), e.choice("light"), e.flag("invert")),
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn normalize3(v: [f32; 3]) -> [f32; 3] {
|
||||
@@ -1297,6 +1385,8 @@ mod tests {
|
||||
let fp = FilterParams::FilterGallery { effects: vec![e.clone()] };
|
||||
apply_tiled(&s, &fp, b, b, None, tile, Some(b)).read_region(b)
|
||||
};
|
||||
// Cutout has its own test below: its tone bands are of the channel mean, which this
|
||||
// pattern keeps nearly constant.
|
||||
for f in [G::RoughPastels, G::AngledStrokes] {
|
||||
let base = run(&GalleryEffect::new(f), 256);
|
||||
for prm in f.params() {
|
||||
@@ -1314,6 +1404,123 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutout_responds_to_every_slider() {
|
||||
// A radial gradient (curved band edges, tones to quantize) with a 10 px square in it.
|
||||
let rows: Vec<Vec<f32>> = (0..96)
|
||||
.map(|y| {
|
||||
(0..96)
|
||||
.map(|x| {
|
||||
if (70..80).contains(&x) && (14..24).contains(&y) {
|
||||
return 0.05;
|
||||
}
|
||||
let d = (((x as f32 - 40.0).powi(2) + (y as f32 - 52.0).powi(2)).sqrt() / 60.0).min(1.0);
|
||||
1.0 - d
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let base = cutout_on(&rows, &[]);
|
||||
for (key, v) in
|
||||
[("numberOfLevels", 2.0), ("numberOfLevels", 8.0), ("edgeSimplicity", 0.0), ("edgeSimplicity", 10.0), ("edgeFidelity", 1.0), ("edgeFidelity", 3.0)]
|
||||
{
|
||||
assert_ne!(cutout_on(&rows, &[(key, v)]), base, "cutout {key} = {v} changed nothing");
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one Cutout over a grey image given as rows of values; returns the red channel.
|
||||
fn cutout_on(rows: &[Vec<f32>], set: &[(&str, f32)]) -> Vec<Vec<f32>> {
|
||||
let (w, h) = (rows[0].len() as i32, rows.len() as i32);
|
||||
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true));
|
||||
for (y, row) in rows.iter().enumerate() {
|
||||
for (x, v) in row.iter().enumerate() {
|
||||
s.write_pixel(x as i32, y as i32, &[*v, *v, *v, 1.0]);
|
||||
}
|
||||
}
|
||||
let mut e = GalleryEffect::new(crate::GalleryFilter::Cutout);
|
||||
for (k, v) in set {
|
||||
e.set(k, *v);
|
||||
}
|
||||
let b = Rect::new(0, 0, w, h);
|
||||
let out = apply_tiled(&s, &FilterParams::FilterGallery { effects: vec![e] }, b, b, None, 256, Some(b)).read_region(b);
|
||||
out.chunks(4 * w as usize).map(|r| r.chunks(4).map(|p| p[0]).collect()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutout_makes_flat_shapes_with_clean_edges() {
|
||||
// It used to posterize every pixel after a blur: noise survived as speckle, edges were
|
||||
// ragged and smooth gradients banded instead of becoming flat paper.
|
||||
let noisy: Vec<Vec<f32>> = (0..32).map(|y| (0..64).map(|x| if x < 32 { 0.2 } else { 0.8 } + (hash01(x, y, 0, 7) - 0.5) * 0.16).collect()).collect();
|
||||
let out = cutout_on(&noisy, &[]);
|
||||
for (x0, x1) in [(2, 28), (36, 62)] {
|
||||
let vals: Vec<f32> = (2..30).flat_map(|y| out[y][x0..x1].to_vec()).collect();
|
||||
let spread = vals.iter().fold(f32::MIN, |a, &v| a.max(v)) - vals.iter().fold(f32::MAX, |a, &v| a.min(v));
|
||||
assert!(spread < 1e-4, "a noisy area should be one flat tone, spread {spread}");
|
||||
}
|
||||
// The cut is an anti-aliased pixel or two, not a blur.
|
||||
for y in 0..32 {
|
||||
let (lo, hi) = (out[y][16], out[y][48]);
|
||||
let soft = out[y].iter().filter(|&&v| (v - lo).abs() > 0.01 && (v - hi).abs() > 0.01).count();
|
||||
assert!(soft <= 2, "row {y}: {soft} edge pixels");
|
||||
}
|
||||
// A grey ramp becomes `numberOfLevels` equal-width bands, each its own mean tone, as in the
|
||||
// reference application (0–255 over 256 px at 4 levels: steps at 64, 128 and 192, the
|
||||
// upper bands 96, 160 and 224).
|
||||
let ramp: Vec<Vec<f32>> = (0..64).map(|_| (0..256).map(|x| x as f32 / 255.0).collect()).collect();
|
||||
let row = &cutout_on(&ramp, &[("numberOfLevels", 4.0), ("edgeSimplicity", 0.0), ("edgeFidelity", 3.0)])[32];
|
||||
for (x, want) in [(100, 95.5), (160, 159.5), (230, 223.5)] {
|
||||
assert!((row[x] * 255.0 - want).abs() < 0.6, "band at {x}: {} vs {want}", row[x] * 255.0);
|
||||
}
|
||||
for step in [64usize, 128, 192] {
|
||||
assert!(row[step - 2] < row[step + 1] - 0.2, "a step at {step}: {} / {}", row[step - 2], row[step + 1]);
|
||||
}
|
||||
// Edge Simplicity drops small shapes by their size alone (measured in the reference
|
||||
// application: 6 px squares stay at 0–2 and 4 px go; 12 px stay at 4 and 8 px go).
|
||||
let square = |n: usize| -> Vec<Vec<f32>> {
|
||||
(0..48).map(|y| (0..48).map(|x| if (20..20 + n).contains(&x) && (20..20 + n).contains(&y) { 0.9 } else { 0.2 }).collect()).collect()
|
||||
};
|
||||
for (n, simplicity, kept) in [(6, 0.0, true), (4, 0.0, false), (6, 2.0, true), (12, 4.0, true), (8, 4.0, false), (12, 10.0, false)] {
|
||||
let out = cutout_on(&square(n), &[("edgeSimplicity", simplicity)]);
|
||||
let c = 20 + n / 2;
|
||||
assert_eq!(out[c][c] - out[2][2] > 0.5, kept, "{n} px square at simplicity {simplicity}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutout_keeps_the_tone_of_surviving_islands_at_low_fidelity() {
|
||||
for (size, simplicity) in [(6, 0.0), (6, 2.0), (8, 0.0), (12, 4.0)] {
|
||||
let rows: Vec<Vec<f32>> =
|
||||
(0..32).map(|y| (0..32).map(|x| if (12..12 + size).contains(&x) && (12..12 + size).contains(&y) { 0.9 } else { 0.2 }).collect()).collect();
|
||||
let reference = cutout_on(&rows, &[("edgeSimplicity", simplicity), ("edgeFidelity", 2.0)]);
|
||||
let centre = 12 + size / 2;
|
||||
for fidelity in [1.0, 1.5, 2.0] {
|
||||
let out = cutout_on(&rows, &[("edgeSimplicity", simplicity), ("edgeFidelity", fidelity)]);
|
||||
assert!(
|
||||
(out[centre][centre] - reference[centre][centre]).abs() < 0.02,
|
||||
"{size}x{size}, simplicity {simplicity}, fidelity {fidelity}: {} vs {}",
|
||||
out[centre][centre],
|
||||
reference[centre][centre]
|
||||
);
|
||||
assert!(out[centre][centre] - out[2][2] > 0.5, "the island keeps its contrast");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stack_around_a_cutout_is_tile_independent() {
|
||||
// Effects before a Cutout are traced with it over the whole image; effects after it run
|
||||
// per tile on the filled-in shapes.
|
||||
use crate::GalleryFilter as G;
|
||||
let b = Rect::new(0, 0, 56, 40);
|
||||
let s = pattern(SampleType::F32);
|
||||
let fp =
|
||||
FilterParams::FilterGallery { effects: vec![GalleryEffect::new(G::Spatter), GalleryEffect::new(G::Cutout), GalleryEffect::new(G::GlowingEdges)] };
|
||||
let a = apply_tiled(&s, &fp, b, b, None, 256, Some(b)).read_region(b);
|
||||
let t = apply_tiled(&s, &fp, b, b, None, 16, Some(b)).read_region(b);
|
||||
let worst = a.iter().zip(&t).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max);
|
||||
assert!(worst < 1e-3, "differs across tilings by {worst}");
|
||||
}
|
||||
|
||||
/// Flat greys keep their tone through every pattern and Contrast, black and white stay
|
||||
/// solid, and Contrast makes the screen crisper.
|
||||
#[test]
|
||||
|
||||
@@ -31,6 +31,43 @@ pub(crate) fn gaussian_kernel(sigma: f32) -> Vec<f32> {
|
||||
k.into_iter().map(|v| v / s).collect()
|
||||
}
|
||||
|
||||
/// Gaussian Blur's kernel for `radius` (its standard deviation). Sampling the curve at whole
|
||||
/// pixels leaves a small radius almost no blur: at 0.3 the neighbour weight is about 0.004, a
|
||||
/// tenth of the spread asked for (#8544). Below 1 the sampling width is instead chosen so the
|
||||
/// kernel has exactly the variance `radius²`; from 1 up the sampled kernel is within about 0.4%
|
||||
/// of it (its 3σ truncation), so larger radii are unchanged and the blur grows smoothly from 0.05.
|
||||
pub(crate) fn gaussian_blur_kernel(radius: f32) -> Vec<f32> {
|
||||
if !(0.05..1.0).contains(&radius) {
|
||||
return gaussian_kernel(radius);
|
||||
}
|
||||
let variance = |k: &[f32]| {
|
||||
let r = (k.len() / 2) as f32;
|
||||
k.iter().enumerate().map(|(i, w)| w * (i as f32 - r).powi(2)).sum::<f32>()
|
||||
};
|
||||
// Sampled at `width` with one fixed half-width for every radius below 1: its variance then
|
||||
// rises steadily with `width` (the search can't settle on a step where taps are added), and
|
||||
// the blur grows without the dips a width chosen per radius made at 1/3 and 2/3. Three is the
|
||||
// half-width `gaussian_kernel` uses at radius 1, so the hand-over is smooth too.
|
||||
let half = 3;
|
||||
let sampled = |width: f32| {
|
||||
let k: Vec<f32> = (-half..=half).map(|i| (-(i * i) as f32 / (2.0 * width * width)).exp()).collect();
|
||||
let s: f32 = k.iter().sum();
|
||||
k.into_iter().map(|v| v / s).collect::<Vec<f32>>()
|
||||
};
|
||||
let target = radius * radius;
|
||||
// The sampled kernel's variance only falls short of sigma², so the width lies above `radius`.
|
||||
let (mut lo, mut hi) = (radius, radius + 1.0);
|
||||
for _ in 0..40 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if variance(&sampled(mid)) < target {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
sampled(0.5 * (lo + hi))
|
||||
}
|
||||
|
||||
/// Separable convolution of `src` (premultiplied internally) for `out`.
|
||||
/// `src` must cover `out` grown by the kernel radii.
|
||||
pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bool) -> Vec<f32> {
|
||||
@@ -107,13 +144,12 @@ fn conv_row<const N: usize>(src: &[f32], dst: &mut [f32], kernel: &[f32]) {
|
||||
|
||||
pub(crate) fn gaussian(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec<f32> {
|
||||
// Exact kernel for small radii; beyond that three box passes approximate the Gaussian with
|
||||
// cost independent of the radius (Kovesi, "Fast almost-Gaussian filtering", 2010).
|
||||
match box_widths(&FilterParams::GaussianBlur { radius }) {
|
||||
Some(boxes) => box_passes(src, out, ctx.alpha, &boxes),
|
||||
None => {
|
||||
let k = gaussian_kernel(radius);
|
||||
conv_sep(src, out, &k, &k, ctx.alpha)
|
||||
}
|
||||
// cost independent of the radius (Kovesi, "Fast almost-Gaussian filtering", 2010). Built
|
||||
// from the description other devices run (`separable`), so they blur the same (#5772).
|
||||
match crate::separable::Separable::of(&FilterParams::GaussianBlur { radius }) {
|
||||
Some(crate::separable::Separable::Boxes(boxes)) => box_passes(src, out, ctx.alpha, &boxes),
|
||||
Some(crate::separable::Separable::Kernel(k)) => conv_sep(src, out, &k, &k, ctx.alpha),
|
||||
None => src.crop(out),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -361,23 +361,7 @@ impl Plan {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut scales = [0.0f64; 5];
|
||||
for (i, pixel) in src.data.chunks_exact(src.ch).enumerate() {
|
||||
if i % 4096 == 0 && ctl.cancelled() {
|
||||
return None;
|
||||
}
|
||||
if pixel.iter().any(|value| !value.is_finite() || value.abs() > MAX_SAMPLE)
|
||||
|| (alpha && !pixel.last().is_some_and(|value| (0.0..=1.0).contains(value)))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let a = if alpha { *pixel.last()? } else { 1.0 };
|
||||
for (c, &value) in pixel.iter().enumerate() {
|
||||
let value = if alpha && c + 1 < src.ch { value * a } else { value };
|
||||
let scale = scales.get_mut(c)?;
|
||||
*scale = scale.max(f64::from(value.abs()));
|
||||
}
|
||||
}
|
||||
let scales = channel_scales(src, alpha, ctl)?;
|
||||
let mut result = buffer(ow.checked_mul(oh)?.checked_mul(src.ch)?, 0.0f64)?;
|
||||
let read = self.read_rect(out)?;
|
||||
let used = read.intersect(&src.rect);
|
||||
@@ -393,81 +377,18 @@ impl Plan {
|
||||
if ctl.cancelled() {
|
||||
return None;
|
||||
}
|
||||
let pair = ChannelPair { src, alpha, scales: &scales, channel };
|
||||
data.fill(Number::default());
|
||||
for y in used.y0..used.y1 {
|
||||
if (y - used.y0) % 8 == 0 && ctl.cancelled() {
|
||||
return None;
|
||||
}
|
||||
let source_start =
|
||||
usize::try_from(y - src.rect.y0).ok()?.checked_mul(sw)?.checked_add(usize::try_from(used.x0 - src.rect.x0).ok()?)?.checked_mul(src.ch)?;
|
||||
let source_len = usize::try_from(used.x1 - used.x0).ok()?.checked_mul(src.ch)?;
|
||||
let row = src.data.get(source_start..source_start.checked_add(source_len)?)?;
|
||||
let start = usize::try_from(y - read.y0).ok()?.checked_mul(self.width)?.checked_add(usize::try_from(used.x0 - read.x0).ok()?)?;
|
||||
let dst = data.get_mut(start..start.checked_add(source_len / src.ch)?)?;
|
||||
for (pixel, dst) in row.chunks_exact(src.ch).zip(dst) {
|
||||
let a = if alpha { *pixel.last()? } else { 1.0 };
|
||||
// Normalize each channel separately before packing a pair.
|
||||
// Otherwise a bright HDR colour's FFT residual could swamp
|
||||
// a small alpha channel sharing the imaginary component.
|
||||
let sample = |c: usize| -> f64 {
|
||||
match (pixel.get(c), scales.get(c)) {
|
||||
(Some(&value), Some(&scale)) if scale > 0.0 => f64::from(if alpha && c + 1 < src.ch { value * a } else { value }) / scale,
|
||||
_ => 0.0,
|
||||
}
|
||||
};
|
||||
*dst = Number::new(sample(channel), sample(channel + 1));
|
||||
}
|
||||
}
|
||||
self.pack_channel_pair(&pair, used, read, &mut data, ctl)?;
|
||||
self.transform(&mut data, &mut transposed, &mut scratch, false, ctl)?;
|
||||
for (value, kernel) in data.iter_mut().zip(&self.kernel) {
|
||||
*value *= kernel;
|
||||
}
|
||||
self.transform(&mut data, &mut transposed, &mut scratch, true, ctl)?;
|
||||
let x0 = usize::try_from(-self.min_x).ok()?;
|
||||
let y0 = usize::try_from(-self.min_y).ok()?;
|
||||
for (y, dst) in result.chunks_exact_mut(ow * src.ch).enumerate() {
|
||||
let start = y.checked_add(y0)?.checked_mul(self.width)?.checked_add(x0)?;
|
||||
let values = data.get(start..start.checked_add(ow)?)?;
|
||||
for (pixel, value) in dst.chunks_exact_mut(src.ch).zip(values) {
|
||||
*pixel.get_mut(channel)? = value.re * norm * scales.get(channel)?;
|
||||
if let Some(next) = pixel.get_mut(channel + 1) {
|
||||
*next = value.im * norm * scales.get(channel + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.unpack_channel_pair(&pair, &data, &mut result, ow, norm)?;
|
||||
}
|
||||
if alpha {
|
||||
// The row kernel accumulates in f32. Recompute very small alpha
|
||||
// near its cutoff so a different summation order cannot expose colour.
|
||||
let uncertainty = f64::from(self.distance.abs().ceil()) * f64::from(f32::EPSILON) * scales.get(src.ch - 1)?;
|
||||
for (i, pixel) in result.chunks_exact_mut(src.ch).enumerate() {
|
||||
if i % 16 == 0 && ctl.cancelled() {
|
||||
return None;
|
||||
}
|
||||
let a = pixel.last_mut()?;
|
||||
// Valid alpha is nonnegative. Remove transform roundoff at
|
||||
// zero to keep fully transparent regions empty.
|
||||
if a.abs() < 1e-14 * scales.get(src.ch - 1)? {
|
||||
*a = 0.0;
|
||||
}
|
||||
*a = a.clamp(0.0, 1.0);
|
||||
let a = *a;
|
||||
// The row kernel's f32 sum can straddle the cutoff at long distances.
|
||||
// Keep its branch and straight colour exactly for that narrow
|
||||
// range, rather than amplifying a tiny alpha difference.
|
||||
if a > 0.0 && (a <= 1.01e-7 + uncertainty || (0.99e-7..=1.01e-7).contains(&(a as f32))) {
|
||||
let x = out.x0.checked_add(i32::try_from(i % ow).ok()?)?;
|
||||
let y = out.y0.checked_add(i32::try_from(i / ow).ok()?)?;
|
||||
let original = sample_row_point(src, x, y, alpha, &self.taps, ctl)?;
|
||||
for (dst, value) in pixel.iter_mut().zip(original) {
|
||||
*dst = f64::from(value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for value in pixel.iter_mut().take(src.ch - 1) {
|
||||
*value = if a as f32 > 1e-7 { *value / a } else { 0.0 };
|
||||
}
|
||||
}
|
||||
self.restore_small_alpha(src, out, ow, &scales, &mut result, ctl)?;
|
||||
}
|
||||
let mut output = buffer(result.len(), 0.0f32)?;
|
||||
for (dst, value) in output.iter_mut().zip(result) {
|
||||
@@ -475,6 +396,125 @@ impl Plan {
|
||||
}
|
||||
Some(output)
|
||||
}
|
||||
|
||||
/// Packs the pair of channels into the real and imaginary parts of `data`, over the source
|
||||
/// rows that fall inside the transform tile, each channel normalised by its own scale.
|
||||
fn pack_channel_pair(&self, pair: &ChannelPair, used: Rect, read: Rect, data: &mut [Number], ctl: &Interrupt) -> Option<()> {
|
||||
let ChannelPair { src, alpha, scales, channel } = *pair;
|
||||
let (sw, _) = dimensions(src.rect)?;
|
||||
for y in used.y0..used.y1 {
|
||||
if (y - used.y0) % 8 == 0 && ctl.cancelled() {
|
||||
return None;
|
||||
}
|
||||
let source_start =
|
||||
usize::try_from(y - src.rect.y0).ok()?.checked_mul(sw)?.checked_add(usize::try_from(used.x0 - src.rect.x0).ok()?)?.checked_mul(src.ch)?;
|
||||
let source_len = usize::try_from(used.x1 - used.x0).ok()?.checked_mul(src.ch)?;
|
||||
let row = src.data.get(source_start..source_start.checked_add(source_len)?)?;
|
||||
let start = usize::try_from(y - read.y0).ok()?.checked_mul(self.width)?.checked_add(usize::try_from(used.x0 - read.x0).ok()?)?;
|
||||
let dst = data.get_mut(start..start.checked_add(source_len / src.ch)?)?;
|
||||
for (pixel, dst) in row.chunks_exact(src.ch).zip(dst) {
|
||||
let a = if alpha { *pixel.last()? } else { 1.0 };
|
||||
// Normalize each channel separately before packing a pair.
|
||||
// Otherwise a bright HDR colour's FFT residual could swamp
|
||||
// a small alpha channel sharing the imaginary component.
|
||||
let sample = |c: usize| -> f64 {
|
||||
match (pixel.get(c), scales.get(c)) {
|
||||
(Some(&value), Some(&scale)) if scale > 0.0 => f64::from(if alpha && c + 1 < src.ch { value * a } else { value }) / scale,
|
||||
_ => 0.0,
|
||||
}
|
||||
};
|
||||
*dst = Number::new(sample(channel), sample(channel + 1));
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Writes the filtered pair of channels from `data` into the output pixels, undoing the
|
||||
/// per-channel normalisation.
|
||||
fn unpack_channel_pair(&self, pair: &ChannelPair, data: &[Number], result: &mut [f64], ow: usize, norm: f64) -> Option<()> {
|
||||
let ChannelPair { src, scales, channel, .. } = *pair;
|
||||
let x0 = usize::try_from(-self.min_x).ok()?;
|
||||
let y0 = usize::try_from(-self.min_y).ok()?;
|
||||
for (y, dst) in result.chunks_exact_mut(ow * src.ch).enumerate() {
|
||||
let start = y.checked_add(y0)?.checked_mul(self.width)?.checked_add(x0)?;
|
||||
let values = data.get(start..start.checked_add(ow)?)?;
|
||||
for (pixel, value) in dst.chunks_exact_mut(src.ch).zip(values) {
|
||||
*pixel.get_mut(channel)? = value.re * norm * scales.get(channel)?;
|
||||
if let Some(next) = pixel.get_mut(channel + 1) {
|
||||
*next = value.im * norm * scales.get(channel + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Straightens the colour of the filtered pixels by their alpha, taking care of the pixels
|
||||
/// whose alpha is at the cutoff.
|
||||
fn restore_small_alpha(&self, src: &Image, out: Rect, ow: usize, scales: &[f64; 5], result: &mut [f64], ctl: &Interrupt) -> Option<()> {
|
||||
// The row kernel accumulates in f32. Recompute very small alpha
|
||||
// near its cutoff so a different summation order cannot expose colour.
|
||||
let uncertainty = f64::from(self.distance.abs().ceil()) * f64::from(f32::EPSILON) * scales.get(src.ch - 1)?;
|
||||
for (i, pixel) in result.chunks_exact_mut(src.ch).enumerate() {
|
||||
if i % 16 == 0 && ctl.cancelled() {
|
||||
return None;
|
||||
}
|
||||
let a = pixel.last_mut()?;
|
||||
// Valid alpha is nonnegative. Remove transform roundoff at
|
||||
// zero to keep fully transparent regions empty.
|
||||
if a.abs() < 1e-14 * scales.get(src.ch - 1)? {
|
||||
*a = 0.0;
|
||||
}
|
||||
*a = a.clamp(0.0, 1.0);
|
||||
let a = *a;
|
||||
// The row kernel's f32 sum can straddle the cutoff at long distances.
|
||||
// Keep its branch and straight colour exactly for that narrow
|
||||
// range, rather than amplifying a tiny alpha difference.
|
||||
if a > 0.0 && (a <= 1.01e-7 + uncertainty || (0.99e-7..=1.01e-7).contains(&(a as f32))) {
|
||||
let x = out.x0.checked_add(i32::try_from(i % ow).ok()?)?;
|
||||
let y = out.y0.checked_add(i32::try_from(i / ow).ok()?)?;
|
||||
let original = sample_row_point(src, x, y, true, &self.taps, ctl)?;
|
||||
for (dst, value) in pixel.iter_mut().zip(original) {
|
||||
*dst = f64::from(value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for value in pixel.iter_mut().take(src.ch - 1) {
|
||||
*value = if a as f32 > 1e-7 { *value / a } else { 0.0 };
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
|
||||
/// One pair of channels (packed as real and imaginary parts) of a filter call.
|
||||
#[derive(Clone, Copy)]
|
||||
struct ChannelPair<'a> {
|
||||
src: &'a Image,
|
||||
alpha: bool,
|
||||
scales: &'a [f64; 5],
|
||||
channel: usize,
|
||||
}
|
||||
|
||||
/// Per-channel peak magnitude of the (alpha-premultiplied) samples. `None` when a sample is not
|
||||
/// finite, is too large, or its alpha is outside 0..=1, and when cancelled.
|
||||
fn channel_scales(src: &Image, alpha: bool, ctl: &Interrupt) -> Option<[f64; 5]> {
|
||||
let mut scales = [0.0f64; 5];
|
||||
for (i, pixel) in src.data.chunks_exact(src.ch).enumerate() {
|
||||
if i % 4096 == 0 && ctl.cancelled() {
|
||||
return None;
|
||||
}
|
||||
if pixel.iter().any(|value| !value.is_finite() || value.abs() > MAX_SAMPLE) || (alpha && !pixel.last().is_some_and(|value| (0.0..=1.0).contains(value)))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let a = if alpha { *pixel.last()? } else { 1.0 };
|
||||
for (c, &value) in pixel.iter().enumerate() {
|
||||
let value = if alpha && c + 1 < src.ch { value * a } else { value };
|
||||
let scale = scales.get_mut(c)?;
|
||||
*scale = scale.max(f64::from(value.abs()));
|
||||
}
|
||||
}
|
||||
Some(scales)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -2,6 +2,76 @@ use super::*;
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
/// #8544, rendered: Gaussian Blur at 0.1–0.3 px through the filter itself (the direct-kernel
|
||||
/// path) visibly softens a hard edge, more with each step, as the kernel predicts.
|
||||
#[test]
|
||||
fn small_gaussian_radii_soften_a_rendered_edge() {
|
||||
let canvas = Rect::new(0, 0, 20, 4);
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
s.fill_rect(canvas, &[0.0, 0.0, 0.0, 1.0]);
|
||||
s.fill_rect(Rect::new(10, 0, 20, 4), &[1.0, 1.0, 1.0, 1.0]);
|
||||
let mut last = 0.0f32;
|
||||
for radius in [0.1f32, 0.2, 0.3] {
|
||||
let p = FilterParams::GaussianBlur { radius };
|
||||
assert!(box_widths(&p).is_none(), "the direct-kernel path");
|
||||
let out = crate::apply(&s, &p, canvas, canvas, None);
|
||||
// The last black pixel takes the white side's share of the kernel.
|
||||
let got = out.pixel(9, 1)[0];
|
||||
let k = gaussian_blur_kernel(radius);
|
||||
let want: f32 = k[k.len() / 2 + 1..].iter().sum();
|
||||
assert!((got - want).abs() <= 1.0 / 255.0, "radius {radius}: {got} vs {want}");
|
||||
assert!(got >= 1.0 / 255.0 && got > last, "radius {radius}: {got} after {last}");
|
||||
last = got;
|
||||
}
|
||||
}
|
||||
|
||||
/// #8544: Gaussian Blur spreads a hard edge for every radius from 0.1, growing smoothly
|
||||
/// with the radius (no dead zone up to 0.3, then a jump); from 1 up the kernel is unchanged.
|
||||
#[test]
|
||||
fn small_gaussian_radii_blur_smoothly() {
|
||||
let spread = |k: &[f32]| {
|
||||
let r = (k.len() / 2) as f32;
|
||||
k.iter().enumerate().map(|(i, w)| w * (i as f32 - r).powi(2)).sum::<f32>()
|
||||
};
|
||||
let mut last = 0.0f32;
|
||||
for step in 1..=20 {
|
||||
let radius = step as f32 * 0.05;
|
||||
let k = gaussian_blur_kernel(radius);
|
||||
assert!((k.iter().sum::<f32>() - 1.0).abs() < 1e-5, "normalised at {radius}");
|
||||
if radius < 1.0 {
|
||||
assert!((spread(&k) - radius * radius).abs() < 1e-4, "variance at {radius}: {}", spread(&k));
|
||||
}
|
||||
// A hard 0 → 1 edge: the first pixel past it takes the kernel's tail beyond the centre.
|
||||
let c = k.len() / 2;
|
||||
let edge: f32 = k[c + 1..].iter().sum();
|
||||
assert!(edge > last, "radius {radius}: edge {edge} after {last}");
|
||||
if radius > 0.1 {
|
||||
assert!(edge - last < 0.05, "radius {radius}: jump {} from {last}", edge - last);
|
||||
}
|
||||
last = edge;
|
||||
}
|
||||
assert!(gaussian_blur_kernel(0.1).len() >= 3, "0.1 blurs");
|
||||
// No step where the variance-matched range hands over to plain sampling.
|
||||
let edge = |r: f32| {
|
||||
let k = gaussian_blur_kernel(r);
|
||||
k[k.len() / 2 + 1..].iter().sum::<f32>()
|
||||
};
|
||||
assert!((edge(0.999) - edge(1.0)).abs() < 0.005, "{} vs {}", edge(0.999), edge(1.0));
|
||||
// No dip where a per-radius width used to add taps (1/3 and 2/3).
|
||||
for pair in [(0.33f32, 0.34f32), (0.66, 0.67)] {
|
||||
assert!(edge(pair.1) > edge(pair.0), "edge falls from {} to {}", pair.0, pair.1);
|
||||
}
|
||||
// Exact variance right up to the hand-over, past where `gaussian_kernel` changes width.
|
||||
for radius in [0.98f32, 0.995, 0.998, 0.9995] {
|
||||
let k = gaussian_blur_kernel(radius);
|
||||
assert!((spread(&k) - radius * radius).abs() < 2e-5, "variance at {radius}: {}", spread(&k));
|
||||
}
|
||||
for radius in [1.0, 1.5, 3.0, 4.0] {
|
||||
assert_eq!(gaussian_blur_kernel(radius), gaussian_kernel(radius), "{radius}");
|
||||
}
|
||||
assert_eq!(gaussian_blur_kernel(0.0), vec![1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_blur_samples_large_arcs_beyond_64_intervals() {
|
||||
let bounds = Rect::new(0, 0, 1000, 1000);
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
//! Outlines: each chain fitted once into a straight-sided polyline, with its corners kept and its
|
||||
//! runs simplified (Douglas–Peucker).
|
||||
|
||||
use super::{Pt, Settings};
|
||||
|
||||
/// `a - b`.
|
||||
fn sub(a: Pt, b: Pt) -> Pt {
|
||||
[a[0] - b[0], a[1] - b[1]]
|
||||
}
|
||||
|
||||
/// Length of `a`.
|
||||
fn len(a: Pt) -> f32 {
|
||||
(a[0] * a[0] + a[1] * a[1]).sqrt()
|
||||
}
|
||||
|
||||
/// Fits one chain: corners kept, runs simplified to straight segments. Its ends stay where they are, so
|
||||
/// chains meeting at a junction still meet.
|
||||
pub(super) fn fit(pts: &[Pt], closed: bool, s: &Settings) -> Vec<Pt> {
|
||||
let n = pts.len();
|
||||
if n < 3 {
|
||||
return pts.to_vec();
|
||||
}
|
||||
let original = pts;
|
||||
let rounded = round_off(pts, closed, s.rounding());
|
||||
let pts = &rounded[..];
|
||||
let at = |i: isize| pts[i.rem_euclid(n as isize) as usize];
|
||||
// Turn at each point, measured over a few cracks either side.
|
||||
const K: isize = 3;
|
||||
let turn = |i: isize| {
|
||||
let (a, b) = (sub(at(i), at(i - K)), sub(at(i + K), at(i)));
|
||||
let (la, lb) = (len(a), len(b));
|
||||
if la < 1e-6 || lb < 1e-6 { 0.0 } else { ((a[0] * b[0] + a[1] * b[1]) / (la * lb)).clamp(-1.0, 1.0).acos() }
|
||||
};
|
||||
let span = if closed { 0..n as isize } else { K..(n as isize - K) };
|
||||
let turns: Vec<(isize, f32)> = span.clone().map(|i| (i, turn(i))).collect();
|
||||
let limit = s.corner_turn();
|
||||
let mut corners: Vec<usize> = Vec::new();
|
||||
for &(i, t) in &turns {
|
||||
if t <= limit {
|
||||
continue;
|
||||
}
|
||||
// Only the sharpest point of a bend.
|
||||
let sharpest = (-K..=K).all(|o| {
|
||||
let j = i + o;
|
||||
let in_span = closed || span.contains(&j);
|
||||
!in_span || o == 0 || {
|
||||
let tj = turn(j);
|
||||
tj < t || (tj == t && o > 0)
|
||||
}
|
||||
});
|
||||
if sharpest {
|
||||
corners.push(i.rem_euclid(n as isize) as usize);
|
||||
}
|
||||
}
|
||||
// Square a corner back where the pixels cut it: where the straight runs either side meet.
|
||||
let mut p = pts.to_vec();
|
||||
for &i in &corners {
|
||||
let ii = i as isize;
|
||||
if let Some(q) = intersect(at(ii - K), at(ii - 1), at(ii + 1), at(ii + K))
|
||||
&& len(sub(q, at(ii))) <= 1.0
|
||||
{
|
||||
p[i] = q;
|
||||
}
|
||||
}
|
||||
let mut keep: Vec<usize> = if closed { corners.clone() } else { std::iter::once(0).chain(corners.iter().copied()).chain(std::iter::once(n - 1)).collect() };
|
||||
keep.sort_unstable();
|
||||
keep.dedup();
|
||||
let mut is_corner = vec![false; n];
|
||||
for &k in &keep {
|
||||
is_corner[k] = true;
|
||||
}
|
||||
if closed && keep.len() < 2 {
|
||||
// A smooth loop: split it at two far-apart points (not corners) to simplify each half.
|
||||
let a = keep.first().copied().unwrap_or(0);
|
||||
let far = (0..n).max_by(|&i, &j| len(sub(p[i], p[a])).total_cmp(&len(sub(p[j], p[a])))).unwrap_or(n / 2);
|
||||
keep = if a == far { vec![a, (a + n / 2) % n] } else { vec![a.min(far), a.max(far)] };
|
||||
}
|
||||
// Douglas–Peucker between consecutive kept points.
|
||||
let tol = s.tolerance();
|
||||
let mut verts: Vec<usize> = Vec::new();
|
||||
let pieces = if closed { keep.len() } else { keep.len() - 1 };
|
||||
for k in 0..pieces {
|
||||
let a = keep[k];
|
||||
let b = if k + 1 < keep.len() { keep[k + 1] } else { keep[0] + n };
|
||||
verts.push(a);
|
||||
let idx: Vec<usize> = (a..=b).collect();
|
||||
let mut marks = vec![false; idx.len()];
|
||||
simplify(&idx.iter().map(|&i| p[i % n]).collect::<Vec<_>>(), 0, idx.len() - 1, tol, &mut marks);
|
||||
verts.extend(idx.iter().zip(&marks).skip(1).take(idx.len().saturating_sub(2)).filter(|m| *m.1).map(|(&i, _)| i % n));
|
||||
}
|
||||
if !closed {
|
||||
verts.push(n - 1);
|
||||
}
|
||||
let v: Vec<Pt> = verts.iter().map(|&i| p[i]).collect();
|
||||
let corner: Vec<bool> = verts.iter().map(|&i| is_corner[i]).collect();
|
||||
let _ = &corner;
|
||||
let mut out = v.clone();
|
||||
if closed {
|
||||
out.extend(v.first().copied());
|
||||
}
|
||||
if closed {
|
||||
let before = signed_area(original);
|
||||
let after = signed_area(&out);
|
||||
// Region merging decides which islands survive. Rounding and simplification must not
|
||||
// erase one (or reverse its hole): keep the original shared outline if half its area
|
||||
// would be lost. Close it explicitly because the fill consumes consecutive segments.
|
||||
if before * after <= 0.0 || after.abs() < before.abs() * 0.5 {
|
||||
let mut out = original.to_vec();
|
||||
out.extend(original.first().copied());
|
||||
return out;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Signed loop area, relative to its first point to avoid cancellation far from the origin.
|
||||
fn signed_area(pts: &[Pt]) -> f64 {
|
||||
let Some(origin) = pts.first() else { return 0.0 };
|
||||
pts.iter()
|
||||
.zip(pts.iter().cycle().skip(1))
|
||||
.map(|(a, b)| {
|
||||
let (ax, ay) = (f64::from(a[0]) - f64::from(origin[0]), f64::from(a[1]) - f64::from(origin[1]));
|
||||
let (bx, by) = (f64::from(b[0]) - f64::from(origin[0]), f64::from(b[1]) - f64::from(origin[1]));
|
||||
ax * by - bx * ay
|
||||
})
|
||||
.sum::<f64>()
|
||||
* 0.5
|
||||
}
|
||||
|
||||
/// Two passes of a moving average over `r` points either side; an open chain's ends (junctions)
|
||||
/// stay put, and the window shrinks towards them so they don't pull the outline.
|
||||
fn round_off(pts: &[Pt], closed: bool, r: usize) -> Vec<Pt> {
|
||||
let n = pts.len();
|
||||
let mut p = pts.to_vec();
|
||||
if r == 0 || n < 3 {
|
||||
return p;
|
||||
}
|
||||
for _ in 0..2 {
|
||||
let src = p.clone();
|
||||
for (i, q) in p.iter_mut().enumerate() {
|
||||
let rr = if closed { r.min((n - 1) / 2) } else { r.min(i).min(n - 1 - i) };
|
||||
if rr == 0 {
|
||||
continue;
|
||||
}
|
||||
let (mut sx, mut sy) = (0.0f32, 0.0f32);
|
||||
for o in -(rr as isize)..=rr as isize {
|
||||
let s = src[(i as isize + o).rem_euclid(n as isize) as usize];
|
||||
sx += s[0];
|
||||
sy += s[1];
|
||||
}
|
||||
let k = (2 * rr + 1) as f32;
|
||||
*q = [sx / k, sy / k];
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Where the lines through `a0`–`a1` and `b0`–`b1` cross, if they aren't parallel.
|
||||
fn intersect(a0: Pt, a1: Pt, b0: Pt, b1: Pt) -> Option<Pt> {
|
||||
let (da, db) = (sub(a1, a0), sub(b1, b0));
|
||||
let den = da[0] * db[1] - da[1] * db[0];
|
||||
if den.abs() < 1e-6 {
|
||||
return None;
|
||||
}
|
||||
let t = ((b0[0] - a0[0]) * db[1] - (b0[1] - a0[1]) * db[0]) / den;
|
||||
Some([a0[0] + da[0] * t, a0[1] + da[1] * t])
|
||||
}
|
||||
|
||||
/// Marks the points of `p[a..=b]` that Douglas–Peucker keeps at tolerance `tol`.
|
||||
fn simplify(p: &[Pt], a: usize, b: usize, tol: f32, marks: &mut [bool]) {
|
||||
let mut stack = vec![(a, b)];
|
||||
while let Some((a, b)) = stack.pop() {
|
||||
if b <= a + 1 {
|
||||
continue;
|
||||
}
|
||||
let (pa, d) = (p[a], sub(p[b], p[a]));
|
||||
let l = len(d);
|
||||
let mut best = (a, 0.0f32);
|
||||
for (i, q) in p.iter().enumerate().take(b).skip(a + 1) {
|
||||
let e = sub(*q, pa);
|
||||
let dist = if l < 1e-6 { len(e) } else { (e[0] * d[1] - e[1] * d[0]).abs() / l };
|
||||
if dist > best.1 {
|
||||
best = (i, dist);
|
||||
}
|
||||
}
|
||||
if best.1 > tol {
|
||||
marks[best.0] = true;
|
||||
stack.push((a, best.0));
|
||||
stack.push((best.0, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounding box `[x0, y0, x1, y1]` of `pts`.
|
||||
pub(super) fn bounds(pts: &[Pt]) -> [f32; 4] {
|
||||
pts.iter().fold([f32::MAX, f32::MAX, f32::MIN, f32::MIN], |b, p| [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn settings(fidelity: f32) -> Settings {
|
||||
Settings { levels: 4, simplicity: 2.0, fidelity }
|
||||
}
|
||||
|
||||
/// The outline of a square from (2, 2) to (12, 12), one point per unit, starting at a corner.
|
||||
fn square() -> Vec<Pt> {
|
||||
let mut p = Vec::new();
|
||||
for i in 0..10 {
|
||||
p.push([2.0 + i as f32, 2.0]);
|
||||
}
|
||||
for i in 0..10 {
|
||||
p.push([12.0, 2.0 + i as f32]);
|
||||
}
|
||||
for i in 0..10 {
|
||||
p.push([12.0 - i as f32, 12.0]);
|
||||
}
|
||||
for i in 0..10 {
|
||||
p.push([2.0, 12.0 - i as f32]);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn close(a: Pt, b: Pt) -> bool {
|
||||
len(sub(a, b)) < 1e-3
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn douglas_peucker_keeps_only_points_off_the_line() {
|
||||
// An L, slightly ragged: only its corner is further from the end-to-end line than the
|
||||
// tolerance, and the two arms are straight within it.
|
||||
let wobble = |i: usize| 0.05 * (i % 2) as f32;
|
||||
let p: Vec<Pt> = (0..9).map(|i| if i <= 4 { [i as f32, wobble(i)] } else { [4.0 + wobble(i), (i - 4) as f32] }).collect();
|
||||
let mut marks = vec![false; p.len()];
|
||||
simplify(&p, 0, 8, 0.5, &mut marks);
|
||||
assert_eq!(marks.iter().filter(|m| **m).count(), 1, "{marks:?}");
|
||||
assert!(marks[4]);
|
||||
let mut marks = vec![false; p.len()];
|
||||
simplify(&p, 0, 8, 3.0, &mut marks);
|
||||
assert!(marks.iter().all(|m| !m), "under the tolerance nothing is kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_cross_where_they_should() {
|
||||
let q = intersect([0.0, 0.0], [1.0, 1.0], [0.0, 2.0], [1.0, 1.0]);
|
||||
assert!(q.is_some_and(|q| close(q, [1.0, 1.0])), "{q:?}");
|
||||
assert_eq!(intersect([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [2.0, 1.0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounding_off_keeps_an_open_chains_ends() {
|
||||
let p: Vec<Pt> = (0..12).map(|i| [i as f32, if i % 2 == 0 { 0.0 } else { 1.0 }]).collect();
|
||||
let r = round_off(&p, false, 3);
|
||||
assert_eq!((r[0], r[11]), (p[0], p[11]));
|
||||
assert!(r[5][1] > 0.2 && r[5][1] < 0.8, "the zigzag is averaged out: {:?}", r[5]);
|
||||
assert_eq!(round_off(&p, false, 0), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_square_keeps_its_four_corners_and_straight_sides() {
|
||||
let out = fit(&square(), true, &settings(2.0));
|
||||
for c in [[2.0, 2.0], [12.0, 2.0], [12.0, 12.0], [2.0, 12.0]] {
|
||||
assert!(out.iter().any(|p| close(*p, c)), "corner {c:?} lost: {out:?}");
|
||||
}
|
||||
let on_side = |p: &Pt| [p[0] - 2.0, p[0] - 12.0, p[1] - 2.0, p[1] - 12.0].iter().any(|d| d.abs() < 1e-3);
|
||||
assert!(out.iter().all(on_side), "{out:?}");
|
||||
assert!(out.len() < 10, "the sides are simplified to their ends: {out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rounded_corner_is_squared_back() {
|
||||
// The pixels cut the corner at (12, 2): the outline goes (11, 2), (12, 3) instead.
|
||||
let mut p = square();
|
||||
p[10] = [11.5, 2.5];
|
||||
let out = fit(&p, true, &settings(2.0));
|
||||
assert!(out.iter().any(|q| close(*q, [12.0, 2.0])), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_chain_ends_where_it_started_at_any_fidelity() {
|
||||
let p: Vec<Pt> = (0..30).map(|i| [i as f32, (i as f32 / 5.0).sin() * 4.0 + 0.3 * (i % 3) as f32]).collect();
|
||||
for f in [1.0, 2.0, 3.0] {
|
||||
let out = fit(&p, false, &settings(f));
|
||||
assert_eq!((out.first(), out.last()), (p.first(), p.last()), "fidelity {f}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitting_keeps_small_islands_and_their_winding() {
|
||||
use super::super::edges::follow_edges;
|
||||
let (w, h) = (32, 32);
|
||||
for size in [2, 3, 4, 6] {
|
||||
let reg: Vec<u32> = (0..w * h).map(|i| u32::from((12..12 + size).contains(&(i % w)) && (12..12 + size).contains(&(i / w)))).collect();
|
||||
let colours = [[0.2; 3], [0.9; 3]];
|
||||
let c: Vec<_> = reg.iter().map(|&r| colours[r as usize]).collect();
|
||||
let (chains, _) = follow_edges(®, &colours, &c, w, h);
|
||||
let (pts, closed) = chains.iter().find(|(_, closed)| *closed).unwrap();
|
||||
assert!(*closed);
|
||||
let area = |p: &[Pt]| p.iter().zip(p.iter().cycle().skip(1)).map(|(a, b)| a[0] as f64 * b[1] as f64 - b[0] as f64 * a[1] as f64).sum::<f64>() / 2.0;
|
||||
let original = area(pts);
|
||||
for simplicity in [0.0, 2.0] {
|
||||
let s = Settings { simplicity, ..settings(2.0) };
|
||||
if size * size < s.min_area() as usize {
|
||||
continue;
|
||||
}
|
||||
for fidelity in [1.0, 1.5, 2.0, 3.0] {
|
||||
let out = fit(pts, true, &Settings { fidelity, ..s });
|
||||
let fitted = area(&out);
|
||||
assert!(
|
||||
fitted * original > 0.0 && fitted.abs() >= original.abs() * 0.5,
|
||||
"{size}x{size}, simplicity {simplicity}, fidelity {fidelity}: {original} -> {fitted}"
|
||||
);
|
||||
assert_eq!(out.first(), out.last(), "fallbacks must close the loop for the fill");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_cover_every_point() {
|
||||
assert_eq!(bounds(&[[1.0, 5.0], [-2.0, 3.0], [4.0, 4.0]]), [-2.0, 3.0, 4.0, 5.0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Edges: the cracks between regions, linked into chains from junction to junction, each crack
|
||||
//! placed where the anti-aliasing says the edge is.
|
||||
|
||||
use super::{OUT, Pt, Px};
|
||||
use crate::par;
|
||||
|
||||
/// A chain's points, and whether it is closed.
|
||||
pub(super) type Chain = (Vec<Pt>, bool);
|
||||
/// A pixel, or a lattice point (the corner at a pixel's top left).
|
||||
type Cell = (i64, i64);
|
||||
|
||||
/// A step along the lattice, y down.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Dir {
|
||||
E,
|
||||
W,
|
||||
S,
|
||||
N,
|
||||
}
|
||||
|
||||
/// Links the cracks between regions into chains. Each chain is its points (the junctions it runs
|
||||
/// between, if any, and one sub-pixel point per crack) and whether it is closed; its sides are
|
||||
/// the regions on its left and right (y down).
|
||||
pub(super) fn follow_edges(reg: &[u32], colours: &[Px], c: &[Px], w: usize, h: usize) -> (Vec<Chain>, Vec<(u32, u32)>) {
|
||||
let (wi, hi) = (w as i64, h as i64);
|
||||
let at = |x: i64, y: i64| -> u32 { if x < 0 || y < 0 || x >= wi || y >= hi { OUT } else { reg[y as usize * w + x as usize] } };
|
||||
let hcount = w * (h + 1);
|
||||
// Horizontal crack (x, y)–(x+1, y) is `y * w + x`; vertical (x, y)–(x, y+1) follows them.
|
||||
let hc = |x: i64, y: i64| (y as usize) * w + x as usize;
|
||||
let vc = |x: i64, y: i64| hcount + (y as usize) * (w + 1) + x as usize;
|
||||
let mut visited = vec![false; hcount + (w + 1) * h];
|
||||
// The crack leaving lattice point (x, y) in `d`: its id, the pixels on its left and right,
|
||||
// and the point it reaches.
|
||||
let step = |x: i64, y: i64, d: Dir| -> Option<(usize, Cell, Cell, Cell)> {
|
||||
match d {
|
||||
Dir::E if x < wi => Some((hc(x, y), (x, y - 1), (x, y), (x + 1, y))),
|
||||
Dir::W if x > 0 => Some((hc(x - 1, y), (x - 1, y), (x - 1, y - 1), (x - 1, y))),
|
||||
Dir::S if y < hi => Some((vc(x, y), (x, y), (x - 1, y), (x, y + 1))),
|
||||
Dir::N if y > 0 => Some((vc(x, y - 1), (x - 1, y - 1), (x, y - 1), (x, y - 1))),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
// Which cracks separate two regions, and which lattice points are junctions, worked out
|
||||
// once (in parallel) before the walk.
|
||||
let mut is_edge = vec![false; visited.len()];
|
||||
let (hb, vb) = is_edge.split_at_mut(hcount);
|
||||
par::chunks_mut(hb, w, true, |y, row| {
|
||||
for (x, b) in row.iter_mut().enumerate() {
|
||||
*b = at(x as i64, y as i64 - 1) != at(x as i64, y as i64);
|
||||
}
|
||||
});
|
||||
par::chunks_mut(vb, w + 1, true, |y, row| {
|
||||
for (x, b) in row.iter_mut().enumerate() {
|
||||
*b = at(x as i64 - 1, y as i64) != at(x as i64, y as i64);
|
||||
}
|
||||
});
|
||||
let boundary = |x: i64, y: i64, d: Dir| step(x, y, d).filter(|s| is_edge[s.0]);
|
||||
let mut is_junction = vec![false; (w + 1) * (h + 1)];
|
||||
par::chunks_mut(&mut is_junction, w + 1, true, |y, row| {
|
||||
let y = y as i64;
|
||||
for (x, j) in row.iter_mut().enumerate() {
|
||||
let x = x as i64;
|
||||
let deg = [Dir::E, Dir::W, Dir::S, Dir::N].iter().filter(|&&d| boundary(x, y, d).is_some()).count();
|
||||
*j = deg >= 3 || (deg > 0 && (x == 0 || x == wi) && (y == 0 || y == hi));
|
||||
}
|
||||
});
|
||||
let junction = |x: i64, y: i64| is_junction[y as usize * (w + 1) + x as usize];
|
||||
// One point per crack: its middle, moved along the normal from left to right pixel by what
|
||||
// their colours say about where the edge really is.
|
||||
let crack_point = |l: (i64, i64), r: (i64, i64)| -> Pt {
|
||||
let mid = [(l.0 + r.0 + 1) as f32 / 2.0, (l.1 + r.1 + 1) as f32 / 2.0];
|
||||
let (n0, n1) = ((r.0 - l.0) as f32, (r.1 - l.1) as f32);
|
||||
let (ra, rb) = (at(l.0, l.1), at(r.0, r.1));
|
||||
let (Some(ca), Some(cb)) = (colours.get(ra as usize), colours.get(rb as usize)) else { return mid };
|
||||
let d = [cb[0] - ca[0], cb[1] - ca[1], cb[2] - ca[2]];
|
||||
let dd = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
if dd < 1e-4 {
|
||||
return mid;
|
||||
}
|
||||
// How far each pixel's colour is from its own region's towards the other's (0–1).
|
||||
let frac = |p: (i64, i64)| {
|
||||
let q = c[p.1 as usize * w + p.0 as usize];
|
||||
((q[0] - ca[0]) * d[0] + (q[1] - ca[1]) * d[1] + (q[2] - ca[2]) * d[2]) / dd
|
||||
};
|
||||
let (fl, fr) = (frac(l).clamp(0.0, 1.0), frac(r).clamp(0.0, 1.0));
|
||||
let off = ((1.0 - fr) - fl).clamp(-0.5, 0.5);
|
||||
[mid[0] + n0 * off, mid[1] + n1 * off]
|
||||
};
|
||||
let mut chains = Vec::new();
|
||||
let mut sides = Vec::new();
|
||||
let mut walk = |x: i64, y: i64, first: Dir, visited: &mut Vec<bool>| {
|
||||
let open = junction(x, y);
|
||||
let mut pts = if open { vec![[x as f32, y as f32]] } else { Vec::new() };
|
||||
let (mut cx, mut cy, mut d) = (x, y, first);
|
||||
let mut side = (OUT, OUT);
|
||||
let mut closed = !open;
|
||||
while let Some((id, l, r, to)) = boundary(cx, cy, d) {
|
||||
if visited[id] {
|
||||
break;
|
||||
}
|
||||
visited[id] = true;
|
||||
if pts.len() <= usize::from(open) {
|
||||
side = (at(l.0, l.1), at(r.0, r.1));
|
||||
}
|
||||
pts.push(crack_point(l, r));
|
||||
(cx, cy) = to;
|
||||
if junction(cx, cy) {
|
||||
pts.push([cx as f32, cy as f32]);
|
||||
closed = false;
|
||||
break;
|
||||
}
|
||||
let back = match d {
|
||||
Dir::E => Dir::W,
|
||||
Dir::W => Dir::E,
|
||||
Dir::S => Dir::N,
|
||||
Dir::N => Dir::S,
|
||||
};
|
||||
let Some(next) = [Dir::E, Dir::S, Dir::W, Dir::N].into_iter().find(|&nd| nd != back && boundary(cx, cy, nd).is_some_and(|b| !visited[b.0])) else {
|
||||
break;
|
||||
};
|
||||
d = next;
|
||||
}
|
||||
if pts.len() >= 2 {
|
||||
chains.push((pts, closed));
|
||||
sides.push(side);
|
||||
}
|
||||
};
|
||||
for y in 0..=hi {
|
||||
for x in 0..=wi {
|
||||
if junction(x, y) {
|
||||
for d in [Dir::E, Dir::S, Dir::W, Dir::N] {
|
||||
if boundary(x, y, d).is_some_and(|b| !visited[b.0]) {
|
||||
walk(x, y, d, &mut visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// What is left are closed loops with no junction (an island inside one other region).
|
||||
for y in 0..=hi {
|
||||
for x in 0..=wi {
|
||||
for d in [Dir::E, Dir::S] {
|
||||
if boundary(x, y, d).is_some_and(|b| !visited[b.0]) {
|
||||
walk(x, y, d, &mut visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(chains, sides)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Regions drawn as digits, one per pixel, with grey `colours` (region `k` is `k / 4`).
|
||||
fn regions(rows: &[&str]) -> (Vec<u32>, Vec<Px>, usize, usize) {
|
||||
let reg: Vec<u32> = rows.iter().flat_map(|r| r.bytes()).map(|b| u32::from(b - b'0')).collect();
|
||||
let n = reg.iter().max().map_or(0, |m| m + 1);
|
||||
(reg, (0..n).map(|k| [k as f32 / 4.0; 3]).collect(), rows[0].len(), rows.len())
|
||||
}
|
||||
|
||||
/// The chain whose sides are regions `a` and `b`, either way round.
|
||||
fn between(chains: &[Chain], sides: &[(u32, u32)], a: u32, b: u32) -> Vec<Chain> {
|
||||
chains.iter().zip(sides).filter(|(_, s)| **s == (a, b) || **s == (b, a)).map(|(c, _)| c.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_edge_between_two_regions_is_one_chain_between_junctions() {
|
||||
let (reg, colours, w, h) = regions(&["000111", "000111", "000111", "000111"]);
|
||||
let c: Vec<Px> = reg.iter().map(|&r| colours[r as usize]).collect();
|
||||
let (chains, sides) = follow_edges(®, &colours, &c, w, h);
|
||||
let inner = between(&chains, &sides, 0, 1);
|
||||
assert_eq!(inner.len(), 1, "{chains:?}");
|
||||
let (pts, closed) = &inner[0];
|
||||
assert!(!closed);
|
||||
// From junction to junction (where it meets the canvas edge), one point per crack.
|
||||
let ends = [pts[0], pts[pts.len() - 1]];
|
||||
assert!(ends.contains(&[3.0, 0.0]) && ends.contains(&[3.0, 4.0]), "{pts:?}");
|
||||
assert_eq!(pts.len(), 2 + 4);
|
||||
assert!(pts.iter().all(|p| p[0] == 3.0), "flat colours: the edge is on the crack: {pts:?}");
|
||||
// Every crack between two regions, the canvas edge included, is in exactly one chain.
|
||||
let cracks: usize = chains.iter().map(|(p, closed)| p.len() - if *closed { 0 } else { 2 }).sum();
|
||||
assert_eq!(cracks, 4 + 2 * (w + h));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_island_is_one_closed_chain() {
|
||||
let (reg, colours, w, h) = regions(&["00000", "01100", "01100", "00000"]);
|
||||
let c: Vec<Px> = reg.iter().map(|&r| colours[r as usize]).collect();
|
||||
let (chains, sides) = follow_edges(®, &colours, &c, w, h);
|
||||
let island = between(&chains, &sides, 0, 1);
|
||||
assert_eq!(island.len(), 1);
|
||||
assert!(island[0].1, "closed");
|
||||
assert_eq!(island[0].0.len(), 8, "one point per crack around the 2x2 island");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_anti_aliased_edge_is_placed_inside_its_pixel() {
|
||||
// Region 0 is black and region 1 white; the last column of region 0 is half white, so
|
||||
// the edge runs down the middle of that column.
|
||||
let (reg, _, w, h) = regions(&["000111", "000111", "000111"]);
|
||||
let colours = vec![[0.0; 3], [1.0; 3]];
|
||||
let c: Vec<Px> = reg.iter().enumerate().map(|(i, &r)| if i % w == 2 { [0.5; 3] } else { colours[r as usize] }).collect();
|
||||
let (chains, sides) = follow_edges(®, &colours, &c, w, h);
|
||||
let inner = between(&chains, &sides, 0, 1);
|
||||
let pts = &inner[0].0;
|
||||
assert!(pts[1..pts.len() - 1].iter().all(|p| (p[0] - 2.5).abs() < 1e-5), "{pts:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Fill: exact-area scan conversion of the traced shapes, so edges are anti-aliased and shapes
|
||||
//! that share an edge sum to full coverage.
|
||||
|
||||
use super::Pt;
|
||||
|
||||
/// Accumulates the signed area of segment `a`–`b` into `acc` (rows of `stride` cells, `h` rows
|
||||
/// visible, columns `0..stride - 2` visible): the part left of the tile is drawn down its left
|
||||
/// edge (it still winds the pixels to its right), the part right of it is dropped.
|
||||
pub(super) fn line_clipped(acc: &mut [f32], stride: usize, h: usize, a: Pt, b: Pt) {
|
||||
if !(a[0].is_finite() && a[1].is_finite() && b[0].is_finite() && b[1].is_finite()) || a[1] == b[1] {
|
||||
return;
|
||||
}
|
||||
let wf = (stride - 2) as f32;
|
||||
// At most the two ends and two crossings; on the stack, as this runs for every segment drawn.
|
||||
let mut buf = [0.0f32, 1.0, 0.0, 0.0];
|
||||
let mut n = 2;
|
||||
for x in [0.0, wf] {
|
||||
let t = (x - a[0]) / (b[0] - a[0]);
|
||||
if t > 0.0 && t < 1.0 {
|
||||
buf[n] = t;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
let cuts = &mut buf[..n];
|
||||
cuts.sort_by(f32::total_cmp);
|
||||
for t in cuts.windows(2) {
|
||||
let p = [a[0] + (b[0] - a[0]) * t[0], a[1] + (b[1] - a[1]) * t[0]];
|
||||
let q = [a[0] + (b[0] - a[0]) * t[1], a[1] + (b[1] - a[1]) * t[1]];
|
||||
let mx = (p[0] + q[0]) / 2.0;
|
||||
if mx >= wf {
|
||||
continue;
|
||||
}
|
||||
if mx < 0.0 {
|
||||
line(acc, stride, h, [0.0, p[1]], [0.0, q[1]]);
|
||||
} else {
|
||||
line(acc, stride, h, [p[0].clamp(0.0, wf), p[1]], [q[0].clamp(0.0, wf), q[1]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Signed-area accumulation of one segment inside the tile (the scanline method of font
|
||||
/// rasterisers): after a running sum along each row, a cell holds the coverage winding.
|
||||
fn line(acc: &mut [f32], stride: usize, h: usize, p0: Pt, p1: Pt) {
|
||||
if p0[1] == p1[1] {
|
||||
return;
|
||||
}
|
||||
let (dir, p0, p1) = if p0[1] < p1[1] { (1.0f32, p0, p1) } else { (-1.0, p1, p0) };
|
||||
let dxdy = (p1[0] - p0[0]) / (p1[1] - p0[1]);
|
||||
let mut x = p0[0];
|
||||
let ystart = p0[1].max(0.0);
|
||||
if p0[1] < 0.0 {
|
||||
x -= p0[1] * dxdy;
|
||||
}
|
||||
let yend = p1[1].min(h as f32);
|
||||
if ystart >= yend {
|
||||
return;
|
||||
}
|
||||
let max_x = (stride - 1) as f32;
|
||||
for y in (ystart as usize)..(yend.ceil() as usize).min(h) {
|
||||
let ls = y * stride;
|
||||
let dy = ((y + 1) as f32).min(p1[1]) - (y as f32).max(p0[1]);
|
||||
let xnext = x + dxdy * dy;
|
||||
let d = dy * dir;
|
||||
let (x0, x1) = if x < xnext { (x, xnext) } else { (xnext, x) };
|
||||
let (x0, x1) = (x0.clamp(0.0, max_x - 1.0), x1.clamp(0.0, max_x - 1.0));
|
||||
let x0f = x0.floor();
|
||||
let x0i = x0f as usize;
|
||||
let x1c = x1.ceil();
|
||||
let x1i = x1c as usize;
|
||||
if x1i <= x0i + 1 {
|
||||
let xmf = 0.5 * (x0 + x1) - x0f;
|
||||
acc[ls + x0i] += d - d * xmf;
|
||||
acc[ls + x0i + 1] += d * xmf;
|
||||
} else {
|
||||
let sx = (x1 - x0).recip();
|
||||
let x0r = x0 - x0f;
|
||||
let a0 = 0.5 * sx * (1.0 - x0r) * (1.0 - x0r);
|
||||
let x1r = x1 - x1c + 1.0;
|
||||
let am = 0.5 * sx * x1r * x1r;
|
||||
acc[ls + x0i] += d * a0;
|
||||
if x1i == x0i + 2 {
|
||||
acc[ls + x0i + 1] += d * (1.0 - a0 - am);
|
||||
} else {
|
||||
let a1 = sx * (1.5 - x0r);
|
||||
acc[ls + x0i + 1] += d * (a1 - a0);
|
||||
for xi in x0i + 2..x1i - 1 {
|
||||
acc[ls + xi] += d * sx;
|
||||
}
|
||||
let a2 = a1 + (x1i - x0i - 3) as f32 * sx;
|
||||
acc[ls + x1i - 1] += d * (1.0 - a2 - am);
|
||||
}
|
||||
acc[ls + x1i] += d * am;
|
||||
}
|
||||
x = xnext;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_square_covers_its_area_exactly() {
|
||||
let mut acc = vec![0.0f32; 12 * 10];
|
||||
let sq = [[2.25, 2.5], [7.75, 2.5], [7.75, 6.5], [2.25, 6.5], [2.25, 2.5]];
|
||||
for s in sq.windows(2) {
|
||||
line_clipped(&mut acc, 12, 10, s[0], s[1]);
|
||||
}
|
||||
let mut total = 0.0;
|
||||
for y in 0..10 {
|
||||
let mut sum = 0.0;
|
||||
for x in 0..10 {
|
||||
sum += acc[y * 12 + x];
|
||||
total += sum.abs().min(1.0);
|
||||
}
|
||||
}
|
||||
assert!((total - 5.5 * 4.0).abs() < 1e-4, "{total}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
//! Cutout (Filter Gallery › Artistic): the image as flat sheets of cut paper.
|
||||
//!
|
||||
//! What it does follows the reference application, measured on grey ramps, hue sweeps, squares of
|
||||
//! known size and a portrait:
|
||||
//!
|
||||
//! 1. **Tone bands**: each pixel's band is the plain mean of its three channels, cut into Number of
|
||||
//! Levels equal steps (four levels: below 64, 128, 192 and the rest, on 0–255). Colours of
|
||||
//! different hue but the same mean share a band.
|
||||
//! 2. **Regions**: 4-connected areas of one band. Regions smaller than the Edge Simplicity area
|
||||
//! merge, smallest first, into the neighbour they share most border with. The area depends on
|
||||
//! the slider alone, not on the image size. Each region is filled with the mean colour of its own
|
||||
//! pixels, so a region mixing hues comes out as their average.
|
||||
//! 3. **Edges**: the cracks between regions are linked into chains from junction to junction
|
||||
//! (where three regions meet, or the canvas edge). Each crack is moved along its normal to where
|
||||
//! the pixels on either side say the edge is (an anti-aliased pixel is part one colour, part the
|
||||
//! other). A chain is fitted once, so two neighbouring shapes share one and the same edge.
|
||||
//! 4. **Outlines**: straight-sided polygons. Sharp bends stay corners; the runs between are
|
||||
//! simplified (Douglas–Peucker) to a tolerance that grows with Edge Simplicity and shrinks with
|
||||
//! Edge Fidelity.
|
||||
//! 5. **Fill**: each region is scan-converted with exact area coverage, so edges are anti-aliased
|
||||
//! and the coverages of shapes that share an edge sum to one: no seams.
|
||||
//!
|
||||
//! Tracing reads the whole image and runs once; filling runs per tile and gives the same pixels
|
||||
//! whatever the tiling.
|
||||
//!
|
||||
//! Each stage is its own module: [`regions`] (1–2), [`edges`] (3), [`curves`] (4) and [`fill`]
|
||||
//! (5); [`trace`] runs them in turn.
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use crate::par;
|
||||
|
||||
mod curves;
|
||||
mod edges;
|
||||
mod fill;
|
||||
mod regions;
|
||||
mod util;
|
||||
|
||||
use curves::{bounds, fit};
|
||||
use edges::follow_edges;
|
||||
use fill::line_clipped;
|
||||
use regions::{components, merge_regions};
|
||||
|
||||
type Px = [f32; 3];
|
||||
type Pt = [f32; 2];
|
||||
|
||||
/// The tone band of a pixel: the mean of its three channels in `levels` equal steps.
|
||||
fn band(p: Px, levels: usize) -> u8 {
|
||||
let v = (p[0] + p[1] + p[2]) / 3.0;
|
||||
let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
((v * levels as f32) as usize).min(levels - 1) as u8
|
||||
}
|
||||
|
||||
/// Region id of the area outside the image.
|
||||
const OUT: u32 = u32::MAX;
|
||||
|
||||
/// The filter's three sliders.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Settings {
|
||||
/// Number of Levels (2–8): how many tone bands.
|
||||
pub levels: usize,
|
||||
/// Edge Simplicity (0–10): smaller shapes merge and outlines simplify more.
|
||||
pub simplicity: f32,
|
||||
/// Edge Fidelity (1–3): more corners are kept.
|
||||
pub fidelity: f32,
|
||||
}
|
||||
|
||||
/// Linear interpolation in a per-step table over Edge Simplicity 0–10.
|
||||
fn by_simplicity(table: &[f32; 11], simplicity: f32) -> f32 {
|
||||
let s = if simplicity.is_finite() { simplicity.clamp(0.0, 10.0) } else { 0.0 };
|
||||
let i = (s.floor() as usize).min(9);
|
||||
let t = s - i as f32;
|
||||
table[i] + (table[i + 1] - table[i]) * t
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Regions with fewer pixels merge into a neighbour. Measured in the reference application
|
||||
/// with squares of 2–96 px: the smallest that survive are 6 px wide at Simplicity 0–2, 12 px
|
||||
/// at 4, 48 px at 6 and 96 px at 7, the same on a 256 px and a 600 px canvas.
|
||||
fn min_area(&self) -> u32 {
|
||||
const AREA: [f32; 11] = [25.0, 25.0, 25.0, 50.0, 100.0, 400.0, 1500.0, 5000.0, 10000.0, 16000.0, 30000.0];
|
||||
by_simplicity(&AREA, self.simplicity).round() as u32
|
||||
}
|
||||
/// Douglas–Peucker tolerance of the straight outlines, in pixels: coarser with more Edge
|
||||
/// Simplicity, finer with more Edge Fidelity.
|
||||
fn tolerance(&self) -> f32 {
|
||||
const TOL: [f32; 11] = [0.5, 0.5, 0.6, 0.8, 1.0, 1.5, 2.5, 4.0, 7.0, 12.0, 20.0];
|
||||
by_simplicity(&TOL, self.simplicity) * 2f32.powf(2.0 - self.fidelity.clamp(1.0, 3.0))
|
||||
}
|
||||
/// Below Edge Fidelity 2 the outline is rounded off before it is fitted: points averaged with
|
||||
/// this many neighbours either side (3 at fidelity 1).
|
||||
fn rounding(&self) -> usize {
|
||||
((2.0 - self.fidelity.clamp(1.0, 3.0)) * 3.0).round().max(0.0) as usize
|
||||
}
|
||||
/// Turns sharper than this stay corners.
|
||||
fn corner_turn(&self) -> f32 {
|
||||
(100.0 - 22.5 * self.fidelity.clamp(1.0, 3.0)).to_radians()
|
||||
}
|
||||
}
|
||||
|
||||
/// A traced image: the shapes and their colours, ready to fill any tile.
|
||||
pub(crate) struct Traced {
|
||||
/// Document position of the traced window's top-left pixel; outlines are relative to it.
|
||||
x0: i32,
|
||||
y0: i32,
|
||||
/// Fitted edges (polylines), each shared by the one or two regions it separates.
|
||||
chains: Vec<Vec<Pt>>,
|
||||
/// Per chain, its bounding box `[x0, y0, x1, y1]`.
|
||||
chain_boxes: Vec<[f32; 4]>,
|
||||
regions: Vec<Region>,
|
||||
}
|
||||
|
||||
/// One shape: its flat colour, bounding box and outline.
|
||||
struct Region {
|
||||
colour: Px,
|
||||
bbox: [f32; 4],
|
||||
/// The chains around the region, reversed where the region is on their right.
|
||||
edges: Vec<(u32, bool)>,
|
||||
}
|
||||
|
||||
/// Traces `c` (sRGB colour of every pixel of `r`, row-major).
|
||||
pub(crate) fn trace(c: &[Px], r: Rect, s: &Settings) -> Traced {
|
||||
let (w, h) = (r.width() as usize, r.height() as usize);
|
||||
let empty = Traced { x0: r.x0, y0: r.y0, chains: Vec::new(), chain_boxes: Vec::new(), regions: Vec::new() };
|
||||
if w == 0 || h == 0 || c.len() != w * h {
|
||||
return empty;
|
||||
}
|
||||
// A non-finite sample (32-bit documents can hold them) would make its region's mean colour,
|
||||
// and so its edges and fill, non-finite: count it as 0 from here on.
|
||||
let clean = |v: f32| if v.is_finite() { v } else { 0.0 };
|
||||
let owned: Vec<Px>;
|
||||
let c: &[Px] = if c.iter().flatten().all(|v| v.is_finite()) {
|
||||
c
|
||||
} else {
|
||||
owned = c.iter().map(|p| p.map(clean)).collect();
|
||||
&owned
|
||||
};
|
||||
let levels = s.levels.clamp(2, 8);
|
||||
let mut labels = vec![0u8; w * h];
|
||||
par::chunks_mut(&mut labels, w, true, |y, row| {
|
||||
for (o, p) in row.iter_mut().zip(&c[y * w..(y + 1) * w]) {
|
||||
*o = band(*p, levels);
|
||||
}
|
||||
});
|
||||
let (comp, n) = components(&labels, w, h);
|
||||
let (reg, colours) = merge_regions(&comp, n, c, w, h, s);
|
||||
let (chains, sides) = follow_edges(®, &colours, c, w, h);
|
||||
let chains: Vec<Vec<Pt>> = par::map(chains.len(), true, |i| fit(&chains[i].0, chains[i].1, s));
|
||||
let chain_boxes: Vec<[f32; 4]> = chains.iter().map(|ch| bounds(ch)).collect();
|
||||
let mut regions: Vec<Region> =
|
||||
colours.into_iter().map(|colour| Region { colour, bbox: [f32::MAX, f32::MAX, f32::MIN, f32::MIN], edges: Vec::new() }).collect();
|
||||
for (k, &(left, right)) in sides.iter().enumerate() {
|
||||
for (side, rev) in [(left, false), (right, true)] {
|
||||
if let Some(g) = regions.get_mut(side as usize) {
|
||||
g.edges.push((k as u32, rev));
|
||||
let b = chain_boxes[k];
|
||||
g.bbox = [g.bbox[0].min(b[0]), g.bbox[1].min(b[1]), g.bbox[2].max(b[2]), g.bbox[3].max(b[3])];
|
||||
}
|
||||
}
|
||||
}
|
||||
Traced { x0: r.x0, y0: r.y0, chains, chain_boxes, regions }
|
||||
}
|
||||
|
||||
impl Traced {
|
||||
/// The cut paper over `r` (document coordinates): sRGB colour per pixel, row-major.
|
||||
pub(crate) fn render(&self, r: Rect) -> Vec<Px> {
|
||||
let (w, h) = (r.width() as usize, r.height() as usize);
|
||||
let mut col = vec![[0.0f32; 3]; w * h];
|
||||
if w == 0 || h == 0 {
|
||||
return col;
|
||||
}
|
||||
let mut weight = vec![0.0f32; w * h];
|
||||
let stride = w + 2;
|
||||
let mut acc = vec![0.0f32; stride * h];
|
||||
let (ox, oy) = ((self.x0 - r.x0) as f32, (self.y0 - r.y0) as f32);
|
||||
let (wf, hf) = (w as f32, h as f32);
|
||||
for g in &self.regions {
|
||||
let b = [g.bbox[0] + ox, g.bbox[1] + oy, g.bbox[2] + ox, g.bbox[3] + oy];
|
||||
if b[2] <= 0.0 || b[0] >= wf || b[3] <= 0.0 || b[1] >= hf {
|
||||
continue;
|
||||
}
|
||||
for &(k, rev) in &g.edges {
|
||||
let (Some(pts), Some(cb)) = (self.chains.get(k as usize), self.chain_boxes.get(k as usize)) else { continue };
|
||||
// Chains left of the tile still count (they wind the rows they cross); chains to
|
||||
// its right or above or below it don't.
|
||||
if cb[0] + ox >= wf || cb[3] + oy <= 0.0 || cb[1] + oy >= hf {
|
||||
continue;
|
||||
}
|
||||
for seg in pts.windows(2) {
|
||||
let (a, z) = if rev { (seg[1], seg[0]) } else { (seg[0], seg[1]) };
|
||||
line_clipped(&mut acc, stride, h, [a[0] + ox, a[1] + oy], [z[0] + ox, z[1] + oy]);
|
||||
}
|
||||
}
|
||||
let y0 = b[1].floor().max(0.0) as usize;
|
||||
let y1 = (b[3].ceil().max(0.0) as usize).min(h);
|
||||
let xe = ((b[2].ceil().max(0.0) as usize) + 1).min(w);
|
||||
for y in y0..y1 {
|
||||
let row = &mut acc[y * stride..(y + 1) * stride];
|
||||
let mut sum = 0.0f32;
|
||||
for (x, v) in row.iter().enumerate().take(xe) {
|
||||
sum += v;
|
||||
let cov = sum.abs().min(1.0);
|
||||
if cov > 0.0 {
|
||||
let i = y * w + x;
|
||||
let paint = g.colour;
|
||||
col[i] = [col[i][0] + paint[0] * cov, col[i][1] + paint[1] * cov, col[i][2] + paint[2] * cov];
|
||||
weight[i] += cov;
|
||||
}
|
||||
}
|
||||
row.fill(0.0);
|
||||
}
|
||||
}
|
||||
for (p, &wt) in col.iter_mut().zip(&weight) {
|
||||
if wt > 1e-6 {
|
||||
*p = [p[0] / wt, p[1] / wt, p[2] / wt];
|
||||
}
|
||||
}
|
||||
col
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dist2(a: Px, b: Px) -> f32 {
|
||||
(a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)
|
||||
}
|
||||
|
||||
fn settings(levels: usize, simplicity: f32, fidelity: f32) -> Settings {
|
||||
Settings { levels, simplicity, fidelity }
|
||||
}
|
||||
|
||||
/// Total coverage of every pixel: each pixel is covered exactly once by the shapes.
|
||||
fn coverage(t: &Traced, r: Rect) -> Vec<f32> {
|
||||
let (w, h) = (r.width() as usize, r.height() as usize);
|
||||
let mut weight = vec![0.0f32; w * h];
|
||||
let stride = w + 2;
|
||||
let mut acc = vec![0.0f32; stride * h];
|
||||
for g in &t.regions {
|
||||
for &(k, rev) in &g.edges {
|
||||
for seg in t.chains[k as usize].windows(2) {
|
||||
let (a, z) = if rev { (seg[1], seg[0]) } else { (seg[0], seg[1]) };
|
||||
line_clipped(&mut acc, stride, h, a, z);
|
||||
}
|
||||
}
|
||||
for y in 0..h {
|
||||
let mut sum = 0.0;
|
||||
for x in 0..w {
|
||||
sum += acc[y * stride + x];
|
||||
weight[y * w + x] += sum.abs().min(1.0);
|
||||
}
|
||||
acc[y * stride..(y + 1) * stride].fill(0.0);
|
||||
}
|
||||
}
|
||||
weight
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_cover_every_pixel_once_with_no_seams() {
|
||||
// Blobs of four colours touching each other and the canvas edge, with T-junctions,
|
||||
// saddles and holes.
|
||||
let (w, h) = (48usize, 36usize);
|
||||
let pal = [[0.1, 0.1, 0.1], [0.9, 0.2, 0.2], [0.2, 0.7, 0.3], [0.95, 0.95, 0.9]];
|
||||
let c: Vec<Px> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as f32, (i / w) as f32);
|
||||
let k = ((x / 7.0).sin() + (y / 5.0).cos() + (x * y / 90.0).sin()).abs() * 1.6;
|
||||
pal[(k as usize).min(3)]
|
||||
})
|
||||
.collect();
|
||||
let r = Rect::new(0, 0, w as i32, h as i32);
|
||||
for fidelity in [1.0, 3.0] {
|
||||
let t = trace(&c, r, &settings(4, 2.0, fidelity));
|
||||
assert!(t.regions.len() > 4);
|
||||
let cov = coverage(&t, r);
|
||||
let worst = cov.iter().map(|v| (v - 1.0).abs()).fold(0.0, f32::max);
|
||||
assert!(worst < 1e-3, "coverage off by {worst}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_fidelity_changes_the_outlines_a_little() {
|
||||
// In the reference application Edge Fidelity nudges the outlines: on a portrait 1.9% of
|
||||
// pixels change from fidelity 1 to 2 and 1.2% from 2 to 3. Large smooth shapes here.
|
||||
let (w, h) = (160usize, 120usize);
|
||||
let pal = [[0.1, 0.1, 0.1], [0.9, 0.3, 0.2], [0.3, 0.6, 0.9]];
|
||||
let c: Vec<Px> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as f32, (i / w) as f32);
|
||||
let k = ((x / 17.0).sin() * 1.3 + (y / 13.0).cos() + (x * y / 900.0).sin()).abs();
|
||||
pal[(k as usize).min(2)]
|
||||
})
|
||||
.collect();
|
||||
let r = Rect::new(0, 0, w as i32, h as i32);
|
||||
let out = |f: f32| trace(&c, r, &settings(3, 2.0, f)).render(r);
|
||||
let changed = |a: &[Px], b: &[Px]| a.iter().zip(b).filter(|(p, q)| dist2(**p, **q) > 0.01).count() as f32 / a.len() as f32;
|
||||
let (loose, normal, tight) = (out(1.0), out(2.0), out(3.0));
|
||||
for (name, a, b) in [("1 vs 2", &loose, &normal), ("2 vs 3", &normal, &tight)] {
|
||||
let f = changed(a, b);
|
||||
assert!(f > 0.002 && f < 0.06, "fidelity {name}: {f}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_images_stop_before_the_transpose_and_row_passes() {
|
||||
for r in [Rect::new(0, 0, 0, 4), Rect::new(0, 0, 4, 0), Rect::new(0, 0, 0, 0)] {
|
||||
let t = trace(&[], r, &settings(4, 2.0, 1.0));
|
||||
assert!(t.regions.is_empty());
|
||||
assert!(t.chains.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filling_does_not_depend_on_the_tiling() {
|
||||
let (w, h) = (40usize, 30usize);
|
||||
let c: Vec<Px> = (0..w * h).map(|i| if ((i % w) / 6 + (i / w) / 5) % 3 == 0 { [0.8, 0.3, 0.1] } else { [0.1, 0.3, 0.7] }).collect();
|
||||
let r = Rect::new(10, 20, 10 + w as i32, 20 + h as i32);
|
||||
let t = trace(&c, r, &settings(3, 1.0, 2.0));
|
||||
let whole = t.render(r);
|
||||
for tile in [Rect::new(10, 20, 23, 31), Rect::new(23, 31, 50, 50), Rect::new(37, 20, 50, 33)] {
|
||||
let part = t.render(tile);
|
||||
for y in tile.y0..tile.y1 {
|
||||
for x in tile.x0..tile.x1 {
|
||||
let a = whole[((y - r.y0) as usize) * w + (x - r.x0) as usize];
|
||||
let b = part[((y - tile.y0) as usize) * tile.width() as usize + (x - tile.x0) as usize];
|
||||
assert!(dist2(a, b) < 1e-8, "({x}, {y}): {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A NaN or infinite sample must not reach a region's colour, its edges or the fill.
|
||||
#[test]
|
||||
fn non_finite_samples_give_finite_covered_output() {
|
||||
let (w, h) = (24usize, 16usize);
|
||||
let mut c: Vec<Px> = (0..w * h).map(|i| if i % w < 12 { [0.1, 0.1, 0.1] } else { [0.9, 0.8, 0.7] }).collect();
|
||||
c[3 * w + 4] = [f32::NAN, 0.5, 0.5];
|
||||
c[8 * w + 15] = [f32::INFINITY, f32::NEG_INFINITY, 0.2];
|
||||
c[12 * w + 12] = [f32::NAN; 3];
|
||||
let r = Rect::new(0, 0, w as i32, h as i32);
|
||||
for simplicity in [0.0, 4.0] {
|
||||
let t = trace(&c, r, &settings(4, simplicity, 2.0));
|
||||
assert!(t.regions.iter().all(|g| g.colour.iter().all(|v| v.is_finite())), "region colours finite");
|
||||
assert!(t.chains.iter().flatten().flatten().all(|v| v.is_finite()), "outlines finite");
|
||||
let out = t.render(r);
|
||||
assert!(out.iter().flatten().all(|v| v.is_finite()), "pixels finite");
|
||||
let worst = coverage(&t, r).iter().map(|v| (v - 1.0).abs()).fold(0.0, f32::max);
|
||||
assert!(worst < 1e-3, "coverage off by {worst}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_merges_into_neighbours_and_a_six_pixel_square_survives_at_simplicity_0() {
|
||||
let (w, h) = (32usize, 32usize);
|
||||
let c: Vec<Px> = (0..w * h)
|
||||
.map(|i| {
|
||||
// Noise inside one band (1/6 to 2/6 at six levels).
|
||||
let v = 0.2 + crate::photo_util::hash01((i % w) as i64, (i / w) as i64, 5) * 0.06;
|
||||
if (11..17).contains(&(i % w)) && (11..17).contains(&(i / w)) { [0.9, 0.9, 0.9] } else { [v, v, v] }
|
||||
})
|
||||
.collect();
|
||||
let r = Rect::new(0, 0, w as i32, h as i32);
|
||||
assert_eq!(trace(&c, r, &settings(6, 0.0, 2.0)).regions.len(), 2, "the speck stays at simplicity 0");
|
||||
assert_eq!(trace(&c, r, &settings(6, 10.0, 2.0)).regions.len(), 1, "the speck merges at simplicity 10");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Regions: connected areas of one tone band, and the merge of small ones into their neighbours.
|
||||
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BinaryHeap, HashMap};
|
||||
use std::hash::BuildHasherDefault;
|
||||
|
||||
use super::util::{FxHasher, find};
|
||||
use super::{Px, Settings};
|
||||
|
||||
/// 4-connected components of equal labels, numbered in raster order of their first pixel.
|
||||
pub(super) fn components(labels: &[u8], w: usize, h: usize) -> (Vec<u32>, usize) {
|
||||
let mut comp = vec![0u32; w * h];
|
||||
let mut parent: Vec<u32> = Vec::new();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
let up = (y > 0 && labels[i - w] == labels[i]).then(|| comp[i - w]);
|
||||
let left = (x > 0 && labels[i - 1] == labels[i]).then(|| comp[i - 1]);
|
||||
comp[i] = match (up, left) {
|
||||
(Some(a), Some(b)) => {
|
||||
let (ra, rb) = (find(&mut parent, a), find(&mut parent, b));
|
||||
let (lo, hi) = (ra.min(rb), ra.max(rb));
|
||||
parent[hi as usize] = lo;
|
||||
lo
|
||||
}
|
||||
(Some(a), None) | (None, Some(a)) => a,
|
||||
(None, None) => {
|
||||
parent.push(parent.len() as u32);
|
||||
parent.len() as u32 - 1
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
let mut index = vec![u32::MAX; parent.len()];
|
||||
let mut n = 0u32;
|
||||
for c in comp.iter_mut() {
|
||||
let root = find(&mut parent, *c) as usize;
|
||||
if index[root] == u32::MAX {
|
||||
index[root] = n;
|
||||
n += 1;
|
||||
}
|
||||
*c = index[root];
|
||||
}
|
||||
(comp, n as usize)
|
||||
}
|
||||
|
||||
/// Merges small and sliver components into their neighbours. Returns each pixel's region and
|
||||
/// each region's mean colour.
|
||||
pub(super) fn merge_regions(comp: &[u32], n: usize, c: &[Px], w: usize, h: usize, s: &Settings) -> (Vec<u32>, Vec<Px>) {
|
||||
let mut area = vec![0u32; n];
|
||||
let mut sum = vec![[0.0f64; 3]; n];
|
||||
for (&k, p) in comp.iter().zip(c) {
|
||||
let k = k as usize;
|
||||
area[k] += 1;
|
||||
sum[k] = [sum[k][0] + f64::from(p[0]), sum[k][1] + f64::from(p[1]), sum[k][2] + f64::from(p[2])];
|
||||
}
|
||||
// Shared border lengths, in cracks.
|
||||
let mut shared: HashMap<u64, u32, BuildHasherDefault<FxHasher>> = HashMap::default();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let a = comp[y * w + x];
|
||||
for (nx, ny) in [(x + 1, y), (x, y + 1)] {
|
||||
if nx < w && ny < h {
|
||||
let b = comp[ny * w + nx];
|
||||
if a != b {
|
||||
*shared.entry((u64::from(a.min(b)) << 32) | u64::from(a.max(b))).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut adj: Vec<Vec<(u32, u32)>> = vec![Vec::new(); n];
|
||||
let mut pairs: Vec<(u64, u32)> = shared.into_iter().collect();
|
||||
pairs.sort_unstable();
|
||||
for (key, len) in pairs {
|
||||
let (a, b) = ((key >> 32) as u32, key as u32);
|
||||
adj[a as usize].push((b, len));
|
||||
adj[b as usize].push((a, len));
|
||||
}
|
||||
let min_area = s.min_area();
|
||||
let mergeable = |a: u32| a < min_area;
|
||||
let mut parent: Vec<u32> = (0..n as u32).collect();
|
||||
let mut heap: BinaryHeap<Reverse<(u32, u32)>> = (0..n).filter(|&i| mergeable(area[i])).map(|i| Reverse((area[i], i as u32))).collect();
|
||||
while let Some(Reverse((a, id))) = heap.pop() {
|
||||
let i = id as usize;
|
||||
if find(&mut parent, id) != id || area[i] != a || !mergeable(a) {
|
||||
continue;
|
||||
}
|
||||
// The neighbours as they are now, with their total shared border.
|
||||
let mut nb: Vec<(u32, u32)> = std::mem::take(&mut adj[i]).into_iter().map(|(k, len)| (find(&mut parent, k), len)).filter(|&(k, _)| k != id).collect();
|
||||
nb.sort_unstable();
|
||||
nb.dedup_by(|b, a| {
|
||||
if a.0 == b.0 {
|
||||
a.1 += b.1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
let Some(&(t, _)) = nb.iter().max_by(|a, b| a.1.cmp(&b.1).then(b.0.cmp(&a.0))) else {
|
||||
adj[i] = nb;
|
||||
continue;
|
||||
};
|
||||
let ti = t as usize;
|
||||
parent[i] = t;
|
||||
area[ti] += area[i];
|
||||
sum[ti] = [sum[ti][0] + sum[i][0], sum[ti][1] + sum[i][1], sum[ti][2] + sum[i][2]];
|
||||
// Appended as they are; resolved and summed when `t` itself merges.
|
||||
adj[ti].extend(nb.into_iter().filter(|&(k, _)| k != t));
|
||||
if mergeable(area[ti]) {
|
||||
heap.push(Reverse((area[ti], t)));
|
||||
}
|
||||
}
|
||||
let mut index = vec![u32::MAX; n];
|
||||
let mut colours = Vec::new();
|
||||
let mut reg = vec![0u32; comp.len()];
|
||||
for (r, &k) in reg.iter_mut().zip(comp) {
|
||||
let root = find(&mut parent, k) as usize;
|
||||
if index[root] == u32::MAX {
|
||||
index[root] = colours.len() as u32;
|
||||
let a = f64::from(area[root].max(1));
|
||||
colours.push([(sum[root][0] / a) as f32, (sum[root][1] / a) as f32, (sum[root][2] / a) as f32]);
|
||||
}
|
||||
*r = index[root];
|
||||
}
|
||||
(reg, colours)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn settings(simplicity: f32) -> Settings {
|
||||
Settings { levels: 4, simplicity, fidelity: 2.0 }
|
||||
}
|
||||
|
||||
/// Labels drawn as text, one character per pixel.
|
||||
fn labels(rows: &[&str]) -> (Vec<u8>, usize, usize) {
|
||||
(rows.iter().flat_map(|r| r.bytes()).collect(), rows[0].len(), rows.len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_are_four_connected_and_numbered_in_raster_order() {
|
||||
let (l, w, h) = labels(&["aab", "abb", "bba"]);
|
||||
let (comp, n) = components(&l, w, h);
|
||||
// The top-left `a`s, the `b`s (one area), then the lone `a` (touching the first only
|
||||
// diagonally), in the order their first pixels come.
|
||||
assert_eq!(n, 3);
|
||||
assert_eq!(comp, vec![0, 0, 1, 0, 1, 1, 1, 1, 2]);
|
||||
// A U shape: both arms are found as separate runs first and joined further down.
|
||||
let (l, w, h) = labels(&["a.a", "a.a", "aaa"]);
|
||||
let (comp, n) = components(&l, w, h);
|
||||
assert_eq!(n, 2);
|
||||
assert_eq!(comp[0], comp[2]);
|
||||
}
|
||||
|
||||
/// Regions from labels, with one flat colour per label.
|
||||
fn merged(rows: &[&str], simplicity: f32) -> (Vec<u32>, Vec<Px>) {
|
||||
let (l, w, h) = labels(rows);
|
||||
let c: Vec<Px> = l.iter().map(|&b| [f32::from(b - b'a') / 4.0; 3]).collect();
|
||||
let (comp, n) = components(&l, w, h);
|
||||
merge_regions(&comp, n, &c, w, h, &settings(simplicity))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_regions_merge_into_the_neighbour_they_share_most_border_with() {
|
||||
let rows = ["aaaaaaaaaa", "aaaaaaaaaa", "aacaaaaaaa", "aaccbbbbbb", "aaaabbbbbb", "aaaabbbbbb", "aaaabbbbbb", "aaaabbbbbb"];
|
||||
// At Edge Simplicity 0 a region needs 25 pixels: the three `c`s go into the `a`s, which
|
||||
// they share six cracks with (one with the `b`s); the 30 `b`s stay.
|
||||
let (reg, colours) = merged(&rows, 0.0);
|
||||
assert_eq!(colours.len(), 2);
|
||||
assert_eq!(reg[2 * 10 + 2], reg[0]);
|
||||
assert_ne!(reg[3 * 10 + 4], reg[0]);
|
||||
// A region's colour is the mean of its pixels: 47 `a`s (0.0) and 3 `c`s (0.5).
|
||||
let a = colours[reg[0] as usize][0];
|
||||
assert!((a - 1.5 / 50.0).abs() < 1e-6, "{a}");
|
||||
// At 4 a region needs 100 pixels, so the `b`s merge too.
|
||||
assert_eq!(merged(&rows, 4.0).1.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Small helpers shared by the Cutout stages.
|
||||
|
||||
use std::hash::Hasher;
|
||||
|
||||
/// Union-find root of `i`, halving the path on the way.
|
||||
pub(super) fn find(parent: &mut [u32], mut i: u32) -> u32 {
|
||||
while parent[i as usize] != i {
|
||||
let p = parent[i as usize];
|
||||
parent[i as usize] = parent[p as usize];
|
||||
i = p;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// A fast deterministic hasher for the region adjacency map.
|
||||
#[derive(Default)]
|
||||
pub(super) struct FxHasher(u64);
|
||||
|
||||
impl Hasher for FxHasher {
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
for &b in bytes {
|
||||
self.write_u64(u64::from(b));
|
||||
}
|
||||
}
|
||||
fn write_u64(&mut self, v: u64) {
|
||||
self.0 = (self.0.rotate_left(5) ^ v).wrapping_mul(0x517c_c1b7_2722_0a95);
|
||||
}
|
||||
fn finish(&self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn find_returns_the_root_and_shortens_the_path() {
|
||||
// 4 -> 3 -> 2 -> 1 -> 0.
|
||||
let mut parent = vec![0, 0, 1, 2, 3];
|
||||
assert_eq!(find(&mut parent, 4), 0);
|
||||
assert_eq!(find(&mut parent, 0), 0);
|
||||
assert!(parent[4] < 3, "the path was halved: {parent:?}");
|
||||
assert!(parent.iter().all(|&p| p <= 2));
|
||||
}
|
||||
}
|
||||
@@ -28,28 +28,57 @@ pub(crate) fn reach() -> f32 {
|
||||
(2 * MAX_LUMA_R + 2 * MAX_CHROMA_R + 6) as f32
|
||||
}
|
||||
|
||||
/// Guided filter of `p` with guide `g` (both `w × h`), box radius `r`.
|
||||
fn guided(g: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec<f32> {
|
||||
let len = w * h;
|
||||
// Interleave the four box-filtered moments to run one pass.
|
||||
let mut m = vec![0.0f32; len * 4];
|
||||
for i in 0..len {
|
||||
m[i * 4] = g[i];
|
||||
m[i * 4 + 1] = p[i];
|
||||
m[i * 4 + 2] = g[i] * g[i];
|
||||
m[i * 4 + 3] = g[i] * p[i];
|
||||
/// Shared first and second moments of a guide (two filtered channels).
|
||||
fn guide_moments(g: &[f32], w: usize, h: usize, r: usize) -> Vec<f32> {
|
||||
let mut moments = Vec::with_capacity(g.len() * 2);
|
||||
for &v in g {
|
||||
moments.extend_from_slice(&[v, v * v]);
|
||||
}
|
||||
box_blur_n(&mut m, w, h, 4, r);
|
||||
let mut ab = vec![0.0f32; len * 2];
|
||||
for i in 0..len {
|
||||
let (mg, mp, mgg, mgp) = (m[i * 4], m[i * 4 + 1], m[i * 4 + 2], m[i * 4 + 3]);
|
||||
let var = (mgg - mg * mg).max(0.0);
|
||||
let a = (mgp - mg * mp) / (var + eps);
|
||||
ab[i * 2] = a;
|
||||
ab[i * 2 + 1] = mp - a * mg;
|
||||
box_blur_n(&mut moments, w, h, 2, r);
|
||||
moments
|
||||
}
|
||||
|
||||
/// Self-guided luminance: mean(p) = mean(g), mean(g*p) = mean(g*g).
|
||||
fn guided_luma(g: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec<f32> {
|
||||
let mut ab = guide_moments(g, w, h, r);
|
||||
for pair in ab.as_chunks_mut::<2>().0.iter_mut() {
|
||||
let mean = pair[0];
|
||||
let covariance = pair[1] - mean * mean;
|
||||
// Keep the original unclamped numerator, including roundoff below zero.
|
||||
let a = covariance / (covariance.max(0.0) + eps);
|
||||
pair[0] = a;
|
||||
pair[1] = mean - a * mean;
|
||||
}
|
||||
box_blur_n(&mut ab, w, h, 2, r);
|
||||
(0..len).map(|i| ab[i * 2] * g[i] + ab[i * 2 + 1]).collect()
|
||||
g.iter().zip(ab.as_chunks::<2>().0.iter()).map(|(&v, pair)| pair[0] * v + pair[1]).collect()
|
||||
}
|
||||
|
||||
/// Chroma planes share the cleaned guide's mean and regularised variance.
|
||||
/// Two guide channels plus two moments and two coefficients per chroma plane:
|
||||
/// ten filtered channels, versus twelve for two independent guided filters.
|
||||
fn guided_chroma(g: &[f32], planes: [&mut [f32]; 2], w: usize, h: usize, r: usize, eps: f32, amount: f32) {
|
||||
let mut stats = guide_moments(g, w, h, r);
|
||||
for pair in stats.as_chunks_mut::<2>().0.iter_mut() {
|
||||
pair[1] = (pair[1] - pair[0] * pair[0]).max(0.0) + eps;
|
||||
}
|
||||
for plane in planes {
|
||||
let mut ab = Vec::with_capacity(g.len() * 2);
|
||||
for (&guide, &p) in g.iter().zip(plane.iter()) {
|
||||
ab.extend_from_slice(&[p, guide * p]);
|
||||
}
|
||||
box_blur_n(&mut ab, w, h, 2, r);
|
||||
for (pair, guide) in ab.as_chunks_mut::<2>().0.iter_mut().zip(stats.as_chunks::<2>().0.iter()) {
|
||||
let mean = pair[0];
|
||||
let a = (pair[1] - guide[0] * mean) / guide[1];
|
||||
pair[0] = a;
|
||||
pair[1] = mean - a * guide[0];
|
||||
}
|
||||
box_blur_n(&mut ab, w, h, 2, r);
|
||||
for ((v, &guide), pair) in plane.iter_mut().zip(g).zip(ab.as_chunks::<2>().0.iter()) {
|
||||
let filtered = pair[0] * guide + pair[1];
|
||||
*v += (filtered - *v) * amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec) -> Vec<f32> {
|
||||
@@ -125,7 +154,7 @@ pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec
|
||||
let r = (1.0 + spec.strength.clamp(0.0, 10.0) * 0.4).round() as usize;
|
||||
let keep = spec.preserve.clamp(0.0, 100.0) / 100.0;
|
||||
let eps = (0.004 + 0.05 * s).powi(2) * (1.05 - keep);
|
||||
let d = guided(&yv, &yv, ww, wh, r.min(MAX_LUMA_R), eps);
|
||||
let d = guided_luma(&yv, ww, wh, r.min(MAX_LUMA_R), eps);
|
||||
let k = s.sqrt();
|
||||
for (v, dv) in yv.iter_mut().zip(d) {
|
||||
*v += (dv - *v) * k;
|
||||
@@ -135,12 +164,7 @@ pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec
|
||||
if col > 0.0 && model != Model::Gray {
|
||||
let r = (1.0 + col * 8.0).round() as usize;
|
||||
let eps = (0.01 + 0.05 * col).powi(2);
|
||||
for plane in [&mut c1, &mut c2] {
|
||||
let d = guided(&yv, plane, ww, wh, r.min(MAX_CHROMA_R), eps);
|
||||
for (v, dv) in plane.iter_mut().zip(d) {
|
||||
*v += (dv - *v) * col;
|
||||
}
|
||||
}
|
||||
guided_chroma(&yv, [&mut c1, &mut c2], ww, wh, r.min(MAX_CHROMA_R), eps, col);
|
||||
}
|
||||
// Sharpen Details: unsharp mask on luma.
|
||||
if sh > 0.0 {
|
||||
@@ -178,3 +202,106 @@ pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Guided filter of `p` with guide `g` (both `w × h`), box radius `r`.
|
||||
fn reference_guided(g: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec<f32> {
|
||||
let len = w * h;
|
||||
// Interleave the four box-filtered moments to run one pass.
|
||||
let mut m = vec![0.0f32; len * 4];
|
||||
for i in 0..len {
|
||||
m[i * 4] = g[i];
|
||||
m[i * 4 + 1] = p[i];
|
||||
m[i * 4 + 2] = g[i] * g[i];
|
||||
m[i * 4 + 3] = g[i] * p[i];
|
||||
}
|
||||
box_blur_n(&mut m, w, h, 4, r);
|
||||
let mut ab = vec![0.0f32; len * 2];
|
||||
for i in 0..len {
|
||||
let (mg, mp, mgg, mgp) = (m[i * 4], m[i * 4 + 1], m[i * 4 + 2], m[i * 4 + 3]);
|
||||
let var = (mgg - mg * mg).max(0.0);
|
||||
let a = (mgp - mg * mp) / (var + eps);
|
||||
ab[i * 2] = a;
|
||||
ab[i * 2 + 1] = mp - a * mg;
|
||||
}
|
||||
box_blur_n(&mut ab, w, h, 2, r);
|
||||
(0..len).map(|i| ab[i * 2] * g[i] + ab[i * 2 + 1]).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_statistics_match_independent_filters_bit_for_bit() {
|
||||
for (w, h) in [(1, 1), (1, 19), (23, 1), (7, 5), (33, 17)] {
|
||||
let len = w * h;
|
||||
for flat in [false, true] {
|
||||
let g: Vec<f32> = (0..len).map(|i| if flat { 0.1 } else { ((i * 37 % 101) as f32 - 20.0) / 63.0 }).collect();
|
||||
let p: Vec<f32> = (0..len).map(|i| (i * 13 % 71) as f32 / 70.0 - 0.5).collect();
|
||||
let q: Vec<f32> = (0..len).map(|i| (i * 29 % 83) as f32 / 41.0 - 1.0).collect();
|
||||
for r in [0, 1, MAX_LUMA_R, MAX_CHROMA_R, 25] {
|
||||
for eps in [0.000001, 0.0036] {
|
||||
let expected = reference_guided(&g, &g, w, h, r, eps);
|
||||
let actual = guided_luma(&g, w, h, r, eps);
|
||||
assert_bits(&actual, &expected);
|
||||
for amount in [0.01, 0.45, 1.0] {
|
||||
let mut actual_p = p.clone();
|
||||
let mut actual_q = q.clone();
|
||||
guided_chroma(&g, [&mut actual_p, &mut actual_q], w, h, r, eps, amount);
|
||||
for (original, actual) in [(&p, &actual_p), (&q, &actual_q)] {
|
||||
let filtered = reference_guided(&g, original, w, h, r, eps);
|
||||
let expected: Vec<f32> = original.iter().zip(filtered).map(|(&v, d)| v + (d - v) * amount).collect();
|
||||
assert_bits(actual, &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduce_noise_guides_both_chroma_planes_by_the_cleaned_luma() {
|
||||
// Caller wiring: Lab keeps its planes as they are, so each output plane is compared with
|
||||
// the independent filters run on the planes in order (luma by itself, chroma by the
|
||||
// cleaned luma).
|
||||
let (w, h) = (19, 13);
|
||||
let rect = Rect::new(0, 0, w as i32, h as i32);
|
||||
let mut src = Image::new(rect, 3);
|
||||
for (i, px) in src.data.as_chunks_mut::<3>().0.iter_mut().enumerate() {
|
||||
px[0] = (i * 37 % 101) as f32 / 100.0;
|
||||
px[1] = (i * 13 % 71) as f32 / 70.0 - 0.5;
|
||||
px[2] = (i * 29 % 83) as f32 / 41.0 - 1.0;
|
||||
}
|
||||
let ctx = Ctx { bounds: rect, mode: ColorMode::Lab, alpha: false };
|
||||
let spec = DenoiseSpec { strength: 6.0, preserve: 30.0, color: 50.0, sharpen: 0.0, jpeg: false };
|
||||
let out = reduce_noise(&src, rect, &ctx, &spec);
|
||||
|
||||
let plane = |c: usize| -> Vec<f32> { src.data.as_chunks::<3>().0.iter().map(|px| px[c]).collect() };
|
||||
let (luma, chroma) = (plane(0), [plane(1), plane(2)]);
|
||||
let s = spec.strength / 10.0;
|
||||
let eps = (0.004 + 0.05 * s).powi(2) * (1.05 - spec.preserve / 100.0);
|
||||
let smooth = reference_guided(&luma, &luma, w, h, (1.0 + spec.strength * 0.4).round() as usize, eps);
|
||||
let cleaned: Vec<f32> = luma.iter().zip(&smooth).map(|(&v, &d)| v + (d - v) * s.sqrt()).collect();
|
||||
let col = spec.color / 100.0;
|
||||
let chroma_r = (1.0 + col * 8.0).round() as usize;
|
||||
let chroma_eps = (0.01 + 0.05 * col).powi(2);
|
||||
let mut expected = vec![cleaned];
|
||||
for p in &chroma {
|
||||
let filtered = reference_guided(&expected[0], p, w, h, chroma_r, chroma_eps);
|
||||
expected.push(p.iter().zip(filtered).map(|(&v, f)| v + (f - v) * col).collect());
|
||||
}
|
||||
for (c, expected) in expected.iter().enumerate() {
|
||||
let actual: Vec<f32> = out.as_chunks::<3>().0.iter().map(|px| px[c]).collect();
|
||||
assert_bits(&actual, expected);
|
||||
assert!(actual.iter().zip(&plane(c)).any(|(a, o)| (a - o).abs() > 1e-3), "plane {c} was filtered");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_bits(actual: &[f32], expected: &[f32]) {
|
||||
assert_eq!(actual.len(), expected.len());
|
||||
for (i, (actual, expected)) in actual.iter().zip(expected).enumerate() {
|
||||
assert_eq!(actual.to_bits(), expected.to_bits(), "pixel {i}: {actual} != {expected}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,29 +167,7 @@ fn complete_cow<'a>(
|
||||
}
|
||||
let r = p.patch_radius.max(1);
|
||||
let psz = 2 * r + 1;
|
||||
// Pyramid: shrink until the hole is about four patches across (a deeper pyramid decides the
|
||||
// structure where the texture has averaged out, and fills flat). A thin hole stops while it
|
||||
// is still about two patches thick: through it, not along it, is what counts (#1366).
|
||||
let mut levels = vec![Level { w, h, img, px: Cow::Borrowed(px) }];
|
||||
let mut boxes = vec![hb0];
|
||||
let mut ext = hb0.width().max(hb0.height());
|
||||
let thickness = hole_thickness(w, hb0, px);
|
||||
let thin = thickness.saturating_mul(THIN_RATIO) <= ext;
|
||||
if thin {
|
||||
ext = thickness;
|
||||
}
|
||||
let mut exts = vec![ext];
|
||||
while let Some(l) = levels.last() {
|
||||
ctl.check()?;
|
||||
if ext <= if thin { 2 * psz } else { 4 * psz } || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
|
||||
break;
|
||||
}
|
||||
let d = downsample(l, ch);
|
||||
levels.push(d);
|
||||
boxes.push(boxes[boxes.len() - 1].half());
|
||||
ext = ext.div_ceil(2);
|
||||
exts.push(ext);
|
||||
}
|
||||
let Pyramid { mut levels, boxes, exts, thin } = build_pyramid(Level { w, h, img, px: Cow::Borrowed(px) }, ch, hb0, psz, ctl)?;
|
||||
let nlev = levels.len();
|
||||
// The coarser level's result.
|
||||
let mut prev: Option<Coarser> = None;
|
||||
@@ -209,15 +187,7 @@ fn complete_cow<'a>(
|
||||
return Ok(Err(limg));
|
||||
}
|
||||
// The level's own pixels to fill (at level 0 the caller's, or a copy made in parallel).
|
||||
let mut limg = match limg {
|
||||
Cow::Owned(v) => v,
|
||||
Cow::Borrowed(b) => {
|
||||
const CHUNK: usize = 1 << 16;
|
||||
let mut v = vec![0.0f32; b.len()];
|
||||
par::chunks_mut(&mut v, CHUNK, true, |i, d| d.copy_from_slice(&b[i * CHUNK..i * CHUNK + d.len()]));
|
||||
v
|
||||
}
|
||||
};
|
||||
let mut limg = into_owned_pixels(limg);
|
||||
let targets = targets_around(lw, r, &lpx, hb, tb);
|
||||
ctl.check()?;
|
||||
// Initialise the hole: at the coarsest level peeled from its edge inwards (or a membrane
|
||||
@@ -225,40 +195,13 @@ fn complete_cow<'a>(
|
||||
// its scaled-up matches.
|
||||
let coarser = prev.take();
|
||||
match &coarser {
|
||||
Some(c) => {
|
||||
let (pw, ph) = (c.w, c.h);
|
||||
for y in hb.y0..hb.y1 {
|
||||
for x in hb.x0..hb.x1 {
|
||||
if lpx[y * lw + x] == Px::Hole {
|
||||
let (sx, sy) = ((x / 2).min(pw - 1), (y / 2).min(ph - 1));
|
||||
let (d, s) = ((y * lw + x) * ch, (sy * pw + sx) * ch);
|
||||
limg[d..d + ch].copy_from_slice(&c.img[s..s + ch]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(c) => copy_up_coarser(&mut limg, &lpx, lw, ch, hb, c),
|
||||
// A thin hole, when peeling it would take too long, takes the membrane below: a few
|
||||
// patches thick here, a membrane across it is its surroundings.
|
||||
None if sources.len() > 0 && (!thin || peel_affordable(lw, &lpx, hb, sources.len())) => {
|
||||
onion_peel(lw, lh, ch, ri, &mut limg, &lpx, hb, &sources.all(&valid, lw), ctl)?
|
||||
}
|
||||
None => {
|
||||
let mut unknown: Vec<bool> = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect();
|
||||
// Across a thin hole the membrane starts a patch radius out, past the
|
||||
// anti-aliased fringe or halo that a selection drawn close to the object leaves.
|
||||
if thin {
|
||||
let hole: Vec<bool> = lpx.iter().map(|k| *k == Px::Hole).collect();
|
||||
for (u, d) in unknown.iter_mut().zip(crate::selection::edt(&hole, lw, lh)) {
|
||||
*u |= d <= r as f32;
|
||||
}
|
||||
}
|
||||
let m = membrane_fill(lw, lh, ch, &limg, &unknown);
|
||||
for (i, k) in lpx.iter().enumerate() {
|
||||
if *k == Px::Hole {
|
||||
limg[i * ch..(i + 1) * ch].copy_from_slice(&m[i * ch..(i + 1) * ch]);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => fill_hole_with_membrane(&mut limg, &lpx, (lw, lh, ch), r, thin),
|
||||
}
|
||||
if sources.len() == 0 {
|
||||
prev = Some(Coarser { w: lw, h: lh, img: limg, field: Vec::new(), tbox: tb });
|
||||
@@ -290,36 +233,7 @@ fn complete_cow<'a>(
|
||||
// NNF init: the coarser level's matches scaled up where they land on a valid source,
|
||||
// random otherwise.
|
||||
let mut rng = Rng::new(p.seed ^ (li as u64) << 20);
|
||||
let tw = tb.width();
|
||||
let mut nnf = vec![(0i32, 0i32); tb.area()];
|
||||
let mut upsampled = false;
|
||||
for y in tb.y0..tb.y1 {
|
||||
if y % 32 == 0 {
|
||||
ctl.check()?;
|
||||
}
|
||||
for x in tb.x0..tb.x1 {
|
||||
let k = (y - tb.y0) * tw + (x - tb.x0);
|
||||
if !solver.targets[k] {
|
||||
continue;
|
||||
}
|
||||
let mut cand = None;
|
||||
if let Some(c) = &coarser
|
||||
&& !c.field.is_empty()
|
||||
{
|
||||
let (cx, cy) = ((x / 2).min(c.w - 1), (y / 2).min(c.h - 1));
|
||||
let (sx, sy) = c.field_at(cx, cy);
|
||||
if sx != i32::MIN {
|
||||
// The same displacement, twice as long.
|
||||
let c = (x as i32 + 2 * (sx - cx as i32), y as i32 + 2 * (sy - cy as i32));
|
||||
if solver.is_valid(c.0, c.1) {
|
||||
cand = Some(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
upsampled |= cand.is_some();
|
||||
nnf[k] = cand.unwrap_or_else(|| sources.nth((rng.next() % sources.len() as u64) as usize, &solver.valid, lw));
|
||||
}
|
||||
}
|
||||
let (mut nnf, upsampled) = seed_matches(&solver, coarser.as_ref(), &sources, &mut rng, lw, tb, ctl)?;
|
||||
// The coarser level has given all it had.
|
||||
drop(coarser);
|
||||
let mut cost = vec![f32::INFINITY; tb.area()];
|
||||
@@ -370,5 +284,138 @@ fn complete_cow<'a>(
|
||||
Ok(prev.map(|c| c.img).ok_or(Cow::Owned(Vec::new())))
|
||||
}
|
||||
|
||||
/// The levels of the completion, finest first, with the hole's bounds and extent at each.
|
||||
struct Pyramid<'a> {
|
||||
levels: Vec<Level<'a>>,
|
||||
boxes: Vec<common::Bounds>,
|
||||
exts: Vec<usize>,
|
||||
/// The hole is a thin one: its thickness, not its length, decides how deep to go.
|
||||
thin: bool,
|
||||
}
|
||||
|
||||
/// Shrinks the image until the hole is about four patches across (a deeper pyramid decides the
|
||||
/// structure where the texture has averaged out, and fills flat). A thin hole stops while it is
|
||||
/// still about two patches thick: through it, not along it, is what counts (#1366).
|
||||
fn build_pyramid<'a>(
|
||||
first: Level<'a>,
|
||||
ch: usize,
|
||||
hb0: common::Bounds,
|
||||
psz: usize,
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
) -> Result<Pyramid<'a>, photocraft_raster::Cancelled> {
|
||||
let thickness = hole_thickness(first.w, hb0, &first.px);
|
||||
let mut levels = vec![first];
|
||||
let mut boxes = vec![hb0];
|
||||
let mut ext = hb0.width().max(hb0.height());
|
||||
let thin = thickness.saturating_mul(THIN_RATIO) <= ext;
|
||||
if thin {
|
||||
ext = thickness;
|
||||
}
|
||||
let mut exts = vec![ext];
|
||||
while let Some(l) = levels.last() {
|
||||
ctl.check()?;
|
||||
if ext <= if thin { 2 * psz } else { 4 * psz } || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
|
||||
break;
|
||||
}
|
||||
let d = downsample(l, ch);
|
||||
levels.push(d);
|
||||
boxes.push(boxes[boxes.len() - 1].half());
|
||||
ext = ext.div_ceil(2);
|
||||
exts.push(ext);
|
||||
}
|
||||
Ok(Pyramid { levels, boxes, exts, thin })
|
||||
}
|
||||
|
||||
/// The level's own pixels to fill: the caller's, or a copy made in parallel.
|
||||
fn into_owned_pixels(img: Cow<'_, [f32]>) -> Vec<f32> {
|
||||
match img {
|
||||
Cow::Owned(v) => v,
|
||||
Cow::Borrowed(b) => {
|
||||
const CHUNK: usize = 1 << 16;
|
||||
let mut v = vec![0.0f32; b.len()];
|
||||
par::chunks_mut(&mut v, CHUNK, true, |i, d| d.copy_from_slice(&b[i * CHUNK..i * CHUNK + d.len()]));
|
||||
v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the hole from the coarser level's pixels, scaled up.
|
||||
fn copy_up_coarser(img: &mut [f32], px: &[Px], w: usize, ch: usize, hb: common::Bounds, c: &Coarser) {
|
||||
let (pw, ph) = (c.w, c.h);
|
||||
for y in hb.y0..hb.y1 {
|
||||
for x in hb.x0..hb.x1 {
|
||||
if px[y * w + x] == Px::Hole {
|
||||
let (sx, sy) = ((x / 2).min(pw - 1), (y / 2).min(ph - 1));
|
||||
let (d, s) = ((y * w + x) * ch, (sy * pw + sx) * ch);
|
||||
img[d..d + ch].copy_from_slice(&c.img[s..s + ch]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the hole as a membrane over its surroundings, for where nothing can be copied. `dims`
|
||||
/// is the level's width, height and channel count.
|
||||
fn fill_hole_with_membrane(img: &mut [f32], px: &[Px], dims: (usize, usize, usize), r: usize, thin: bool) {
|
||||
let (w, h, ch) = dims;
|
||||
let mut unknown: Vec<bool> = px.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect();
|
||||
// Across a thin hole the membrane starts a patch radius out, past the
|
||||
// anti-aliased fringe or halo that a selection drawn close to the object leaves.
|
||||
if thin {
|
||||
let hole: Vec<bool> = px.iter().map(|k| *k == Px::Hole).collect();
|
||||
for (u, d) in unknown.iter_mut().zip(crate::selection::edt(&hole, w, h)) {
|
||||
*u |= d <= r as f32;
|
||||
}
|
||||
}
|
||||
let m = membrane_fill(w, h, ch, img, &unknown);
|
||||
for (i, k) in px.iter().enumerate() {
|
||||
if *k == Px::Hole {
|
||||
img[i * ch..(i + 1) * ch].copy_from_slice(&m[i * ch..(i + 1) * ch]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The coarser level's match for `(x, y)`, scaled up, where it lands on a valid source.
|
||||
fn upscaled_match(solver: &Solver, c: &Coarser, x: usize, y: usize) -> Option<(i32, i32)> {
|
||||
let (cx, cy) = ((x / 2).min(c.w - 1), (y / 2).min(c.h - 1));
|
||||
let (sx, sy) = c.field_at(cx, cy);
|
||||
if sx == i32::MIN {
|
||||
return None;
|
||||
}
|
||||
// The same displacement, twice as long.
|
||||
let m = (x as i32 + 2 * (sx - cx as i32), y as i32 + 2 * (sy - cy as i32));
|
||||
solver.is_valid(m.0, m.1).then_some(m)
|
||||
}
|
||||
|
||||
/// NNF init: the coarser level's matches scaled up where they land on a valid source, random
|
||||
/// otherwise. Also says whether any came from the coarser level.
|
||||
fn seed_matches(
|
||||
solver: &Solver,
|
||||
coarser: Option<&Coarser>,
|
||||
sources: &Sources,
|
||||
rng: &mut Rng,
|
||||
lw: usize,
|
||||
tb: common::Bounds,
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
) -> Result<(Vec<(i32, i32)>, bool), photocraft_raster::Cancelled> {
|
||||
let tw = tb.width();
|
||||
let mut nnf = vec![(0i32, 0i32); tb.area()];
|
||||
let mut upsampled = false;
|
||||
for y in tb.y0..tb.y1 {
|
||||
if y % 32 == 0 {
|
||||
ctl.check()?;
|
||||
}
|
||||
for x in tb.x0..tb.x1 {
|
||||
let k = (y - tb.y0) * tw + (x - tb.x0);
|
||||
if !solver.targets[k] {
|
||||
continue;
|
||||
}
|
||||
let cand = coarser.filter(|c| !c.field.is_empty()).and_then(|c| upscaled_match(solver, c, x, y));
|
||||
upsampled |= cand.is_some();
|
||||
nnf[k] = cand.unwrap_or_else(|| sources.nth((rng.next() % sources.len() as u64) as usize, &solver.valid, lw));
|
||||
}
|
||||
}
|
||||
Ok((nnf, upsampled))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::Cow;
|
||||
|
||||
/// Below this many cells a pass runs on the calling thread (thread hand-off costs more than it
|
||||
/// saves). Every pass gives the same result either way.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::common::{Adapt, Bounds, PAR_MIN_PATCHES, Px, Rng};
|
||||
use super::*;
|
||||
use super::par;
|
||||
|
||||
/// Most channels whose moments the colour adaptation fits (the rest are copied as they are).
|
||||
const MOM_CH: usize = 8;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::common::{Bounds, Level, PAR_MIN, Px};
|
||||
use super::*;
|
||||
use super::{Cow, par};
|
||||
|
||||
/// Half-size level: a cell is a hole if any of its pixels is; else it averages its non-void
|
||||
/// pixels, and may be copied from only if all of them may.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::common::{Bounds, Px, Rng};
|
||||
use super::solver::{Best, LANES, known_sse, known_sse_any, known_sse_lanes, least_sum};
|
||||
use super::sources::{hole_bbox, integral, window_count};
|
||||
use super::*;
|
||||
use super::{HoleIntegral, membrane_fill, par};
|
||||
|
||||
/// Fill the hole from its edge inwards, one ring at a time: each pixel takes the centre of the
|
||||
/// source patch that best matches what is already known around it (the first in `sources` of
|
||||
|
||||
@@ -26,6 +26,7 @@ mod blur2;
|
||||
pub mod camera_raw;
|
||||
pub mod color_to_alpha;
|
||||
pub mod content_aware;
|
||||
mod cutout;
|
||||
mod denoise;
|
||||
pub mod dff;
|
||||
pub mod direct;
|
||||
@@ -72,6 +73,7 @@ pub mod seam;
|
||||
pub mod segment;
|
||||
pub mod selection;
|
||||
mod selection_blur;
|
||||
pub mod separable;
|
||||
mod shape_blur;
|
||||
mod sharpen;
|
||||
pub mod stack;
|
||||
@@ -564,6 +566,9 @@ impl FilterParams {
|
||||
pub fn halo(&self) -> Halo {
|
||||
let g = |sigma: f32| Halo::Radius(((sigma.max(0.0) * 3.0).ceil() as i32).saturating_add(1));
|
||||
match self {
|
||||
// Small Gaussian radii use the calibrated seven-tap kernel as well: its reach is
|
||||
// three pixels even when ceil(3 * radius) + 1 would be smaller.
|
||||
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } if (0.05..1.0).contains(radius) => Halo::Radius(3),
|
||||
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius),
|
||||
FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)),
|
||||
FilterParams::SmartSharpen { radius, .. } => g(*radius),
|
||||
@@ -730,6 +735,7 @@ fn halo_ext(p: &FilterParams) -> Halo {
|
||||
FilterParams::PathBlur { paths } => r(paths.iter().map(|p| p.speed.abs()).fold(0.0, f32::max) / 2.0 + 1.0),
|
||||
FilterParams::Custom { .. } => r(2.0),
|
||||
FilterParams::DeInterlace { .. } => r(1.0),
|
||||
FilterParams::FilterGallery { effects } if artistic_fx::is_global(effects) => Halo::Bounds,
|
||||
FilterParams::FilterGallery { effects } => Halo::Radius(artistic_fx::reach(effects)),
|
||||
_ => Halo::Radius(0),
|
||||
}
|
||||
@@ -1120,6 +1126,14 @@ fn apply_tiled_with_impl(
|
||||
}
|
||||
}
|
||||
let shared = (halo == Halo::Bounds).then(|| Image::read(surface, bounds.union(&area)));
|
||||
// A Filter Gallery stack with a Cutout traces the whole image once; tiles only fill it in.
|
||||
let gallery = match (params, &shared) {
|
||||
(FilterParams::FilterGallery { effects }, Some(src)) => artistic_fx::Prepared::new(effects, src, &ctx),
|
||||
_ => None,
|
||||
};
|
||||
if ctl.cancelled() {
|
||||
return None;
|
||||
}
|
||||
// Shape rasterization depends only on params, not on a tile's pixels.
|
||||
let shape = match params {
|
||||
FilterParams::ShapeBlur { radius, shape } => {
|
||||
@@ -1197,6 +1211,7 @@ fn apply_tiled_with_impl(
|
||||
}
|
||||
};
|
||||
let Some(mut data) = (match (&shape, &radial) {
|
||||
_ if gallery.is_some() => gallery.as_ref().map(|g| g.run(src, *t, &ctx)),
|
||||
(Some(shape), _) => shape.checked_run(src, *t, &ctx),
|
||||
(None, Some(plan)) => match plan.filter(src, *t, &ctx, ctl) {
|
||||
Some(data) => Some(data),
|
||||
|
||||
@@ -33,6 +33,9 @@ pub enum LiquifyTool {
|
||||
Reconstruct,
|
||||
Smooth,
|
||||
TwirlCw,
|
||||
/// Twirl Clockwise with Alt (Option) held: a stroke's direction, not a tool of its own. The
|
||||
/// reference application's Liquify has Twirl Clockwise only; strokes may still name
|
||||
/// `twirlCcw` (Alt-strokes record it, and older scripts and saved strokes use it).
|
||||
TwirlCcw,
|
||||
Pucker,
|
||||
Bloat,
|
||||
@@ -49,12 +52,13 @@ pub enum LiquifyTool {
|
||||
}
|
||||
|
||||
impl LiquifyTool {
|
||||
pub const ALL: [LiquifyTool; 11] = [
|
||||
/// The tool strip, in order. Twirl Counterclockwise is not on it: it is Twirl Clockwise with
|
||||
/// Alt (Option) held.
|
||||
pub const ALL: [LiquifyTool; 10] = [
|
||||
LiquifyTool::ForwardWarp,
|
||||
LiquifyTool::Reconstruct,
|
||||
LiquifyTool::Smooth,
|
||||
LiquifyTool::TwirlCw,
|
||||
LiquifyTool::TwirlCcw,
|
||||
LiquifyTool::Pucker,
|
||||
LiquifyTool::Bloat,
|
||||
LiquifyTool::PushLeft,
|
||||
|
||||
@@ -35,8 +35,11 @@ pub struct RefineParams {
|
||||
pub feather: f32,
|
||||
/// 0–100.
|
||||
pub contrast: f32,
|
||||
/// −100–100. 100% moves the edge by `max(radius, 4)` pixels. Values between whole
|
||||
/// pixels blend the two neighbouring shifts, so the slider does not jump.
|
||||
/// −100–100. A curve on the refined coverage, applied last: each pixel's odds (`a / (1 − a)`)
|
||||
/// are multiplied by `49^(shift / 100)`, so at −100% a half-covered pixel drops to 2% and at
|
||||
/// +100% it rises to 98%. Fully selected and unselected pixels never change, so Shift Edge
|
||||
/// moves only soft borders and a hard edge stays put (measured against the reference
|
||||
/// application: the fit holds within ±0.05 in log-odds at feathers of 2, 5 and 10 px).
|
||||
pub shift_edge: f32,
|
||||
}
|
||||
|
||||
@@ -60,18 +63,10 @@ impl RefineParams {
|
||||
fn feather_sigma(&self) -> f32 {
|
||||
self.feather.max(0.0) / 2.0
|
||||
}
|
||||
/// Signed shift in pixels, fractional. 100% is `max(radius, 4)` px, clamped to ±64.
|
||||
fn shift_amount(&self) -> f32 {
|
||||
let px = self.shift_edge.clamp(-100.0, 100.0) / 100.0 * self.radius.max(4.0);
|
||||
if px.is_finite() { px.clamp(-64.0, 64.0) } else { 0.0 }
|
||||
}
|
||||
/// How many pixels the mask can move: the farther of the two integer radii a fractional
|
||||
/// shift blends. Whole-pixel amounts stay exact (`ceil(4) == 4`).
|
||||
fn shift_px(&self) -> i32 {
|
||||
let px = self.shift_amount();
|
||||
let reach = px.abs().ceil();
|
||||
let signed = if px.is_sign_negative() { -reach } else { reach };
|
||||
if signed.is_finite() { signed as i32 } else { 0 }
|
||||
/// The factor Shift Edge multiplies each pixel's odds by: `49^(shift / 100)`, 1 at 0%.
|
||||
fn shift_odds(&self) -> f32 {
|
||||
let s = self.shift_edge.clamp(-100.0, 100.0) / 100.0;
|
||||
if s.is_finite() { 49f32.powf(s) } else { 1.0 }
|
||||
}
|
||||
fn uses_image(&self) -> bool {
|
||||
self.radius > 0.0
|
||||
@@ -84,14 +79,13 @@ impl RefineParams {
|
||||
} else {
|
||||
0
|
||||
};
|
||||
edge + (3.0 * self.smooth_sigma()).ceil() as i32 + (3.0 * self.feather_sigma()).ceil() as i32 + self.shift_px().abs() + 2
|
||||
edge + (3.0 * self.smooth_sigma()).ceil() as i32 + (3.0 * self.feather_sigma()).ceil() as i32 + 2
|
||||
}
|
||||
/// How far the refined mask can extend beyond the original.
|
||||
pub fn spread(&self) -> i32 {
|
||||
(if self.uses_image() { self.radius.ceil() as i32 } else { 0 })
|
||||
+ (3.0 * self.smooth_sigma()).ceil() as i32
|
||||
+ (3.0 * self.feather_sigma()).ceil() as i32
|
||||
+ self.shift_px().max(0)
|
||||
+ 1
|
||||
}
|
||||
}
|
||||
@@ -307,7 +301,7 @@ pub fn edge_width(img: &RgbImage, r: usize) -> Vec<f32> {
|
||||
|
||||
/// Refines a soft mask buffer `m` (`w × h`) with `img` as guide (required when `radius > 0`).
|
||||
pub fn refine_buffer(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams) -> Vec<f32> {
|
||||
refine_buffer_with(img, m, w, h, p, None, morph)
|
||||
refine_buffer_with(img, m, w, h, p, None)
|
||||
}
|
||||
|
||||
/// The Refine Edge Brush over one buffer: signed coverage (`w × h`, see [`EdgeBrush`]) and the
|
||||
@@ -318,10 +312,7 @@ struct BrushBuf<'a> {
|
||||
window: usize,
|
||||
}
|
||||
|
||||
fn refine_buffer_with<F>(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams, brush: Option<BrushBuf>, morphology: F) -> Vec<f32>
|
||||
where
|
||||
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy,
|
||||
{
|
||||
fn refine_buffer_with(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams, brush: Option<BrushBuf>) -> Vec<f32> {
|
||||
let mut a = m.to_vec();
|
||||
if let (true, Some(img)) = (p.uses_image(), img) {
|
||||
let bin: Vec<bool> = m.iter().map(|v| *v >= 0.5).collect();
|
||||
@@ -381,36 +372,23 @@ where
|
||||
*v = ((*v - 0.5) * gain + 0.5).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
let amount = p.shift_amount();
|
||||
if amount.abs() >= 1.0e-4 {
|
||||
a = shift_mask(&a, w, h, amount, morphology);
|
||||
let k = p.shift_odds();
|
||||
if (k - 1.0).abs() >= 1.0e-6 {
|
||||
for v in &mut a {
|
||||
*v = shift_coverage(*v, k);
|
||||
}
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// Grow or shrink `src` by a fractional pixel distance. Whole pixels use one morphology;
|
||||
/// the remainder blends that result with the next pixel, so 0.3 px is not the same step as 1 px.
|
||||
fn shift_mask<F>(src: &[f32], w: usize, h: usize, amount: f32, morphology: F) -> Vec<f32>
|
||||
where
|
||||
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy,
|
||||
{
|
||||
let mag = amount.abs();
|
||||
if !mag.is_finite() || mag < 1.0e-4 {
|
||||
return src.to_vec();
|
||||
/// Shift Edge on one coverage value: its odds times `k`. 0 and 1 stay exact.
|
||||
fn shift_coverage(v: f32, k: f32) -> f32 {
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
if v <= 0.0 || v >= 1.0 {
|
||||
return v;
|
||||
}
|
||||
let grow = amount > 0.0;
|
||||
let whole = mag.floor() as usize;
|
||||
let frac = mag - whole as f32;
|
||||
if frac < 1.0e-4 {
|
||||
return morphology(src, w, h, whole, grow);
|
||||
}
|
||||
let lo = if whole == 0 { src.to_vec() } else { morphology(src, w, h, whole, grow) };
|
||||
let hi = morphology(src, w, h, whole + 1, grow);
|
||||
let n = w.saturating_mul(h);
|
||||
if lo.len() < n || hi.len() < n || lo.len() != hi.len() {
|
||||
return hi;
|
||||
}
|
||||
lo.into_iter().zip(hi).map(|(a, b)| a + (b - a) * frac).collect()
|
||||
let shifted = k * v / (k * v + (1.0 - v));
|
||||
if shifted.is_finite() { shifted.clamp(0.0, 1.0) } else { v }
|
||||
}
|
||||
|
||||
/// Most pixels one refine should cover. Its result is a dense [`Region`] over [`refine_area`]
|
||||
@@ -433,7 +411,7 @@ pub fn refine_area(content: Rect, canvas: Rect, p: &RefineParams, brush: Option<
|
||||
/// its non-zero pixels; the result is clipped to `canvas`. `sampler` supplies the guide image
|
||||
/// (only read when `radius > 0`). Returns `None` when nothing remains selected.
|
||||
pub fn refine_mask(sampler: &dyn Sampler, mask: &(dyn Fn(Rect) -> Vec<f32> + Sync), content: Rect, canvas: Rect, p: &RefineParams) -> Option<Region> {
|
||||
refine_mask_with(sampler, mask, content, canvas, p, None, morph)
|
||||
refine_mask_with(sampler, mask, content, canvas, p, None)
|
||||
}
|
||||
|
||||
/// [`refine_mask`] with the Refine Edge Brush: where `brush` adds, coverage comes from the image
|
||||
@@ -447,21 +425,17 @@ pub fn refine_mask_brushed(
|
||||
p: &RefineParams,
|
||||
brush: Option<&EdgeBrush>,
|
||||
) -> Option<Region> {
|
||||
refine_mask_with(sampler, mask, content, canvas, p, brush, morph)
|
||||
refine_mask_with(sampler, mask, content, canvas, p, brush)
|
||||
}
|
||||
|
||||
fn refine_mask_with<F>(
|
||||
fn refine_mask_with(
|
||||
sampler: &dyn Sampler,
|
||||
mask: &(dyn Fn(Rect) -> Vec<f32> + Sync),
|
||||
content: Rect,
|
||||
canvas: Rect,
|
||||
p: &RefineParams,
|
||||
brush: Option<&EdgeBrush>,
|
||||
morphology: F,
|
||||
) -> Option<Region>
|
||||
where
|
||||
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy + Sync + Send,
|
||||
{
|
||||
) -> Option<Region> {
|
||||
let bbox = refine_area(content, canvas, p, brush);
|
||||
if bbox.is_empty() {
|
||||
return None;
|
||||
@@ -501,7 +475,7 @@ where
|
||||
(Some(v), Some(b)) => Some(BrushBuf { v, window: b.window() }),
|
||||
_ => None,
|
||||
};
|
||||
let out = refine_buffer_with(img.as_ref(), &m, rw, rh, p, bb, morphology);
|
||||
let out = refine_buffer_with(img.as_ref(), &m, rw, rh, p, bb);
|
||||
(*t, core(&out))
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -765,22 +739,30 @@ mod tests {
|
||||
assert!(f[5 * w + 29] > 0.5 && f[5 * w + 29] < 0.8 && f[5 * w + 31] > 0.1);
|
||||
let c = refine_buffer(None, &f, w, h, &RefineParams { contrast: 100.0, ..Default::default() });
|
||||
assert!(c[5 * w + 29] == 1.0 && c[5 * w + 31] == 0.0);
|
||||
let grown = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 100.0, ..Default::default() });
|
||||
assert_eq!(grown[5 * w + 33], 1.0);
|
||||
assert_eq!(grown[5 * w + 34], 0.0);
|
||||
let shrunk = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -50.0, ..Default::default() });
|
||||
assert_eq!(shrunk[5 * w + 27], 1.0);
|
||||
assert_eq!(shrunk[5 * w + 28], 0.0);
|
||||
// Radius 0 scales 100% to 4 px, so 12.5% is half a pixel and 25% is one pixel.
|
||||
// The pixel just outside a hard edge fades in instead of snapping on.
|
||||
let half = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 12.5, ..Default::default() });
|
||||
let one = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 25.0, ..Default::default() });
|
||||
assert_eq!(m[5 * w + 30], 0.0);
|
||||
assert!((half[5 * w + 30] - 0.5).abs() < 0.02, "half pixel {}", half[5 * w + 30]);
|
||||
assert_eq!(one[5 * w + 30], 1.0);
|
||||
assert_eq!(half[5 * w + 31], 0.0, "the blend must not jump a second pixel");
|
||||
let pulled = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -12.5, ..Default::default() });
|
||||
assert!((pulled[5 * w + 29] - 0.5).abs() < 0.02, "half-pixel contract {}", pulled[5 * w + 29]);
|
||||
// Shift Edge leaves a hard edge where it is, at any amount: only soft coverage moves.
|
||||
for shift_edge in [-100.0, -50.0, 50.0, 100.0] {
|
||||
let s = refine_buffer(None, &m, w, h, &RefineParams { shift_edge, ..Default::default() });
|
||||
assert_eq!(s, m, "Shift Edge {shift_edge}% moved a hard edge");
|
||||
}
|
||||
// Coverage before → after, read off the reference application's feathered edges.
|
||||
for (shift_edge, before, after) in [
|
||||
(-100.0, 0.898, 0.153),
|
||||
(-100.0, 0.612, 0.031),
|
||||
(-100.0, 0.965, 0.357),
|
||||
(-50.0, 0.537, 0.141),
|
||||
(-50.0, 0.898, 0.557),
|
||||
(100.0, 0.102, 0.847),
|
||||
(100.0, 0.388, 0.969),
|
||||
] {
|
||||
let flat = vec![before; w * h];
|
||||
let s = refine_buffer(None, &flat, w, h, &RefineParams { shift_edge, ..Default::default() });
|
||||
assert!((s[5 * w + 30] - after).abs() < 0.01, "{shift_edge}%: {before} became {}, expected {after}", s[5 * w + 30]);
|
||||
}
|
||||
// So a feathered edge moves: at −100% its half-covered point lands where it was 98%.
|
||||
let soft = refine_buffer(None, &m, w, h, &RefineParams { feather: 6.0, ..Default::default() });
|
||||
let pulled = refine_buffer(None, &m, w, h, &RefineParams { feather: 6.0, shift_edge: -100.0, ..Default::default() });
|
||||
let half = |v: &[f32]| (0..w).find(|x| v[5 * w + x] < 0.5).unwrap_or(w);
|
||||
assert!(half(&pulled) < half(&soft), "−100% did not pull the soft edge in");
|
||||
let sm = refine_buffer(None, &m, w, h, &RefineParams { smooth: 50.0, ..Default::default() });
|
||||
assert!(sm[5 * w + 10] == 1.0 && sm[5 * w + 50] == 0.0);
|
||||
}
|
||||
|
||||
@@ -230,65 +230,6 @@ fn octagonal_morphology_release_comparison() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "release comparison of the full tiled Select and Mask CPU path"]
|
||||
#[allow(clippy::assertions_on_constants)]
|
||||
fn tiled_refinement_release_comparison() {
|
||||
use crate::{
|
||||
matting::{RefineParams, refine_mask_with, region_reader},
|
||||
segment::{ImageSampler, RgbImage, trim_region},
|
||||
selection::Region,
|
||||
};
|
||||
use photocraft_geom::Rect;
|
||||
use std::{hint::black_box, time::Instant};
|
||||
assert!(!cfg!(debug_assertions), "run with --release");
|
||||
for (w, h) in [(6000usize, 4000usize), (7200, 5000)] {
|
||||
let image = RgbImage::from_fn(w, h, |x, y| {
|
||||
let t = x as f32 / w as f32;
|
||||
let u = y as f32 / h as f32;
|
||||
[0.2 + 0.5 * t, 0.15 + 0.55 * u, 0.3 + 0.15 * ((x / 17 + y / 23) % 3) as f32]
|
||||
});
|
||||
let sampler = ImageSampler { img: &image, origin: (0, 0) };
|
||||
let canvas = Rect::new(0, 0, w as i32, h as i32);
|
||||
let mask: Vec<_> = (0..w * h)
|
||||
.map(|i| {
|
||||
let dx = (i % w) as f64 - w as f64 * 0.51;
|
||||
let dy = (i / w) as f64 - h as f64 * 0.48;
|
||||
let d = (dx * dx + dy * dy).sqrt();
|
||||
((0.5 + (w.min(h) as f64 * 0.27 - d) / 7.0).clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
})
|
||||
.collect();
|
||||
let region = trim_region(Region { bbox: canvas, mask }).unwrap();
|
||||
let reader = region_reader(®ion);
|
||||
let p = RefineParams { radius: 64.0, shift_edge: 100.0, ..Default::default() };
|
||||
let old = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, None, repeated).unwrap();
|
||||
let new = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, None, morph).unwrap();
|
||||
assert_eq!(new.bbox, old.bbox);
|
||||
assert_eq!(new.mask, old.mask);
|
||||
let (mut old_ms, mut new_ms) = (Vec::new(), Vec::new());
|
||||
for run in 0..3 {
|
||||
let mut measure = |fast: bool| {
|
||||
let start = Instant::now();
|
||||
let out = if fast {
|
||||
refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, None, morph)
|
||||
} else {
|
||||
refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, None, repeated)
|
||||
}
|
||||
.unwrap();
|
||||
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
|
||||
assert_eq!(out.bbox, old.bbox);
|
||||
assert_eq!(out.mask, old.mask);
|
||||
if fast { new_ms.push(elapsed) } else { old_ms.push(elapsed) }
|
||||
};
|
||||
measure(run % 2 == 0);
|
||||
measure(run % 2 != 0);
|
||||
}
|
||||
old_ms.sort_by(f64::total_cmp);
|
||||
new_ms.sort_by(f64::total_cmp);
|
||||
println!("REFINE {w}x{h} radius=64 shift=100 old_ms={old_ms:?} new_ms={new_ms:?} speedup={:.2}", old_ms[1] / new_ms[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "release comparison on a dense fractional 24 MP matte"]
|
||||
#[allow(clippy::assertions_on_constants)]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Normal, Placement, par_map, par_rows};
|
||||
|
||||
// ---------- photometric ----------
|
||||
|
||||
|
||||
@@ -62,68 +62,213 @@ impl Normal {
|
||||
}
|
||||
}
|
||||
|
||||
/// A sparse least-squares problem `min Σ w²(row·x − rhs)²`, solved by conjugate gradients on the
|
||||
/// normal equations (CGLS). Used for mesh warps with thousands of unknowns.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SparseLs {
|
||||
pub n: usize,
|
||||
rows: Vec<(Vec<(usize, f64)>, f64)>,
|
||||
/// A compressed sparse row matrix (symmetric, used by CG).
|
||||
pub(crate) struct Csr {
|
||||
pub rows: Vec<usize>,
|
||||
pub cols: Vec<usize>,
|
||||
pub vals: Vec<f64>,
|
||||
pub diag: Vec<f64>,
|
||||
}
|
||||
|
||||
impl SparseLs {
|
||||
pub fn new(n: usize) -> Self {
|
||||
SparseLs { n, rows: Vec::new() }
|
||||
}
|
||||
pub fn add(&mut self, row: Vec<(usize, f64)>, rhs: f64, w: f64) {
|
||||
self.rows.push((row.into_iter().map(|(i, c)| (i, c * w)).collect(), rhs * w));
|
||||
}
|
||||
/// CGLS from `x0`; at most `iters` iterations.
|
||||
pub fn solve(&self, x0: &[f64], iters: usize) -> Vec<f64> {
|
||||
let n = self.n;
|
||||
let mut x = x0.to_vec();
|
||||
let ax = |x: &[f64]| -> Vec<f64> { self.rows.iter().map(|(r, _)| r.iter().map(|&(i, c)| c * x[i]).sum()).collect() };
|
||||
let atv = |v: &[f64]| -> Vec<f64> {
|
||||
let mut out = vec![0.0; n];
|
||||
for ((r, _), vi) in self.rows.iter().zip(v) {
|
||||
for &(i, c) in r {
|
||||
out[i] += c * vi;
|
||||
impl Csr {
|
||||
pub fn from_triplets(n: usize, mut t: Vec<(usize, usize, f64)>) -> Csr {
|
||||
t.sort_by_key(|a| (a.0, a.1));
|
||||
let mut rows = vec![0usize; n + 1];
|
||||
let mut cols = Vec::with_capacity(t.len());
|
||||
let mut vals: Vec<f64> = Vec::with_capacity(t.len());
|
||||
let mut last = (usize::MAX, usize::MAX);
|
||||
for (i, j, v) in t {
|
||||
if (i, j) == last {
|
||||
*vals.last_mut().unwrap_or(&mut 0.0) += v;
|
||||
continue;
|
||||
}
|
||||
last = (i, j);
|
||||
cols.push(j);
|
||||
vals.push(v);
|
||||
rows[i + 1] = cols.len();
|
||||
}
|
||||
for i in 0..n {
|
||||
rows[i + 1] = rows[i + 1].max(rows[i]);
|
||||
}
|
||||
let mut diag = vec![1.0; n];
|
||||
for (i, d) in diag.iter_mut().enumerate() {
|
||||
for k in rows[i]..rows[i + 1] {
|
||||
if cols[k] == i && vals[k] > 0.0 {
|
||||
*d = vals[k];
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
let mut r: Vec<f64> = self.rows.iter().zip(ax(&x)).map(|((_, b), a)| b - a).collect();
|
||||
let mut s = atv(&r);
|
||||
let mut p = s.clone();
|
||||
let mut gamma: f64 = s.iter().map(|v| v * v).sum();
|
||||
let g0 = gamma.max(1e-30);
|
||||
for _ in 0..iters {
|
||||
if gamma <= g0 * 1e-20 {
|
||||
}
|
||||
Csr { rows, cols, vals, diag }
|
||||
}
|
||||
|
||||
pub fn mul(&self, x: &[f64], out: &mut [f64]) {
|
||||
for (i, o) in out.iter_mut().enumerate() {
|
||||
let mut s = 0.0;
|
||||
for k in self.rows[i]..self.rows[i + 1] {
|
||||
s += self.vals[k] * x[self.cols[k]];
|
||||
}
|
||||
*o = s;
|
||||
}
|
||||
}
|
||||
|
||||
/// Jacobi-preconditioned conjugate gradients, warm-started from `x`.
|
||||
pub fn solve(&self, b: &[f64], x: &mut [f64]) {
|
||||
let n = b.len();
|
||||
let mut r = vec![0.0; n];
|
||||
self.mul(x, &mut r);
|
||||
for i in 0..n {
|
||||
r[i] = b[i] - r[i];
|
||||
}
|
||||
let bn: f64 = b.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-30);
|
||||
let mut z: Vec<f64> = r.iter().zip(&self.diag).map(|(r, d)| r / d).collect();
|
||||
let mut p = z.clone();
|
||||
let mut rz: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
|
||||
let mut ap = vec![0.0; n];
|
||||
for _ in 0..(4 * n).clamp(50, 2000) {
|
||||
if r.iter().map(|v| v * v).sum::<f64>().sqrt() <= 1e-12 * bn {
|
||||
break;
|
||||
}
|
||||
let q = ax(&p);
|
||||
let qq: f64 = q.iter().map(|v| v * v).sum();
|
||||
if qq <= 1e-300 {
|
||||
self.mul(&p, &mut ap);
|
||||
let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum();
|
||||
if pap.abs() < 1e-300 {
|
||||
break;
|
||||
}
|
||||
let alpha = gamma / qq;
|
||||
let alpha = rz / pap;
|
||||
for i in 0..n {
|
||||
x[i] += alpha * p[i];
|
||||
r[i] -= alpha * ap[i];
|
||||
}
|
||||
for (ri, qi) in r.iter_mut().zip(&q) {
|
||||
*ri -= alpha * qi;
|
||||
}
|
||||
s = atv(&r);
|
||||
let g1: f64 = s.iter().map(|v| v * v).sum();
|
||||
let beta = g1 / gamma;
|
||||
gamma = g1;
|
||||
for i in 0..n {
|
||||
p[i] = s[i] + beta * p[i];
|
||||
z[i] = r[i] / self.diag[i];
|
||||
}
|
||||
let rz2: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
|
||||
let beta = rz2 / rz;
|
||||
rz = rz2;
|
||||
for i in 0..n {
|
||||
p[i] = z[i] + beta * p[i];
|
||||
}
|
||||
}
|
||||
x
|
||||
}
|
||||
}
|
||||
|
||||
/// Banded Cholesky factor `A = L Lᵀ` (the mesh numbers its vertices in reverse Cuthill–McKee
|
||||
/// order, so the system's bandwidth is about one breadth-first front): factored once per pin
|
||||
/// set, then each global step is two triangular solves.
|
||||
pub(crate) struct BandChol {
|
||||
n: usize,
|
||||
p: usize,
|
||||
/// Row `i` holds `L[i][i - p ..= i]`.
|
||||
l: Vec<f64>,
|
||||
}
|
||||
|
||||
impl BandChol {
|
||||
/// `None` when the band would be too large (or the matrix isn't positive definite).
|
||||
pub fn new(a: &Csr) -> Option<BandChol> {
|
||||
let n = a.diag.len();
|
||||
let mut p = 0;
|
||||
for i in 0..n {
|
||||
for k in a.rows[i]..a.rows[i + 1] {
|
||||
p = p.max(i.abs_diff(a.cols[k]));
|
||||
}
|
||||
}
|
||||
if n.saturating_mul(p + 1) > 40_000_000 {
|
||||
return None;
|
||||
}
|
||||
let w = p + 1;
|
||||
let mut l = vec![0.0f64; n * w];
|
||||
for i in 0..n {
|
||||
for k in a.rows[i]..a.rows[i + 1] {
|
||||
let j = a.cols[k];
|
||||
if j <= i {
|
||||
l[i * w + (j + p - i)] = a.vals[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..n {
|
||||
let lo = i.saturating_sub(p);
|
||||
for j in lo..=i {
|
||||
let jlo = j.saturating_sub(p).max(lo);
|
||||
let mut s = l[i * w + (j + p - i)];
|
||||
for k in jlo..j {
|
||||
s -= l[i * w + (k + p - i)] * l[j * w + (k + p - j)];
|
||||
}
|
||||
if i == j {
|
||||
if s <= 0.0 || !s.is_finite() {
|
||||
return None;
|
||||
}
|
||||
l[i * w + p] = s.sqrt();
|
||||
} else {
|
||||
l[i * w + (j + p - i)] = s / l[j * w + p];
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(BandChol { n, p, l })
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_range_loop)] // band index arithmetic reads clearer with indices
|
||||
pub fn solve(&self, b: &[f64], x: &mut [f64]) {
|
||||
let (n, p, w) = (self.n, self.p, self.p + 1);
|
||||
let mut y = b.to_vec();
|
||||
for i in 0..n {
|
||||
let mut s = y[i];
|
||||
for k in i.saturating_sub(p)..i {
|
||||
s -= self.l[i * w + (k + p - i)] * y[k];
|
||||
}
|
||||
y[i] = s / self.l[i * w + p];
|
||||
}
|
||||
for i in (0..n).rev() {
|
||||
let mut s = y[i];
|
||||
for k in i + 1..(i + p + 1).min(n) {
|
||||
s -= self.l[k * w + (i + p - k)] * x[k];
|
||||
}
|
||||
x[i] = s / self.l[i * w + p];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Preconditioned conjugate gradients on the SPD system `A x = b`, warm-started from `x`.
|
||||
/// `a(v, out)` writes `A·v` and `m(r, out)` writes `M⁻¹·r`. Stops when `‖r‖ ≤ tol·‖b‖`, when the
|
||||
/// system stops being usable (non-finite or non-positive curvature), or after `max_iter`
|
||||
/// iterations. Returns the iterations used.
|
||||
pub(crate) fn pcg(a: impl Fn(&[f64], &mut [f64]), m: impl Fn(&[f64], &mut [f64]), b: &[f64], x: &mut [f64], tol: f64, max_iter: usize) -> usize {
|
||||
let dot = |u: &[f64], v: &[f64]| -> f64 { u.iter().zip(v).map(|(p, q)| p * q).sum() };
|
||||
let n = b.len().min(x.len());
|
||||
let mut r = vec![0.0; n];
|
||||
a(x, &mut r);
|
||||
for (ri, bi) in r.iter_mut().zip(b) {
|
||||
*ri = bi - *ri;
|
||||
}
|
||||
let stop = tol * dot(b, b).sqrt().max(1e-30);
|
||||
let mut z = vec![0.0; n];
|
||||
m(&r, &mut z);
|
||||
let mut p = z.clone();
|
||||
let mut rz = dot(&r, &z);
|
||||
let mut ap = vec![0.0; n];
|
||||
for it in 0..max_iter {
|
||||
if dot(&r, &r).sqrt() <= stop || rz <= 1e-300 || !rz.is_finite() {
|
||||
return it;
|
||||
}
|
||||
a(&p, &mut ap);
|
||||
let pap = dot(&p, &ap);
|
||||
if pap <= 1e-300 || !pap.is_finite() {
|
||||
return it;
|
||||
}
|
||||
let alpha = rz / pap;
|
||||
for i in 0..n {
|
||||
x[i] += alpha * p[i];
|
||||
r[i] -= alpha * ap[i];
|
||||
}
|
||||
m(&r, &mut z);
|
||||
let rz2 = dot(&r, &z);
|
||||
let beta = rz2 / rz;
|
||||
rz = rz2;
|
||||
for i in 0..n {
|
||||
p[i] = z[i] + beta * p[i];
|
||||
}
|
||||
}
|
||||
max_iter
|
||||
}
|
||||
|
||||
/// Runs `f(y, row)` for every row of a `w × h × ch` buffer, in parallel on native targets.
|
||||
pub(crate) fn par_rows<T: Send + Sync>(buf: &mut [T], w: usize, ch: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) {
|
||||
let stride = (w * ch).max(1);
|
||||
@@ -251,22 +396,50 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dense_and_sparse_solvers_agree() {
|
||||
fn dense_solver_fits_a_line_and_rejects_singular_systems() {
|
||||
// Overdetermined line fit y = 2x + 1.
|
||||
let mut ne = Normal::new(2);
|
||||
let mut sp = SparseLs::new(2);
|
||||
for x in 0..10 {
|
||||
let y = 2.0 * x as f64 + 1.0;
|
||||
ne.add(&[(0, x as f64), (1, 1.0)], y, 1.0);
|
||||
sp.add(vec![(0, x as f64), (1, 1.0)], y, 1.0);
|
||||
ne.add(&[(0, x as f64), (1, 1.0)], 2.0 * x as f64 + 1.0, 1.0);
|
||||
}
|
||||
let a = ne.solve().unwrap();
|
||||
let b = sp.solve(&[0.0, 0.0], 50);
|
||||
assert!((a[0] - 2.0).abs() < 1e-9 && (a[1] - 1.0).abs() < 1e-9);
|
||||
assert!((b[0] - 2.0).abs() < 1e-6 && (b[1] - 1.0).abs() < 1e-6, "{b:?}");
|
||||
assert!(solve_dense(2, vec![1.0, 2.0, 2.0, 4.0], vec![1.0, 2.0]).is_none());
|
||||
}
|
||||
|
||||
/// A tridiagonal SPD system: identity preconditioning needs many iterations, the exact
|
||||
/// banded Cholesky preconditioner one or two; both match the dense solve.
|
||||
#[test]
|
||||
fn pcg_matches_the_dense_solve_and_an_exact_preconditioner_converges_at_once() {
|
||||
let n = 40;
|
||||
let mut trip = Vec::new();
|
||||
for i in 0..n {
|
||||
trip.push((i, i, 2.5));
|
||||
if i + 1 < n {
|
||||
trip.push((i, i + 1, -1.0));
|
||||
trip.push((i + 1, i, -1.0));
|
||||
}
|
||||
}
|
||||
let k = Csr::from_triplets(n, trip);
|
||||
let mut dense = vec![0.0; n * n];
|
||||
for i in 0..n {
|
||||
for e in k.rows[i]..k.rows[i + 1] {
|
||||
dense[i * n + k.cols[e]] = k.vals[e];
|
||||
}
|
||||
}
|
||||
let b: Vec<f64> = (0..n).map(|i| (i as f64 * 0.7).sin()).collect();
|
||||
let want = solve_dense(n, dense, b.clone()).unwrap();
|
||||
let mul = |v: &[f64], out: &mut [f64]| k.mul(v, out);
|
||||
let mut x = vec![0.0; n];
|
||||
let slow = pcg(mul, |r, z| z.copy_from_slice(r), &b, &mut x, 1e-12, 500);
|
||||
assert!(x.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-8), "identity-preconditioned result differs");
|
||||
let chol = BandChol::new(&k).unwrap();
|
||||
let mut y = vec![0.0; n];
|
||||
let fast = pcg(mul, |r, z| chol.solve(r, z), &b, &mut y, 1e-12, 500);
|
||||
assert!(y.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-8), "cholesky-preconditioned result differs");
|
||||
assert!(fast <= 2 && slow > fast, "iterations: exact {fast}, identity {slow}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_round_trip_and_sampling() {
|
||||
for v in [0.0f32, 0.01, 0.2, 0.5, 1.0] {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Reuse the jittered lattice in cell space. Raster work stays in the original
|
||||
//! row/candidate order, including f32 ties and boundary-cell average additions.
|
||||
use super::*;
|
||||
use super::{Ctx, Image, MAXC, Rect, cell_point, hash01, native, ncol, subtractive};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Site {
|
||||
@@ -348,76 +348,101 @@ fn pointillize_grid(src: &Image, out: Rect, ctx: &Ctx, cell: f32, background: [f
|
||||
let mut owners = vec![u32::MAX; width];
|
||||
for (row, pixels) in result.chunks_exact_mut(stride).enumerate() {
|
||||
let y = out.y0.checked_add(i32::try_from(row).ok()?)?;
|
||||
let fy = y as f32 + 0.5;
|
||||
let cy = key(y, origin.1, cell);
|
||||
depths.fill(-0.5);
|
||||
owners.fill(u32::MAX);
|
||||
if cell >= 32.0 && out.width() <= 2048 {
|
||||
for &(cx, x0, x1) in &base_runs {
|
||||
let ids = grid.neighbours(cx, cy)?;
|
||||
let start = usize::try_from(x0.checked_sub(out.x0)?).ok()?;
|
||||
let end = usize::try_from(x1.checked_sub(out.x0)?).ok()?;
|
||||
certified_span::<true>(grid, &ids, x0, y, owners.get_mut(start..end)?, depths.get_mut(start..end)?)?;
|
||||
}
|
||||
certified_row(grid, &base_runs, out, cy, y, &mut owners, &mut depths)?;
|
||||
} else {
|
||||
for gy in cy.checked_sub(1)?..=cy.checked_add(1)? {
|
||||
let row = usize::try_from(gy.checked_sub(grid.y0)?).ok()?.checked_mul(grid.width)?;
|
||||
// Global gy/gx order is the original dy/dx tie order at every pixel.
|
||||
for (column, &(x0, x1)) in columns.iter().enumerate() {
|
||||
if x0 >= x1 {
|
||||
continue;
|
||||
}
|
||||
let i = row.checked_add(column)?;
|
||||
let site = grid.sites.get(i)?;
|
||||
let dy2 = (site.point.1 - fy).powi(2);
|
||||
let reach = f64::from(site.radius) + 1.5;
|
||||
let square = reach * reach - f64::from(dy2);
|
||||
if square < 0.0 {
|
||||
continue;
|
||||
}
|
||||
// The extra pixel covers subtraction/square/sqrt rounding for
|
||||
// the bounded coordinates/radii above. The exact old depth
|
||||
// comparison still decides every candidate that survives.
|
||||
let reach_x = square.max(0.0).sqrt() + 1.0;
|
||||
let lo = ((f64::from(site.point.0) - 0.5 - reach_x).floor() as i32).max(x0);
|
||||
let hi = ((f64::from(site.point.0) - 0.5 + reach_x).ceil() as i32).min(x1);
|
||||
if lo >= hi {
|
||||
continue;
|
||||
}
|
||||
let start = usize::try_from(lo.checked_sub(out.x0)?).ok()?;
|
||||
let end = usize::try_from(hi.checked_sub(out.x0)?).ok()?;
|
||||
for (offset, (depth, owner)) in depths.get_mut(start..end)?.iter_mut().zip(owners.get_mut(start..end)?).enumerate() {
|
||||
let fx = (lo + offset as i32) as f32 + 0.5;
|
||||
let d = ((site.point.0 - fx).powi(2) + dy2).sqrt();
|
||||
let candidate = site.radius - d;
|
||||
if candidate > *depth {
|
||||
*depth = candidate;
|
||||
*owner = i as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for ((pixel, &depth), &owner) in pixels.chunks_exact_mut(n).zip(&depths).zip(&owners) {
|
||||
let a_orig = if ctx.alpha { *pixel.get(n - 1)? } else { 1.0 };
|
||||
let mut canvas = bg;
|
||||
if ctx.alpha {
|
||||
*canvas.get_mut(n - 1)? = a_orig.max(bg.get(n - 1)?.min(1.0));
|
||||
}
|
||||
if owner != u32::MAX {
|
||||
let i = owner as usize;
|
||||
let k = (depth + 0.5).clamp(0.0, 1.0);
|
||||
let dot = grid.sites.get(i)?;
|
||||
for (c, value) in canvas.iter_mut().enumerate().take(n) {
|
||||
*value += (*dot.color.get(c)? - *value) * k;
|
||||
}
|
||||
}
|
||||
pixel.copy_from_slice(canvas.get(..n)?);
|
||||
scan_row(grid, &columns, out, cy, y, &mut owners, &mut depths)?;
|
||||
}
|
||||
composite_row(pixels, &depths, &owners, grid, ctx, bg)?;
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// Resolve one output row's dot owners through the certified run spans.
|
||||
fn certified_row(grid: &Grid, base_runs: &[(i32, i32, i32)], out: Rect, cy: i32, y: i32, owners: &mut [u32], depths: &mut [f32]) -> Option<()> {
|
||||
for &(cx, x0, x1) in base_runs {
|
||||
let ids = grid.neighbours(cx, cy)?;
|
||||
let start = usize::try_from(x0.checked_sub(out.x0)?).ok()?;
|
||||
let end = usize::try_from(x1.checked_sub(out.x0)?).ok()?;
|
||||
certified_span::<true>(grid, &ids, x0, y, owners.get_mut(start..end)?, depths.get_mut(start..end)?)?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Resolve one output row's dot owners by scanning the 3x3 neighbouring cells.
|
||||
fn scan_row(grid: &Grid, columns: &[(i32, i32)], out: Rect, cy: i32, y: i32, owners: &mut [u32], depths: &mut [f32]) -> Option<()> {
|
||||
let fy = y as f32 + 0.5;
|
||||
for gy in cy.checked_sub(1)?..=cy.checked_add(1)? {
|
||||
let row = usize::try_from(gy.checked_sub(grid.y0)?).ok()?.checked_mul(grid.width)?;
|
||||
// Global gy/gx order is the original dy/dx tie order at every pixel.
|
||||
for (column, &span) in columns.iter().enumerate() {
|
||||
if span.0 >= span.1 {
|
||||
continue;
|
||||
}
|
||||
let i = row.checked_add(column)?;
|
||||
scan_site(grid, i, fy, span, out, owners, depths)?;
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Let one site claim the pixels of the current row it reaches within `[x0, x1)`.
|
||||
fn scan_site(grid: &Grid, i: usize, fy: f32, (x0, x1): (i32, i32), out: Rect, owners: &mut [u32], depths: &mut [f32]) -> Option<()> {
|
||||
let site = grid.sites.get(i)?;
|
||||
let dy2 = (site.point.1 - fy).powi(2);
|
||||
let reach = f64::from(site.radius) + 1.5;
|
||||
let square = reach * reach - f64::from(dy2);
|
||||
if square < 0.0 {
|
||||
return Some(());
|
||||
}
|
||||
// The extra pixel covers subtraction/square/sqrt rounding for
|
||||
// the bounded coordinates/radii above. The exact old depth
|
||||
// comparison still decides every candidate that survives.
|
||||
let reach_x = square.max(0.0).sqrt() + 1.0;
|
||||
let lo = ((f64::from(site.point.0) - 0.5 - reach_x).floor() as i32).max(x0);
|
||||
let hi = ((f64::from(site.point.0) - 0.5 + reach_x).ceil() as i32).min(x1);
|
||||
if lo >= hi {
|
||||
return Some(());
|
||||
}
|
||||
let start = usize::try_from(lo.checked_sub(out.x0)?).ok()?;
|
||||
let end = usize::try_from(hi.checked_sub(out.x0)?).ok()?;
|
||||
for (offset, (depth, owner)) in depths.get_mut(start..end)?.iter_mut().zip(owners.get_mut(start..end)?).enumerate() {
|
||||
let fx = (lo + offset as i32) as f32 + 0.5;
|
||||
let d = ((site.point.0 - fx).powi(2) + dy2).sqrt();
|
||||
let candidate = site.radius - d;
|
||||
if candidate > *depth {
|
||||
*depth = candidate;
|
||||
*owner = i as u32;
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Blend each pixel of a row with the background and its owning dot colour.
|
||||
fn composite_row(pixels: &mut [f32], depths: &[f32], owners: &[u32], grid: &Grid, ctx: &Ctx, bg: [f32; 8]) -> Option<()> {
|
||||
let n = pixels.len() / depths.len().max(1);
|
||||
for ((pixel, &depth), &owner) in pixels.chunks_exact_mut(n).zip(depths).zip(owners) {
|
||||
let a_orig = if ctx.alpha { *pixel.get(n - 1)? } else { 1.0 };
|
||||
let mut canvas = bg;
|
||||
if ctx.alpha {
|
||||
*canvas.get_mut(n - 1)? = a_orig.max(bg.get(n - 1)?.min(1.0));
|
||||
}
|
||||
if owner != u32::MAX {
|
||||
let i = owner as usize;
|
||||
let k = (depth + 0.5).clamp(0.0, 1.0);
|
||||
let dot = grid.sites.get(i)?;
|
||||
for (c, value) in canvas.iter_mut().enumerate().take(n) {
|
||||
*value += (*dot.color.get(c)? - *value) * k;
|
||||
}
|
||||
}
|
||||
pixel.copy_from_slice(canvas.get(..n)?);
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// The filters sample cell centres, not a dense neighbourhood. On ordinary
|
||||
/// tiles read those sparse samples directly; keep only the original output
|
||||
/// pixels for alpha and selection mixing. Crystallize boundary tiles still
|
||||
|
||||
@@ -26,6 +26,7 @@ use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::photo_util::{BandChol, Csr};
|
||||
use crate::transform::Interp;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -744,170 +745,6 @@ fn band_order(pos: &[[f64; 2]], tris: &[[usize; 3]]) -> Vec<usize> {
|
||||
order
|
||||
}
|
||||
|
||||
/// A compressed sparse row matrix (symmetric, used by CG).
|
||||
struct Csr {
|
||||
rows: Vec<usize>,
|
||||
cols: Vec<usize>,
|
||||
vals: Vec<f64>,
|
||||
diag: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Csr {
|
||||
fn from_triplets(n: usize, mut t: Vec<(usize, usize, f64)>) -> Csr {
|
||||
t.sort_by_key(|a| (a.0, a.1));
|
||||
let mut rows = vec![0usize; n + 1];
|
||||
let mut cols = Vec::with_capacity(t.len());
|
||||
let mut vals: Vec<f64> = Vec::with_capacity(t.len());
|
||||
let mut last = (usize::MAX, usize::MAX);
|
||||
for (i, j, v) in t {
|
||||
if (i, j) == last {
|
||||
*vals.last_mut().unwrap_or(&mut 0.0) += v;
|
||||
continue;
|
||||
}
|
||||
last = (i, j);
|
||||
cols.push(j);
|
||||
vals.push(v);
|
||||
rows[i + 1] = cols.len();
|
||||
}
|
||||
for i in 0..n {
|
||||
rows[i + 1] = rows[i + 1].max(rows[i]);
|
||||
}
|
||||
let mut diag = vec![1.0; n];
|
||||
for (i, d) in diag.iter_mut().enumerate() {
|
||||
for k in rows[i]..rows[i + 1] {
|
||||
if cols[k] == i && vals[k] > 0.0 {
|
||||
*d = vals[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
Csr { rows, cols, vals, diag }
|
||||
}
|
||||
|
||||
fn mul(&self, x: &[f64], out: &mut [f64]) {
|
||||
for (i, o) in out.iter_mut().enumerate() {
|
||||
let mut s = 0.0;
|
||||
for k in self.rows[i]..self.rows[i + 1] {
|
||||
s += self.vals[k] * x[self.cols[k]];
|
||||
}
|
||||
*o = s;
|
||||
}
|
||||
}
|
||||
|
||||
/// Jacobi-preconditioned conjugate gradients, warm-started from `x`.
|
||||
fn solve(&self, b: &[f64], x: &mut [f64]) {
|
||||
let n = b.len();
|
||||
let mut r = vec![0.0; n];
|
||||
self.mul(x, &mut r);
|
||||
for i in 0..n {
|
||||
r[i] = b[i] - r[i];
|
||||
}
|
||||
let bn: f64 = b.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-30);
|
||||
let mut z: Vec<f64> = r.iter().zip(&self.diag).map(|(r, d)| r / d).collect();
|
||||
let mut p = z.clone();
|
||||
let mut rz: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
|
||||
let mut ap = vec![0.0; n];
|
||||
for _ in 0..(4 * n).clamp(50, 2000) {
|
||||
if r.iter().map(|v| v * v).sum::<f64>().sqrt() <= 1e-12 * bn {
|
||||
break;
|
||||
}
|
||||
self.mul(&p, &mut ap);
|
||||
let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum();
|
||||
if pap.abs() < 1e-300 {
|
||||
break;
|
||||
}
|
||||
let alpha = rz / pap;
|
||||
for i in 0..n {
|
||||
x[i] += alpha * p[i];
|
||||
r[i] -= alpha * ap[i];
|
||||
}
|
||||
for i in 0..n {
|
||||
z[i] = r[i] / self.diag[i];
|
||||
}
|
||||
let rz2: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
|
||||
let beta = rz2 / rz;
|
||||
rz = rz2;
|
||||
for i in 0..n {
|
||||
p[i] = z[i] + beta * p[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Banded Cholesky factor `A = L Lᵀ` (the mesh numbers its vertices in reverse Cuthill–McKee
|
||||
/// order, so the system's bandwidth is about one breadth-first front): factored once per pin
|
||||
/// set, then each global step is two triangular solves.
|
||||
struct BandChol {
|
||||
n: usize,
|
||||
p: usize,
|
||||
/// Row `i` holds `L[i][i - p ..= i]`.
|
||||
l: Vec<f64>,
|
||||
}
|
||||
|
||||
impl BandChol {
|
||||
/// `None` when the band would be too large (or the matrix isn't positive definite).
|
||||
fn new(a: &Csr) -> Option<BandChol> {
|
||||
let n = a.diag.len();
|
||||
let mut p = 0;
|
||||
for i in 0..n {
|
||||
for k in a.rows[i]..a.rows[i + 1] {
|
||||
p = p.max(i.abs_diff(a.cols[k]));
|
||||
}
|
||||
}
|
||||
if n.saturating_mul(p + 1) > 40_000_000 {
|
||||
return None;
|
||||
}
|
||||
let w = p + 1;
|
||||
let mut l = vec![0.0f64; n * w];
|
||||
for i in 0..n {
|
||||
for k in a.rows[i]..a.rows[i + 1] {
|
||||
let j = a.cols[k];
|
||||
if j <= i {
|
||||
l[i * w + (j + p - i)] = a.vals[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..n {
|
||||
let lo = i.saturating_sub(p);
|
||||
for j in lo..=i {
|
||||
let jlo = j.saturating_sub(p).max(lo);
|
||||
let mut s = l[i * w + (j + p - i)];
|
||||
for k in jlo..j {
|
||||
s -= l[i * w + (k + p - i)] * l[j * w + (k + p - j)];
|
||||
}
|
||||
if i == j {
|
||||
if s <= 0.0 || !s.is_finite() {
|
||||
return None;
|
||||
}
|
||||
l[i * w + p] = s.sqrt();
|
||||
} else {
|
||||
l[i * w + (j + p - i)] = s / l[j * w + p];
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(BandChol { n, p, l })
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_range_loop)] // band index arithmetic reads clearer with indices
|
||||
fn solve(&self, b: &[f64], x: &mut [f64]) {
|
||||
let (n, p, w) = (self.n, self.p, self.p + 1);
|
||||
let mut y = b.to_vec();
|
||||
for i in 0..n {
|
||||
let mut s = y[i];
|
||||
for k in i.saturating_sub(p)..i {
|
||||
s -= self.l[i * w + (k + p - i)] * y[k];
|
||||
}
|
||||
y[i] = s / self.l[i * w + p];
|
||||
}
|
||||
for i in (0..n).rev() {
|
||||
let mut s = y[i];
|
||||
for k in i + 1..(i + p + 1).min(n) {
|
||||
s -= self.l[k * w + (i + p - k)] * x[k];
|
||||
}
|
||||
x[i] = s / self.l[i * w + p];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pin bound to the mesh: barycentric weights on a triangle's vertices.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Bound {
|
||||
|
||||
@@ -639,53 +639,162 @@ fn spot(shape: HalftoneShape, u: f32, v: f32) -> f32 {
|
||||
|
||||
/// Converts gray values (0..=1, row-major `w` wide) to 0/1 in place.
|
||||
pub fn to_bitmap(gray: &mut [f32], w: usize, method: BitmapMethod) {
|
||||
let h = gray.len().checked_div(w).unwrap_or(0);
|
||||
match method {
|
||||
BitmapMethod::Threshold => gray.iter_mut().for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 }),
|
||||
BitmapMethod::Pattern => {
|
||||
for (i, v) in gray.iter_mut().enumerate() {
|
||||
*v = if *v > bayer8(i % w, i / w) + 0.5 { 1.0 } else { 0.0 };
|
||||
BitmapStrips::new(w, method).convert(gray);
|
||||
}
|
||||
|
||||
/// Image › Mode › Bitmap fed in strips of whole rows, top to bottom, so a huge image never needs a
|
||||
/// full-size gray plane (#8477): [`BitmapStrips::convert`] turns each strip to 0/1 in place,
|
||||
/// carrying error diffusion's error into the next strip. The result is the same however the image
|
||||
/// is cut into strips.
|
||||
pub struct BitmapStrips<'a> {
|
||||
w: usize,
|
||||
kind: StripKind<'a>,
|
||||
/// Image row of the next strip's first row.
|
||||
y: usize,
|
||||
}
|
||||
|
||||
enum StripKind<'a> {
|
||||
Threshold,
|
||||
Pattern,
|
||||
/// Floyd–Steinberg: the error each pixel of the last converted row passes down.
|
||||
Diffusion {
|
||||
errs: Vec<f32>,
|
||||
},
|
||||
/// The spot function's values over a cell, sorted (its CDF ranks coverage by tone).
|
||||
Halftone {
|
||||
cell: f32,
|
||||
sin: f32,
|
||||
cos: f32,
|
||||
shape: HalftoneShape,
|
||||
table: Vec<f32>,
|
||||
},
|
||||
/// Custom Pattern's threshold map, `mw` x `mh`, tiled from the top-left corner.
|
||||
Custom {
|
||||
map: &'a [f32],
|
||||
mw: usize,
|
||||
mh: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl<'a> BitmapStrips<'a> {
|
||||
pub fn new(w: usize, method: BitmapMethod) -> Self {
|
||||
let kind = match method {
|
||||
BitmapMethod::Threshold => StripKind::Threshold,
|
||||
BitmapMethod::Pattern => StripKind::Pattern,
|
||||
BitmapMethod::Diffusion => StripKind::Diffusion { errs: Vec::new() },
|
||||
BitmapMethod::Halftone { cell, angle, shape } => {
|
||||
let (sin, cos) = angle.to_radians().sin_cos();
|
||||
// Rank the spot function over the cell so coverage is proportional to the tone.
|
||||
let mut table: Vec<f32> =
|
||||
(0..32 * 32).map(|k| spot(shape, ((k % 32) as f32 + 0.5) / 16.0 - 1.0, ((k / 32) as f32 + 0.5) / 16.0 - 1.0)).collect();
|
||||
table.sort_by(f32::total_cmp);
|
||||
StripKind::Halftone { cell: cell.max(2.0), sin, cos, shape, table }
|
||||
}
|
||||
};
|
||||
Self { w, kind, y: 0 }
|
||||
}
|
||||
|
||||
/// Bitmap's Custom Pattern: each gray value turns white where it reaches the threshold `map`
|
||||
/// (`mw` x `mh`, from [`custom_pattern_thresholds`]) tiled from the image's top-left corner,
|
||||
/// black elsewhere. A map that doesn't fit `mw` x `mh` thresholds at 50%.
|
||||
pub fn custom(w: usize, map: &'a [f32], mw: usize, mh: usize) -> Self {
|
||||
let fits = mw > 0 && mh > 0 && mw.checked_mul(mh) == Some(map.len());
|
||||
let kind = if fits { StripKind::Custom { map, mw, mh } } else { StripKind::Threshold };
|
||||
Self { w, kind, y: 0 }
|
||||
}
|
||||
|
||||
/// Converts the next rows (gray 0..=1, `w` wide; a short last row is allowed) to 0/1 in place.
|
||||
pub fn convert(&mut self, gray: &mut [f32]) {
|
||||
let (w, y0) = (self.w, self.y);
|
||||
if w == 0 {
|
||||
// No rows to lay a pattern or diffuse along: a 50% threshold.
|
||||
gray.iter_mut().for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 });
|
||||
return;
|
||||
}
|
||||
BitmapMethod::Diffusion => {
|
||||
let mut buf: Vec<f32> = gray.to_vec();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
let old = buf[i];
|
||||
let new = if old >= 0.5 { 1.0 } else { 0.0 };
|
||||
let e = old - new;
|
||||
gray[i] = new;
|
||||
if x + 1 < w {
|
||||
buf[i + 1] += e * 7.0 / 16.0;
|
||||
}
|
||||
if y + 1 < h {
|
||||
if x > 0 {
|
||||
buf[i + w - 1] += e * 3.0 / 16.0;
|
||||
}
|
||||
buf[i + w] += e * 5.0 / 16.0;
|
||||
if x + 1 < w {
|
||||
buf[i + w + 1] += e / 16.0;
|
||||
}
|
||||
}
|
||||
self.y = y0.saturating_add(gray.len().div_ceil(w));
|
||||
// Every method but diffusion is pointwise: rows convert in parallel.
|
||||
let par = gray.len() >= 1 << 16;
|
||||
match &mut self.kind {
|
||||
StripKind::Threshold => crate::par::chunks_mut(gray, w, par, |_, row| {
|
||||
row.iter_mut().for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 });
|
||||
}),
|
||||
StripKind::Pattern => crate::par::chunks_mut(gray, w, par, |r, row| {
|
||||
for (x, v) in row.iter_mut().enumerate() {
|
||||
*v = if *v > bayer8(x, y0 + r) + 0.5 { 1.0 } else { 0.0 };
|
||||
}
|
||||
}),
|
||||
StripKind::Diffusion { errs } => {
|
||||
// The previous strip's last row passes its error down to this strip's first.
|
||||
let first = w.min(gray.len());
|
||||
pass_down(errs, gray.get_mut(..first).unwrap_or_default());
|
||||
let mut rest = gray;
|
||||
while !rest.is_empty() {
|
||||
let n = w.min(rest.len());
|
||||
let (row, tail) = std::mem::take(&mut rest).split_at_mut(n);
|
||||
diffuse_row(row, errs);
|
||||
let below = w.min(tail.len());
|
||||
pass_down(errs, tail.get_mut(..below).unwrap_or_default());
|
||||
rest = tail;
|
||||
}
|
||||
}
|
||||
}
|
||||
BitmapMethod::Halftone { cell, angle, shape } => {
|
||||
let cell = cell.max(2.0);
|
||||
let (s, c) = angle.to_radians().sin_cos();
|
||||
// Rank the spot function over the cell so coverage is proportional to the tone.
|
||||
let mut table: Vec<f32> = (0..32 * 32).map(|k| spot(shape, ((k % 32) as f32 + 0.5) / 16.0 - 1.0, ((k / 32) as f32 + 0.5) / 16.0 - 1.0)).collect();
|
||||
table.sort_by(f32::total_cmp);
|
||||
let cdf = |x: f32| table.partition_point(|t| *t < x) as f32 / table.len() as f32;
|
||||
for (i, v) in gray.iter_mut().enumerate() {
|
||||
let (x, y) = ((i % w) as f32 + 0.5, (i / w) as f32 + 0.5);
|
||||
let (rx, ry) = (x * c + y * s, -x * s + y * c);
|
||||
let u = (rx / cell).rem_euclid(1.0) * 2.0 - 1.0;
|
||||
let vv = (ry / cell).rem_euclid(1.0) * 2.0 - 1.0;
|
||||
// Ink (black) covers where the spot function exceeds the gray level.
|
||||
*v = if cdf(spot(shape, u, vv)) >= *v { 0.0 } else { 1.0 };
|
||||
StripKind::Halftone { cell, sin, cos, shape, table } => {
|
||||
let (cell, s, c, shape, table) = (*cell, *sin, *cos, *shape, &*table);
|
||||
let cdf = |x: f32| table.partition_point(|t| *t < x) as f32 / table.len() as f32;
|
||||
crate::par::chunks_mut(gray, w, par, |r, row| {
|
||||
let y = (y0 + r) as f32 + 0.5;
|
||||
for (x, v) in row.iter_mut().enumerate() {
|
||||
let x = x as f32 + 0.5;
|
||||
let (rx, ry) = (x * c + y * s, -x * s + y * c);
|
||||
let u = (rx / cell).rem_euclid(1.0) * 2.0 - 1.0;
|
||||
let vv = (ry / cell).rem_euclid(1.0) * 2.0 - 1.0;
|
||||
// Ink (black) covers where the spot function exceeds the gray level.
|
||||
*v = if cdf(spot(shape, u, vv)) >= *v { 0.0 } else { 1.0 };
|
||||
}
|
||||
});
|
||||
}
|
||||
StripKind::Custom { map, mw, mh } => {
|
||||
let (map, mw, mh) = (&**map, *mw, *mh);
|
||||
crate::par::chunks_mut(gray, w, par, |r, row| {
|
||||
let base = (y0 + r) % mh * mw;
|
||||
for (x, v) in row.iter_mut().enumerate() {
|
||||
let t = map.get(base + x % mw).copied().unwrap_or(0.5);
|
||||
*v = if *v >= t { 1.0 } else { 0.0 };
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One Floyd–Steinberg row, in place: each pixel takes its error from the left (7/16), and
|
||||
/// `errs` gets the error it passes down to the next row ([`pass_down`]).
|
||||
fn diffuse_row(row: &mut [f32], errs: &mut Vec<f32>) {
|
||||
errs.clear();
|
||||
for x in 0..row.len() {
|
||||
let old = row[x];
|
||||
let new = if old >= 0.5 { 1.0 } else { 0.0 };
|
||||
let e = old - new;
|
||||
row[x] = new;
|
||||
if let Some(next) = row.get_mut(x + 1) {
|
||||
*next += e * 7.0 / 16.0;
|
||||
}
|
||||
errs.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the error `errs` of a row to the row below: 1/16 from the pixel up-left, 5/16 from the
|
||||
/// one above, 3/16 from the one up-right, in that order (the order the row's pixels were
|
||||
/// converted in), so the result is the same whether or not the rows are in the same strip.
|
||||
fn pass_down(errs: &[f32], below: &mut [f32]) {
|
||||
for (t, v) in below.iter_mut().enumerate() {
|
||||
if let Some(e) = t.checked_sub(1).and_then(|l| errs.get(l)) {
|
||||
*v += e / 16.0;
|
||||
}
|
||||
if let Some(e) = errs.get(t) {
|
||||
*v += e * 5.0 / 16.0;
|
||||
}
|
||||
if let Some(e) = errs.get(t + 1) {
|
||||
*v += e * 3.0 / 16.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -719,15 +828,10 @@ pub fn custom_pattern_thresholds(lightness: &[f32]) -> Vec<f32> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Bitmap's Custom Pattern: each gray value (0..=1, row-major `w` wide) turns white where it
|
||||
/// reaches the threshold `map` (`mw` x `mh`, from [`custom_pattern_thresholds`]) tiled from the
|
||||
/// image's top-left corner, black elsewhere. A map that doesn't fit `mw` x `mh` thresholds at 50%.
|
||||
/// Bitmap's Custom Pattern over a whole image (gray 0..=1, row-major `w` wide), see
|
||||
/// [`BitmapStrips::custom`]. An image `w` of 0 thresholds at 50%.
|
||||
pub fn to_bitmap_custom(gray: &mut [f32], w: usize, map: &[f32], mw: usize, mh: usize) {
|
||||
let fits = w > 0 && mw > 0 && mh > 0 && mw.checked_mul(mh) == Some(map.len());
|
||||
for (i, v) in gray.iter_mut().enumerate() {
|
||||
let t = if fits { map.get((i / w) % mh * mw + (i % w) % mw).copied().unwrap_or(0.5) } else { 0.5 };
|
||||
*v = if *v >= t { 1.0 } else { 0.0 };
|
||||
}
|
||||
BitmapStrips::custom(w, map, mw, mh).convert(gray);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -440,3 +440,110 @@ fn custom_pattern_tiles_from_the_top_left() {
|
||||
to_bitmap_custom(&mut g, 0, &map, 2, 1);
|
||||
assert_eq!(g, [1.0]);
|
||||
}
|
||||
|
||||
/// `gray` converted by `conv` in strips of `rows` rows each, cycling through `rows`.
|
||||
fn in_strips(gray: &[f32], w: usize, rows: &[usize], mut conv: BitmapStrips<'_>) -> Vec<f32> {
|
||||
let mut out = gray.to_vec();
|
||||
let mut rest = out.as_mut_slice();
|
||||
for r in rows.iter().cycle() {
|
||||
if rest.is_empty() {
|
||||
break;
|
||||
}
|
||||
let (strip, tail) = rest.split_at_mut((r * w).min(rest.len()));
|
||||
conv.convert(strip);
|
||||
rest = tail;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_strips_match_the_whole_image_however_it_is_cut() {
|
||||
// #8477: strip boundaries must preserve diffusion state and keep patterns/screens anchored.
|
||||
let (w, h) = (37, 29);
|
||||
let gray: Vec<f32> = (0..w * h).map(|i| ((i % w) as f32 / w as f32 * 0.7 + (i / w) as f32 / h as f32 * 0.3).fract()).collect();
|
||||
let map = custom_pattern_thresholds(&[0.1, 0.9, 0.4, 0.6, 0.2, 0.8]);
|
||||
let methods = [
|
||||
BitmapMethod::Threshold,
|
||||
BitmapMethod::Pattern,
|
||||
BitmapMethod::Diffusion,
|
||||
BitmapMethod::Halftone { cell: 5.5, angle: 30.0, shape: HalftoneShape::Ellipse },
|
||||
];
|
||||
for rows in [&[1][..], &[3], &[5, 2, 7], &[h]] {
|
||||
for method in methods {
|
||||
let mut whole = gray.clone();
|
||||
to_bitmap(&mut whole, w, method);
|
||||
assert!(whole.iter().all(|v| *v == 0.0 || *v == 1.0), "{method:?}");
|
||||
assert_eq!(in_strips(&gray, w, rows, BitmapStrips::new(w, method)), whole, "{method:?} in strips of {rows:?}");
|
||||
}
|
||||
let mut whole = gray.clone();
|
||||
to_bitmap_custom(&mut whole, w, &map, 3, 2);
|
||||
assert_eq!(in_strips(&gray, w, rows, BitmapStrips::custom(w, &map, 3, 2)), whole, "custom in strips of {rows:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_strips_match_the_whole_image_with_a_ragged_last_row() {
|
||||
// A short final row still receives the error from the row above, including in its own strip.
|
||||
let w = 37;
|
||||
let gray: Vec<f32> = (0..w * 5 + 11).map(|i| ((i % w) as f32 / w as f32 * 0.7 + (i / w) as f32 * 0.13).fract()).collect();
|
||||
for method in [BitmapMethod::Diffusion, BitmapMethod::Threshold, BitmapMethod::Pattern] {
|
||||
let mut whole = gray.clone();
|
||||
to_bitmap(&mut whole, w, method);
|
||||
for rows in [&[1][..], &[5], &[2, 3], &[5, 1]] {
|
||||
assert_eq!(in_strips(&gray, w, rows, BitmapStrips::new(w, method)), whole, "{method:?} in strips of {rows:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Floyd–Steinberg over the whole image as `to_bitmap` did before strips (#8477).
|
||||
fn diffusion_reference(gray: &mut [f32], w: usize) {
|
||||
let h = gray.len() / w;
|
||||
let mut buf = gray.to_vec();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
let old = buf[i];
|
||||
let new = if old >= 0.5 { 1.0 } else { 0.0 };
|
||||
let error = old - new;
|
||||
gray[i] = new;
|
||||
if x + 1 < w {
|
||||
buf[i + 1] += error * 7.0 / 16.0;
|
||||
}
|
||||
if y + 1 < h {
|
||||
if x > 0 {
|
||||
buf[i + w - 1] += error * 3.0 / 16.0;
|
||||
}
|
||||
buf[i + w] += error * 5.0 / 16.0;
|
||||
if x + 1 < w {
|
||||
buf[i + w + 1] += error / 16.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_diffusion_in_strips_is_bit_identical_to_one_pass() {
|
||||
// Keep error accumulation order identical; one rounding change can flip many later pixels.
|
||||
let (w, h) = (173, 61);
|
||||
let gray: Vec<f32> = (0..w * h).map(|i| ((i % w) as f32 * 0.013 + (i / w) as f32 * 0.007 + ((i * 7919) % 101) as f32 * 0.001).fract()).collect();
|
||||
let mut expected = gray.clone();
|
||||
diffusion_reference(&mut expected, w);
|
||||
for rows in [&[1][..], &[4, 9], &[h]] {
|
||||
assert_eq!(in_strips(&gray, w, rows, BitmapStrips::new(w, BitmapMethod::Diffusion)), expected, "strips of {rows:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_strips_handle_empty_and_ragged_input() {
|
||||
// A zero width thresholds at 50%, and a short final row is converted too.
|
||||
for method in [BitmapMethod::Pattern, BitmapMethod::Diffusion, BitmapMethod::Halftone { cell: 4.0, angle: 0.0, shape: HalftoneShape::Round }] {
|
||||
let mut gray = vec![0.4, 0.6];
|
||||
to_bitmap(&mut gray, 0, method);
|
||||
assert_eq!(gray, [0.0, 1.0], "{method:?}");
|
||||
let mut gray = vec![0.3; 7];
|
||||
to_bitmap(&mut gray, 3, method);
|
||||
assert!(gray.iter().all(|v| *v == 0.0 || *v == 1.0), "{method:?}: {gray:?}");
|
||||
to_bitmap(&mut [], 3, method);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1236,256 +1236,320 @@ fn star(out: &mut Vec<Prim>, c: (f32, f32), r: f32, points: usize, col: [f32; 4]
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds picture-frame primitives inside `canvas`.
|
||||
pub fn picture_frame(spec: &FrameSpec, canvas: Rect) -> Vec<Prim> {
|
||||
let mut out = Vec::new();
|
||||
let short = (canvas.width().min(canvas.height()) as f32).max(8.0);
|
||||
let margin = short * spec.margin.clamp(0.0, 30.0) / 100.0;
|
||||
let unit = short * (0.008 + spec.size.clamp(1.0, 100.0) / 100.0 * 0.05);
|
||||
let thick = (unit * 0.04 + short * 0.00012 * spec.thickness.clamp(1.0, 100.0)).max(0.6);
|
||||
let spacing = unit * (4.0 - 3.2 * spec.arrangement.clamp(1.0, 100.0) / 100.0);
|
||||
let inset = margin + unit;
|
||||
let b = Border {
|
||||
x0: canvas.x0 as f32 + inset,
|
||||
y0: canvas.y0 as f32 + inset,
|
||||
w: (canvas.width() as f32 - 2.0 * inset).max(1.0),
|
||||
h: (canvas.height() as f32 - 2.0 * inset).max(1.0),
|
||||
};
|
||||
let total = b.len();
|
||||
let count = (total / spacing).round().max(4.0) as usize;
|
||||
let step = total / count as f32;
|
||||
let mut rng = Rng::new(spec.seed, 0xF8A3E + spec.frame as u64);
|
||||
let (vine, fl, lf) = (spec.vine_color, spec.flower_color, spec.leaf_color);
|
||||
let style = FRAME_STYLES[(spec.frame.clamp(1, FRAME_STYLES.len() as u32) - 1) as usize];
|
||||
let lines = spec.lines.clamp(1, 5) as usize;
|
||||
let white = [1.0, 1.0, 1.0, 1.0];
|
||||
let yellow = [0.98, 0.82, 0.2, 1.0];
|
||||
// A wavy vine along the border.
|
||||
let vine_path = |out: &mut Vec<Prim>, amp: f32, wl: f32, col: [f32; 4], r: f32| {
|
||||
let n = (total / 3.0).ceil().max(8.0) as usize;
|
||||
/// Resolved geometry and palette shared by every picture-frame style.
|
||||
struct FrameLayout {
|
||||
b: Border,
|
||||
unit: f32,
|
||||
thick: f32,
|
||||
spacing: f32,
|
||||
total: f32,
|
||||
count: usize,
|
||||
step: f32,
|
||||
lines: usize,
|
||||
vine: [f32; 4],
|
||||
fl: [f32; 4],
|
||||
lf: [f32; 4],
|
||||
style: &'static str,
|
||||
}
|
||||
|
||||
const FRAME_WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
|
||||
const FRAME_YELLOW: [f32; 4] = [0.98, 0.82, 0.2, 1.0];
|
||||
|
||||
fn wave_off(s: f32, amp: f32, wl: f32) -> f32 {
|
||||
amp * (s / wl * std::f32::consts::TAU).sin()
|
||||
}
|
||||
|
||||
impl FrameLayout {
|
||||
fn new(spec: &FrameSpec, canvas: Rect) -> Self {
|
||||
let short = (canvas.width().min(canvas.height()) as f32).max(8.0);
|
||||
let margin = short * spec.margin.clamp(0.0, 30.0) / 100.0;
|
||||
let unit = short * (0.008 + spec.size.clamp(1.0, 100.0) / 100.0 * 0.05);
|
||||
let thick = (unit * 0.04 + short * 0.00012 * spec.thickness.clamp(1.0, 100.0)).max(0.6);
|
||||
let spacing = unit * (4.0 - 3.2 * spec.arrangement.clamp(1.0, 100.0) / 100.0);
|
||||
let inset = margin + unit;
|
||||
let b = Border {
|
||||
x0: canvas.x0 as f32 + inset,
|
||||
y0: canvas.y0 as f32 + inset,
|
||||
w: (canvas.width() as f32 - 2.0 * inset).max(1.0),
|
||||
h: (canvas.height() as f32 - 2.0 * inset).max(1.0),
|
||||
};
|
||||
let total = b.len();
|
||||
let count = (total / spacing).round().max(4.0) as usize;
|
||||
Self {
|
||||
b,
|
||||
unit,
|
||||
thick,
|
||||
spacing,
|
||||
total,
|
||||
count,
|
||||
step: total / count as f32,
|
||||
lines: spec.lines.clamp(1, 5) as usize,
|
||||
vine: spec.vine_color,
|
||||
fl: spec.flower_color,
|
||||
lf: spec.leaf_color,
|
||||
style: FRAME_STYLES[(spec.frame.clamp(1, FRAME_STYLES.len() as u32) - 1) as usize],
|
||||
}
|
||||
}
|
||||
|
||||
/// A wavy vine along the border.
|
||||
fn vine_path(&self, out: &mut Vec<Prim>, amp: f32, wl: f32, col: [f32; 4], r: f32) {
|
||||
let n = (self.total / 3.0).ceil().max(8.0) as usize;
|
||||
let mut prev: Option<(f32, f32)> = None;
|
||||
for k in 0..=n {
|
||||
let s = k as f32 / n as f32 * total;
|
||||
let (p, _, nn) = b.at(s);
|
||||
let o = amp * (s / wl * std::f32::consts::TAU).sin();
|
||||
let s = k as f32 / n as f32 * self.total;
|
||||
let (p, _, nn) = self.b.at(s);
|
||||
let o = wave_off(s, amp, wl);
|
||||
let q = (p.0 + nn.0 * o, p.1 + nn.1 * o);
|
||||
if let Some(pp) = prev {
|
||||
out.push(Prim::capsule(pp, q, r, r, col, col));
|
||||
}
|
||||
prev = Some(q);
|
||||
}
|
||||
};
|
||||
let wave_off = |s: f32, amp: f32, wl: f32| amp * (s / wl * std::f32::consts::TAU).sin();
|
||||
match style {
|
||||
"vineWithFlowers" | "vineWithLeaves" | "ivy" | "roses" | "daisies" | "berries" => {
|
||||
let amp = unit * 0.5;
|
||||
let wl = step * 2.0;
|
||||
vine_path(&mut out, amp, wl, vine, thick);
|
||||
for k in 0..count {
|
||||
let s = k as f32 * step + step * 0.5;
|
||||
let (p, t, nn) = b.at(s);
|
||||
let o = wave_off(s, amp, wl);
|
||||
let q = (p.0 + nn.0 * o, p.1 + nn.1 * o);
|
||||
let side = if k % 2 == 0 { 1.0 } else { -1.0 };
|
||||
let ta = t.1.atan2(t.0);
|
||||
let la = ta + side * 0.9 + rng.sym() * 0.3;
|
||||
let ll = unit * rng.range(0.9, 1.2);
|
||||
match style {
|
||||
"vineWithFlowers" => {
|
||||
leaf(&mut out, q, la, ll, lf);
|
||||
if k % 2 == 0 {
|
||||
let c = (q.0 + nn.0 * unit * 0.7 * side, q.1 + nn.1 * unit * 0.7 * side);
|
||||
flower(&mut out, c, unit * 0.65, 5, fl, yellow, rng.f() * 6.3);
|
||||
}
|
||||
}
|
||||
"vineWithLeaves" => {
|
||||
leaf(&mut out, q, la, ll, lf);
|
||||
leaf(&mut out, q, ta - side * 0.9, ll * 0.8, lf);
|
||||
}
|
||||
"ivy" => {
|
||||
for j in 0..3 {
|
||||
let a = la + (j as f32 - 1.0) * 0.7;
|
||||
leaf(&mut out, q, a, ll * 0.75, shade([lf[0], lf[1], lf[2]], 0.75 + 0.15 * j as f32));
|
||||
}
|
||||
}
|
||||
"roses" => {
|
||||
leaf(&mut out, q, la, ll, lf);
|
||||
if k % 3 == 0 {
|
||||
let r = unit * 0.7;
|
||||
for (j, f) in [1.0f32, 0.75, 0.5, 0.28].iter().enumerate() {
|
||||
let c = shade([fl[0], fl[1], fl[2]], 0.75 + 0.1 * j as f32);
|
||||
out.push(Prim::ellipse(q, r * f, r * f * 0.9, rng.f() * 3.0, c, shade([fl[0], fl[1], fl[2]], 0.55)));
|
||||
}
|
||||
}
|
||||
}
|
||||
"daisies" => {
|
||||
leaf(&mut out, q, la, ll * 0.8, lf);
|
||||
if k % 2 == 1 {
|
||||
flower(&mut out, q, unit * 0.6, 12, white, yellow, rng.f());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
leaf(&mut out, q, la, ll, lf);
|
||||
for _ in 0..3 {
|
||||
let c = (q.0 + rng.sym() * unit * 0.4, q.1 + rng.sym() * unit * 0.4);
|
||||
out.push(Prim::ellipse(c, unit * 0.2, unit * 0.2, 0.0, shade([fl[0], fl[1], fl[2]], 1.2), shade([fl[0], fl[1], fl[2]], 0.6)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"bamboo" => {
|
||||
for edge in 0..4 {
|
||||
let (s0, l) = match edge {
|
||||
0 => (0.0, b.w),
|
||||
1 => (b.w, b.h),
|
||||
2 => (b.w + b.h, b.w),
|
||||
_ => (2.0 * b.w + b.h, b.h),
|
||||
};
|
||||
let (a, _, _) = b.at(s0 + 0.01);
|
||||
let (z, _, _) = b.at(s0 + l - 0.01);
|
||||
let joints = (l / (spacing * 2.0)).round().max(1.0) as usize;
|
||||
for j in 0..joints {
|
||||
let f0 = j as f32 / joints as f32;
|
||||
let f1 = (j + 1) as f32 / joints as f32;
|
||||
let p0 = (a.0 + (z.0 - a.0) * f0, a.1 + (z.1 - a.1) * f0);
|
||||
let p1 = (a.0 + (z.0 - a.0) * f1, a.1 + (z.1 - a.1) * f1);
|
||||
out.push(Prim::capsule(p0, p1, unit * 0.35, unit * 0.35, vine, shade([vine[0], vine[1], vine[2]], 0.8)));
|
||||
out.push(Prim::disc(p1, unit * 0.4, shade([vine[0], vine[1], vine[2]], 0.7)));
|
||||
}
|
||||
// A few leaves per edge.
|
||||
for _ in 0..(joints / 2).max(1) {
|
||||
let f = rng.f();
|
||||
let p = (a.0 + (z.0 - a.0) * f, a.1 + (z.1 - a.1) * f);
|
||||
leaf(&mut out, p, rng.f() * 6.3, unit * 1.4, lf);
|
||||
}
|
||||
}
|
||||
}
|
||||
"waves" | "zigzag" | "doubleLine" | "rope" => {
|
||||
for li in 0..lines {
|
||||
let off = (li as f32 - (lines - 1) as f32 / 2.0) * thick * 4.0;
|
||||
let n = (total / 2.0).ceil().max(8.0) as usize;
|
||||
let mut prev: Option<(f32, f32)> = None;
|
||||
for k in 0..=n {
|
||||
let s = k as f32 / n as f32 * total;
|
||||
let (p, _, nn) = b.at(s);
|
||||
let o = off
|
||||
+ match style {
|
||||
"waves" => wave_off(s + li as f32 * step * 0.25, unit * 0.4, step),
|
||||
"zigzag" => {
|
||||
let ph = (s / step).fract();
|
||||
unit * 0.4 * (4.0 * (ph - 0.5).abs() - 1.0)
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
let q = (p.0 + nn.0 * o, p.1 + nn.1 * o);
|
||||
if style == "rope" {
|
||||
if k % 2 == 0 {
|
||||
let (_, t, _) = b.at(s);
|
||||
out.push(Prim::ellipse(
|
||||
q,
|
||||
unit * 0.45,
|
||||
unit * 0.2,
|
||||
t.1.atan2(t.0) + 0.7,
|
||||
shade([vine[0], vine[1], vine[2]], 1.1),
|
||||
shade([vine[0], vine[1], vine[2]], 0.65),
|
||||
));
|
||||
}
|
||||
} else if let Some(pp) = prev {
|
||||
out.push(Prim::capsule(pp, q, thick, thick, vine, vine));
|
||||
}
|
||||
prev = Some(q);
|
||||
}
|
||||
if style == "doubleLine" {
|
||||
// A second rule inside each line and corner squares.
|
||||
let inner = Border {
|
||||
x0: b.x0 + unit * 0.6 + off,
|
||||
y0: b.y0 + unit * 0.6 + off,
|
||||
w: b.w - 2.0 * (unit * 0.6 + off),
|
||||
h: b.h - 2.0 * (unit * 0.6 + off),
|
||||
};
|
||||
for e in 0..4 {
|
||||
let (p, _, _) = inner.at([0.0, inner.w, inner.w + inner.h, 2.0 * inner.w + inner.h][e]);
|
||||
let (q, _, _) = inner.at([inner.w, inner.w + inner.h, 2.0 * inner.w + inner.h, inner.len() - 0.001][e]);
|
||||
out.push(Prim::capsule(p, q, thick * 0.6, thick * 0.6, vine, vine));
|
||||
}
|
||||
}
|
||||
}
|
||||
if style == "doubleLine" {
|
||||
for e in 0..4 {
|
||||
let (p, _, _) = b.at([0.0, b.w, b.w + b.h, 2.0 * b.w + b.h][e]);
|
||||
out.push(Prim::ellipse(p, unit * 0.45, unit * 0.45, std::f32::consts::FRAC_PI_4, fl, fl));
|
||||
}
|
||||
}
|
||||
}
|
||||
"dots" | "scallops" | "snowflakes" | "stars" | "hearts" => {
|
||||
for k in 0..count {
|
||||
let s = k as f32 * step;
|
||||
let (p, t, nn) = b.at(s);
|
||||
let ta = t.1.atan2(t.0);
|
||||
match style {
|
||||
"dots" => {
|
||||
let r = unit * if k % 2 == 0 { 0.4 } else { 0.22 };
|
||||
out.push(Prim::ellipse(p, r, r, 0.0, shade([fl[0], fl[1], fl[2]], 1.15), shade([fl[0], fl[1], fl[2]], 0.75)));
|
||||
}
|
||||
"scallops" => {
|
||||
let (c0, _, _) = b.at(s - step * 0.5);
|
||||
let mid = ((c0.0 + p.0) / 2.0, (c0.1 + p.1) / 2.0);
|
||||
let n = 8;
|
||||
for j in 0..n {
|
||||
let a0 = std::f32::consts::PI * j as f32 / n as f32;
|
||||
let a1 = std::f32::consts::PI * (j + 1) as f32 / n as f32;
|
||||
let r = step * 0.5;
|
||||
let pt = |a: f32| (mid.0 - t.0 * r * a.cos() - nn.0 * r * a.sin(), mid.1 - t.1 * r * a.cos() - nn.1 * r * a.sin());
|
||||
out.push(Prim::capsule(pt(a0), pt(a1), thick, thick, vine, vine));
|
||||
}
|
||||
}
|
||||
"snowflakes" => {
|
||||
let r = unit * 0.6;
|
||||
let c = [0.85, 0.92, 1.0, 1.0];
|
||||
for j in 0..6 {
|
||||
let a = ta + j as f32 * std::f32::consts::FRAC_PI_3;
|
||||
let e = (p.0 + a.cos() * r, p.1 + a.sin() * r);
|
||||
out.push(Prim::capsule(p, e, (thick * 0.6).max(0.6), (thick * 0.4).max(0.5), c, c));
|
||||
let m = (p.0 + a.cos() * r * 0.6, p.1 + a.sin() * r * 0.6);
|
||||
for sd in [-0.7f32, 0.7] {
|
||||
let b2 = (m.0 + (a + sd).cos() * r * 0.3, m.1 + (a + sd).sin() * r * 0.3);
|
||||
out.push(Prim::capsule(m, b2, (thick * 0.4).max(0.5), 0.4, c, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
"stars" => star(&mut out, p, unit * 0.55, 5, if k % 2 == 0 { yellow } else { fl }, ta - std::f32::consts::FRAC_PI_2),
|
||||
_ => {
|
||||
// Heart: two lobes and a point towards the inside.
|
||||
let r = unit * 0.3;
|
||||
let (tx, ty) = t;
|
||||
let l = (p.0 - tx * r * 0.8 - nn.0 * r * 0.3, p.1 - ty * r * 0.8 - nn.1 * r * 0.3);
|
||||
let rr = (p.0 + tx * r * 0.8 - nn.0 * r * 0.3, p.1 + ty * r * 0.8 - nn.1 * r * 0.3);
|
||||
let tip = (p.0 + nn.0 * r * 1.6, p.1 + nn.1 * r * 1.6);
|
||||
out.push(Prim::capsule(l, tip, r, 0.6, fl, fl));
|
||||
out.push(Prim::capsule(rr, tip, r, 0.6, fl, fl));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Fade: lower the opacity of ornaments that reach further inside.
|
||||
let fade = spec.fade.clamp(0.0, 100.0) / 100.0;
|
||||
if fade > 0.0 {
|
||||
|
||||
/// Vine styles: a wavy stem with a leaf cluster or bloom at each step.
|
||||
fn vine_styles(&self, out: &mut Vec<Prim>, rng: &mut Rng) {
|
||||
let (unit, step, fl, lf) = (self.unit, self.step, self.fl, self.lf);
|
||||
let amp = unit * 0.5;
|
||||
let wl = step * 2.0;
|
||||
self.vine_path(out, amp, wl, self.vine, self.thick);
|
||||
for k in 0..self.count {
|
||||
let s = k as f32 * step + step * 0.5;
|
||||
let (p, t, nn) = self.b.at(s);
|
||||
let o = wave_off(s, amp, wl);
|
||||
let q = (p.0 + nn.0 * o, p.1 + nn.1 * o);
|
||||
let side = if k % 2 == 0 { 1.0 } else { -1.0 };
|
||||
let ta = t.1.atan2(t.0);
|
||||
let la = ta + side * 0.9 + rng.sym() * 0.3;
|
||||
let ll = unit * rng.range(0.9, 1.2);
|
||||
match self.style {
|
||||
"vineWithFlowers" => {
|
||||
leaf(out, q, la, ll, lf);
|
||||
if k % 2 == 0 {
|
||||
let c = (q.0 + nn.0 * unit * 0.7 * side, q.1 + nn.1 * unit * 0.7 * side);
|
||||
flower(out, c, unit * 0.65, 5, fl, FRAME_YELLOW, rng.f() * 6.3);
|
||||
}
|
||||
}
|
||||
"vineWithLeaves" => {
|
||||
leaf(out, q, la, ll, lf);
|
||||
leaf(out, q, ta - side * 0.9, ll * 0.8, lf);
|
||||
}
|
||||
"ivy" => {
|
||||
for j in 0..3 {
|
||||
let a = la + (j as f32 - 1.0) * 0.7;
|
||||
leaf(out, q, a, ll * 0.75, shade([lf[0], lf[1], lf[2]], 0.75 + 0.15 * j as f32));
|
||||
}
|
||||
}
|
||||
"roses" => {
|
||||
leaf(out, q, la, ll, lf);
|
||||
if k % 3 == 0 {
|
||||
let r = unit * 0.7;
|
||||
for (j, f) in [1.0f32, 0.75, 0.5, 0.28].iter().enumerate() {
|
||||
let c = shade([fl[0], fl[1], fl[2]], 0.75 + 0.1 * j as f32);
|
||||
out.push(Prim::ellipse(q, r * f, r * f * 0.9, rng.f() * 3.0, c, shade([fl[0], fl[1], fl[2]], 0.55)));
|
||||
}
|
||||
}
|
||||
}
|
||||
"daisies" => {
|
||||
leaf(out, q, la, ll * 0.8, lf);
|
||||
if k % 2 == 1 {
|
||||
flower(out, q, unit * 0.6, 12, FRAME_WHITE, FRAME_YELLOW, rng.f());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
leaf(out, q, la, ll, lf);
|
||||
for _ in 0..3 {
|
||||
let c = (q.0 + rng.sym() * unit * 0.4, q.1 + rng.sym() * unit * 0.4);
|
||||
out.push(Prim::ellipse(c, unit * 0.2, unit * 0.2, 0.0, shade([fl[0], fl[1], fl[2]], 1.2), shade([fl[0], fl[1], fl[2]], 0.6)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bamboo: jointed canes along each edge with a few leaves.
|
||||
fn bamboo(&self, out: &mut Vec<Prim>, rng: &mut Rng) {
|
||||
let (b, unit, vine) = (&self.b, self.unit, self.vine);
|
||||
for edge in 0..4 {
|
||||
let (s0, l) = match edge {
|
||||
0 => (0.0, b.w),
|
||||
1 => (b.w, b.h),
|
||||
2 => (b.w + b.h, b.w),
|
||||
_ => (2.0 * b.w + b.h, b.h),
|
||||
};
|
||||
let (a, _, _) = b.at(s0 + 0.01);
|
||||
let (z, _, _) = b.at(s0 + l - 0.01);
|
||||
let joints = (l / (self.spacing * 2.0)).round().max(1.0) as usize;
|
||||
for j in 0..joints {
|
||||
let f0 = j as f32 / joints as f32;
|
||||
let f1 = (j + 1) as f32 / joints as f32;
|
||||
let p0 = (a.0 + (z.0 - a.0) * f0, a.1 + (z.1 - a.1) * f0);
|
||||
let p1 = (a.0 + (z.0 - a.0) * f1, a.1 + (z.1 - a.1) * f1);
|
||||
out.push(Prim::capsule(p0, p1, unit * 0.35, unit * 0.35, vine, shade([vine[0], vine[1], vine[2]], 0.8)));
|
||||
out.push(Prim::disc(p1, unit * 0.4, shade([vine[0], vine[1], vine[2]], 0.7)));
|
||||
}
|
||||
// A few leaves per edge.
|
||||
for _ in 0..(joints / 2).max(1) {
|
||||
let f = rng.f();
|
||||
let p = (a.0 + (z.0 - a.0) * f, a.1 + (z.1 - a.1) * f);
|
||||
leaf(out, p, rng.f() * 6.3, unit * 1.4, self.lf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Continuous rule styles: waves, zigzag, double line and rope.
|
||||
fn rule_styles(&self, out: &mut Vec<Prim>) {
|
||||
let (b, unit, thick, step, vine) = (&self.b, self.unit, self.thick, self.step, self.vine);
|
||||
for li in 0..self.lines {
|
||||
let off = (li as f32 - (self.lines - 1) as f32 / 2.0) * thick * 4.0;
|
||||
let n = (self.total / 2.0).ceil().max(8.0) as usize;
|
||||
let mut prev: Option<(f32, f32)> = None;
|
||||
for k in 0..=n {
|
||||
let s = k as f32 / n as f32 * self.total;
|
||||
let (p, _, nn) = b.at(s);
|
||||
let o = off
|
||||
+ match self.style {
|
||||
"waves" => wave_off(s + li as f32 * step * 0.25, unit * 0.4, step),
|
||||
"zigzag" => {
|
||||
let ph = (s / step).fract();
|
||||
unit * 0.4 * (4.0 * (ph - 0.5).abs() - 1.0)
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
let q = (p.0 + nn.0 * o, p.1 + nn.1 * o);
|
||||
if self.style == "rope" {
|
||||
if k % 2 == 0 {
|
||||
let (_, t, _) = b.at(s);
|
||||
out.push(Prim::ellipse(
|
||||
q,
|
||||
unit * 0.45,
|
||||
unit * 0.2,
|
||||
t.1.atan2(t.0) + 0.7,
|
||||
shade([vine[0], vine[1], vine[2]], 1.1),
|
||||
shade([vine[0], vine[1], vine[2]], 0.65),
|
||||
));
|
||||
}
|
||||
} else if let Some(pp) = prev {
|
||||
out.push(Prim::capsule(pp, q, thick, thick, vine, vine));
|
||||
}
|
||||
prev = Some(q);
|
||||
}
|
||||
if self.style == "doubleLine" {
|
||||
self.inner_rule(out, off);
|
||||
}
|
||||
}
|
||||
if self.style == "doubleLine" {
|
||||
for e in 0..4 {
|
||||
let (p, _, _) = b.at([0.0, b.w, b.w + b.h, 2.0 * b.w + b.h][e]);
|
||||
out.push(Prim::ellipse(p, unit * 0.45, unit * 0.45, std::f32::consts::FRAC_PI_4, self.fl, self.fl));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A second rule inside each double line.
|
||||
fn inner_rule(&self, out: &mut Vec<Prim>, off: f32) {
|
||||
let (b, unit) = (&self.b, self.unit);
|
||||
let inner = Border { x0: b.x0 + unit * 0.6 + off, y0: b.y0 + unit * 0.6 + off, w: b.w - 2.0 * (unit * 0.6 + off), h: b.h - 2.0 * (unit * 0.6 + off) };
|
||||
for e in 0..4 {
|
||||
let (p, _, _) = inner.at([0.0, inner.w, inner.w + inner.h, 2.0 * inner.w + inner.h][e]);
|
||||
let (q, _, _) = inner.at([inner.w, inner.w + inner.h, 2.0 * inner.w + inner.h, inner.len() - 0.001][e]);
|
||||
out.push(Prim::capsule(p, q, self.thick * 0.6, self.thick * 0.6, self.vine, self.vine));
|
||||
}
|
||||
}
|
||||
|
||||
/// Evenly spaced motif styles: dots, scallops, snowflakes, stars and hearts.
|
||||
fn motif_styles(&self, out: &mut Vec<Prim>) {
|
||||
let (unit, fl) = (self.unit, self.fl);
|
||||
for k in 0..self.count {
|
||||
let s = k as f32 * self.step;
|
||||
let (p, t, nn) = self.b.at(s);
|
||||
let ta = t.1.atan2(t.0);
|
||||
match self.style {
|
||||
"dots" => {
|
||||
let r = unit * if k % 2 == 0 { 0.4 } else { 0.22 };
|
||||
out.push(Prim::ellipse(p, r, r, 0.0, shade([fl[0], fl[1], fl[2]], 1.15), shade([fl[0], fl[1], fl[2]], 0.75)));
|
||||
}
|
||||
"scallops" => self.scallop(out, s, p, t, nn),
|
||||
"snowflakes" => self.snowflake(out, p, ta),
|
||||
"stars" => star(out, p, unit * 0.55, 5, if k % 2 == 0 { FRAME_YELLOW } else { fl }, ta - std::f32::consts::FRAC_PI_2),
|
||||
_ => self.heart(out, p, t, nn),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scallop(&self, out: &mut Vec<Prim>, s: f32, p: (f32, f32), t: (f32, f32), nn: (f32, f32)) {
|
||||
let (c0, _, _) = self.b.at(s - self.step * 0.5);
|
||||
let mid = ((c0.0 + p.0) / 2.0, (c0.1 + p.1) / 2.0);
|
||||
let n = 8;
|
||||
for j in 0..n {
|
||||
let a0 = std::f32::consts::PI * j as f32 / n as f32;
|
||||
let a1 = std::f32::consts::PI * (j + 1) as f32 / n as f32;
|
||||
let r = self.step * 0.5;
|
||||
let pt = |a: f32| (mid.0 - t.0 * r * a.cos() - nn.0 * r * a.sin(), mid.1 - t.1 * r * a.cos() - nn.1 * r * a.sin());
|
||||
out.push(Prim::capsule(pt(a0), pt(a1), self.thick, self.thick, self.vine, self.vine));
|
||||
}
|
||||
}
|
||||
|
||||
fn snowflake(&self, out: &mut Vec<Prim>, p: (f32, f32), ta: f32) {
|
||||
let thick = self.thick;
|
||||
let r = self.unit * 0.6;
|
||||
let c = [0.85, 0.92, 1.0, 1.0];
|
||||
for j in 0..6 {
|
||||
let a = ta + j as f32 * std::f32::consts::FRAC_PI_3;
|
||||
let e = (p.0 + a.cos() * r, p.1 + a.sin() * r);
|
||||
out.push(Prim::capsule(p, e, (thick * 0.6).max(0.6), (thick * 0.4).max(0.5), c, c));
|
||||
let m = (p.0 + a.cos() * r * 0.6, p.1 + a.sin() * r * 0.6);
|
||||
for sd in [-0.7f32, 0.7] {
|
||||
let b2 = (m.0 + (a + sd).cos() * r * 0.3, m.1 + (a + sd).sin() * r * 0.3);
|
||||
out.push(Prim::capsule(m, b2, (thick * 0.4).max(0.5), 0.4, c, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Heart: two lobes and a point towards the inside.
|
||||
fn heart(&self, out: &mut Vec<Prim>, p: (f32, f32), t: (f32, f32), nn: (f32, f32)) {
|
||||
let r = self.unit * 0.3;
|
||||
let (tx, ty) = t;
|
||||
let l = (p.0 - tx * r * 0.8 - nn.0 * r * 0.3, p.1 - ty * r * 0.8 - nn.1 * r * 0.3);
|
||||
let rr = (p.0 + tx * r * 0.8 - nn.0 * r * 0.3, p.1 + ty * r * 0.8 - nn.1 * r * 0.3);
|
||||
let tip = (p.0 + nn.0 * r * 1.6, p.1 + nn.1 * r * 1.6);
|
||||
out.push(Prim::capsule(l, tip, r, 0.6, self.fl, self.fl));
|
||||
out.push(Prim::capsule(rr, tip, r, 0.6, self.fl, self.fl));
|
||||
}
|
||||
|
||||
/// Fade: lower the opacity of ornaments that reach further inside.
|
||||
fn apply_fade(&self, out: &mut [Prim], fade: f32, canvas: Rect) {
|
||||
let cx = canvas.x0 as f32 + canvas.width() as f32 / 2.0;
|
||||
let cy = canvas.y0 as f32 + canvas.height() as f32 / 2.0;
|
||||
for p in &mut out {
|
||||
for p in out {
|
||||
let c = match p.shape {
|
||||
Shape::Capsule { a, .. } => a,
|
||||
Shape::Ellipse { c, .. } => c,
|
||||
};
|
||||
let edge = ((c.0 - cx).abs() / (b.w / 2.0)).max((c.1 - cy).abs() / (b.h / 2.0)).clamp(0.0, 1.0);
|
||||
let edge = ((c.0 - cx).abs() / (self.b.w / 2.0)).max((c.1 - cy).abs() / (self.b.h / 2.0)).clamp(0.0, 1.0);
|
||||
let k = 1.0 - fade * (1.0 - edge) * 8.0;
|
||||
p.c0[3] *= k.clamp(0.0, 1.0);
|
||||
p.c1[3] *= k.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds picture-frame primitives inside `canvas`.
|
||||
pub fn picture_frame(spec: &FrameSpec, canvas: Rect) -> Vec<Prim> {
|
||||
let mut out = Vec::new();
|
||||
let layout = FrameLayout::new(spec, canvas);
|
||||
let mut rng = Rng::new(spec.seed, 0xF8A3E + spec.frame as u64);
|
||||
match layout.style {
|
||||
"vineWithFlowers" | "vineWithLeaves" | "ivy" | "roses" | "daisies" | "berries" => layout.vine_styles(&mut out, &mut rng),
|
||||
"bamboo" => layout.bamboo(&mut out, &mut rng),
|
||||
"waves" | "zigzag" | "doubleLine" | "rope" => layout.rule_styles(&mut out),
|
||||
"dots" | "scallops" | "snowflakes" | "stars" | "hearts" => layout.motif_styles(&mut out),
|
||||
_ => {}
|
||||
}
|
||||
let fade = spec.fade.clamp(0.0, 100.0) / 100.0;
|
||||
if fade > 0.0 {
|
||||
layout.apply_fade(&mut out, fade, canvas);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -210,34 +210,80 @@ pub fn local_sharpen(
|
||||
protect_detail: bool,
|
||||
) -> Vec<f32> {
|
||||
let (x0, y0, x1, y1) = win;
|
||||
let ow = x1 - x0;
|
||||
let (ow, oh) = (x1 - x0, y1 - y0);
|
||||
if ow == 0 || oh == 0 || ch == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let blur = local_blur(data, w, h, ch, alpha, win, 1.0);
|
||||
let mut out = vec![0.0f32; blur.len()];
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let o = ((y - y0) * ow + (x - x0)) * ch;
|
||||
let stride = ow * ch;
|
||||
|
||||
// Rows are independent, so process them in parallel with rayon (#5426).
|
||||
let process_row = |(oy, dst): (usize, &mut [f32])| {
|
||||
let y = y0 + oy;
|
||||
let blur_row = &blur[oy * stride..(oy + 1) * stride];
|
||||
for ox in 0..ow {
|
||||
let x = x0 + ox;
|
||||
let o = ox * ch;
|
||||
let i = (y * w + x) * ch;
|
||||
|
||||
// Precompute 3×3 neighbourhood offsets once per pixel if protecting detail.
|
||||
let offsets: [usize; 8] = if protect_detail {
|
||||
if x > 0 && x + 1 < w && y > 0 && y + 1 < h {
|
||||
let w_ch = w * ch;
|
||||
[i - w_ch - ch, i - w_ch, i - w_ch + ch, i - ch, i + ch, i + w_ch - ch, i + w_ch, i + w_ch + ch]
|
||||
} else {
|
||||
let get_n_idx = |dx: i32, dy: i32| -> usize {
|
||||
let nx = (x as i32 + dx).clamp(0, w as i32 - 1) as usize;
|
||||
let ny = (y as i32 + dy).clamp(0, h as i32 - 1) as usize;
|
||||
(ny * w + nx) * ch
|
||||
};
|
||||
[
|
||||
get_n_idx(-1, -1),
|
||||
get_n_idx(0, -1),
|
||||
get_n_idx(1, -1),
|
||||
get_n_idx(-1, 0),
|
||||
get_n_idx(1, 0),
|
||||
get_n_idx(-1, 1),
|
||||
get_n_idx(0, 1),
|
||||
get_n_idx(1, 1),
|
||||
]
|
||||
}
|
||||
} else {
|
||||
[0; 8]
|
||||
};
|
||||
|
||||
for c in 0..ch {
|
||||
let v = data[i + c];
|
||||
if Some(c) == alpha {
|
||||
out[o + c] = v;
|
||||
dst[o + c] = v;
|
||||
continue;
|
||||
}
|
||||
let mut s = v + amount * (v - blur[o + c]);
|
||||
let mut s = v + amount * (v - blur_row[o + c]);
|
||||
if protect_detail {
|
||||
let (mut lo, mut hi) = (v, v);
|
||||
for (dx, dy) in [(-1i32, -1i32), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] {
|
||||
let (nx, ny) = ((x as i32 + dx).clamp(0, w as i32 - 1) as usize, (y as i32 + dy).clamp(0, h as i32 - 1) as usize);
|
||||
let nv = data[(ny * w + nx) * ch + c];
|
||||
for &n_idx in &offsets {
|
||||
let nv = data[n_idx + c];
|
||||
lo = lo.min(nv);
|
||||
hi = hi.max(nv);
|
||||
}
|
||||
s = s.clamp(lo, hi);
|
||||
}
|
||||
out[o + c] = s;
|
||||
dst[o + c] = s;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
out.par_chunks_mut(stride).enumerate().for_each(process_row);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
out.chunks_mut(stride).enumerate().for_each(process_row);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -252,14 +252,28 @@ pub fn grid_cut_cancellable(
|
||||
weight: impl Fn(f32) -> f32,
|
||||
cancel: &dyn Fn() -> bool,
|
||||
) -> Option<Vec<bool>> {
|
||||
grid_cut_impl::<true>(img, cost_fg, cost_bg, fixed, weight, cancel)
|
||||
grid_cut_impl::<true>(img, cost_fg, cost_bg, fixed, weight, cancel, false)
|
||||
}
|
||||
|
||||
/// [`grid_cut_cancellable`] solved with Boykov–Kolmogorov instead of push/relabel: faster when
|
||||
/// the data costs come from colour models (GrabCut and the band re-cut), where terminal links
|
||||
/// are few and paths short. The minimum cut value is the same.
|
||||
pub fn grid_cut_bk_cancellable(
|
||||
img: &RgbImage,
|
||||
cost_fg: &[f32],
|
||||
cost_bg: &[f32],
|
||||
fixed: &[u8],
|
||||
weight: impl Fn(f32) -> f32,
|
||||
cancel: &dyn Fn() -> bool,
|
||||
) -> Option<Vec<bool>> {
|
||||
grid_cut_impl::<true>(img, cost_fg, cost_bg, fixed, weight, cancel, true)
|
||||
}
|
||||
|
||||
mod edge_starts;
|
||||
|
||||
// The const parameter also runs the previous full scan in before/after workflow tests.
|
||||
fn grid_cut_weighted<const OPTIMIZED: bool>(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[u8], weight: impl Fn(f32) -> f32) -> Vec<bool> {
|
||||
grid_cut_impl::<OPTIMIZED>(img, cost_fg, cost_bg, fixed, weight, &|| false).unwrap_or_default()
|
||||
grid_cut_impl::<OPTIMIZED>(img, cost_fg, cost_bg, fixed, weight, &|| false, false).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn grid_cut_impl<const OPTIMIZED: bool>(
|
||||
@@ -269,6 +283,7 @@ fn grid_cut_impl<const OPTIMIZED: bool>(
|
||||
fixed: &[u8],
|
||||
weight: impl Fn(f32) -> f32,
|
||||
cancel: &dyn Fn() -> bool,
|
||||
bk: bool,
|
||||
) -> Option<Vec<bool>> {
|
||||
let (w, h) = (img.w as i32, img.h as i32);
|
||||
let Some(n) = img.w.checked_mul(img.h).filter(|&n| n < u32::MAX as usize - 2) else { return Some(Vec::new()) };
|
||||
@@ -336,7 +351,9 @@ fn grid_cut_impl<const OPTIMIZED: bool>(
|
||||
if cancel() {
|
||||
return None;
|
||||
}
|
||||
if OPTIMIZED {
|
||||
if OPTIMIZED && bk {
|
||||
g.maxflow_bk_with(cancel)?;
|
||||
} else if OPTIMIZED {
|
||||
g.maxflow_with(cancel)?;
|
||||
} else {
|
||||
g.maxflow_bk();
|
||||
@@ -733,7 +750,7 @@ fn refine_band(
|
||||
}
|
||||
let beta = contrast_beta(&img);
|
||||
// `grid_cut`'s weight, with the cut stopping on cancel.
|
||||
let cut = grid_cut_cancellable(&img, &cfg, &cbg, &fixed, |d2| 50.0 * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?;
|
||||
let cut = grid_cut_bk_cancellable(&img, &cfg, &cbg, &fixed, |d2| 50.0 * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?;
|
||||
let (cw, ch) = (t.width() as usize, t.height() as usize);
|
||||
let mut core = Vec::with_capacity(cw * ch);
|
||||
for yy in 0..ch {
|
||||
|
||||
@@ -10,7 +10,7 @@ use photocraft_geom::Rect;
|
||||
use photocraft_raster::{Cancelled, Interrupt};
|
||||
|
||||
use super::gmm::{DEFAULT_REG, Gmm};
|
||||
use super::{FREE, HARD_BG, HARD_FG, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut_cancellable, subsample};
|
||||
use super::{FREE, HARD_BG, HARD_FG, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut_bk_cancellable, subsample};
|
||||
|
||||
/// Definite background.
|
||||
pub const BG: u8 = 0;
|
||||
@@ -67,7 +67,7 @@ pub fn grabcut_with(img: &RgbImage, trimap: &mut [u8], iters: usize, ctl: &Inter
|
||||
};
|
||||
let (cf, cb) = data_costs(img, &fg, &bg);
|
||||
// `grid_cut`'s weight, with the cut stopping on cancel.
|
||||
let cut = grid_cut_cancellable(img, &cf, &cb, &fixed, |d2| GAMMA * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?;
|
||||
let cut = grid_cut_bk_cancellable(img, &cf, &cb, &fixed, |d2| GAMMA * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?;
|
||||
let mut changed = 0usize;
|
||||
for (t, fgnd) in trimap.iter_mut().zip(&cut) {
|
||||
if *t == PR_BG || *t == PR_FG {
|
||||
|
||||
@@ -140,11 +140,22 @@ impl Graph {
|
||||
self.bk(cancel)
|
||||
}
|
||||
|
||||
/// [`Graph::maxflow_with`] that always runs Boykov–Kolmogorov. It is the faster solver on
|
||||
/// the 8-connected colour-model grids of GrabCut (about 2.3x on a 107k-pixel working image);
|
||||
/// push/relabel wins on Quick Selection's many area-cost terminals, which keep
|
||||
/// [`Graph::maxflow_with`].
|
||||
pub fn maxflow_bk_with(&mut self, cancel: &dyn Fn() -> bool) -> Option<f64> {
|
||||
self.bk(cancel)
|
||||
}
|
||||
|
||||
pub(super) fn maxflow_bk(&mut self) -> f64 {
|
||||
self.bk(&never).unwrap_or(self.flow)
|
||||
}
|
||||
|
||||
fn bk(&mut self, cancel: &dyn Fn() -> bool) -> Option<f64> {
|
||||
if cancel() {
|
||||
return None;
|
||||
}
|
||||
let n = self.node_count();
|
||||
let mut queue: VecDeque<u32> = VecDeque::new();
|
||||
let mut orphans: VecDeque<u32> = VecDeque::new();
|
||||
@@ -219,7 +230,7 @@ impl Graph {
|
||||
self.adopt(o as usize, time, &mut queue, &mut orphans);
|
||||
}
|
||||
}
|
||||
Some(self.flow)
|
||||
if cancel() { None } else { Some(self.flow) }
|
||||
}
|
||||
|
||||
fn augment(&mut self, m: usize, orphans: &mut VecDeque<u32>) {
|
||||
@@ -408,3 +419,51 @@ impl Graph {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cancellation_tests {
|
||||
use super::{CANCEL_EVERY, Graph};
|
||||
use std::cell::Cell;
|
||||
|
||||
#[test]
|
||||
fn bk_checks_cancellation_on_short_runs() {
|
||||
let mut graph = Graph::with_capacity(2, 0);
|
||||
graph.add_tweights(0, 1.0, 0.0);
|
||||
graph.add_tweights(1, 0.0, 1.0);
|
||||
assert!(graph.maxflow_bk_with(&|| true).is_none());
|
||||
|
||||
let calls = Cell::new(0);
|
||||
assert!(
|
||||
graph
|
||||
.maxflow_bk_with(&|| {
|
||||
calls.set(calls.get() + 1);
|
||||
calls.get() == 2
|
||||
})
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(calls.get(), 2, "short run must check before returning");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bk_checks_cancellation_during_a_long_run() {
|
||||
let mut graph = Graph::with_capacity(CANCEL_EVERY as usize + 1, 0);
|
||||
for i in 0..graph.node_count() {
|
||||
graph.add_tweights(i, 1.0, 0.0);
|
||||
}
|
||||
let calls = Cell::new(0);
|
||||
assert!(
|
||||
graph
|
||||
.maxflow_bk_with(&|| {
|
||||
calls.set(calls.get() + 1);
|
||||
calls.get() == 2
|
||||
})
|
||||
.is_none()
|
||||
);
|
||||
// The first check is at entry; more than CANCEL_EVERY active nodes ensure the
|
||||
// second is the periodic poll, before the solver can finish.
|
||||
assert_eq!(calls.get(), 2);
|
||||
// Without the periodic poll the final check would be the second call. The poll stops the
|
||||
// run with the last queued node unprocessed, still marked active.
|
||||
assert!(graph.active[CANCEL_EVERY as usize], "periodic cancel must stop before the last queued node is processed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,7 +265,7 @@ fn cut_levels<const OPTIMIZED: bool>(
|
||||
let (w, h) = (img.w, img.h);
|
||||
let zeros = vec![0.0f32; w * h];
|
||||
if w * h <= direct_px || w < 8 || h < 8 {
|
||||
return super::grid_cut_impl::<OPTIMIZED>(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2), cancel);
|
||||
return super::grid_cut_impl::<OPTIMIZED>(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2), cancel, false);
|
||||
}
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let coarse_img = img.downsample(2);
|
||||
@@ -310,7 +310,7 @@ fn cut_levels<const OPTIMIZED: bool>(
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
super::grid_cut_impl::<OPTIMIZED>(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2), cancel)
|
||||
super::grid_cut_impl::<OPTIMIZED>(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2), cancel, false)
|
||||
}
|
||||
|
||||
/// Pixels within `radius` (working pixels) of the polyline `pts`, plus each point's own pixel.
|
||||
|
||||
@@ -288,6 +288,65 @@ fn grabcut_trimap_on_disc() {
|
||||
assert!(iou > 0.95, "IoU {iou}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grabcut_small_disc_mask_is_pinned() {
|
||||
// The mask GrabCut produced with the push/relabel solver, before the switch to BK. Min-cut
|
||||
// ties may break differently between solvers, so any drift here is a visible change.
|
||||
let (img, _) = disc_scene(32, 24, 16.0, 12.0, 8.0, 3);
|
||||
let mut tri = vec![grabcut::BG; img.w * img.h];
|
||||
for y in 2..22 {
|
||||
for x in 6..26 {
|
||||
tri[y * img.w + x] = grabcut::PR_FG;
|
||||
}
|
||||
}
|
||||
grabcut::grabcut(&img, &mut tri, 8).unwrap();
|
||||
let got: Vec<String> = tri.chunks(img.w).map(|r| r.iter().map(|t| if *t == grabcut::PR_FG || *t == grabcut::FG { '#' } else { '.' }).collect()).collect();
|
||||
let want = [
|
||||
"................................",
|
||||
"................................",
|
||||
"................................",
|
||||
"................................",
|
||||
".............######.............",
|
||||
"...........##########...........",
|
||||
"..........############..........",
|
||||
".........##############.........",
|
||||
".........##############.........",
|
||||
"........################........",
|
||||
"........################........",
|
||||
"........################........",
|
||||
"........################........",
|
||||
"........################........",
|
||||
"........################........",
|
||||
".........##############.........",
|
||||
".........##############.........",
|
||||
"..........############..........",
|
||||
"...........##########...........",
|
||||
".............######.............",
|
||||
"................................",
|
||||
"................................",
|
||||
"................................",
|
||||
"................................",
|
||||
];
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grabcut_large_disc_mask_is_pinned() {
|
||||
// 5168 free nodes with terminal links both ways, so the pre-BK solver was push/relabel here.
|
||||
// The count and FNV-1a hash are of the mask that push/relabel produced at the merge base.
|
||||
let (img, _) = disc_scene(96, 80, 48.0, 40.0, 26.0, 3);
|
||||
let mut tri = vec![grabcut::BG; img.w * img.h];
|
||||
for y in 6..74 {
|
||||
for x in 10..86 {
|
||||
tri[y * img.w + x] = grabcut::PR_FG;
|
||||
}
|
||||
}
|
||||
grabcut::grabcut(&img, &mut tri, 8).unwrap();
|
||||
let fg: Vec<bool> = tri.iter().map(|t| *t == grabcut::PR_FG || *t == grabcut::FG).collect();
|
||||
let hash = fg.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3));
|
||||
assert_eq!((fg.iter().filter(|b| **b).count(), hash), (2128, 0x62fd_c64a_0ee4_4e75));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_select_disc_full_resolution() {
|
||||
let (img, truth) = disc_scene(200, 160, 100.0, 80.0, 50.0, 2);
|
||||
@@ -1068,3 +1127,87 @@ fn hole_fill_without_specks_fills_object_coloured_holes_only() {
|
||||
assert!(out[25 * w + 25], "a speck of the object's colour fills");
|
||||
assert!(!out[10 * w + 10], "a speck of the background's colour stays open");
|
||||
}
|
||||
|
||||
/// Energy of a labelling: data costs plus the contrast weights of separated 8-neighbours.
|
||||
fn grid_energy(img: &RgbImage, cf: &[f32], cb: &[f32], cut: &[bool], weight: impl Fn(f32) -> f32) -> f64 {
|
||||
let (w, h) = (img.w as i32, img.h as i32);
|
||||
let mut e = 0.0f64;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = (y * w + x) as usize;
|
||||
e += f64::from(if cut[i] { cf[i] } else { cb[i] });
|
||||
for (dx, dy, len) in NB {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
if nx >= 0 && nx < w && ny < h {
|
||||
let j = (ny * w + nx) as usize;
|
||||
if cut[i] != cut[j] {
|
||||
e += f64::from(weight(d2(img.px[i], img.px[j])) / len);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bk_and_default_grid_cut_tie_reaches_the_same_energy() {
|
||||
// Hard background down the left edge, hard foreground down the right, uniform pixels and equal
|
||||
// data costs: a vertical cut at any column separates the borders at the same energy, so only
|
||||
// the solvers' tie-breaking decides the labelling.
|
||||
let (w, h) = (120usize, 100usize);
|
||||
let img = RgbImage::from_fn(w, h, |_, _| [0.5, 0.5, 0.5]);
|
||||
let (cf, cb) = (vec![1.0f32; w * h], vec![1.0f32; w * h]);
|
||||
let fixed: Vec<u8> = (0..w * h)
|
||||
.map(|i| match i % w {
|
||||
0 => HARD_BG,
|
||||
x if x == w - 1 => HARD_FG,
|
||||
_ => FREE,
|
||||
})
|
||||
.collect();
|
||||
let weight = |_: f32| 2.0;
|
||||
// Two distinct candidate labellings (vertical cuts at different columns) tie at the minimum.
|
||||
let split = |col: usize| -> Vec<bool> { (0..w * h).map(|i| i % w >= col).collect() };
|
||||
let (e30, e90) = (grid_energy(&img, &cf, &cb, &split(30), weight), grid_energy(&img, &cf, &cb, &split(90), weight));
|
||||
assert!((e30 - e90).abs() <= 1e-6 * e30.abs().max(1.0), "candidates must tie: {e30} vs {e90}");
|
||||
assert_ne!(split(30), split(90));
|
||||
let a = grid_cut_cancellable(&img, &cf, &cb, &fixed, weight, &|| false).expect("default cut");
|
||||
let b = grid_cut_bk_cancellable(&img, &cf, &cb, &fixed, weight, &|| false).expect("bk cut");
|
||||
let (ea, eb) = (grid_energy(&img, &cf, &cb, &a, weight), grid_energy(&img, &cf, &cb, &b, weight));
|
||||
assert!((ea - e30).abs() <= 1e-3 * e30.abs().max(1.0), "default {ea} is not the tied minimum {e30}");
|
||||
assert!((eb - e30).abs() <= 1e-3 * e30.abs().max(1.0), "bk {eb} is not the tied minimum {e30}");
|
||||
// The solvers may pick different minimum cuts here; only the energy is guaranteed.
|
||||
assert!(a.iter().any(|&l| l) && b.iter().any(|&l| l), "both label the foreground border");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bk_and_default_grid_cut_reach_the_same_minimum_energy() {
|
||||
// Large enough (>= 4096 free nodes, both terminals) that the default solver is push/relabel.
|
||||
let (w, h) = (120usize, 100usize);
|
||||
let mut rng = Rng::new(7);
|
||||
let img = RgbImage::from_fn(w, h, |x, y| {
|
||||
let inside = (x as f32 - 60.0).powi(2) + (y as f32 - 50.0).powi(2) < 1600.0;
|
||||
let base = if inside { [0.8, 0.3, 0.2] } else { [0.2, 0.4, 0.7] };
|
||||
base.map(|v| v + 0.08 * rng.normal())
|
||||
});
|
||||
let cf: Vec<f32> = img.px.iter().map(|p| 4.0 * d2(*p, [0.8, 0.3, 0.2])).collect();
|
||||
let cb: Vec<f32> = img.px.iter().map(|p| 4.0 * d2(*p, [0.2, 0.4, 0.7])).collect();
|
||||
let fixed: Vec<u8> = (0..w * h)
|
||||
.map(|i| {
|
||||
if i % w == 0 {
|
||||
HARD_BG
|
||||
} else if i == (50 * w + 60) {
|
||||
HARD_FG
|
||||
} else {
|
||||
FREE
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let weight = |d: f32| 50.0 * (-8.0 * d).exp();
|
||||
let a = grid_cut_cancellable(&img, &cf, &cb, &fixed, weight, &|| false).expect("default cut");
|
||||
let b = grid_cut_bk_cancellable(&img, &cf, &cb, &fixed, weight, &|| false).expect("bk cut");
|
||||
let (ea, eb) = (grid_energy(&img, &cf, &cb, &a, weight), grid_energy(&img, &cf, &cb, &b, weight));
|
||||
assert!((ea - eb).abs() <= 1e-3 * ea.abs().max(1.0), "default {ea} vs bk {eb}");
|
||||
assert_eq!(a, b, "same labelling, not just the same energy");
|
||||
assert!(grid_cut_bk_cancellable(&img, &cf, &cb, &fixed, weight, &|| true).is_none(), "BK honours cancel");
|
||||
}
|
||||
|
||||
@@ -134,18 +134,30 @@ pub fn mask_to_surface(m: &[f32], area: Rect) -> Surface {
|
||||
surface_from_masks(area, m, None, |_, v| v)
|
||||
}
|
||||
|
||||
mod combine_mask;
|
||||
pub use combine_mask::combine_band;
|
||||
|
||||
/// Combines `new` with `old` by `mode`. Returns `None` when nothing is selected.
|
||||
pub fn combine(old: Option<&Surface>, new: &[f32], area: Rect, mode: SelectionMode) -> Option<Surface> {
|
||||
if area.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Replace already writes straight from the input mask to tiles; it needs no old mask.
|
||||
if mode == SelectionMode::Replace || (old.is_none() && mode == SelectionMode::Add) {
|
||||
let s = mask_to_surface(new, area);
|
||||
return (s.tile_count() > 0).then_some(s);
|
||||
}
|
||||
let old = old?;
|
||||
if old.format() == PixelFormat::GRAY8 {
|
||||
return combine_mask::gray8(Some(old), new, area, mode, false);
|
||||
}
|
||||
// Quantize to the 8-bit grid selections are stored on, so combining a mask with itself is
|
||||
// exact. Below half an 8-bit step is nothing.
|
||||
let q = |v: f32| f32::from(coverage8(v)) / 255.0;
|
||||
let s = match mode {
|
||||
SelectionMode::Replace => surface_from_masks(area, new, None, |_, v| v),
|
||||
_ => {
|
||||
let prev = mask_from_surface(old, area);
|
||||
let prev = mask_from_surface(Some(old), area);
|
||||
match mode {
|
||||
SelectionMode::Add => surface_from_masks(area, new, Some(&prev), |p, v| p.max(q(v))),
|
||||
SelectionMode::Subtract => surface_from_masks(area, new, Some(&prev), |p, v| (p - q(v)).max(0.0)),
|
||||
@@ -381,6 +393,9 @@ pub fn combine_region(old: Option<&Surface>, new: Option<&Region>, mode: Selecti
|
||||
let empty = Region { bbox: Rect::EMPTY, mask: Vec::new() };
|
||||
let new = new.unwrap_or(&empty);
|
||||
let b = new.bbox;
|
||||
if old.is_none_or(|s| s.format() == PixelFormat::GRAY8) {
|
||||
return combine_mask::gray8_bytes(old, &new.mask, b, mode);
|
||||
}
|
||||
let prev_u8 = |s: &Surface| -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
s.read_region_into(b, &mut v);
|
||||
@@ -761,11 +776,13 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec<f32> {
|
||||
}
|
||||
|
||||
mod distance;
|
||||
mod inverse;
|
||||
mod lasso;
|
||||
mod marquee;
|
||||
mod morphology;
|
||||
|
||||
pub use distance::edt;
|
||||
pub use inverse::inverse_surface;
|
||||
pub(crate) use lasso::clip_segment;
|
||||
pub use lasso::{MAX_PAINTED_TILES, polygon_edge_tiles, polygon_surface};
|
||||
pub use marquee::{combine_sparse, combine_surfaces, ellipse_surface};
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Float mask inputs meet stored GRAY8 selections at the tile boundary. No dense copy of
|
||||
//! the previous selection or of the combined result is needed.
|
||||
|
||||
use super::{SelectionMode, combine, coverage8};
|
||||
use crate::photo_util::par_map;
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
|
||||
use photocraft_raster::{Surface, Tile};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Combines a mask covering only `band` (zero elsewhere). Add and Subtract retain the old
|
||||
/// selection outside it, including pixels sharing a tile with the band's edges. Replace
|
||||
/// and Intersect return only the selection inside the band. Missing mask samples read zero.
|
||||
pub fn combine_band(old: Option<&Surface>, mask: &[f32], band: Rect, mode: SelectionMode) -> Option<Surface> {
|
||||
let keep = matches!(mode, SelectionMode::Add | SelectionMode::Subtract);
|
||||
if !keep || old.is_none() {
|
||||
return combine(old, mask, band, mode);
|
||||
}
|
||||
if band.is_empty() {
|
||||
return old.cloned();
|
||||
}
|
||||
if old.is_none_or(|s| s.format() == PixelFormat::GRAY8) {
|
||||
return gray8(old, mask, band, mode, true);
|
||||
}
|
||||
// Preserve the format and conversion behavior of unusual selection surfaces. The normal
|
||||
// GRAY8 path above never materializes these band-sized buffers.
|
||||
let inside = combine(old, mask, band, mode);
|
||||
let mut out = old.cloned()?;
|
||||
let values = inside.map_or_else(|| vec![0.0; band.width() as usize * band.height() as usize], |s| s.read_region(band));
|
||||
if out.channels() == 1 {
|
||||
out.write_region(band, &values);
|
||||
} else {
|
||||
// A selection uses its first channel as coverage. Broadcast it when writing back to
|
||||
// an unusual multichannel surface, rather than passing a short buffer to write_region.
|
||||
let pixels: Vec<f32> = values.iter().flat_map(|v| std::iter::repeat_n(*v, out.channels())).collect();
|
||||
out.write_region(band, &pixels);
|
||||
}
|
||||
out.prune();
|
||||
(out.tile_count() > 0).then_some(out)
|
||||
}
|
||||
|
||||
pub(super) fn gray8(old: Option<&Surface>, mask: &[f32], area: Rect, mode: SelectionMode, keep_outside: bool) -> Option<Surface> {
|
||||
build(old, area, mode, keep_outside, |i| coverage8(mask.get(i).copied().unwrap_or(0.0)))
|
||||
}
|
||||
|
||||
pub(super) fn gray8_bytes(old: Option<&Surface>, mask: &[u8], area: Rect, mode: SelectionMode) -> Option<Surface> {
|
||||
let keep = matches!(mode, SelectionMode::Add | SelectionMode::Subtract);
|
||||
if area.is_empty() {
|
||||
return if keep { old.cloned().filter(|s| !s.content_bounds().is_empty()) } else { None };
|
||||
}
|
||||
build(old, area, mode, keep, |i| mask.get(i).copied().unwrap_or(0))
|
||||
}
|
||||
|
||||
fn build(old: Option<&Surface>, area: Rect, mode: SelectionMode, keep_outside: bool, sample: impl Fn(usize) -> u8 + Sync) -> Option<Surface> {
|
||||
let mut out = if keep_outside { old.cloned().unwrap_or_else(|| Surface::new(PixelFormat::GRAY8)) } else { Surface::new(PixelFormat::GRAY8) };
|
||||
let default = old.and_then(|s| s.default_bytes().first()).copied().unwrap_or(0);
|
||||
let out_default = out.default_bytes().first().copied().unwrap_or(0);
|
||||
let (blank, full) = (out.solid_tile(&[0.0]), out.solid_tile(&[1.0]));
|
||||
let untouched = out.solid_tile(&[f32::from(out_default) / 255.0]);
|
||||
let coords: Vec<TileCoord> = area.tiles().collect();
|
||||
let width = area.width() as usize;
|
||||
let ts = TILE_SIZE as usize;
|
||||
let built = par_map(coords.len(), |k| {
|
||||
let tc = *coords.get(k)?;
|
||||
let rect = tc.rect();
|
||||
let cut = rect.intersect(&area);
|
||||
let previous = old.and_then(|s| s.tile(tc));
|
||||
let mut tile = Tile::clone(if keep_outside { previous.unwrap_or(&untouched) } else { &blank });
|
||||
for y in cut.y0..cut.y1 {
|
||||
let offset = y.abs_diff(rect.y0) as usize * ts + cut.x0.abs_diff(rect.x0) as usize;
|
||||
let len = cut.width() as usize;
|
||||
let Some(dst) = tile.bytes_mut().get_mut(offset..offset + len) else { continue };
|
||||
let src = previous.and_then(|t| t.bytes().get(offset..offset + len));
|
||||
let start = (y.abs_diff(area.y0) as usize).checked_mul(width).and_then(|i| i.checked_add(cut.x0.abs_diff(area.x0) as usize));
|
||||
for (i, d) in dst.iter_mut().enumerate() {
|
||||
let new = start.and_then(|j| j.checked_add(i)).map(&sample).unwrap_or(0);
|
||||
let prev = src.and_then(|s| s.get(i)).copied().unwrap_or(default);
|
||||
*d = match mode {
|
||||
SelectionMode::Replace => new,
|
||||
SelectionMode::Add => prev.max(new),
|
||||
SelectionMode::Subtract => prev.saturating_sub(new),
|
||||
SelectionMode::Intersect => prev.min(new),
|
||||
};
|
||||
}
|
||||
}
|
||||
let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), b| (lo.min(*b), hi.max(*b)));
|
||||
let tile = if lo == out_default && hi == out_default {
|
||||
None
|
||||
} else if previous.is_some_and(|p| p.bytes() == tile.bytes()) {
|
||||
previous.cloned()
|
||||
} else if lo == u8::MAX {
|
||||
Some(Arc::clone(&full))
|
||||
} else {
|
||||
Some(Arc::new(tile))
|
||||
};
|
||||
Some((tc, tile))
|
||||
});
|
||||
for (tc, tile) in built.into_iter().flatten() {
|
||||
match tile {
|
||||
Some(tile) => out.put_tiles([(tc, tile)]),
|
||||
None if keep_outside && out.tile(tc).is_some() => drop(out.take_tiles(tc.rect())),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if keep_outside {
|
||||
out.prune();
|
||||
}
|
||||
(out.tile_count() > 0).then_some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,179 @@
|
||||
use super::*;
|
||||
use crate::selection::{Region, combine_region, mask_from_surface, surface_from_masks};
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
|
||||
const MODES: [SelectionMode; 4] = [SelectionMode::Replace, SelectionMode::Add, SelectionMode::Subtract, SelectionMode::Intersect];
|
||||
|
||||
// The pre-refactor float formulas, including quantization before the binary operation.
|
||||
fn reference(old: Option<&Surface>, mask: &[f32], area: Rect, mode: SelectionMode) -> Option<Surface> {
|
||||
if area.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if mode == SelectionMode::Replace {
|
||||
let s = surface_from_masks(area, mask, None, |_, v| v);
|
||||
return (s.tile_count() > 0).then_some(s);
|
||||
}
|
||||
let prev = mask_from_surface(old, area);
|
||||
let s = surface_from_masks(area, mask, Some(&prev), |p, v| {
|
||||
let q = f32::from(coverage8(v)) / 255.0;
|
||||
match mode {
|
||||
SelectionMode::Replace => v,
|
||||
SelectionMode::Add => p.max(q),
|
||||
SelectionMode::Subtract => (p - q).max(0.0),
|
||||
SelectionMode::Intersect => p.min(q),
|
||||
}
|
||||
});
|
||||
(s.tile_count() > 0).then_some(s)
|
||||
}
|
||||
|
||||
fn reference_band(old: Option<&Surface>, mask: &[f32], area: Rect, mode: SelectionMode) -> Option<Surface> {
|
||||
let inside = reference(old, mask, area, mode);
|
||||
let Some(old) = old.filter(|_| matches!(mode, SelectionMode::Add | SelectionMode::Subtract)) else { return inside };
|
||||
if area.is_empty() {
|
||||
return Some(old.clone());
|
||||
}
|
||||
let values = inside.map_or_else(|| vec![0.0; area.width() as usize * area.height() as usize], |s| s.read_region(area));
|
||||
let mut out = old.clone();
|
||||
out.write_region(area, &values);
|
||||
out.prune();
|
||||
(out.tile_count() > 0).then_some(out)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_byte_pair_matches_float_combining() {
|
||||
let area = Rect::new(0, 0, 256, 256);
|
||||
let prev: Vec<f32> = (0..65536).map(|i| (i / 256) as f32 / 255.0).collect();
|
||||
let mask: Vec<f32> = (0..65536).map(|i| (i % 256) as f32 / 255.0).collect();
|
||||
let old = crate::selection::mask_to_surface(&prev, area);
|
||||
for mode in MODES {
|
||||
assert_eq!(combine(Some(&old), &mask, area, mode), reference(Some(&old), &mask, area, mode), "{mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cropped_bands_match_dense_writeback_with_defaults_and_short_masks() {
|
||||
let values = [f32::NAN, f32::NEG_INFINITY, -0.2, 0.0, 0.49 / 255.0, 0.5 / 255.0, 0.51 / 255.0, 0.5, 1.0, 1.2, f32::INFINITY];
|
||||
for area in [Rect::new(-259, -2, 259, 3), Rect::new(255, 255, 258, 259), Rect::new(0, 0, 512, 256), Rect::EMPTY] {
|
||||
let n = area.width() as usize * area.height() as usize;
|
||||
let mask: Vec<f32> = (0..n).map(|i| values[i % values.len()]).collect();
|
||||
for default in [0.0, 0.5, 1.0] {
|
||||
let mut old = Surface::with_default(PixelFormat::GRAY8, &[default]);
|
||||
old.fill_rect(Rect::new(-300, -20, 300, 270), &[0.75]);
|
||||
old.write_pixel(1024, 1024, &[1.0]);
|
||||
old.write_pixel(-257, 0, &[0.0]);
|
||||
let snapshot = old.clone();
|
||||
for prev in [None, Some(&old)] {
|
||||
for len in [0, n / 2, n] {
|
||||
for mode in MODES {
|
||||
let mask = &mask[..len];
|
||||
assert_eq!(combine(prev, mask, area, mode), reference(prev, mask, area, mode), "{area:?} {mode:?} {default} {len}");
|
||||
assert_eq!(combine_band(prev, mask, area, mode), reference_band(prev, mask, area, mode), "band {area:?} {mode:?} {default} {len}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(old, snapshot, "combining must leave undo snapshots unchanged");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusual_selection_formats_keep_their_float_behavior() {
|
||||
let area = Rect::new(-2, -1, 3, 2);
|
||||
for sample in SampleType::ALL {
|
||||
for color in [ColorMode::Grayscale, ColorMode::Rgb] {
|
||||
let mut old = Surface::with_default(PixelFormat::new(color, sample, false), &vec![0.123; if color == ColorMode::Rgb { 3 } else { 1 }]);
|
||||
old.write_pixel(0, 0, &vec![0.754; old.channels()]);
|
||||
for mode in MODES {
|
||||
let mask = [0.5; 15];
|
||||
assert_eq!(combine(Some(&old), &mask, area, mode), reference(Some(&old), &mask, area, mode));
|
||||
if color == ColorMode::Grayscale {
|
||||
assert_eq!(combine_band(Some(&old), &mask, area, mode), reference_band(Some(&old), &mask, area, mode));
|
||||
} else {
|
||||
let out = combine_band(Some(&old), &mask, area, mode).unwrap();
|
||||
let inside = reference(Some(&old), &mask, area, mode).unwrap();
|
||||
let expected = inside.pixel(0, 0)[0];
|
||||
let encoded = Surface::with_default(out.format(), &vec![expected; out.channels()]);
|
||||
assert_eq!(out.pixel(0, 0), encoded.pixel(0, 0));
|
||||
if matches!(mode, SelectionMode::Add | SelectionMode::Subtract) {
|
||||
assert_eq!(out.pixel(20, 20), old.pixel(20, 20));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_bytes_match_float_bands() {
|
||||
let bbox = Rect::new(-257, 255, 258, 258);
|
||||
let mask: Vec<u8> = (0..bbox.width() * bbox.height()).map(|i| (i % 256) as u8).collect();
|
||||
let floats: Vec<f32> = mask.iter().map(|b| f32::from(*b) / 255.0).collect();
|
||||
let region = Region { bbox, mask };
|
||||
let mut old = Surface::new(PixelFormat::GRAY8);
|
||||
old.fill_rect(Rect::new(-300, 250, 300, 270), &[0.75]);
|
||||
for prev in [None, Some(&old)] {
|
||||
for mode in MODES {
|
||||
assert_eq!(combine_region(prev, Some(®ion), mode), reference_band(prev, &floats, bbox, mode));
|
||||
assert_eq!(combine_region(prev, None, mode), reference_band(prev, &[], Rect::EMPTY, mode));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiles_stay_shared_or_are_pruned() {
|
||||
let area = Rect::new(0, 0, 768, 256);
|
||||
let mut old = Surface::new(PixelFormat::GRAY8);
|
||||
old.fill_rect(area, &[1.0]);
|
||||
let tc = TileCoord::new(0, 0);
|
||||
let unchanged = combine(Some(&old), &[], area, SelectionMode::Add).unwrap();
|
||||
assert!(Arc::ptr_eq(unchanged.tile(tc).unwrap(), old.tile(tc).unwrap()));
|
||||
let band = Rect::new(257, 1, 260, 3);
|
||||
let changed = combine_band(Some(&old), &[1.0; 6], band, SelectionMode::Subtract).unwrap();
|
||||
assert!(Arc::ptr_eq(changed.tile(tc).unwrap(), old.tile(tc).unwrap()));
|
||||
assert_eq!(changed.pixel(256, 1), vec![1.0]);
|
||||
assert_eq!(changed.pixel(257, 1), vec![0.0]);
|
||||
assert_eq!(changed.pixel(260, 1), vec![1.0]);
|
||||
let full = combine(None, &vec![1.0; 768 * 256], area, SelectionMode::Replace).unwrap();
|
||||
assert!(Arc::ptr_eq(full.tile(tc).unwrap(), full.tile(TileCoord::new(1, 0)).unwrap()));
|
||||
assert!(combine_band(Some(&old), &vec![1.0; 768 * 256], area, SelectionMode::Subtract).is_none());
|
||||
assert!(combine(None, &[], area, SelectionMode::Replace).is_none());
|
||||
}
|
||||
|
||||
/// Run only in release on CI: compares the former implementation, including dense reads and
|
||||
/// band writeback, against the byte path with identical 36 MP inputs and exact output checks.
|
||||
#[test]
|
||||
#[ignore = "36 MP release performance comparison; run on GitHub Actions"]
|
||||
fn gray8_combining_release_comparison() {
|
||||
use std::{hint::black_box, time::Instant};
|
||||
let area = Rect::new(0, 0, 6000, 6000);
|
||||
let mut old = Surface::new(PixelFormat::GRAY8);
|
||||
old.fill_rect(Rect::new(100, 100, 5900, 5900), &[0.75]);
|
||||
println!("36 MP; median of three release runs including allocation; exact output checks; previous dense mask alone = 144000000 bytes");
|
||||
for (case, size) in [("sparse", 50), ("dense", 4000), ("full", 6000)] {
|
||||
let mut mask = vec![0.0; 6000 * 6000];
|
||||
let start = (6000 - size) / 2;
|
||||
for row in mask.as_chunks_mut::<6000>().0.iter_mut().skip(start).take(size) {
|
||||
row[start..start + size].fill(0.5);
|
||||
}
|
||||
for mode in MODES {
|
||||
for band in [false, true] {
|
||||
let baseline = if band { reference_band } else { reference };
|
||||
let direct = if band { combine_band } else { combine };
|
||||
assert_eq!(direct(Some(&old), &mask, area, mode), baseline(Some(&old), &mask, area, mode));
|
||||
let mut medians = [0.0; 2];
|
||||
for (median, op) in medians.iter_mut().zip([baseline, direct]) {
|
||||
let mut runs = [0.0; 3];
|
||||
for ms in &mut runs {
|
||||
let start = Instant::now();
|
||||
let out = black_box(op(Some(black_box(&old)), black_box(&mask), area, mode));
|
||||
*ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
drop(out);
|
||||
}
|
||||
runs.sort_by(f64::total_cmp);
|
||||
*median = runs[1];
|
||||
}
|
||||
println!("{case} {mode:?} band={band}: old={:.2}ms new={:.2}ms speedup={:.2}x exact=true", medians[0], medians[1], medians[0] / medians[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||