commit 3f1ffeb690d1832b3dc8f4c2bcd049f2df69b83b Author: Brandon Thomas Date: Wed Sep 30 10:04:12 2026 -0400 M0.1/M0.2: workspace, xtask (layers/wasm/ci/stats), geom, color, raster Co-Authored-By: Claude Opus 5.5 diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..35049cbcb --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --package xtask --" diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..0d78b09de --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*~ +plan/ +/target +/dist +/corpus +/log +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..79eaa839c --- /dev/null +++ b/AGENTS.md @@ -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-`; 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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..79eaa839c --- /dev/null +++ b/CLAUDE.md @@ -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-`; 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. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..fe13521a3 --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..29847fce9 --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/NOTICE b/NOTICE new file mode 100644 index 000000000..d74266de7 --- /dev/null +++ b/NOTICE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 000000000..5e2cf1a1c --- /dev/null +++ b/README.md @@ -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. diff --git a/assets/fonts/Inter-Medium.ttf b/assets/fonts/Inter-Medium.ttf new file mode 100644 index 000000000..458cd0601 Binary files /dev/null and b/assets/fonts/Inter-Medium.ttf differ diff --git a/assets/fonts/Inter-Regular.ttf b/assets/fonts/Inter-Regular.ttf new file mode 100644 index 000000000..b7aaca8de Binary files /dev/null and b/assets/fonts/Inter-Regular.ttf differ diff --git a/assets/fonts/Inter-SemiBold.ttf b/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 000000000..47f8ab1d6 Binary files /dev/null and b/assets/fonts/Inter-SemiBold.ttf differ diff --git a/assets/fonts/OFL-Inter.txt b/assets/fonts/OFL-Inter.txt new file mode 100644 index 000000000..9b2ca37b3 --- /dev/null +++ b/assets/fonts/OFL-Inter.txt @@ -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. diff --git a/assets/fonts/OFL-SourceSans3.txt b/assets/fonts/OFL-SourceSans3.txt new file mode 100644 index 000000000..22c601b82 --- /dev/null +++ b/assets/fonts/OFL-SourceSans3.txt @@ -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. diff --git a/assets/fonts/SourceSans3-Bold.ttf b/assets/fonts/SourceSans3-Bold.ttf new file mode 100644 index 000000000..85693ccc8 Binary files /dev/null and b/assets/fonts/SourceSans3-Bold.ttf differ diff --git a/assets/fonts/SourceSans3-It.ttf b/assets/fonts/SourceSans3-It.ttf new file mode 100644 index 000000000..8e0576f24 Binary files /dev/null and b/assets/fonts/SourceSans3-It.ttf differ diff --git a/assets/fonts/SourceSans3-Regular.ttf b/assets/fonts/SourceSans3-Regular.ttf new file mode 100644 index 000000000..c5dd0b88c Binary files /dev/null and b/assets/fonts/SourceSans3-Regular.ttf differ diff --git a/assets/fonts/SourceSans3-Semibold.ttf b/assets/fonts/SourceSans3-Semibold.ttf new file mode 100644 index 000000000..4aff9a3ae Binary files /dev/null and b/assets/fonts/SourceSans3-Semibold.ttf differ diff --git a/crates/color/Cargo.toml b/crates/color/Cargo.toml new file mode 100644 index 000000000..b16a3dcef --- /dev/null +++ b/crates/color/Cargo.toml @@ -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 } diff --git a/crates/color/src/cct.rs b/crates/color/src/cct.rs new file mode 100644 index 000000000..abd241dde --- /dev/null +++ b/crates/color/src/cct.rs @@ -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:?}"); + } +} diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs new file mode 100644 index 000000000..77d06185a --- /dev/null +++ b/crates/color/src/lib.rs @@ -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 { + 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 { + 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); + } +} diff --git a/crates/color/src/perceptual.rs b/crates/color/src/perceptual.rs new file mode 100644 index 000000000..40743f80a --- /dev/null +++ b/crates/color/src/perceptual.rs @@ -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 = 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); + } +} diff --git a/crates/color/src/spline.rs b/crates/color/src/spline.rs new file mode 100644 index 000000000..c8860d950 --- /dev/null +++ b/crates/color/src/spline.rs @@ -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, + ys: Vec, + ms: Vec, +} + +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 = p.iter().map(|q| q.0).collect(); + let ys: Vec = p.iter().map(|q| q.1).collect(); + let d: Vec = (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, +} + +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; } + } + } +} diff --git a/crates/color/src/transfer.rs b/crates/color/src/transfer.rs new file mode 100644 index 000000000..8a7e4fdbf --- /dev/null +++ b/crates/color/src/transfer.rs @@ -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> = 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); + } +} diff --git a/crates/geom/Cargo.toml b/crates/geom/Cargo.toml new file mode 100644 index 000000000..12ad504a7 --- /dev/null +++ b/crates/geom/Cargo.toml @@ -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 } diff --git a/crates/geom/src/crop.rs b/crates/geom/src/crop.rs new file mode 100644 index 000000000..a86fdb7e5 --- /dev/null +++ b/crates/geom/src/crop.rs @@ -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) -> 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); + } +} diff --git a/crates/geom/src/homography.rs b/crates/geom/src/homography.rs new file mode 100644 index 000000000..6a49cc5d1 --- /dev/null +++ b/crates/geom/src/homography.rs @@ -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 { + 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 { + 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 = (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()); + } +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs new file mode 100644 index 000000000..530d089d0 --- /dev/null +++ b/crates/geom/src/lib.rs @@ -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 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 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 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 { + 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); + } + } +} diff --git a/crates/raster/Cargo.toml b/crates/raster/Cargo.toml new file mode 100644 index 000000000..bb59b3486 --- /dev/null +++ b/crates/raster/Cargo.toml @@ -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 } diff --git a/crates/raster/src/blur.rs b/crates/raster/src/blur.rs new file mode 100644 index 000000000..13f9c43a3 --- /dev/null +++ b/crates/raster/src/blur.rs @@ -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(src: &Image, r: usize) -> Image { + let w = src.width; + let mut out = Image::::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(src: &Image) -> Image { + let (w, h) = (src.width, src.height); + let mut out = Image::::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(img: &Image, sigma: f32) -> Image { + 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::::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::::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::::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()); + } +} diff --git a/crates/raster/src/histogram.rs b/crates/raster/src/histogram.rs new file mode 100644 index 000000000..300fa273b --- /dev/null +++ b/crates/raster/src/histogram.rs @@ -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, + pub g: Vec, + pub b: Vec, + pub luma: Vec, + 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::() / (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); + } +} diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs new file mode 100644 index 000000000..3cc311ed4 --- /dev/null +++ b/crates/raster/src/lib.rs @@ -0,0 +1,241 @@ +//! Image buffers and basic raster operations for LightCraft. +//! +//! - [`Image`]: interleaved, row-major pixels (`Rgb32f = Image<[f32; 3]>`, `Rgba8 = Image<[u8; 4]>`, +//! `Plane = Image`). +//! - 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(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 { + pub width: usize, + pub height: usize, + pub data: Vec, +} + +pub type Rgb32f = Image<[f32; 3]>; +pub type Rgba8 = Image<[u8; 4]>; +pub type Plane = Image; + +impl Image { + 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(&self, f: impl Fn(T) -> U + Sync + Send) -> Image + where + T: Sync, + { + let mut out = Image::::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 { + self.data.iter().flat_map(|p| *p).collect() + } + pub fn from_bytes(width: usize, height: usize, bytes: &[u8]) -> Option { + 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::::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::::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); + } +} diff --git a/crates/raster/src/resample.rs b/crates/raster/src/resample.rs new file mode 100644 index 000000000..b6c37b792 --- /dev/null +++ b/crates/raster/src/resample.rs @@ -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)> { + 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 = (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(img: &Image, w: usize, h: usize, filter: Filter) -> Image { + 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::::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::::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(img: &Image, max_w: usize, max_h: usize, filter: Filter) -> Image { + 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(img: &Image) -> Image { + let w = img.width.div_ceil(2).max(1); + let h = img.height.div_ceil(2).max(1); + let mut out = Image::::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::::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::::from_fn(64, 64, |x, y| ((x * 7 + y * 3) % 11) as f32 / 10.0); + let mean = img.data.iter().sum::() / img.len() as f32; + let r = resize(&img, 16, 16, Filter::Lanczos3); + let m2 = r.data.iter().sum::() / 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::() / hh.len() as f32; + assert!((mean - m3).abs() < 1e-5); + } + + #[test] + fn fit_keeps_aspect() { + let img = Image::::new(4000, 3000); + let r = fit(&img, 400, 400, Filter::Box); + assert_eq!((r.width, r.height), (400, 300)); + let small = fit(&Image::::new(10, 5), 400, 400, Filter::Box); + assert_eq!((small.width, small.height), (10, 5)); + } +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 000000000..3121924d6 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 150 +use_small_heuristics = "Max" diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 000000000..6d935ae8f --- /dev/null +++ b/xtask/Cargo.toml @@ -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 } diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs new file mode 100644 index 000000000..7ce550f13 --- /dev/null +++ b/xtask/src/layers.rs @@ -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 { + 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 { + 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, +} + +#[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 { + 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, 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) -> 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()); + } + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 000000000..305d3dc49 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,218 @@ +//! Workspace tooling: `cargo xtask `. +//! +//! 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 + +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 = 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 { + 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 = 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, 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 Result<(), String>>); + let steps: Vec = 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(()) +} diff --git a/xtask/src/stats.rs b/xtask/src/stats.rs new file mode 100644 index 000000000..f40b1300a --- /dev/null +++ b/xtask/src/stats.rs @@ -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 { + 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)); + } +}