mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 11:17:05 -04:00
M0.1/M0.2: workspace, xtask (layers/wasm/ci/stats), geom, color, raster
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
3f1ffeb690
37 files changed
+3743
No files matched your search
@@ -0,0 +1,2 @@
|
||||
[alias]
|
||||
xtask = "run --package xtask --"
|
||||
@@ -0,0 +1,7 @@
|
||||
*~
|
||||
plan/
|
||||
/target
|
||||
/dist
|
||||
/corpus
|
||||
/log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,27 @@
|
||||
# LightCraft — instructions for agents
|
||||
|
||||
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../printcraft` (Acrobat), `../photocraft` (Photoshop), `../drawcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
|
||||
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
`plan/` is gitignored (local-only).
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…).
|
||||
- **Pure Rust** in the product. No C/C++ dependencies.
|
||||
- **Layering** (`plan/architecture.md` §3, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd.
|
||||
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id. Every slider is a `develop` control spec.
|
||||
- **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, wasm).
|
||||
- **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p lightcraft -- --control 7980` opens the desktop app with the JSON-lines control server (see `docs/control-protocol.md`).
|
||||
- For UI work, **look at the result**: drive via the control channel and take `ui.screenshot`, compare with `plan/lightroom/screenshots/`.
|
||||
- MCP: `lightcraft-cli mcp` (see `docs/mcp.md`).
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
|
||||
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; keep every `Cargo.toml` valid at all times (the `crates/*` glob means one broken manifest breaks everyone).
|
||||
- Test corpora: `cargo xtask corpus --download` into `corpus/` (gitignored, CC0 only). Never commit media.
|
||||
@@ -0,0 +1,27 @@
|
||||
# LightCraft — instructions for agents
|
||||
|
||||
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../printcraft` (Acrobat), `../photocraft` (Photoshop), `../drawcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
|
||||
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots).
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
`plan/` is gitignored (local-only).
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…).
|
||||
- **Pure Rust** in the product. No C/C++ dependencies.
|
||||
- **Layering** (`plan/architecture.md` §3, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd.
|
||||
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id. Every slider is a `develop` control spec.
|
||||
- **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, wasm).
|
||||
- **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p lightcraft -- --control 7980` opens the desktop app with the JSON-lines control server (see `docs/control-protocol.md`).
|
||||
- For UI work, **look at the result**: drive via the control channel and take `ui.screenshot`, compare with `plan/lightroom/screenshots/`.
|
||||
- MCP: `lightcraft-cli mcp` (see `docs/mcp.md`).
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
|
||||
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; keep every `Cargo.toml` valid at all times (the `crates/*` glob means one broken manifest breaks everyone).
|
||||
- Test corpora: `cargo xtask corpus --download` into `corpus/` (gitignored, CC0 only). Never commit media.
|
||||
Generated
+494
@@ -0,0 +1,494 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-color"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"lightcraft-geom",
|
||||
"proptest",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-geom"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-raster"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
"proptest",
|
||||
"rayon",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proptest"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bitflags",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"rand_chacha",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
"rusty-fork",
|
||||
"tempfile",
|
||||
"unarray",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_xorshift"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusty-fork"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"quick-error",
|
||||
"tempfile",
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unarray"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.59"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.59"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
repository = "https://github.com/storytold/lightcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
lightcraft-geom = { path = "crates/geom" }
|
||||
lightcraft-color = { path = "crates/color" }
|
||||
lightcraft-raster = { path = "crates/raster" }
|
||||
lightcraft-tiff = { path = "crates/tiff" }
|
||||
lightcraft-raw = { path = "crates/raw" }
|
||||
lightcraft-codecs = { path = "crates/codecs" }
|
||||
lightcraft-meta = { path = "crates/meta" }
|
||||
lightcraft-develop = { path = "crates/develop" }
|
||||
lightcraft-scenes = { path = "crates/scenes" }
|
||||
lightcraft-pipeline = { path = "crates/pipeline" }
|
||||
lightcraft-catalog = { path = "crates/catalog" }
|
||||
lightcraft-engine = { path = "crates/engine" }
|
||||
lightcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
lightcraft-mcp = { path = "crates/mcp" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
rayon = "1"
|
||||
proptest = "1"
|
||||
bytemuck = { version = "1", features = ["derive"] }
|
||||
|
||||
# codecs
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff", "webp"] }
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
rfd = "0.17"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "deny"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
needless_range_loop = "allow"
|
||||
type_complexity = "allow"
|
||||
excessive_precision = "allow"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
|
||||
[profile.release]
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.dist]
|
||||
inherits = "release"
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
debug = false
|
||||
@@ -0,0 +1,10 @@
|
||||
LightCraft
|
||||
Copyright the LightCraft contributors. Licensed under MIT OR Apache-2.0.
|
||||
|
||||
This product bundles third-party material under the following licences:
|
||||
|
||||
- Source Sans 3 (assets/fonts/SourceSans3-*) — SIL Open Font License 1.1. See assets/fonts/OFL-SourceSans3.txt.
|
||||
- Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||
|
||||
Algorithms are implemented from public specifications and papers (see crate READMEs). Rust dependencies
|
||||
(egui, eframe, image, rayon, serde, …) are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io.
|
||||
@@ -0,0 +1,18 @@
|
||||
# LightCraft
|
||||
|
||||
A fast, beautiful, open-source photo library and non-destructive raw developer — a clean-room, pure-Rust
|
||||
alternative to Adobe Lightroom. Runs on macOS, Windows, Linux and the web (WASM).
|
||||
|
||||
- Engine-first Cargo workspace; the egui UI is one swappable crate.
|
||||
- Everything is a command: every menu item, slider, brush stroke and crop drag is drivable over a JSON control
|
||||
channel and an MCP server, so agents can cull, develop and export.
|
||||
- Scene-referred, wide-gamut float pipeline; own RAW decoders; local-first catalog.
|
||||
|
||||
```sh
|
||||
cargo run --release -p lightcraft # desktop app
|
||||
cargo run --release -p lightcraft -- --control 7980 # with the automation channel
|
||||
cargo run -p lightcraft-cli -- mcp # MCP server (stdio)
|
||||
cargo xtask ci # fmt, clippy, tests, layering, wasm
|
||||
```
|
||||
|
||||
Licence: MIT OR Apache-2.0. By the artcraft team.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2010-2024 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "lightcraft-color"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "LightCraft color (L0)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
lightcraft-geom = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Correlated colour temperature, tint, and white-balance adaptation.
|
||||
//!
|
||||
//! The Planckian locus uses the Kim et al. (2002) cubic approximation (1667–25000 K); outside that
|
||||
//! range it is extended linearly in mired space. Tint is a signed offset perpendicular to the locus in
|
||||
//! CIE 1960 (u, v): **positive tint = magenta** (below the locus), `Duv = −tint / TINT_SCALE`.
|
||||
|
||||
use crate::{Mat3, RgbSpace, Xy, bradford};
|
||||
|
||||
/// Tint units per unit of Duv.
|
||||
pub const TINT_SCALE: f64 = 3000.0;
|
||||
pub const MIN_K: f64 = 1667.0;
|
||||
pub const MAX_K: f64 = 25000.0;
|
||||
|
||||
fn kim_xy(t: f64) -> Xy {
|
||||
let t = t.clamp(MIN_K, MAX_K);
|
||||
let (t2, t3) = (t * t, t * t * t);
|
||||
let x = if t <= 4000.0 {
|
||||
-0.2661239e9 / t3 - 0.2343589e6 / t2 + 0.8776956e3 / t + 0.179910
|
||||
} else {
|
||||
-3.0258469e9 / t3 + 2.1070379e6 / t2 + 0.2226347e3 / t + 0.240390
|
||||
};
|
||||
let (x2, x3) = (x * x, x * x * x);
|
||||
let y = if t <= 2222.0 {
|
||||
-1.1063814 * x3 - 1.34811020 * x2 + 2.18555832 * x - 0.20219683
|
||||
} else if t <= 4000.0 {
|
||||
-0.9549476 * x3 - 1.37418593 * x2 + 2.09137015 * x - 0.16748867
|
||||
} else {
|
||||
3.0817580 * x3 - 5.87338670 * x2 + 3.75112997 * x - 0.37001483
|
||||
};
|
||||
Xy::new(x, y)
|
||||
}
|
||||
|
||||
pub fn xy_to_uv(p: Xy) -> (f64, f64) {
|
||||
let d = -2.0 * p.x + 12.0 * p.y + 3.0;
|
||||
(4.0 * p.x / d, 6.0 * p.y / d)
|
||||
}
|
||||
|
||||
pub fn uv_to_xy(u: f64, v: f64) -> Xy {
|
||||
let d = 2.0 * u - 8.0 * v + 4.0;
|
||||
Xy::new(3.0 * u / d, 2.0 * v / d)
|
||||
}
|
||||
|
||||
/// Planckian locus in (u, v) with mired-linear extension outside the approximation's range.
|
||||
fn locus_uv(t: f64) -> (f64, f64) {
|
||||
let t = t.max(1000.0);
|
||||
if (MIN_K..=MAX_K).contains(&t) {
|
||||
return xy_to_uv(kim_xy(t));
|
||||
}
|
||||
let (a, b) = if t < MIN_K { (MIN_K, MIN_K + 50.0) } else { (MAX_K - 500.0, MAX_K) };
|
||||
let (ua, va) = xy_to_uv(kim_xy(a));
|
||||
let (ub, vb) = xy_to_uv(kim_xy(b));
|
||||
let (ma, mb, m) = (1e6 / a, 1e6 / b, 1e6 / t);
|
||||
let s = (m - ma) / (mb - ma);
|
||||
(ua + (ub - ua) * s, va + (vb - va) * s)
|
||||
}
|
||||
|
||||
/// Unit normal of the locus at `t` pointing towards green (increasing v).
|
||||
fn locus_normal(t: f64) -> (f64, f64) {
|
||||
let m = 1e6 / t;
|
||||
let (u0, v0) = locus_uv(1e6 / (m + 1.0));
|
||||
let (u1, v1) = locus_uv(1e6 / (m - 1.0).max(1.0));
|
||||
let (du, dv) = (u1 - u0, v1 - v0);
|
||||
let l = du.hypot(dv).max(1e-12);
|
||||
let (nx, ny) = (-dv / l, du / l);
|
||||
if ny < 0.0 { (-nx, -ny) } else { (nx, ny) }
|
||||
}
|
||||
|
||||
/// Chromaticity of the white point for temperature `t` (K) and `tint`.
|
||||
pub fn temp_tint_to_xy(t: f64, tint: f64) -> Xy {
|
||||
let (u, v) = locus_uv(t);
|
||||
let (nu, nv) = locus_normal(t);
|
||||
let duv = -tint / TINT_SCALE;
|
||||
uv_to_xy(u + nu * duv, v + nv * duv)
|
||||
}
|
||||
|
||||
/// Inverse of [`temp_tint_to_xy`]: nearest locus temperature (searched in mired space) and tint.
|
||||
pub fn xy_to_temp_tint(p: Xy) -> (f64, f64) {
|
||||
let (u, v) = xy_to_uv(p);
|
||||
let dist = |m: f64| {
|
||||
let (lu, lv) = locus_uv(1e6 / m);
|
||||
(u - lu).powi(2) + (v - lv).powi(2)
|
||||
};
|
||||
// coarse scan then golden-section refine over mired 20..1000 (50000 K .. 1000 K)
|
||||
let mut best = 20.0;
|
||||
let mut bd = f64::MAX;
|
||||
let mut m = 20.0;
|
||||
while m <= 1000.0 {
|
||||
let d = dist(m);
|
||||
if d < bd {
|
||||
bd = d;
|
||||
best = m;
|
||||
}
|
||||
m += 5.0;
|
||||
}
|
||||
let (mut a, mut b) = ((best - 5.0).max(20.0), (best + 5.0).min(1000.0));
|
||||
let g = 0.618_033_988_75;
|
||||
for _ in 0..60 {
|
||||
let c = b - g * (b - a);
|
||||
let d = a + g * (b - a);
|
||||
if dist(c) < dist(d) { b = d } else { a = c }
|
||||
}
|
||||
let m = (a + b) / 2.0;
|
||||
let t = 1e6 / m;
|
||||
let (lu, lv) = locus_uv(t);
|
||||
let (nu, nv) = locus_normal(t);
|
||||
let duv = (u - lu) * nu + (v - lv) * nv;
|
||||
(t, -duv * TINT_SCALE)
|
||||
}
|
||||
|
||||
/// Matrix (linear RGB in `space` → same space) that white-balances a scene lit by `src` so that it
|
||||
/// renders as if lit by the space's white (von-Kries/Bradford adaptation).
|
||||
pub fn wb_matrix(space: &RgbSpace, src: Xy) -> Mat3 {
|
||||
let adapt = bradford(src, space.white);
|
||||
space.from_xyz().mul(&adapt).mul(&space.to_xyz())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn d65_is_about_6504k() {
|
||||
let (t, tint) = xy_to_temp_tint(crate::D65);
|
||||
assert!((t - 6504.0).abs() < 60.0, "{t}");
|
||||
assert!(tint.abs() < 15.0, "{tint}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_temp_tint() {
|
||||
for t in [2000.0, 2856.0, 4000.0, 5003.0, 6500.0, 9000.0, 15000.0, 30000.0, 50000.0] {
|
||||
for tint in [-100.0, -20.0, 0.0, 35.0, 150.0] {
|
||||
let (t2, tint2) = xy_to_temp_tint(temp_tint_to_xy(t, tint));
|
||||
assert!((t2 - t).abs() / t < 0.01, "{t} {tint} -> {t2}");
|
||||
assert!((tint2 - tint).abs() < 1.0, "{t} {tint} -> {tint2}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmer_is_redder() {
|
||||
let warm = temp_tint_to_xy(3000.0, 0.0);
|
||||
let cool = temp_tint_to_xy(9000.0, 0.0);
|
||||
assert!(warm.x > cool.x);
|
||||
// positive tint is magenta: lower v
|
||||
let (_, v0) = xy_to_uv(temp_tint_to_xy(5000.0, 0.0));
|
||||
let (_, v1) = xy_to_uv(temp_tint_to_xy(5000.0, 50.0));
|
||||
assert!(v1 < v0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wb_matrix_maps_src_white_to_white() {
|
||||
let sp = crate::REC2020;
|
||||
let src = temp_tint_to_xy(3200.0, 10.0);
|
||||
let m = wb_matrix(&sp, src);
|
||||
// RGB of the source white in the space:
|
||||
let rgb = sp.from_xyz().apply(src.to_xyz());
|
||||
let out = m.apply(rgb);
|
||||
assert!((out[0] - out[1]).abs() < 1e-6 && (out[1] - out[2]).abs() < 1e-6, "{out:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Colour science for LightCraft.
|
||||
//!
|
||||
//! - [`Mat3`] and RGB colour spaces defined by primaries + white point ([`RgbSpace`]).
|
||||
//! - Chromatic adaptation (Bradford), correlated colour temperature ↔ chromaticity, and the
|
||||
//! temperature/tint white-balance model ([`cct`]).
|
||||
//! - Transfer functions (sRGB, gamma, PQ, HLG) in [`transfer`].
|
||||
//! - Perceptual spaces: OkLab / OkLCh, HSL/HSV in [`perceptual`].
|
||||
//! - Tone-curve splines in [`spline`].
|
||||
//!
|
||||
//! The pipeline's working space is **linear Rec.2020, D65** ([`WORKING`]).
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod cct;
|
||||
pub mod perceptual;
|
||||
pub mod spline;
|
||||
pub mod transfer;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Row-major 3×3 matrix.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Mat3(pub [[f64; 3]; 3]);
|
||||
|
||||
impl Mat3 {
|
||||
pub const IDENTITY: Mat3 = Mat3([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
|
||||
|
||||
pub fn diag(a: f64, b: f64, c: f64) -> Mat3 {
|
||||
Mat3([[a, 0.0, 0.0], [0.0, b, 0.0], [0.0, 0.0, c]])
|
||||
}
|
||||
pub fn mul(&self, o: &Mat3) -> Mat3 {
|
||||
let mut r = [[0.0; 3]; 3];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
r[i][j] = (0..3).map(|k| self.0[i][k] * o.0[k][j]).sum();
|
||||
}
|
||||
}
|
||||
Mat3(r)
|
||||
}
|
||||
pub fn apply(&self, v: [f64; 3]) -> [f64; 3] {
|
||||
let m = &self.0;
|
||||
[
|
||||
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
|
||||
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
|
||||
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
|
||||
]
|
||||
}
|
||||
pub fn apply_f32(&self, v: [f32; 3]) -> [f32; 3] {
|
||||
let r = self.apply([v[0] as f64, v[1] as f64, v[2] as f64]);
|
||||
[r[0] as f32, r[1] as f32, r[2] as f32]
|
||||
}
|
||||
pub fn determinant(&self) -> f64 {
|
||||
let m = &self.0;
|
||||
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
||||
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
|
||||
}
|
||||
pub fn inverse(&self) -> Option<Mat3> {
|
||||
let d = self.determinant();
|
||||
if d.abs() < 1e-300 {
|
||||
return None;
|
||||
}
|
||||
let m = &self.0;
|
||||
let i = 1.0 / d;
|
||||
Some(Mat3([
|
||||
[(m[1][1] * m[2][2] - m[1][2] * m[2][1]) * i, (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * i, (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * i],
|
||||
[(m[1][2] * m[2][0] - m[1][0] * m[2][2]) * i, (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * i, (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * i],
|
||||
[(m[1][0] * m[2][1] - m[1][1] * m[2][0]) * i, (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * i, (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * i],
|
||||
]))
|
||||
}
|
||||
pub fn transpose(&self) -> Mat3 {
|
||||
let m = &self.0;
|
||||
Mat3([[m[0][0], m[1][0], m[2][0]], [m[0][1], m[1][1], m[2][1]], [m[0][2], m[1][2], m[2][2]]])
|
||||
}
|
||||
/// As f32 (row-major), for kernels.
|
||||
pub fn to_f32(&self) -> [[f32; 3]; 3] {
|
||||
self.0.map(|r| r.map(|v| v as f32))
|
||||
}
|
||||
}
|
||||
|
||||
/// CIE xy chromaticity.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Xy {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
impl Xy {
|
||||
pub const fn new(x: f64, y: f64) -> Self {
|
||||
Self { x, y }
|
||||
}
|
||||
/// XYZ with Y = 1.
|
||||
pub fn to_xyz(self) -> [f64; 3] {
|
||||
[self.x / self.y, 1.0, (1.0 - self.x - self.y) / self.y]
|
||||
}
|
||||
pub fn from_xyz(v: [f64; 3]) -> Xy {
|
||||
let s = v[0] + v[1] + v[2];
|
||||
if s <= 0.0 { D65 } else { Xy::new(v[0] / s, v[1] / s) }
|
||||
}
|
||||
}
|
||||
|
||||
pub const D65: Xy = Xy::new(0.3127, 0.3290);
|
||||
pub const D50: Xy = Xy::new(0.3457, 0.3585);
|
||||
|
||||
/// An RGB colour space: primaries and white point.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
pub struct RgbSpace {
|
||||
pub name: &'static str,
|
||||
pub r: Xy,
|
||||
pub g: Xy,
|
||||
pub b: Xy,
|
||||
pub white: Xy,
|
||||
}
|
||||
|
||||
pub const SRGB: RgbSpace = RgbSpace { name: "sRGB", r: Xy::new(0.64, 0.33), g: Xy::new(0.30, 0.60), b: Xy::new(0.15, 0.06), white: D65 };
|
||||
pub const DISPLAY_P3: RgbSpace =
|
||||
RgbSpace { name: "Display P3", r: Xy::new(0.680, 0.320), g: Xy::new(0.265, 0.690), b: Xy::new(0.150, 0.060), white: D65 };
|
||||
pub const REC2020: RgbSpace = RgbSpace { name: "Rec.2020", r: Xy::new(0.708, 0.292), g: Xy::new(0.170, 0.797), b: Xy::new(0.131, 0.046), white: D65 };
|
||||
pub const PROPHOTO: RgbSpace =
|
||||
RgbSpace { name: "ProPhoto RGB", r: Xy::new(0.734699, 0.265301), g: Xy::new(0.159597, 0.840403), b: Xy::new(0.036598, 0.000105), white: D50 };
|
||||
/// The 1998 "Adobe RGB" primaries (a public colour-space definition; name used descriptively).
|
||||
pub const ADOBE_RGB: RgbSpace =
|
||||
RgbSpace { name: "Adobe RGB (1998)", r: Xy::new(0.64, 0.33), g: Xy::new(0.21, 0.71), b: Xy::new(0.15, 0.06), white: D65 };
|
||||
|
||||
/// The develop pipeline's working space: linear Rec.2020 / D65.
|
||||
pub const WORKING: RgbSpace = REC2020;
|
||||
|
||||
impl RgbSpace {
|
||||
/// Matrix taking linear RGB in this space to XYZ (white Y = 1) relative to its own white.
|
||||
pub fn to_xyz(&self) -> Mat3 {
|
||||
let p = [self.r.to_xyz(), self.g.to_xyz(), self.b.to_xyz()];
|
||||
let m = Mat3([[p[0][0], p[1][0], p[2][0]], [p[0][1], p[1][1], p[2][1]], [p[0][2], p[1][2], p[2][2]]]);
|
||||
let s = m.inverse().expect("primaries are independent").apply(self.white.to_xyz());
|
||||
m.mul(&Mat3::diag(s[0], s[1], s[2]))
|
||||
}
|
||||
pub fn from_xyz(&self) -> Mat3 {
|
||||
self.to_xyz().inverse().expect("invertible")
|
||||
}
|
||||
/// Luminance weights (the Y row of `to_xyz`).
|
||||
pub fn luma(&self) -> [f64; 3] {
|
||||
self.to_xyz().0[1]
|
||||
}
|
||||
/// Linear RGB in `self` → linear RGB in `dst`, Bradford-adapting between white points.
|
||||
pub fn to_space(&self, dst: &RgbSpace) -> Mat3 {
|
||||
let adapt = bradford(self.white, dst.white);
|
||||
dst.from_xyz().mul(&adapt).mul(&self.to_xyz())
|
||||
}
|
||||
}
|
||||
|
||||
const BRADFORD: Mat3 = Mat3([[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]);
|
||||
|
||||
/// Bradford chromatic adaptation matrix (XYZ → XYZ) from white `src` to white `dst`.
|
||||
pub fn bradford(src: Xy, dst: Xy) -> Mat3 {
|
||||
if src == dst {
|
||||
return Mat3::IDENTITY;
|
||||
}
|
||||
let s = BRADFORD.apply(src.to_xyz());
|
||||
let d = BRADFORD.apply(dst.to_xyz());
|
||||
let inv = BRADFORD.inverse().expect("invertible");
|
||||
inv.mul(&Mat3::diag(d[0] / s[0], d[1] / s[1], d[2] / s[2])).mul(&BRADFORD)
|
||||
}
|
||||
|
||||
/// Rec.2020 luminance weights as f32 (hot path).
|
||||
pub const LUMA_2020: [f32; 3] = [0.2627, 0.6780, 0.0593];
|
||||
/// Rec.709 / sRGB luminance weights.
|
||||
pub const LUMA_709: [f32; 3] = [0.2126, 0.7152, 0.0722];
|
||||
|
||||
#[inline]
|
||||
pub fn luminance_2020(c: [f32; 3]) -> f32 {
|
||||
c[0] * LUMA_2020[0] + c[1] * LUMA_2020[1] + c[2] * LUMA_2020[2]
|
||||
}
|
||||
|
||||
/// An sRGB 8-bit colour, for UI and swatches.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct Rgb8(pub u8, pub u8, pub u8);
|
||||
|
||||
impl Rgb8 {
|
||||
pub fn from_hex(s: &str) -> Option<Rgb8> {
|
||||
let s = s.trim_start_matches('#');
|
||||
if s.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
let v = u32::from_str_radix(s, 16).ok()?;
|
||||
Some(Rgb8((v >> 16) as u8, (v >> 8) as u8, v as u8))
|
||||
}
|
||||
pub fn to_hex(self) -> String {
|
||||
format!("#{:02x}{:02x}{:02x}", self.0, self.1, self.2)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn srgb_matrix_matches_standard() {
|
||||
let m = SRGB.to_xyz();
|
||||
let want = [[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505]];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
assert!((m.0[i][j] - want[i][j]).abs() < 2e-4, "{i}{j} {}", m.0[i][j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rec2020_luma() {
|
||||
let l = REC2020.luma();
|
||||
assert!((l[0] - 0.2627).abs() < 1e-3 && (l[1] - 0.6780).abs() < 1e-3 && (l[2] - 0.0593).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_maps_to_white() {
|
||||
for sp in [SRGB, DISPLAY_P3, REC2020, PROPHOTO, ADOBE_RGB] {
|
||||
let w = sp.to_space(&REC2020).apply([1.0, 1.0, 1.0]);
|
||||
for c in w {
|
||||
assert!((c - 1.0).abs() < 1e-3, "{} {:?}", sp.name, w);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_roundtrip() {
|
||||
let a = SRGB.to_space(&PROPHOTO);
|
||||
let b = PROPHOTO.to_space(&SRGB);
|
||||
let v = b.apply(a.apply([0.2, 0.5, 0.9]));
|
||||
assert!((v[0] - 0.2).abs() < 1e-9 && (v[1] - 0.5).abs() < 1e-9 && (v[2] - 0.9).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex() {
|
||||
assert_eq!(Rgb8::from_hex("#1e1e1e"), Some(Rgb8(30, 30, 30)));
|
||||
assert_eq!(Rgb8(1, 2, 255).to_hex(), "#0102ff");
|
||||
assert_eq!(Rgb8::from_hex("xyz"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Perceptual colour models: OkLab / OkLCh (Björn Ottosson, 2020), HSL and HSV.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::{Mat3, REC2020};
|
||||
|
||||
const XYZ_TO_LMS: Mat3 = Mat3([
|
||||
[0.818_933_010_1, 0.361_866_742_4, -0.128_859_713_7],
|
||||
[0.032_984_543_6, 0.929_311_871_5, 0.036_145_638_7],
|
||||
[0.048_200_301_8, 0.264_366_269_1, 0.633_851_707_0],
|
||||
]);
|
||||
const LMS_TO_LAB: Mat3 = Mat3([
|
||||
[0.210_454_255_3, 0.793_617_785_0, -0.004_072_046_8],
|
||||
[1.977_998_495_1, -2.428_592_205_0, 0.450_593_709_9],
|
||||
[0.025_904_037_1, 0.782_771_766_2, -0.808_675_766_0],
|
||||
]);
|
||||
|
||||
struct OkMats {
|
||||
to_lms: [[f32; 3]; 3],
|
||||
from_lms: [[f32; 3]; 3],
|
||||
to_lab: [[f32; 3]; 3],
|
||||
from_lab: [[f32; 3]; 3],
|
||||
}
|
||||
|
||||
fn mats() -> &'static OkMats {
|
||||
static M: OnceLock<OkMats> = OnceLock::new();
|
||||
M.get_or_init(|| {
|
||||
let to_lms = XYZ_TO_LMS.mul(&REC2020.to_xyz());
|
||||
OkMats {
|
||||
to_lms: to_lms.to_f32(),
|
||||
from_lms: to_lms.inverse().expect("invertible").to_f32(),
|
||||
to_lab: LMS_TO_LAB.to_f32(),
|
||||
from_lab: LMS_TO_LAB.inverse().expect("invertible").to_f32(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn mul(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
|
||||
[
|
||||
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
|
||||
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
|
||||
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
|
||||
]
|
||||
}
|
||||
|
||||
/// Linear Rec.2020 → OkLab `[L, a, b]`.
|
||||
#[inline]
|
||||
pub fn oklab_from_2020(rgb: [f32; 3]) -> [f32; 3] {
|
||||
let m = mats();
|
||||
let lms = mul(&m.to_lms, rgb).map(f32::cbrt);
|
||||
mul(&m.to_lab, lms)
|
||||
}
|
||||
|
||||
/// OkLab → linear Rec.2020.
|
||||
#[inline]
|
||||
pub fn oklab_to_2020(lab: [f32; 3]) -> [f32; 3] {
|
||||
let m = mats();
|
||||
let lms = mul(&m.from_lab, lab).map(|v| v * v * v);
|
||||
mul(&m.from_lms, lms)
|
||||
}
|
||||
|
||||
/// OkLab → OkLCh (hue in radians, −π..π).
|
||||
#[inline]
|
||||
pub fn lab_to_lch(lab: [f32; 3]) -> [f32; 3] {
|
||||
[lab[0], lab[1].hypot(lab[2]), lab[2].atan2(lab[1])]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn lch_to_lab(lch: [f32; 3]) -> [f32; 3] {
|
||||
let (s, c) = lch[2].sin_cos();
|
||||
[lch[0], lch[1] * c, lch[1] * s]
|
||||
}
|
||||
|
||||
/// RGB (0..1) → HSV (h in degrees 0..360, s, v 0..1).
|
||||
pub fn rgb_to_hsv(c: [f32; 3]) -> [f32; 3] {
|
||||
let max = c[0].max(c[1]).max(c[2]);
|
||||
let min = c[0].min(c[1]).min(c[2]);
|
||||
let d = max - min;
|
||||
let h = hue_of(c, max, d);
|
||||
[h, if max > 0.0 { d / max } else { 0.0 }, max]
|
||||
}
|
||||
|
||||
pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] {
|
||||
let c = v * s;
|
||||
let hp = (h.rem_euclid(360.0)) / 60.0;
|
||||
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
|
||||
let (r, g, b) = match hp as u32 {
|
||||
0 => (c, x, 0.0),
|
||||
1 => (x, c, 0.0),
|
||||
2 => (0.0, c, x),
|
||||
3 => (0.0, x, c),
|
||||
4 => (x, 0.0, c),
|
||||
_ => (c, 0.0, x),
|
||||
};
|
||||
let m = v - c;
|
||||
[r + m, g + m, b + m]
|
||||
}
|
||||
|
||||
/// RGB (0..1) → HSL (h degrees, s, l).
|
||||
pub fn rgb_to_hsl(c: [f32; 3]) -> [f32; 3] {
|
||||
let max = c[0].max(c[1]).max(c[2]);
|
||||
let min = c[0].min(c[1]).min(c[2]);
|
||||
let d = max - min;
|
||||
let l = (max + min) / 2.0;
|
||||
let s = if d == 0.0 { 0.0 } else { d / (1.0 - (2.0 * l - 1.0).abs()).max(1e-9) };
|
||||
[hue_of(c, max, d), s, l]
|
||||
}
|
||||
|
||||
pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [f32; 3] {
|
||||
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
||||
let v = l + c / 2.0;
|
||||
hsv_to_rgb(h, if v > 0.0 { c / v } else { 0.0 }, v)
|
||||
}
|
||||
|
||||
fn hue_of(c: [f32; 3], max: f32, d: f32) -> f32 {
|
||||
if d == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let h = if max == c[0] {
|
||||
((c[1] - c[2]) / d).rem_euclid(6.0)
|
||||
} else if max == c[1] {
|
||||
(c[2] - c[0]) / d + 2.0
|
||||
} else {
|
||||
(c[0] - c[1]) / d + 4.0
|
||||
};
|
||||
h * 60.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn oklab_white_and_grey() {
|
||||
let w = oklab_from_2020([1.0, 1.0, 1.0]);
|
||||
assert!((w[0] - 1.0).abs() < 2e-3 && w[1].abs() < 2e-3 && w[2].abs() < 2e-3, "{w:?}");
|
||||
let g = oklab_from_2020([0.18, 0.18, 0.18]);
|
||||
assert!(g[1].abs() < 2e-3 && g[2].abs() < 2e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oklab_roundtrip() {
|
||||
for c in [[0.1, 0.5, 0.9], [1.0, 0.0, 0.0], [0.02, 0.03, 0.01], [2.0, 1.5, 0.3]] {
|
||||
let back = oklab_to_2020(oklab_from_2020(c));
|
||||
for i in 0..3 {
|
||||
assert!((back[i] - c[i]).abs() < 1e-4 * c[i].max(1.0), "{c:?} {back:?}");
|
||||
}
|
||||
}
|
||||
let lch = lab_to_lch([0.5, 0.1, -0.05]);
|
||||
let lab = lch_to_lab(lch);
|
||||
assert!((lab[1] - 0.1).abs() < 1e-6 && (lab[2] + 0.05).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hsv_hsl_roundtrip() {
|
||||
for c in [[0.2, 0.4, 0.6], [1.0, 0.0, 0.0], [0.5, 0.5, 0.5], [0.9, 0.8, 0.1]] {
|
||||
let h = rgb_to_hsv(c);
|
||||
let r = hsv_to_rgb(h[0], h[1], h[2]);
|
||||
let l = rgb_to_hsl(c);
|
||||
let r2 = hsl_to_rgb(l[0], l[1], l[2]);
|
||||
for i in 0..3 {
|
||||
assert!((r[i] - c[i]).abs() < 1e-5 && (r2[i] - c[i]).abs() < 1e-5, "{c:?}");
|
||||
}
|
||||
}
|
||||
assert_eq!(rgb_to_hsv([0.0, 1.0, 0.0])[0], 120.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Curves for tone mapping: monotone cubic Hermite splines (Fritsch–Carlson) and lookup tables.
|
||||
//!
|
||||
//! Monotone interpolation never overshoots between control points, so a point curve can't invert
|
||||
//! tones by accident (a common complaint with natural cubic splines).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A curve through control points in `0..1 × 0..1` (x strictly increasing after normalization).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MonotoneCurve {
|
||||
xs: Vec<f64>,
|
||||
ys: Vec<f64>,
|
||||
ms: Vec<f64>,
|
||||
}
|
||||
|
||||
impl MonotoneCurve {
|
||||
/// Build from points; sorts by x, merges duplicates. Fewer than two points → identity.
|
||||
pub fn new(points: &[(f64, f64)]) -> MonotoneCurve {
|
||||
let mut p: Vec<(f64, f64)> = points.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).collect();
|
||||
p.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
p.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-9);
|
||||
if p.len() < 2 {
|
||||
p = vec![(0.0, 0.0), (1.0, 1.0)];
|
||||
}
|
||||
let n = p.len();
|
||||
let xs: Vec<f64> = p.iter().map(|q| q.0).collect();
|
||||
let ys: Vec<f64> = p.iter().map(|q| q.1).collect();
|
||||
let d: Vec<f64> = (0..n - 1).map(|i| (ys[i + 1] - ys[i]) / (xs[i + 1] - xs[i])).collect();
|
||||
let mut ms = vec![0.0; n];
|
||||
ms[0] = d[0];
|
||||
ms[n - 1] = d[n - 2];
|
||||
for i in 1..n - 1 {
|
||||
ms[i] = if d[i - 1] * d[i] <= 0.0 { 0.0 } else { (d[i - 1] + d[i]) / 2.0 };
|
||||
}
|
||||
// Fritsch–Carlson limiter where the data is monotone within a segment.
|
||||
for i in 0..n - 1 {
|
||||
if d[i] == 0.0 {
|
||||
ms[i] = 0.0;
|
||||
ms[i + 1] = 0.0;
|
||||
continue;
|
||||
}
|
||||
let a = ms[i] / d[i];
|
||||
let b = ms[i + 1] / d[i];
|
||||
let s = a * a + b * b;
|
||||
if s > 9.0 {
|
||||
let t = 3.0 / s.sqrt();
|
||||
ms[i] = t * a * d[i];
|
||||
ms[i + 1] = t * b * d[i];
|
||||
}
|
||||
}
|
||||
MonotoneCurve { xs, ys, ms }
|
||||
}
|
||||
|
||||
pub fn identity() -> MonotoneCurve {
|
||||
MonotoneCurve::new(&[(0.0, 0.0), (1.0, 1.0)])
|
||||
}
|
||||
|
||||
pub fn eval(&self, x: f64) -> f64 {
|
||||
let n = self.xs.len();
|
||||
if x <= self.xs[0] {
|
||||
return self.ys[0] + self.ms[0] * (x - self.xs[0]).min(0.0) * 0.0;
|
||||
}
|
||||
if x >= self.xs[n - 1] {
|
||||
return self.ys[n - 1];
|
||||
}
|
||||
let i = match self.xs.binary_search_by(|v| v.total_cmp(&x)) {
|
||||
Ok(i) => return self.ys[i],
|
||||
Err(i) => i - 1,
|
||||
};
|
||||
let h = self.xs[i + 1] - self.xs[i];
|
||||
let t = (x - self.xs[i]) / h;
|
||||
let (t2, t3) = (t * t, t * t * t);
|
||||
(2.0 * t3 - 3.0 * t2 + 1.0) * self.ys[i]
|
||||
+ (t3 - 2.0 * t2 + t) * h * self.ms[i]
|
||||
+ (-2.0 * t3 + 3.0 * t2) * self.ys[i + 1]
|
||||
+ (t3 - t2) * h * self.ms[i + 1]
|
||||
}
|
||||
|
||||
/// Sample into a LUT of `n` entries over `0..1`.
|
||||
pub fn to_lut(&self, n: usize) -> Lut1 {
|
||||
Lut1 { v: (0..n).map(|i| self.eval(i as f64 / (n - 1) as f64) as f32).collect() }
|
||||
}
|
||||
|
||||
pub fn points(&self) -> Vec<(f64, f64)> {
|
||||
self.xs.iter().copied().zip(self.ys.iter().copied()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A 1-D lookup table over `0..1` with linear interpolation; inputs outside clamp.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Lut1 {
|
||||
pub v: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Lut1 {
|
||||
pub fn identity(n: usize) -> Lut1 {
|
||||
Lut1 { v: (0..n).map(|i| i as f32 / (n - 1) as f32).collect() }
|
||||
}
|
||||
pub fn from_fn(n: usize, f: impl Fn(f32) -> f32) -> Lut1 {
|
||||
Lut1 { v: (0..n).map(|i| f(i as f32 / (n - 1) as f32)).collect() }
|
||||
}
|
||||
#[inline]
|
||||
pub fn eval(&self, x: f32) -> f32 {
|
||||
let n = self.v.len();
|
||||
let f = x.clamp(0.0, 1.0) * (n - 1) as f32;
|
||||
let i = (f as usize).min(n - 2);
|
||||
let t = f - i as f32;
|
||||
self.v[i] + (self.v[i + 1] - self.v[i]) * t
|
||||
}
|
||||
/// `self ∘ inner` (apply `inner` first).
|
||||
pub fn compose(&self, inner: &Lut1) -> Lut1 {
|
||||
Lut1 { v: inner.v.iter().map(|&x| self.eval(x)).collect() }
|
||||
}
|
||||
pub fn is_identity(&self) -> bool {
|
||||
let n = self.v.len();
|
||||
self.v.iter().enumerate().all(|(i, &y)| (y - i as f32 / (n - 1) as f32).abs() < 1e-6)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn identity_curve() {
|
||||
let c = MonotoneCurve::identity();
|
||||
for i in 0..=10 {
|
||||
let x = i as f64 / 10.0;
|
||||
assert!((c.eval(x) - x).abs() < 1e-12);
|
||||
}
|
||||
assert!(c.to_lut(256).is_identity());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passes_through_points_and_is_monotone() {
|
||||
let pts = [(0.0, 0.0), (0.25, 0.15), (0.5, 0.5), (0.75, 0.88), (1.0, 1.0)];
|
||||
let c = MonotoneCurve::new(&pts);
|
||||
for (x, y) in pts {
|
||||
assert!((c.eval(x) - y).abs() < 1e-12);
|
||||
}
|
||||
let mut prev = -1.0;
|
||||
for i in 0..=1000 {
|
||||
let y = c.eval(i as f64 / 1000.0);
|
||||
assert!(y >= prev - 1e-12);
|
||||
prev = y;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_overshoot_on_flat_segment() {
|
||||
let c = MonotoneCurve::new(&[(0.0, 0.0), (0.4, 0.5), (0.6, 0.5), (1.0, 1.0)]);
|
||||
for i in 400..=600 {
|
||||
let y = c.eval(i as f64 / 1000.0);
|
||||
assert!((y - 0.5).abs() < 1e-9, "{y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lut_compose() {
|
||||
let a = Lut1::from_fn(1024, |x| x * x);
|
||||
let b = Lut1::from_fn(1024, |x| x.sqrt());
|
||||
let c = a.compose(&b);
|
||||
assert!((c.eval(0.3) - 0.3).abs() < 2e-3);
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn monotone_for_increasing_data(ys in proptest::collection::vec(0.0f64..1.0, 3..8)) {
|
||||
let mut ys = ys; ys.sort_by(|a, b| a.total_cmp(b));
|
||||
let n = ys.len();
|
||||
let pts: Vec<(f64, f64)> = ys.iter().enumerate().map(|(i, y)| (i as f64 / (n - 1) as f64, *y)).collect();
|
||||
let c = MonotoneCurve::new(&pts);
|
||||
let mut prev = f64::MIN;
|
||||
for i in 0..=200 { let y = c.eval(i as f64 / 200.0); proptest::prop_assert!(y >= prev - 1e-9); prev = y; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Transfer functions (OETF/EOTF) and fast 8-bit display encoding.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[inline]
|
||||
pub fn srgb_to_linear(v: f32) -> f32 {
|
||||
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn linear_to_srgb(v: f32) -> f32 {
|
||||
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
|
||||
}
|
||||
|
||||
pub fn gamma_to_linear(v: f32, g: f32) -> f32 {
|
||||
v.max(0.0).powf(g)
|
||||
}
|
||||
|
||||
pub fn linear_to_gamma(v: f32, g: f32) -> f32 {
|
||||
v.max(0.0).powf(1.0 / g)
|
||||
}
|
||||
|
||||
const PQ_M1: f64 = 2610.0 / 16384.0;
|
||||
const PQ_M2: f64 = 2523.0 / 4096.0 * 128.0;
|
||||
const PQ_C1: f64 = 3424.0 / 4096.0;
|
||||
const PQ_C2: f64 = 2413.0 / 4096.0 * 32.0;
|
||||
const PQ_C3: f64 = 2392.0 / 4096.0 * 32.0;
|
||||
|
||||
/// SMPTE ST 2084 inverse EOTF: absolute luminance / 10000 cd/m² → code value 0..1.
|
||||
pub fn pq_encode(y: f64) -> f64 {
|
||||
let y = y.clamp(0.0, 1.0).powf(PQ_M1);
|
||||
((PQ_C1 + PQ_C2 * y) / (1.0 + PQ_C3 * y)).powf(PQ_M2)
|
||||
}
|
||||
|
||||
/// SMPTE ST 2084 EOTF: code value → luminance / 10000 cd/m².
|
||||
pub fn pq_decode(e: f64) -> f64 {
|
||||
let p = e.clamp(0.0, 1.0).powf(1.0 / PQ_M2);
|
||||
((p - PQ_C1).max(0.0) / (PQ_C2 - PQ_C3 * p)).powf(1.0 / PQ_M1)
|
||||
}
|
||||
|
||||
const HLG_A: f64 = 0.178_832_77;
|
||||
const HLG_B: f64 = 0.284_668_92;
|
||||
const HLG_C: f64 = 0.559_910_73;
|
||||
|
||||
/// ITU-R BT.2100 HLG OETF (scene linear 0..1 → signal).
|
||||
pub fn hlg_encode(e: f64) -> f64 {
|
||||
let e = e.max(0.0);
|
||||
if e <= 1.0 / 12.0 { (3.0 * e).sqrt() } else { HLG_A * (12.0 * e - HLG_B).ln() + HLG_C }
|
||||
}
|
||||
|
||||
pub fn hlg_decode(s: f64) -> f64 {
|
||||
if s <= 0.5 { s * s / 3.0 } else { (((s - HLG_C) / HLG_A).exp() + HLG_B) / 12.0 }
|
||||
}
|
||||
|
||||
const ENC_BITS: usize = 14;
|
||||
const ENC_N: usize = 1 << ENC_BITS;
|
||||
|
||||
fn enc_lut() -> &'static [u8] {
|
||||
static LUT: OnceLock<Vec<u8>> = OnceLock::new();
|
||||
LUT.get_or_init(|| {
|
||||
(0..ENC_N)
|
||||
.map(|i| {
|
||||
// index = sqrt-spaced domain for precision in the shadows
|
||||
let t = i as f32 / (ENC_N - 1) as f32;
|
||||
let lin = t * t;
|
||||
(linear_to_srgb(lin) * 255.0 + 0.5) as u8
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Fast linear (0..1, clamped) → sRGB 8-bit using a sqrt-domain LUT (max error ≤ 1 code value).
|
||||
#[inline]
|
||||
pub fn encode_srgb8(v: f32) -> u8 {
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
let i = (v.sqrt() * (ENC_N - 1) as f32 + 0.5) as usize;
|
||||
enc_lut()[i.min(ENC_N - 1)]
|
||||
}
|
||||
|
||||
fn dec_lut() -> &'static [f32; 256] {
|
||||
static LUT: OnceLock<[f32; 256]> = OnceLock::new();
|
||||
LUT.get_or_init(|| std::array::from_fn(|i| srgb_to_linear(i as f32 / 255.0)))
|
||||
}
|
||||
|
||||
/// sRGB 8-bit → linear.
|
||||
#[inline]
|
||||
pub fn decode_srgb8(v: u8) -> f32 {
|
||||
dec_lut()[v as usize]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn srgb_roundtrip() {
|
||||
for i in 0..=100 {
|
||||
let v = i as f32 / 100.0;
|
||||
assert!((linear_to_srgb(srgb_to_linear(v)) - v).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode8_matches_exact() {
|
||||
for i in 0..=4096 {
|
||||
let lin = i as f32 / 4096.0;
|
||||
let exact = (linear_to_srgb(lin) * 255.0).round() as i32;
|
||||
assert!((encode_srgb8(lin) as i32 - exact).abs() <= 1, "{lin}");
|
||||
}
|
||||
for v in 0..=255u8 {
|
||||
assert_eq!(encode_srgb8(decode_srgb8(v)), v);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pq_hlg_roundtrip() {
|
||||
for i in 0..=50 {
|
||||
let y = i as f64 / 50.0;
|
||||
assert!((pq_decode(pq_encode(y)) - y).abs() < 1e-9);
|
||||
assert!((hlg_decode(hlg_encode(y)) - y).abs() < 1e-9);
|
||||
}
|
||||
// 100 cd/m² ≈ PQ 0.508
|
||||
assert!((pq_encode(0.01) - 0.508).abs() < 0.002);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "lightcraft-geom"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "LightCraft geom (L0)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -0,0 +1,143 @@
|
||||
//! The crop model.
|
||||
//!
|
||||
//! The image (w × h pixels after orientation) is rotated by `angle` about its centre. The crop is an
|
||||
//! axis-aligned rectangle in that rotated frame, stored *normalized* to the image size
|
||||
//! (`x / w`, `y / h`), so it is independent of preview resolution. The crop must stay inside the
|
||||
//! rotated image (Lightroom's "Constrain to image").
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Affine, Point, Rect, Vec2};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CropGeometry {
|
||||
/// Normalized crop rectangle in the rotated frame (0..1 when angle = 0).
|
||||
pub rect: Rect,
|
||||
/// Straighten angle in degrees, clockwise positive, −45..45.
|
||||
pub angle: f64,
|
||||
}
|
||||
|
||||
impl Default for CropGeometry {
|
||||
fn default() -> Self {
|
||||
Self { rect: Rect::UNIT, angle: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl CropGeometry {
|
||||
pub fn is_identity(&self) -> bool {
|
||||
self.angle == 0.0 && self.rect == Rect::UNIT
|
||||
}
|
||||
|
||||
/// Crop rectangle in pixels of the rotated frame for an image of `w × h`.
|
||||
pub fn rect_px(&self, w: f64, h: f64) -> Rect {
|
||||
self.rect.scale(w, h)
|
||||
}
|
||||
|
||||
/// Transform from *output* (cropped) pixel coordinates to *source* (oriented image) pixel
|
||||
/// coordinates, for an output of `out_w × out_h` pixels rendered from a `w × h` source.
|
||||
pub fn output_to_source(&self, w: f64, h: f64, out_w: f64, out_h: f64) -> Affine {
|
||||
let r = self.rect_px(w, h);
|
||||
// output -> crop rect in rotated frame
|
||||
let to_rot = Affine::translate(Vec2::new(r.x0, r.y0)) * Affine::scale(r.width() / out_w, r.height() / out_h);
|
||||
// rotated frame -> source: inverse rotation about the image centre
|
||||
let c = Point::new(w / 2.0, h / 2.0);
|
||||
Affine::rotate_about(-self.angle.to_radians(), c) * to_rot
|
||||
}
|
||||
|
||||
/// Whether every crop corner lies within the rotated image.
|
||||
pub fn is_within_image(&self, w: f64, h: f64) -> bool {
|
||||
let r = self.rect_px(w, h);
|
||||
let back = Affine::rotate_about(-self.angle.to_radians(), Point::new(w / 2.0, h / 2.0));
|
||||
r.corners().iter().all(|p| {
|
||||
let q = back.apply(*p);
|
||||
q.x >= -1e-6 * w && q.y >= -1e-6 * h && q.x <= w * (1.0 + 1e-6) && q.y <= h * (1.0 + 1e-6)
|
||||
})
|
||||
}
|
||||
|
||||
/// Shrink the crop (about its centre, keeping aspect) and then shift it until it fits inside the
|
||||
/// rotated image.
|
||||
pub fn constrained(&self, w: f64, h: f64) -> CropGeometry {
|
||||
if self.is_within_image(w, h) {
|
||||
return *self;
|
||||
}
|
||||
let r = self.rect_px(w, h);
|
||||
// Try moving towards the image centre first.
|
||||
let c = Point::new(w / 2.0, h / 2.0);
|
||||
let mut best = *self;
|
||||
for i in 1..=32 {
|
||||
let t = i as f64 / 32.0;
|
||||
let nc = r.center().lerp(c, t);
|
||||
let cand = CropGeometry { rect: Rect::from_center(nc, r.width(), r.height()).scale(1.0 / w, 1.0 / h), angle: self.angle };
|
||||
if cand.is_within_image(w, h) {
|
||||
return cand;
|
||||
}
|
||||
best = cand;
|
||||
}
|
||||
// Then shrink around the centre.
|
||||
let s = max_inscribed_scale(w, h, self.angle, r.width(), r.height());
|
||||
let nr = Rect::from_center(c, r.width() * s, r.height() * s);
|
||||
let _ = best;
|
||||
CropGeometry { rect: nr.scale(1.0 / w, 1.0 / h), angle: self.angle }
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest scale `s` such that a centred `cw·s × ch·s` rectangle fits inside a `w × h` image rotated
|
||||
/// by `angle_deg` about its centre.
|
||||
pub fn max_inscribed_scale(w: f64, h: f64, angle_deg: f64, cw: f64, ch: f64) -> f64 {
|
||||
let a = angle_deg.to_radians().abs();
|
||||
let (s, c) = a.sin_cos();
|
||||
// Half extents of the crop must satisfy, in the image frame (rotated back):
|
||||
// |x| c + |y| s <= w/2 and |x| s + |y| c <= h/2 for the crop corner (x, y) = (cw/2, ch/2)·scale.
|
||||
let (hx, hy) = (cw / 2.0, ch / 2.0);
|
||||
let s1 = (w / 2.0) / (hx * c + hy * s);
|
||||
let s2 = (h / 2.0) / (hx * s + hy * c);
|
||||
s1.min(s2)
|
||||
}
|
||||
|
||||
/// The biggest crop of the image's own aspect (or `aspect` if given) that fits at `angle_deg`.
|
||||
pub fn crop_fit_angle(w: f64, h: f64, angle_deg: f64, aspect: Option<f64>) -> CropGeometry {
|
||||
let a = aspect.unwrap_or(w / h);
|
||||
let base = Rect::new(0.0, 0.0, w, h).fit_aspect(a);
|
||||
let s = max_inscribed_scale(w, h, angle_deg, base.width(), base.height()).min(1.0);
|
||||
let r = Rect::from_center(Point::new(w / 2.0, h / 2.0), base.width() * s, base.height() * s);
|
||||
CropGeometry { rect: r.scale(1.0 / w, 1.0 / h), angle: angle_deg }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn identity_maps_pixels() {
|
||||
let c = CropGeometry::default();
|
||||
let t = c.output_to_source(400.0, 300.0, 400.0, 300.0);
|
||||
let p = t.apply(Point::new(10.0, 20.0));
|
||||
assert!((p.x - 10.0).abs() < 1e-9 && (p.y - 20.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_fit_is_inside() {
|
||||
for ang in [-45.0, -10.0, 0.0, 3.5, 30.0, 45.0] {
|
||||
let c = crop_fit_angle(600.0, 400.0, ang, None);
|
||||
assert!(c.is_within_image(600.0, 400.0), "{ang}");
|
||||
// and maximal: 1% bigger does not fit (except angle 0 where it equals the image)
|
||||
let big = CropGeometry { rect: Rect::from_center(c.rect.center(), c.rect.width() * 1.01, c.rect.height() * 1.01), angle: ang };
|
||||
assert!(!big.is_within_image(600.0, 400.0), "{ang}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrain_pulls_crop_inside() {
|
||||
let c = CropGeometry { rect: Rect::new(0.5, 0.5, 1.4, 1.2), angle: 12.0 };
|
||||
let k = c.constrained(300.0, 200.0);
|
||||
assert!(k.is_within_image(300.0, 200.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_crop_maps_to_centre_quadrant() {
|
||||
let c = CropGeometry { rect: Rect::new(0.25, 0.25, 0.75, 0.75), angle: 0.0 };
|
||||
let t = c.output_to_source(100.0, 100.0, 50.0, 50.0);
|
||||
let p = t.apply(Point::new(0.0, 0.0));
|
||||
assert!((p.x - 25.0).abs() < 1e-9 && (p.y - 25.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Projective transforms (Upright, guided perspective, panorama alignment).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Affine, Point};
|
||||
|
||||
/// Row-major 3×3 matrix mapping homogeneous points: p' = H·p.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Homography(pub [f64; 9]);
|
||||
|
||||
impl Default for Homography {
|
||||
fn default() -> Self {
|
||||
Homography::IDENTITY
|
||||
}
|
||||
}
|
||||
|
||||
impl Homography {
|
||||
pub const IDENTITY: Homography = Homography([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
|
||||
|
||||
pub fn from_affine(a: &Affine) -> Homography {
|
||||
let [a0, b, c, d, e, f] = a.0;
|
||||
Homography([a0, c, e, b, d, f, 0.0, 0.0, 1.0])
|
||||
}
|
||||
|
||||
pub fn apply(&self, p: Point) -> Point {
|
||||
let m = &self.0;
|
||||
let w = m[6] * p.x + m[7] * p.y + m[8];
|
||||
let w = if w.abs() < 1e-300 { 1e-300 } else { w };
|
||||
Point::new((m[0] * p.x + m[1] * p.y + m[2]) / w, (m[3] * p.x + m[4] * p.y + m[5]) / w)
|
||||
}
|
||||
|
||||
pub fn mul(&self, o: &Homography) -> Homography {
|
||||
let (a, b) = (&self.0, &o.0);
|
||||
let mut r = [0.0; 9];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
r[i * 3 + j] = (0..3).map(|k| a[i * 3 + k] * b[k * 3 + j]).sum();
|
||||
}
|
||||
}
|
||||
Homography(r)
|
||||
}
|
||||
|
||||
pub fn inverse(&self) -> Option<Homography> {
|
||||
let m = &self.0;
|
||||
let c00 = m[4] * m[8] - m[5] * m[7];
|
||||
let c01 = m[5] * m[6] - m[3] * m[8];
|
||||
let c02 = m[3] * m[7] - m[4] * m[6];
|
||||
let det = m[0] * c00 + m[1] * c01 + m[2] * c02;
|
||||
if det.abs() < 1e-300 {
|
||||
return None;
|
||||
}
|
||||
let inv = 1.0 / det;
|
||||
Some(Homography([
|
||||
c00 * inv,
|
||||
(m[2] * m[7] - m[1] * m[8]) * inv,
|
||||
(m[1] * m[5] - m[2] * m[4]) * inv,
|
||||
c01 * inv,
|
||||
(m[0] * m[8] - m[2] * m[6]) * inv,
|
||||
(m[2] * m[3] - m[0] * m[5]) * inv,
|
||||
c02 * inv,
|
||||
(m[1] * m[6] - m[0] * m[7]) * inv,
|
||||
(m[0] * m[4] - m[1] * m[3]) * inv,
|
||||
]))
|
||||
}
|
||||
|
||||
/// The homography mapping `src[i]` → `dst[i]` (4 correspondences, DLT with h33 = 1).
|
||||
pub fn from_quads(src: &[Point; 4], dst: &[Point; 4]) -> Option<Homography> {
|
||||
let mut a = [[0.0f64; 9]; 8];
|
||||
for i in 0..4 {
|
||||
let (x, y) = (src[i].x, src[i].y);
|
||||
let (u, v) = (dst[i].x, dst[i].y);
|
||||
a[2 * i] = [x, y, 1.0, 0.0, 0.0, 0.0, -u * x, -u * y, u];
|
||||
a[2 * i + 1] = [0.0, 0.0, 0.0, x, y, 1.0, -v * x, -v * y, v];
|
||||
}
|
||||
// Gaussian elimination with partial pivoting on the 8×8 system (last column = rhs).
|
||||
for col in 0..8 {
|
||||
let piv = (col..8).max_by(|&i, &j| a[i][col].abs().total_cmp(&a[j][col].abs()))?;
|
||||
if a[piv][col].abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
a.swap(col, piv);
|
||||
for row in 0..8 {
|
||||
if row != col {
|
||||
let f = a[row][col] / a[col][col];
|
||||
for k in col..9 {
|
||||
a[row][k] -= f * a[col][k];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let h: Vec<f64> = (0..8).map(|i| a[i][8] / a[i][i]).collect();
|
||||
Some(Homography([h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1.0]))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quad_to_quad() {
|
||||
let src = [Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(1.0, 1.0), Point::new(0.0, 1.0)];
|
||||
let dst = [Point::new(0.1, 0.0), Point::new(0.9, 0.05), Point::new(1.0, 1.0), Point::new(0.0, 0.95)];
|
||||
let h = Homography::from_quads(&src, &dst).unwrap();
|
||||
for i in 0..4 {
|
||||
assert!(h.apply(src[i]).dist(dst[i]) < 1e-9);
|
||||
}
|
||||
let inv = h.inverse().unwrap();
|
||||
let p = Point::new(0.3, 0.7);
|
||||
assert!(inv.apply(h.apply(p)).dist(p) < 1e-9);
|
||||
assert!(h.mul(&inv).apply(p).dist(p) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_quad_rejected() {
|
||||
let src = [Point::new(0.0, 0.0); 4];
|
||||
assert!(Homography::from_quads(&src, &src).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
//! Geometry for LightCraft: points, rectangles, affine and projective transforms, and the crop model.
|
||||
//!
|
||||
//! Conventions: image coordinates are y-down. *Normalized* coordinates map the full (uncropped,
|
||||
//! oriented) image to `0..1` on both axes, so tools and settings are resolution independent.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod crop;
|
||||
mod homography;
|
||||
|
||||
pub use crop::{CropGeometry, crop_fit_angle, max_inscribed_scale};
|
||||
pub use homography::Homography;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Point {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
impl Point {
|
||||
pub const ZERO: Point = Point { x: 0.0, y: 0.0 };
|
||||
pub const fn new(x: f64, y: f64) -> Self {
|
||||
Self { x, y }
|
||||
}
|
||||
pub fn dist(self, o: Point) -> f64 {
|
||||
(self - o).len()
|
||||
}
|
||||
pub fn lerp(self, o: Point, t: f64) -> Point {
|
||||
Point::new(self.x + (o.x - self.x) * t, self.y + (o.y - self.y) * t)
|
||||
}
|
||||
pub fn to_vec(self) -> Vec2 {
|
||||
Vec2::new(self.x, self.y)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Vec2 {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
impl Vec2 {
|
||||
pub const fn new(x: f64, y: f64) -> Self {
|
||||
Self { x, y }
|
||||
}
|
||||
pub fn len(self) -> f64 {
|
||||
self.x.hypot(self.y)
|
||||
}
|
||||
pub fn dot(self, o: Vec2) -> f64 {
|
||||
self.x * o.x + self.y * o.y
|
||||
}
|
||||
pub fn cross(self, o: Vec2) -> f64 {
|
||||
self.x * o.y - self.y * o.x
|
||||
}
|
||||
pub fn normalized(self) -> Vec2 {
|
||||
let l = self.len();
|
||||
if l > 0.0 { Vec2::new(self.x / l, self.y / l) } else { self }
|
||||
}
|
||||
pub fn angle(self) -> f64 {
|
||||
self.y.atan2(self.x)
|
||||
}
|
||||
pub fn rotate(self, a: f64) -> Vec2 {
|
||||
let (s, c) = a.sin_cos();
|
||||
Vec2::new(self.x * c - self.y * s, self.x * s + self.y * c)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Sub for Point {
|
||||
type Output = Vec2;
|
||||
fn sub(self, o: Point) -> Vec2 {
|
||||
Vec2::new(self.x - o.x, self.y - o.y)
|
||||
}
|
||||
}
|
||||
impl std::ops::Add<Vec2> for Point {
|
||||
type Output = Point;
|
||||
fn add(self, v: Vec2) -> Point {
|
||||
Point::new(self.x + v.x, self.y + v.y)
|
||||
}
|
||||
}
|
||||
impl std::ops::Sub<Vec2> for Point {
|
||||
type Output = Point;
|
||||
fn sub(self, v: Vec2) -> Point {
|
||||
Point::new(self.x - v.x, self.y - v.y)
|
||||
}
|
||||
}
|
||||
impl std::ops::Add for Vec2 {
|
||||
type Output = Vec2;
|
||||
fn add(self, o: Vec2) -> Vec2 {
|
||||
Vec2::new(self.x + o.x, self.y + o.y)
|
||||
}
|
||||
}
|
||||
impl std::ops::Sub for Vec2 {
|
||||
type Output = Vec2;
|
||||
fn sub(self, o: Vec2) -> Vec2 {
|
||||
Vec2::new(self.x - o.x, self.y - o.y)
|
||||
}
|
||||
}
|
||||
impl std::ops::Mul<f64> for Vec2 {
|
||||
type Output = Vec2;
|
||||
fn mul(self, s: f64) -> Vec2 {
|
||||
Vec2::new(self.x * s, self.y * s)
|
||||
}
|
||||
}
|
||||
impl std::ops::Neg for Vec2 {
|
||||
type Output = Vec2;
|
||||
fn neg(self) -> Vec2 {
|
||||
Vec2::new(-self.x, -self.y)
|
||||
}
|
||||
}
|
||||
|
||||
/// Axis-aligned rectangle `[x0, x1) × [y0, y1)`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Rect {
|
||||
pub x0: f64,
|
||||
pub y0: f64,
|
||||
pub x1: f64,
|
||||
pub y1: f64,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
pub const UNIT: Rect = Rect { x0: 0.0, y0: 0.0, x1: 1.0, y1: 1.0 };
|
||||
pub const fn new(x0: f64, y0: f64, x1: f64, y1: f64) -> Self {
|
||||
Self { x0, y0, x1, y1 }
|
||||
}
|
||||
pub fn from_xywh(x: f64, y: f64, w: f64, h: f64) -> Self {
|
||||
Self::new(x, y, x + w, y + h)
|
||||
}
|
||||
pub fn from_center(c: Point, w: f64, h: f64) -> Self {
|
||||
Self::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0)
|
||||
}
|
||||
pub fn from_points(a: Point, b: Point) -> Self {
|
||||
Self::new(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y))
|
||||
}
|
||||
pub fn width(&self) -> f64 {
|
||||
self.x1 - self.x0
|
||||
}
|
||||
pub fn height(&self) -> f64 {
|
||||
self.y1 - self.y0
|
||||
}
|
||||
pub fn area(&self) -> f64 {
|
||||
self.width().max(0.0) * self.height().max(0.0)
|
||||
}
|
||||
pub fn center(&self) -> Point {
|
||||
Point::new((self.x0 + self.x1) / 2.0, (self.y0 + self.y1) / 2.0)
|
||||
}
|
||||
pub fn aspect(&self) -> f64 {
|
||||
if self.height() != 0.0 { self.width() / self.height() } else { 0.0 }
|
||||
}
|
||||
pub fn contains(&self, p: Point) -> bool {
|
||||
p.x >= self.x0 && p.x < self.x1 && p.y >= self.y0 && p.y < self.y1
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.x1 <= self.x0 || self.y1 <= self.y0
|
||||
}
|
||||
pub fn intersect(&self, o: &Rect) -> Rect {
|
||||
Rect::new(self.x0.max(o.x0), self.y0.max(o.y0), self.x1.min(o.x1), self.y1.min(o.y1))
|
||||
}
|
||||
pub fn union(&self, o: &Rect) -> Rect {
|
||||
Rect::new(self.x0.min(o.x0), self.y0.min(o.y0), self.x1.max(o.x1), self.y1.max(o.y1))
|
||||
}
|
||||
pub fn inflate(&self, dx: f64, dy: f64) -> Rect {
|
||||
Rect::new(self.x0 - dx, self.y0 - dy, self.x1 + dx, self.y1 + dy)
|
||||
}
|
||||
pub fn scale(&self, sx: f64, sy: f64) -> Rect {
|
||||
Rect::new(self.x0 * sx, self.y0 * sy, self.x1 * sx, self.y1 * sy)
|
||||
}
|
||||
pub fn translate(&self, v: Vec2) -> Rect {
|
||||
Rect::new(self.x0 + v.x, self.y0 + v.y, self.x1 + v.x, self.y1 + v.y)
|
||||
}
|
||||
pub fn corners(&self) -> [Point; 4] {
|
||||
[Point::new(self.x0, self.y0), Point::new(self.x1, self.y0), Point::new(self.x1, self.y1), Point::new(self.x0, self.y1)]
|
||||
}
|
||||
/// Largest rectangle of aspect `a` (w/h) centred in `self`.
|
||||
pub fn fit_aspect(&self, a: f64) -> Rect {
|
||||
if a <= 0.0 || self.is_empty() {
|
||||
return *self;
|
||||
}
|
||||
let (w, h) = (self.width(), self.height());
|
||||
let (nw, nh) = if w / h > a { (h * a, h) } else { (w, w / a) };
|
||||
Rect::from_center(self.center(), nw, nh)
|
||||
}
|
||||
/// Round outward to integer pixel bounds.
|
||||
pub fn round_out(&self) -> Rect {
|
||||
Rect::new(self.x0.floor(), self.y0.floor(), self.x1.ceil(), self.y1.ceil())
|
||||
}
|
||||
}
|
||||
|
||||
/// 2-D affine transform `[a b c d e f]`: x' = a·x + c·y + e, y' = b·x + d·y + f.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Affine(pub [f64; 6]);
|
||||
|
||||
impl Default for Affine {
|
||||
fn default() -> Self {
|
||||
Affine::IDENTITY
|
||||
}
|
||||
}
|
||||
|
||||
impl Affine {
|
||||
pub const IDENTITY: Affine = Affine([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
|
||||
pub fn translate(v: Vec2) -> Affine {
|
||||
Affine([1.0, 0.0, 0.0, 1.0, v.x, v.y])
|
||||
}
|
||||
pub fn scale(sx: f64, sy: f64) -> Affine {
|
||||
Affine([sx, 0.0, 0.0, sy, 0.0, 0.0])
|
||||
}
|
||||
pub fn rotate(a: f64) -> Affine {
|
||||
let (s, c) = a.sin_cos();
|
||||
Affine([c, s, -s, c, 0.0, 0.0])
|
||||
}
|
||||
pub fn rotate_about(a: f64, p: Point) -> Affine {
|
||||
Affine::translate(p.to_vec()) * Affine::rotate(a) * Affine::translate(-p.to_vec())
|
||||
}
|
||||
pub fn apply(&self, p: Point) -> Point {
|
||||
let [a, b, c, d, e, f] = self.0;
|
||||
Point::new(a * p.x + c * p.y + e, b * p.x + d * p.y + f)
|
||||
}
|
||||
pub fn apply_vec(&self, v: Vec2) -> Vec2 {
|
||||
let [a, b, c, d, ..] = self.0;
|
||||
Vec2::new(a * v.x + c * v.y, b * v.x + d * v.y)
|
||||
}
|
||||
pub fn determinant(&self) -> f64 {
|
||||
self.0[0] * self.0[3] - self.0[1] * self.0[2]
|
||||
}
|
||||
pub fn inverse(&self) -> Option<Affine> {
|
||||
let det = self.determinant();
|
||||
if det.abs() < 1e-300 {
|
||||
return None;
|
||||
}
|
||||
let [a, b, c, d, e, f] = self.0;
|
||||
let inv = 1.0 / det;
|
||||
Some(Affine([d * inv, -b * inv, -c * inv, a * inv, (c * f - d * e) * inv, (b * e - a * f) * inv]))
|
||||
}
|
||||
/// Bounding box of a transformed rectangle.
|
||||
pub fn transform_rect_bbox(&self, r: &Rect) -> Rect {
|
||||
let pts = r.corners().map(|p| self.apply(p));
|
||||
let mut out = Rect::new(pts[0].x, pts[0].y, pts[0].x, pts[0].y);
|
||||
for p in &pts[1..] {
|
||||
out = out.union(&Rect::new(p.x, p.y, p.x, p.y));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Mul for Affine {
|
||||
type Output = Affine;
|
||||
/// `self * o` applies `o` first, then `self`.
|
||||
fn mul(self, o: Affine) -> Affine {
|
||||
let [a, b, c, d, e, f] = self.0;
|
||||
let [a2, b2, c2, d2, e2, f2] = o.0;
|
||||
Affine([a * a2 + c * b2, b * a2 + d * b2, a * c2 + c * d2, b * c2 + d * d2, a * e2 + c * f2 + e, b * e2 + d * f2 + f])
|
||||
}
|
||||
}
|
||||
|
||||
/// EXIF orientation (1..=8) as a transform of the stored pixel grid.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Orientation {
|
||||
#[default]
|
||||
Normal,
|
||||
FlipH,
|
||||
Rotate180,
|
||||
FlipV,
|
||||
Transpose,
|
||||
Rotate90,
|
||||
Transverse,
|
||||
Rotate270,
|
||||
}
|
||||
|
||||
impl Orientation {
|
||||
pub fn from_exif(v: u16) -> Orientation {
|
||||
match v {
|
||||
2 => Orientation::FlipH,
|
||||
3 => Orientation::Rotate180,
|
||||
4 => Orientation::FlipV,
|
||||
5 => Orientation::Transpose,
|
||||
6 => Orientation::Rotate90,
|
||||
7 => Orientation::Transverse,
|
||||
8 => Orientation::Rotate270,
|
||||
_ => Orientation::Normal,
|
||||
}
|
||||
}
|
||||
pub fn to_exif(self) -> u16 {
|
||||
match self {
|
||||
Orientation::Normal => 1,
|
||||
Orientation::FlipH => 2,
|
||||
Orientation::Rotate180 => 3,
|
||||
Orientation::FlipV => 4,
|
||||
Orientation::Transpose => 5,
|
||||
Orientation::Rotate90 => 6,
|
||||
Orientation::Transverse => 7,
|
||||
Orientation::Rotate270 => 8,
|
||||
}
|
||||
}
|
||||
/// Whether width and height swap.
|
||||
pub fn swaps_axes(self) -> bool {
|
||||
matches!(self, Orientation::Transpose | Orientation::Rotate90 | Orientation::Transverse | Orientation::Rotate270)
|
||||
}
|
||||
/// Decompose into (clockwise quarter turns applied after an optional horizontal flip).
|
||||
pub fn to_parts(self) -> (bool, u8) {
|
||||
match self {
|
||||
Orientation::Normal => (false, 0),
|
||||
Orientation::Rotate90 => (false, 1),
|
||||
Orientation::Rotate180 => (false, 2),
|
||||
Orientation::Rotate270 => (false, 3),
|
||||
Orientation::FlipH => (true, 0),
|
||||
Orientation::Transverse => (true, 1),
|
||||
Orientation::FlipV => (true, 2),
|
||||
Orientation::Transpose => (true, 3),
|
||||
}
|
||||
}
|
||||
pub fn from_parts(flip: bool, quarter_turns_cw: u8) -> Orientation {
|
||||
match (flip, quarter_turns_cw % 4) {
|
||||
(false, 0) => Orientation::Normal,
|
||||
(false, 1) => Orientation::Rotate90,
|
||||
(false, 2) => Orientation::Rotate180,
|
||||
(false, _) => Orientation::Rotate270,
|
||||
(true, 0) => Orientation::FlipH,
|
||||
(true, 1) => Orientation::Transverse,
|
||||
(true, 2) => Orientation::FlipV,
|
||||
(true, _) => Orientation::Transpose,
|
||||
}
|
||||
}
|
||||
/// Rotate the (already oriented) image a further quarter turn clockwise (`cw`) or counter-clockwise.
|
||||
pub fn rotated(self, cw: bool) -> Orientation {
|
||||
let (f, t) = self.to_parts();
|
||||
Orientation::from_parts(f, if cw { t + 1 } else { t + 3 })
|
||||
}
|
||||
/// Flip the displayed image horizontally.
|
||||
pub fn flipped_h(self) -> Orientation {
|
||||
let (f, t) = self.to_parts();
|
||||
// flipH ∘ rot(t) = rot(-t) ∘ flipH
|
||||
Orientation::from_parts(!f, (4 - t) % 4)
|
||||
}
|
||||
/// Map a point in stored pixel space (w×h stored dims) to oriented space.
|
||||
pub fn map(self, x: f64, y: f64, w: f64, h: f64) -> (f64, f64) {
|
||||
let (flip, t) = self.to_parts();
|
||||
let (mut x, y2, mut w2, mut h2) = (x, y, w, h);
|
||||
if flip {
|
||||
x = w2 - x;
|
||||
}
|
||||
let mut y = y2;
|
||||
for _ in 0..t {
|
||||
// rotate 90° cw: (x, y) -> (h - y, x)
|
||||
let nx = h2 - y;
|
||||
let ny = x;
|
||||
x = nx;
|
||||
y = ny;
|
||||
std::mem::swap(&mut w2, &mut h2);
|
||||
}
|
||||
(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clamp01(v: f64) -> f64 {
|
||||
v.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
pub fn deg(r: f64) -> f64 {
|
||||
r.to_degrees()
|
||||
}
|
||||
|
||||
pub fn rad(d: f64) -> f64 {
|
||||
d.to_radians()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn close(a: Point, b: Point) -> bool {
|
||||
a.dist(b) < 1e-9
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_compose_and_inverse() {
|
||||
let t = Affine::translate(Vec2::new(3.0, -2.0)) * Affine::rotate(0.7) * Affine::scale(2.0, 0.5);
|
||||
let inv = t.inverse().unwrap();
|
||||
let p = Point::new(1.5, 4.0);
|
||||
assert!(close(inv.apply(t.apply(p)), p));
|
||||
assert!(close((t * inv).apply(p), p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_about_fixes_centre() {
|
||||
let c = Point::new(10.0, 5.0);
|
||||
assert!(close(Affine::rotate_about(1.1, c).apply(c), c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_ops() {
|
||||
let r = Rect::new(0.0, 0.0, 4.0, 2.0);
|
||||
assert_eq!(r.aspect(), 2.0);
|
||||
let f = r.fit_aspect(1.0);
|
||||
assert_eq!((f.width(), f.height()), (2.0, 2.0));
|
||||
assert_eq!(f.center(), r.center());
|
||||
assert!(r.contains(Point::new(3.9, 1.9)));
|
||||
assert!(!r.contains(Point::new(4.0, 1.0)));
|
||||
assert!(r.intersect(&Rect::new(5.0, 5.0, 6.0, 6.0)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_roundtrip_and_dims() {
|
||||
for v in 1..=8 {
|
||||
let o = Orientation::from_exif(v);
|
||||
assert_eq!(o.to_exif(), v);
|
||||
let (f, t) = o.to_parts();
|
||||
assert_eq!(Orientation::from_parts(f, t), o);
|
||||
}
|
||||
assert!(Orientation::Rotate90.swaps_axes());
|
||||
// Rotate90: top-left of stored goes to top-right of display.
|
||||
let (x, y) = Orientation::Rotate90.map(0.0, 0.0, 4.0, 2.0);
|
||||
assert_eq!((x, y), (2.0, 0.0));
|
||||
assert_eq!(Orientation::Normal.rotated(true).rotated(true), Orientation::Rotate180);
|
||||
assert_eq!(Orientation::Normal.rotated(false), Orientation::Rotate270);
|
||||
assert_eq!(Orientation::Normal.flipped_h(), Orientation::FlipH);
|
||||
assert_eq!(Orientation::Rotate90.flipped_h().flipped_h(), Orientation::Rotate90);
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn affine_inverse_prop(a in -3.0f64..3.0, sx in 0.1f64..5.0, sy in 0.1f64..5.0, tx in -50.0f64..50.0, px in -20.0f64..20.0, py in -20.0f64..20.0) {
|
||||
let t = Affine::translate(Vec2::new(tx, -tx)) * Affine::rotate(a) * Affine::scale(sx, sy);
|
||||
let p = Point::new(px, py);
|
||||
let q = t.inverse().unwrap().apply(t.apply(p));
|
||||
proptest::prop_assert!(q.dist(p) < 1e-7);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "lightcraft-raster"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "LightCraft raster (L0)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
lightcraft-geom = { workspace = true }
|
||||
lightcraft-color = { workspace = true }
|
||||
rayon = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = ["parallel"]
|
||||
parallel = ["dep:rayon"]
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Gaussian-like blur by three successive box filters (running sums, O(1) per pixel per pass).
|
||||
|
||||
use crate::resample::Pixel;
|
||||
use crate::{Image, par_rows};
|
||||
|
||||
/// Box radii approximating a Gaussian of `sigma` with 3 passes (Kovesi / Wells).
|
||||
fn box_radii(sigma: f32) -> [usize; 3] {
|
||||
let n = 3.0f32;
|
||||
let w_ideal = (12.0 * sigma * sigma / n + 1.0).sqrt();
|
||||
let mut wl = w_ideal.floor() as i32;
|
||||
if wl % 2 == 0 {
|
||||
wl -= 1;
|
||||
}
|
||||
let wu = wl + 2;
|
||||
let m_ideal = (12.0 * sigma * sigma - n * (wl * wl) as f32 - 4.0 * n * wl as f32 - 3.0 * n) / (-4.0 * wl as f32 - 4.0);
|
||||
let m = m_ideal.round() as i32;
|
||||
std::array::from_fn(|i| (((if (i as i32) < m { wl } else { wu }) - 1) / 2).max(0) as usize)
|
||||
}
|
||||
|
||||
fn box_h<T: Pixel>(src: &Image<T>, r: usize) -> Image<T> {
|
||||
let w = src.width;
|
||||
let mut out = Image::<T>::new(w, src.height);
|
||||
if r == 0 {
|
||||
return src.clone();
|
||||
}
|
||||
let inv = 1.0 / (2 * r + 1) as f32;
|
||||
par_rows(&mut out.data, w, |y, row| {
|
||||
let s = src.row(y);
|
||||
let at = |i: isize| s[i.clamp(0, w as isize - 1) as usize];
|
||||
let mut acc = T::zero();
|
||||
for i in -(r as isize)..=(r as isize) {
|
||||
acc = acc.madd(at(i), 1.0);
|
||||
}
|
||||
for x in 0..w {
|
||||
row[x] = T::zero().madd(acc, inv);
|
||||
acc = acc.madd(at(x as isize + r as isize + 1), 1.0).madd(at(x as isize - r as isize), -1.0);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn transpose<T: Pixel>(src: &Image<T>) -> Image<T> {
|
||||
let (w, h) = (src.width, src.height);
|
||||
let mut out = Image::<T>::new(h, w);
|
||||
par_rows(&mut out.data, h, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
*o = src.data[x * w + y];
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Blur with Gaussian `sigma` (pixels). `sigma <= 0.3` returns a copy.
|
||||
pub fn gaussian<T: Pixel>(img: &Image<T>, sigma: f32) -> Image<T> {
|
||||
if sigma <= 0.3 || img.is_empty() {
|
||||
return img.clone();
|
||||
}
|
||||
let radii = box_radii(sigma);
|
||||
let mut a = img.clone();
|
||||
for r in radii {
|
||||
a = box_h(&a, r);
|
||||
}
|
||||
let mut t = transpose(&a);
|
||||
for r in radii {
|
||||
t = box_h(&t, r);
|
||||
}
|
||||
transpose(&t)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn preserves_constant_and_mass() {
|
||||
let img = Image::<f32>::filled(50, 30, 0.7);
|
||||
let b = gaussian(&img, 4.0);
|
||||
assert!(b.data.iter().all(|v| (v - 0.7).abs() < 1e-5));
|
||||
let mut imp = Image::<f32>::new(101, 101);
|
||||
imp.set(50, 50, 1.0);
|
||||
let b = gaussian(&imp, 5.0);
|
||||
let sum: f32 = b.data.iter().sum();
|
||||
assert!((sum - 1.0).abs() < 1e-3, "{sum}");
|
||||
// peak moved to the centre and symmetric
|
||||
assert!((b.get(45, 50) - b.get(55, 50)).abs() < 1e-6);
|
||||
assert!((b.get(50, 45) - b.get(50, 55)).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variance_close_to_sigma_squared() {
|
||||
let mut imp = Image::<f32>::new(201, 1);
|
||||
imp.set(100, 0, 1.0);
|
||||
let sigma = 8.0;
|
||||
let b = gaussian(&imp, sigma);
|
||||
let var: f32 = b.data.iter().enumerate().map(|(i, v)| v * ((i as f32 - 100.0).powi(2))).sum();
|
||||
assert!((var.sqrt() - sigma).abs() / sigma < 0.1, "{}", var.sqrt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Histograms of display-encoded images (what the Histogram panel shows).
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::Rgba8;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct Histogram {
|
||||
pub r: Vec<u32>,
|
||||
pub g: Vec<u32>,
|
||||
pub b: Vec<u32>,
|
||||
pub luma: Vec<u32>,
|
||||
pub total: u32,
|
||||
}
|
||||
|
||||
impl Histogram {
|
||||
pub const BINS: usize = 256;
|
||||
|
||||
pub fn of_srgb8(img: &Rgba8) -> Histogram {
|
||||
let mut h = Histogram { r: vec![0; 256], g: vec![0; 256], b: vec![0; 256], luma: vec![0; 256], total: 0 };
|
||||
// Sample at most ~1 MP for speed.
|
||||
let step = ((img.len() as f64 / 1_000_000.0).sqrt().ceil() as usize).max(1);
|
||||
for y in (0..img.height).step_by(step) {
|
||||
for x in (0..img.width).step_by(step) {
|
||||
let p = img.get(x, y);
|
||||
h.r[p[0] as usize] += 1;
|
||||
h.g[p[1] as usize] += 1;
|
||||
h.b[p[2] as usize] += 1;
|
||||
let l = (p[0] as u32 * 54 + p[1] as u32 * 183 + p[2] as u32 * 19) >> 8;
|
||||
h.luma[l.min(255) as usize] += 1;
|
||||
h.total += 1;
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Fraction of pixels clipped at black / white in any channel (approximate: per channel max).
|
||||
pub fn clipping(&self) -> (f32, f32) {
|
||||
if self.total == 0 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let t = self.total as f32;
|
||||
let lo = self.r[0].max(self.g[0]).max(self.b[0]) as f32 / t;
|
||||
let hi = self.r[255].max(self.g[255]).max(self.b[255]) as f32 / t;
|
||||
(lo, hi)
|
||||
}
|
||||
|
||||
/// Mean luma in 0..1.
|
||||
pub fn mean_luma(&self) -> f32 {
|
||||
if self.total == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.luma.iter().enumerate().map(|(i, &c)| i as f32 * c as f32).sum::<f32>() / (255.0 * self.total as f32)
|
||||
}
|
||||
|
||||
/// Luma value (0..1) below which `q` of the pixels fall.
|
||||
pub fn percentile(&self, q: f32) -> f32 {
|
||||
let target = (q.clamp(0.0, 1.0) * self.total as f32) as u32;
|
||||
let mut acc = 0;
|
||||
for (i, &c) in self.luma.iter().enumerate() {
|
||||
acc += c;
|
||||
if acc >= target {
|
||||
return i as f32 / 255.0;
|
||||
}
|
||||
}
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn counts_and_stats() {
|
||||
let img = Rgba8::from_fn(10, 10, |x, _| if x < 5 { [0, 0, 0, 255] } else { [255, 255, 255, 255] });
|
||||
let h = Histogram::of_srgb8(&img);
|
||||
assert_eq!(h.total, 100);
|
||||
assert_eq!(h.r[0], 50);
|
||||
assert_eq!(h.luma[255], 50);
|
||||
let (lo, hi) = h.clipping();
|
||||
assert!((lo - 0.5).abs() < 1e-6 && (hi - 0.5).abs() < 1e-6);
|
||||
assert!((h.mean_luma() - 0.5).abs() < 1e-3);
|
||||
assert_eq!(h.percentile(0.25), 0.0);
|
||||
assert_eq!(h.percentile(0.75), 1.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Image buffers and basic raster operations for LightCraft.
|
||||
//!
|
||||
//! - [`Image<T>`]: interleaved, row-major pixels (`Rgb32f = Image<[f32; 3]>`, `Rgba8 = Image<[u8; 4]>`,
|
||||
//! `Plane = Image<f32>`).
|
||||
//! - Resampling ([`resample`]): separable Lanczos-3 / Mitchell / box, rayon-parallel.
|
||||
//! - Blur ([`blur`]): separable Gaussian via repeated box filters (O(1) per pixel in the radius).
|
||||
//! - [`Histogram`] of display-encoded RGB + luminance.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod blur;
|
||||
pub mod histogram;
|
||||
pub mod resample;
|
||||
|
||||
pub use histogram::Histogram;
|
||||
|
||||
/// Run `f` over row chunks, in parallel when the `parallel` feature is on.
|
||||
pub fn par_rows<T: Send>(data: &mut [T], row_len: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) {
|
||||
if row_len == 0 {
|
||||
return;
|
||||
}
|
||||
#[cfg(feature = "parallel")]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
data.par_chunks_mut(row_len).enumerate().for_each(|(y, row)| f(y, row));
|
||||
}
|
||||
#[cfg(not(feature = "parallel"))]
|
||||
{
|
||||
data.chunks_mut(row_len).enumerate().for_each(|(y, row)| f(y, row));
|
||||
}
|
||||
}
|
||||
|
||||
/// Interleaved row-major image.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Image<T> {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub data: Vec<T>,
|
||||
}
|
||||
|
||||
pub type Rgb32f = Image<[f32; 3]>;
|
||||
pub type Rgba8 = Image<[u8; 4]>;
|
||||
pub type Plane = Image<f32>;
|
||||
|
||||
impl<T: Copy + Default> Image<T> {
|
||||
pub fn new(width: usize, height: usize) -> Self {
|
||||
Self { width, height, data: vec![T::default(); width * height] }
|
||||
}
|
||||
pub fn filled(width: usize, height: usize, v: T) -> Self {
|
||||
Self { width, height, data: vec![v; width * height] }
|
||||
}
|
||||
pub fn from_fn(width: usize, height: usize, f: impl Fn(usize, usize) -> T) -> Self {
|
||||
let mut data = Vec::with_capacity(width * height);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
data.push(f(x, y));
|
||||
}
|
||||
}
|
||||
Self { width, height, data }
|
||||
}
|
||||
#[inline]
|
||||
pub fn get(&self, x: usize, y: usize) -> T {
|
||||
self.data[y * self.width + x]
|
||||
}
|
||||
/// Clamped access (edge extension).
|
||||
#[inline]
|
||||
pub fn get_clamped(&self, x: isize, y: isize) -> T {
|
||||
let x = x.clamp(0, self.width as isize - 1) as usize;
|
||||
let y = y.clamp(0, self.height as isize - 1) as usize;
|
||||
self.data[y * self.width + x]
|
||||
}
|
||||
#[inline]
|
||||
pub fn set(&mut self, x: usize, y: usize, v: T) {
|
||||
self.data[y * self.width + x] = v;
|
||||
}
|
||||
pub fn row(&self, y: usize) -> &[T] {
|
||||
&self.data[y * self.width..(y + 1) * self.width]
|
||||
}
|
||||
pub fn row_mut(&mut self, y: usize) -> &mut [T] {
|
||||
&mut self.data[y * self.width..(y + 1) * self.width]
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.data.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.data.is_empty()
|
||||
}
|
||||
pub fn map<U: Copy + Default + Send>(&self, f: impl Fn(T) -> U + Sync + Send) -> Image<U>
|
||||
where
|
||||
T: Sync,
|
||||
{
|
||||
let mut out = Image::<U>::new(self.width, self.height);
|
||||
let w = self.width;
|
||||
par_rows(&mut out.data, w, |y, row| {
|
||||
let src = &self.data[y * w..(y + 1) * w];
|
||||
for (o, s) in row.iter_mut().zip(src) {
|
||||
*o = f(*s);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
/// Crop to `[x0, x0+w) × [y0, y0+h)` (clamped to the image).
|
||||
pub fn crop(&self, x0: usize, y0: usize, w: usize, h: usize) -> Self {
|
||||
let x0 = x0.min(self.width);
|
||||
let y0 = y0.min(self.height);
|
||||
let w = w.min(self.width - x0);
|
||||
let h = h.min(self.height - y0);
|
||||
let mut data = Vec::with_capacity(w * h);
|
||||
for y in y0..y0 + h {
|
||||
data.extend_from_slice(&self.data[y * self.width + x0..y * self.width + x0 + w]);
|
||||
}
|
||||
Self { width: w, height: h, data }
|
||||
}
|
||||
/// Rotate 90° clockwise.
|
||||
pub fn rotate_cw(&self) -> Self {
|
||||
let (w, h) = (self.width, self.height);
|
||||
Image::from_fn(h, w, |x, y| self.get(y, h - 1 - x))
|
||||
}
|
||||
pub fn rotate_180(&self) -> Self {
|
||||
let mut d = self.data.clone();
|
||||
d.reverse();
|
||||
Self { width: self.width, height: self.height, data: d }
|
||||
}
|
||||
pub fn flip_h(&self) -> Self {
|
||||
let mut out = self.clone();
|
||||
for y in 0..self.height {
|
||||
out.row_mut(y).reverse();
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Apply an EXIF orientation (stored → displayed).
|
||||
pub fn oriented(&self, o: lightcraft_geom::Orientation) -> Self {
|
||||
let (flip, turns) = o.to_parts();
|
||||
let mut img = if flip { self.flip_h() } else { self.clone() };
|
||||
match turns {
|
||||
1 => img = img.rotate_cw(),
|
||||
2 => img = img.rotate_180(),
|
||||
3 => img = img.rotate_180().rotate_cw(),
|
||||
_ => {}
|
||||
}
|
||||
img
|
||||
}
|
||||
}
|
||||
|
||||
impl Rgb32f {
|
||||
/// Bilinear sample at continuous pixel coordinates (pixel centres at +0.5).
|
||||
#[inline]
|
||||
pub fn sample_bilinear(&self, x: f32, y: f32) -> [f32; 3] {
|
||||
let fx = x - 0.5;
|
||||
let fy = y - 0.5;
|
||||
let x0 = fx.floor();
|
||||
let y0 = fy.floor();
|
||||
let tx = fx - x0;
|
||||
let ty = fy - y0;
|
||||
let (x0, y0) = (x0 as isize, y0 as isize);
|
||||
let a = self.get_clamped(x0, y0);
|
||||
let b = self.get_clamped(x0 + 1, y0);
|
||||
let c = self.get_clamped(x0, y0 + 1);
|
||||
let d = self.get_clamped(x0 + 1, y0 + 1);
|
||||
std::array::from_fn(|i| {
|
||||
let top = a[i] + (b[i] - a[i]) * tx;
|
||||
let bot = c[i] + (d[i] - c[i]) * tx;
|
||||
top + (bot - top) * ty
|
||||
})
|
||||
}
|
||||
|
||||
pub fn luminance(&self) -> Plane {
|
||||
self.map(lightcraft_color::luminance_2020)
|
||||
}
|
||||
|
||||
/// Encode to 8-bit sRGB from linear sRGB values (no gamut conversion).
|
||||
pub fn to_srgb8(&self) -> Rgba8 {
|
||||
use lightcraft_color::transfer::encode_srgb8;
|
||||
self.map(|p| [encode_srgb8(p[0]), encode_srgb8(p[1]), encode_srgb8(p[2]), 255])
|
||||
}
|
||||
}
|
||||
|
||||
impl Rgba8 {
|
||||
/// Raw bytes (RGBA, row-major).
|
||||
pub fn as_bytes(&self) -> Vec<u8> {
|
||||
self.data.iter().flat_map(|p| *p).collect()
|
||||
}
|
||||
pub fn from_bytes(width: usize, height: usize, bytes: &[u8]) -> Option<Self> {
|
||||
if bytes.len() != width * height * 4 {
|
||||
return None;
|
||||
}
|
||||
Some(Self { width, height, data: bytes.chunks_exact(4).map(|c| [c[0], c[1], c[2], c[3]]).collect() })
|
||||
}
|
||||
/// Linear Rec.709/sRGB primaries float image from 8-bit sRGB.
|
||||
pub fn to_linear(&self) -> Rgb32f {
|
||||
use lightcraft_color::transfer::decode_srgb8;
|
||||
self.map(|p| [decode_srgb8(p[0]), decode_srgb8(p[1]), decode_srgb8(p[2])])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_geom::Orientation;
|
||||
|
||||
#[test]
|
||||
fn crop_and_rotate() {
|
||||
let img = Image::<u32>::from_fn(4, 3, |x, y| (y * 10 + x) as u32);
|
||||
let c = img.crop(1, 1, 2, 2);
|
||||
assert_eq!(c.data, vec![11, 12, 21, 22]);
|
||||
let r = img.rotate_cw();
|
||||
assert_eq!((r.width, r.height), (3, 4));
|
||||
// top-left of rotated = bottom-left of original
|
||||
assert_eq!(r.get(0, 0), 20);
|
||||
assert_eq!(r.get(2, 0), 0);
|
||||
assert_eq!(img.rotate_cw().rotate_cw().rotate_cw().rotate_cw(), img);
|
||||
assert_eq!(img.rotate_cw().rotate_cw(), img.rotate_180());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_matches_geom_mapping() {
|
||||
let img = Image::<u32>::from_fn(5, 3, |x, y| (y * 10 + x) as u32);
|
||||
for v in 1..=8 {
|
||||
let o = Orientation::from_exif(v);
|
||||
let out = img.oriented(o);
|
||||
for (sx, sy) in [(0usize, 0usize), (4, 0), (2, 1), (0, 2)] {
|
||||
let (dx, dy) = o.map(sx as f64 + 0.5, sy as f64 + 0.5, 5.0, 3.0);
|
||||
assert_eq!(out.get(dx as usize, dy as usize), img.get(sx, sy), "orientation {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bilinear_at_centres_is_exact() {
|
||||
let img = Rgb32f::from_fn(3, 3, |x, y| [x as f32, y as f32, 1.0]);
|
||||
assert_eq!(img.sample_bilinear(1.5, 2.5), [1.0, 2.0, 1.0]);
|
||||
let m = img.sample_bilinear(1.0, 1.5);
|
||||
assert!((m[0] - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb8_roundtrip() {
|
||||
let img = Rgba8::from_fn(16, 16, |x, y| [(x * 16) as u8, (y * 16) as u8, 7, 255]);
|
||||
assert_eq!(img.to_linear().to_srgb8(), img);
|
||||
assert_eq!(Rgba8::from_bytes(16, 16, &img.as_bytes()).unwrap(), img);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Separable resampling with precomputed filter weights.
|
||||
|
||||
use crate::{Image, par_rows};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Filter {
|
||||
Box,
|
||||
Bilinear,
|
||||
Mitchell,
|
||||
Lanczos3,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
fn support(self) -> f32 {
|
||||
match self {
|
||||
Filter::Box => 0.5,
|
||||
Filter::Bilinear => 1.0,
|
||||
Filter::Mitchell => 2.0,
|
||||
Filter::Lanczos3 => 3.0,
|
||||
}
|
||||
}
|
||||
fn eval(self, x: f32) -> f32 {
|
||||
let x = x.abs();
|
||||
match self {
|
||||
Filter::Box => {
|
||||
if x <= 0.5 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
Filter::Bilinear => (1.0 - x).max(0.0),
|
||||
Filter::Mitchell => {
|
||||
let (b, c) = (1.0 / 3.0, 1.0 / 3.0);
|
||||
if x < 1.0 {
|
||||
((12.0 - 9.0 * b - 6.0 * c) * x * x * x + (-18.0 + 12.0 * b + 6.0 * c) * x * x + (6.0 - 2.0 * b)) / 6.0
|
||||
} else if x < 2.0 {
|
||||
((-b - 6.0 * c) * x * x * x + (6.0 * b + 30.0 * c) * x * x + (-12.0 * b - 48.0 * c) * x + (8.0 * b + 24.0 * c)) / 6.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
Filter::Lanczos3 => {
|
||||
if x < 1e-6 {
|
||||
1.0
|
||||
} else if x < 3.0 {
|
||||
let px = std::f32::consts::PI * x;
|
||||
3.0 * px.sin() * (px / 3.0).sin() / (px * px)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Contributions for each output coordinate: (first source index, weights).
|
||||
fn weights(src: usize, dst: usize, filter: Filter) -> Vec<(usize, Vec<f32>)> {
|
||||
let scale = src as f32 / dst as f32;
|
||||
let fscale = scale.max(1.0);
|
||||
let support = filter.support() * fscale;
|
||||
(0..dst)
|
||||
.map(|i| {
|
||||
let center = (i as f32 + 0.5) * scale;
|
||||
let lo = ((center - support).floor() as isize).max(0) as usize;
|
||||
let hi = ((center + support).ceil() as usize).min(src);
|
||||
let mut w: Vec<f32> = (lo..hi).map(|j| filter.eval((j as f32 + 0.5 - center) / fscale)).collect();
|
||||
let sum: f32 = w.iter().sum();
|
||||
if sum.abs() > 1e-8 {
|
||||
w.iter_mut().for_each(|v| *v /= sum);
|
||||
} else if !w.is_empty() {
|
||||
let n = w.len() as f32;
|
||||
w.iter_mut().for_each(|v| *v = 1.0 / n);
|
||||
}
|
||||
(lo, w)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Pixel types that can be resampled (linear combination).
|
||||
pub trait Pixel: Copy + Default + Send + Sync {
|
||||
fn zero() -> Self;
|
||||
fn madd(self, o: Self, w: f32) -> Self;
|
||||
}
|
||||
|
||||
impl Pixel for f32 {
|
||||
fn zero() -> Self {
|
||||
0.0
|
||||
}
|
||||
#[inline]
|
||||
fn madd(self, o: Self, w: f32) -> Self {
|
||||
self + o * w
|
||||
}
|
||||
}
|
||||
|
||||
impl Pixel for [f32; 3] {
|
||||
fn zero() -> Self {
|
||||
[0.0; 3]
|
||||
}
|
||||
#[inline]
|
||||
fn madd(self, o: Self, w: f32) -> Self {
|
||||
[self[0] + o[0] * w, self[1] + o[1] * w, self[2] + o[2] * w]
|
||||
}
|
||||
}
|
||||
|
||||
impl Pixel for [f32; 4] {
|
||||
fn zero() -> Self {
|
||||
[0.0; 4]
|
||||
}
|
||||
#[inline]
|
||||
fn madd(self, o: Self, w: f32) -> Self {
|
||||
[self[0] + o[0] * w, self[1] + o[1] * w, self[2] + o[2] * w, self[3] + o[3] * w]
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize to `w × h` (separable; horizontal pass then vertical pass).
|
||||
pub fn resize<T: Pixel>(img: &Image<T>, w: usize, h: usize, filter: Filter) -> Image<T> {
|
||||
let (w, h) = (w.max(1), h.max(1));
|
||||
if img.width == w && img.height == h {
|
||||
return img.clone();
|
||||
}
|
||||
let wx = weights(img.width, w, filter);
|
||||
let mut tmp = Image::<T>::new(w, img.height);
|
||||
par_rows(&mut tmp.data, w, |y, row| {
|
||||
let src = img.row(y);
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let (lo, ws) = &wx[x];
|
||||
let mut acc = T::zero();
|
||||
for (k, wt) in ws.iter().enumerate() {
|
||||
acc = acc.madd(src[lo + k], *wt);
|
||||
}
|
||||
*o = acc;
|
||||
}
|
||||
});
|
||||
let wy = weights(img.height, h, filter);
|
||||
let mut out = Image::<T>::new(w, h);
|
||||
par_rows(&mut out.data, w, |y, row| {
|
||||
let (lo, ws) = &wy[y];
|
||||
for (k, wt) in ws.iter().enumerate() {
|
||||
let src = tmp.row(lo + k);
|
||||
for (o, s) in row.iter_mut().zip(src) {
|
||||
*o = o.madd(*s, *wt);
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Fit within `max_w × max_h` preserving aspect (never upscales).
|
||||
pub fn fit<T: Pixel>(img: &Image<T>, max_w: usize, max_h: usize, filter: Filter) -> Image<T> {
|
||||
let s = (max_w as f64 / img.width as f64).min(max_h as f64 / img.height as f64).min(1.0);
|
||||
let w = ((img.width as f64 * s).round() as usize).max(1);
|
||||
let h = ((img.height as f64 * s).round() as usize).max(1);
|
||||
resize(img, w, h, filter)
|
||||
}
|
||||
|
||||
/// 2× box downsample (pyramid level).
|
||||
pub fn half<T: Pixel>(img: &Image<T>) -> Image<T> {
|
||||
let w = img.width.div_ceil(2).max(1);
|
||||
let h = img.height.div_ceil(2).max(1);
|
||||
let mut out = Image::<T>::new(w, h);
|
||||
par_rows(&mut out.data, w, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let (sx, sy) = (2 * x as isize, 2 * y as isize);
|
||||
*o = T::zero()
|
||||
.madd(img.get_clamped(sx, sy), 0.25)
|
||||
.madd(img.get_clamped(sx + 1, sy), 0.25)
|
||||
.madd(img.get_clamped(sx, sy + 1), 0.25)
|
||||
.madd(img.get_clamped(sx + 1, sy + 1), 0.25);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn constant_image_stays_constant() {
|
||||
let img = Image::<f32>::filled(37, 21, 0.42);
|
||||
for f in [Filter::Box, Filter::Bilinear, Filter::Mitchell, Filter::Lanczos3] {
|
||||
for (w, h) in [(10, 7), (80, 40), (37, 21), (1, 1)] {
|
||||
let r = resize(&img, w, h, f);
|
||||
assert!(r.data.iter().all(|v| (v - 0.42).abs() < 1e-5), "{f:?} {w}x{h}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downscale_preserves_mean() {
|
||||
let img = Image::<f32>::from_fn(64, 64, |x, y| ((x * 7 + y * 3) % 11) as f32 / 10.0);
|
||||
let mean = img.data.iter().sum::<f32>() / img.len() as f32;
|
||||
let r = resize(&img, 16, 16, Filter::Lanczos3);
|
||||
let m2 = r.data.iter().sum::<f32>() / r.len() as f32;
|
||||
assert!((mean - m2).abs() < 0.01);
|
||||
let hh = half(&img);
|
||||
assert_eq!((hh.width, hh.height), (32, 32));
|
||||
let m3 = hh.data.iter().sum::<f32>() / hh.len() as f32;
|
||||
assert!((mean - m3).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_keeps_aspect() {
|
||||
let img = Image::<f32>::new(4000, 3000);
|
||||
let r = fit(&img, 400, 400, Filter::Box);
|
||||
assert_eq!((r.width, r.height), (400, 300));
|
||||
let small = fit(&Image::<f32>::new(10, 5), 400, 400, Filter::Box);
|
||||
assert_eq!((small.width, small.height), (10, 5));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
max_width = 150
|
||||
use_small_heuristics = "Max"
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "xtask"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Dependency layering rules (plan/architecture.md §3).
|
||||
//!
|
||||
//! The rule engine works on a small, metadata-independent model so it can be
|
||||
//! unit-tested; `from_metadata` builds that model from `cargo metadata`.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Where a workspace crate sits.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Class {
|
||||
/// Regular layered crate.
|
||||
Layer(u8),
|
||||
/// Layer 0, and additionally may depend on no workspace crate at all.
|
||||
Standalone,
|
||||
/// Test tooling: may depend on anything up to L5 (it sits at L6 for rule
|
||||
/// purposes); other crates may use it only as a dev-dependency.
|
||||
Testkit,
|
||||
/// Binaries and build tooling: exempt from the rules.
|
||||
Exempt,
|
||||
}
|
||||
|
||||
impl Class {
|
||||
fn layer(self) -> Option<u8> {
|
||||
match self {
|
||||
Class::Layer(l) => Some(l),
|
||||
Class::Standalone => Some(0),
|
||||
Class::Testkit => Some(5),
|
||||
Class::Exempt => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layering table. Names are package names without the `lightcraft-`
|
||||
/// prefix.
|
||||
pub const TABLE: &[(&str, Class)] = &[
|
||||
("geom", Class::Layer(0)),
|
||||
("color", Class::Layer(0)),
|
||||
("raster", Class::Layer(0)),
|
||||
("tiff", Class::Layer(0)),
|
||||
("raw", Class::Layer(1)),
|
||||
("codecs", Class::Layer(1)),
|
||||
("meta", Class::Layer(1)),
|
||||
("develop", Class::Layer(1)),
|
||||
("scenes", Class::Layer(1)),
|
||||
("pipeline", Class::Layer(2)),
|
||||
("gpu", Class::Layer(3)),
|
||||
("catalog", Class::Layer(3)),
|
||||
("preview", Class::Layer(3)),
|
||||
("export", Class::Layer(3)),
|
||||
("merge", Class::Layer(3)),
|
||||
("engine", Class::Layer(4)),
|
||||
("ui-egui", Class::Layer(5)),
|
||||
("mcp", Class::Layer(5)),
|
||||
("testkit", Class::Testkit),
|
||||
// L6 apps and tooling
|
||||
("lightcraft", Class::Exempt),
|
||||
("cli", Class::Exempt),
|
||||
("web", Class::Exempt),
|
||||
("xtask", Class::Exempt),
|
||||
];
|
||||
|
||||
/// Explicit orderings *within* a layer (earlier may be used by later).
|
||||
/// L0: `raster` builds on `color` and `geom`; `color` uses `geom` for matrices.
|
||||
/// L1: `raw` and `codecs` read metadata through `meta`; `develop` uses `meta` for XMP.
|
||||
pub const INTRA_LAYER_ORDER: &[&[&str]] = &[
|
||||
&["geom", "color", "raster"],
|
||||
&["tiff", "raster"],
|
||||
&["meta", "raw"],
|
||||
&["meta", "codecs"],
|
||||
&["meta", "develop"],
|
||||
&["develop", "scenes"],
|
||||
&["codecs", "raw"],
|
||||
];
|
||||
|
||||
fn intra_layer_allowed(from: &str, to: &str) -> bool {
|
||||
let (from, to) = (short_name(from), short_name(to));
|
||||
INTRA_LAYER_ORDER.iter().any(|chain| match (chain.iter().position(|n| *n == from), chain.iter().position(|n| *n == to)) {
|
||||
(Some(f), Some(t)) => t < f,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// External crates that constitute a UI toolkit / windowing dependency.
|
||||
/// Entries ending in `*` are prefixes.
|
||||
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"];
|
||||
|
||||
/// First layer allowed to use UI crates.
|
||||
pub const UI_MIN_LAYER: u8 = 5;
|
||||
|
||||
pub fn short_name(pkg: &str) -> &str {
|
||||
pkg.strip_prefix("lightcraft-").unwrap_or(pkg)
|
||||
}
|
||||
|
||||
pub fn classify(pkg: &str) -> Option<Class> {
|
||||
let s = short_name(pkg);
|
||||
TABLE.iter().find(|(n, _)| *n == s).map(|(_, c)| *c)
|
||||
}
|
||||
|
||||
fn is_ui_crate(name: &str) -> bool {
|
||||
UI_CRATES.iter().any(|p| match p.strip_suffix('*') {
|
||||
Some(prefix) => name.starts_with(prefix),
|
||||
None => name == *p,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DepKind {
|
||||
Normal,
|
||||
Dev,
|
||||
Build,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Dep {
|
||||
pub name: String,
|
||||
pub kind: DepKind,
|
||||
/// `true` if the dependency is a workspace member.
|
||||
pub workspace: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Crate {
|
||||
pub name: String,
|
||||
pub deps: Vec<Dep>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Violation {
|
||||
Unregistered { krate: String },
|
||||
Upward { krate: String, dep: String, from: u8, to: u8, kind: DepKind },
|
||||
StandaloneHasWorkspaceDep { krate: String, dep: String },
|
||||
TestkitAsNormalDep { krate: String },
|
||||
UiBelowL5 { krate: String, dep: String, layer: u8 },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Violation {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Violation::Unregistered { krate } => {
|
||||
write!(f, "{krate}: unknown workspace crate; register it in xtask/src/layers.rs TABLE (see plan/architecture.md §3)")
|
||||
}
|
||||
Violation::Upward { krate, dep, from, to, kind } => {
|
||||
write!(f, "{krate} (L{from}) -> {dep} (L{to}) [{kind:?}]: may only depend on strictly lower layers")
|
||||
}
|
||||
Violation::StandaloneHasWorkspaceDep { krate, dep } => {
|
||||
write!(f, "{krate}: standalone crate must not depend on workspace crate {dep}")
|
||||
}
|
||||
Violation::TestkitAsNormalDep { krate } => {
|
||||
write!(f, "{krate}: lightcraft-testkit may only be a dev-dependency")
|
||||
}
|
||||
Violation::UiBelowL5 { krate, dep, layer } => {
|
||||
write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L5+")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check all rules. Returns violations sorted for stable output.
|
||||
pub fn check(crates: &[Crate]) -> Vec<Violation> {
|
||||
let mut out = Vec::new();
|
||||
for c in crates {
|
||||
let Some(class) = classify(&c.name) else {
|
||||
out.push(Violation::Unregistered { krate: c.name.clone() });
|
||||
continue;
|
||||
};
|
||||
if class == Class::Exempt {
|
||||
continue;
|
||||
}
|
||||
let layer = class.layer().unwrap_or(0);
|
||||
for d in &c.deps {
|
||||
// Self dev-dependencies (e.g. to enable features in tests) are fine.
|
||||
if d.name == c.name {
|
||||
continue;
|
||||
}
|
||||
if d.workspace {
|
||||
if class == Class::Standalone {
|
||||
out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() });
|
||||
continue;
|
||||
}
|
||||
match classify(&d.name) {
|
||||
// Unregistered deps are reported on their own entry.
|
||||
None => {}
|
||||
Some(Class::Testkit) => {
|
||||
if d.kind != DepKind::Dev {
|
||||
out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() });
|
||||
}
|
||||
}
|
||||
Some(dc) => {
|
||||
let to = dc.layer().unwrap_or(u8::MAX);
|
||||
if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) {
|
||||
out.push(Violation::Upward {
|
||||
krate: c.name.clone(),
|
||||
dep: d.name.clone(),
|
||||
from: layer,
|
||||
to: if to == u8::MAX { 7 } else { to },
|
||||
kind: d.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) {
|
||||
out.push(Violation::UiBelowL5 { krate: c.name.clone(), dep: d.name.clone(), layer });
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|v| v.to_string());
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// Build the model from `cargo metadata --format-version 1 --no-deps`.
|
||||
pub fn from_metadata(meta: &Value) -> Result<Vec<Crate>, String> {
|
||||
let pkgs = meta["packages"].as_array().ok_or("metadata: no packages array")?;
|
||||
let members: Vec<&str> = pkgs.iter().filter_map(|p| p["name"].as_str()).collect();
|
||||
let mut out = Vec::new();
|
||||
for p in pkgs {
|
||||
let name = p["name"].as_str().ok_or("package without name")?.to_owned();
|
||||
let mut deps = Vec::new();
|
||||
for d in p["dependencies"].as_array().into_iter().flatten() {
|
||||
let dname = d["name"].as_str().unwrap_or_default().to_owned();
|
||||
let kind = match d["kind"].as_str() {
|
||||
Some("dev") => DepKind::Dev,
|
||||
Some("build") => DepKind::Build,
|
||||
_ => DepKind::Normal,
|
||||
};
|
||||
let workspace = members.contains(&dname.as_str()) || d["path"].is_string();
|
||||
deps.push(Dep { name: dname, kind, workspace });
|
||||
}
|
||||
out.push(Crate { name, deps });
|
||||
}
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn describe(class: Option<Class>) -> String {
|
||||
match class {
|
||||
Some(Class::Layer(l)) => format!("L{l}"),
|
||||
Some(Class::Standalone) => "L0 standalone".into(),
|
||||
Some(Class::Testkit) => "testkit".into(),
|
||||
Some(Class::Exempt) => "exempt".into(),
|
||||
None => "UNREGISTERED".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn c(name: &str, deps: &[(&str, DepKind, bool)]) -> Crate {
|
||||
Crate { name: name.into(), deps: deps.iter().map(|(n, k, w)| Dep { name: (*n).into(), kind: *k, workspace: *w }).collect() }
|
||||
}
|
||||
use DepKind::*;
|
||||
|
||||
#[test]
|
||||
fn clean_downward_graph_passes() {
|
||||
let g = [
|
||||
c("lightcraft-geom", &[("serde", Normal, false)]),
|
||||
c("lightcraft-develop", &[("lightcraft-geom", Normal, true)]),
|
||||
c("lightcraft-engine", &[("lightcraft-develop", Normal, true), ("lightcraft-testkit", Dev, true)]),
|
||||
c("lightcraft-ui-egui", &[("lightcraft-engine", Normal, true), ("egui", Normal, false)]),
|
||||
c("lightcraft-cli", &[("lightcraft-ui-egui", Normal, true)]),
|
||||
];
|
||||
assert!(check(&g).is_empty(), "{:?}", check(&g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upward_dependency_flagged() {
|
||||
let v = check(&[c("lightcraft-develop", &[("lightcraft-engine", Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::Upward { from: 1, to: 4, .. }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sideways_dependency_flagged() {
|
||||
let v = check(&[c("lightcraft-catalog", &[("lightcraft-preview", Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::Upward { from: 3, to: 3, .. }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l0_chain_allowed_one_way() {
|
||||
assert!(check(&[c("lightcraft-raster", &[("lightcraft-color", Normal, true)])]).is_empty());
|
||||
assert!(!check(&[c("lightcraft-color", &[("lightcraft-raster", Normal, true)])]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_crates_forbidden_below_l5() {
|
||||
for dep in ["egui", "eframe", "winit", "rfd"] {
|
||||
let v = check(&[c("lightcraft-engine", &[(dep, Normal, false)])]);
|
||||
assert!(matches!(v[..], [Violation::UiBelowL5 { .. }]), "{dep}");
|
||||
}
|
||||
assert!(check(&[c("lightcraft-mcp", &[("winit", Normal, false)])]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregistered_and_testkit_rules() {
|
||||
let v = check(&[c("lightcraft-mystery", &[])]);
|
||||
assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "lightcraft-mystery"));
|
||||
assert!(!check(&[c("lightcraft-pipeline", &[("lightcraft-testkit", Normal, true)])]).is_empty());
|
||||
assert!(check(&[c("lightcraft-pipeline", &[("lightcraft-testkit", Dev, true)])]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apps_exempt() {
|
||||
for app in ["lightcraft", "lightcraft-cli", "lightcraft-web", "xtask"] {
|
||||
assert!(check(&[c(app, &[("egui", Normal, false), ("lightcraft-ui-egui", Normal, true)])]).is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Workspace tooling: `cargo xtask <command>`.
|
||||
//!
|
||||
//! Pure Rust (std + serde_json). External tools (`cargo`, `curl`, `tar`) are
|
||||
//! invoked through `std::process::Command`.
|
||||
|
||||
mod layers;
|
||||
mod stats;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
|
||||
const USAGE: &str = "\
|
||||
usage: cargo xtask <command>
|
||||
|
||||
commands:
|
||||
layers enforce the crate dependency layering (plan/architecture.md §3)
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
|
||||
ci fmt --check, clippy -D warnings, test, layers, wasm (stops at first failure)
|
||||
corpus [--download]
|
||||
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
|
||||
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
|
||||
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(format!("unknown command `{other}`\n\n{USAGE}")),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Workspace root (parent of the xtask crate).
|
||||
pub fn root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf()
|
||||
}
|
||||
|
||||
pub fn cargo() -> Command {
|
||||
let mut c = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()));
|
||||
c.current_dir(root());
|
||||
c
|
||||
}
|
||||
|
||||
fn run(mut cmd: Command, what: &str) -> Result<(), String> {
|
||||
eprintln!("$ {what}");
|
||||
let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?;
|
||||
if status.success() { Ok(()) } else { Err(format!("{what}: exited with {status}")) }
|
||||
}
|
||||
|
||||
pub fn metadata() -> Result<serde_json::Value, String> {
|
||||
let out = cargo().args(["metadata", "--format-version", "1", "--no-deps"]).output().map_err(|e| format!("cargo metadata: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("cargo metadata failed:\n{}", String::from_utf8_lossy(&out.stderr)));
|
||||
}
|
||||
serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata: bad JSON: {e}"))
|
||||
}
|
||||
|
||||
fn cmd_layers() -> Result<(), String> {
|
||||
let crates = layers::from_metadata(&metadata()?)?;
|
||||
println!("Dependency layering (plan/architecture.md §3)\n");
|
||||
println!("{:<28} {:<14} workspace deps", "crate", "layer");
|
||||
for c in &crates {
|
||||
let ws: Vec<String> = c
|
||||
.deps
|
||||
.iter()
|
||||
.filter(|d| d.workspace)
|
||||
.map(|d| {
|
||||
let k = match d.kind {
|
||||
layers::DepKind::Normal => "",
|
||||
layers::DepKind::Dev => " (dev)",
|
||||
layers::DepKind::Build => " (build)",
|
||||
};
|
||||
format!("{}{k}", layers::short_name(&d.name))
|
||||
})
|
||||
.collect();
|
||||
println!("{:<28} {:<14} {}", c.name, layers::describe(layers::classify(&c.name)), ws.join(", "));
|
||||
}
|
||||
let violations = layers::check(&crates);
|
||||
println!();
|
||||
if violations.is_empty() {
|
||||
println!("OK: {} crates, no layering violations.", crates.len());
|
||||
Ok(())
|
||||
} else {
|
||||
println!("{} violation(s):", violations.len());
|
||||
for v in &violations {
|
||||
println!(" - {v}");
|
||||
}
|
||||
Err(format!("{} layering violation(s)", violations.len()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Workspace packages that must build for wasm32: all L0–L5 crates plus
|
||||
/// the egui shell.
|
||||
fn wasm_set() -> Result<Vec<String>, String> {
|
||||
let crates = layers::from_metadata(&metadata()?)?;
|
||||
Ok(crates
|
||||
.into_iter()
|
||||
.filter(|c| match layers::classify(&c.name) {
|
||||
Some(layers::Class::Layer(l)) => l <= 5,
|
||||
Some(layers::Class::Standalone) => true,
|
||||
_ => false,
|
||||
})
|
||||
.map(|c| c.name)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn cmd_wasm() -> Result<(), String> {
|
||||
let set = wasm_set()?;
|
||||
let mut results = Vec::new();
|
||||
for pkg in &set {
|
||||
let mut c = cargo();
|
||||
c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]);
|
||||
let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_ok();
|
||||
results.push((pkg.clone(), ok));
|
||||
}
|
||||
println!("\nwasm32-unknown-unknown check:");
|
||||
for (p, ok) in &results {
|
||||
println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" });
|
||||
}
|
||||
let failed = results.iter().filter(|r| !r.1).count();
|
||||
if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) }
|
||||
}
|
||||
|
||||
fn cmd_ci() -> Result<(), String> {
|
||||
type Step = (&'static str, Box<dyn Fn() -> Result<(), String>>);
|
||||
let steps: Vec<Step> = vec![
|
||||
(
|
||||
"fmt",
|
||||
Box::new(|| {
|
||||
let mut c = cargo();
|
||||
c.args(["fmt", "--all", "--", "--check"]);
|
||||
run(c, "cargo fmt --all -- --check")
|
||||
}),
|
||||
),
|
||||
(
|
||||
"clippy",
|
||||
Box::new(|| {
|
||||
let mut c = cargo();
|
||||
c.args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]);
|
||||
run(c, "cargo clippy --workspace --all-targets -- -D warnings")
|
||||
}),
|
||||
),
|
||||
(
|
||||
"test",
|
||||
Box::new(|| {
|
||||
let mut c = cargo();
|
||||
c.args(["test", "--workspace"]);
|
||||
run(c, "cargo test --workspace")
|
||||
}),
|
||||
),
|
||||
("layers", Box::new(cmd_layers)),
|
||||
("wasm", Box::new(cmd_wasm)),
|
||||
];
|
||||
let mut done = Vec::new();
|
||||
for (name, f) in &steps {
|
||||
eprintln!("\n=== ci: {name} ===");
|
||||
if let Err(e) = f() {
|
||||
println!("\nCI summary:");
|
||||
for d in &done {
|
||||
println!(" ok {d}");
|
||||
}
|
||||
println!(" FAIL {name}: {e}");
|
||||
for (n, _) in steps.iter().skip(done.len() + 1) {
|
||||
println!(" skip {n}");
|
||||
}
|
||||
return Err(format!("ci failed at `{name}`"));
|
||||
}
|
||||
done.push(*name);
|
||||
}
|
||||
println!("\nCI summary: all {} steps passed ({})", done.len(), done.join(", "));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// CC0 raw samples (raw.pixls.us). Small, representative set; extend freely (CC0 only).
|
||||
const RAW_SAMPLES: &[(&str, &str)] = &[];
|
||||
|
||||
fn cmd_corpus(download: bool) -> Result<(), String> {
|
||||
let corpus = root().join("corpus");
|
||||
println!(
|
||||
"Test corpora live under {} (git-ignored, never committed).
|
||||
Tests that use a corpus skip cleanly when it is absent.
|
||||
|
||||
corpus/raw/ raw.pixls.us samples (CC0) — lightcraft-raw decodes every file.
|
||||
corpus/images/ CC0 / public-domain JPEG/PNG/TIFF/HEIC samples (optional)
|
||||
",
|
||||
corpus.display()
|
||||
);
|
||||
if !download {
|
||||
return Ok(());
|
||||
}
|
||||
let dest = corpus.join("raw");
|
||||
std::fs::create_dir_all(&dest).map_err(|e| format!("create {}: {e}", dest.display()))?;
|
||||
for (name, url) in RAW_SAMPLES {
|
||||
let out = dest.join(name);
|
||||
if out.exists() {
|
||||
continue;
|
||||
}
|
||||
let mut curl = Command::new("curl");
|
||||
curl.args(["-fsSL", "-o"]).arg(&out).arg(url);
|
||||
run(curl, &format!("curl {url}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! `cargo xtask stats`: tests and lines of Rust per crate.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct Counts {
|
||||
files: usize,
|
||||
lines: usize,
|
||||
tests: usize,
|
||||
proptests: usize,
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, c: &mut Counts) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if p.is_dir() {
|
||||
// Nested fuzz workspaces and build output are not part of the crate.
|
||||
if name != "target" && name != "fuzz" && !name.starts_with('.') {
|
||||
walk(&p, c);
|
||||
}
|
||||
} else if name.ends_with(".rs")
|
||||
&& let Ok(s) = std::fs::read_to_string(&p)
|
||||
{
|
||||
c.files += 1;
|
||||
c.lines += s.lines().count();
|
||||
let (t, pt) = count_tests(&s);
|
||||
c.tests += t;
|
||||
c.proptests += pt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `#[test]` attributes and `proptest!` blocks (each block may hold many
|
||||
/// property tests, and macro-generated tests are only counted once — use
|
||||
/// `--exact` for harness-accurate numbers).
|
||||
fn count_tests(src: &str) -> (usize, usize) {
|
||||
let mut tests = 0;
|
||||
let mut props = 0;
|
||||
for line in src.lines() {
|
||||
let t = line.trim_start();
|
||||
if t.starts_with("//") {
|
||||
continue;
|
||||
}
|
||||
tests += t.matches("#[test]").count();
|
||||
if t.starts_with("proptest!") {
|
||||
props += 1;
|
||||
}
|
||||
}
|
||||
(tests, props)
|
||||
}
|
||||
|
||||
fn exact_tests(pkg: &str) -> Option<usize> {
|
||||
let out = crate::cargo().args(["test", "-q", "-p", pkg, "--", "--list"]).output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&out.stdout).lines().filter(|l| l.ends_with(": test")).count())
|
||||
}
|
||||
|
||||
pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
|
||||
let meta = crate::metadata()?;
|
||||
let mut rows = Vec::new();
|
||||
for p in meta["packages"].as_array().into_iter().flatten() {
|
||||
let name = p["name"].as_str().unwrap_or("?").to_owned();
|
||||
let Some(dir) = p["manifest_path"].as_str().and_then(|m| Path::new(m).parent().map(Path::to_path_buf)) else {
|
||||
continue;
|
||||
};
|
||||
let mut c = Counts::default();
|
||||
walk(&dir, &mut c);
|
||||
let ex = if exact { exact_tests(&name) } else { None };
|
||||
rows.push((name, c, ex));
|
||||
}
|
||||
rows.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
let exact_col = if exact { format!(" {:>8}", "harness") } else { String::new() };
|
||||
println!("{:<26} {:>6} {:>8} {:>7} {:>10}{exact_col}", "crate", "files", "lines", "#[test]", "proptest!");
|
||||
println!("{}", "-".repeat(62 + if exact { 9 } else { 0 }));
|
||||
let mut total = Counts::default();
|
||||
let mut total_exact = 0;
|
||||
for (name, c, ex) in &rows {
|
||||
let ex_s = match (exact, ex) {
|
||||
(false, _) => String::new(),
|
||||
(true, Some(n)) => {
|
||||
total_exact += n;
|
||||
format!(" {n:>8}")
|
||||
}
|
||||
(true, None) => format!(" {:>8}", "error"),
|
||||
};
|
||||
println!("{name:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", c.files, c.lines, c.tests, c.proptests);
|
||||
total.files += c.files;
|
||||
total.lines += c.lines;
|
||||
total.tests += c.tests;
|
||||
total.proptests += c.proptests;
|
||||
}
|
||||
println!("{}", "-".repeat(62 + if exact { 9 } else { 0 }));
|
||||
let ex_s = if exact { format!(" {total_exact:>8}") } else { String::new() };
|
||||
println!("{:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", "TOTAL", total.files, total.lines, total.tests, total.proptests);
|
||||
if !exact {
|
||||
println!("\n(#[test] counts source attributes; macro-generated tests count once. Use --exact for harness counts.)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::count_tests;
|
||||
|
||||
#[test]
|
||||
fn counts_attributes_and_proptest_blocks() {
|
||||
let src = "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n";
|
||||
assert_eq!(count_tests(src), (2, 1));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user