//! TRACES: FR-DEV-3e //! Base curves — the per-body rendering that turns a correct exposure into a //! photograph. //! //! # What this is for //! //! A camera matrix gets the *colours* right and leaves the picture flat. Sensor //! data is scene-referred and very nearly linear; a print, a screen and a //! camera's own JPEG are none of those things. Rendering linear data straight //! out is the dcraw default, and FR-DEV-3e names it precisely: "the flat, //! poor-skin-tone rendering characteristic of dcraw defaults, which is the //! documented reason people abandon darktable in the first hour." //! //! The fix is a tone curve applied as part of *reading* the file rather than as //! an edit — a toe, a steep midtone, and a shoulder that rolls highlights off //! instead of clipping them. Every raw converter has one. Adobe calls it the //! camera profile's tone curve, darktable calls it the base curve, and the name //! here follows darktable's because the placement does too: it runs in camera //! RGB, after white balance and the user's adjustments, immediately before the //! conversion out to a working space. //! //! # Why it is not an edit //! //! It never reaches the sidecar and there is no slider for it, for the same //! reason the EXIF orientation is not an edit (FR-DEV-3h): it is a property of //! the body that took the frame, not of what anyone decided about the frame. //! Sidecars are shared between devices and bodies (FR-NC-9), and one camera's //! rendering must not follow an edit onto another camera's file. //! //! # Why it is data //! //! FR-DEV-3e requires the profile database to be "versioned independently of //! the app binary so bodies and curves can be added without a release — and, //! under D8's GPLv3, contributed by users". So the curves live in //! `profiles/base_curves.yaml`, a file that is compiled in as a floor and //! *overridden* by a copy on disk carrying a higher `version:`. Adding a body //! is adding ten numbers to a YAML file; shipping that body to users is //! publishing the file. Neither is a code change and neither needs a release. //! //! See [`load`] for the search path and [`Curves::body`] for the matching. use std::path::{Path, PathBuf}; use std::sync::OnceLock; /// How many control points a base curve has. /// /// Five, which is not a coincidence: it is what the tone curve widget uses /// (`dr_pipeline::ops::curve::POINTS`), so the shader evaluates a profile's /// curve and a photographer's curve through exactly the same spline. A profile /// author and a photographer dragging a point mean the same thing by it, and /// the generated shader carries one implementation rather than two that could /// disagree. pub const POINTS: usize = 5; /// TRACES: FR-DEV-3e /// A base curve: five points on a monotone spline through the unit square. /// /// `xs` is scene-linear camera RGB, normalised so that 1.0 is the sensor's /// saturation point. `ys` is display-referred linear — *not* gamma-encoded, /// because the sRGB transfer function is applied at the very end of the /// generated shader and applying it twice would wash the image out. #[derive(Debug, Clone, Copy, PartialEq)] pub struct BaseCurve { pub xs: [f32; POINTS], pub ys: [f32; POINTS], } impl BaseCurve { /// The curve that does nothing — the identity diagonal. /// /// What an unrecognised body gets if the database carries no default, and /// what a JPEG gets always: an already-rendered image must not be rendered /// a second time. pub const IDENTITY: Self = Self { xs: [0.0, 0.25, 0.5, 0.75, 1.0], ys: [0.0, 0.25, 0.5, 0.75, 1.0], }; /// Whether this curve would leave the image alone. /// /// The shader is told to skip the stage entirely when it would, so an /// unprofiled body costs a branch that is uniform across the dispatch /// rather than a spline evaluation per channel per pixel. pub fn is_identity(&self) -> bool { self.xs .iter() .zip(self.ys.iter()) .all(|(x, y)| (x - y).abs() < 1e-6) } /// Build from raw pairs, rejecting anything that is not a curve. /// /// A profile file is data a user may have edited, so this is the boundary /// where "ten numbers" becomes "a curve": the x coordinates must increase, /// the y coordinates must not decrease, and both must lie in the unit /// square. A non-monotone x sends the spline's span search backwards and /// divides by a negative width; a decreasing y inverts tones locally, /// which reads as a dark halo through smooth gradients rather than as a /// bad profile. /// /// Endpoints are not forced to (0,0) and (1,1). A curve that lifts black /// slightly, or that places the shoulder below white, is a legitimate /// rendering choice and several bodies make it. pub fn from_points(points: &[[f32; 2]]) -> Option { if points.len() != POINTS { return None; } let mut xs = [0.0f32; POINTS]; let mut ys = [0.0f32; POINTS]; for (i, p) in points.iter().enumerate() { if !p[0].is_finite() || !p[1].is_finite() { return None; } if !(0.0..=1.0).contains(&p[0]) || !(0.0..=1.0).contains(&p[1]) { return None; } xs[i] = p[0]; ys[i] = p[1]; } for i in 1..POINTS { // Strictly increasing in x — the spline divides by the span width. if xs[i] <= xs[i - 1] { return None; } // Non-decreasing in y. Flat is allowed: a curve that holds a // highlight range at white is clipping deliberately. if ys[i] < ys[i - 1] { return None; } } Some(Self { xs, ys }) } } /// One body's entry in the database. #[derive(Debug, Clone, PartialEq)] pub struct BodyCurve { /// The manufacturer, as the file writes it — "Canon", "NIKON CORPORATION". pub make: String, /// The model, as the file writes it — "EOS 6D", "ILCE-7M3". pub model: String, pub curve: BaseCurve, } /// TRACES: FR-DEV-3e /// The base curve database. /// /// Versioned as a whole rather than per body, because that is the unit a user /// downloads and the unit that has to beat the built-in copy. See [`load`]. #[derive(Debug, Clone, PartialEq)] pub struct Curves { version: u32, default: Option, bodies: Vec, } impl Curves { /// TRACES: FR-DEV-3e /// The curve to render a frame from this body with. /// /// Falls back, in order, to the database's `default:` and then to the /// identity. **The default is deliberately not the identity**: an /// unrecognised body rendered flat is the failure this requirement exists /// to prevent, and a gentle, conservative curve is much closer to right for /// every body than no curve is for any of them. A body with its own entry /// gets that instead. /// /// # What "this body" has to survive /// /// The same camera names itself three ways depending on which program last /// touched the file. A native NEF says make "NIKON CORPORATION", model /// "NIKON Z 6"; rawler's own database cleans that to "Nikon" and "Z 6"; an /// Adobe-converted DNG keeps the uncleaned pair. A database that had to /// spell every variant would go stale the first time a maker changed its /// mind about its own name, so the matching does the folding instead: /// /// - Case, punctuation and runs of whitespace are flattened, so /// "ILCE-7M3", "ILCE 7M3" and "ilce-7m3" are one body. /// - The make is compared on its **first word only**. Every maker's /// trailing corporate boilerplate — "CORPORATION", "IMAGING CORP" — is /// noise, and no two camera manufacturers share a first word. /// - The model is tried both as written and with a leading copy of the /// make removed, which is what lets one "Canon"/"EOS 6D" entry cover /// "Canon EOS 6D" as well. pub fn body(&self, make: &str, model: &str) -> BaseCurve { let (make, model) = (make_key(make), normalise(model)); // The model with a leading copy of the maker's name removed. let bare = model.strip_prefix(&format!("{make} ")).unwrap_or(&model); self.bodies .iter() .find(|b| { let entry_model = normalise(&b.model); make_key(&b.make) == make && (entry_model == model || entry_model == bare) }) .map(|b| b.curve) .or(self.default) .unwrap_or(BaseCurve::IDENTITY) } /// The database version. Higher wins; see [`load`]. pub fn version(&self) -> u32 { self.version } /// How many bodies have their own curve, excluding the default. pub fn len(&self) -> usize { self.bodies.len() } pub fn is_empty(&self) -> bool { self.bodies.is_empty() } /// Parse a database from YAML. /// /// Entries that are not curves are dropped with a warning rather than /// failing the parse. A user-contributed file with one bad body should /// cost that body's rendering, not every body's — and the alternative is an /// application that will not open a photograph because somebody typed a /// comma. pub fn parse(yaml: &str) -> Result { let file: File = serde_norway::from_str(yaml).map_err(|e| e.to_string())?; let default = file.default.and_then(|d| { BaseCurve::from_points(&d.points).or_else(|| { log::warn!("base curves: the default entry is not a monotone curve; ignoring it"); None }) }); let bodies = file .bodies .into_iter() .filter_map(|b| match BaseCurve::from_points(&b.points) { Some(curve) => Some(BodyCurve { make: b.make, model: b.model, curve, }), None => { log::warn!( "base curves: {} {} is not a monotone curve; ignoring it", b.make, b.model ); None } }) .collect(); Ok(Self { version: file.version, default, bodies, }) } } /// The copy that ships inside the binary. /// /// A floor, not the answer: [`load`] prefers a newer file on disk. Compiled in /// so that a fresh install with no profile directory — and every Android build, /// where there is no such directory to speak of — still renders properly. const BUILT_IN: &str = include_str!("../profiles/base_curves.yaml"); /// TRACES: FR-DEV-3e /// The base curve database, loaded once. /// /// # The search path, and why it is a version comparison /// /// 1. `$DARKROOM_PROFILES`, a directory, when set. The escape hatch: a profile /// author iterating on a curve points this at their working copy and does /// not have to install anything. /// 2. `$XDG_DATA_HOME/darkroom/profiles/`, else `$HOME/.local/share/darkroom/profiles/`. /// The same base directory the catalog uses, chosen there for the same /// reason — it is data, not cache, and must survive a storage sweep. /// 3. The copy compiled into the binary. /// /// The first file that parses *and carries a higher `version:` than the /// built-in copy* wins. The version check is the whole mechanism the /// requirement asks for, and it runs in both directions: /// /// - A downloaded pack at version 7 supersedes a binary shipping version 3, so /// a body added after the release renders correctly with no release. /// - A stale pack at version 2 does **not** supersede a binary shipping version /// 3, so upgrading the application cannot silently lose curves to a file /// somebody downloaded a year ago and forgot. /// /// Failures are warnings, never errors. A malformed profile file must cost the /// user their curves, not their photographs. pub fn load() -> &'static Curves { static LOADED: OnceLock = OnceLock::new(); LOADED.get_or_init(|| { let built_in = Curves::parse(BUILT_IN).unwrap_or_else(|e| { // Unreachable in a build that ran its tests — `the_shipped_database_parses` // asserts exactly this — but a panic here would mean an // application that cannot open a photograph because of a typo in a // data file, which is never the right trade. log::error!("base curves: the built-in database does not parse: {e}"); Curves { version: 0, default: None, bodies: Vec::new(), } }); choose(built_in, &search_path()) }) } /// The version comparison, separated from where the directories come from. /// /// Split out so it can be tested against real files in a real directory /// without the process-wide `OnceLock` and the environment `load` reads. The /// rule this implements is the whole of what FR-DEV-3e asks for, so it is /// worth being able to state it as a test rather than as a comment. fn choose(built_in: Curves, dirs: &[PathBuf]) -> Curves { for dir in dirs { let path = dir.join("base_curves.yaml"); let Ok(text) = std::fs::read_to_string(&path) else { continue; }; match Curves::parse(&text) { Ok(external) if external.version > built_in.version => { log::info!( "base curves: using {} (version {}, {} bodies) over the built-in version {}", path.display(), external.version, external.len(), built_in.version ); return external; } Ok(external) => log::info!( "base curves: ignoring {} at version {}; the built-in database is version {}", path.display(), external.version, built_in.version ), Err(e) => log::warn!("base curves: {} does not parse: {e}", path.display()), } } built_in } /// TRACES: FR-DEV-3e /// The curve for a body, from the loaded database. /// /// The one call site the decoder needs; everything above is reachable for /// tests and for a future profile editor. pub fn for_body(make: &str, model: &str) -> BaseCurve { load().body(make, model) } /// Directories that may hold a `base_curves.yaml`, most specific first. fn search_path() -> Vec { let mut dirs = Vec::new(); if let Some(explicit) = std::env::var_os("DARKROOM_PROFILES") { dirs.push(PathBuf::from(explicit)); } // The same resolution `dr_ui::library::catalog_path` uses, and for the // same reason: this is data a user may have installed, not a cache. It is // duplicated rather than shared because `dr-decode` sits far below the UI // and must not acquire a dependency on it to find a directory. let base = std::env::var_os("XDG_DATA_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|h| Path::new(&h).join(".local/share"))); if let Some(base) = base { dirs.push(base.join("darkroom").join("profiles")); } dirs } /// A manufacturer's first word, folded. /// /// "NIKON CORPORATION", "Nikon" and "nikon" all become `NIKON`. The corporate /// suffixes are not information — they appear or not depending on whether the /// file went through a DNG converter — and no two camera manufacturers share a /// first word, so nothing is lost by dropping them. fn make_key(s: &str) -> String { normalise(s) .split(' ') .next() .unwrap_or_default() .to_string() } /// Fold a make or model into something two files can agree on. /// /// Upper-cased, with every run of non-alphanumeric characters collapsed to one /// space and the ends trimmed, so that "ILCE-7M3", "ILCE 7M3" and "ilce-7m3" /// become one. fn normalise(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut pending_space = false; for c in s.chars() { if c.is_ascii_alphanumeric() { if pending_space && !out.is_empty() { out.push(' '); } pending_space = false; out.push(c.to_ascii_uppercase()); } else { pending_space = true; } } out } // ---- The on-disk shape, kept apart from the in-memory one ---------------- // // Deliberately separate types. The file is data a user edits and is allowed to // be wrong; `Curves` is a parsed database whose every entry is known to be a // monotone curve. Deriving `Deserialize` on `BaseCurve` directly would delete // that boundary and let an unchecked five-point array reach the shader. // // Unknown fields are **accepted**, which is not laziness. The database is // versioned independently of the binary and moves in both directions: a pack // published after this release may carry keys this build has never heard of — // a hue twist, a look table (FR-DEV-3f) — and it must still deliver its curves // to an older DarkRoom rather than failing to parse and leaving every body // flat. `deny_unknown_fields` would trade that for a diagnostic nobody needs. #[derive(serde::Deserialize)] struct File { version: u32, #[serde(default)] default: Option, #[serde(default)] bodies: Vec, } #[derive(serde::Deserialize)] struct Entry { points: Vec<[f32; 2]>, } #[derive(serde::Deserialize)] struct BodyEntry { make: String, model: String, points: Vec<[f32; 2]>, } #[cfg(test)] mod tests { use super::*; #[test] fn the_shipped_database_parses_and_carries_a_default() { // The one test that must never be allowed to fail quietly: `load` // degrades to an empty database rather than panicking, so without this // a typo in the YAML would ship as "every photograph renders flat" // rather than as a build failure. let curves = Curves::parse(BUILT_IN).expect("the shipped database parses"); assert!(curves.version() >= 1); assert!(!curves.is_empty(), "the database ships bodies"); assert!( !curves.body("Nobody", "Nothing").is_identity(), "an unknown body must still get the default rendering" ); } #[test] fn every_shipped_curve_lifts_the_midtones_and_rolls_the_highlights() { // What makes a base curve a base curve rather than a decoration. If a // shipped curve failed either half it would be a worse rendering than // the flat one it replaced, which is the one outcome forbidden. let curves = Curves::parse(BUILT_IN).expect("parses"); let all = curves .bodies .iter() .map(|b| (format!("{} {}", b.make, b.model), b.curve)) .chain(curves.default.map(|c| ("default".to_string(), c))); for (name, curve) in all { // The midtone point sits above the diagonal: a linear midtone is // roughly a stop and a half darker than any camera renders it. let mid = 2; assert!( curve.ys[mid] > curve.xs[mid], "{name} does not lift its midtones ({} -> {})", curve.xs[mid], curve.ys[mid] ); // And the last span is shallower than the one before it, which is // what a shoulder *is*. Without one the curve clips highlights // harder than the linear rendering did. let slope = |i: usize| (curve.ys[i + 1] - curve.ys[i]) / (curve.xs[i + 1] - curve.xs[i]); assert!( slope(POINTS - 2) < slope(POINTS - 3), "{name} has no highlight shoulder" ); } } #[test] fn a_curve_that_is_not_monotone_is_refused() { // The profile file is user-editable, so this is a real boundary and // not a formality. A decreasing y inverts tones locally and shows up // as a dark halo in a gradient, which reads as a rendering fault // rather than as a bad profile. assert_eq!( BaseCurve::from_points(&[[0.0, 0.0], [0.25, 0.4], [0.5, 0.3], [0.75, 0.8], [1.0, 1.0]]), None ); } #[test] fn a_curve_whose_x_does_not_advance_is_refused() { // The spline divides by the span width; a repeated x is a division by // zero in the shader, which is a NaN pixel rather than an error. assert_eq!( BaseCurve::from_points(&[ [0.0, 0.0], [0.25, 0.3], [0.25, 0.5], [0.75, 0.8], [1.0, 1.0] ]), None ); } #[test] fn a_curve_of_the_wrong_length_is_refused() { assert_eq!(BaseCurve::from_points(&[[0.0, 0.0], [1.0, 1.0]]), None); } #[test] fn values_outside_the_unit_square_are_refused() { // The shader clamps its output at the very end anyway, but a control // point above 1.0 would put the shoulder outside the range the curve // is defined over and silently flatten everything below it. assert_eq!( BaseCurve::from_points(&[[0.0, 0.0], [0.25, 0.3], [0.5, 1.4], [0.75, 1.5], [1.0, 1.6]]), None ); } #[test] fn a_body_with_its_own_entry_beats_the_default() { let curves = Curves::parse( "version: 2 default: points: [[0.0, 0.0], [0.25, 0.3], [0.5, 0.6], [0.75, 0.85], [1.0, 1.0]] bodies: - make: Canon model: EOS 6D points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]] ", ) .expect("parses"); assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35); assert_eq!(curves.body("Canon", "EOS 5D").ys[1], 0.30); } #[test] fn the_make_may_be_repeated_in_the_model() { // Canon writes "Canon" as the make and "Canon EOS 6D" as the model; // rawler's cleaned strings drop the repetition and both reach here. // One entry has to cover both or half the files on a card miss. let curves = Curves::parse( "version: 1 bodies: - make: Canon model: EOS 6D points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]] ", ) .expect("parses"); assert_eq!(curves.body("Canon", "Canon EOS 6D").ys[1], 0.35); assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35); assert_eq!(curves.body("CANON", "eos 6d").ys[1], 0.35); } #[test] fn a_corporate_suffix_does_not_hide_a_body() { // The same Z 6 arrives as "Nikon"/"Z 6" from rawler's camera database // and as "NIKON CORPORATION"/"NIKON Z 6" from a DNG converted out of // the same file. Both must find the entry, or converting a file to // DNG would silently change how it renders. let curves = Curves::parse( "version: 1 bodies: - make: Nikon model: Z 6 points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]] ", ) .expect("parses"); assert_eq!(curves.body("Nikon", "Z 6").ys[1], 0.35); assert_eq!(curves.body("NIKON CORPORATION", "NIKON Z 6").ys[1], 0.35); } #[test] fn punctuation_and_spacing_do_not_decide_whether_a_body_is_known() { let curves = Curves::parse( "version: 1 bodies: - make: Sony model: ILCE-7M3 points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]] ", ) .expect("parses"); assert_eq!(curves.body("SONY", "ILCE 7M3").ys[1], 0.35); assert_eq!(curves.body("sony", "ilce-7m3").ys[1], 0.35); } #[test] fn one_bad_entry_does_not_cost_the_rest() { // A user-contributed file with one typo should cost that body's // rendering, not every body's. let curves = Curves::parse( "version: 1 bodies: - make: Broken model: Body points: [[0.0, 0.0], [0.25, 0.9], [0.5, 0.1], [0.75, 0.9], [1.0, 1.0]] - make: Canon model: EOS 6D points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]] ", ) .expect("parses"); assert_eq!(curves.len(), 1); assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35); assert!(curves.body("Broken", "Body").is_identity()); } #[test] fn a_pack_from_the_future_still_delivers_its_curves() { // The database is versioned independently of the binary, so a pack // published after this build may carry keys this build has never heard // of. It must still hand over the curves it does understand — failing // the parse would leave every body flat, which is the exact failure // FR-DEV-3e exists to prevent, delivered by the mechanism meant to // prevent it. let curves = Curves::parse( "version: 9 look_table: ambitious bodies: - make: Canon model: EOS 6D hue_twist: [1, 2, 3] points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]] ", ) .expect("an unfamiliar key must not fail the parse"); assert_eq!(curves.version(), 9); assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35); } #[test] fn an_unknown_body_with_no_default_gets_the_identity() { // Graceful fallback, stated as a property: never worse than a flat // render, and never a curve tuned for somebody else's sensor when the // database declines to offer one. let curves = Curves::parse("version: 1\nbodies: []\n").expect("parses"); assert!(curves.body("Nobody", "Nothing").is_identity()); } /// A directory holding one `base_curves.yaml`, unique to the caller. fn a_pack_dir(name: &str, yaml: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("darkroom-base-curves-{name}")); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("a writable temp directory"); std::fs::write(dir.join("base_curves.yaml"), yaml).expect("write"); dir } const A_CANON_ENTRY: &str = "bodies: - make: Canon model: EOS 6D points: [[0.0, 0.0], [0.25, 0.42], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]] "; #[test] fn a_newer_pack_on_disk_supersedes_the_built_in_database() { // **This is the requirement.** FR-DEV-3e asks for a profile database // versioned independently of the app binary "so bodies and curves can // be added without a release". A file with a higher version, dropped // in the profile directory, is what that means in practice. let built_in = Curves::parse(BUILT_IN).expect("parses"); let newer = format!("version: {}\n{A_CANON_ENTRY}", built_in.version() + 1); let dir = a_pack_dir("newer", &newer); let chosen = choose(built_in.clone(), &[dir]); assert_eq!(chosen.version(), built_in.version() + 1); assert_eq!(chosen.body("Canon", "EOS 6D").ys[1], 0.42); } #[test] fn a_stale_pack_does_not_survive_an_upgrade() { // The other direction, and the one that protects the user. Somebody // downloads a pack, a release later ships better curves for the same // bodies, and the forgotten file must not quietly hold the application // back at last year's rendering. let built_in = Curves::parse(BUILT_IN).expect("parses"); let stale = format!("version: {}\n{A_CANON_ENTRY}", built_in.version()); let dir = a_pack_dir("stale", &stale); let chosen = choose(built_in.clone(), &[dir]); assert_eq!(chosen.version(), built_in.version()); assert_ne!( chosen.body("Canon", "EOS 6D").ys[1], 0.42, "an equal version must not displace the built-in database" ); } #[test] fn a_broken_pack_costs_the_curves_and_not_the_photographs() { // A malformed profile file must degrade to the built-in database, not // to an error. The user came here to look at a photograph. let built_in = Curves::parse(BUILT_IN).expect("parses"); let dir = a_pack_dir("broken", "version: [this is not a number\n"); let chosen = choose(built_in.clone(), &[dir]); assert_eq!(chosen.version(), built_in.version()); assert_eq!(chosen.len(), built_in.len()); } #[test] fn a_directory_with_no_pack_in_it_is_simply_skipped() { // The ordinary case on every machine: the search path exists, the file // does not. It must not be a warning, an error, or a slow path. let built_in = Curves::parse(BUILT_IN).expect("parses"); let missing = std::env::temp_dir().join("darkroom-base-curves-nothing-here"); let _ = std::fs::remove_dir_all(&missing); assert_eq!(choose(built_in.clone(), &[missing]), built_in); } #[test] fn the_identity_is_recognised_as_doing_nothing() { assert!(BaseCurve::IDENTITY.is_identity()); assert!(!Curves::parse(BUILT_IN) .expect("parses") .body("Canon", "EOS 6D") .is_identity()); } }