M0.1/M0.2: workspace, xtask (layers/wasm/ci/stats), geom, color, raster

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-09-30 10:04:12 -04:00
commit 3f1ffeb690
37 files changed
+3743

No files matched your search

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