From 146cb7b975fa0b542018e20924448e6a9beda226 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:57:03 +0000 Subject: [PATCH 01/12] LR-MERGE-HDR: the merge no longer depends on the order the frames come in The same bracket merged in another order gave a different result: on real ARW brackets, the app's selection order and the CLI's file order differed on 0.6 % of the pixels (up to 102 levels), while the same order gave byte-identical files. Alignment seeds were the frames' input positions, and sums, deghost masks and brightness ties followed them too. merge_hdr now puts the frames in brightness order first (ties by median, then a content fingerprint, never by position), runs everything in that order, and returns the per-frame results (EV, alignment, reference index) in the caller's order. Test: every permutation of a bracket gives the same radiance and deghost overlay bit for bit, and the same per-frame results (fails before: "the radiance differs"). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS --- crates/merge/src/hdr.rs | 47 ++++++++++++++++++++++++++++++++------- crates/merge/tests/hdr.rs | 25 +++++++++++++++++++++ 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/crates/merge/src/hdr.rs b/crates/merge/src/hdr.rs index 7c596c4a7..aff017892 100644 --- a/crates/merge/src/hdr.rs +++ b/crates/merge/src/hdr.rs @@ -1,8 +1,9 @@ //! HDR merge of exposure brackets. //! //! 1. **Order and reference.** Frames are ordered by brightness (EXIF exposure, or median -//! luminance without EXIF); the middle one is the reference (geometry, colour and the output's -//! default brightness come from it). +//! luminance without EXIF; ties by content); the middle one is the reference (geometry, colour +//! and the output's default brightness come from it). Everything below runs in that order, so +//! the result is the same whatever order the frames come in. //! 2. **Auto align** ([`crate::align`]): each frame is registered to the reference and resampled //! into its geometry. //! 3. **Exposure normalisation.** The exposure ratio between brightness-adjacent frames is @@ -125,9 +126,25 @@ pub fn merge_hdr(mut frames: Vec, opts: &HdrOptions, progress: &Progress) Some(e) if e.iter().any(|v| (v - e[0]).abs() > 0.1) => e.clone(), _ => medians.iter().map(|m| (*m as f64).log2()).collect(), }; - let mut order: Vec = (0..n).collect(); - order.sort_by(|&a, &b| brightness[a].total_cmp(&brightness[b]).then(a.cmp(&b))); - let reference = order[n / 2]; + // ties (equal brightness) by content, never by position + let fingerprint: Vec = + frames.par_iter().map(|f| f.image.data.iter().step_by(997).map(|p| p[0] as f64 + 2.0 * p[1] as f64 + 3.0 * p[2] as f64).sum()).collect(); + let mut input: Vec = (0..n).collect(); + input.sort_by(|&a, &b| { + brightness[a].total_cmp(&brightness[b]).then(medians[a].total_cmp(&medians[b])).then(fingerprint[a].total_cmp(&fingerprint[b])) + }); + // From here on frames are in brightness order (`input[p]` is the caller's index of position `p`): alignment seeds, + // sums and deghost masks no longer depend on the order the caller passed. Per-frame results go back at the end. + let mut slots: Vec> = frames.into_iter().map(Some).collect(); + let mut frames: Vec = input.iter().filter_map(|&i| slots.get_mut(i).and_then(Option::take)).collect(); + if frames.len() != n { + return Err(MergeError::Mismatch("HDR merge lost a frame while ordering them".into())); + } + let exif: Option> = exif.map(|e| input.iter().map(|&i| e[i]).collect()); + let medians: Vec = input.iter().map(|&i| medians[i]).collect(); + let brightness: Vec = input.iter().map(|&i| brightness[i]).collect(); + let order: Vec = (0..n).collect(); + let reference = n / 2; // 2. alignment let mut alignments = vec![Alignment::identity(); n]; @@ -264,14 +281,28 @@ pub fn merge_hdr(mut frames: Vec, opts: &HdrOptions, progress: &Progress) } None => Plane::new(0, 0), }; + // per-frame results in the caller's order + let mut ev_in = vec![0.0; n]; + let mut alignments_in = vec![Alignment::identity(); n]; + for (p, &i) in input.iter().enumerate() { + ev_in[i] = ev[p]; + alignments_in[i] = alignments[p].clone(); + } + let ev_exif = ev_exif.map(|e: Vec| { + let mut v = vec![0.0; n]; + for (p, &i) in input.iter().enumerate() { + v[i] = e[p]; + } + v + }); let rf = &frames[reference]; Ok(HdrResult { radiance: out, ghost, - reference, - ev, + reference: input[reference], + ev: ev_in, ev_exif, - alignments, + alignments: alignments_in, color: rf.color.clone(), raw: rf.raw, orientation: rf.orientation, diff --git a/crates/merge/tests/hdr.rs b/crates/merge/tests/hdr.rs index 68b9ba4b9..abfc471ec 100644 --- a/crates/merge/tests/hdr.rs +++ b/crates/merge/tests/hdr.rs @@ -125,6 +125,31 @@ fn bracket_merge_recovers_radiance_and_alignment() { assert!(rmed < 0.05, "recovered highlights: median log error {rmed}"); } +/// The result doesn't depend on the order the frames come in: every permutation of a bracket gives the same radiance and +/// deghost overlay, bit for bit, and the same per-frame results for each frame. (Real brackets merged in the app's +/// selection order and in the CLI's file order differed on 0.6 % of their pixels: alignment seeds followed the input +/// positions.) +#[test] +fn merge_does_not_depend_on_the_frame_order() { + let t = truth(); + let shifts = [(3.25f32, -2.0f32), (0.0, 0.0), (-4.0, 1.5)]; + let ks = [0.25f32, 1.0, 4.0]; + let shot: Vec = (0..3).map(|i| shoot(&t, ks[i], shifts[i].0, shifts[i].1, i as u64 + 1)).collect(); + let opts = HdrOptions { align: true, deghost: Deghost::Medium }; + let base = merge_hdr(shot.clone(), &opts, &no_progress).unwrap(); + assert_eq!(base.reference, 1); + for perm in [[2usize, 0, 1], [1, 2, 0], [0, 2, 1]] { + let r = merge_hdr(perm.iter().map(|&i| shot[i].clone()).collect(), &opts, &no_progress).unwrap(); + assert!(r.radiance.data == base.radiance.data, "{perm:?}: the radiance differs"); + assert!(r.ghost.data == base.ghost.data, "{perm:?}: the deghost overlay differs"); + assert_eq!(perm[r.reference], base.reference, "{perm:?}"); + for (p, &i) in perm.iter().enumerate() { + assert_eq!(r.ev[p], base.ev[i], "{perm:?}"); + assert_eq!(r.alignments[p].h, base.alignments[i].h, "{perm:?}"); + } + } +} + #[test] fn deghost_removes_a_moving_object() { let t = truth(); From 951d52788ac6c1ee6e86ca77ffaf0ec304cfb2a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 05:22:19 +0000 Subject: [PATCH 02/12] LR-MERGE-HDR: pixels next to clipped ones don't tint HDR merges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured against the darkest frame on real ARW brackets, each frame is linear to 1 % up to the clip far from clipped pixels, but within 2 px of them the +2 EV frame's green reads 3–5 % low: demosaicing interpolates it from clipped photosites. That frame carries the most weight (k = 4) and those pixels were unclipped themselves, so they kept it, and bright windows took a magenta tinge. A frame's weight now goes by its brightest channel within 1 px (3 × 3; wider margins changed nothing more). On two real interior brackets (ILCE-7M3, 0 / −2 / +2 EV), bright pixels whose green against red and blue is over 10 % below the darkest frame's, in camera RGB: dining room 0.32 % → 0.08 %, kitchen 2.86 % → 0.28 %. Rendered at −3 EV with main's colour tags (#1123), window pixels with G < 0.85·min(R, B) go 50 → 3 and 16 → 10 (the darkest frame alone: 2 and 205); most of the visible magenta those brackets showed before came from the missing colour tags. Test: a bracket whose +2 EV frame has 5 % less green next to its clipped pixels merges without the deficit (fails before: −1.96 %). Docs: docs/merge.md, the LR-MERGE-HDR row, docs/whats-new.md (also for the frame-order fix). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS --- crates/merge/src/hdr.rs | 39 ++++++++++++++++++++++++++++++++++----- crates/merge/tests/hdr.rs | 38 ++++++++++++++++++++++++++++++++++++++ docs/merge.md | 5 ++++- docs/parity-checklist.md | 2 +- docs/whats-new.md | 7 +++++++ 5 files changed, 84 insertions(+), 7 deletions(-) diff --git a/crates/merge/src/hdr.rs b/crates/merge/src/hdr.rs index aff017892..fbb24cad7 100644 --- a/crates/merge/src/hdr.rs +++ b/crates/merge/src/hdr.rs @@ -10,10 +10,11 @@ //! measured robustly — the median of log ratios over pixels well exposed in both — and chained //! to the reference; EXIF is the fallback when too few pixels overlap. Radiance //! `Rᵢ = vᵢ / kᵢ` is expressed in reference units. -//! 4. **Weights.** `wᵢ = kᵢ · (1 − smoothstep(0.80·clip, 0.96·clip, max channel))`: longer -//! exposures get more weight (shot-noise optimal for a linear sensor), saturated pixels none -//! (all channels together, so a clipped channel never shifts colour). The darkest frame keeps a -//! tiny weight so fully saturated pixels still get a value. +//! 4. **Weights.** `wᵢ = kᵢ · (1 − smoothstep(0.80·clip, 0.96·clip, max channel within 1 px))`: +//! longer exposures get more weight (shot-noise optimal for a linear sensor), saturated pixels +//! none (all channels together, so a clipped channel never shifts colour), nor their neighbours, +//! which demosaicing interpolated from clipped photosites. The darkest frame keeps a tiny weight +//! so fully saturated pixels still get a value. //! 5. **Deghost** (None/Low/Medium/High): on a ≤ 1024 px version, a per-pixel consistency test //! against the reference: the reference radiance (or, where the reference is clipped/too dark, //! the nearest well-exposed frame's) predicts what each frame should have recorded; frames that @@ -221,6 +222,8 @@ pub fn merge_hdr(mut frames: Vec, opts: &HdrOptions, progress: &Progress) let darkest = order[0]; let clips: Vec = frames.iter().map(|f| f.clip).collect(); + // how close each pixel of each frame is to saturation: its brightest channel within 1 px (see `saturation`) + let sat: Vec> = frames.par_iter().map(|f| saturation(&f.image)).collect(); let mut out = Rgb32f::new(w, h); let fr = &frames; let vd = &valid; @@ -235,7 +238,7 @@ pub fn merge_hdr(mut frames: Vec, opts: &HdrOptions, progress: &Progress) continue; } let v = fr[i].image.data[i_px]; - let m = maxc(v); + let m = sat[i][i_px]; let mut wt = k[i] * (1.0 - smoothstep(0.80 * clips[i], 0.96 * clips[i], m)); if i == darkest { wt += 1e-6; @@ -478,3 +481,29 @@ fn dilate(p: &Plane, r: usize) -> Plane { } out } + +/// A frame's saturation per pixel: the brightest channel within 1 px (3 × 3). A demosaiced pixel next to clipped +/// photosites is interpolated from them and reads low in the channel that clipped (green, in camera RGB), so it loses +/// its weight with them. On real ARW brackets the brightest frame's green was 3–5 % low within 2 px of clipped pixels +/// (and linear to 1 % elsewhere), and as that frame carries the most weight, bright windows got magenta fringes. +fn saturation(img: &Rgb32f) -> Vec { + let (w, h) = (img.width, img.height); + let m: Vec = img.data.iter().map(|p| maxc(*p)).collect(); + if w == 0 || h == 0 { + return m; + } + let mut rows = vec![0f32; w * h]; + rows.par_chunks_mut(w).zip(m.par_chunks(w)).for_each(|(out, src)| { + for (x, o) in out.iter_mut().enumerate() { + *o = src[x.saturating_sub(1)..=(x + 1).min(w - 1)].iter().copied().fold(0.0, f32::max); + } + }); + let mut out = vec![0f32; w * h]; + out.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let (a, b) = (y.saturating_sub(1), (y + 1).min(h - 1)); + for (x, v) in row.iter_mut().enumerate() { + *v = (a..=b).map(|yy| rows[yy * w + x]).fold(0.0, f32::max); + } + }); + out +} diff --git a/crates/merge/tests/hdr.rs b/crates/merge/tests/hdr.rs index abfc471ec..27b395824 100644 --- a/crates/merge/tests/hdr.rs +++ b/crates/merge/tests/hdr.rs @@ -150,6 +150,44 @@ fn merge_does_not_depend_on_the_frame_order() { } } +/// Pixels demosaiced next to clipped photosites read low in the clipped channel (real ARW brackets: the brightest +/// frame's green 3–5 % low within 2 px of clipped pixels, linear to 1 % elsewhere); the merge must not take them from +/// that frame, or bright windows get magenta fringes. Here the +2 EV frame's green is 5 % low around its clipped areas. +#[test] +fn pixels_next_to_clipped_ones_do_not_tint_the_merge() { + let t = truth(); + let ks = [0.25f32, 1.0, 4.0]; + let mut frames: Vec = (0..3).map(|i| shoot(&t, ks[i], 0.0, 0.0, i as u64 + 1)).collect(); + let (w, h) = (frames[2].image.width, frames[2].image.height); + let clipped = |img: &Rgb32f, x: usize, y: usize| img.data[y * w + x].iter().any(|&v| v >= 1.0); + let mut ring = vec![false; w * h]; + for y in 1..h - 1 { + for x in 1..w - 1 { + let img = &frames[2].image; + ring[y * w + x] = !clipped(img, x, y) && (y - 1..=y + 1).any(|yy| (x - 1..=x + 1).any(|xx| clipped(img, xx, yy))); + } + } + for (p, &r) in frames[2].image.data.iter_mut().zip(&ring) { + if r { + p[1] *= 0.95; + } + } + let r = merge_hdr(frames, &HdrOptions { align: false, deghost: Deghost::None }, &no_progress).unwrap(); + let ratio = |p: [f32; 3]| p[1] / (p[0] + p[2]); + let mut devs = Vec::new(); + for y in 1..h - 1 { + for x in 1..w - 1 { + let want = t.get(x + M, y + M); + if ring[y * w + x] && want[0] > 0.01 && want[2] > 0.01 { + devs.push(ratio(r.radiance.get(x, y)) / ratio(want) - 1.0); + } + } + } + assert!(devs.len() > 500, "ring pixels: {}", devs.len()); + let med = pct(devs, 0.5); + assert!(med.abs() < 0.01, "green against red and blue on the ring: {:+.2} %", 100.0 * med); +} + #[test] fn deghost_removes_a_moving_object() { let t = truth(); diff --git a/docs/merge.md b/docs/merge.md index e78c356fb..abd58b695 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -25,7 +25,10 @@ reference. Auto Align: features on exposure-normalised luminance, RANSAC transla homography (the simplest model that explains ≥ 97 % of the matches). Exposure ratios are measured (median log ratio of well-exposed pixels between brightness neighbours), EXIF is the fallback. Radiance is a weighted mean in linear light, weight `k · (1 − smoothstep(0.8·clip, 0.96·clip, max -channel))`. Deghost: a per-pixel consistency test against the reference radiance (with a noise +channel within 1 px))`: a pixel next to clipped photosites was demosaiced from them and reads low in the clipped +channel, so it goes with them (on real ARW brackets the brightest frame's green was 3–5 % low there, and bright +windows took a magenta tinge). The frames are taken in brightness order (ties by content), so the result doesn't +depend on the order they come in. Deghost: a per-pixel consistency test against the reference radiance (with a noise floor), dilated and feathered masks; the union is the deghost overlay. Output: 16-bit float DNG. **Panorama** (`pano/`): MOPS-style features, pairwise RANSAC homographies with Brown & Lowe's diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 0ab638f35..b57958ed4 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -346,7 +346,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear; raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965 | +| LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear; the same result whatever order the frames come in (`merge_does_not_depend_on_the_frame_order`); pixels next to clipped ones lose their weight with them (`pixels_next_to_clipped_ones_do_not_tint_the_merge`); raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965 | | LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG; no lens model / 360° wrap | | LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF | | LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast` | no dialog; the options of the last merge of that kind (defaults the first time) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 81d9ad7fc..6585271ad 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,13 @@ ## 2026-10-13 +### Cleaner HDR merges +- Bright windows in an HDR made with Photo ▸ Photo Merge ▸ HDR no longer take a magenta tinge from the brighter + photos along the areas they clipped: right next to a clipped area, a photo's colour isn't used. It showed most when + the highlights were darkened or saturated. +- The same photos give the same HDR whatever order they are selected in; before, a different order could change a + few pixels. + ### Slider drags on large raws - Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's From ac84cd31ee9475f75107c3aa5f30700e0332e198 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:14:24 +0200 Subject: [PATCH 03/12] LR-VIEW-FOCUSPOINT: show the camera's focus point (Sony) and zoom to it (#1480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read the autofocus point from Sony maker notes (FocusLocation 0x2027, FocusFrameSize 0x2037, FocusMode 0x201b, AFAreaModeSetting 0x201c, AFTracking 0x2021 and the area mode used from the enciphered 0x9402 block), for the ILCE-1 and ILCE-7SM3, whose layout was checked against ExifTool's output on 333 real ARWs. Manual focus, centre-only Multi reports and unchecked bodies give no point. The box is stored on the upright photo as Meta.focus (omitted when unknown; no catalog format change), filled at import and by Reload from Disk. View > Show Focus Point (Alt+F) draws it in Detail, Compare and Survey through rotation and crop; View > Zoom to Focus Point (on by default) makes Toggle Zoom centre on it; the Info panel shows "AF-C · Wide · Animal Eye Tracking". photo.inspect returns meta.focus. --- crates/catalog/src/model.rs | 5 + crates/catalog/src/tests.rs | 22 + crates/engine/src/cmd/convert.rs | 25 ++ crates/engine/src/demo.rs | 10 + crates/engine/src/files.rs | 1 + crates/meta/src/exif.rs | 8 +- crates/meta/src/focus.rs | 559 ++++++++++++++++++++++++ crates/meta/src/lib.rs | 8 + crates/meta/src/xmp.rs | 1 + crates/ui-egui/locales/de.json | 20 +- crates/ui-egui/locales/es.json | 20 +- crates/ui-egui/locales/fr.json | 20 +- crates/ui-egui/locales/ja.json | 20 +- crates/ui-egui/locales/pt-br.json | 20 +- crates/ui-egui/locales/ru.json | 20 +- crates/ui-egui/locales/uk.json | 20 +- crates/ui-egui/locales/zh-hans.json | 20 +- crates/ui-egui/locales/zh-hant.json | 20 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menubar.rs | 4 + crates/ui-egui/src/menus.rs | 25 +- crates/ui-egui/src/panels/compare.rs | 3 + crates/ui-egui/src/panels/detail.rs | 60 +++ crates/ui-egui/src/panels/right.rs | 19 + crates/ui-egui/src/state.rs | 7 + crates/ui-egui/src/tests_focus_point.rs | 158 +++++++ docs/control-protocol.md | 5 + docs/focus-points.md | 79 ++++ docs/mcp.md | 2 +- docs/parity-checklist.md | 12 +- docs/whats-new.md | 10 + 31 files changed, 1186 insertions(+), 19 deletions(-) create mode 100644 crates/meta/src/focus.rs create mode 100644 crates/ui-egui/src/tests_focus_point.rs create mode 100644 docs/focus-points.md diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 434ea3a1e..f7b832e3a 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -199,6 +199,11 @@ pub struct Meta { /// Left out of the catalog JSON when empty (most photos), so older catalogs read unchanged. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub regions: Vec, + /// Where the camera focused (read from the file's maker notes at import or Reload from Disk; Sony + /// bodies whose files have been checked), on the upright (EXIF-oriented) photo. Read-only: never + /// written to XMP. Left out of the catalog JSON when unknown, so older catalogs read unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focus: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index acfbb1bb1..42dc515f1 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -391,6 +391,28 @@ fn empty_regions_are_not_serialized_and_default_when_missing() { assert_eq!(back, with); } +/// The camera's focus point is optional camera data: absent from the catalog JSON when unknown (so a +/// catalog from before it existed reads with none, and writes the same), kept exactly when known. +#[test] +fn focus_point_is_not_serialized_when_unknown_and_round_trips() { + let m = Meta { camera: "SONY ILCE-1".into(), ..Default::default() }; + let v = serde_json::to_value(&m).unwrap(); + assert!(v.get("focus").is_none(), "{v}"); + let back: Meta = serde_json::from_value(v).unwrap(); + assert!(back.focus.is_none()); + let mut with = m.clone(); + with.focus = Some(lightcraft_meta::FocusPoint { + rect: lightcraft_meta::Rect { x0: 0.36, y0: 0.33, x1: 0.38, y1: 0.36 }, + mode: Some("AF-C".into()), + area: Some("Wide".into()), + tracking: None, + }); + let json = serde_json::to_string(&with).unwrap(); + assert!(json.contains("\"focus\"") && !json.contains("tracking"), "{json}"); + let back: Meta = serde_json::from_str(&json).unwrap(); + assert_eq!(back, with); +} + // ---- random sort: Given a seed, the order is a stable, reproducible shuffle of the matching photos fn many(c: &mut Catalog, n: usize) -> Vec { diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 4d7f13aeb..53c6a10f9 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -234,6 +234,10 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P m.aperture = m.aperture.or(src.aperture); m.iso = m.iso.or(src.iso); m.gps = m.gps.or(src.gps); + // the camera's focus point: photos imported before it was read get it here + if m.focus.is_none() { + m.focus = src.focus.clone(); + } let mut ops = Vec::new(); if m != before { ops.push(Op::SetMeta { id, meta: Box::new(m) }); @@ -421,6 +425,27 @@ mod tests { assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty()); } + /// Reload from Disk gives a photo imported before focus points were read its camera focus point, + /// and never replaces one it has. + #[test] + fn reload_fills_a_missing_focus_point() { + let fp = |x: f64| lightcraft_meta::FocusPoint { + rect: lightcraft_meta::Rect { x0: x, y0: 0.4, x1: x + 0.02, y1: 0.42 }, + mode: Some("AF-C".into()), + area: None, + tracking: None, + }; + let mut ph = Photo::new(PhotoId(1), Source::File { path: "/x.arw".into() }, "x.arw", "ARW", 8640, 5760, "2026-10-06T00:00:00"); + ph.captured = Some("2026-10-04T08:16:11".into()); + let mut info = crate::media::ProbeInfo { captured: ph.captured.clone(), ..Default::default() }; + info.meta.focus = Some(fp(0.3)); + let ops = fill_missing_meta(PhotoId(1), &ph, &info); + let Some(Op::SetMeta { meta, .. }) = ops.first() else { panic!("{ops:?}") }; + assert_eq!(meta.focus, Some(fp(0.3))); + ph.meta.focus = Some(fp(0.1)); + assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty(), "a known focus point is kept"); + } + fn lens() -> lightcraft_develop::EmbeddedLens { lightcraft_develop::EmbeddedLens { warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }), diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 6a89dda88..ab5976115 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -30,6 +30,16 @@ pub fn load(s: &mut Session) { creator: "LightCraft Demo".into(), ..Default::default() }; + // one demo photo carries a camera focus point (as a Sony raw would), on the bloom's centre, so + // View ▸ Show Focus Point and Zoom to Focus Point can be tried without a camera file + if sc.name == "Cosmos bloom" { + p.meta.focus = Some(lightcraft_meta::FocusPoint { + rect: lightcraft_geom::Rect { x0: 0.42, y0: 0.433, x1: 0.50, y1: 0.487 }, + mode: Some("AF-S".into()), + area: Some("Flexible Spot".into()), + tracking: None, + }); + } p.rating = [0, 3, 4, 5, 2, 0, 4, 3][sc.id as usize % 8]; p.flag = match sc.id % 6 { 1 => Flag::Pick, diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index dc79b8b31..2ec364b5a 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -40,6 +40,7 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { // paths from `lr:hierarchicalSubject`, names from `dc:subject` that aren't their levels keywords: lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords), regions: m.regions.clone(), + focus: m.focus.clone(), }; (meta, m.capture_time.as_ref().map(|d| d.to_iso())) } diff --git a/crates/meta/src/exif.rs b/crates/meta/src/exif.rs index ca8d7b7aa..6e175b379 100644 --- a/crates/meta/src/exif.rs +++ b/crates/meta/src/exif.rs @@ -16,8 +16,12 @@ pub fn read_exif(bytes: &[u8]) -> Metadata { /// Like [`read_exif`] but reports a structurally invalid TIFF header / IFD0. pub fn try_read_exif(bytes: &[u8]) -> Result { - let tiff = Tiff::parse(strip_exif_header(bytes))?; - Ok(from_tiff(&tiff)) + let data = strip_exif_header(bytes); + let tiff = Tiff::parse(data)?; + let mut m = from_tiff(&tiff); + // the maker note's values are offsets into the stream, which `from_tiff` doesn't have + m.focus = crate::focus::read(data, &tiff, m.make.as_deref(), m.model.as_deref(), m.orientation); + Ok(m) } fn text(ifd: Option<&Ifd>, tag: u16) -> Option { diff --git a/crates/meta/src/focus.rs b/crates/meta/src/focus.rs new file mode 100644 index 000000000..92801f8a7 --- /dev/null +++ b/crates/meta/src/focus.rs @@ -0,0 +1,559 @@ +//! Where the camera focused: the autofocus point a camera records in its maker notes, as a box on the +//! upright photo ([`FocusPoint`]). Sony only for now. +//! +//! **Sony** (the MakerNote IFD: header-less in ARWs, after a `SONY DSC` header in JPEGs). Tag identities and value names come from the public ExifTool +//! tag-name documentation (Sony table); every layout below was confirmed on real ILCE-1 (α1) and +//! ILCE-7SM3 (A7S III) ARWs, compared with ExifTool's output as a black box. No third-party code was read. +//! +//! | Tag | Layout (confirmed) | Use | +//! |---|---|---| +//! | `0x2027` FocusLocation | 4 × int16u: the width and height of the frame it is measured in, then the focus centre x, y in pixels of that frame (the sensor's landscape frame: a portrait shot still says 8640 × 5760) | the point | +//! | `0x2037` FocusFrameSize | 6 bytes: int16u width, int16u height of the frame the camera showed, then 2 bytes that are zero when there is no frame (manual focus) | the box size | +//! | `0x201b` FocusMode | int8u: 0 Manual, 2 AF-S, 3 AF-C, 4 AF-A, 6 DMF, 7 AF-D | manual = no point | +//! | `0x201c` AFAreaModeSetting | int8u: 0 Wide, 1 Center, 3 Flexible Spot, 11 Zone, 12 Expanded Flexible Spot, 13 Custom AF Area | shown | +//! | `0x2021` AFTracking | int8u: 0 Off, 1 Face tracking, 2 Lock On AF | shown | +//! | `0x9402` (enciphered block) byte 0x16, 0x17 | FocusMode (low 7 bits) and the AF area mode actually used: 0 Multi, 1 Center, 2 Spot, 3 Flexible Spot, 11 Zone, 12 Expanded Flexible Spot, 13 Custom AF Area, 14 Tracking, 15 Face Tracking, 20 Animal Eye Tracking, 21 Human Eye Tracking, 255 Manual | shown; read only when its focus mode agrees with `0x201b` | +//! +//! The `0x94xx` blocks are enciphered byte by byte (the documentation calls it "a simple substitution +//! cipher"). On the sample files each plain byte `p < 249` is stored as `p³ mod 249` and bytes 249–255 +//! as themselves (e.g. a stored `0x20` reads as AFAreaMode 20, stored `0x77` as `0x83`, FocusMode AF-C +//! with its top bit set); the map is a bijection on 0..249 (cubing is invertible mod 3 and mod 83), so +//! it is undone by a table of its inverse. +//! +//! **No real point** (nothing is stored, so nothing is drawn and Z zooms as before): +//! - a body the layout hasn't been confirmed on ([`SONY_MODELS`]), a missing or malformed tag, a zero +//! frame size, or a centre outside its frame; +//! - manual focus (`0x201b` = 0, or the block's area mode says Manual): the camera still records where +//! the AF frame was parked, not where anything was in focus (on the samples these are off-centre); +//! - the exact frame centre while the area used was Multi (Wide with nothing tracked): on the samples +//! the wide area reports the centre when it picked no particular spot. A centred Spot, Zone or +//! Flexible Spot frame is a real choice and is kept. + +use crate::{Orientation, Rect}; +use lightcraft_tiff::{ByteOrder, Ifd, Tiff, makernote, tags as t}; +use serde::{Deserialize, Serialize}; + +/// Where the camera focused, on the upright photo. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FocusPoint { + /// The focus frame, normalized to the photo's full, EXIF-oriented frame (y down, 0..1 on both + /// axes, clipped to it) — the frame face regions use, so it follows Rotate, flips and crops. + pub rect: Rect, + /// Focus mode as the camera names it (`AF-C`, `AF-S`, `DMF`…). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + /// The AF area chosen on the camera (`Wide`, `Zone`, `Flexible Spot`…). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub area: Option, + /// What the camera followed, when it says (`Animal Eye Tracking`, `Face tracking`, `Lock On AF`…). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tracking: Option, +} + +impl FocusPoint { + /// The box's centre (normalized, upright). + pub fn center(&self) -> (f64, f64) { + ((self.rect.x0 + self.rect.x1) / 2.0, (self.rect.y0 + self.rect.y1) / 2.0) + } + /// The words that describe it, in order (mode, area, tracking), for "AF-C · Wide · Animal Eye Tracking". + pub fn words(&self) -> Vec<&str> { + [&self.mode, &self.area, &self.tracking].into_iter().filter_map(|s| s.as_deref()).filter(|s| !s.is_empty()).collect() + } + /// The box clipped to the frame and checked: `None` when it is not a usable box (non-finite, + /// empty, or entirely outside the photo). + pub fn sanitized(mut self) -> Option { + let r = self.rect; + if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) { + return None; + } + let (x0, x1) = (r.x0.min(r.x1).clamp(0.0, 1.0), r.x0.max(r.x1).clamp(0.0, 1.0)); + let (y0, y1) = (r.y0.min(r.y1).clamp(0.0, 1.0), r.y0.max(r.y1).clamp(0.0, 1.0)); + if x1 - x0 <= 0.0 || y1 - y0 <= 0.0 { + return None; + } + self.rect = Rect { x0, y0, x1, y1 }; + Some(self) + } +} + +/// Sony bodies whose focus tags have been checked against real files. Another body may store the same +/// tags differently (or not mean the same by them): it shows no focus point until its files are checked. +pub const SONY_MODELS: &[&str] = &["ILCE-1", "ILCE-7SM3"]; + +const FOCUS_MODE: u16 = 0x201b; +const AF_AREA_SETTING: u16 = 0x201c; +const AF_TRACKING: u16 = 0x2021; +const FOCUS_LOCATION: u16 = 0x2027; +const FOCUS_FRAME_SIZE: u16 = 0x2037; +const TAG_9402: u16 = 0x9402; + +/// Side of the box drawn when the camera gave a point but no frame size, as a fraction of the frame's +/// short side (about the size of the α1's smallest frames). +const DEFAULT_SIDE: f64 = 0.03; + +/// The focus point a file's maker note records, or `None` (see the module notes for when). +/// `data` is the TIFF stream the maker note's offsets refer to (a whole raw, or an Exif block). +pub fn read(data: &[u8], tiff: &Tiff, make: Option<&str>, model: Option<&str>, orientation: Option) -> Option { + let make = make?; + if !make.to_ascii_uppercase().starts_with("SONY") || !SONY_MODELS.contains(&model?.trim()) { + return None; + } + let e = tiff.exif()?.get(t::MAKER_NOTE)?; + let mn = makernote::parse_makernote(data, e.offset, e.count() as u64, tiff.order, make)?; + // ARWs from these bodies carry a header-less IFD; their JPEGs the `SONY DSC` header + if !matches!(mn.kind, makernote::MakerNoteKind::Sony | makernote::MakerNoteKind::Plain) { + return None; + } + sony(&mn.ifd, mn.order, orientation.unwrap_or_default()) +} + +fn byte(ifd: &Ifd, tag: u16) -> Option { + ifd.value(tag)?.get_u64(0).and_then(|v| u8::try_from(v).ok()) +} + +/// Decode the Sony tags of a parsed maker-note IFD. +pub(crate) fn sony(ifd: &Ifd, order: ByteOrder, orientation: Orientation) -> Option { + let loc = ifd.value(FOCUS_LOCATION)?; + let [w, h, x, y] = [0, 1, 2, 3].map(|i| loc.get_u64(i)); + let (w, h, x, y) = (w? as f64, h? as f64, x? as f64, y? as f64); + if w < 1.0 || h < 1.0 || x > w || y > h { + return None; + } + let mode = byte(ifd, FOCUS_MODE); + if mode == Some(0) { + return None; + } + // the enciphered block: its focus mode must agree with the plain one, else its layout is not the one we know + let block = ifd.bytes(TAG_9402).and_then(|b| { + let (m, area) = (decipher(*b.get(0x16)?) & 0x7f, decipher(*b.get(0x17)?)); + (Some(m) == mode).then_some(area) + }); + if block == Some(255) { + return None; + } + let setting = byte(ifd, AF_AREA_SETTING); + let tracking = byte(ifd, AF_TRACKING); + let wide_untracked = match block { + Some(a) => a == 0, + None => setting == Some(0) && tracking.is_none_or(|t| t == 0), + }; + if wide_untracked && x * 2.0 == w && y * 2.0 == h { + return None; + } + // frame size: two int16u in the note's byte order, then a non-zero marker when there is a frame + let size = ifd.bytes(FOCUS_FRAME_SIZE).and_then(|b| { + let (fw, fh) = (order.read_u16(b, 0)? as f64, order.read_u16(b, 2)? as f64); + let shown = b.get(4..6).is_none_or(|m| m.iter().any(|&v| v != 0)); + (shown && fw >= 1.0 && fh >= 1.0).then_some((fw, fh)) + }); + let (fw, fh) = size.unwrap_or_else(|| { + let side = w.min(h) * DEFAULT_SIDE; + (side, side) + }); + let sensor = Rect { x0: (x - fw / 2.0) / w, y0: (y - fh / 2.0) / h, x1: (x + fw / 2.0) / w, y1: (y + fh / 2.0) / h }; + let name = |v: Option, f: fn(u8) -> Option<&'static str>| v.and_then(f).map(str::to_string); + let area = name(setting, area_setting_name); + let used = name(block, area_used_name); + // what it followed: the area mode used when it says more than the setting (Animal Eye Tracking on + // Wide), else the tracking setting when it is on + let tracking = used.filter(|u| Some(u) != area.as_ref() && u != "Multi").or_else(|| name(tracking, tracking_name).filter(|t| t != "Off")); + FocusPoint { rect: orientation.map_norm_rect(sensor), mode: name(mode, focus_mode_name), area, tracking }.sanitized() +} + +/// Undo the `0x94xx` byte substitution: the stored byte is `p³ mod 249` for `p < 249`. +fn decipher(b: u8) -> u8 { + if b >= 249 { + return b; + } + (0..249u32).find(|p| (p * p * p) % 249 == u32::from(b)).and_then(|p| u8::try_from(p).ok()).unwrap_or(b) +} + +fn focus_mode_name(v: u8) -> Option<&'static str> { + Some(match v { + 0 => "Manual", + 2 => "AF-S", + 3 => "AF-C", + 4 => "AF-A", + 6 => "DMF", + 7 => "AF-D", + _ => return None, + }) +} + +fn area_setting_name(v: u8) -> Option<&'static str> { + Some(match v { + 0 => "Wide", + 1 | 9 => "Center", + 3 | 4 => "Flexible Spot", + 11 => "Zone", + 12 => "Expanded Flexible Spot", + 13 => "Custom AF Area", + _ => return None, + }) +} + +fn tracking_name(v: u8) -> Option<&'static str> { + Some(match v { + 0 => "Off", + 1 => "Face tracking", + 2 => "Lock On AF", + _ => return None, + }) +} + +fn area_used_name(v: u8) -> Option<&'static str> { + Some(match v { + 0 => "Multi", + 1 => "Center", + 2 => "Spot", + 3 => "Flexible Spot", + 11 => "Zone", + 12 => "Expanded Flexible Spot", + 13 => "Custom AF Area", + 14 => "Tracking", + 15 => "Face Tracking", + 20 => "Animal Eye Tracking", + 21 => "Human Eye Tracking", + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_tiff::{Entry, IfdBuilder, TiffWriter, Value}; + + fn encipher(p: u8) -> u8 { + if p >= 249 { p } else { ((u32::from(p).pow(3)) % 249) as u8 } + } + + fn entry(tag: u16, value: Value) -> Entry { + Entry { tag, value, offset: 0 } + } + + /// The α1 sample `_DSC6954.ARW`: AF-C, Wide, Face tracking, Animal Eye Tracking, 8640×5760 at (3226, 1980), 175×180. + fn alpha1() -> Ifd { + let mut block = vec![0u8; 0x40]; + block[0x16] = encipher(0x83); + block[0x17] = encipher(20); + Ifd { + entries: vec![ + entry(FOCUS_MODE, Value::Byte(vec![3])), + entry(AF_AREA_SETTING, Value::Byte(vec![0])), + entry(AF_TRACKING, Value::Byte(vec![1])), + entry(FOCUS_LOCATION, Value::Short(vec![8640, 5760, 3226, 1980])), + entry(FOCUS_FRAME_SIZE, Value::Undefined(vec![0xaf, 0, 0xb4, 0, 1, 1])), + entry(TAG_9402, Value::Undefined(block)), + ], + ..Default::default() + } + } + + fn set(ifd: &mut Ifd, tag: u16, value: Value) { + ifd.entries.retain(|e| e.tag != tag); + ifd.entries.push(entry(tag, value)); + // IFD entries are kept sorted by tag (lookups rely on it) + ifd.entries.sort_by_key(|e| e.tag); + } + + #[test] + fn the_cipher_matches_the_bytes_seen_in_files() { + // stored → plain, as read from an α1 file's 0x9402 block next to ExifTool's decoded values + assert_eq!(decipher(0x20), 20); + assert_eq!(decipher(0x77), 0x83); + assert_eq!(decipher(0xff), 0xff); + for p in 0..=255u8 { + assert_eq!(decipher(encipher(p)), p); + } + } + + #[test] + fn reads_the_alpha1_sample() { + let f = sony(&alpha1(), ByteOrder::Little, Orientation::Normal).unwrap(); + assert!((f.rect.x0 - (3226.0 - 87.5) / 8640.0).abs() < 1e-12); + assert!((f.rect.y1 - (1980.0 + 90.0) / 5760.0).abs() < 1e-12); + assert_eq!(f.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + let (cx, cy) = f.center(); + assert!((cx - 3226.0 / 8640.0).abs() < 1e-12 && (cy - 1980.0 / 5760.0).abs() < 1e-12); + } + + #[test] + fn big_endian_frame_size() { + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_FRAME_SIZE, Value::Undefined(vec![0, 0xaf, 0, 0xb4, 1, 1])); + let f = sony(&ifd, ByteOrder::Big, Orientation::Normal).unwrap(); + assert!(((f.rect.x1 - f.rect.x0) * 8640.0 - 175.0).abs() < 1e-9); + } + + #[test] + fn follows_the_exif_orientation() { + // a portrait shot (Rotate 270 CW, EXIF 8) still records landscape sensor coordinates + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 864, 576])); + let f = sony(&ifd, ByteOrder::Little, Orientation::from_exif(8)).unwrap(); + let (cx, cy) = f.center(); + // left of the sensor (x 0.1) is the bottom of the upright photo; its top (y 0.1) the left + assert!((cx - 0.1).abs() < 1e-9 && (cy - 0.9).abs() < 1e-9, "{cx} {cy}"); + let f = sony(&ifd, ByteOrder::Little, Orientation::from_exif(6)).unwrap(); + let (cx, cy) = f.center(); + assert!((cx - 0.9).abs() < 1e-9 && (cy - 0.1).abs() < 1e-9, "{cx} {cy}"); + } + + #[test] + fn no_real_point() { + // manual focus + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_MODE, Value::Byte(vec![0])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // the block says Manual (its focus mode agreeing) + let mut ifd = alpha1(); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(3); + block[0x17] = encipher(255); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // Multi at the exact centre + let mut ifd = alpha1(); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(3); + block[0x17] = encipher(0); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 4320, 2880])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // …and without the block: Wide, tracking off + ifd.entries.retain(|e| e.tag != TAG_9402); + set(&mut ifd, AF_TRACKING, Value::Byte(vec![0])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // missing location, zero frame, centre outside it + let mut ifd = alpha1(); + ifd.entries.retain(|e| e.tag != FOCUS_LOCATION); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + for loc in [vec![0, 5760, 0, 0], vec![8640, 0, 1, 0], vec![8640, 5760, 9000, 10], vec![8640, 5760, 10, 6000], vec![8640, 5760, 10]] { + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_LOCATION, Value::Short(loc.clone())); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none(), "{loc:?}"); + } + } + + #[test] + fn a_centred_spot_is_a_real_point() { + let mut ifd = alpha1(); + set(&mut ifd, AF_AREA_SETTING, Value::Byte(vec![3])); + set(&mut ifd, AF_TRACKING, Value::Byte(vec![0])); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(2); + block[0x17] = encipher(3); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + set(&mut ifd, FOCUS_MODE, Value::Byte(vec![2])); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 4320, 2880])); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert_eq!(f.words(), vec!["AF-S", "Flexible Spot"]); + let (cx, cy) = f.center(); + assert!((cx - 0.5).abs() < 1e-12 && (cy - 0.5).abs() < 1e-12); + } + + #[test] + fn a_block_that_disagrees_is_ignored() { + // another body's block layout: its "focus mode" is not the plain tag's, so its area isn't trusted + let mut ifd = alpha1(); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(7); + block[0x17] = encipher(255); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert_eq!(f.words(), vec!["AF-C", "Wide", "Face tracking"]); + // a block too short to hold the bytes is ignored the same way + set(&mut ifd, TAG_9402, Value::Undefined(vec![1, 2, 3])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_some()); + } + + #[test] + fn missing_or_garbage_frame_size_gets_a_small_box_and_edges_are_clipped() { + for fs in [ + Value::Undefined(vec![0, 0, 0, 0, 0, 0]), + Value::Undefined(vec![1]), + Value::Short(vec![5]), + Value::Undefined(vec![0xaf, 0, 0xb4, 0, 0, 0]), + ] { + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_FRAME_SIZE, fs); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert!(((f.rect.y1 - f.rect.y0) - DEFAULT_SIDE).abs() < 1e-9); + } + // a frame reaching past the corner is clipped to the photo + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 0, 5760])); + set(&mut ifd, FOCUS_FRAME_SIZE, Value::Undefined(vec![0xff, 0xff, 0xff, 0xff, 1, 1])); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert_eq!(f.rect, Rect { x0: 0.0, y0: 0.0, x1: 1.0, y1: 1.0 }); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert!(f.rect.x0 >= 0.0 && f.rect.y1 <= 1.0); + } + + #[test] + fn wrong_types_and_extreme_values_never_panic() { + let values = [ + Value::Ascii("x".into()), + Value::Byte(vec![]), + Value::SShort(vec![-1, -1, -1, -1]), + Value::Long(vec![u32::MAX; 4]), + Value::Double(vec![f64::NAN; 4]), + Value::Undefined(vec![0xff; 300]), + ]; + for v in &values { + for tag in [FOCUS_MODE, AF_AREA_SETTING, AF_TRACKING, FOCUS_LOCATION, FOCUS_FRAME_SIZE, TAG_9402] { + let mut ifd = alpha1(); + set(&mut ifd, tag, v.clone()); + if let Some(f) = sony(&ifd, ByteOrder::Little, Orientation::Normal) { + assert!(f.rect.x0 >= 0.0 && f.rect.x1 <= 1.0 && f.rect.x0 < f.rect.x1); + } + } + } + assert!(FocusPoint { rect: Rect { x0: f64::NAN, y0: 0.0, x1: 1.0, y1: 1.0 }, mode: None, area: None, tracking: None }.sanitized().is_none()); + assert!(FocusPoint { rect: Rect { x0: 2.0, y0: 2.0, x1: 3.0, y1: 3.0 }, mode: None, area: None, tracking: None }.sanitized().is_none()); + } + + /// A whole TIFF with a `SONY DSC` maker note holding `ifd`'s entries, written for real. + fn sony_file(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)]) -> Vec { + let order = ByteOrder::Little; + // the note: 12-byte header, then an IFD whose out-of-line values are offset from the TIFF header, + // which we only know after writing; write once to learn where the note lands, then rebuild + let build = |note_at: u64| -> Vec { + let mut note = b"SONY DSC \0\0\0".to_vec(); + let ifd_len = 2 + note_entries.len() * 12 + 4; + let mut tail: Vec = Vec::new(); + order.put_u16(&mut note, note_entries.len() as u16); + for (tag, v) in note_entries { + let raw = lightcraft_tiff::writer::encode(order, v); + order.put_u16(&mut note, *tag); + order.put_u16(&mut note, v.field_type() as u16); + order.put_u32(&mut note, v.count() as u32); + if raw.len() <= 4 { + let mut r = raw.clone(); + r.resize(4, 0); + note.extend(r); + } else { + order.put_u32(&mut note, (note_at + 12 + ifd_len as u64 + tail.len() as u64) as u32); + tail.extend(raw); + if tail.len() % 2 == 1 { + tail.push(0); + } + } + } + order.put_u32(&mut note, 0); + note.extend(tail); + let mut exif = IfdBuilder::new(); + exif.set(t::MAKER_NOTE, Value::Undefined(note)); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + ifd0.set(t::MODEL, Value::Ascii(model.into())); + ifd0.set(t::ORIENTATION, Value::Short(vec![orientation])); + ifd0.set_child(t::EXIF_IFD, exif); + TiffWriter { order, bigtiff: false }.write(&[ifd0]).unwrap() + }; + let first = build(0); + let at = Tiff::parse(&first).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset; + let file = build(at); + assert_eq!(Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset, at); + file + } + + fn alpha1_entries() -> Vec<(u16, Value)> { + alpha1().entries.into_iter().map(|e| (e.tag, e.value)).collect() + } + + #[test] + fn read_exif_finds_it_through_the_maker_note_and_orientation() { + let file = sony_file("SONY", "ILCE-1", 8, &alpha1_entries()); + let m = crate::read_exif(&file); + let f = m.focus.expect("focus point"); + assert_eq!(f.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + // EXIF 8: sensor x becomes 1 - upright y + let (cx, cy) = f.center(); + assert!((cy - (1.0 - 3226.0 / 8640.0)).abs() < 1e-9 && (cx - 1980.0 / 5760.0).abs() < 1e-9, "{cx} {cy}"); + // an unconfirmed body or another maker: nothing + assert!(crate::read_exif(&sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries())).focus.is_none()); + assert!(crate::read_exif(&sony_file("Canon", "ILCE-1", 1, &alpha1_entries())).focus.is_none()); + } + + #[test] + fn truncated_and_garbage_maker_notes_never_panic() { + let file = sony_file("SONY", "ILCE-1", 1, &alpha1_entries()); + let note_at = Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as usize; + for cut in (0..file.len()).step_by(7) { + let _ = crate::read_exif(&file[..cut]); + } + let mut seed = 0x2545_f491_u32; + for _ in 0..300 { + let mut f = file.clone(); + for _ in 0..8 { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let i = note_at + (seed as usize % (file.len() - note_at)); + f[i] = (seed >> 8) as u8; + } + if let Some(fp) = crate::read_exif(&f).focus { + assert!(fp.rect.x0 >= 0.0 && fp.rect.x1 <= 1.0 && fp.rect.y0 >= 0.0 && fp.rect.y1 <= 1.0); + } + } + } + + /// Opt-in: decode real α1 / A7S III files and compare with the values ExifTool (run as a black box) + /// reports for them. `LIGHTCRAFT_FOCUS_SAMPLES=` is the photo folder the paths below are relative + /// to; skipped without it. No user file is part of the repository. + #[test] + fn real_sony_files_match_exiftool() { + let Ok(dir) = std::env::var("LIGHTCRAFT_FOCUS_SAMPLES") else { return }; + let dir = std::path::Path::new(&dir); + type Want = Option<([f64; 4], [f64; 2], &'static [&'static str])>; + // (file, EXIF orientation, ExifTool's FocusLocation and FocusFrameSize, the words we show; None = no point) + let cases: [(&str, u16, Want); 8] = [ + // α1, AF-C, Wide, Face tracking, AFAreaMode Animal Eye Tracking + ( + "2023/11-november/17th/_DSC6954.ARW", + 1, + Some(([8640.0, 5760.0, 3226.0, 1980.0], [175.0, 180.0], &["AF-C", "Wide", "Animal Eye Tracking"])), + ), + ( + "2023/11-november/17th/_DSC6894.ARW", + 1, + Some(([8640.0, 5760.0, 4725.0, 1860.0], [135.0, 132.0], &["AF-C", "Wide", "Animal Eye Tracking"])), + ), + // α1, manual focus (the location 4441 3060 is where the frame was parked) + ("2023/11-november/15th/_DSC6613.ARW", 1, None), + // α1 portrait, AF-S, Wide, Multi at the exact centre 4320 2880 + ("2022/01-january/9th/_DSC6079.ARW", 8, None), + // α1 APS-C crop mode portrait, AF-C, Wide, Lock On AF, AFAreaMode Tracking + ("2022/12-december/16th/_DSC1575.ARW", 8, Some(([5616.0, 3744.0, 4071.0, 1848.0], [280.0, 280.0], &["AF-C", "Wide", "Tracking"]))), + // A7S III portrait, AF-A, Wide, Face tracking, Animal Eye Tracking + ( + "2020/10-october/31st/DSC00065.ARW", + 8, + Some(([4240.0, 2832.0, 1192.0, 1675.0], [278.0, 277.0], &["AF-A", "Wide", "Animal Eye Tracking"])), + ), + // A7S III upside down, AF-C, a centred Flexible Spot: a real point + ("2021/11-november/7th/DSC05975.ARW", 3, Some(([4240.0, 2832.0, 2120.0, 1416.0], [251.0, 224.0], &["AF-C", "Flexible Spot"]))), + // A7S III, manual focus + ("2021/06-june/12th/DSC06806.ARW", 1, None), + ]; + for (name, exif_orientation, want) in cases { + let bytes = std::fs::read(dir.join(name)).unwrap_or_else(|e| panic!("{name}: {e}")); + let got = crate::extract(&bytes).focus; + match (want, got) { + (None, None) => {} + (Some((loc, size, words)), Some(f)) => { + let [w, h, x, y] = loc; + let sensor = + Rect { x0: (x - size[0] / 2.0) / w, y0: (y - size[1] / 2.0) / h, x1: (x + size[0] / 2.0) / w, y1: (y + size[1] / 2.0) / h }; + let r = Orientation::from_exif(exif_orientation).map_norm_rect(sensor); + for (a, b) in [(f.rect.x0, r.x0), (f.rect.y0, r.y0), (f.rect.x1, r.x1), (f.rect.y1, r.y1)] { + assert!((a - b).abs() < 1e-9, "{name}: {:?} vs {r:?}", f.rect); + } + assert_eq!(f.words(), words, "{name}"); + } + (w, g) => panic!("{name}: want {w:?}, got {g:?}"), + } + } + } +} diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 31a35cc65..abf6fd868 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -15,6 +15,7 @@ mod container; pub mod cr3; mod datetime; mod exif; +pub mod focus; mod gpx; mod iptc; pub mod tags; @@ -24,6 +25,7 @@ mod xmp_merge; pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks}; pub use datetime::DateTime; pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif}; +pub use focus::FocusPoint; pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx}; pub use iptc::parse_iptc; pub use lightcraft_geom::{Orientation, Rect}; @@ -142,6 +144,9 @@ pub struct Metadata { pub label: Option, /// Face/pet/focus/barcode regions (`mwg-rs:Regions`), read from XMP only. pub regions: Vec, + /// Where the camera focused, from its maker notes ([`focus`]; Sony only for now), on the upright photo. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focus: Option, } macro_rules! fill { @@ -205,6 +210,9 @@ impl Metadata { if self.regions.is_empty() { self.regions = other.regions.clone(); } + if self.focus.is_none() { + self.focus = other.focus.clone(); + } } /// Replace the user-editable fields (rating, label, title, caption, artist, copyright, keywords, GPS) with diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index 2a4d753dc..8c3b75263 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -686,6 +686,7 @@ mod tests { // write_xmp doesn't emit regions yet (read-only interchange; see docs/xmp-interop.md), so the // roundtrip fixture must leave this empty or `roundtrip_all_fields` can't round-trip it. regions: Vec::new(), + focus: None, make: Some("Maker & Sons".into()), model: Some("X <1>".into()), serial_number: Some("SN1".into()), diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..db5349830 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2118,5 +2118,23 @@ "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Kein Grafikprozessor gefunden: LightCraft rendert auf der CPU, was viel langsamer ist. Details unter Hilfe → Systeminformationen.", "On NixOS, start it from `nix develop`.": "Unter NixOS starten Sie es aus `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert." + "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert.", + "Show Focus Point": "Fokuspunkt anzeigen", + "Zoom to Focus Point": "Auf Fokuspunkt zoomen", + "Focus": "Fokus", + "Focus point": "Fokuspunkt", + "Wide": "Breit", + "Center": "Mitte", + "Flexible Spot": "Flexibler Spot", + "Zone": "Feld", + "Expanded Flexible Spot": "Erweiterter flexibler Spot", + "Custom AF Area": "Benutzerdefinierter AF-Bereich", + "Face tracking": "Gesichtsverfolgung", + "Lock On AF": "Lock-on-AF", + "Multi": "Multi", + "Spot": "Spot", + "Tracking": "Tracking", + "Face Tracking": "Gesichtsverfolgung", + "Animal Eye Tracking": "Tieraugen-Tracking", + "Human Eye Tracking": "Augen-Tracking (Mensch)" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..e94a5b377 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1995,5 +1995,23 @@ "Adding to Apple Photos": "Añadiendo a Fotos de Apple", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "No se encontró ningún procesador gráfico: LightCraft renderiza en la CPU, lo que es mucho más lento. Detalles en Ayuda → Información del sistema.", "On NixOS, start it from `nix develop`.": "En NixOS, inícielo desde `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero." + "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero.", + "Show Focus Point": "Mostrar punto de enfoque", + "Zoom to Focus Point": "Zoom al punto de enfoque", + "Focus": "Enfoque", + "Focus point": "Punto de enfoque", + "Wide": "Amplio", + "Center": "Centro", + "Flexible Spot": "Punto flexible", + "Zone": "Zona", + "Expanded Flexible Spot": "Punto flexible ampliado", + "Custom AF Area": "Área AF personalizada", + "Face tracking": "Seguimiento de caras", + "Lock On AF": "AF de bloqueo", + "Multi": "Multi", + "Spot": "Punto", + "Tracking": "Seguimiento", + "Face Tracking": "Seguimiento de caras", + "Animal Eye Tracking": "Seguimiento de ojos de animales", + "Human Eye Tracking": "Seguimiento de ojos humanos" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..89b1d309b 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1957,5 +1957,23 @@ "Adding to Apple Photos": "Ajout à Apple Photos", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Aucun processeur graphique trouvé : LightCraft effectue le rendu sur le processeur, ce qui est beaucoup plus lent. Détails dans Aide → Informations système.", "On NixOS, start it from `nix develop`.": "Sous NixOS, lancez-le depuis `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé." + "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé.", + "Show Focus Point": "Afficher le point de mise au point", + "Zoom to Focus Point": "Zoomer sur le point de mise au point", + "Focus": "Mise au point", + "Focus point": "Point de mise au point", + "Wide": "Large", + "Center": "Centre", + "Flexible Spot": "Spot flexible", + "Zone": "Zone", + "Expanded Flexible Spot": "Spot flexible étendu", + "Custom AF Area": "Zone AF personnalisée", + "Face tracking": "Suivi des visages", + "Lock On AF": "AF verrouillé", + "Multi": "Multi", + "Spot": "Spot", + "Tracking": "Suivi", + "Face Tracking": "Suivi des visages", + "Animal Eye Tracking": "Suivi des yeux d’animaux", + "Human Eye Tracking": "Suivi des yeux humains" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..bec052af7 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1961,5 +1961,23 @@ "Adding to Apple Photos": "Appleの写真に追加中", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "グラフィックプロセッサが見つかりません。LightCraftはCPUで描画するため、かなり遅くなります。詳細は「ヘルプ → システム情報」を参照してください。", "On NixOS, start it from `nix develop`.": "NixOSでは `nix develop` から起動してください。", - "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。" + "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。", + "Show Focus Point": "フォーカスポイントを表示", + "Zoom to Focus Point": "フォーカスポイントにズーム", + "Focus": "フォーカス", + "Focus point": "フォーカスポイント", + "Wide": "ワイド", + "Center": "中央", + "Flexible Spot": "フレキシブルスポット", + "Zone": "ゾーン", + "Expanded Flexible Spot": "拡張フレキシブルスポット", + "Custom AF Area": "カスタムAFエリア", + "Face tracking": "顔追尾", + "Lock On AF": "ロックオンAF", + "Multi": "マルチ", + "Spot": "スポット", + "Tracking": "トラッキング", + "Face Tracking": "顔トラッキング", + "Animal Eye Tracking": "動物瞳トラッキング", + "Human Eye Tracking": "人物瞳トラッキング" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 2e21c727c..e64080fa3 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1998,5 +1998,23 @@ "Recognition models": "Modelos de reconhecimento", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nenhum processador gráfico encontrado: o LightCraft renderiza na CPU, o que é muito mais lento. Detalhes em Ajuda → Informações do sistema.", "On NixOS, start it from `nix develop`.": "No NixOS, inicie-o a partir de `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado." + "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado.", + "Show Focus Point": "Mostrar ponto de foco", + "Zoom to Focus Point": "Zoom no ponto de foco", + "Focus": "Foco", + "Focus point": "Ponto de foco", + "Wide": "Amplo", + "Center": "Centro", + "Flexible Spot": "Ponto flexível", + "Zone": "Zona", + "Expanded Flexible Spot": "Ponto flexível expandido", + "Custom AF Area": "Área de AF personalizada", + "Face tracking": "Rastreamento de rosto", + "Lock On AF": "AF com travamento", + "Multi": "Multi", + "Spot": "Ponto", + "Tracking": "Rastreamento", + "Face Tracking": "Rastreamento de rosto", + "Animal Eye Tracking": "Rastreamento de olhos de animais", + "Human Eye Tracking": "Rastreamento de olhos humanos" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..7e1de22df 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2151,5 +2151,23 @@ "Adding to Apple Photos": "Добавление в Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графический процессор не найден: LightCraft выполняет рендеринг на CPU, а это намного медленнее. Подробности: Справка → Сведения о системе.", "On NixOS, start it from `nix develop`.": "В NixOS запускайте его из `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый." + "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый.", + "Show Focus Point": "Показать точку фокусировки", + "Zoom to Focus Point": "Увеличить по точке фокусировки", + "Focus": "Фокус", + "Focus point": "Точка фокусировки", + "Wide": "Широкая", + "Center": "Центр", + "Flexible Spot": "Гибкая точка", + "Zone": "Зона", + "Expanded Flexible Spot": "Расширенная гибкая точка", + "Custom AF Area": "Своя зона АФ", + "Face tracking": "Отслеживание лиц", + "Lock On AF": "АФ с захватом", + "Multi": "Мульти", + "Spot": "Точка", + "Tracking": "Слежение", + "Face Tracking": "Отслеживание лиц", + "Animal Eye Tracking": "Слежение за глазами животных", + "Human Eye Tracking": "Слежение за глазами людей" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..5f884dfb2 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2151,5 +2151,23 @@ "Adding to Apple Photos": "Додавання до Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графічний процесор не знайдено: LightCraft виконує рендеринг на ЦП, а це набагато повільніше. Докладніше: Довідка → Інформація про систему.", "On NixOS, start it from `nix develop`.": "У NixOS запускайте його з `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший." + "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший.", + "Show Focus Point": "Показати точку фокусування", + "Zoom to Focus Point": "Збільшити до точки фокусування", + "Focus": "Фокус", + "Focus point": "Точка фокусування", + "Wide": "Широка", + "Center": "Центр", + "Flexible Spot": "Гнучка точка", + "Zone": "Зона", + "Expanded Flexible Spot": "Розширена гнучка точка", + "Custom AF Area": "Власна зона АФ", + "Face tracking": "Відстеження облич", + "Lock On AF": "АФ із захопленням", + "Multi": "Мульти", + "Spot": "Точка", + "Tracking": "Стеження", + "Face Tracking": "Відстеження облич", + "Animal Eye Tracking": "Стеження за очима тварин", + "Human Eye Tracking": "Стеження за очима людей" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..b5e340a5c 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1997,5 +1997,23 @@ "Adding to Apple Photos": "正在添加到 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "未找到图形处理器:LightCraft 改用 CPU 渲染,速度会慢很多。详情见“帮助 → 系统信息”。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,请从 `nix develop` 启动。", - "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。" + "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。", + "Show Focus Point": "显示对焦点", + "Zoom to Focus Point": "缩放至对焦点", + "Focus": "对焦", + "Focus point": "对焦点", + "Wide": "广域", + "Center": "中间", + "Flexible Spot": "自由点", + "Zone": "区", + "Expanded Flexible Spot": "扩展自由点", + "Custom AF Area": "自定义AF区域", + "Face tracking": "人脸追踪", + "Lock On AF": "锁定AF", + "Multi": "多重", + "Spot": "点", + "Tracking": "追踪", + "Face Tracking": "人脸追踪", + "Animal Eye Tracking": "动物眼部追踪", + "Human Eye Tracking": "人眼追踪" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..af33e1f94 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1959,5 +1959,23 @@ "Adding to Apple Photos": "正在加入 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "找不到圖形處理器:LightCraft 改用 CPU 算繪,速度會慢很多。詳情請見「說明 → 系統資訊」。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,請從 `nix develop` 啟動。", - "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。" + "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。", + "Show Focus Point": "顯示對焦點", + "Zoom to Focus Point": "縮放至對焦點", + "Focus": "對焦", + "Focus point": "對焦點", + "Wide": "廣域", + "Center": "中間", + "Flexible Spot": "自由點", + "Zone": "區", + "Expanded Flexible Spot": "擴充自由點", + "Custom AF Area": "自訂AF區域", + "Face tracking": "臉部追蹤", + "Lock On AF": "鎖定AF", + "Multi": "多重", + "Spot": "點", + "Tracking": "追蹤", + "Face Tracking": "臉部追蹤", + "Animal Eye Tracking": "動物眼部追蹤", + "Human Eye Tracking": "人眼追蹤" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8248ee5a1..bfd72a9c6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -66,6 +66,8 @@ mod tests_dialogs; #[cfg(test)] mod tests_filmstrip; #[cfg(test)] +mod tests_focus_point; +#[cfg(test)] mod tests_grading; #[cfg(test)] mod tests_grid; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 04eea2439..43703ed9c 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -132,6 +132,8 @@ const LAYOUT: &[(&str, &[&str])] = &[ "@Sidebar Sections", "view.photoCounts", "view.faceBoxes", + "view.focusPoint", + "view.zoomToFocus", "view.filmstrip", "view.histogram", "view.navigator", @@ -315,6 +317,8 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.reference" => Some(u.view == ViewMode::Reference), "view.leftPanel" => Some(u.left_panel), "view.faceBoxes" => Some(u.face_boxes), + "view.focusPoint" => Some(u.settings.focus_point), + "view.zoomToFocus" => Some(u.settings.zoom_to_focus), "view.filmstrip" => Some(u.filmstrip), "view.histogram" => Some(u.histogram), "view.clipping" => Some(u.show_clipping), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 141d6d782..3e9b71c1f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -65,6 +65,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.people", "People", None, "View"), ("view.person", "Show Person", None, ""), ("view.faceBoxes", "Face Boxes", None, "View"), + ("view.focusPoint", "Show Focus Point", Some("Alt+F"), "View"), + ("view.zoomToFocus", "Zoom to Focus Point", None, "View"), ("view.reference", "Reference View", Some("Shift+R"), "View"), ("photo.setReference", "Set as Reference Photo", None, ""), ("compare.swap", "Swap Compare Photos", None, "View"), @@ -486,6 +488,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::panels::compare::enter_compare(app), + "view.focusPoint" => { + // {on?}; no params toggles. The camera's focus point (Sony, from the file) over the photo. + app.ui.settings.focus_point = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.settings.focus_point); + Ok(json!({"on": app.ui.settings.focus_point})) + } + "view.zoomToFocus" => { + // {on?}; no params toggles. Toggle Zoom (Z) centres on the focus point when there is one. + app.ui.settings.zoom_to_focus = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.settings.zoom_to_focus); + Ok(json!({"on": app.ui.settings.zoom_to_focus})) + } "view.faceBoxes" => { app.ui.face_boxes = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.face_boxes); Ok(json!({"show": app.ui.face_boxes})) @@ -707,9 +719,18 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - // the same ratio a click on the photo zooms to - app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(app.ui.click_zoom as f32) } else { Zoom::Fit }; + // the same ratio a click on the photo zooms to; zooming in centres on the camera's focus + // point when the photo has one (View ▸ Zoom to Focus Point) + let zoom_in = app.ui.zoom == Zoom::Fit; + app.ui.zoom = if zoom_in { Zoom::Percent(app.ui.click_zoom as f32) } else { Zoom::Fit }; app.ui.zoom_anim = true; + if zoom_in + && app.ui.settings.zoom_to_focus + && let Some(pan) = app.session.active().and_then(|id| crate::panels::detail::focus_pan(app, id)) + { + app.ui.pan = pan; + return Some(Ok(json!({"focus": [pan.0, pan.1]}))); + } Ok(Value::Null) } "view.clickZoom" => { diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 4c2855718..048852c69 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -254,6 +254,9 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo if photo.flag == Flag::Reject { p.rect_filled(display, 0.0, Color32::from_black_alpha(110)); } + if app.ui.settings.focus_point { + super::detail::draw_focus_point(&p, display, &photo, &format!("compare:focusPoint:{}", id.0)); + } let active = app.session.selection.active == Some(id); if active { p.rect_stroke(display.intersect(img_area.shrink(1.0)), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 66f9c7997..cb2a31dea 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -236,6 +236,63 @@ pub(crate) fn output_px(frame: &Frame) -> [usize; 2] { [px(w), px(h)] } +/// The camera's focus point of `photo` in the displayed frame (its crop, rotation and flips), as the +/// normalized box over the drawn image: `None` when the photo has none or it is cropped away. +pub(crate) fn focus_box(photo: &lightcraft_catalog::Photo) -> Option { + let f = photo.meta.focus.as_ref()?; + let d = &photo.develop; + let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, d, true, photo.embedded_lens.as_ref()); + let [w, h] = output_px(&frame); + let to_out = frame.out_to_norm(w, h).inverse()?; + // stored on the upright photo: the user's Rotate / Flip on top, then the crop + let r = d.orientation.map_norm_rect(f.rect); + let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x0, r.y1), Point::new(r.x1, r.y1)].map(|c| { + let q = to_out.apply(c); + (q.x / w as f64, q.y / h as f64) + }); + let xs = corners.map(|c| c.0); + let ys = corners.map(|c| c.1); + let out = lightcraft_geom::Rect { + x0: xs.iter().copied().fold(f64::INFINITY, f64::min), + y0: ys.iter().copied().fold(f64::INFINITY, f64::min), + x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), + y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max), + }; + if ![out.x0, out.y0, out.x1, out.y1].iter().all(|v| v.is_finite()) { + return None; + } + // its centre must be in the frame; the box may reach past the edge (it is clipped when drawn) + let (cx, cy) = ((out.x0 + out.x1) / 2.0, (out.y0 + out.y1) / 2.0); + ((0.0..=1.0).contains(&cx) && (0.0..=1.0).contains(&cy)).then_some(out) +} + +/// Where Toggle Zoom centres the view on the photo's focus point (`app.ui.pan`), if it has one in frame. +pub(crate) fn focus_pan(app: &LightcraftApp, id: PhotoId) -> Option<(f32, f32)> { + let r = focus_box(app.session.catalog.photo(id)?)?; + Some((((r.x0 + r.x1) / 2.0) as f32, ((r.y0 + r.y1) / 2.0) as f32)) +} + +/// Draw the camera's focus box over a photo drawn in `img` (View ▸ Show Focus Point): green corner +/// brackets with a dark keyline, the way cameras show their AF frame, never smaller than 14 pt. +/// `key` names it for tests and the control channel (`canvas:focusPoint`, `compare:focusPoint:`). +pub(crate) fn draw_focus_point(p: &egui::Painter, img: Rect, photo: &lightcraft_catalog::Photo, key: &str) { + let Some(r) = focus_box(photo) else { return }; + let at = |x: f64, y: f64| pos2(img.left() + x as f32 * img.width(), img.top() + y as f32 * img.height()); + let b = Rect::from_two_pos(at(r.x0, r.y0), at(r.x1, r.y1)); + let b = Rect::from_center_size(b.center(), b.size().max(vec2(14.0, 14.0))); + let arm = (b.width().min(b.height()) * 0.3).max(5.0); + let green = Color32::from_rgb(90, 230, 110); + for (c, dx, dy) in [(b.left_top(), 1.0, 1.0), (b.right_top(), -1.0, 1.0), (b.left_bottom(), 1.0, -1.0), (b.right_bottom(), -1.0, -1.0)] { + let pts = vec![c + vec2(dx * arm, 0.0), c, c + vec2(0.0, dy * arm)]; + p.add(egui::Shape::line(pts.clone(), Stroke::new(4.0, Color32::from_black_alpha(170)))); + p.add(egui::Shape::line(pts, Stroke::new(2.0, green))); + } + let c = b.center(); + p.circle_filled(c, 2.5, Color32::from_black_alpha(170)); + p.circle_filled(c, 1.5, green); + register(p.ctx(), key, b); +} + /// Ease the loupe rect toward `target` while a click-zoom animation runs; otherwise follow it exactly. fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { let t = if *anim { 0.22 } else { 0.0 }; @@ -722,6 +779,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { None => {} } } + if app.ui.settings.focus_point && !crop_tool { + draw_focus_point(&p.with_clip_rect(img_rect.intersect(canvas)), img_rect, &photo, "canvas:focusPoint"); + } // a fine grid while a transform (geometry) slider is dragged, to judge verticals if app.ui.dragging_control.as_deref().is_some_and(|c| c.starts_with("geometry.")) { let n = 12; diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 8d0cc0d3a..e0b032385 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -760,9 +760,28 @@ fn camera_card(ui: &mut egui::Ui, p: &lightcraft_catalog::Photo) { } }); } + // where the camera focused, and how (View ▸ Show Focus Point draws it): only when known; the + // whole card's width, so "AF-C · Expanded Flexible Spot · Face tracking" fits + if let Some(text) = focus_text(m.focus.as_ref()) { + ui.add_space(3.0); + let r = ui.horizontal_wrapped(|ui| { + ui.label(dim(crate::i18n::tr("Focus"))); + ui.add_space(12.0); + ui.label(egui::RichText::new(text).size(12.0).color(t.text)); + }); + register(ui.ctx(), "info:focus", r.response.rect); + } }); } +/// The camera's focus mode, AF area and tracking in the interface language ("AF-C · Wide · Animal Eye +/// Tracking"), or `None` when the photo has no focus point. +pub(crate) fn focus_text(f: Option<&lightcraft_meta::FocusPoint>) -> Option { + let f = f?; + let words: Vec<&str> = f.words().into_iter().map(crate::i18n::tr).collect(); + Some(if words.is_empty() { crate::i18n::tr("Focus point").to_string() } else { words.join(" · ") }) +} + fn human_size(bytes: u64) -> String { match bytes { b if b >= 1 << 20 => format!("{:.1} MB", b as f64 / (1u64 << 20) as f64), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6c4dd3f6a..46349e866 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -275,6 +275,11 @@ pub struct AppSettings { /// The monitor's ICC profile previews are shown through (`app.displayProfile`; "" = none: /// the display is treated as sRGB). pub display_profile: String, + /// View ▸ Show Focus Point: draw the box the camera focused on (`view.focusPoint`). + pub focus_point: bool, + /// View ▸ Zoom to Focus Point: Toggle Zoom (Z) centres the zoomed view on the camera's focus point + /// when the photo has one (`view.zoomToFocus`). + pub zoom_to_focus: bool, } impl Default for AppSettings { @@ -300,6 +305,8 @@ impl Default for AppSettings { light_theme: LightTheme::Silver, favorite_themes: Vec::new(), display_profile: String::new(), + focus_point: false, + zoom_to_focus: true, } } } diff --git a/crates/ui-egui/src/tests_focus_point.rs b/crates/ui-egui/src/tests_focus_point.rs new file mode 100644 index 000000000..fa9ae60a0 --- /dev/null +++ b/crates/ui-egui/src/tests_focus_point.rs @@ -0,0 +1,158 @@ +//! Headless tests of the camera's focus point (#1480): View ▸ Show Focus Point draws the box where the +//! camera focused, through the user's rotation and crop; Toggle Zoom (Z) centres on it; the Info panel +//! names the focus mode. + +use std::sync::Arc; +use std::time::Duration; + +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use lightcraft_meta::FocusPoint; +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Zoom; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn focus(x0: f64, y0: f64, x1: f64, y1: f64) -> FocusPoint { + FocusPoint { + rect: lightcraft_geom::Rect { x0, y0, x1, y1 }, + mode: Some("AF-C".into()), + area: Some("Wide".into()), + tracking: Some("Animal Eye Tracking".into()), + } +} + +/// The demo library in Detail with the Info panel, its first photo active and given `f` as its focus point. +fn detail_with_focus(f: Option) -> (Headless, PhotoId) { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + app.ui.settings.gpu = false; + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let id = *h.app.session.visible_cloned().first().expect("a demo photo"); + let mut meta = h.app.session.catalog.photo(id).expect("photo").meta.clone(); + meta.focus = f; + h.app.session.catalog.apply(Op::SetMeta { id, meta: Box::new(meta) }).expect("set meta"); + let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.set", json!({"view": "detail", "right": "info"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + (h, id) +} + +fn widget(h: &Headless, id: &str) -> Option { + h.app.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +fn run(h: &mut Headless, cmd: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": cmd, "params": params}), T); + assert_eq!(r["ok"], true, "{cmd}: {r}"); + h.settle(SETTLE); + r["result"].clone() +} + +/// Where `(x, y)` (fractions of the drawn photo) is on screen. +fn on_image(h: &Headless, x: f32, y: f32) -> egui::Pos2 { + let img = h.app.image_rect.expect("the photo is drawn"); + egui::pos2(img.left() + x * img.width(), img.top() + y * img.height()) +} + +#[test] +fn the_overlay_draws_on_the_focus_point_and_follows_rotation() { + let (mut h, _) = detail_with_focus(Some(focus(0.20, 0.30, 0.26, 0.36))); + assert!(widget(&h, "canvas:focusPoint").is_none(), "off by default"); + // the Info panel names how the camera focused + assert!(widget(&h, "info:focus").is_some(), "no Focus row in the Info panel"); + assert_eq!(run(&mut h, "view.focusPoint", json!({}))["on"], true); + let b = widget(&h, "canvas:focusPoint").expect("the focus box is drawn"); + let want = on_image(&h, 0.23, 0.33); + assert!((b.center() - want).length() < 1.5, "box at {:?}, want {want:?}", b.center()); + let img = h.app.image_rect.unwrap(); + assert!((b.width() - 0.06 * img.width()).abs() < 1.5, "{b:?} in {img:?}"); + // Rotate Right turns the photo 90° clockwise: the upright point (x, y) is drawn at (1 − y, x) + run(&mut h, "photo.rotateRight", json!({})); + let b = widget(&h, "canvas:focusPoint").expect("still drawn after rotating"); + let want = on_image(&h, 0.67, 0.23); + assert!((b.center() - want).length() < 1.5, "rotated box at {:?}, want {want:?}", b.center()); + // and the command turns it off again + assert_eq!(run(&mut h, "view.focusPoint", json!({"on": false}))["on"], false); + assert!(widget(&h, "canvas:focusPoint").is_none()); +} + +#[test] +fn toggle_zoom_centres_on_the_focus_point() { + let (mut h, _) = detail_with_focus(Some(focus(0.70, 0.20, 0.74, 0.26))); + assert_eq!(h.app.ui.zoom, Zoom::Fit); + let r = h.request("ui.key", json!({"key": "z"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "{:?}", h.app.ui.zoom); + let (u, v) = h.app.ui.pan; + assert!((u - 0.72).abs() < 1e-3 && (v - 0.23).abs() < 1e-3, "pan {u} {v}"); + // the zoomed view is centred on it (the canvas centre shows the focus point) + let canvas = h.app.canvas_rect.expect("canvas"); + let at = on_image(&h, 0.72, 0.23); + let img = h.app.image_rect.unwrap(); + // (unless the photo's edge stops the pan: here the point is well inside, so it is centred) + assert!(img.width() > canvas.width(), "zoomed in"); + assert!((at.x - canvas.center().x).abs() < 3.0, "{at:?} vs {:?}", canvas.center()); + // Z again: back to Fit; with Zoom to Focus Point off, Z keeps the current pan + run(&mut h, "view.zoomToggle", json!({})); + assert_eq!(h.app.ui.zoom, Zoom::Fit); + assert_eq!(run(&mut h, "view.zoomToFocus", json!({}))["on"], false); + h.app.ui.pan = (0.5, 0.5); + run(&mut h, "view.zoomToggle", json!({})); + assert_eq!(h.app.ui.pan, (0.5, 0.5)); +} + +#[test] +fn without_a_focus_point_zoom_and_info_are_unchanged() { + let (mut h, _) = detail_with_focus(None); + run(&mut h, "view.focusPoint", json!({"on": true})); + assert!(widget(&h, "canvas:focusPoint").is_none()); + assert!(widget(&h, "info:focus").is_none()); + h.app.ui.pan = (0.4, 0.6); + run(&mut h, "view.zoomToggle", json!({})); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_))); + assert_eq!(h.app.ui.pan, (0.4, 0.6), "no focus point: Z zooms where it did before"); +} + +/// The box maps through the crop (and a point cropped away gives no box, so Z zooms as before). +#[test] +fn focus_box_follows_the_crop_and_drops_a_point_cropped_away() { + let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 1 }, "a.jpg", "JPEG", 6000, 4000, "2026-10-13T00:00:00"); + p.meta.focus = Some(focus(0.70, 0.45, 0.80, 0.55)); + let b = crate::panels::detail::focus_box(&p).expect("box"); + assert!((b.x0 - 0.70).abs() < 1e-9 && (b.y1 - 0.55).abs() < 1e-9, "{b:?}"); + // keep the right half: x 0.5..1 becomes 0..1 + let mut d = (*p.develop).clone(); + d.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.5, y0: 0.0, x1: 1.0, y1: 1.0 }; + p.develop = Arc::new(d.clone()); + let b = crate::panels::detail::focus_box(&p).expect("box"); + assert!((b.x0 - 0.40).abs() < 1e-6 && (b.x1 - 0.60).abs() < 1e-6, "{b:?}"); + // keep the left half: the point is gone + d.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.0, y0: 0.0, x1: 0.5, y1: 1.0 }; + p.develop = Arc::new(d); + assert!(crate::panels::detail::focus_box(&p).is_none()); + // a zero-size photo or a garbage box never panics + let mut z = Photo::new(PhotoId(2), Source::Demo { scene: 1 }, "z.jpg", "JPEG", 0, 0, "2026-10-13T00:00:00"); + z.meta.focus = Some(focus(f64::NAN, 0.0, f64::INFINITY, 1.0)); + assert!(crate::panels::detail::focus_box(&z).is_none()); + z.meta.focus = Some(focus(0.4, 0.4, 0.6, 0.6)); + assert!(crate::panels::detail::focus_box(&z).is_some()); +} + +#[test] +fn compare_and_survey_draw_each_photos_focus_point() { + let (mut h, id) = detail_with_focus(Some(focus(0.20, 0.30, 0.26, 0.36))); + let other = h.app.session.visible_cloned().get(1).copied().expect("a second photo"); + run(&mut h, "view.focusPoint", json!({"on": true})); + run(&mut h, "library.select", json!({"ids": [id.0, other.0], "active": id.0})); + for view in ["view.compare", "view.survey"] { + run(&mut h, view, json!({})); + assert!(widget(&h, &format!("compare:focusPoint:{}", id.0)).is_some(), "{view}: no focus box"); + assert!(widget(&h, &format!("compare:focusPoint:{}", other.0)).is_none(), "{view}: a box on a photo without one"); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5ecf41b0d..185ad2678 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -103,6 +103,11 @@ scrolling pans in both axes and respects the operating system's scrolling direct These gestures work in Detail (including editing tools and full-screen preview), Compare and Reference views, and only apply over their image areas. Panning stops at the image edges. +The camera's focus point ([focus-points.md](focus-points.md)) is `meta.focus` in `photo.inspect`: `{rect: {x0, y0, +x1, y1}, mode?, area?, tracking?}`, normalized to the upright photo (absent when the file records none). +`view.focusPoint {on?}` shows it over the photo (no params toggles; replies `{on}`), and `view.zoomToFocus {on?}` +(on by default) makes `view.zoomToggle` zoom in centred on it, replying `{focus: [x, y]}` (the `pan` it set). + The interface theme is `view.appearance {mode?: "auto" | "light" | "dark"}` (no mode: the next one, Auto → Light → Dark → Auto, like the top bar's `icon:appearance` button) and `view.theme {theme: "charcoal" | "midnight" | "silver" | "paper"}`, which picks a theme and fixes the mode to its family. diff --git a/docs/focus-points.md b/docs/focus-points.md new file mode 100644 index 000000000..c6993948f --- /dev/null +++ b/docs/focus-points.md @@ -0,0 +1,79 @@ +# Focus points + +Most cameras record where they focused. LightCraft reads that point from the file, draws it over the photo and +zooms to it, so checking whether the subject is sharp takes one key while culling. Lightroom shows no focus points +(photographers add plug-ins for it); this goes beyond parity (tracker row `LR-VIEW-FOCUSPOINT`). + +## Using it + +- **View ▸ Show Focus Point** (⌥F; `view.focusPoint {on?}`) draws the camera's AF frame as green corner brackets in + Detail, Compare and Survey. It follows Rotate, flips and the crop; a point cropped away is not drawn. Off by + default; the choice is saved with the app's settings. +- **View ▸ Zoom to Focus Point** (`view.zoomToFocus {on?}`, on by default): Toggle Zoom (Z or Space) zooms in + centred on the focus point instead of the last position. A click on the photo still zooms where you click, and a + photo without a focus point zooms as before. On by default because it changes nothing for photos without a point, + and for those with one the focus point is what a 1:1 check is for. In Compare the two panes share one pan, so Z + centres on the active photo's point; per-pane focus zoom is a follow-up. +- **Info panel**: a *Focus* row says how the camera focused, e.g. `AF-C · Wide · Animal Eye Tracking` (focus mode, + the AF area set on the camera, and what it tracked when the camera says). +- **Agents**: `photo.inspect` (and the MCP `lightcraft://photo/active` resource) returns it as `meta.focus`: + `{rect: {x0, y0, x1, y1}, mode?, area?, tracking?}`, the box normalized to the upright (EXIF-oriented) photo, y down. + Photos without one have no `focus` key. `view.zoomToggle` replies `{focus: [x, y]}` (the pan it centred on) when it + zoomed to one. + +The point is read when a photo is imported. Photos imported earlier get it with **Photo ▸ Reload from Disk** +(`photo.reload`), which fills camera data the catalog lacks; it never replaces a point already stored. It is +read-only camera data: never written to XMP, and absent from the catalog JSON when unknown, so older catalogs read +unchanged (no catalog format change). + +## Cameras + +Sony first, on bodies whose files have been checked: **ILCE-1 (α1)** and **ILCE-7SM3 (A7S III)** +(`lightcraft_meta::focus::SONY_MODELS`). Other Sony bodies store the same tags, but a body is added only after its +files are checked, so an unchecked one shows nothing rather than a misleading box. Fujifilm, Canon, Nikon, OM System +and Panasonic record similar data and are later steps. + +### Sony layout and evidence + +Tag identities and value names come from the public ExifTool tag-name documentation (Sony MakerNotes table). No +third-party code was read or ported. Every field was confirmed on real files by comparing LightCraft's decoder with +ExifTool's output, run as a black box: 333 ARWs sampled across four years of a photographer's library (180 α1, +153 A7S III; 12 portrait or upside-down, 54 in APS-C crop or reduced-size modes), with no mismatch in the point, frame or names. + +| MakerNote tag | Layout | Notes | +|---|---|---| +| `0x2027` FocusLocation | 4 × int16u: frame width, frame height, focus x, focus y | in the sensor's landscape frame (a portrait shot still says 8640 × 5760); the frame is the image size (5616 × 3744 in α1 APS-C mode) | +| `0x2037` FocusFrameSize | 6 bytes: int16u width, int16u height, then 2 bytes that are zero when no frame was shown | zero in manual focus; one AF-C α1 file had no frame (a small box is drawn: 3 % of the short side) | +| `0x201b` FocusMode | int8u: 0 Manual, 2 AF-S, 3 AF-C, 4 AF-A, 6 DMF, 7 AF-D | | +| `0x201c` AFAreaModeSetting | int8u: 0 Wide, 1 Center, 3 Flexible Spot, 11 Zone, 12 Expanded Flexible Spot, 13 Custom AF Area | | +| `0x2021` AFTracking | int8u: 0 Off, 1 Face tracking, 2 Lock On AF | | +| `0x9402` bytes 0x16 / 0x17 | enciphered: focus mode (low 7 bits) and the AF area mode used (0 Multi, 2 Spot, 3 Flexible Spot, 11 Zone, 14 Tracking, 15 Face Tracking, 20 Animal Eye Tracking, 21 Human Eye Tracking, 255 Manual…) | read only when its focus mode equals `0x201b`'s | + +The documentation says the `0x94xx` blocks use "a simple substitution cipher". On the files each byte `p < 249` is +stored as `p³ mod 249` (bytes 249–255 unchanged): ExifTool's decoded AFAreaMode 20 is stored as `0x20`, a decoded +`0x83` as `0x77`. Cubing is a bijection modulo 249 (= 3 × 83), so the inverse is a table lookup. + +On the α1 files the box sits on the subject: drawn on the embedded previews, it lands on the bird's eye in eye-AF +shots and follows spot, zone, wide and tracking shots, in portrait and landscape. + +### When there is no real point + +Nothing is stored (so nothing is drawn and Z zooms as before) when: + +- the body is not one of the checked ones, the tags are missing or malformed, the frame size is zero, or the centre + lies outside its frame; +- **manual focus** (`0x201b` = 0, or the block's area mode is Manual). The camera still records where the AF frame was + parked: 83 of the 91 sampled manual-focus files carry an off-centre location that says nothing about focus; +- the location is the **exact frame centre while the area used was Multi** (Wide with nothing tracked; without the + block, Wide with tracking off). The wide area reports the centre when it picked no particular spot; 7 of the 34 + sampled autofocus Multi files did. A centred Spot, Zone or Flexible Spot frame is a choice the photographer made, and + is kept (49 of the 111 sampled spot and zone files were centred, the camera's default position). + +DMF (AF, then manual touch-up) keeps its point. + +## Code + +- `crates/meta/src/focus.rs`: the decoder (bounded; hostile values give no point, never a panic) and its tests, + including an opt-in test over real files (`LIGHTCRAFT_FOCUS_SAMPLES=`). +- `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `focus_pan`, `draw_focus_point`): mapping through the user's + rotation and crop, the overlay, and the zoom target; `crates/ui-egui/src/tests_focus_point.rs`. diff --git a/docs/mcp.md b/docs/mcp.md index e09138cfb..d3d23e325 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -208,7 +208,7 @@ still reaches every command. |---|---| | `lightcraft://library` | Source, filter, sort, selection, undo/redo labels (`library.state`) | | `lightcraft://photos` | Photos in the current view (`catalog.query`) | -| `lightcraft://photo/active` | Everything about the active photo (`photo.inspect`) | +| `lightcraft://photo/active` | Everything about the active photo (`photo.inspect`), including the camera's focus point as `meta.focus` when the file records one ([focus-points.md](focus-points.md)) | | `lightcraft://develop/active` | The active photo's develop settings (`develop.get`) | | `lightcraft://controls` | Every develop control with its current value (`develop.controls`) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..45bdaac01 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-VIEW-FOCUSPOINT: camera focus point overlay and zoom (#1480); LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -28,7 +28,7 @@ cited here still exists. Formerly docs/parity.md. |---|---:|---:|---:|---:|---:|---:| | A. Import (IMP) | 12 | 3 | 1 | 1 | 4/6 (67%) | 5/5 (100%) | | B. Library management (LIB) | 22 | 2 | 1 | 2 | 9/9 (100%) | 9/9 (100%) | -| C. Views & navigation (VIEW) | 15 | 1 | 1 | 0 | 8/9 (89%) | 4/4 (100%) | +| C. Views & navigation (VIEW) | 15 | 2 | 1 | 0 | 8/9 (89%) | 4/4 (100%) | | D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) | | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | | F. Edit panel — global adjustments (EDIT) | 43 | 2 | 3 | 1 | 28/28 (100%) | 13/14 (93%) | @@ -36,7 +36,7 @@ cited here still exists. Formerly docs/parity.md. | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | -| K. Masking (MASK) | 13 | 5 | 5 | 0 | 7/8 (88%) | 5/5 (100%) | +| K. Masking (MASK) | 14 | 5 | 5 | 0 | 7/8 (88%) | 6/6 (100%) | | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 408 | 52 | 72 | 35 | 193/201 (96%) | 145/157 (92%) | +| **Total** | 409 | 53 | 72 | 35 | 193/201 (96%) | 146/158 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 532 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.0% of 173. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 534 in-scope rows — P0 98.0% of 201 · P1 95.3% of 158 · P2 50.0% of 174. ## Top gaps and shortcut notes @@ -130,6 +130,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | | LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) | +| LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `crates/meta/src/focus.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import (Reload from Disk fills older photos) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Show Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:`) or “This photo can't be opened” (`compare:renderFailed:`) as the loupe does | @@ -789,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | LR-VIEW-FOCUSPOINT (new, 🟡): the camera's focus point from Sony α1 / A7S III maker notes, drawn in Detail, Compare and Survey, the Info panel's Focus row, and Z zooming to it (#1480) | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | diff --git a/docs/whats-new.md b/docs/whats-new.md index b5c2ac68c..7bf854d0e 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,16 @@ ## 2026-10-13 +### Where the camera focused +- **View ▸ Show Focus Point** (⌥F) draws the autofocus frame the camera recorded over the photo, in Detail, Compare + and Survey, following rotation and crops; the Info panel says how it focused (`AF-C · Wide · Animal Eye Tracking`). + Lightroom has no such view. +- **Z zooms to it**: Toggle Zoom centres on the focus point, so checking that the subject is sharp is one key + (View ▸ Zoom to Focus Point turns this off). Photos without a point zoom as before. +- Sony α1 (ILCE-1) and A7S III (ILCE-7SM3) raws for now; manual-focus shots and Wide shots that only report the frame + centre show nothing. Photos imported earlier get their point with Photo ▸ Reload from Disk. More cameras will + follow. Details: [focus-points.md](focus-points.md). + ### Slider drags on large raws - Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's From fdad5a4ad437583723d1a3c4bf22641a473ecfbc Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:06:06 +0200 Subject: [PATCH 04/12] LR-VIEW-FOCUSPOINT: read focus points for photos already in the library, header-only in the background (#1480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photos imported before focus points were read got one only through Reload from Disk, which hashes whole files on the UI thread. Now: - lightcraft_meta::focus::read_header reads only what the decoder looks at: IFD0 (Make, Model), the Exif IFD, the maker note's IFD and the focus tags' values (a JPEG: its segments up to the Exif one), walked round by round and decoded with the whole-file code from a buffer of the file's length holding just those ranges. Bounded rounds, bytes (2 MB) and size (1 GB). One 32 KB read per α1 / A7S III ARW; on 109 real files (83 with a point) it gives the whole-file read's point for every one. - A background job (crates/engine/src/focus_scan.rs) reads photos with no point from a supported body (by the stored camera; nothing else is opened), ARW or JPEG, one at a time in path order, with a "Reading focus points" activity row (Cancel::Flag). The UI's per-frame focus.pump gives it the face scan's pace (paused while dragging or typing) and starts it once per library opening. Commits every 200 photos or 3 s as system ops (no undo step, no sidecar write), only meta.focus, only on photos that still have none at the same path. "Looked, none" goes to focus-scanned.bin beside the library, headed by the decoder's tag so a decoder knowing more bodies starts afresh; unreadable files are retried at the next opening. - photo.readFocusPoints {ids?, wait?} (Photo > Read Focus Points) runs it on demand. Measured cold on a 5400 rpm USB drive: 200 α1 / A7S III ARWs in 1.9 s (~100 photos/s, 32 KB each), so 58,796 raws take about 10 minutes instead of re-reading 2.25 TB. --- crates/engine/src/activity.rs | 2 +- crates/engine/src/cmd/focus.rs | 50 +++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/faces_index.rs | 29 +- crates/engine/src/focus_scan.rs | 432 ++++++++++++++++++++++ crates/engine/src/lib.rs | 9 + crates/engine/src/tests_focus_scan.rs | 297 +++++++++++++++ crates/meta/src/focus.rs | 198 +++++++++- crates/meta/src/focus_read.rs | 465 ++++++++++++++++++++++++ crates/meta/src/lib.rs | 1 + crates/ui-egui/locales/de.json | 4 +- crates/ui-egui/locales/es.json | 4 +- crates/ui-egui/locales/fr.json | 4 +- crates/ui-egui/locales/ja.json | 4 +- crates/ui-egui/locales/pt-br.json | 4 +- crates/ui-egui/locales/ru.json | 4 +- crates/ui-egui/locales/uk.json | 4 +- crates/ui-egui/locales/zh-hans.json | 4 +- crates/ui-egui/locales/zh-hant.json | 4 +- crates/ui-egui/src/focus_scan.rs | 24 ++ crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/menubar.rs | 1 + crates/ui-egui/src/panels/faces.rs | 7 + crates/ui-egui/src/tests_focus_point.rs | 31 ++ docs/background-tasks.md | 3 +- docs/control-protocol.md | 3 + docs/focus-points.md | 44 ++- docs/mcp.md | 2 +- docs/parity-checklist.md | 4 +- docs/whats-new.md | 6 +- 30 files changed, 1621 insertions(+), 29 deletions(-) create mode 100644 crates/engine/src/cmd/focus.rs create mode 100644 crates/engine/src/focus_scan.rs create mode 100644 crates/engine/src/tests_focus_scan.rs create mode 100644 crates/meta/src/focus_read.rs create mode 100644 crates/ui-egui/src/focus_scan.rs diff --git a/crates/engine/src/activity.rs b/crates/engine/src/activity.rs index 6fc618039..eb02ff8bf 100644 --- a/crates/engine/src/activity.rs +++ b/crates/engine/src/activity.rs @@ -73,7 +73,7 @@ impl Unit { pub struct TaskInfo { pub id: u64, /// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`, - /// `faces`, `denoise`, `findMissing`, `applePhotos`. + /// `faces`, `denoise`, `findMissing`, `applePhotos`, `focus`. pub kind: &'static str, /// English; the frontend translates it. pub label: String, diff --git a/crates/engine/src/cmd/focus.rs b/crates/engine/src/cmd/focus.rs new file mode 100644 index 000000000..efe1d1b7f --- /dev/null +++ b/crates/engine/src/cmd/focus.rs @@ -0,0 +1,50 @@ +//! Focus points for photos already in the library: `photo.readFocusPoints` (Photo ▸ Read Focus Points) and the +//! per-frame `focus.pump` that keeps the background job going. The job itself is `crate::focus_scan`. + +use serde_json::Value; + +use super::{CommandSpec, cmd}; +use crate::{Result, Session}; + +#[cfg(not(target_arch = "wasm32"))] +fn read_points(s: &mut Session, p: &Value) -> Result { + crate::focus_scan::read_points(s, p) +} + +#[cfg(target_arch = "wasm32")] +fn read_points(_: &mut Session, _: &Value) -> Result { + Err(super::bad("photo.readFocusPoints", "reading focus points needs the photos' files on disk")) +} + +#[cfg(not(target_arch = "wasm32"))] +fn pump(s: &mut Session, p: &Value) -> Result { + crate::focus_scan::pump(s, p) +} + +#[cfg(target_arch = "wasm32")] +fn pump(_: &mut Session, _: &Value) -> Result { + Ok(serde_json::json!({"active": false})) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "photo.readFocusPoints", + "Read Focus Points", + ["Photo"], + None, + "{ids?, wait?: false} → {queued, running, last?: {total, done, found, none, unavailable, bytesRead, seconds, cancelled}} — read the camera's focus point for photos imported before it was read (Sony α1 and A7S III raws and JPEGs), from the few KB of each file that hold it, on a background job with a row in the activity stack. Without `ids`, every photo in the library not looked at yet; with `ids`, those photos, looked at again. Photos that have a point keep it. No undo step and no sidecar write. `wait: true` returns when all are read (scripts)", + super::always, + read_points + ), + cmd!( + query "focus.pump", + "Read Focus Points in the Background", + [], + None, + "{pace?: pause | light | normal} → {active, running?: {done, total, found}, last?, lookedAt} — cheap to call every frame (the app does): takes in what the background focus-point reader has read and commits it in batches; reads nothing new while paused and rests between files when light. Starts the reader by itself once per library opening when photos are left to look at", + super::always, + pump + ), + ] +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4de460761..47477d3c7 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -19,6 +19,7 @@ pub(crate) mod face_detect; mod face_models; mod face_recognize; pub mod filters; +mod focus; pub mod geocode; pub mod keywords; pub mod library; @@ -165,6 +166,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(face_models::specs()); v.extend(face_detect::specs()); v.extend(face_recognize::specs()); + v.extend(focus::specs()); v.extend(activity::specs()); v }) diff --git a/crates/engine/src/faces_index.rs b/crates/engine/src/faces_index.rs index fd4d9f2e7..1b5920999 100644 --- a/crates/engine/src/faces_index.rs +++ b/crates/engine/src/faces_index.rs @@ -222,15 +222,29 @@ const SCAN_MAX_FILE: u64 = 64 << 20; /// The photos the detector has already searched for faces, so each is searched once, however many times LightCraft /// starts. Kept beside the library (`face-scanned.bin`: a header naming the detector, then photo ids, appended to). -#[derive(Default)] +/// The focus-point backfill keeps its own record the same way ([`Scanned::new`] with its magic and decoder tag). pub(crate) struct Scanned { set: std::collections::HashSet, unsaved: Vec, path: Option, file_matches: bool, + magic: [u8; 4], + /// What made the record: a file made by anything else is started afresh. + tag: String, +} + +impl Default for Scanned { + fn default() -> Self { + Scanned::new(*SCAN_MAGIC, SCAN_TAG) + } } impl Scanned { + /// An empty record (in memory until [`Scanned::reset`] gives it a path) whose file starts with `magic` and `tag`. + pub fn new(magic: [u8; 4], tag: &str) -> Scanned { + Scanned { set: Default::default(), unsaved: Vec::new(), path: None, file_matches: false, magic, tag: tag.to_string() } + } + pub fn path(&self) -> Option<&Path> { self.path.as_deref() } @@ -265,12 +279,13 @@ impl Scanned { } } - fn header() -> Vec { + fn header(&self) -> Vec { let mut h = Vec::new(); - h.extend_from_slice(SCAN_MAGIC); + h.extend_from_slice(&self.magic); h.push(SCAN_VERSION); - h.extend_from_slice(&(SCAN_TAG.len() as u16).to_le_bytes()); - h.extend_from_slice(SCAN_TAG.as_bytes()); + let tag = self.tag.as_bytes().get(..usize::from(u16::MAX)).unwrap_or(self.tag.as_bytes()); + h.extend_from_slice(&(tag.len() as u16).to_le_bytes()); + h.extend_from_slice(tag); h } @@ -280,7 +295,7 @@ impl Scanned { return; } let Ok(bytes) = std::fs::read(path) else { return }; - let head = Self::header(); + let head = self.header(); let Some(rest) = bytes.strip_prefix(head.as_slice()) else { return }; self.file_matches = true; // a half-written last id (a crash while appending) is ignored @@ -302,7 +317,7 @@ impl Scanned { let buf: Vec = self.unsaved.iter().flat_map(|id| id.to_le_bytes()).collect(); f.write_all(&buf)?; } else { - let mut buf = Self::header(); + let mut buf = self.header(); buf.extend(self.set.iter().flat_map(|id| id.to_le_bytes())); let part = path.with_extension("part"); std::fs::write(&part, &buf)?; diff --git a/crates/engine/src/focus_scan.rs b/crates/engine/src/focus_scan.rs new file mode 100644 index 000000000..6be63e7eb --- /dev/null +++ b/crates/engine/src/focus_scan.rs @@ -0,0 +1,432 @@ +//! Reading the camera's focus point for photos already in the library (issue #1480). +//! +//! Import reads a photo's focus point ([`lightcraft_meta::focus`]); photos imported before that have none. This job +//! fills them in, cheaply enough to run over a whole library on a spinning disk: it opens only the photos from a body +//! whose focus data is decoded (by the camera the catalog stores, so nothing else is opened), and reads only the few +//! kilobytes of each file that hold the focus data ([`lightcraft_meta::focus::read_header`]: about 32 KB of a 75 MB α1 +//! ARW), one file at a time on one worker thread. +//! +//! - **Commits** go in batches of [`COMMIT_EVERY`] photos (or every [`COMMIT_AFTER`]), as LightCraft's own bookkeeping +//! ([`Session::apply_system`]): no undo step, no sidecar write (the focus point is never written to XMP). Only +//! `meta.focus` changes, and only on a photo that still has none, still in the library at the same path. +//! - **"Looked, found nothing"** (manual focus, a centre-only Multi report) is kept beside the library in +//! `focus-scanned.bin` (photo ids, appended to, headed by the decoder's tag): it is cheap to append, never touches the +//! catalog, and a decoder that learns more bodies has another tag, so its record starts afresh and those photos are +//! looked at again. A photo whose file can't be read (offline drive, file gone or replaced mid-read) is not recorded: +//! it is tried again the next time the library opens. +//! - **Runs** off the UI thread with a row in the activity stack ("Reading focus points", n of N, ✕ stops it). The UI's +//! per-frame `focus.pump` gives it the pace: nothing new while the user drags, types or scrolls, a short rest between +//! files while they are around. +//! - **Starts** by itself once per library opening (from `focus.pump`) when photos are left to look at, and on demand +//! with `photo.readFocusPoints`. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use lightcraft_catalog::{Op, PhotoId, Source}; +use lightcraft_meta::FocusPoint; +use lightcraft_meta::focus::HeaderRead; +use serde_json::{Value, json}; + +use crate::activity::{Cancel, TaskGuard}; +use crate::faces_index::Scanned; +use crate::{Result, Session}; + +/// The record of photos looked at with nothing found, beside the library. +pub(crate) const RECORD_FILE: &str = "focus-scanned.bin"; +const RECORD_MAGIC: [u8; 4] = *b"LCFP"; +/// Photos committed at once. +pub(crate) const COMMIT_EVERY: usize = 200; +/// What has been read is committed at least this often, so points appear while a long run goes on. +const COMMIT_AFTER: Duration = Duration::from_secs(3); +/// The rest between files while the user is around (`light` pace): their own reads of the same disk go first. +const LIGHT_REST: Duration = Duration::from_millis(40); +/// How often a paused worker looks at its pace again. +const PAUSE_POLL: Duration = Duration::from_millis(50); +/// Results taken in per pump (the rest wait for the next frame). +const MAX_DRAIN: usize = 5000; + +/// The file extensions read (Sony bodies also shoot HEIF, which carries no maker note this decoder reads). +const EXTENSIONS: [&str; 3] = ["arw", "jpg", "jpeg"]; + +/// How hard the worker may work, from what the user is doing (`focus.pump`'s `pace`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub(crate) enum Pace { + Pause = 0, + Light = 1, + Normal = 2, +} + +impl Pace { + pub fn parse(name: Option<&str>) -> Pace { + match name { + Some("pause") => Pace::Pause, + Some("light") => Pace::Light, + _ => Pace::Normal, + } + } +} + +/// The focus point in the file at `path`, read from its header only. +pub fn read_focus(path: &str) -> std::result::Result { + use std::io::{Read, Seek, SeekFrom}; + let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?; + let len = file.metadata().map_err(|e| format!("{path}: {e}"))?.len(); + lightcraft_meta::focus::read_header(len, &mut |off, buf| { + file.seek(SeekFrom::Start(off))?; + file.read_exact(buf) + }) + .map_err(|e| format!("{path}: {e}")) +} + +/// What the worker says about one photo. +enum Outcome { + /// The header was read: the point it holds, or none. + Read { id: PhotoId, focus: Option, bytes: u64 }, + /// The file couldn't be read (offline, gone, changed under the read): tried again another time. + Unavailable, +} + +/// What a run did (the last one is kept for `focus.status` and `photo.readFocusPoints {wait}`). +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Summary { + pub total: u64, + pub done: u64, + pub found: u64, + pub none: u64, + pub unavailable: u64, + pub bytes: u64, + pub seconds: f64, + pub cancelled: bool, +} + +impl Summary { + fn json(&self) -> Value { + json!({ + "total": self.total, + "done": self.done, + "found": self.found, + "none": self.none, + "unavailable": self.unavailable, + "bytesRead": self.bytes, + "seconds": self.seconds, + "cancelled": self.cancelled, + }) + } +} + +struct Job { + identity: Arc<()>, + cancel: Arc, + pace: Arc, + rx: mpsc::Receiver<(Outcome, String)>, + /// Its row in the activity stack (dropping the job removes it). + guard: TaskGuard, + /// The path each photo was read at: a photo moved or relinked since is left for another run. + paths: HashMap, + pending: Vec<(PhotoId, Option)>, + committed_at: Instant, + started: Instant, + summary: Summary, +} + +impl Drop for Job { + fn drop(&mut self) { + // the worker stops before its next file (and when it can't hand a result over) + self.cancel.store(true, Ordering::Relaxed); + } +} + +/// The backfill's state in the session. +pub(crate) struct FocusScan { + record: Scanned, + /// The library opening the record and the automatic start belong to. + library: Option>, + auto_checked: bool, + job: Option, + last: Option, + /// The pace the UI last gave (a job started between two pumps starts at it). + pace: Pace, +} + +impl Default for FocusScan { + fn default() -> Self { + FocusScan { + record: Scanned::new(RECORD_MAGIC, &lightcraft_meta::focus::decoder_tag()), + library: None, + auto_checked: false, + job: None, + last: None, + pace: Pace::Normal, + } + } +} + +/// Follow the session's library: another one (or none) drops a running job (its results belong to the old library and +/// are read again there next time) and loads that library's record. +fn follow_library(s: &mut Session) { + if s.focus_scan.library.as_ref().is_some_and(|l| Arc::ptr_eq(l, &s.library_identity)) { + return; + } + s.focus_scan.job = None; + s.focus_scan.auto_checked = false; + let path = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join(RECORD_FILE)); + s.focus_scan.record.reset(path); + s.focus_scan.library = Some(Arc::clone(&s.library_identity)); +} + +/// The photos to read: in the library, no focus point yet, from a body whose focus data is decoded, a raw or JPEG file; +/// with `ids`, those of them; without, every one not already looked at. In path order (neighbours on disk together). +fn candidates(s: &Session, ids: Option<&[PhotoId]>) -> Vec<(PhotoId, String)> { + let wanted = |ph: &lightcraft_catalog::Photo| -> Option<(PhotoId, String)> { + if !ph.in_library() || ph.meta.focus.is_some() || !lightcraft_meta::focus::camera_supported(&ph.meta.camera) { + return None; + } + let Source::File { path } = &ph.source else { return None }; + let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); + EXTENSIONS.contains(&ext.as_str()).then(|| (ph.id, path.clone())) + }; + let mut out: Vec<(PhotoId, String)> = match ids { + Some(ids) => ids.iter().filter_map(|id| s.catalog.photo(*id)).filter_map(|ph| wanted(ph)).collect(), + None => s.catalog.photos().filter(|ph| !s.focus_scan.record.contains(ph.id.0)).filter_map(|ph| wanted(ph)).collect(), + }; + out.sort_by(|a, b| a.1.cmp(&b.1)); + out.dedup_by_key(|c| c.0); + out +} + +/// Start reading `list` on a worker thread. +fn start(s: &mut Session, list: Vec<(PhotoId, String)>, pace: Pace) -> std::result::Result<(), String> { + let cancel = Arc::new(AtomicBool::new(false)); + let pace = Arc::new(AtomicU8::new(pace as u8)); + let (tx, rx) = mpsc::channel(); + let paths: HashMap = list.iter().cloned().collect(); + let total = list.len() as u64; + let (worker_cancel, worker_pace) = (cancel.clone(), pace.clone()); + std::thread::Builder::new() + .name("focus-points".into()) + .spawn(move || { + for (id, path) in list { + loop { + if worker_cancel.load(Ordering::Relaxed) { + return; + } + if worker_pace.load(Ordering::Relaxed) != Pace::Pause as u8 { + break; + } + std::thread::sleep(PAUSE_POLL); + } + if worker_pace.load(Ordering::Relaxed) == Pace::Light as u8 { + std::thread::sleep(LIGHT_REST); + } + // a decoder bug in one file is that file's problem, not the job's end + let outcome = match crate::guard::catch("reading a focus point", || read_focus(&path)) { + Ok(Ok(r)) => Outcome::Read { id, focus: r.focus, bytes: r.bytes_read }, + Ok(Err(e)) => { + log::debug!("focus point: {e}"); + Outcome::Unavailable + } + Err(_) => Outcome::Unavailable, + }; + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + if tx.send((outcome, name)).is_err() { + return; + } + } + }) + .map_err(|e| format!("could not start reading focus points: {e}"))?; + let guard = s.activity.start("focus", "Reading focus points", Cancel::Flag(cancel.clone())); + guard.progress(0, total); + let now = Instant::now(); + s.focus_scan.job = Some(Job { + identity: Arc::clone(&s.library_identity), + cancel, + pace, + rx, + guard, + paths, + pending: Vec::new(), + committed_at: now, + started: now, + summary: Summary { total, ..Default::default() }, + }); + Ok(()) +} + +/// Commit what has been read: the points found (one system op, no undo step, no sidecar write) and the photos with none +/// (to the record). +fn commit(s: &mut Session) { + let Some(job) = s.focus_scan.job.as_mut() else { return }; + let pending = std::mem::take(&mut job.pending); + job.committed_at = Instant::now(); + if !Arc::ptr_eq(&job.identity, &s.library_identity) { + return; + } + let mut ops = Vec::new(); + let mut none = Vec::new(); + for (id, focus) in pending { + let Some(ph) = s.catalog.photo(id) else { continue }; + let same_file = matches!((&ph.source, job.paths.get(&id)), (Source::File { path }, Some(read)) if path == read); + // deleted, moved, relinked or given a point meanwhile: leave it + if !same_file || !ph.in_library() || ph.meta.focus.is_some() { + continue; + } + match focus { + Some(f) => { + let mut meta = ph.meta.clone(); + meta.focus = Some(f); + ops.push(Op::SetMeta { id, meta: Box::new(meta) }); + } + None => none.push(id.0), + } + } + for id in none { + s.focus_scan.record.insert(id); + } + if let Err(e) = s.focus_scan.record.save() { + log::warn!("could not save the list of photos read for focus points: {e}"); + } + if ops.is_empty() { + return; + } + match s.apply_system(Op::Batch { ops }) { + // the focus point is never written to XMP: no sidecar is rewritten for it + Ok(()) => s.skip_auto_write = true, + Err(e) => log::warn!("could not store focus points: {e}"), + } +} + +/// Take in what the worker has read, commit when a batch is due, and end the job when it is done or cancelled. +/// Returns whether a job is still running. +fn step(s: &mut Session, pace: Pace) -> bool { + let Some(job) = s.focus_scan.job.as_mut() else { return false }; + job.pace.store(pace as u8, Ordering::Relaxed); + let mut finished = job.cancel.load(Ordering::Relaxed); + let mut last_name = None; + for _ in 0..MAX_DRAIN { + match job.rx.try_recv() { + Ok((outcome, name)) => { + job.summary.done += 1; + last_name = Some(name); + match outcome { + Outcome::Read { id, focus, bytes } => { + job.summary.bytes = job.summary.bytes.saturating_add(bytes); + if focus.is_some() { + job.summary.found += 1; + } else { + job.summary.none += 1; + } + job.pending.push((id, focus)); + } + Outcome::Unavailable => job.summary.unavailable += 1, + } + } + Err(mpsc::TryRecvError::Empty) => break, + // the worker ended (all read, cancelled, or it could not go on) + Err(mpsc::TryRecvError::Disconnected) => { + finished = true; + break; + } + } + } + finished |= job.summary.done >= job.summary.total; + job.guard.progress(job.summary.done, job.summary.total); + if let Some(name) = last_name { + job.guard.detail(&name); + } + if finished || job.pending.len() >= COMMIT_EVERY || (!job.pending.is_empty() && job.committed_at.elapsed() >= COMMIT_AFTER) { + commit(s); + } + if finished && let Some(job) = s.focus_scan.job.take() { + let mut summary = job.summary.clone(); + summary.cancelled = job.cancel.load(Ordering::Relaxed) && summary.done < summary.total; + summary.seconds = job.started.elapsed().as_secs_f64(); + log::info!( + "focus points: read {} of {} photos ({} found, {} without, {} unavailable, {} KB) in {:.1} s", + summary.done, + summary.total, + summary.found, + summary.none, + summary.unavailable, + summary.bytes / 1024, + summary.seconds + ); + s.focus_scan.last = Some(summary); + return false; + } + true +} + +fn status(s: &Session) -> Value { + let running = s.focus_scan.job.as_ref().map(|j| json!({"done": j.summary.done, "total": j.summary.total, "found": j.summary.found})); + json!({"active": running.is_some(), "running": running, "last": s.focus_scan.last.as_ref().map(Summary::json), "lookedAt": s.focus_scan.record.len()}) +} + +/// `focus.pump {pace?}`: cheap to call every frame. +pub(crate) fn pump(s: &mut Session, p: &Value) -> Result { + follow_library(s); + let pace = Pace::parse(p.get("pace").and_then(Value::as_str)); + s.focus_scan.pace = pace; + if !step(s, pace) && !s.focus_scan.auto_checked { + // once per library opening: start by itself when photos are left to look at (a few KB each) + s.focus_scan.auto_checked = true; + let list = candidates(s, None); + if !list.is_empty() + && let Err(e) = start(s, list, pace) + { + log::warn!("{e}"); + } + } + Ok(status(s)) +} + +/// `photo.readFocusPoints {ids?, wait?}`. +pub(crate) fn read_points(s: &mut Session, p: &Value) -> Result { + const C: &str = "photo.readFocusPoints"; + follow_library(s); + if s.focus_scan.job.is_some() { + return Ok(json!({"queued": 0, "running": true, "status": status(s)})); + } + let ids: Option> = match p.get("ids") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_array() + .ok_or_else(|| crate::cmd::bad(C, "`ids` must be a list of photo ids"))? + .iter() + .map(|v| v.as_u64().map(PhotoId).ok_or_else(|| crate::cmd::bad(C, "`ids` must be a list of photo ids"))) + .collect::>>()?, + ), + }; + let list = candidates(s, ids.as_deref()); + let queued = list.len(); + if queued == 0 { + return Ok(json!({"queued": 0, "running": false})); + } + let wait = p.get("wait").and_then(Value::as_bool) == Some(true); + start(s, list, if wait { Pace::Normal } else { s.focus_scan.pace }).map_err(|e| crate::cmd::bad(C, e))?; + // a run asked for counts as this library opening's automatic one + s.focus_scan.auto_checked = true; + if !wait { + return Ok(json!({"queued": queued, "running": true})); + } + // a script's call: read them all now (the batches still commit as they come) + while step(s, Pace::Normal) { + std::thread::sleep(Duration::from_millis(5)); + } + let last = s.focus_scan.last.as_ref().map(Summary::json).unwrap_or(Value::Null); + Ok(json!({"queued": queued, "running": false, "last": last})) +} + +#[cfg(test)] +pub(crate) fn record_len(s: &Session) -> usize { + s.focus_scan.record.len() +} + +#[cfg(test)] +pub(crate) fn record_contains(s: &Session, id: PhotoId) -> bool { + s.focus_scan.record.contains(id.0) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9631b7d84..46d99073a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -36,6 +36,8 @@ mod faces_index; #[cfg(not(target_arch = "wasm32"))] mod faces_worker; pub mod files; +#[cfg(not(target_arch = "wasm32"))] +mod focus_scan; pub mod fonts; pub mod guard; pub mod import; @@ -209,6 +211,9 @@ pub struct Session { /// The loaded recognition model and the face embeddings made with it. #[cfg(not(target_arch = "wasm32"))] pub(crate) faces: faces_index::FacesState, + /// Reading focus points for photos imported before they were read (`photo.readFocusPoints`, `focus.pump`). + #[cfg(not(target_arch = "wasm32"))] + pub(crate) focus_scan: focus_scan::FocusScan, /// Copied develop settings (partial JSON) for Paste. pub clipboard: Option, /// The folder on disk the [`LibrarySource::Folder`] view browses. @@ -350,6 +355,8 @@ impl Session { face_catalog: Default::default(), #[cfg(not(target_arch = "wasm32"))] faces: Default::default(), + #[cfg(not(target_arch = "wasm32"))] + focus_scan: Default::default(), clipboard: None, meta_clipboard: None, browse: None, @@ -1002,6 +1009,8 @@ mod tests_face_models; mod tests_face_recognize; #[cfg(test)] mod tests_filesystem_history; +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests_focus_scan; #[cfg(test)] mod tests_folders; #[cfg(test)] diff --git a/crates/engine/src/tests_focus_scan.rs b/crates/engine/src/tests_focus_scan.rs new file mode 100644 index 000000000..eadfbdbc2 --- /dev/null +++ b/crates/engine/src/tests_focus_scan.rs @@ -0,0 +1,297 @@ +//! Reading focus points for photos already in the library (`photo.readFocusPoints`, `focus.pump`): header-only reads, +//! batches committed without undo steps, "looked, found nothing" remembered across sessions, and the activity row. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::focus_scan::{read_focus, record_contains, record_len}; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-focus-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn sony_arw(mode: u8, samples: usize) -> Vec { + lightcraft_meta::focus::synthetic_arw(mode, samples) +} + +/// A photo of `camera` at `path` (an import made before focus points were read: none stored). +fn photo(id: u64, path: &Path, camera: &str) -> Photo { + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + let mut p = Photo::new(PhotoId(id), Source::File { path: path.to_string_lossy().into_owned() }, &name, "ARW", 8640, 5760, "2026-10-13T00:00:00"); + p.meta.camera = camera.into(); + p +} + +fn add(s: &mut Session, photos: Vec) { + s.execute_fn("test.add", |s| { + for p in photos { + s.apply_system(Op::AddPhoto { photo: Box::new(p) })?; + } + Ok(Value::Null) + }) + .unwrap(); +} + +fn focus(s: &Session, id: u64) -> Option { + s.catalog.photo(PhotoId(id)).unwrap().meta.focus.clone() +} + +fn pump_until_idle(s: &mut Session) -> Value { + let started = Instant::now(); + loop { + let v = s.execute("focus.pump", &json!({"pace": "normal"})).unwrap(); + if v["active"] == false { + return v; + } + assert!(started.elapsed() < Duration::from_secs(30), "the job never ended: {v}"); + std::thread::sleep(Duration::from_millis(5)); + } +} + +#[test] +fn the_header_read_reads_a_small_part_of_the_file() { + let dir = temp_dir("header"); + let path = dir.join("a.ARW"); + let file = sony_arw(3, 4_000_000); + std::fs::write(&path, &file).unwrap(); + let r = read_focus(&path.to_string_lossy()).unwrap(); + assert!(r.focus.is_some()); + assert_eq!(r.focus, lightcraft_meta::extract(&file).focus, "the same point as a whole-file read"); + assert!(r.bytes_read <= 32 * 1024 && r.bytes_read * 100 < file.len() as u64, "read {} of {}", r.bytes_read, file.len()); + assert!(read_focus(&dir.join("gone.ARW").to_string_lossy()).is_err()); +} + +/// Fills the missing points; leaves other cameras, other formats and known points alone without opening them; records +/// "looked, found nothing" (manual focus, garbage) but not a file it could not read; makes no undo step. +#[test] +fn fills_missing_points_and_remembers_photos_without_one() { + let dir = temp_dir("fill"); + let af = dir.join("af.ARW"); + std::fs::write(&af, sony_arw(3, 100_000)).unwrap(); + let manual = dir.join("manual.ARW"); + std::fs::write(&manual, sony_arw(0, 100_000)).unwrap(); + let garbage = dir.join("garbage.ARW"); + std::fs::write(&garbage, vec![0x5a; 70_000]).unwrap(); + let heif = dir.join("x.HIF"); + std::fs::write(&heif, sony_arw(3, 1000)).unwrap(); + let known = lightcraft_meta::FocusPoint { + rect: lightcraft_geom::Rect { x0: 0.1, y0: 0.1, x1: 0.2, y1: 0.2 }, + mode: Some("AF-S".into()), + area: None, + tracking: None, + }; + let mut with_point = photo(4, &af, "SONY ILCE-1"); + with_point.meta.focus = Some(known.clone()); + let mut s = Session::new(); + add( + &mut s, + vec![ + photo(1, &af, "SONY ILCE-1"), + photo(2, &manual, "SONY ILCE-1"), + // another body's photo is not opened, even when its file would have a point + photo(3, &af, "Canon EOS R5"), + with_point, + photo(5, &dir.join("offline.ARW"), "SONY ILCE-7SM3"), + photo(6, &garbage, "SONY ILCE-1"), + photo(7, &heif, "SONY ILCE-1"), + ], + ); + let undo = s.undo.len(); + let r = s.execute("photo.readFocusPoints", &json!({"wait": true})).unwrap(); + assert_eq!(r["queued"], 4, "{r}"); + let last = &r["last"]; + assert_eq!( + (last["done"].as_u64(), last["found"].as_u64(), last["none"].as_u64(), last["unavailable"].as_u64()), + (Some(4), Some(1), Some(2), Some(1)), + "{r}" + ); + assert!(last["bytesRead"].as_u64().unwrap() <= 3 * 32 * 1024, "{r}"); + assert_eq!(focus(&s, 1), lightcraft_meta::extract(&std::fs::read(&af).unwrap()).focus); + assert!(focus(&s, 1).is_some()); + assert_eq!(focus(&s, 1).unwrap().words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + assert_eq!(focus(&s, 2), None); + assert_eq!(focus(&s, 3), None); + assert_eq!(focus(&s, 4), Some(known), "a stored point is never replaced"); + assert_eq!(focus(&s, 5), None); + assert_eq!(focus(&s, 7), None); + assert!(record_contains(&s, PhotoId(2)) && record_contains(&s, PhotoId(6))); + assert!(!record_contains(&s, PhotoId(5)), "an unreadable file is tried again later"); + assert_eq!(record_len(&s), 2); + assert_eq!(s.undo.len(), undo, "no undo step"); + // asked again: only the offline photo is left + assert_eq!(s.execute("photo.readFocusPoints", &json!({"wait": true})).unwrap()["queued"], 1); + // named photos are looked at again, even those recorded as having none + assert_eq!(s.execute("photo.readFocusPoints", &json!({"ids": [2, 3, 4], "wait": true})).unwrap()["queued"], 1); +} + +/// What was read survives a restart: the next session reads nothing again, and a photo whose file was offline is read +/// once it is back. The automatic start (once per library opening, from the pump) picks it up. +#[test] +fn resumes_after_a_restart_without_reading_again() { + let dir = temp_dir("resume"); + let lib = dir.join("library"); + let af = dir.join("af.ARW"); + std::fs::write(&af, sony_arw(3, 50_000)).unwrap(); + let manual = dir.join("manual.ARW"); + std::fs::write(&manual, sony_arw(0, 50_000)).unwrap(); + let later = dir.join("later.ARW"); + { + let mut s = Session::new(); + s.open_library(&lib, false).unwrap(); + add(&mut s, vec![photo(1, &af, "SONY ILCE-1"), photo(2, &manual, "SONY ILCE-1"), photo(3, &later, "SONY ILCE-1")]); + // the pump starts it by itself + let first = s.execute("focus.pump", &json!({"pace": "normal"})).unwrap(); + assert_eq!(first["active"], true, "{first}"); + let v = pump_until_idle(&mut s); + assert_eq!(v["last"]["found"], 1, "{v}"); + assert!(lib.join(crate::focus_scan::RECORD_FILE).exists()); + } + let mut s = Session::new(); + s.open_library(&lib, false).unwrap(); + assert!(focus(&s, 1).is_some(), "the point was saved with the library"); + // the file that was offline is back: only it is read + std::fs::write(&later, sony_arw(3, 50_000)).unwrap(); + let v = s.execute("focus.pump", &json!({"pace": "normal"})).unwrap(); + assert!(record_contains(&s, PhotoId(2)), "the record was read back"); + assert_eq!(v["running"]["total"], 1, "{v}"); + let v = pump_until_idle(&mut s); + assert_eq!((v["last"]["total"].as_u64(), v["last"]["found"].as_u64()), (Some(1), Some(1)), "{v}"); + assert!(focus(&s, 3).is_some()); + assert!(record_contains(&s, PhotoId(2))); + // and it doesn't start again in this opening + assert_eq!(s.execute("focus.pump", &json!({})).unwrap()["active"], false); +} + +/// The job has a row in the activity stack (n of N, cancellable); paused, it reads nothing new; cancelled (the row's ✕), +/// it commits what it read and the row goes away. +#[test] +fn the_activity_row_progresses_pauses_and_cancels() { + let dir = temp_dir("row"); + let mut photos = Vec::new(); + for i in 0..40u64 { + let p = dir.join(format!("{i:03}.ARW")); + std::fs::write(&p, sony_arw(3, 20_000)).unwrap(); + photos.push(photo(i + 1, &p, "SONY ILCE-7SM3")); + } + let mut s = Session::new(); + add(&mut s, photos); + // the pump starts the job by itself, at the pace it is given: paused, while the user drags + let v = s.execute("focus.pump", &json!({"pace": "pause"})).unwrap(); + assert_eq!(v["active"], true); + let rows = s.activity.list(); + let row = rows.iter().find(|t| t.kind == "focus").expect("an activity row"); + assert_eq!((row.label.as_str(), row.total, row.cancellable), ("Reading focus points", 40, true)); + // already running: asking again starts nothing new + let r = s.execute("photo.readFocusPoints", &json!({})).unwrap(); + assert_eq!((r["queued"].as_u64(), r["running"].as_bool()), (Some(0), Some(true))); + std::thread::sleep(Duration::from_millis(300)); + let still = s.execute("focus.pump", &json!({"pace": "pause"})).unwrap()["running"]["done"].as_u64().unwrap(); + assert_eq!(still, 0, "paused: nothing read"); + // a few more at a light pace, then cancel + let started = Instant::now(); + let mut done = still; + while done < still + 3 && started.elapsed() < Duration::from_secs(20) { + std::thread::sleep(Duration::from_millis(20)); + done = s.execute("focus.pump", &json!({"pace": "light"})).unwrap()["running"]["done"].as_u64().unwrap_or(40); + } + let row = s.activity.list().into_iter().find(|t| t.kind == "focus").expect("still running"); + assert!(row.done >= 3, "progress shows: {}", row.done); + s.execute("focus.pump", &json!({"pace": "pause"})).unwrap(); + s.activity.cancel(row.id).unwrap(); + let v = s.execute("focus.pump", &json!({"pace": "pause"})).unwrap(); + assert_eq!(v["active"], false, "{v}"); + assert_eq!(v["last"]["cancelled"], true, "{v}"); + assert!(s.activity.list().iter().all(|t| t.kind != "focus"), "the row is gone"); + let found = s.catalog.photos().filter(|p| p.meta.focus.is_some()).count() as u64; + assert_eq!(found, v["last"]["found"].as_u64().unwrap(), "what was read before the cancel is kept"); + assert!(found < 40); + // the rest are read next time + let r = s.execute("photo.readFocusPoints", &json!({"wait": true})).unwrap(); + assert_eq!(r["queued"].as_u64().unwrap(), 40 - found); + assert_eq!(s.catalog.photos().filter(|p| p.meta.focus.is_some()).count(), 40); +} + +/// Photos deleted, moved or given a point while the job runs are left alone; a library closed mid-run takes the job +/// with it (nothing lands in the next library), and nothing panics. +#[test] +fn changes_under_a_running_job_are_respected() { + let dir = temp_dir("changes"); + let mut photos = Vec::new(); + for i in 0..6u64 { + let p = dir.join(format!("{i}.ARW")); + std::fs::write(&p, sony_arw(3, 10_000)).unwrap(); + photos.push(photo(i + 1, &p, "SONY ILCE-1")); + } + let mut s = Session::new(); + s.open_library(dir.join("lib-a"), false).unwrap(); + add(&mut s, photos); + // started by the pump, paused + assert_eq!(s.execute("focus.pump", &json!({"pace": "pause"})).unwrap()["running"]["total"], 6); + // meanwhile: photo 1 removed, photo 2 relinked elsewhere, photo 3 given a point by the user's Reload, photo 4's file + // replaced by garbage, photo 5's deleted + let mine = + lightcraft_meta::FocusPoint { rect: lightcraft_geom::Rect { x0: 0.5, y0: 0.5, x1: 0.6, y1: 0.6 }, mode: None, area: None, tracking: None }; + s.execute_fn("test.change", |s| { + s.apply_system(Op::RemovePhoto { id: PhotoId(1) })?; + let moved = dir.join("moved.ARW"); + std::fs::copy(dir.join("1.ARW"), &moved).map_err(|e| crate::EngineError::Other(e.to_string()))?; + s.apply_system(Op::Relink { + id: PhotoId(2), + file_name: "moved.ARW".into(), + source: Source::File { path: moved.to_string_lossy().into() }, + format: None, + })?; + let mut meta = s.catalog.photo(PhotoId(3)).unwrap().meta.clone(); + meta.focus = Some(mine.clone()); + s.apply_system(Op::SetMeta { id: PhotoId(3), meta: Box::new(meta) })?; + Ok(Value::Null) + }) + .unwrap(); + std::fs::write(dir.join("3.ARW"), b"not a raw any more").unwrap(); + std::fs::remove_file(dir.join("4.ARW")).unwrap(); + let v = pump_until_idle(&mut s); + assert!(s.catalog.photo(PhotoId(1)).is_none()); + assert_eq!(focus(&s, 2), None, "read at the old path: left for another run"); + assert_eq!(focus(&s, 3), Some(mine)); + assert_eq!(focus(&s, 5), None); + assert!(focus(&s, 6).is_some()); + assert!(v["last"]["done"].as_u64().unwrap() <= 6, "{v}"); + // a library closed mid-run: the job goes with it + s.execute("focus.pump", &json!({"pace": "pause"})).unwrap(); + assert_eq!(s.execute("photo.readFocusPoints", &json!({"ids": [2, 4, 5]})).unwrap()["queued"], 3); + s.open_library(dir.join("lib-b"), false).unwrap(); + let v = s.execute("focus.pump", &json!({"pace": "normal"})).unwrap(); + assert_eq!(v["active"], false, "{v}"); + assert!(s.activity.list().iter().all(|t| t.kind != "focus")); + assert_eq!(s.catalog.photos().count(), 0); +} + +/// Opt-in measurement on real files: `LIGHTCRAFT_FOCUS_FILES=` reads each +/// whole and by header, asserts they give the same focus point, and prints the bytes the header read took. Skipped +/// without it; no user file is part of the repository. +#[test] +fn real_files_header_read_matches_whole_file() { + let Ok(list) = std::env::var("LIGHTCRAFT_FOCUS_FILES") else { return }; + let paths: Vec = std::fs::read_to_string(list).unwrap().lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect(); + let (mut n, mut with_point, mut bytes, mut max) = (0u64, 0u64, 0u64, 0u64); + for p in &paths { + let whole = lightcraft_meta::extract(&std::fs::read(p).unwrap()).focus; + let r = read_focus(p).unwrap(); + assert_eq!(r.focus, whole, "{p}"); + n += 1; + with_point += u64::from(whole.is_some()); + bytes += r.bytes_read; + max = max.max(r.bytes_read); + } + eprintln!( + "focus header reads: {n} files ({with_point} with a point) agree with whole-file reads; {} bytes each on average, {max} at most", + bytes / n.max(1) + ); +} diff --git a/crates/meta/src/focus.rs b/crates/meta/src/focus.rs index 92801f8a7..e1c84f296 100644 --- a/crates/meta/src/focus.rs +++ b/crates/meta/src/focus.rs @@ -29,6 +29,7 @@ //! the wide area reports the centre when it picked no particular spot. A centred Spot, Zone or //! Flexible Spot frame is a real choice and is kept. +pub use crate::focus_read::{HEADER_PREFIX, HeaderRead, MAX_HEADER_BYTES, read_header}; use crate::{Orientation, Rect}; use lightcraft_tiff::{ByteOrder, Ifd, Tiff, makernote, tags as t}; use serde::{Deserialize, Serialize}; @@ -80,6 +81,19 @@ impl FocusPoint { /// tags differently (or not mean the same by them): it shows no focus point until its files are checked. pub const SONY_MODELS: &[&str] = &["ILCE-1", "ILCE-7SM3"]; +/// Whether a camera as the catalog names it (`"SONY ILCE-1"`: the Exif Make, a space, the Model) is +/// one whose files can carry a focus point, so a photo from any other camera is never opened to look. +pub fn camera_supported(camera: &str) -> bool { + let camera = camera.trim(); + camera.get(..4).is_some_and(|m| m.eq_ignore_ascii_case("SONY")) && camera.rsplit(' ').next().is_some_and(|model| SONY_MODELS.contains(&model)) +} + +/// Names what [`read`] decodes (its bodies), so a record of "looked, found nothing" made by this +/// decoder is not trusted by one that knows more bodies. +pub fn decoder_tag() -> String { + format!("sony-v1:{}", SONY_MODELS.join(",")) +} + const FOCUS_MODE: u16 = 0x201b; const AF_AREA_SETTING: u16 = 0x201c; const AF_TRACKING: u16 = 0x2021; @@ -160,6 +174,54 @@ pub(crate) fn sony(ifd: &Ifd, order: ByteOrder, orientation: Orientation) -> Opt FocusPoint { rect: orientation.map_norm_rect(sensor), mode: name(mode, focus_mode_name), area, tracking }.sanitized() } +/// A minimal little-endian TIFF laid out as an α1 ARW's metadata is (IFD0 with Make `SONY` and Model `ILCE-1` → +/// Exif IFD → a header-less maker note with the focus tags: AF-C or the given FocusMode byte, Wide, Face tracking, +/// Animal Eye Tracking at (3226, 1980) of 8640 × 5760), followed by `samples` bytes standing in for sensor data. For +/// tests of the readers and the backfill in other crates; not a real raw (it has no image). +#[doc(hidden)] +pub fn synthetic_arw(mode: u8, samples: usize) -> Vec { + fn entry(f: &mut Vec, tag: u16, typ: u16, count: u32, value: [u8; 4]) { + f.extend_from_slice(&tag.to_le_bytes()); + f.extend_from_slice(&typ.to_le_bytes()); + f.extend_from_slice(&count.to_le_bytes()); + f.extend_from_slice(&value); + } + let encipher = |p: u8| if p >= 249 { p } else { (u32::from(p).pow(3) % 249) as u8 }; + let mut f = b"II*\0".to_vec(); + f.extend_from_slice(&8u32.to_le_bytes()); + // IFD0 at 8: Make (at 62), Model (at 68), Orientation, the Exif IFD (at 76) + f.extend_from_slice(&4u16.to_le_bytes()); + entry(&mut f, t::MAKE, 2, 5, 62u32.to_le_bytes()); + entry(&mut f, t::MODEL, 2, 7, 68u32.to_le_bytes()); + entry(&mut f, t::ORIENTATION, 3, 1, [1, 0, 0, 0]); + entry(&mut f, t::EXIF_IFD, 4, 1, 76u32.to_le_bytes()); + f.extend_from_slice(&[0; 4]); + f.extend_from_slice(b"SONY\0\0ILCE-1\0\0"); + // the Exif IFD: the maker note at 94, 156 bytes + f.extend_from_slice(&1u16.to_le_bytes()); + entry(&mut f, t::MAKER_NOTE, 7, 156, 94u32.to_le_bytes()); + f.extend_from_slice(&[0; 4]); + // the maker note's IFD, its values from 172 + f.extend_from_slice(&6u16.to_le_bytes()); + entry(&mut f, FOCUS_MODE, 1, 1, [mode, 0, 0, 0]); + entry(&mut f, AF_AREA_SETTING, 1, 1, [0, 0, 0, 0]); + entry(&mut f, AF_TRACKING, 1, 1, [1, 0, 0, 0]); + entry(&mut f, FOCUS_LOCATION, 3, 4, 172u32.to_le_bytes()); + entry(&mut f, FOCUS_FRAME_SIZE, 7, 6, 180u32.to_le_bytes()); + entry(&mut f, TAG_9402, 7, 64, 186u32.to_le_bytes()); + f.extend_from_slice(&[0; 4]); + for v in [8640u16, 5760, 3226, 1980] { + f.extend_from_slice(&v.to_le_bytes()); + } + f.extend_from_slice(&[0xaf, 0, 0xb4, 0, 1, 1]); + let mut block = [0u8; 64]; + block[0x16] = encipher(0x80 | mode); + block[0x17] = encipher(20); + f.extend_from_slice(&block); + f.extend((0..samples).map(|i| (i % 251) as u8)); + f +} + /// Undo the `0x94xx` byte substitution: the stored byte is `p³ mod 249` for `p < 249`. fn decipher(b: u8) -> u8 { if b >= 249 { @@ -415,13 +477,20 @@ mod tests { /// A whole TIFF with a `SONY DSC` maker note holding `ifd`'s entries, written for real. fn sony_file(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)]) -> Vec { + sony_file_with(make, model, orientation, note_entries, None) + } + + /// [`sony_file`], with the `0x9402` block's value placed `far` bytes past the end of the TIFF + /// (the gap filled with "sample data") when `far` is given. + fn sony_file_with(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)], far: Option) -> Vec { let order = ByteOrder::Little; // the note: 12-byte header, then an IFD whose out-of-line values are offset from the TIFF header, // which we only know after writing; write once to learn where the note lands, then rebuild - let build = |note_at: u64| -> Vec { + let build = |note_at: u64, end: u64| -> (Vec, Vec) { let mut note = b"SONY DSC \0\0\0".to_vec(); let ifd_len = 2 + note_entries.len() * 12 + 4; let mut tail: Vec = Vec::new(); + let mut outside: Vec = Vec::new(); order.put_u16(&mut note, note_entries.len() as u16); for (tag, v) in note_entries { let raw = lightcraft_tiff::writer::encode(order, v); @@ -432,6 +501,9 @@ mod tests { let mut r = raw.clone(); r.resize(4, 0); note.extend(r); + } else if let (Some(gap), TAG_9402) = (far, *tag) { + order.put_u32(&mut note, (end + gap as u64) as u32); + outside = raw; } else { order.put_u32(&mut note, (note_at + 12 + ifd_len as u64 + tail.len() as u64) as u32); tail.extend(raw); @@ -449,12 +521,17 @@ mod tests { ifd0.set(t::MODEL, Value::Ascii(model.into())); ifd0.set(t::ORIENTATION, Value::Short(vec![orientation])); ifd0.set_child(t::EXIF_IFD, exif); - TiffWriter { order, bigtiff: false }.write(&[ifd0]).unwrap() + (TiffWriter { order, bigtiff: false }.write(&[ifd0]).unwrap(), outside) }; - let first = build(0); + let (first, _) = build(0, 0); let at = Tiff::parse(&first).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset; - let file = build(at); + let (mut file, outside) = build(at, first.len() as u64); + assert_eq!(file.len(), first.len()); assert_eq!(Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset, at); + if let Some(gap) = far { + file.extend((0..gap).map(|i| (i * 13 % 241) as u8)); + file.extend(outside); + } file } @@ -499,6 +576,119 @@ mod tests { } } + /// A whole-file read and a header-only read of the same file agree, and the header read reads a + /// small part of it (the sample data after the metadata is never touched). + #[test] + fn header_read_matches_the_whole_file_and_reads_little() { + let read = |file: &[u8]| { + let mut asked = 0u64; + let r = read_header(file.len() as u64, &mut |off, buf: &mut [u8]| { + asked += buf.len() as u64; + let s = off as usize; + buf.copy_from_slice(&file[s..s + buf.len()]); + Ok(()) + }) + .unwrap(); + assert_eq!(r.bytes_read, asked); + r + }; + // a raw: the metadata, then 8 MB of "samples" + let mut file = sony_file("SONY", "ILCE-1", 8, &alpha1_entries()); + let meta_len = file.len(); + file.extend((0..8_000_000u32).map(|i| (i * 31 % 251) as u8)); + let whole = crate::extract(&file).focus; + assert!(whole.is_some()); + let r = read(&file); + assert_eq!(r.focus, whole); + assert!(r.bytes_read <= HEADER_PREFIX && r.bytes_read < file.len() as u64 / 100, "{} of {}", r.bytes_read, file.len()); + assert!(meta_len < HEADER_PREFIX as usize); + // the area-mode block placed 3 MB further on: found in a further round, still a small part + let mut far = sony_file_with("SONY", "ILCE-1", 1, &alpha1_entries(), Some(3_000_000)); + far.extend((0..4_000_000u32).map(|i| (i * 7 % 253) as u8)); + let whole = crate::extract(&far).focus; + assert!(whole.is_some()); + let r = read(&far); + assert_eq!(r.focus, whole); + assert!(r.bytes_read < 64 * 1024, "{}", r.bytes_read); + // a JPEG: the Exif segment after another segment + let exif = sony_file("SONY", "ILCE-1", 6, &alpha1_entries()); + let mut jpeg = vec![0xff, 0xd8, 0xff, 0xe0, 0, 16]; + jpeg.extend_from_slice(b"JFIF\0\x01\x01\0\0\x01\0\x01\0\0"); + jpeg.extend_from_slice(&[0xff, 0xe1]); + jpeg.extend_from_slice(&((exif.len() + 8) as u16).to_be_bytes()); + jpeg.extend_from_slice(b"Exif\0\0"); + jpeg.extend_from_slice(&exif); + jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]); + jpeg.extend(std::iter::repeat_n(0x55u8, 3_000_000)); + let whole = crate::extract(&jpeg).focus; + assert!(whole.is_some()); + let r = read(&jpeg); + assert_eq!(r.focus, whole); + assert!(r.bytes_read <= HEADER_PREFIX); + // unsupported bodies agree too (no point either way) + let other = sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries()); + assert_eq!(read(&other).focus, None); + } + + /// Every truncation and corruption of a file gives the header read the whole-file read's answer + /// (or an error when the file is shorter than it said), never a panic. + #[test] + fn header_read_of_damaged_files_agrees_or_fails_cleanly() { + let file = sony_file("SONY", "ILCE-1", 1, &alpha1_entries()); + for cut in (0..file.len()).step_by(5) { + let part = &file[..cut]; + let r = read_header(part.len() as u64, &mut |off, buf: &mut [u8]| { + let s = off as usize; + buf.copy_from_slice(part.get(s..s + buf.len()).ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?); + Ok(()) + }) + .unwrap(); + assert_eq!(r.focus, crate::extract(part).focus, "cut at {cut}"); + } + let mut seed = 0x9e37_79b9_u32; + for _ in 0..400 { + let mut f = file.clone(); + for _ in 0..6 { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let i = seed as usize % f.len(); + f[i] = (seed >> 8) as u8; + } + let r = read_header(f.len() as u64, &mut |off, buf: &mut [u8]| { + let s = off as usize; + buf.copy_from_slice(&f[s..s + buf.len()]); + Ok(()) + }) + .unwrap(); + assert_eq!(r.focus, crate::extract(&f).focus); + } + // a reader told the file is longer than it is (replaced since it was measured): an error + let r = read_header(file.len() as u64 + 100_000, &mut |off, buf: &mut [u8]| { + let s = off as usize; + buf.copy_from_slice(file.get(s..s + buf.len()).ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?); + Ok(()) + }); + assert!(r.is_err()); + } + + #[test] + fn the_synthetic_arw_is_laid_out_as_described() { + let f = synthetic_arw(3, 10); + assert_eq!(f.len(), 260); + let p = crate::extract(&f).focus.expect("a point"); + assert_eq!(p.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + assert!(crate::extract(&synthetic_arw(0, 0)).focus.is_none(), "manual focus"); + } + + #[test] + fn cameras_that_can_have_a_point() { + assert!(camera_supported("SONY ILCE-1") && camera_supported("Sony ILCE-7SM3 ")); + for c in ["SONY ILCE-7M4", "Canon EOS R5", "", "SONY", "ILCE-1", "SON"] { + assert!(!camera_supported(c), "{c}"); + } + } + /// Opt-in: decode real α1 / A7S III files and compare with the values ExifTool (run as a black box) /// reports for them. `LIGHTCRAFT_FOCUS_SAMPLES=` is the photo folder the paths below are relative /// to; skipped without it. No user file is part of the repository. diff --git a/crates/meta/src/focus_read.rs b/crates/meta/src/focus_read.rs new file mode 100644 index 000000000..5a5d349d1 --- /dev/null +++ b/crates/meta/src/focus_read.rs @@ -0,0 +1,465 @@ +//! Reading a file's focus point without reading the file: [`read_header`] asks for the few byte +//! ranges [`crate::focus::read`] looks at (a TIFF raw's IFD0, its Exif IFD, the maker-note IFD and +//! the values of the focus tags; a JPEG's Exif segment) and decodes them exactly as a whole-file +//! read would. On the α1 and the A7S III all of it sits in the first 16 KB of a 50–120 MB ARW, so a +//! library of them can be backfilled from a slow disk by reading one small block per photo. +//! +//! The ranges are found by walking the structure as the readers do (the IFD tables and the value +//! offsets they hold), round by round: each round asks only for what the bytes loaded so far point +//! at. A TIFF is decoded from a buffer of the file's length in which only the loaded ranges hold the +//! file's bytes (the rest stays zero and its pages are never touched), so every offset and bound the +//! reader checks is the one it checks on the whole file. Rounds, bytes and the buffer are bounded: +//! a file that wants more is reported as having no focus point. + +use std::io; + +use crate::focus::FocusPoint; +use lightcraft_tiff::{ByteOrder, FieldType, Tiff, tags as t}; + +/// Bytes read first: the α1's and A7S III's focus data ends before 16 KB, so one read covers it. +pub const HEADER_PREFIX: u64 = 32 * 1024; +/// Later reads are widened to this granularity (fewer, larger reads on a spinning disk). +const ALIGN: u64 = 4 * 1024; +/// The most rounds of reads one file gets (a real file needs one; the walk takes at most nine). +const MAX_ROUNDS: usize = 12; +/// The most bytes read from one file: past this the layout is not one the decoder knows. +pub const MAX_HEADER_BYTES: u64 = 2 << 20; +/// The largest TIFF given a buffer of its length (its untouched pages are never written, but the +/// allocation must succeed: the engine's planned preview reads use the same bound). Raws are far below it. +const MAX_SPARSE_FILE: u64 = 1 << 30; +/// JPEG segments walked before giving up. +const MAX_SEGMENTS: usize = 1024; +/// Entries read from one IFD (the TIFF reader's limit). +const MAX_ENTRIES: u64 = 8192; + +/// The focus tags whose values [`crate::focus`] reads from a Sony maker note. +const FOCUS_TAGS: [u16; 6] = [0x201b, 0x201c, 0x2021, 0x2027, 0x2037, 0x9402]; + +/// What [`read_header`] found, and what it cost. +#[derive(Clone, Debug, PartialEq)] +pub struct HeaderRead { + /// The focus point a whole-file read gives ([`crate::extract`]`(file).focus`). + pub focus: Option, + /// Bytes read from the file. + pub bytes_read: u64, +} + +/// The focus point of a file of `len` bytes, reading only what it needs through `read_at(offset, +/// buf)`, which fills `buf` with the file's bytes at `offset` (an error — a file that shrank or +/// went away — is returned). A malformed or unknown file is `Ok` with no point. +pub fn read_header(len: u64, read_at: &mut dyn FnMut(u64, &mut [u8]) -> io::Result<()>) -> io::Result { + let mut reader = Reader { len, read_at, bytes: 0 }; + let head = reader.fetch(0, HEADER_PREFIX.min(len))?; + let focus = if head.starts_with(&[0xff, 0xd8]) { + jpeg(&mut reader, &head)? + } else if matches!(Tiff::sniff(&head), Some((_, false))) { + tiff(&mut reader, head)? + } else { + None + }; + Ok(HeaderRead { focus, bytes_read: reader.bytes }) +} + +struct Reader<'a> { + len: u64, + read_at: &'a mut dyn FnMut(u64, &mut [u8]) -> io::Result<()>, + bytes: u64, +} + +impl Reader<'_> { + /// `[start, end)` of the file (clipped to it), counted against [`MAX_HEADER_BYTES`]. + fn fetch(&mut self, start: u64, end: u64) -> io::Result> { + let end = end.min(self.len); + let n = end.saturating_sub(start); + if self.bytes.saturating_add(n) > MAX_HEADER_BYTES { + return Err(io::Error::other(OverBudget)); + } + let mut buf = vec![0u8; usize::try_from(n).map_err(|_| io::Error::other("range too large"))?]; + if n > 0 { + (self.read_at)(start, &mut buf)?; + } + self.bytes = self.bytes.saturating_add(n); + Ok(buf) + } +} + +/// A file whose focus data would take more than [`MAX_HEADER_BYTES`] to read. +#[derive(Debug)] +struct OverBudget; + +impl std::fmt::Display for OverBudget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("focus data larger than a header") + } +} + +impl std::error::Error for OverBudget {} + +fn over_budget(e: &io::Error) -> bool { + e.get_ref().is_some_and(|i| i.is::()) +} + +/// An over-budget read is "no focus point here", not a failure to report. +fn budget_is_none(r: io::Result>) -> io::Result> { + match r { + Err(e) if over_budget(&e) => Ok(None), + r => r, + } +} + +/// A JPEG's first `Exif` APP1 segment, found as [`crate::jpeg_segments`] finds it. +fn jpeg(r: &mut Reader, head: &[u8]) -> io::Result> { + budget_is_none(jpeg_inner(r, head)) +} + +fn jpeg_inner(r: &mut Reader, head: &[u8]) -> io::Result> { + let len = r.len; + let at = |r: &mut Reader, start: u64, n: u64| -> io::Result> { + let local = usize::try_from(start).ok().zip(usize::try_from(start.saturating_add(n)).ok()).and_then(|(a, b)| head.get(a..b)); + match local { + Some(s) => Ok(s.to_vec()), + None => r.fetch(start, start.saturating_add(n)), + } + }; + let mut i = 2u64; + for _ in 0..MAX_SEGMENTS { + if i.saturating_add(4) > len { + break; + } + let h = at(r, i, 4)?; + let (Some(&ff), Some(&marker), Some(&a), Some(&b)) = (h.first(), h.get(1), h.get(2), h.get(3)) else { break }; + if ff != 0xff { + break; + } + if marker == 0xff { + i += 1; + continue; + } + if marker == 0xd8 || (0xd0..=0xd7).contains(&marker) || marker == 0x01 { + i += 2; + continue; + } + if marker == 0xd9 || marker == 0xda { + break; + } + let seg = u64::from(u16::from_be_bytes([a, b])); + if seg < 2 { + break; + } + let end = i.saturating_add(2).saturating_add(seg).min(len); + if marker == 0xe1 { + let payload = at(r, i + 4, end.saturating_sub(i + 4))?; + if payload.starts_with(b"Exif\0") { + return Ok(crate::read_exif(payload.get(6..).unwrap_or(&[])).focus); + } + } + i = end; + } + Ok(None) +} + +/// A TIFF raw, decoded from a buffer of its length holding only the ranges [`plan`] asked for. +fn tiff(r: &mut Reader, head: Vec) -> io::Result> { + if r.len > MAX_SPARSE_FILE { + return Ok(None); + } + let Ok(len) = usize::try_from(r.len) else { return Ok(None) }; + let mut buf = vec![0u8; len]; + let Some(dst) = buf.get_mut(..head.len()) else { return Ok(None) }; + dst.copy_from_slice(&head); + let mut loaded = Loaded::default(); + loaded.insert(0, head.len() as u64); + for _ in 0..MAX_ROUNDS { + match plan(&buf, &loaded) { + Step::Ready => return Ok(crate::try_read_exif(&buf).ok().and_then(|m| m.focus)), + Step::Nothing => return Ok(None), + Step::Need(ranges) => { + for (a, b) in ranges { + // widen to the read granularity, but never re-read what is loaded + let (wa, wb) = ((a / ALIGN) * ALIGN, b.div_ceil(ALIGN).saturating_mul(ALIGN).min(r.len)); + for (s, e) in loaded.gaps(wa, wb) { + let bytes = match r.fetch(s, e) { + Err(e) if over_budget(&e) => return Ok(None), + other => other?, + }; + let (Ok(su), Ok(eu)) = (usize::try_from(s), usize::try_from(e)) else { return Ok(None) }; + let Some(dst) = buf.get_mut(su..eu) else { return Ok(None) }; + dst.copy_from_slice(&bytes); + loaded.insert(s, e); + } + } + } + } + } + Ok(None) +} + +/// Sorted, disjoint, non-touching byte ranges. +#[derive(Default)] +struct Loaded(Vec<(u64, u64)>); + +impl Loaded { + fn insert(&mut self, start: u64, end: u64) { + if start >= end { + return; + } + let (mut s, mut e) = (start, end); + let mut out = Vec::with_capacity(self.0.len() + 1); + for &(a, b) in &self.0 { + if b < s || a > e { + out.push((a, b)); + } else { + s = s.min(a); + e = e.max(b); + } + } + out.push((s, e)); + out.sort_unstable(); + self.0 = out; + } + + fn contains(&self, start: u64, end: u64) -> bool { + start >= end || self.0.iter().any(|&(a, b)| a <= start && end <= b) + } + + /// The parts of `[start, end)` not loaded. + fn gaps(&self, start: u64, end: u64) -> Vec<(u64, u64)> { + let mut out = Vec::new(); + let mut at = start; + for &(a, b) in &self.0 { + if b <= at || a >= end { + continue; + } + if a > at { + out.push((at, a.min(end))); + } + at = at.max(b); + if at >= end { + break; + } + } + if at < end { + out.push((at, end)); + } + out + } +} + +enum Step { + /// Load these and ask again. + Need(Vec<(u64, u64)>), + /// Everything the decoder reads is loaded. + Ready, + /// The file has nothing the decoder could read (no Exif, no maker note, a structure it rejects). + Nothing, +} + +/// One IFD entry as the TIFF reader sees it: where its value is (absolute), if it fits the file. +struct RawEntry { + tag: u16, + typ: FieldType, + start: u64, + end: u64, +} + +struct Want<'a> { + buf: &'a [u8], + order: ByteOrder, + loaded: &'a Loaded, + need: Vec<(u64, u64)>, +} + +impl Want<'_> { + fn len(&self) -> u64 { + self.buf.len() as u64 + } + + /// Whether `[start, end)` (clipped to the file) is loaded; asks for it if not. + fn want(&mut self, start: u64, end: u64) -> bool { + let end = end.min(self.len()); + if self.loaded.contains(start, end) { + return true; + } + self.need.push((start, end)); + false + } + + /// The entries of the IFD at `off` (values relative to 0), once its table is loaded; `Some(empty)` + /// for one the reader would reject. + fn table(&mut self, off: u64) -> Option> { + let len = self.len(); + if off == 0 || off >= len { + return Some(Vec::new()); + } + if !self.want(off, off.saturating_add(2)) { + return None; + } + let pos = usize::try_from(off).ok()?; + let n = u64::from(self.order.read_u16(self.buf, pos)?); + let table = off + 2; + let n_read = n.min(len.saturating_sub(table) / 12).min(MAX_ENTRIES); + let end = table + n_read * 12 + if n_read == n { 4 } else { 0 }; + if !self.want(off, end) { + return None; + } + let mut out = Vec::new(); + for i in 0..n_read { + let Ok(p) = usize::try_from(table + i * 12) else { continue }; + let (Some(tag), Some(typ), Some(count)) = ( + self.order.read_u16(self.buf, p), + self.order.read_u16(self.buf, p + 2).and_then(FieldType::from_u16), + self.order.read_u32(self.buf, p + 4), + ) else { + continue; + }; + let Some(total) = u64::from(count).checked_mul(typ.size() as u64) else { continue }; + let field = p as u64 + 8; + let start = if total <= 4 { + field + } else { + match self.order.read_u32(self.buf, p + 8) { + Some(v) => u64::from(v), + None => continue, + } + }; + let Some(end) = start.checked_add(total).filter(|&e| e <= len) else { continue }; + out.push(RawEntry { tag, typ, start, end }); + } + Some(out) + } + + /// The first unsigned value of an entry (loaded). + fn first_value(&self, e: &RawEntry) -> Option { + let pos = usize::try_from(e.start).ok()?; + match e.typ.size() { + 1 => self.buf.get(pos).map(|&b| u64::from(b)), + 2 => self.order.read_u16(self.buf, pos).map(u64::from), + 4 => self.order.read_u32(self.buf, pos).map(u64::from), + _ => self.order.read_u64(self.buf, pos), + } + } + + fn step(self) -> Step { + if self.need.is_empty() { Step::Ready } else { Step::Need(self.need) } + } +} + +/// What of a classic TIFF the focus decoder still needs, given what is `loaded`: IFD0 (Make, Model, +/// the Exif pointer), the Exif IFD (the maker-note entry), the maker note's header and IFD as +/// `lightcraft_tiff::makernote` locates it, and the focus tags' values. +fn plan(buf: &[u8], loaded: &Loaded) -> Step { + let Some((order, false)) = Tiff::sniff(buf) else { return Step::Nothing }; + let mut w = Want { buf, order, loaded, need: Vec::new() }; + let Some(ifd0) = order.read_u32(buf, 4).map(u64::from) else { return Step::Nothing }; + let Some(e0) = w.table(ifd0) else { return w.step() }; + let mut exif = None; + for e in &e0 { + if matches!(e.tag, t::MAKE | t::MODEL | t::UNIQUE_CAMERA_MODEL | t::ORIENTATION) { + w.want(e.start, e.end); + } + if e.tag == t::EXIF_IFD && exif.is_none() && w.want(e.start, e.end) { + exif = w.first_value(e); + } + } + if !w.need.is_empty() { + return w.step(); + } + let Some(exif) = exif.filter(|&o| o != 0) else { return Step::Nothing }; + let Some(ee) = w.table(exif) else { return w.step() }; + let Some(note) = ee.iter().find(|e| e.tag == t::MAKER_NOTE) else { return Step::Nothing }; + let head_end = note.start.saturating_add(12).min(note.end); + if !w.want(note.start, head_end) { + return w.step(); + } + let head = usize::try_from(note.start).ok().zip(usize::try_from(head_end).ok()).and_then(|(a, b)| buf.get(a..b)).unwrap_or(&[]); + let sony = head.starts_with(b"SONY") || head.starts_with(b"VHAB \0"); + // another maker's header: `focus::read` only decodes Sony and header-less notes + let foreign: [&[u8]; 13] = [ + b"Nikon\0", + b"FUJIFILM", + b"GENERALE", + b"OLYMPUS\0", + b"OM SYSTEM\0", + b"OLYMP\0", + b"EPSON\0", + b"MINOL\0", + b"CAMER\0", + b"SANYO\0", + b"Panasonic\0", + b"AOC\0", + b"PENTAX \0", + ]; + if !sony && foreign.iter().any(|p| head.starts_with(p)) { + return Step::Nothing; + } + let mut entries = None; + if sony { + match w.table(note.start.saturating_add(12)) { + None => return w.step(), + Some(e) if !e.is_empty() => entries = Some(e), + Some(_) => {} + } + } + let entries = match entries { + Some(e) => e, + None => match w.table(note.start) { + Some(e) => e, + None => return w.step(), + }, + }; + for e in entries.iter().filter(|e| FOCUS_TAGS.contains(&e.tag)) { + w.want(e.start, e.end); + } + w.step() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A reader over `file` that counts what it is asked for. + fn read(file: &[u8]) -> (io::Result, u64) { + let mut asked = 0u64; + let r = read_header(file.len() as u64, &mut |off, buf: &mut [u8]| { + asked += buf.len() as u64; + let start = usize::try_from(off).map_err(io::Error::other)?; + let src = file.get(start..start + buf.len()).ok_or_else(|| io::Error::from(io::ErrorKind::UnexpectedEof))?; + buf.copy_from_slice(src); + Ok(()) + }); + (r, asked) + } + + #[test] + fn loaded_ranges() { + let mut l = Loaded::default(); + l.insert(10, 20); + l.insert(30, 40); + assert_eq!(l.gaps(0, 50), vec![(0, 10), (20, 30), (40, 50)]); + l.insert(20, 30); + assert_eq!(l.0, vec![(10, 40)]); + assert!(l.contains(12, 38) && !l.contains(5, 12)); + assert!(l.gaps(15, 35).is_empty()); + } + + #[test] + fn garbage_and_empty_files_are_no_point() { + for file in [vec![], vec![0xff, 0xd8], b"II*\0\x08\0\0\0".to_vec(), b"MM\0*\xff\xff\xff\xff".to_vec(), vec![0x42; 100_000]] { + let (r, _) = read(&file); + assert_eq!(r.unwrap().focus, None); + } + // a JPEG of nothing but fill bytes and tiny segments stops at its bounds + let mut j = vec![0xff, 0xd8]; + for _ in 0..5000 { + j.extend_from_slice(&[0xff, 0xe2, 0, 2]); + } + assert_eq!(read(&j).0.unwrap().focus, None); + } + + #[test] + fn a_file_that_shrinks_mid_read_is_an_error() { + // the reader is told the file is longer than it is (replaced between the stat and the read) + let r = read_header(1 << 20, &mut |_, _| Err(io::Error::from(io::ErrorKind::UnexpectedEof))); + assert!(r.is_err()); + } +} diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index abf6fd868..ad8ffbafd 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -16,6 +16,7 @@ pub mod cr3; mod datetime; mod exif; pub mod focus; +mod focus_read; mod gpx; mod iptc; pub mod tags; diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index db5349830..0256c33f3 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2136,5 +2136,7 @@ "Tracking": "Tracking", "Face Tracking": "Gesichtsverfolgung", "Animal Eye Tracking": "Tieraugen-Tracking", - "Human Eye Tracking": "Augen-Tracking (Mensch)" + "Human Eye Tracking": "Augen-Tracking (Mensch)", + "Read Focus Points": "Fokuspunkte lesen", + "Reading focus points": "Fokuspunkte werden gelesen" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index e94a5b377..5167800df 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -2013,5 +2013,7 @@ "Tracking": "Seguimiento", "Face Tracking": "Seguimiento de caras", "Animal Eye Tracking": "Seguimiento de ojos de animales", - "Human Eye Tracking": "Seguimiento de ojos humanos" + "Human Eye Tracking": "Seguimiento de ojos humanos", + "Read Focus Points": "Leer puntos de enfoque", + "Reading focus points": "Leyendo puntos de enfoque" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 89b1d309b..0660fe67f 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1975,5 +1975,7 @@ "Tracking": "Suivi", "Face Tracking": "Suivi des visages", "Animal Eye Tracking": "Suivi des yeux d’animaux", - "Human Eye Tracking": "Suivi des yeux humains" + "Human Eye Tracking": "Suivi des yeux humains", + "Read Focus Points": "Lire les points de mise au point", + "Reading focus points": "Lecture des points de mise au point" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index bec052af7..10314b8a9 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1979,5 +1979,7 @@ "Tracking": "トラッキング", "Face Tracking": "顔トラッキング", "Animal Eye Tracking": "動物瞳トラッキング", - "Human Eye Tracking": "人物瞳トラッキング" + "Human Eye Tracking": "人物瞳トラッキング", + "Read Focus Points": "フォーカスポイントを読み込む", + "Reading focus points": "フォーカスポイントを読み込み中" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index e64080fa3..e92b5d0be 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -2016,5 +2016,7 @@ "Tracking": "Rastreamento", "Face Tracking": "Rastreamento de rosto", "Animal Eye Tracking": "Rastreamento de olhos de animais", - "Human Eye Tracking": "Rastreamento de olhos humanos" + "Human Eye Tracking": "Rastreamento de olhos humanos", + "Read Focus Points": "Ler pontos de foco", + "Reading focus points": "Lendo pontos de foco" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 7e1de22df..92753fd94 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2169,5 +2169,7 @@ "Tracking": "Слежение", "Face Tracking": "Отслеживание лиц", "Animal Eye Tracking": "Слежение за глазами животных", - "Human Eye Tracking": "Слежение за глазами людей" + "Human Eye Tracking": "Слежение за глазами людей", + "Read Focus Points": "Прочитать точки фокусировки", + "Reading focus points": "Чтение точек фокусировки" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 5f884dfb2..7e07ec82c 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2169,5 +2169,7 @@ "Tracking": "Стеження", "Face Tracking": "Відстеження облич", "Animal Eye Tracking": "Стеження за очима тварин", - "Human Eye Tracking": "Стеження за очима людей" + "Human Eye Tracking": "Стеження за очима людей", + "Read Focus Points": "Прочитати точки фокусування", + "Reading focus points": "Читання точок фокусування" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index b5e340a5c..6652d249e 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -2015,5 +2015,7 @@ "Tracking": "追踪", "Face Tracking": "人脸追踪", "Animal Eye Tracking": "动物眼部追踪", - "Human Eye Tracking": "人眼追踪" + "Human Eye Tracking": "人眼追踪", + "Read Focus Points": "读取对焦点", + "Reading focus points": "正在读取对焦点" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index af33e1f94..103164ad8 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1977,5 +1977,7 @@ "Tracking": "追蹤", "Face Tracking": "臉部追蹤", "Animal Eye Tracking": "動物眼部追蹤", - "Human Eye Tracking": "人眼追蹤" + "Human Eye Tracking": "人眼追蹤", + "Read Focus Points": "讀取對焦點", + "Reading focus points": "正在讀取對焦點" } diff --git a/crates/ui-egui/src/focus_scan.rs b/crates/ui-egui/src/focus_scan.rs new file mode 100644 index 000000000..de6e292dc --- /dev/null +++ b/crates/ui-egui/src/focus_scan.rs @@ -0,0 +1,24 @@ +//! Keeps the engine's focus-point reader going (`focus.pump`): it reads the camera's focus point for photos imported +//! before it was read, a few KB of each file, at the pace the face scan uses (nothing new while the user drags, types or +//! scrolls). Its progress is its row in the activity stack. + +use serde_json::json; + +use crate::LightcraftApp; + +/// How often a running reader is asked for what it has read (its batches commit from here). +const BUSY_SECS: f64 = 0.25; + +/// Called every frame. Idle, it costs a comparison and wakes nothing; reading, it asks again every [`BUSY_SECS`]. +pub fn pump(app: &mut LightcraftApp, ctx: &egui::Context) { + let now = ctx.input(|i| i.time); + if now < app.caches.focus_next_pump { + return; + } + let pace = crate::panels::faces::background_pace(app, ctx, now); + let Ok(v) = app.session.execute("focus.pump", &json!({"pace": pace})) else { return }; + if v["active"] == true { + app.caches.focus_next_pump = now + BUSY_SECS; + ctx.request_repaint_after(std::time::Duration::from_secs_f64(BUSY_SECS)); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bfd72a9c6..090f29137 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -13,6 +13,7 @@ pub mod credits; pub mod date_picker; pub mod docking; pub mod export_task; +mod focus_scan; pub mod headless; pub mod i18n; pub mod icons; @@ -1079,6 +1080,7 @@ impl LightcraftApp { panels::denoise::pump(self, ctx); model_setup::pump(self, ctx); panels::faces::pump(self, ctx); + focus_scan::pump(self, ctx); self.collect_screenshots(ctx); self.issue_screenshots(ctx); if self.fonts_ready { @@ -1512,6 +1514,8 @@ pub struct Caches { pub faces_indexed: u64, pub faces_pending: u64, pub faces_next_pump: f64, + /// When to ask the focus-point reader again (`focus_scan::pump`). + pub focus_next_pump: f64, /// When the user last dragged, typed or scrolled, and when they last moved the pointer (egui time). pub last_input: f64, /// The pace the face scan was last given, and whether the window was in front then (for `ui.inspect`). diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 43703ed9c..b753d1ef2 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -204,6 +204,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "photo.saveMetadataToFile", "photo.readMetadataFromFile", "photo.reload", + "photo.readFocusPoints", "app.showInFinder", "dialog.rename", "dialog.captureTime", diff --git a/crates/ui-egui/src/panels/faces.rs b/crates/ui-egui/src/panels/faces.rs index 2b3fe0329..902dabc06 100644 --- a/crates/ui-egui/src/panels/faces.rs +++ b/crates/ui-egui/src/panels/faces.rs @@ -27,6 +27,13 @@ fn scan_pace(app: &LightcraftApp, ctx: &egui::Context, now: f64) -> (&'static st (pace_for(focused, minimized, now - app.caches.last_input, now - app.caches.last_move, watching), focused && !minimized) } +/// The pace for other background work that reads the library's files (the focus-point reader): the face scan's, without +/// the "watching" speed-up. +pub(crate) fn background_pace(app: &LightcraftApp, ctx: &egui::Context, now: f64) -> &'static str { + let (focused, minimized) = ctx.input(|i| (i.focused, i.raw.viewports.get(&i.raw.viewport_id).and_then(|v| v.minimized).unwrap_or(false))); + pace_for(focused, minimized, now - app.caches.last_input, now - app.caches.last_move, false) +} + /// The pace for a window that has the keyboard (or not) and is minimized (or not), `worked` seconds after the user last /// dragged, typed or scrolled and `moved` seconds after they last moved the pointer, while they are (or are not) looking /// at the scan's progress. diff --git a/crates/ui-egui/src/tests_focus_point.rs b/crates/ui-egui/src/tests_focus_point.rs index fa9ae60a0..f93dd4f7b 100644 --- a/crates/ui-egui/src/tests_focus_point.rs +++ b/crates/ui-egui/src/tests_focus_point.rs @@ -156,3 +156,34 @@ fn compare_and_survey_draw_each_photos_focus_point() { assert!(widget(&h, &format!("compare:focusPoint:{}", other.0)).is_none(), "{view}: a box on a photo without one"); } } + +/// A photo imported before focus points were read gets its point from the background reader the frame loop drives +/// (`focus.pump`, started by itself once the library is open), and Photo ▸ Read Focus Points runs it on demand. +#[test] +fn the_frame_loop_reads_focus_points_for_photos_imported_earlier() { + let dir = std::env::temp_dir().join(format!("lc-ui-focus-scan-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + let add = |app: &mut LightcraftApp, id: u64, name: &str| { + let path = dir.join(name); + std::fs::write(&path, lightcraft_meta::focus::synthetic_arw(3, 200_000)).expect("write"); + let mut p = Photo::new(PhotoId(id), Source::File { path: path.to_string_lossy().into() }, name, "ARW", 8640, 5760, "2026-10-13T00:00:00"); + p.meta.camera = "SONY ILCE-1".into(); + app.session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).expect("add"); + }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + app.ui.settings.gpu = false; + add(&mut app, 9001, "old.ARW"); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + let has_point = |h: &Headless, id: u64| h.app.session.catalog.photo(PhotoId(id)).is_some_and(|p| p.meta.focus.is_some()); + assert!(h.step_until(SETTLE, |h| has_point(h, 9001)), "read by itself"); + assert!(h.step_until(T, |h| h.app.session.activity.list().iter().all(|t| t.kind != "focus")), "its row goes when it is done"); + // a photo added later is read when asked (the menu item runs the command) + add(&mut h.app, 9002, "later.ARW"); + let spec = h.app.session.commands().into_iter().find(|c| c.id == "photo.readFocusPoints").expect("the command"); + assert_eq!(spec.menu, vec!["Photo"]); + let r = run(&mut h, "photo.readFocusPoints", json!({})); + assert_eq!(r["queued"], 1, "{r}"); + assert!(h.step_until(SETTLE, |h| has_point(h, 9002))); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/background-tasks.md b/docs/background-tasks.md index 02336c97c..5a09a9a48 100644 --- a/docs/background-tasks.md +++ b/docs/background-tasks.md @@ -33,7 +33,7 @@ guard.detail(&file_name); dead job never leaves a stuck bar. A worker that only reports progress takes a `TaskHandle` (`guard.handle()`, `Clone + Send`). - **`kind`** is a stable camelCase id that agents filter by (`export`, `import`, `scan`, `sync`, `previews`, - `download`, `faces`…). **`label`** is a short English name in sentence case ("Building previews"). The stack shows it + `download`, `faces`, `focus`…). **`label`** is a short English name in sentence case ("Building previews"). The stack shows it translated, so add it to every catalog in `crates/ui-egui/locales/`. - **Progress:** `progress(done, total)`. A `total` of 0 means the amount of work isn't known yet, and the bar sweeps. `set_unit(Unit::Bytes)` makes the count read "12 of 340 MB"; `Unit::Percent` reads "40 %". The default `Unit::Count` @@ -59,6 +59,7 @@ guard.detail(&file_name); | Lightroom catalog import | `crates/ui-egui/src/lightroom_import.rs` | stop being cancellable for a phase that must finish | | Face scan, AI Denoise | `crates/engine/src/cmd/face_recognize.rs`, `crates/engine/src/denoise.rs` | a continuous job with no ✕, kept in session state | | Find Missing Photos | `crates/ui-egui/src/tasks.rs` | a generic background task with a row (`kind: Some(..)`) | +| Reading focus points | `crates/engine/src/focus_scan.rs` | a worker paced by the UI's per-frame pump (`focus.pump {pace}`); `Cancel::Flag`; commits in batches without undo steps | ## Testing diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 185ad2678..a5ea5ad4a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -107,6 +107,9 @@ The camera's focus point ([focus-points.md](focus-points.md)) is `meta.focus` in x1, y1}, mode?, area?, tracking?}`, normalized to the upright photo (absent when the file records none). `view.focusPoint {on?}` shows it over the photo (no params toggles; replies `{on}`), and `view.zoomToFocus {on?}` (on by default) makes `view.zoomToggle` zoom in centred on it, replying `{focus: [x, y]}` (the `pan` it set). +`photo.readFocusPoints {ids?, wait?}` reads the point for photos imported before it was read, from a few KB of each +file, on a background job (activity kind `focus`); `wait: true` returns the run's counts when done. The app calls +`focus.pump {pace}` every frame to drive it. The interface theme is `view.appearance {mode?: "auto" | "light" | "dark"}` (no mode: the next one, Auto → Light → Dark → Auto, like the top bar's `icon:appearance` button) and `view.theme {theme: diff --git a/docs/focus-points.md b/docs/focus-points.md index c6993948f..61c562b03 100644 --- a/docs/focus-points.md +++ b/docs/focus-points.md @@ -21,11 +21,46 @@ zooms to it, so checking whether the subject is sharp takes one key while cullin Photos without one have no `focus` key. `view.zoomToggle` replies `{focus: [x, y]}` (the pan it centred on) when it zoomed to one. -The point is read when a photo is imported. Photos imported earlier get it with **Photo ▸ Reload from Disk** -(`photo.reload`), which fills camera data the catalog lacks; it never replaces a point already stored. It is +The point is read when a photo is imported. Photos imported earlier get it in the background (below). It is read-only camera data: never written to XMP, and absent from the catalog JSON when unknown, so older catalogs read unchanged (no catalog format change). +## Photos imported earlier + +A library imported before focus points were read gets them by itself: when a library opens and some of its photos +from a supported body have no point and haven't been looked at, LightCraft reads them in the background, with a +**Reading focus points** row in the activity stack (n of N; ✕ stops it). **Photo ▸ Read Focus Points** +(`photo.readFocusPoints`) starts the same job by hand. Photo ▸ Reload from Disk still fills a missing point too, but it +re-reads and hashes whole files and runs while you wait, so it is for a few photos, not a library. + +- **What is opened**: only photos whose stored camera is a supported body (`SONY ILCE-1`, `SONY ILCE-7SM3`), whose + file is an ARW or JPEG, that are in the library and have no point yet. Nothing else is opened. +- **What is read**: the few kilobytes that hold the focus data, not the file + (`lightcraft_meta::focus::read_header`). It walks the file's structure as the decoder does — the TIFF header and + IFD0 (Make, Model), the Exif IFD (the MakerNote entry), the maker note's IFD and the values of the focus tags; in a + JPEG, the segments up to the Exif one — asking only for the ranges the bytes read so far point at, and decodes them + with the same code as a whole-file read. On the α1 and the A7S III everything sits in the first 16 KB, so one 32 KB + read per photo does it (instead of 50–120 MB). Rounds (12), bytes (2 MB) and the file size are bounded; a file that + wants more is treated as having no point. On 109 real α1 / A7S III ARWs (83 with a point) the header read gave the + same point as the whole-file read for every file. +- **Pace**: one file at a time on one worker thread (the drive may be a spinning disk), in path order so neighbouring + files are read together. Nothing new is read while you drag, type or scroll, and it rests briefly between files + while you are around, so your own previews come first. Measured on a 5400 rpm USB drive, cold cache, 200 α1 / A7S III + ARWs: 1.9 s (about 100 photos a second, 32 KB each), so a library of 58,796 raws takes about ten minutes at full + pace instead of re-reading 2.25 TB. +- **Saving**: what was read is committed every 200 photos (or every 3 seconds) as LightCraft's own bookkeeping: no + undo step, no sidecar written, and only `meta.focus` changes, on a photo that still has none and still points at the + file that was read (a photo deleted, moved or relinked meanwhile is left for another run). Photos that turned out to + have no point (manual focus, a centre-only Wide report) are listed in `focus-scanned.bin` beside the library, so they + are not read again at every launch. The list is headed by the decoder's tag (the bodies it knows): when a body is + added, the list starts afresh and those photos are looked at again. A file that can't be read (an offline drive, a + file gone or replaced during the read) is not listed, and is tried again the next time the library opens. +- **Resuming**: quitting mid-run keeps what was committed; the next launch reads the rest. +- **Agents**: `photo.readFocusPoints {ids?, wait?}` — without `ids`, every photo not looked at yet; with `ids`, those + photos, looked at again even if listed. `wait: true` returns when done with `{last: {total, done, found, none, + unavailable, bytesRead, seconds, cancelled}}` (`lightcraft-cli run --library DIR photo.readFocusPoints wait=true`). + The app's frame loop calls `focus.pump {pace}`, which also starts the job once per library opening. + ## Cameras Sony first, on bodies whose files have been checked: **ILCE-1 (α1)** and **ILCE-7SM3 (A7S III)** @@ -75,5 +110,10 @@ DMF (AF, then manual touch-up) keeps its point. - `crates/meta/src/focus.rs`: the decoder (bounded; hostile values give no point, never a panic) and its tests, including an opt-in test over real files (`LIGHTCRAFT_FOCUS_SAMPLES=`). +- `crates/meta/src/focus_read.rs`: the header-only read; its tests check it against the whole-file read on intact, + truncated and corrupted files and count what it reads. +- `crates/engine/src/focus_scan.rs`: the background job (`photo.readFocusPoints`, `focus.pump`) and + `crates/engine/src/tests_focus_scan.rs`, with an opt-in comparison over real files + (`LIGHTCRAFT_FOCUS_FILES=`); `crates/ui-egui/src/focus_scan.rs` drives it from the frame loop. - `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `focus_pan`, `draw_focus_point`): mapping through the user's rotation and crop, the overlay, and the zoom target; `crates/ui-egui/src/tests_focus_point.rs`. diff --git a/docs/mcp.md b/docs/mcp.md index d3d23e325..51fb7968a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -208,7 +208,7 @@ still reaches every command. |---|---| | `lightcraft://library` | Source, filter, sort, selection, undo/redo labels (`library.state`) | | `lightcraft://photos` | Photos in the current view (`catalog.query`) | -| `lightcraft://photo/active` | Everything about the active photo (`photo.inspect`), including the camera's focus point as `meta.focus` when the file records one ([focus-points.md](focus-points.md)) | +| `lightcraft://photo/active` | Everything about the active photo (`photo.inspect`), including the camera's focus point as `meta.focus` when the file records one ([focus-points.md](focus-points.md); `run_command photo.readFocusPoints {wait: true}` reads it for photos imported before it was read, a few KB per file) | | `lightcraft://develop/active` | The active photo's develop settings (`develop.get`) | | `lightcraft://controls` | Every develop control with its current value (`develop.controls`) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 45bdaac01..578ea28c5 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -130,7 +130,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | | LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) | -| LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `crates/meta/src/focus.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import (Reload from Disk fills older photos) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Show Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | +| LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests_focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Show Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:`) or “This photo can't be opened” (`compare:renderFailed:`) as the loupe does | @@ -790,7 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| -| 2026-10-13 | minor | LR-VIEW-FOCUSPOINT (new, 🟡): the camera's focus point from Sony α1 / A7S III maker notes, drawn in Detail, Compare and Survey, the Info panel's Focus row, and Z zooming to it (#1480) | +| 2026-10-13 | minor | LR-VIEW-FOCUSPOINT (new, 🟡): the camera's focus point from Sony α1 / A7S III maker notes, drawn in Detail, Compare and Survey, the Info panel's Focus row, and Z zooming to it; photos already in the library read in the background, header only (#1480) | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 7bf854d0e..c30248ee5 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -9,8 +9,10 @@ - **Z zooms to it**: Toggle Zoom centres on the focus point, so checking that the subject is sharp is one key (View ▸ Zoom to Focus Point turns this off). Photos without a point zoom as before. - Sony α1 (ILCE-1) and A7S III (ILCE-7SM3) raws for now; manual-focus shots and Wide shots that only report the frame - centre show nothing. Photos imported earlier get their point with Photo ▸ Reload from Disk. More cameras will - follow. Details: [focus-points.md](focus-points.md). + centre show nothing. More cameras will follow. Details: [focus-points.md](focus-points.md). +- **Photos already in your library get theirs in the background**: LightCraft reads just the few kilobytes of each + file that hold the focus point (about 100 photos a second from a spinning disk), with a *Reading focus points* row + in the activity stack you can stop. Photo ▸ Read Focus Points runs it by hand. ### Slider drags on large raws - Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you From c854e172e41a59d59a1315c095517c82eb3f57e9 Mon Sep 17 00:00:00 2001 From: Michael Anderson Date: Tue, 13 Oct 2026 10:00:43 -0500 Subject: [PATCH 05/12] LR-PROF-CAMERACOLOR: Match the camera look against lens-corrected JPEGs The fit compared the uncorrected raw with the corrected JPEG. It now warps the raw with the file's own lens correction when that lines up better. --- crates/engine/src/camera_preview.rs | 252 ++++++++++++++++++++++++--- crates/engine/src/camera_profiles.rs | 6 +- crates/engine/src/files.rs | 5 +- docs/camera-preview-colour.md | 2 + docs/parity-checklist.md | 5 +- docs/whats-new.md | 5 + 6 files changed, 248 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index c4824997c..a958b9666 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -30,7 +30,8 @@ use lightcraft_raw::{ /// states, for every make (#778). /// 4: a tone curve from black-and-white camera JPEGs (#577). /// 5: dark JPEG luminance pairs for the camera toe (#1307). -pub const LOOK_VERSION: u32 = 5; +/// 6: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected. +pub const LOOK_VERSION: u32 = 6; #[derive(Clone, Debug)] pub(crate) struct CameraLook { @@ -74,8 +75,10 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool { /// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's /// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the /// as-shot white, the camera transform at it and the fitted look. -pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8]) -> (Xy, CameraTransform, Option) { - starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t), |raw, t| monochrome_look(raw, bytes, t)) +/// `lens` is the file's own lens-correction warp ([`lens_geometry`], read before the caller removes it from +/// `OpcodeList3`): the camera applied it to its JPEG, so the sensor proxy is warped the same way before pairing. +pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8], lens: Option<&LensGeometry>) -> (Xy, CameraTransform, Option) { + starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t, lens), |raw, t| monochrome_look(raw, bytes, t, lens)) } /// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white @@ -119,7 +122,8 @@ pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option Option { } /// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]). -pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> (Option, bool) { +pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> (Option, bool) { if !transform.matrix_is_fallback || !file_local_look(raw.format) { return (None, false); } - let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY) else { return (None, false) }; + let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, lens) else { return (None, false) }; let black_and_white = monochrome(&reference); // A camera profile pooled from many photos knows colours this photo shows too little of; // try its colour first and fit tone/chroma per photo (picture styles vary). @@ -340,7 +344,13 @@ const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000; /// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s /// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020), /// and per proxy pixel whether any sensor sample it covers is clipped. -fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f, Vec)> { +fn proxies( + raw: &RawImage, + bytes: &[u8], + transform: &CameraTransform, + size: usize, + lens: Option<&LensGeometry>, +) -> Option<(Rgb32f, Rgb32f, Vec)> { let edge = (2 * size).max(384) as u32; let decoded = crate::files::decode_raw_preview( bytes, @@ -370,6 +380,12 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2; let mut sensor = sensor_proxy(raw, k, edge as usize)?; let mut clipped = clip_mask(&sensor); + // The camera may have corrected its JPEG with the lens warp the file carries (Nikon Z lenses always, at up to 9 % + // zoom; other cameras when their correction setting is on): the sensor moved into that geometry is a candidate, + // kept below when it lines up with the JPEG better than the uncorrected one. + let warped = lens + .filter(|_| raw.format != RawFormat::Cr3) + .and_then(|l| Some((into_lens_geometry(&sensor, raw, k, l)?, into_lens_geometry(&clipped, raw, k, l)?))); let gain = 2f32.powf(transform.baseline_exposure as f32); let to_working = |p: [f32; 3]| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain)); if raw.format == RawFormat::Cr3 { @@ -383,7 +399,15 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz (sensor, clipped) = align_cr3_framing(sensor, clipped, &reference); } let mut sensor = fit(&sensor, size, size, Filter::Box); - let reference = reference_at(&reference, &sensor); + let mut reference_small = reference_at(&reference, &sensor); + if let Some((warped, warped_clipped)) = warped { + let candidate = fit(&warped, size, size, Filter::Box); + let candidate_reference = reference_at(&reference, &candidate); + if alignment(&candidate, &candidate_reference) > alignment(&sensor, &reference_small) { + (sensor, reference_small, clipped) = (candidate, candidate_reference, warped_clipped); + } + } + let reference = reference_small; // A proxy pixel is clipped when any sample it averages is (outside the CR3 framing the mask is // NaN: those pixels are left out of the pairs anyway). Resized to exactly the proxy's size, like // the reference, so the mask pairs pixel for pixel. @@ -545,12 +569,12 @@ fn align_cr3_framing(sensor: Rgb32f, clipped: Rgb32f, reference: &Rgb32f) -> (Rg /// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the /// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for formats with their own /// colour matrices, other files or unusable previews. -pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option> { +pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8], lens: Option<&LensGeometry>) -> Option> { if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) { return None; } let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw)); - let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY)?; + let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, lens)?; let to_camera = transform.matrix.inverse()?; // clipped sensor pixels don't record the scene's colour (see `fit_look`) let sensor = without_clipped(&sensor, &clipped).unwrap_or(sensor); @@ -565,6 +589,102 @@ pub(crate) fn fit_profile(pairs: &[([f64; 3], [f64; 3])]) -> Option<(Mat3, Optio Some((matrix, fit_hue_sat(pairs, &matrix))) } +/// A raw's lens-correction warp, as its camera applied it to the embedded JPEG: the first `WarpRectilinear` of +/// `OpcodeList3` (its green plane; the in-file corrections LightCraft reads have no tangential terms). `None` without +/// one, or for a DNG 1.6 `WarpRectilinear2` chain (phone DNGs, which carry colour matrices and are not fitted). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct LensGeometry { + /// `kr0..kr3`: source radius = r * (kr0 + kr1 r^2 + kr2 r^4 + kr3 r^6), r over the farthest corner distance. + k: [f64; 4], + /// The optical centre, relative to the active area (as the opcode has it). + center: [f64; 2], +} + +pub(crate) fn lens_geometry(list3: &[lightcraft_raw::Opcode]) -> Option { + use lightcraft_raw::Opcode; + let ops: Vec<&Opcode> = lightcraft_raw::opcodes::applied_ops(list3).collect(); + if ops.iter().any(|op| matches!(op, Opcode::WarpRectilinear2 { .. })) { + return None; + } + ops.into_iter().find_map(|op| match op { + Opcode::WarpRectilinear { planes, center } => { + let p = planes.get(1).or(planes.first())?; + let k = [p[0], p[1], p[2], p[3]]; + (k.iter().chain(center).all(|v| v.is_finite()) && (0.0..=1.0).contains(¢er[0]) && (0.0..=1.0).contains(¢er[1])) + .then_some(LensGeometry { k, center: *center }) + } + _ => None, + }) +} + +/// How well two same-size proxies line up: the correlation of their high-passed log luminance over the pixels both +/// have (a tone curve or colour change leaves it alone, a geometric offset lowers it). `-1` without enough pixels. +fn alignment(a: &Rgb32f, b: &Rgb32f) -> f64 { + let (w, h) = (a.width, a.height); + if (b.width, b.height) != (w, h) || w < 3 || h < 3 { + return -1.0; + } + let lum = |img: &Rgb32f| img.data.iter().map(|p| (luminance_2020(*p).max(1e-4) as f64).ln()).collect::>(); + let high = |l: &[f64]| -> Vec { + (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + if x == 0 || y == 0 || x + 1 == w || y + 1 == h { + return f64::NAN; + } + let around = [i - w - 1, i - w, i - w + 1, i - 1, i + 1, i + w - 1, i + w, i + w + 1]; + l[i] - around.iter().map(|&j| l[j]).sum::() / 8.0 + }) + .collect() + }; + let (ha, hb) = (high(&lum(a)), high(&lum(b))); + let pairs: Vec<(f64, f64)> = ha.into_iter().zip(hb).filter(|(x, y)| x.is_finite() && y.is_finite()).collect(); + if pairs.len() < 64 { + return -1.0; + } + let n = pairs.len() as f64; + let (ma, mb) = (pairs.iter().map(|p| p.0).sum::() / n, pairs.iter().map(|p| p.1).sum::() / n); + let cov: f64 = pairs.iter().map(|p| (p.0 - ma) * (p.1 - mb)).sum(); + let (va, vb): (f64, f64) = (pairs.iter().map(|p| (p.0 - ma).powi(2)).sum(), pairs.iter().map(|p| (p.1 - mb).powi(2)).sum()); + let r = cov / (va * vb).sqrt().max(1e-12); + if r.is_finite() { r } else { -1.0 } +} + +/// A `k`-binned sensor proxy of `raw` ([`sensor_proxy`]: the default crop, or the active area when the crop is +/// smaller than a block, at `1/k`) moved into the camera JPEG's geometry: each pixel samples the proxy where `lens` +/// maps it, and is NaN where that falls outside the proxy (the pairs leave it out). `None` for a proxy of another +/// size (the demosaiced fallback of linear raws). +fn into_lens_geometry(img: &Rgb32f, raw: &RawImage, k: usize, lens: &LensGeometry) -> Option { + let a = raw.active_area; + let c = raw.crop.clipped(a.width, a.height); + let c = if c.width < k || c.height < k { lightcraft_raw::Rect::new(0, 0, a.width, a.height) } else { c }; + let (w, h) = (img.width, img.height); + if k == 0 || w != c.width / k || h != c.height / k || w == 0 || h == 0 || a.width < 2 || a.height < 2 { + return None; + } + let (aw, ah) = (a.width as f64 - 1.0, a.height as f64 - 1.0); + let (cx, cy) = (lens.center[0] * aw, lens.center[1] * ah); + let m = [(0.0, 0.0), (aw, 0.0), (0.0, ah), (aw, ah)].iter().map(|&(x, y)| (x - cx).hypot(y - cy)).fold(0.0, f64::max).max(1e-9); + let [k0, k1, k2, k3] = lens.k; + let kf = k as f64; + let (x0, y0) = (c.x as f64, c.y as f64); + let mut out = Rgb32f::new(w, h); + for (i, px) in out.data.iter_mut().enumerate() { + // the proxy pixel's centre in active-area pixel-index coordinates, mapped to the sensor + let (ox, oy) = (x0 + ((i % w) as f64 + 0.5) * kf - 0.5, y0 + ((i / w) as f64 + 0.5) * kf - 0.5); + let (dx, dy) = ((ox - cx) / m, (oy - cy) / m); + let r2 = dx * dx + dy * dy; + let f = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)); + let (sx, sy) = ((cx + m * dx * f + 0.5 - x0) / kf, (cy + m * dy * f + 0.5 - y0) / kf); + *px = if sx >= 0.5 && sy >= 0.5 && sx <= w as f64 - 0.5 && sy <= h as f64 - 0.5 { + img.sample_bilinear(sx as f32, sy as f32) + } else { + [f32::NAN; 3] + }; + } + Some(out) +} + fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option { Some(match raw.develop_binned(k, SENSOR_CLIP).ok()? { Some(sensor) => sensor, @@ -1052,11 +1172,11 @@ fn monochrome(reference: &Rgb32f) -> bool { /// `transform` is the colour the photo gets otherwise (the spectral matrices or the fallback's); the look keeps it and /// adds the tone curve alone ([`fit_tone_only`]), as [`fit_look`] tries the photo's own colour after a profile's. The /// chroma curve stays neutral: the JPEG has no colourfulness to learn. -fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option { +fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> Option { if !file_local_look(raw.format) { return None; } - let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY)?; + let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, lens)?; if !monochrome(&reference) { return None; } @@ -2005,6 +2125,93 @@ mod tests { } } + /// The lens warp the colour fit follows is the first `WarpRectilinear`'s green plane; no warp, a DNG 1.6 chain + /// (phone DNGs, which carry colour matrices), a centre outside the area or a non-finite term give none. + #[test] + fn lens_geometry_reads_the_first_rectilinear_warp() { + use lightcraft_raw::Opcode; + let warp = |k0: f64, c: [f64; 2]| Opcode::WarpRectilinear { + planes: vec![[9.0, 0.0, 0.0, 0.0, 0.0, 0.0], [k0, 0.1, -0.02, 0.003, 0.0, 0.0], [9.0, 0.0, 0.0, 0.0, 0.0, 0.0]], + center: c, + }; + assert_eq!(lens_geometry(&[warp(0.97, [0.5, 0.4])]), Some(LensGeometry { k: [0.97, 0.1, -0.02, 0.003], center: [0.5, 0.4] })); + let single = Opcode::WarpRectilinear { planes: vec![[1.02, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }; + assert_eq!(lens_geometry(std::slice::from_ref(&single)).map(|g| g.k[0]), Some(1.02)); + assert!(lens_geometry(&[]).is_none()); + assert!(lens_geometry(&[warp(1.0, [1.5, 0.5])]).is_none()); + assert!(lens_geometry(&[warp(f64::NAN, [0.5, 0.5])]).is_none()); + let mut p = [0.0; lightcraft_raw::opcodes::WR2_LEN]; + p[0] = 1.0; + p[18] = 1e6; + let chain = Opcode::WarpRectilinear2 { planes: vec![p], center: [0.5, 0.5], reciprocal: false, optional: false }; + assert!(lens_geometry(&[chain, single]).is_none()); + } + + /// A binned sensor proxy moved into the camera JPEG's geometry: the identity warp leaves it as it is, a warp + /// whose source radius is 0.8 of the output's samples nearer the centre, output pixels whose source falls outside + /// the proxy are NaN (left out of the pairs), and a proxy of another size is left alone. + #[test] + fn the_sensor_proxy_follows_the_cameras_lens_warp() { + let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON Z 6_2", Default::default()); + raw.active_area = lightcraft_raw::Rect::new(0, 0, 64, 48); + raw.crop = raw.active_area; + // a 1/4 proxy whose pixels hold their own coordinates + let mut img = Rgb32f::new(16, 12); + for (i, p) in img.data.iter_mut().enumerate() { + *p = [(i % 16) as f32, (i / 16) as f32, 1.0]; + } + let geometry = |k0: f64| LensGeometry { k: [k0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }; + let same = into_lens_geometry(&img, &raw, 4, &geometry(1.0)).unwrap(); + assert!(same.data.iter().zip(&img.data).all(|(a, b)| (0..3).all(|c| (a[c] - b[c]).abs() < 1e-4))); + // the corner pixel's centre (1.5, 1.5) maps to (31.5 - 0.8 * 30, 23.5 - 0.8 * 22) = (7.5, 5.9) on the sensor + let zoomed = into_lens_geometry(&img, &raw, 4, &geometry(0.8)).unwrap(); + assert!((zoomed.data[0][0] - 1.5).abs() < 1e-4 && (zoomed.data[0][1] - 1.1).abs() < 1e-4, "{:?}", zoomed.data[0]); + let outside = into_lens_geometry(&img, &raw, 4, &geometry(1.3)).unwrap(); + assert!(outside.data[0][0].is_nan() && outside.data[6 * 16 + 8][0].is_finite()); + assert!(into_lens_geometry(&Rgb32f::new(15, 12), &raw, 4, &geometry(1.0)).is_none()); + } + + /// A raw with an in-file lens correction (Fujifilm, Panasonic, the A7R IVA, Nikon) may have a lens-corrected + /// camera JPEG: the proxies the fit pairs line up with it at least as well as the uncorrected sensor, on every + /// corpus file the colour fit uses that carries such a warp, and better on those whose JPEG is corrected. + #[test] + fn corpus_lens_corrected_jpegs_pair_with_the_warped_sensor() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + let Ok(entries) = std::fs::read_dir(&dir) else { + eprintln!("skip: no corpus at {}", dir.display()); + return; + }; + let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect(); + paths.sort(); + let (mut checked, mut gain) = (0, 0.0); + for path in paths { + let Ok(bytes) = std::fs::read(&path) else { continue }; + let Ok(mut raw) = lightcraft_raw::decode(&bytes) else { continue }; + let Some(lens) = lens_geometry(&raw.opcodes.list3) else { continue }; + if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) { + continue; + } + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let (Some((plain, reference, _)), Some((chosen, chosen_reference, _))) = + (proxies(&raw, &bytes, &t, PROXY, None), proxies(&raw, &bytes, &t, PROXY, Some(&lens))) + else { + continue; + }; + let (before, after) = (alignment(&plain, &reference), alignment(&chosen, &chosen_reference)); + eprintln!("{}: alignment {before:.3} -> {after:.3}", path.display()); + // (chosen on camera RGB, measured after the colour transform: a near tie may flip by a little) + assert!(after >= before - 0.01, "{}: {before:.3} -> {after:.3}", path.display()); + gain += after - before; + checked += 1; + } + eprintln!("{checked} corpus raws with an in-file lens warp, mean gain {:.3}", gain / checked.max(1) as f64); + assert!(checked == 0 || gain > 0.0); + } + /// Step 1: a file's own colour matrices win over everything, including a spectral row for its model; no JPEG fit /// is tried. #[test] @@ -2052,7 +2259,7 @@ mod tests { assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M4").unwrap(); let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default()); - let (_, t, look) = starting_colour(&mut raw, &[]); + let (_, t, look) = starting_colour(&mut raw, &[], None); assert!(look.is_none()); assert_eq!(raw.color.color_matrix, camera.color_matrix.map(Some)); assert!(!t.matrix_is_fallback); @@ -2111,13 +2318,13 @@ mod tests { #[test] fn a_camera_outside_the_table_keeps_the_fallback() { let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default()); - let (_, t, look) = starting_colour(&mut raw, &[]); + let (_, t, look) = starting_colour(&mut raw, &[], None); assert!(look.is_none() && t.matrix_is_fallback); assert_eq!(raw.color, lightcraft_raw::ColorData::default()); // without a model there is no table row let mut raw = raw_of(RawFormat::Cr2, "Canon", "", Default::default()); raw.metadata.model = None; - assert!(starting_colour(&mut raw, &[]).1.matrix_is_fallback); + assert!(starting_colour(&mut raw, &[], None).1.matrix_is_fallback); } /// The colour precedence on public raws (each skipped without its corpus file): a photo whose JPEG fit is @@ -2136,13 +2343,14 @@ mod tests { continue; }; let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + let lens = lens_geometry(&raw.opcodes.list3); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let own = lightcraft_raw::color::has_matrix(&raw.color); let (make, model) = (raw.metadata.make.as_deref(), raw.metadata.model.as_deref().unwrap_or_default()); let measured = lightcraft_raw::spectral::find(make, model).is_some(); let covered = measured || lightcraft_raw::kelvin_presets::covers(make, model); let before = raw.color.clone(); - let (_, t, fitted) = starting_colour(&mut raw, &bytes); + let (_, t, fitted) = starting_colour(&mut raw, &bytes, lens.as_ref()); let spectral = !own && fitted.is_none() && measured; eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some()); assert_eq!(raw.color != before, spectral, "{name}"); @@ -2249,7 +2457,7 @@ mod tests { assert_eq!(raw.format, RawFormat::Cr3); assert_eq!(raw.info().developed_size(), (6000, 4000)); let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform).0; + let look = fit_preview(&raw, &bytes, &transform, None).0; eprintln!("R100 guarded colour fit accepted: {}", look.is_some()); assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment"); let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); @@ -2282,7 +2490,7 @@ mod tests { let raw = lightcraft_raw::decode(&bytes).unwrap(); assert_eq!(raw.metadata.model.as_deref(), Some("ILCE-7CR")); let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform).0.expect("fit accepted"); + let look = fit_preview(&raw, &bytes, &transform, None).0.expect("fit accepted"); let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap()); for (row, want) in look.matrix.0.iter().zip(expected.0) { for (got, want) in row.iter().zip(want) { @@ -2565,9 +2773,9 @@ mod tests { let mut raw = lightcraft_raw::decode(&bytes).unwrap(); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform).0.expect("a camera look"); + let look = fit_preview(&raw, &bytes, &transform, None).0.expect("a camera look"); // the look fitted to the JPEG, as before this change - let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY).unwrap(); + let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, None).unwrap(); let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); let fitted = fit_look(&sensor, &reference, &clipped, colour).unwrap(); @@ -3155,12 +3363,12 @@ mod tests { tiff }; let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = monochrome_look(&raw, &file([1.0; 3]), &t).expect("a tone curve from the black-and-white JPEG"); + let look = monochrome_look(&raw, &file([1.0; 3]), &t, None).expect("a tone curve from the black-and-white JPEG"); assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "on the transform's colour"); let curve = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); let mid = curve.apply(0.25); assert!((mid - (1.0 - (-0.75f32).exp())).abs() < 0.05, "the camera's curve at 0.25: {mid}"); - assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t).is_none(), "a tinted JPEG is not black and white"); + assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t, None).is_none(), "a tinted JPEG is not black and white"); } /// The black-and-white fit has the partial look's checks: a JPEG of another picture, or one the default curve diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs index 6c35c2a45..49dfad408 100644 --- a/crates/engine/src/camera_profiles.rs +++ b/crates/engine/src/camera_profiles.rs @@ -194,10 +194,12 @@ impl Pool { /// NEF or RAF without colour matrices, no usable camera JPEG, too little colour). pub fn add(&mut self, bytes: &[u8]) -> Result, String> { let mut raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?; - // colour only: geometric lens corrections would stop the sensor proxy from binning + // colour only: geometric lens corrections would stop the sensor proxy from binning (the pairs still follow + // the warp the camera applied to its JPEG) + let lens = crate::camera_preview::lens_geometry(&raw.opcodes.list3); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let Some(model) = raw.metadata.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) else { return Ok(None) }; - let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes) else { return Ok(None) }; + let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes, lens.as_ref()) else { return Ok(None) }; let step = pairs.len().div_ceil(PAIRS_PER_FILE).max(1); let entry = self.models.entry(model.to_owned()).or_default(); entry.0 += 1; diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index e2144eb85..af027ff7b 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -400,12 +400,15 @@ fn load_bytes_now( // (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`). let info = raw.info(); let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes)); + // (the camera look still pairs the sensor with the camera JPEG through the warp the camera applied to it) + let lens_geometry = crate::camera_preview::lens_geometry(&raw.opcodes.list3); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); // Which scale white balance is edited on, and the as-shot white on it, from the file's own colour tags or // the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera) let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); // the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted - let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes), || dng_mattes(&bytes, &info)); + let ((xy, t, camera_look), mattes) = + rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref()), || dng_mattes(&bytes, &info)); drop(bytes); // Previews and thumbnails bin the mosaic straight to (about) the size they need; only // larger levels (exports, 1:1) demosaic the whole sensor. diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 1210e6b54..a12cfc326 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -19,6 +19,8 @@ The matrix is applied to scene-linear sensor pixels. The curve is carried in `So One third of the pixels are held out from both fits. The combined model must reduce linear-display squared error by at least 30% and have per-channel RMS error ≤0.10 (0.055 rejected half of the public raw.pixls.us samples tried — ILCE-6400, -6700, -7M4 — whose fits were still 1.5–3× closer to the camera JPEG than the fallback and visibly better). When the fit on all pixels fails these gates, it is tried once more, with the same gates, on the pixels away from edges (where the 3×3 neighbourhood in either proxy spans a luminance ratio above 3). Many camera JPEGs are lens-corrected while the raw is not (Sony "Distortion Comp.: Auto", compacts and kit zooms), and on a detailed scene the misaligned edges alone fail the gate although the colours agree everywhere else (issue #232). The public ILCE-7RM2 samples (raw.pixls.us, one façade shot in seven compression/correction variants, 24–70 mm at 51 mm) are up to 15 px of 1440 off their JPEG; the six with distortion correction on missed the gate at held-out RMS 0.108–0.115 and opened in the grey neutral fallback, 13 L* too dark (ΔE76 16.6–16.9 against the JPEG after registering its geometry). Away from edges they pass at 0.049–0.052 and land at ΔE 3.7–3.8 with lightness within 0.6 L*, like the variant shot with distortion correction off (0.059 on all pixels, ΔE 3.9). The corpus GX80 kit-zoom sample (up to 63 px of 1440 off) passes too: 0.107 → 0.081, ΔE 19.1 → 7.9. Fits accepted on all pixels are unchanged. These are provisional acceptance gates, not a colour-accuracy certification. Singular, unrelated and aspect-mismatched references retain the documented fallback; poor-fitting ones get the partial look below when it is clearly closer to the camera JPEG, and black-and-white ones only their tone curve (below). Both fits use the same fixed proxy at every decode resolution. +**Lens-corrected camera JPEGs.** Some raws carry the camera's own lens correction (Fujifilm and Panasonic tables, the ILCE-7RM4A, Nikon's NikonNEFInfo) and the camera often applies it to its JPEG. Nikon Z lenses always do, and that JPEG can be zoomed in by up to 9% to fill the frame. The fit used to pair the uncorrected sensor with the corrected JPEG, so near the edges it compared pixels from different parts of the scene. Now the sensor proxy is also warped with the file's correction (`into_lens_geometry`, pixels that land outside the picture are dropped), and the fit uses whichever of the two lines up better with the JPEG, going by the correlation of their high-passed log luminance. Files whose JPEG isn't corrected keep the plain proxy. On the 29 CC0 corpus raws with a warp the alignment goes up by 0.13 on average (Panasonic GX80 0.41 to 0.83, Fujifilm X-T20 0.44 to 0.95). Mean ΔE against the camera JPEG over the 35 corpus raws of those formats goes from 3.73 to 3.61, and on 299 private Z 6II photos with Nikon's correction read it goes from 3.22 to 2.68. + **A partial look when the full look misses the gates.** The neutral fallback (camera RGB taken as sRGB, the default tone map) is far from the camera JPEG. Rendered for every photo on this path in the CC0 archive (1,150 files with a camera JPEG), it is 10.7 L\* too dark (median; about one stop), keeps 0.36 of the camera's colourfulness and has hues 7° off on the coloured pixels (median CIEDE2000 13.6). Its white balance is right: on the pixels the camera renders near-neutral the cast is under 1 a\*/b\* (median, every maker). The "blue" fallback on newer Sony bodies (Δb\* −22 over the whole frame on the ILCE-7RM5 sample) is a warm scene losing its colour, not a cast: its near-neutral pixels are within −1.6 b\*. So before falling back, a partial look is fitted (`fit_partial`): the quantile tone curve (registration-free, the largest part of the error), the chroma curve, and, when the scene has enough colour to learn a matrix (the full look's condition), the photo's own colour matrix at half weight, the other half the identity (`PARTIAL_MATRIX_WEIGHT`). No hue/saturation table. Half weight because, on the held-out proxy pixels of the 23 colourful photos without a look, the full matrix left 2 of them more than 2° further from the camera's hues than the neutral fallback and half weight none (median hue error 9.6° with the generic colour, 5.1° at half weight, 4.2° at full weight; on the 1,080 photos with a look 3.9° and 2.7°). Without a matrix (tone and chroma curve alone, or with one global saturation scale) about a third of all photos ended up more than 2° further from the camera's hues than the fallback, as the added colour makes the generic matrix's hue errors visible. The partial look is tried on all pixels, then away from edges, and used only when it cuts the held-out squared error below 0.7 of the neutral fallback's in linear and in gamma-encoded display values, and when the JPEG's luminance ranks the pixels like the scene's (Spearman ≥ 0.5, `PARTIAL_MIN_RANK_CORRELATION`): a curve matched to the brightness of an unrelated picture also beats the dark fallback pixel for pixel. The accepted full looks have 0.55 or more; three old Coolpix files whose raws decode wrongly have 0.07–0.27 and keep the fallback. There is no limit on the partial look's own error. On the archive (1,783 renders): all 1,087 full looks are byte-identical; of the 55 photos that reach the partial fit, 49 get it (40 on all pixels, 9 away from edges; 13 with the damped matrix, 41 with a chroma curve) and 6 keep the fallback (lens-corrected compacts: RX10, TZ80 1:1, D-LUX 4, G10 16:9, GF2 ×2). On the 49, median CIEDE2000 to the camera JPEG 14.0 → 2.1, ΔL\* −11.5 → +0.6, colourfulness 0.36 → 0.87, hue error 10.1° → 7.1°; every one is closer, by at least 1.3. Most of the others without a look never reach a fit: on 48 files (Nikon D4, Df, D300/D300S, D7000, D5100 and others) the camera JPEG proxy comes out a pixel narrower than the sensor proxy and no pairs are collected (the resize in PR #531 fixes this; with it 46 of them pass the full look's gates, CIEDE2000 17.9 → 2.9). **A tone curve from a black-and-white JPEG.** A raw shot in a monochrome picture style (Canon Monochrome, Sony B/W, Nikon Monochrome) records full colour, but its camera JPEG is grey, so neither the full look nor the partial look has colour to fit. Such a photo started from the spectral matrices or the neutral fallback with the default tone curve: on a Canon EOS RP, about 1.3 EV darker and flatter than the same body's photos shot in colour (issue #577). The grey JPEG still shows the camera's tone curve. When no look was fitted and every pixel of the JPEG proxy but one in a thousand has its channels within 0.002 of each other (`monochrome`; a sepia-toned JPEG has colour and isn't one), the quantile tone curve is fitted alone, luminance against luminance, on top of the colour the photo gets otherwise: the model's camera profile when it has one (in `fit_preview`, like the profile's colour looks), else its spectral matrices or the neutral fallback's (`monochrome_look`). The chroma curve stays neutral. It has the partial look's checks: the JPEG must rank the pixels like the scene (Spearman ≥ 0.5, with tied values sharing their rank, so a flat grey JPEG relates to nothing), and the curve must cut the held-out squared luminance error below 0.7 of the default curve's, in linear and gamma-encoded values, on all pixels or away from edges. Sony's DRO is handled as for colour JPEGs, and white balance stays relative to the as-shot look. The check reads pixels, not the picture style: a colour JPEG of a scene so colourless that every pixel is grey within 0.002 counts too (such a photo gets no look today either). Photos whose JPEG has colour render as before. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..46e126f6b 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-PROF-CAMERACOLOR: the camera look follows lens-corrected JPEGs; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -234,7 +234,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md) | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); the fit pairs the sensor moved into the file's lens-warp geometry when that lines up better with a lens-corrected camera JPEG (29 CC0 raws: alignment +0.13, mean ΔE 3.73 → 3.61) | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | @@ -815,6 +815,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | patch | LR-BEHAV-AUTOSAVE: a damaged snapshot that still parses is repaired on open (ids, stacks, folder loops, id counters) instead of panicking or hanging; status unchanged | | 2026-10-13 | minor | LR-EDIT-OPTICS-PROFILE: Nikon Z 6II / Z 8 / Z f NEFs use the camera's in-file distortion correction (NikonNEFInfo); status unchanged (🟡) | | 2026-10-11 | minor | Model setup folder action, opener error and retry (LR-MASK-OBJECTS). | +| 2026-10-13 | minor | LR-PROF-CAMERACOLOR: the camera look follows the lens warp of lens-corrected camera JPEGs; status unchanged (🟡) | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..cd0fd0935 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-14 +### Closer camera colour for lens-corrected raws +- Raws from cameras that correct their own JPEG for lens distortion (Panasonic and Fujifilm with that setting on, the + Sony A7R IVA) now start from colours closer to the camera's. The starting look was being fitted against a JPEG that + didn't line up with the raw near the edges. Photos you already imported pick this up when their previews are rebuilt. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From 2bc2f60277e39422df6b6e680225e7fdf2e48344 Mon Sep 17 00:00:00 2001 From: Michael Anderson Date: Tue, 13 Oct 2026 11:27:36 -0500 Subject: [PATCH 06/12] LR-PROF-CAMERACOLOR: Add a bundled Nikon Z 8 camera profile Fitted with lightcraft-cli calibrate to 261 of my own Z 8 photos. --- README.md | 2 +- README.zh-hans.md | 2 +- assets/ATTRIBUTION.md | 1 + assets/camera-profiles/NIKON_Z_8.json | 1819 +++++++++++++++++++++++++ crates/engine/src/camera_preview.rs | 33 +- crates/engine/src/camera_profiles.rs | 1 + docs/camera-preview-colour.md | 8 +- docs/parity-checklist.md | 3 +- docs/whats-new.md | 4 +- xtask/src/main.rs | 6 + 10 files changed, 1863 insertions(+), 16 deletions(-) create mode 100644 assets/camera-profiles/NIKON_Z_8.json diff --git a/README.md b/README.md index 3e93b41ee..c16ded23c 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det casual use.** It's great for JPEG/DNG and most Sony / Panasonic / Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera. - **The biggest gaps:** - - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S, X-T4 and Nikon Z 6II profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; + - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II and Z 8 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; - **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md); - **AI masks and denoise:** subject and sky selection are classical heuristics; - **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md); diff --git a/README.zh-hans.md b/README.zh-hans.md index 5183d40a8..0bc8239d6 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -304,7 +304,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**( - **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/ 较老佳能的 raw 都很出色。 - **最大的缺口:** - - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S、X-T4 和尼康 Z 6II 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; + - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II 和 Z 8 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; - **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md); - **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法; - **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。** diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index a5e1716d7..d3fbc4d7b 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -15,6 +15,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | `assets/camera-profiles/ILCE-7CR.json` | Sony ILCE-7CR camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @AlexChambers | original work: fitted by `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131, September 2025 to September 2026, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-09 | none | | `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none | | `assets/camera-profiles/NIKON_Z_6_2.json` | Nikon Z 6II camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 296 usable Z 6II NEFs of my own (of 299, 2024 to 2026, Standard and Auto picture controls; NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none | +| `assets/camera-profiles/NIKON_Z_8.json` | Nikon Z 8 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 261 usable Z 8 NEFs of my own (of 278, 2023 and 2026, nearly all Standard picture control, lossless 14-bit; NIKKOR Z 24-120mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none | | `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix | | `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none | | `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a | diff --git a/assets/camera-profiles/NIKON_Z_8.json b/assets/camera-profiles/NIKON_Z_8.json new file mode 100644 index 000000000..0c63c0d84 --- /dev/null +++ b/assets/camera-profiles/NIKON_Z_8.json @@ -0,0 +1,1819 @@ +{ + "version": 1, + "model": "NIKON Z 8", + "files": 261, + "samples": 969860, + "matrix": [ + [1.0990362785046823, -0.20725005493106963, 0.10268209021537977], + [-0.029117321941279695, 1.1458550830734442, -0.11855469779814065], + [-0.10582269960146817, -0.749496743582382, 1.900598651063195] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [0.0, 1.0, 1.0], + [7.111473, 1.181401, 1.0], + [2.9113195, 1.1633477, 1.0], + [1.8994527, 1.137069, 1.0], + [0.53534466, 1.0345078, 1.0], + [0.0, 1.0, 1.0], + [6.794022, 1.1620317, 1.0], + [1.6603756, 1.1454334, 1.0], + [0.65508085, 1.131233, 1.0], + [0.18744454, 1.0536848, 1.0], + [0.0, 1.0, 1.0], + [6.2740474, 1.1378076, 1.0], + [0.39785913, 1.126624, 1.0], + [-1.5408971, 1.0759563, 1.0], + [-1.7140917, 1.0247983, 1.0], + [0.0, 1.0, 1.0], + [5.2532353, 1.1126455, 1.0], + [-0.7449979, 1.1205007, 1.0], + [-3.0170422, 1.0559602, 1.0], + [-3.354577, 0.9959453, 1.0], + [0.0, 1.0, 1.0], + [4.8965535, 1.0955226, 1.0], + [-0.70895463, 1.1094975, 1.0], + [-3.1364403, 1.0476824, 1.0], + [-4.1989164, 0.96736634, 1.0], + [0.0, 1.0, 1.0], + [3.7045963, 1.0627788, 1.0], + [-1.3298069, 1.0956368, 1.0], + [-2.823391, 1.0708525, 1.0], + [-2.8166869, 1.0153031, 1.0], + [0.0, 1.0, 1.0], + [2.7337725, 1.037307, 1.0], + [-1.8260294, 1.0655074, 1.0], + [-3.5580583, 1.0488383, 1.0], + [-4.437229, 1.0113343, 1.0], + [0.0, 1.0, 1.0], + [2.3106112, 1.0036124, 1.0], + [-2.1256907, 1.0205824, 1.0], + [-4.961178, 1.0156583, 1.0], + [-6.710935, 0.9911362, 1.0], + [0.0, 1.0, 1.0], + [1.3536092, 0.9934402, 1.0], + [-2.5290065, 0.99898964, 1.0], + [-5.4174004, 0.9998236, 1.0], + [-6.597085, 0.9757822, 1.0], + [0.0, 1.0, 1.0], + [1.1366612, 1.002107, 1.0], + [-2.4894423, 0.9997597, 1.0], + [-5.4894304, 0.9861238, 1.0], + [-7.5021935, 0.92478454, 1.0], + [0.0, 1.0, 1.0], + [1.5145179, 1.021503, 1.0], + [-1.5998987, 1.0070803, 1.0], + [-4.508984, 0.9658881, 1.0], + [-7.158313, 0.89098585, 1.0], + [0.0, 1.0, 1.0], + [1.5639887, 1.0445297, 1.0], + [-0.915344, 1.0133737, 1.0], + [-3.1283567, 0.9622828, 1.0], + [-5.1986914, 0.8836733, 1.0], + [0.0, 1.0, 1.0], + [1.1355672, 1.0603209, 1.0], + [-0.56755894, 1.0115352, 1.0], + [-1.8851622, 0.95658684, 1.0], + [-3.4127562, 0.8781371, 1.0], + [0.0, 1.0, 1.0], + [0.5467292, 1.0580485, 1.0], + [-0.41302148, 1.0018384, 1.0], + [-1.0522548, 0.94771504, 1.0], + [-1.7539524, 0.876335, 1.0], + [0.0, 1.0, 1.0], + [-0.0725913, 1.0581536, 1.0], + [-0.4528266, 0.9990066, 1.0], + [-0.57961255, 0.9448505, 1.0], + [-0.5526036, 0.8833151, 1.0], + [0.0, 1.0, 1.0], + [-0.46772903, 1.0652338, 1.0], + [-0.02375252, 1.0044563, 1.0], + [0.43251258, 0.94911736, 1.0], + [0.87126744, 0.89478797, 1.0], + [0.0, 1.0, 1.0], + [0.16140896, 1.0854058, 1.0], + [1.07174, 1.0152693, 1.0], + [1.7586585, 0.95322454, 1.0], + [2.3524222, 0.8983894, 1.0], + [0.0, 1.0, 1.0], + [0.2469127, 1.1031706, 1.0], + [1.1097546, 1.0245277, 1.0], + [1.8333874, 0.96286947, 1.0], + [2.6193652, 0.9074495, 1.0], + [0.0, 1.0, 1.0], + [-0.6874714, 1.1182649, 1.0], + [0.16355518, 1.0515357, 1.0], + [1.1668121, 0.98603916, 1.0], + [2.3031707, 0.9206025, 1.0], + [0.0, 1.0, 1.0], + [-2.0503242, 1.1499335, 1.0], + [-1.1552397, 1.0879966, 1.0], + [-0.10152792, 1.0231872, 1.0], + [1.0718579, 0.95476115, 1.0], + [0.0, 1.0, 1.0], + [-3.8791447, 1.188533, 1.0], + [-2.9817088, 1.1327138, 1.0], + [-1.7755328, 1.0618997, 1.0], + [-0.21666291, 0.94862247, 1.0], + [0.0, 1.0, 1.0], + [-5.8841834, 1.226612, 1.0], + [-4.98382, 1.1740679, 1.0], + [-3.1025357, 1.0698453, 1.0], + [-0.9934275, 0.9242727, 1.0], + [0.0, 1.0, 1.0], + [-8.169295, 1.2657825, 1.0], + [-7.0660734, 1.2132481, 1.0], + [-3.1967385, 1.0559258, 1.0], + [-0.6672474, 0.97608167, 1.0], + [0.0, 1.0, 1.0], + [-10.515467, 1.3097212, 1.0], + [-8.952165, 1.2460706, 1.0], + [-2.6956723, 1.0394313, 1.0], + [-0.24548055, 0.9968061, 1.0], + [0.0, 1.0, 1.0], + [-13.161258, 1.348111, 1.0], + [-10.267992, 1.2503926, 1.0], + [-1.3459312, 1.0174122, 1.0], + [-0.040196028, 1.0000353, 1.0], + [0.0, 1.0, 1.0], + [-15.790122, 1.339094, 1.0], + [-11.393451, 1.2008783, 1.0], + [-2.688318, 1.0172836, 1.0], + [-0.20984244, 1.0009907, 1.0], + [0.0, 1.0, 1.0], + [-18.627531, 1.308051, 1.0], + [-12.985201, 1.160609, 1.0], + [-2.6413164, 1.014484, 1.0], + [-0.1162438, 1.0004718, 1.0], + [0.0, 1.0, 1.0], + [-19.562479, 1.2632611, 1.0], + [-12.712911, 1.108189, 1.0], + [-2.095739, 1.0048033, 1.0], + [-0.04567434, 0.99998564, 1.0], + [0.0, 1.0, 1.0], + [-20.291159, 1.2343935, 1.0], + [-12.380699, 1.0716544, 1.0], + [-1.7035112, 0.9987354, 1.0], + [-0.026139155, 0.9999211, 1.0], + [0.0, 1.0, 1.0], + [-19.989433, 1.200086, 1.0], + [-11.1118, 1.0480033, 1.0], + [-1.400926, 0.996738, 1.0], + [-0.024835525, 0.9998831, 1.0], + [0.0, 1.0, 1.0], + [-19.566994, 1.17641, 1.0], + [-10.257852, 1.0363005, 1.0], + [-1.2149851, 0.99804825, 1.0], + [-0.015627315, 0.9999487, 1.0], + [0.0, 1.0, 1.0], + [-18.913862, 1.134827, 1.0], + [-10.370755, 1.0167582, 1.0], + [-1.6505148, 0.99528724, 1.0], + [-0.026268613, 0.9998695, 1.0], + [0.0, 1.0, 1.0], + [-18.999414, 1.1098753, 1.0], + [-9.642386, 1.0115949, 1.0], + [-1.2675271, 0.9941188, 1.0], + [-0.018203475, 0.99986815, 1.0], + [0.0, 1.0, 1.0], + [-17.586956, 1.091192, 1.0], + [-8.722237, 1.0048285, 1.0], + [-1.1602849, 0.9960165, 1.0], + [-0.013571129, 0.9999434, 1.0], + [0.0, 1.0, 1.0], + [-16.729242, 1.0722094, 1.0], + [-9.583627, 0.9960505, 1.0], + [-2.039792, 0.9939141, 1.0], + [-0.04050307, 0.99985063, 1.0], + [0.0, 1.0, 1.0], + [-16.906395, 1.075484, 1.0], + [-8.840361, 1.0053103, 1.0], + [-1.4046353, 0.9954329, 1.0], + [-0.031679757, 0.99983394, 1.0], + [0.0, 1.0, 1.0], + [-17.531858, 1.0629116, 1.0], + [-8.608148, 0.9959869, 1.0], + [-1.1416123, 0.99636537, 1.0], + [-0.013378057, 0.99994534, 1.0], + [0.0, 1.0, 1.0], + [-17.99668, 1.0072999, 1.0], + [-10.570478, 0.9696376, 1.0], + [-1.9599729, 0.9910149, 1.0], + [-0.022619616, 0.99988747, 1.0], + [0.0, 1.0, 1.0], + [-18.86213, 0.96442, 1.0], + [-11.836861, 0.9444823, 1.0], + [-2.469638, 0.984263, 1.0], + [-0.030691203, 0.99978447, 1.0], + [0.0, 1.0, 1.0], + [-20.236654, 0.90864915, 1.0], + [-13.107022, 0.9121311, 1.0], + [-2.9203506, 0.97508854, 1.0], + [-0.041130785, 0.9996122, 1.0], + [0.0, 1.0, 1.0], + [-20.714214, 0.8536292, 1.0], + [-14.74853, 0.8750625, 1.0], + [-3.946734, 0.9621585, 1.0], + [-0.05774192, 0.9994195, 1.0], + [0.0, 1.0, 1.0], + [-20.098251, 0.80836505, 1.0], + [-14.476766, 0.851234, 1.0], + [-5.6719704, 0.9378101, 1.0], + [-0.12513989, 0.9985625, 1.0], + [0.0, 1.0, 1.0], + [-19.542671, 0.76297987, 1.0], + [-13.628136, 0.83560556, 1.0], + [-7.0074353, 0.91483426, 1.0], + [-0.28871387, 0.99629575, 1.0], + [0.0, 1.0, 1.0], + [-18.084225, 0.74823433, 1.0], + [-12.162158, 0.84476614, 1.0], + [-6.7999973, 0.9135009, 1.0], + [-0.40371415, 0.9944375, 1.0], + [0.0, 1.0, 1.0], + [-16.452961, 0.72636646, 1.0], + [-9.328942, 0.86091775, 1.0], + [-4.9608183, 0.9375208, 1.0], + [-0.27671492, 0.9964092, 1.0], + [0.0, 1.0, 1.0], + [-13.168701, 0.74453783, 1.0], + [-6.218363, 0.90167975, 1.0], + [-3.7400405, 0.95979875, 1.0], + [-0.46412376, 0.995171, 1.0], + [0.0, 1.0, 1.0], + [-10.4688425, 0.77134514, 1.0], + [-5.7574325, 0.8950614, 1.0], + [-3.034646, 0.9650316, 1.0], + [-0.36110362, 0.9969887, 1.0], + [0.0, 1.0, 1.0], + [-7.514723, 0.7996464, 1.0], + [-3.7689729, 0.9172442, 1.0], + [-0.9782931, 1.0084524, 1.0], + [0.31402144, 1.0211289, 1.0], + [0.0, 1.0, 1.0], + [-5.6537147, 0.8334752, 1.0], + [-2.504266, 0.96418613, 1.0], + [-0.07010336, 1.052774, 1.0], + [0.894411, 1.0569643, 1.0], + [0.0, 1.0, 1.0], + [-4.493737, 0.7882688, 1.0], + [-2.3410633, 0.936153, 1.0], + [-0.32673165, 1.0513396, 1.0], + [0.49912313, 1.0484643, 1.0], + [0.0, 1.0, 1.0], + [-1.7034019, 0.73160046, 1.0], + [-0.831635, 0.8589306, 1.0], + [-0.3882015, 1.0134665, 1.0], + [-0.18704136, 1.0146226, 1.0], + [0.0, 1.0, 1.0], + [0.61953825, 0.7258855, 1.0], + [0.81030744, 0.8431724, 1.0], + [0.00040164185, 1.0047435, 1.0], + [-0.10240378, 1.0050768, 1.0], + [0.0, 1.0, 1.0], + [2.0624804, 0.7425685, 1.0], + [1.5886667, 0.8645959, 1.0], + [0.4176404, 1.0037674, 1.0], + [-0.00398716, 1.0012411, 1.0], + [0.0, 1.0, 1.0], + [3.623457, 0.7510747, 1.0], + [1.7844758, 0.885587, 1.0], + [-0.036421247, 1.0089039, 1.0], + [-0.020441405, 1.001009, 1.0], + [0.0, 1.0, 1.0], + [4.7174945, 0.7708686, 1.0], + [3.2143273, 0.90923154, 1.0], + [0.25317824, 1.0065747, 1.0], + [-0.016773207, 1.0006565, 1.0], + [0.0, 1.0, 1.0], + [5.9824314, 0.7961609, 1.0], + [3.939417, 0.94614726, 1.0], + [0.7240807, 1.0062363, 1.0], + [0.030703958, 1.0001671, 1.0], + [0.0, 1.0, 1.0], + [7.3545732, 0.82526684, 1.0], + [4.982609, 0.9743153, 1.0], + [1.4535582, 1.0078721, 1.0], + [0.062866114, 1.0002259, 1.0], + [0.0, 1.0, 1.0], + [7.4568152, 0.8617716, 1.0], + [3.6539178, 1.0011972, 1.0], + [0.9039327, 1.0089482, 1.0], + [0.03915892, 1.000176, 1.0], + [0.0, 1.0, 1.0], + [7.3634186, 0.91159344, 1.0], + [3.4343126, 1.0100657, 1.0], + [0.695478, 1.010742, 1.0], + [0.03255169, 1.0004065, 1.0], + [0.0, 1.0, 1.0], + [7.238165, 0.9486031, 1.0], + [4.089919, 1.0266953, 1.0], + [1.1045972, 1.0186851, 1.0], + [0.057033073, 1.0010694, 1.0], + [0.0, 1.0, 1.0], + [7.0543814, 0.9770822, 1.0], + [3.9128, 1.0336262, 1.0], + [1.1944752, 1.0113511, 1.0], + [0.07083795, 1.0004443, 1.0], + [0.0, 1.0, 1.0], + [8.143442, 0.99413896, 1.0], + [4.040843, 1.0496417, 1.0], + [0.8080747, 1.0116086, 1.0], + [0.042427447, 0.99993575, 1.0], + [0.0, 1.0, 1.0], + [7.18577, 0.9783746, 1.0], + [3.2767346, 1.0421677, 1.0], + [1.2492384, 1.016086, 1.0], + [0.15854485, 1.0005085, 1.0], + [0.0, 1.0, 1.0], + [7.194208, 1.0016203, 1.0], + [3.0112083, 1.0349026, 1.0], + [0.91562295, 1.0119883, 1.0], + [0.0654759, 1.0005552, 1.0], + [0.0, 1.0, 1.0], + [7.414966, 0.998843, 1.0], + [3.1521475, 1.0262057, 1.0], + [0.9972541, 1.0102346, 1.0], + [0.060677312, 1.0006106, 1.0], + [0.0, 1.0, 1.0], + [9.276159, 0.9916557, 1.0], + [4.375442, 1.0329365, 1.0], + [1.5409418, 1.015073, 1.0], + [0.13645935, 1.0012853, 1.0], + [0.0, 1.0, 1.0], + [9.8098955, 1.0221618, 1.0], + [5.381683, 1.0610034, 1.0], + [2.1655529, 1.0283315, 1.0], + [0.15032019, 1.0015818, 1.0], + [0.0, 1.0, 1.0], + [8.995072, 1.0311706, 1.0], + [5.2788897, 1.0783035, 1.0], + [2.6601613, 1.0430328, 1.0], + [0.23225841, 1.0026511, 1.0], + [0.0, 1.0, 1.0], + [10.116063, 1.0506214, 1.0], + [6.2658486, 1.0895245, 1.0], + [3.640211, 1.0535048, 1.0], + [0.39292678, 1.0040704, 1.0], + [0.0, 1.0, 1.0], + [9.800368, 1.0582323, 1.0], + [6.5737424, 1.0822098, 1.0], + [6.3034444, 1.0650462, 1.0], + [1.6771196, 1.0111806, 1.0], + [0.0, 1.0, 1.0], + [9.205544, 1.097704, 1.0], + [5.412633, 1.1020687, 1.0], + [4.3975163, 1.0531521, 1.0], + [1.2703937, 1.0038551, 1.0], + [0.0, 1.0, 1.0], + [8.4540615, 1.1362739, 1.0], + [4.478308, 1.1312087, 1.0], + [2.8681872, 1.0796018, 1.0], + [0.52417415, 1.009483, 1.0], + [0.0, 1.0, 1.0], + [6.872955, 1.1356013, 1.0], + [2.2753267, 1.1355586, 1.0], + [1.7234284, 1.1281133, 1.0], + [1.1223476, 1.0683283, 1.0], + [0.0, 1.0, 1.0], + [6.124278, 1.1170305, 1.0], + [0.4689261, 1.1054591, 1.0], + [-0.22648379, 1.088597, 1.0], + [-0.41844296, 1.0501652, 1.0], + [0.0, 1.0, 1.0], + [5.5311556, 1.0830601, 1.0], + [-0.7712272, 1.0794301, 1.0], + [-2.3635144, 1.0375766, 1.0], + [-2.6961873, 1.0000627, 1.0], + [0.0, 1.0, 1.0], + [4.776837, 1.051424, 1.0], + [-1.30482, 1.0655525, 1.0], + [-3.4497104, 1.0115541, 1.0], + [-4.008624, 0.9658105, 1.0], + [0.0, 1.0, 1.0], + [4.325843, 1.0289863, 1.0], + [-0.89701647, 1.0507549, 1.0], + [-3.5267189, 0.9840365, 1.0], + [-4.8896995, 0.92570627, 1.0], + [0.0, 1.0, 1.0], + [3.2383246, 0.9979077, 1.0], + [-1.0782118, 1.0382204, 1.0], + [-2.7714362, 1.0106578, 1.0], + [-3.8654947, 0.9470026, 1.0], + [0.0, 1.0, 1.0], + [2.3593056, 0.97518253, 1.0], + [-1.4679712, 1.0127448, 1.0], + [-3.3288295, 1.0004958, 1.0], + [-4.8622665, 0.95781606, 1.0], + [0.0, 1.0, 1.0], + [1.5416691, 0.94487643, 1.0], + [-1.9971544, 0.97270143, 1.0], + [-4.8065176, 0.9733487, 1.0], + [-6.8176403, 0.9401679, 1.0], + [0.0, 1.0, 1.0], + [0.57058907, 0.9429605, 1.0], + [-2.4935536, 0.96301734, 1.0], + [-5.3679204, 0.9637666, 1.0], + [-6.831026, 0.91412973, 1.0], + [0.0, 1.0, 1.0], + [0.26053378, 0.9610666, 1.0], + [-2.7039409, 0.9701933, 1.0], + [-5.4002733, 0.9458906, 1.0], + [-6.9784875, 0.86947393, 1.0], + [0.0, 1.0, 1.0], + [0.53928864, 0.9811304, 1.0], + [-2.0121186, 0.97265023, 1.0], + [-4.3233175, 0.9172091, 1.0], + [-6.005866, 0.8446555, 1.0], + [0.0, 1.0, 1.0], + [0.6283378, 1.0006269, 1.0], + [-1.4216532, 0.9777993, 1.0], + [-3.27393, 0.91542476, 1.0], + [-4.512793, 0.8313424, 1.0], + [0.0, 1.0, 1.0], + [0.36547175, 1.0127599, 1.0], + [-1.0703696, 0.9762654, 1.0], + [-2.3095455, 0.9178097, 1.0], + [-3.2788665, 0.82293683, 1.0], + [0.0, 1.0, 1.0], + [-0.073588796, 1.014126, 1.0], + [-0.83646154, 0.96881944, 1.0], + [-1.3723681, 0.9147329, 1.0], + [-1.7665399, 0.82023007, 1.0], + [0.0, 1.0, 1.0], + [-0.63139224, 1.017959, 1.0], + [-0.81763905, 0.96828437, 1.0], + [-0.8057263, 0.91791266, 1.0], + [-0.65346074, 0.85073775, 1.0], + [0.0, 1.0, 1.0], + [-0.9540565, 1.0314627, 1.0], + [-0.4009139, 0.97503597, 1.0], + [0.16406323, 0.91989255, 1.0], + [0.69900894, 0.8609631, 1.0], + [0.0, 1.0, 1.0], + [-0.29338956, 1.051337, 1.0], + [0.6638694, 0.9824637, 1.0], + [1.4182094, 0.92527664, 1.0], + [2.0502326, 0.8743307, 1.0], + [0.0, 1.0, 1.0], + [-0.19034386, 1.060521, 1.0], + [0.7418265, 0.9920945, 1.0], + [1.5311259, 0.94175714, 1.0], + [2.3645382, 0.89062786, 1.0], + [0.0, 1.0, 1.0], + [-1.1072253, 1.0738078, 1.0], + [-0.17039186, 1.0250928, 1.0], + [1.0041893, 0.9713254, 1.0], + [2.2262986, 0.9069038, 1.0], + [0.0, 1.0, 1.0], + [-2.3444607, 1.0938511, 1.0], + [-1.396141, 1.0545899, 1.0], + [-0.19473651, 1.0042597, 1.0], + [1.0580148, 0.93644226, 1.0], + [0.0, 1.0, 1.0], + [-3.9988203, 1.1291953, 1.0], + [-3.121686, 1.0951587, 1.0], + [-1.7795982, 1.0358233, 1.0], + [-0.33727086, 0.92730236, 1.0], + [0.0, 1.0, 1.0], + [-5.7153583, 1.1640722, 1.0], + [-4.88902, 1.1327654, 1.0], + [-3.3953416, 1.0511376, 1.0], + [-1.5109448, 0.885162, 1.0], + [0.0, 1.0, 1.0], + [-7.723858, 1.1939173, 1.0], + [-6.834126, 1.162886, 1.0], + [-4.4360557, 1.0393157, 1.0], + [-2.0289664, 0.9277147, 1.0], + [0.0, 1.0, 1.0], + [-9.929747, 1.233579, 1.0], + [-8.989848, 1.1980727, 1.0], + [-5.8884463, 1.0467408, 1.0], + [-1.4371129, 0.9823743, 1.0], + [0.0, 1.0, 1.0], + [-12.79711, 1.2787904, 1.0], + [-11.43725, 1.2281201, 1.0], + [-5.0934134, 1.0372151, 1.0], + [-0.3870912, 0.99834675, 1.0], + [0.0, 1.0, 1.0], + [-15.6093645, 1.2775221, 1.0], + [-13.393321, 1.1939902, 1.0], + [-7.004254, 1.02318, 1.0], + [-0.8632698, 1.0002364, 1.0], + [0.0, 1.0, 1.0], + [-18.6125, 1.2465408, 1.0], + [-15.71152, 1.1482728, 1.0], + [-7.1894984, 1.0131606, 1.0], + [-0.6019608, 0.9991237, 1.0], + [0.0, 1.0, 1.0], + [-19.882221, 1.2043245, 1.0], + [-16.663143, 1.0912375, 1.0], + [-6.9870176, 0.9956438, 1.0], + [-0.2885382, 0.9992374, 1.0], + [0.0, 1.0, 1.0], + [-20.625488, 1.1759886, 1.0], + [-16.814833, 1.0537983, 1.0], + [-6.23361, 0.9808524, 1.0], + [-0.16323197, 0.99922365, 1.0], + [0.0, 1.0, 1.0], + [-21.191574, 1.1593459, 1.0], + [-16.181896, 1.0420355, 1.0], + [-4.434142, 0.979971, 1.0], + [-0.098916985, 0.9993104, 1.0], + [0.0, 1.0, 1.0], + [-21.118694, 1.1469342, 1.0], + [-15.308236, 1.0306582, 1.0], + [-3.593454, 0.9800867, 1.0], + [-0.11090971, 0.99904144, 1.0], + [0.0, 1.0, 1.0], + [-20.06453, 1.1023701, 1.0], + [-16.392384, 0.9912434, 1.0], + [-6.5470276, 0.95821047, 1.0], + [-0.29561612, 0.99735236, 1.0], + [0.0, 1.0, 1.0], + [-20.86482, 1.0911682, 1.0], + [-16.905964, 0.9835901, 1.0], + [-6.5230694, 0.95751834, 1.0], + [-0.18875386, 0.99822915, 1.0], + [0.0, 1.0, 1.0], + [-19.563591, 1.0770866, 1.0], + [-15.724344, 0.9894494, 1.0], + [-5.0314937, 0.9782653, 1.0], + [-0.08916562, 0.99951047, 1.0], + [0.0, 1.0, 1.0], + [-19.648596, 1.066223, 1.0], + [-15.772977, 0.97731113, 1.0], + [-6.1778703, 0.97096556, 1.0], + [-0.17319372, 0.999023, 1.0], + [0.0, 1.0, 1.0], + [-19.83075, 1.0626674, 1.0], + [-15.684646, 0.9758881, 1.0], + [-5.8669987, 0.96583444, 1.0], + [-0.21658812, 0.99854726, 1.0], + [0.0, 1.0, 1.0], + [-19.55086, 1.04659, 1.0], + [-14.666198, 0.9744223, 1.0], + [-4.026313, 0.98011285, 1.0], + [-0.07394151, 0.9995447, 1.0], + [0.0, 1.0, 1.0], + [-20.196869, 0.988159, 1.0], + [-17.390198, 0.93337435, 1.0], + [-7.4060826, 0.95599365, 1.0], + [-0.14739579, 0.99894, 1.0], + [0.0, 1.0, 1.0], + [-21.26852, 0.9264197, 1.0], + [-18.344461, 0.8974489, 1.0], + [-8.743559, 0.936538, 1.0], + [-0.19807786, 0.99823266, 1.0], + [0.0, 1.0, 1.0], + [-21.510828, 0.87103164, 1.0], + [-18.518517, 0.86176705, 1.0], + [-10.065859, 0.9085589, 1.0], + [-0.2821744, 0.99699414, 1.0], + [0.0, 1.0, 1.0], + [-21.584885, 0.8148765, 1.0], + [-18.512503, 0.82746816, 1.0], + [-11.73013, 0.8824068, 1.0], + [-0.3848725, 0.9958189, 1.0], + [0.0, 1.0, 1.0], + [-20.356161, 0.77016896, 1.0], + [-16.736427, 0.8110233, 1.0], + [-13.46877, 0.8463329, 1.0], + [-1.0992389, 0.9864028, 1.0], + [0.0, 1.0, 1.0], + [-19.00107, 0.7378436, 1.0], + [-14.466894, 0.8157218, 1.0], + [-12.32627, 0.84496945, 1.0], + [-2.4299142, 0.9672269, 1.0], + [0.0, 1.0, 1.0], + [-17.491095, 0.72382104, 1.0], + [-11.5228195, 0.84021336, 1.0], + [-9.1774845, 0.87830716, 1.0], + [-2.8254447, 0.9605242, 1.0], + [0.0, 1.0, 1.0], + [-15.435101, 0.7124373, 1.0], + [-8.480166, 0.8606328, 1.0], + [-5.882448, 0.91346925, 1.0], + [-1.6596885, 0.9736952, 1.0], + [0.0, 1.0, 1.0], + [-9.965368, 0.78840566, 1.0], + [-5.0230317, 0.92467666, 1.0], + [-3.841451, 0.95225155, 1.0], + [-2.0082777, 0.97166634, 1.0], + [0.0, 1.0, 1.0], + [-7.585785, 0.8164634, 1.0], + [-4.2593484, 0.9317818, 1.0], + [-2.753388, 0.96636844, 1.0], + [-0.8575842, 0.98519945, 1.0], + [0.0, 1.0, 1.0], + [-5.9080076, 0.82606196, 1.0], + [-2.4876971, 0.95708805, 1.0], + [-0.017117301, 1.0234948, 1.0], + [1.1949928, 1.0367807, 1.0], + [0.0, 1.0, 1.0], + [-4.3217926, 0.8621694, 1.0], + [-1.1579821, 1.013248, 1.0], + [0.829161, 1.0717134, 1.0], + [1.6019802, 1.078209, 1.0], + [0.0, 1.0, 1.0], + [-3.4365811, 0.801541, 1.0], + [-1.142285, 0.9963089, 1.0], + [0.6441237, 1.0807948, 1.0], + [1.3309326, 1.0855218, 1.0], + [0.0, 1.0, 1.0], + [-1.0976561, 0.7120078, 1.0], + [-0.8466615, 0.89612305, 1.0], + [-0.7710759, 1.0564098, 1.0], + [-0.6550111, 1.07382, 1.0], + [0.0, 1.0, 1.0], + [0.8495644, 0.71387726, 1.0], + [0.49764365, 0.8776602, 1.0], + [-0.9067246, 1.0398972, 1.0], + [-1.0517284, 1.0432695, 1.0], + [0.0, 1.0, 1.0], + [2.4473016, 0.72682, 1.0], + [1.7983775, 0.88795257, 1.0], + [0.20548964, 1.0286621, 1.0], + [-0.35707185, 1.0189359, 1.0], + [0.0, 1.0, 1.0], + [4.0796447, 0.7306628, 1.0], + [2.1504579, 0.89834225, 1.0], + [0.34639126, 1.0416607, 1.0], + [-0.23788506, 1.019259, 1.0], + [0.0, 1.0, 1.0], + [5.6701546, 0.74359995, 1.0], + [4.0726633, 0.91694146, 1.0], + [1.3216503, 1.0316267, 1.0], + [-0.045850165, 1.0076267, 1.0], + [0.0, 1.0, 1.0], + [7.0043573, 0.7694095, 1.0], + [5.4557076, 0.9445862, 1.0], + [4.0584545, 1.0185257, 1.0], + [0.56355107, 1.0008852, 1.0], + [0.0, 1.0, 1.0], + [8.084995, 0.78970784, 1.0], + [5.8458114, 0.970168, 1.0], + [4.5538306, 1.024938, 1.0], + [0.49345702, 1.0022416, 1.0], + [0.0, 1.0, 1.0], + [8.05806, 0.817034, 1.0], + [4.4210987, 1.0048858, 1.0], + [3.0152867, 1.0326685, 1.0], + [0.38135135, 1.0021529, 1.0], + [0.0, 1.0, 1.0], + [9.005357, 0.85885465, 1.0], + [4.763144, 1.0251466, 1.0], + [2.17603, 1.0425395, 1.0], + [0.36619714, 1.0030634, 1.0], + [0.0, 1.0, 1.0], + [8.993758, 0.90082896, 1.0], + [5.2438936, 1.0453547, 1.0], + [2.549889, 1.0553246, 1.0], + [0.32793412, 1.0068744, 1.0], + [0.0, 1.0, 1.0], + [9.829749, 0.9506837, 1.0], + [6.0920825, 1.0672028, 1.0], + [3.7592368, 1.0513718, 1.0], + [0.4075813, 1.0041977, 1.0], + [0.0, 1.0, 1.0], + [9.575895, 0.9809986, 1.0], + [5.147536, 1.0732907, 1.0], + [2.6697485, 1.0479143, 1.0], + [0.23983598, 1.0018369, 1.0], + [0.0, 1.0, 1.0], + [8.565204, 0.9654144, 1.0], + [4.159953, 1.0592482, 1.0], + [2.982862, 1.046513, 1.0], + [0.51444083, 1.0031579, 1.0], + [0.0, 1.0, 1.0], + [9.271386, 0.9671414, 1.0], + [3.9021678, 1.0480341, 1.0], + [2.3847647, 1.0395492, 1.0], + [0.338095, 1.0032356, 1.0], + [0.0, 1.0, 1.0], + [9.753154, 0.9583014, 1.0], + [4.58982, 1.0358695, 1.0], + [3.3719125, 1.0326496, 1.0], + [0.62065965, 1.0025914, 1.0], + [0.0, 1.0, 1.0], + [10.777158, 0.9585278, 1.0], + [5.382428, 1.024311, 1.0], + [3.983915, 1.028954, 1.0], + [0.95064604, 1.0067729, 1.0], + [0.0, 1.0, 1.0], + [10.107686, 0.98531634, 1.0], + [5.2336845, 1.0421758, 1.0], + [3.7427158, 1.0352445, 1.0], + [0.91338927, 1.0056044, 1.0], + [0.0, 1.0, 1.0], + [10.010471, 1.002975, 1.0], + [5.0806794, 1.0562643, 1.0], + [3.9300086, 1.0432692, 1.0], + [1.2768706, 1.0071726, 1.0], + [0.0, 1.0, 1.0], + [10.721367, 1.020363, 1.0], + [5.9179363, 1.0760281, 1.0], + [4.653525, 1.0701501, 1.0], + [1.5535151, 1.0154992, 1.0], + [0.0, 1.0, 1.0], + [10.558615, 1.0318329, 1.0], + [6.080362, 1.067487, 1.0], + [6.671132, 1.0704038, 1.0], + [4.238108, 1.0257412, 1.0], + [0.0, 1.0, 1.0], + [9.48343, 1.0585111, 1.0], + [4.4426146, 1.0725648, 1.0], + [4.5216165, 1.0536869, 1.0], + [3.2611907, 1.0090235, 1.0], + [0.0, 1.0, 1.0], + [8.163124, 1.0967343, 1.0], + [3.4163864, 1.1021494, 1.0], + [3.0036535, 1.0894843, 1.0], + [1.6298203, 1.0331198, 1.0], + [0.0, 1.0, 1.0], + [4.7200303, 0.99990386, 1.0], + [-0.18434145, 0.9829197, 1.0], + [-1.7782454, 0.9463851, 1.0], + [-0.8780288, 0.9674484, 1.0], + [0.0, 1.0, 1.0], + [3.3225217, 0.9776916, 1.0], + [-1.491963, 0.9430486, 1.0], + [-2.2781556, 0.91853374, 1.0], + [-1.9229406, 0.92325467, 1.0], + [0.0, 1.0, 1.0], + [2.4281845, 0.93801886, 1.0], + [-2.0302162, 0.92144394, 1.0], + [-2.984083, 0.90740937, 1.0], + [-2.8202295, 0.9010818, 1.0], + [0.0, 1.0, 1.0], + [2.21699, 0.9216663, 1.0], + [-1.478845, 0.9148489, 1.0], + [-3.1692345, 0.9026742, 1.0], + [-3.4822135, 0.8847937, 1.0], + [0.0, 1.0, 1.0], + [2.6095815, 0.91278374, 1.0], + [-0.50396746, 0.90550876, 1.0], + [-2.5619872, 0.8661648, 1.0], + [-3.3487625, 0.8425053, 1.0], + [0.0, 1.0, 1.0], + [2.2401028, 0.9033001, 1.0], + [-0.25377062, 0.8960398, 1.0], + [-2.032842, 0.87459487, 1.0], + [-2.7365766, 0.83498585, 1.0], + [0.0, 1.0, 1.0], + [1.695987, 0.8888191, 1.0], + [-0.67016447, 0.88870287, 1.0], + [-2.36245, 0.87585235, 1.0], + [-3.2576814, 0.8303152, 1.0], + [0.0, 1.0, 1.0], + [0.9194321, 0.8731023, 1.0], + [-1.3481888, 0.8760105, 1.0], + [-3.32636, 0.8514322, 1.0], + [-4.274623, 0.8039877, 1.0], + [0.0, 1.0, 1.0], + [0.23748975, 0.8785199, 1.0], + [-1.6813989, 0.8869438, 1.0], + [-4.1970234, 0.867815, 1.0], + [-4.993503, 0.8058187, 1.0], + [0.0, 1.0, 1.0], + [-0.0443294, 0.89978844, 1.0], + [-1.9890785, 0.90232074, 1.0], + [-3.950284, 0.85917366, 1.0], + [-4.1894603, 0.7918877, 1.0], + [0.0, 1.0, 1.0], + [0.29374087, 0.92003465, 1.0], + [-1.5607008, 0.90463936, 1.0], + [-2.7787287, 0.8298095, 1.0], + [-2.9107876, 0.7702355, 1.0], + [0.0, 1.0, 1.0], + [0.2690241, 0.9420806, 1.0], + [-1.3042494, 0.91745335, 1.0], + [-2.3432212, 0.8269896, 1.0], + [-2.5380266, 0.7619623, 1.0], + [0.0, 1.0, 1.0], + [0.00078135007, 0.9576381, 1.0], + [-1.0908285, 0.9226896, 1.0], + [-1.7685524, 0.83202, 1.0], + [-1.8833431, 0.7369599, 1.0], + [0.0, 1.0, 1.0], + [-0.3298981, 0.9611947, 1.0], + [-0.9030142, 0.9218993, 1.0], + [-1.1803176, 0.84522974, 1.0], + [-1.2364491, 0.7347839, 1.0], + [0.0, 1.0, 1.0], + [-0.90065134, 0.96639967, 1.0], + [-0.92589504, 0.9220938, 1.0], + [-0.7533085, 0.86246485, 1.0], + [-0.58836806, 0.7862185, 1.0], + [0.0, 1.0, 1.0], + [-1.2350203, 0.982918, 1.0], + [-0.63451254, 0.92791486, 1.0], + [-0.05854761, 0.8623331, 1.0], + [0.30643168, 0.8013406, 1.0], + [0.0, 1.0, 1.0], + [-0.6747929, 0.99744356, 1.0], + [0.28179812, 0.93459874, 1.0], + [0.98867965, 0.88537693, 1.0], + [1.5234042, 0.83812547, 1.0], + [0.0, 1.0, 1.0], + [-0.5743862, 1.0066322, 1.0], + [0.4175759, 0.9530498, 1.0], + [1.1614387, 0.9119914, 1.0], + [1.933506, 0.86427605, 1.0], + [0.0, 1.0, 1.0], + [-1.3801366, 1.0321106, 1.0], + [-0.38676104, 0.9921655, 1.0], + [0.70755553, 0.94378203, 1.0], + [1.802139, 0.8818721, 1.0], + [0.0, 1.0, 1.0], + [-2.5278633, 1.0482858, 1.0], + [-1.6080537, 1.0186095, 1.0], + [-0.4270131, 0.96137357, 1.0], + [0.52042204, 0.89067566, 1.0], + [0.0, 1.0, 1.0], + [-3.9787476, 1.078391, 1.0], + [-3.1643727, 1.050093, 1.0], + [-1.7115612, 0.9687348, 1.0], + [-0.4047129, 0.8798787, 1.0], + [0.0, 1.0, 1.0], + [-5.596516, 1.1130706, 1.0], + [-4.6923046, 1.0734227, 1.0], + [-2.8240192, 0.92865837, 1.0], + [-1.3897139, 0.8587299, 1.0], + [0.0, 1.0, 1.0], + [-7.4136, 1.1421592, 1.0], + [-6.2911453, 1.0910819, 1.0], + [-3.5203412, 0.91654646, 1.0], + [-1.5387671, 0.8988556, 1.0], + [0.0, 1.0, 1.0], + [-9.345446, 1.175205, 1.0], + [-7.8788543, 1.1139026, 1.0], + [-3.9887538, 0.95732474, 1.0], + [-0.7494897, 0.9770353, 1.0], + [0.0, 1.0, 1.0], + [-11.726303, 1.2018178, 1.0], + [-9.235658, 1.1204212, 1.0], + [-2.6193218, 0.99971265, 1.0], + [-0.17413116, 0.9981184, 1.0], + [0.0, 1.0, 1.0], + [-13.852155, 1.1776309, 1.0], + [-9.851412, 1.0741005, 1.0], + [-3.3592322, 0.99141484, 1.0], + [-0.30808955, 0.99881417, 1.0], + [0.0, 1.0, 1.0], + [-15.295475, 1.1342069, 1.0], + [-10.099276, 1.0167769, 1.0], + [-3.3911893, 0.9816512, 1.0], + [-0.22844037, 0.99877155, 1.0], + [0.0, 1.0, 1.0], + [-15.857363, 1.0911952, 1.0], + [-10.737116, 0.98477983, 1.0], + [-3.0231202, 0.9772009, 1.0], + [-0.08856626, 0.99933064, 1.0], + [0.0, 1.0, 1.0], + [-16.511763, 1.0658559, 1.0], + [-10.546561, 0.97217023, 1.0], + [-2.431429, 0.97843724, 1.0], + [-0.04837607, 0.9995322, 1.0], + [0.0, 1.0, 1.0], + [-17.32334, 1.046019, 1.0], + [-9.642773, 0.9659112, 1.0], + [-1.8824904, 0.9784988, 1.0], + [-0.039536994, 0.9995053, 1.0], + [0.0, 1.0, 1.0], + [-16.230003, 1.0588695, 1.0], + [-7.7154303, 0.98527706, 1.0], + [-1.1871514, 0.9866838, 1.0], + [-0.05562747, 0.99940675, 1.0], + [0.0, 1.0, 1.0], + [-14.798325, 1.0390636, 1.0], + [-9.137856, 0.95683104, 1.0], + [-3.1458273, 0.96849316, 1.0], + [-0.17147331, 0.99812067, 1.0], + [0.0, 1.0, 1.0], + [-15.813182, 1.0053109, 1.0], + [-10.048948, 0.94771963, 1.0], + [-3.4650283, 0.9650353, 1.0], + [-0.12416336, 0.9983432, 1.0], + [0.0, 1.0, 1.0], + [-16.047022, 0.98889244, 1.0], + [-8.209296, 0.96882516, 1.0], + [-2.0700836, 0.98336583, 1.0], + [-0.05653161, 0.9994855, 1.0], + [0.0, 1.0, 1.0], + [-16.9603, 0.97119784, 1.0], + [-8.66278, 0.9401887, 1.0], + [-2.2208483, 0.9735744, 1.0], + [-0.05832822, 0.9992916, 1.0], + [0.0, 1.0, 1.0], + [-15.93582, 0.9818394, 1.0], + [-9.735115, 0.91714615, 1.0], + [-2.8493056, 0.9654458, 1.0], + [-0.06305568, 0.99927205, 1.0], + [0.0, 1.0, 1.0], + [-14.386252, 0.98472387, 1.0], + [-8.998497, 0.9461114, 1.0], + [-2.1540806, 0.9810613, 1.0], + [-0.04269774, 0.99961257, 1.0], + [0.0, 1.0, 1.0], + [-15.852745, 0.9424422, 1.0], + [-13.541525, 0.9004132, 1.0], + [-5.0671234, 0.95313597, 1.0], + [-0.12552416, 0.99874544, 1.0], + [0.0, 1.0, 1.0], + [-17.605848, 0.8931836, 1.0], + [-11.864264, 0.9022595, 1.0], + [-3.5728223, 0.95726645, 1.0], + [-0.09980284, 0.9986085, 1.0], + [0.0, 1.0, 1.0], + [-18.33637, 0.8163921, 1.0], + [-12.252236, 0.8543749, 1.0], + [-3.4134076, 0.94611895, 1.0], + [-0.07950901, 0.9985124, 1.0], + [0.0, 1.0, 1.0], + [-19.060442, 0.7734047, 1.0], + [-15.23906, 0.8017895, 1.0], + [-5.954306, 0.91588944, 1.0], + [-0.12067752, 0.9982148, 1.0], + [0.0, 1.0, 1.0], + [-19.100754, 0.72602576, 1.0], + [-15.591848, 0.7819147, 1.0], + [-9.629234, 0.86622393, 1.0], + [-0.3522098, 0.99482995, 1.0], + [0.0, 1.0, 1.0], + [-18.568441, 0.6896306, 1.0], + [-14.766998, 0.7783395, 1.0], + [-11.441, 0.83353055, 1.0], + [-0.95255774, 0.98503613, 1.0], + [0.0, 1.0, 1.0], + [-17.873262, 0.66292804, 1.0], + [-11.700582, 0.7895474, 1.0], + [-7.4423504, 0.87738365, 1.0], + [-0.7649209, 0.9871464, 1.0], + [0.0, 1.0, 1.0], + [-15.81971, 0.65139574, 1.0], + [-9.3584585, 0.78892857, 1.0], + [-5.956205, 0.8730324, 1.0], + [-0.8720731, 0.9798331, 1.0], + [0.0, 1.0, 1.0], + [-9.644368, 0.7343603, 1.0], + [-5.3256245, 0.8688635, 1.0], + [-4.0904546, 0.905134, 1.0], + [-1.9802942, 0.942991, 1.0], + [0.0, 1.0, 1.0], + [-6.834648, 0.768598, 1.0], + [-3.7342772, 0.8917114, 1.0], + [-2.275454, 0.9256257, 1.0], + [-0.91388834, 0.94262, 1.0], + [0.0, 1.0, 1.0], + [-4.958538, 0.79897887, 1.0], + [-1.7752197, 0.92934436, 1.0], + [-0.052028436, 0.9775266, 1.0], + [1.0069743, 0.99931246, 1.0], + [0.0, 1.0, 1.0], + [-3.5435648, 0.83892524, 1.0], + [-0.45535767, 0.99585503, 1.0], + [1.1220992, 1.0467564, 1.0], + [1.688386, 1.0540297, 1.0], + [0.0, 1.0, 1.0], + [-2.584903, 0.7611296, 1.0], + [-0.29603556, 0.99536204, 1.0], + [1.0826626, 1.0670043, 1.0], + [1.5285076, 1.0699658, 1.0], + [0.0, 1.0, 1.0], + [-0.24621776, 0.6534611, 1.0], + [-0.5641967, 0.88916516, 1.0], + [-0.5208639, 1.0147351, 1.0], + [-0.17287369, 1.0311226, 1.0], + [0.0, 1.0, 1.0], + [1.6623358, 0.67162013, 1.0], + [-0.014613975, 0.90694416, 1.0], + [-0.7910066, 0.9813947, 1.0], + [-0.7020828, 0.9796464, 1.0], + [0.0, 1.0, 1.0], + [3.2134006, 0.70449984, 1.0], + [0.69320697, 0.9216747, 1.0], + [-0.55953246, 0.98736876, 1.0], + [-0.60563326, 0.9877621, 1.0], + [0.0, 1.0, 1.0], + [4.672629, 0.72619575, 1.0], + [2.541607, 0.90983087, 1.0], + [1.9858508, 0.9788352, 1.0], + [0.5622153, 1.0007318, 1.0], + [0.0, 1.0, 1.0], + [6.4891405, 0.7448458, 1.0], + [4.03939, 0.9308739, 1.0], + [3.3720996, 0.97321606, 1.0], + [1.2299676, 0.9903418, 1.0], + [0.0, 1.0, 1.0], + [7.157809, 0.77485734, 1.0], + [4.3740115, 0.9462917, 1.0], + [3.2057083, 0.98681796, 1.0], + [0.7071888, 0.997923, 1.0], + [0.0, 1.0, 1.0], + [7.228337, 0.79360247, 1.0], + [2.7674537, 0.9669471, 1.0], + [1.3489184, 1.003461, 1.0], + [0.16082476, 1.0005132, 1.0], + [0.0, 1.0, 1.0], + [6.9538918, 0.81647366, 1.0], + [3.1088212, 0.9820907, 1.0], + [1.4730394, 1.0125114, 1.0], + [0.15418816, 1.0018836, 1.0], + [0.0, 1.0, 1.0], + [7.6807427, 0.8534504, 1.0], + [3.718583, 0.99722385, 1.0], + [1.4714887, 1.0191232, 1.0], + [0.16044605, 1.0014175, 1.0], + [0.0, 1.0, 1.0], + [6.902218, 0.8794101, 1.0], + [2.855522, 0.99446875, 1.0], + [1.4610727, 1.0151895, 1.0], + [0.11758561, 1.0017471, 1.0], + [0.0, 1.0, 1.0], + [7.0196548, 0.9160186, 1.0], + [1.6999657, 1.0058115, 1.0], + [1.0062673, 1.0163534, 1.0], + [0.08308377, 1.0012561, 1.0], + [0.0, 1.0, 1.0], + [6.268169, 0.93658304, 1.0], + [1.035491, 0.99574167, 1.0], + [0.41364503, 1.0079995, 1.0], + [0.040540703, 1.0006597, 1.0], + [0.0, 1.0, 1.0], + [5.259043, 0.92028683, 1.0], + [0.23776771, 0.9797173, 1.0], + [0.07063755, 0.9973912, 1.0], + [0.066322915, 1.0000901, 1.0], + [0.0, 1.0, 1.0], + [6.786019, 0.91943693, 1.0], + [-0.12373619, 0.97899324, 1.0], + [-1.2265637, 0.9915, 1.0], + [-0.047353376, 0.9991457, 1.0], + [0.0, 1.0, 1.0], + [6.188807, 0.89457834, 1.0], + [-0.7516189, 0.9425688, 1.0], + [-1.1165174, 0.962824, 1.0], + [0.15711758, 0.99334264, 1.0], + [0.0, 1.0, 1.0], + [7.1094146, 0.89115375, 1.0], + [-0.14875633, 0.9282804, 1.0], + [-0.99777395, 0.94288254, 1.0], + [-0.13148743, 0.9870557, 1.0], + [0.0, 1.0, 1.0], + [6.441057, 0.90025866, 1.0], + [-0.48353556, 0.9186272, 1.0], + [-0.82087964, 0.9264453, 1.0], + [0.08318026, 0.9731093, 1.0], + [0.0, 1.0, 1.0], + [6.7111974, 0.91075236, 1.0], + [-0.36840874, 0.92250305, 1.0], + [-0.4315016, 0.9269158, 1.0], + [0.2359939, 0.96514857, 1.0], + [0.0, 1.0, 1.0], + [7.3763676, 0.92495906, 1.0], + [0.36888885, 0.9462593, 1.0], + [-1.5960634, 0.9405903, 1.0], + [-0.54044867, 0.9806632, 1.0], + [0.0, 1.0, 1.0], + [7.855471, 0.9369563, 1.0], + [1.1207947, 0.95559967, 1.0], + [-0.42048588, 0.9605789, 1.0], + [0.09410473, 0.9890609, 1.0], + [0.0, 1.0, 1.0], + [6.971675, 0.9428375, 1.0], + [1.4723355, 0.95907015, 1.0], + [-0.40449092, 0.9526683, 1.0], + [-0.034233358, 0.98458976, 1.0], + [0.0, 1.0, 1.0], + [6.2168164, 0.96651506, 1.0], + [0.45716965, 0.969795, 1.0], + [-1.276185, 0.95871323, 1.0], + [-0.46196827, 0.98386484, 1.0], + [0.0, 1.0, 1.0], + [1.5696334, 0.88579595, 1.0], + [-4.8797174, 0.8235008, 1.0], + [-5.7836246, 0.81873804, 1.0], + [-1.2262843, 0.9544453, 1.0], + [0.0, 1.0, 1.0], + [0.3305974, 0.86768496, 1.0], + [-4.0607495, 0.7667695, 1.0], + [-5.33198, 0.72661614, 1.0], + [-2.8205767, 0.83346885, 1.0], + [0.0, 1.0, 1.0], + [-0.10735662, 0.7961712, 1.0], + [-2.5164855, 0.746526, 1.0], + [-3.905786, 0.70082253, 1.0], + [-2.8722813, 0.7402977, 1.0], + [0.0, 1.0, 1.0], + [1.0955659, 0.7785365, 1.0], + [-0.020220347, 0.7376785, 1.0], + [-1.4722649, 0.72488576, 1.0], + [-1.4273844, 0.7533604, 1.0], + [0.0, 1.0, 1.0], + [3.1442819, 0.74805653, 1.0], + [2.5943675, 0.7122985, 1.0], + [1.4479681, 0.70843244, 1.0], + [-0.06824569, 0.74796575, 1.0], + [0.0, 1.0, 1.0], + [3.0809562, 0.7545235, 1.0], + [2.7876127, 0.7176543, 1.0], + [3.0311806, 0.6859141, 1.0], + [2.3895133, 0.7123276, 1.0], + [0.0, 1.0, 1.0], + [1.9816447, 0.78059196, 1.0], + [2.157276, 0.7259703, 1.0], + [2.2583017, 0.69609505, 1.0], + [2.6869667, 0.69099945, 1.0], + [0.0, 1.0, 1.0], + [1.1339029, 0.8082483, 1.0], + [0.9635001, 0.7438238, 1.0], + [1.7119975, 0.69262445, 1.0], + [2.8751085, 0.67264295, 1.0], + [0.0, 1.0, 1.0], + [0.7148004, 0.8236315, 1.0], + [0.15944664, 0.7895265, 1.0], + [0.8037349, 0.73090047, 1.0], + [2.2817705, 0.700999, 1.0], + [0.0, 1.0, 1.0], + [1.1721781, 0.823198, 1.0], + [0.42141455, 0.79766697, 1.0], + [1.731092, 0.72331715, 1.0], + [1.9070219, 0.70068294, 1.0], + [0.0, 1.0, 1.0], + [1.4472144, 0.82568, 1.0], + [0.7194572, 0.78889567, 1.0], + [1.2272654, 0.7160339, 1.0], + [1.3908615, 0.6934153, 1.0], + [0.0, 1.0, 1.0], + [0.9659478, 0.8547631, 1.0], + [0.23514794, 0.8048065, 1.0], + [0.6698278, 0.720812, 1.0], + [1.0716225, 0.6872872, 1.0], + [0.0, 1.0, 1.0], + [0.3646527, 0.8849565, 1.0], + [-0.07247575, 0.8116052, 1.0], + [0.0033967285, 0.72046185, 1.0], + [-0.12690859, 0.66787803, 1.0], + [0.0, 1.0, 1.0], + [0.14792149, 0.90996736, 1.0], + [-0.41231233, 0.8411673, 1.0], + [-0.4463926, 0.7333069, 1.0], + [-0.42133877, 0.6662905, 1.0], + [0.0, 1.0, 1.0], + [-0.5877023, 0.92537576, 1.0], + [-0.7669311, 0.85629356, 1.0], + [-0.6382856, 0.7815932, 1.0], + [-0.5130515, 0.72520864, 1.0], + [0.0, 1.0, 1.0], + [-0.8632642, 0.935545, 1.0], + [-0.6218721, 0.8610767, 1.0], + [-0.2218913, 0.7949077, 1.0], + [-0.026698561, 0.76374143, 1.0], + [0.0, 1.0, 1.0], + [-0.6670934, 0.95232236, 1.0], + [-0.013086532, 0.8753517, 1.0], + [0.3907465, 0.82373375, 1.0], + [0.6438301, 0.7755369, 1.0], + [0.0, 1.0, 1.0], + [-0.8246577, 0.96011007, 1.0], + [-0.022377672, 0.9073638, 1.0], + [0.5644851, 0.86494243, 1.0], + [1.0286213, 0.8235526, 1.0], + [0.0, 1.0, 1.0], + [-1.3516122, 0.97745264, 1.0], + [-0.5598576, 0.9293583, 1.0], + [0.04228649, 0.8763748, 1.0], + [-0.31584078, 0.8592634, 1.0], + [0.0, 1.0, 1.0], + [-1.9975094, 0.9925349, 1.0], + [-1.3282233, 0.9415561, 1.0], + [-0.52022403, 0.85210764, 1.0], + [-0.5763454, 0.8441975, 1.0], + [0.0, 1.0, 1.0], + [-3.0044324, 0.993117, 1.0], + [-2.32693, 0.9048576, 1.0], + [-1.0574396, 0.8305523, 1.0], + [-0.25919157, 0.86520964, 1.0], + [0.0, 1.0, 1.0], + [-2.8471406, 0.9966514, 1.0], + [-2.3174558, 0.8910772, 1.0], + [-1.6867507, 0.8372742, 1.0], + [-0.6674814, 0.9019048, 1.0], + [0.0, 1.0, 1.0], + [-2.7654781, 1.0002338, 1.0], + [-2.2775855, 0.92181945, 1.0], + [-1.6766036, 0.8592095, 1.0], + [-0.6071752, 0.91706604, 1.0], + [0.0, 1.0, 1.0], + [-3.2180955, 1.0019648, 1.0], + [-2.4868007, 0.9530428, 1.0], + [-0.8147237, 0.9659948, 1.0], + [-0.08990055, 0.99272156, 1.0], + [0.0, 1.0, 1.0], + [-2.6169603, 1.0069607, 1.0], + [-1.80826, 0.9822141, 1.0], + [-0.27318954, 0.99557036, 1.0], + [-0.0078226, 0.9997451, 1.0], + [0.0, 1.0, 1.0], + [-2.620088, 1.0105745, 1.0], + [-1.6558734, 0.99095327, 1.0], + [-0.34937736, 0.99785286, 1.0], + [-0.009377529, 0.9999541, 1.0], + [0.0, 1.0, 1.0], + [-1.9161261, 1.0054843, 1.0], + [-0.7893424, 0.9937573, 1.0], + [-0.21549447, 0.9983831, 1.0], + [-0.006033252, 0.9999624, 1.0], + [0.0, 1.0, 1.0], + [-1.6860873, 1.000248, 1.0], + [-0.8517966, 0.99048835, 1.0], + [-0.1809431, 0.99742275, 1.0], + [-0.0035661473, 0.99995184, 1.0], + [0.0, 1.0, 1.0], + [-2.009457, 0.9998743, 1.0], + [-1.0168654, 0.9889097, 1.0], + [-0.14624618, 0.99738944, 1.0], + [-0.002028748, 0.99996185, 1.0], + [0.0, 1.0, 1.0], + [-1.343951, 0.9905693, 1.0], + [-0.7694108, 0.988736, 1.0], + [-0.13042787, 0.9973714, 1.0], + [-0.0019758502, 0.9999585, 1.0], + [0.0, 1.0, 1.0], + [-1.5010524, 0.9900646, 1.0], + [-0.6511199, 0.99298286, 1.0], + [-0.05297016, 0.9991895, 1.0], + [-0.0008825612, 0.99998635, 1.0], + [0.0, 1.0, 1.0], + [-1.6744328, 0.9952045, 1.0], + [-0.56495047, 0.9939041, 1.0], + [-0.072879314, 0.9991123, 1.0], + [-0.0022557385, 0.9999713, 1.0], + [0.0, 1.0, 1.0], + [-2.817171, 0.98676676, 1.0], + [-0.59550554, 0.9955451, 1.0], + [-0.10011909, 0.99888563, 1.0], + [-0.002785918, 0.9999608, 1.0], + [0.0, 1.0, 1.0], + [-4.859452, 0.9669093, 1.0], + [-0.5635375, 0.9964578, 1.0], + [-0.061867803, 0.99939847, 1.0], + [-0.001733142, 0.9999818, 1.0], + [0.0, 1.0, 1.0], + [-6.3632984, 0.9476491, 1.0], + [-0.9620065, 0.9895864, 1.0], + [-0.1275121, 0.9982268, 1.0], + [-0.0027327028, 0.9999624, 1.0], + [0.0, 1.0, 1.0], + [-3.8316839, 0.9652471, 1.0], + [-1.5537338, 0.9825404, 1.0], + [-0.27127823, 0.99667627, 1.0], + [-0.0039444612, 0.9999499, 1.0], + [0.0, 1.0, 1.0], + [-2.9406507, 0.9717847, 1.0], + [-1.1054865, 0.98933583, 1.0], + [-0.15259248, 0.9985821, 1.0], + [-0.0023587716, 0.99997854, 1.0], + [0.0, 1.0, 1.0], + [-4.1605053, 0.9520869, 1.0], + [-2.1564174, 0.9791306, 1.0], + [-0.33962235, 0.9967707, 1.0], + [-0.005252303, 0.9999497, 1.0], + [0.0, 1.0, 1.0], + [-4.6772366, 0.9365187, 1.0], + [-0.94710606, 0.98695683, 1.0], + [-0.11579239, 0.9980016, 1.0], + [-0.002743927, 0.9999484, 1.0], + [0.0, 1.0, 1.0], + [-5.730075, 0.9172761, 1.0], + [-2.2775342, 0.96206695, 1.0], + [-0.38358852, 0.9927294, 1.0], + [-0.0075762877, 0.9998489, 1.0], + [0.0, 1.0, 1.0], + [-6.438858, 0.8943925, 1.0], + [-5.09395, 0.91621476, 1.0], + [-1.3278813, 0.9786671, 1.0], + [-0.026160287, 0.99958307, 1.0], + [0.0, 1.0, 1.0], + [-8.272365, 0.8478823, 1.0], + [-3.386477, 0.9320077, 1.0], + [-0.5339506, 0.9900594, 1.0], + [-0.008624411, 0.9998474, 1.0], + [0.0, 1.0, 1.0], + [-11.802094, 0.75678855, 1.0], + [-6.5117326, 0.85963863, 1.0], + [-1.7044662, 0.96734977, 1.0], + [-0.04367834, 0.9992392, 1.0], + [0.0, 1.0, 1.0], + [-15.306793, 0.6631, 1.0], + [-9.06385, 0.7773203, 1.0], + [-2.7547972, 0.9315435, 1.0], + [-0.10010718, 0.99756825, 1.0], + [0.0, 1.0, 1.0], + [-14.635688, 0.62598675, 1.0], + [-11.576207, 0.6989409, 1.0], + [-7.9337134, 0.8109356, 1.0], + [-0.6527108, 0.98375565, 1.0], + [0.0, 1.0, 1.0], + [-9.552264, 0.68059266, 1.0], + [-8.43511, 0.7298391, 1.0], + [-8.363759, 0.7697578, 1.0], + [-3.363505, 0.9041366, 1.0], + [0.0, 1.0, 1.0], + [-7.9862127, 0.70823747, 1.0], + [-6.549984, 0.7803926, 1.0], + [-6.411847, 0.8035285, 1.0], + [-5.4986544, 0.83412504, 1.0], + [0.0, 1.0, 1.0], + [-6.795965, 0.7171654, 1.0], + [-4.852691, 0.821311, 1.0], + [-4.071751, 0.8528317, 1.0], + [-2.6617806, 0.89110637, 1.0], + [0.0, 1.0, 1.0], + [-5.3123918, 0.72757715, 1.0], + [-3.218556, 0.8593564, 1.0], + [-1.1645839, 0.93264043, 1.0], + [0.039813682, 0.9652358, 1.0], + [0.0, 1.0, 1.0], + [-3.6778567, 0.6624857, 1.0], + [-1.7741417, 0.8541198, 1.0], + [-0.0029635795, 0.9721248, 1.0], + [0.40605798, 0.98661965, 1.0], + [0.0, 1.0, 1.0], + [-0.8761976, 0.6101054, 1.0], + [-0.49433082, 0.8107166, 1.0], + [-0.104019575, 0.86723596, 1.0], + [0.20899503, 0.890897, 1.0], + [0.0, 1.0, 1.0], + [2.861542, 0.67296326, 1.0], + [0.71851164, 0.8735976, 1.0], + [0.8827842, 0.8889713, 1.0], + [1.2020328, 0.8868588, 1.0], + [0.0, 1.0, 1.0], + [5.1083093, 0.72884905, 1.0], + [1.4813685, 0.8916017, 1.0], + [0.98923063, 0.90779537, 1.0], + [1.0078008, 0.9088085, 1.0], + [0.0, 1.0, 1.0], + [5.061599, 0.7781577, 1.0], + [6.068938, 0.85700047, 1.0], + [7.827683, 0.85164875, 1.0], + [3.5587494, 0.9304464, 1.0], + [0.0, 1.0, 1.0], + [5.0541573, 0.80283755, 1.0], + [5.1185517, 0.9080198, 1.0], + [6.060745, 0.91160166, 1.0], + [2.6937423, 0.9596598, 1.0], + [0.0, 1.0, 1.0], + [4.3760943, 0.8454239, 1.0], + [2.5560715, 0.9567565, 1.0], + [2.1207442, 0.9821871, 1.0], + [0.37868842, 0.99643296, 1.0], + [0.0, 1.0, 1.0], + [3.3878245, 0.8679573, 1.0], + [0.71156067, 0.96918815, 1.0], + [0.12126909, 0.99451196, 1.0], + [0.001029497, 0.9995172, 1.0], + [0.0, 1.0, 1.0], + [3.1282337, 0.8924943, 1.0], + [0.5896895, 0.9759098, 1.0], + [-0.11373672, 0.9960285, 1.0], + [-0.01838675, 0.9997154, 1.0], + [0.0, 1.0, 1.0], + [4.3400493, 0.90476185, 1.0], + [1.3310212, 0.9786638, 1.0], + [0.17455858, 0.99855655, 1.0], + [0.005317117, 0.9999772, 1.0], + [0.0, 1.0, 1.0], + [2.8491004, 0.93242216, 1.0], + [1.6193472, 0.9691039, 1.0], + [0.80294114, 0.99088013, 1.0], + [0.029686294, 0.9997005, 1.0], + [0.0, 1.0, 1.0], + [1.4401128, 0.9450957, 1.0], + [-0.16860908, 0.97805417, 1.0], + [0.08968142, 0.9966332, 1.0], + [0.0058796364, 0.9999138, 1.0], + [0.0, 1.0, 1.0], + [0.9945528, 0.91562444, 1.0], + [-1.0134145, 0.97015774, 1.0], + [-0.3692183, 0.9947299, 1.0], + [-0.007456935, 0.9999008, 1.0], + [0.0, 1.0, 1.0], + [0.055624425, 0.9145302, 1.0], + [-2.5425062, 0.9648608, 1.0], + [-1.0486484, 0.98947704, 1.0], + [-0.02597243, 0.99974424, 1.0], + [0.0, 1.0, 1.0], + [1.5285127, 0.9355288, 1.0], + [-2.3404818, 0.95899427, 1.0], + [-1.5345422, 0.98380965, 1.0], + [-0.061189, 0.9995191, 1.0], + [0.0, 1.0, 1.0], + [0.72061044, 0.92197156, 1.0], + [-5.383721, 0.90637255, 1.0], + [-3.6513355, 0.9478104, 1.0], + [-0.16841647, 0.9974061, 1.0], + [0.0, 1.0, 1.0], + [-0.028428238, 0.89694136, 1.0], + [-7.034104, 0.86039716, 1.0], + [-6.6405563, 0.88451445, 1.0], + [-0.60662454, 0.98856294, 1.0], + [0.0, 1.0, 1.0], + [-1.0993353, 0.8637165, 1.0], + [-7.684664, 0.8347192, 1.0], + [-6.927881, 0.8624654, 1.0], + [-0.7485936, 0.98060685, 1.0], + [0.0, 1.0, 1.0], + [-0.6461796, 0.892773, 1.0], + [-7.967004, 0.8294017, 1.0], + [-6.8060684, 0.8550583, 1.0], + [-0.8756036, 0.9734578, 1.0], + [0.0, 1.0, 1.0], + [0.017491894, 0.8894634, 1.0], + [-13.43903, 0.7625003, 1.0], + [-16.398203, 0.7407526, 1.0], + [-4.292457, 0.92504275, 1.0], + [0.0, 1.0, 1.0], + [1.3580081, 0.8969149, 1.0], + [-12.114263, 0.7745682, 1.0], + [-15.785243, 0.74346626, 1.0], + [-3.6444035, 0.9348789, 1.0], + [0.0, 1.0, 1.0], + [1.7327255, 0.87856543, 1.0], + [-6.4373803, 0.8177134, 1.0], + [-10.325781, 0.78358805, 1.0], + [-2.567262, 0.9422052, 1.0], + [0.0, 1.0, 1.0], + [1.7113199, 0.8661993, 1.0], + [-6.172294, 0.80224586, 1.0], + [-8.819174, 0.79800993, 1.0], + [-2.020759, 0.95133585, 1.0], + [0.0, 1.0, 1.0], + [-0.265015, 0.9794298, 1.0], + [-2.8535793, 0.9150202, 1.0], + [-2.305115, 0.93263686, 1.0], + [-0.16463748, 0.9945547, 1.0], + [0.0, 1.0, 1.0], + [-0.040856205, 0.9830346, 1.0], + [-1.0551267, 0.93554235, 1.0], + [-1.7591288, 0.8933596, 1.0], + [-0.34586504, 0.97479594, 1.0], + [0.0, 1.0, 1.0], + [0.04883148, 0.94569665, 1.0], + [-0.56813735, 0.9045709, 1.0], + [-1.5370053, 0.81085134, 1.0], + [-0.67263573, 0.8751091, 1.0], + [0.0, 1.0, 1.0], + [0.3425455, 0.9003105, 1.0], + [0.7451464, 0.8549796, 1.0], + [0.20236868, 0.8441968, 1.0], + [0.07073531, 0.86383015, 1.0], + [0.0, 1.0, 1.0], + [2.8022106, 0.80535203, 1.0], + [4.376988, 0.70769286, 1.0], + [4.8166633, 0.707951, 1.0], + [2.8132818, 0.8092864, 1.0], + [0.0, 1.0, 1.0], + [2.3843334, 0.85017675, 1.0], + [5.65875, 0.6995908, 1.0], + [8.184944, 0.62843776, 1.0], + [5.3796787, 0.7383771, 1.0], + [0.0, 1.0, 1.0], + [0.9017272, 0.9307022, 1.0], + [3.9063222, 0.765117, 1.0], + [6.974823, 0.69017345, 1.0], + [8.185112, 0.6926818, 1.0], + [0.0, 1.0, 1.0], + [0.34200728, 0.96810126, 1.0], + [2.172312, 0.84994847, 1.0], + [8.207728, 0.66688955, 1.0], + [10.739886, 0.62480503, 1.0], + [0.0, 1.0, 1.0], + [0.2027068, 0.9796112, 1.0], + [0.44695723, 0.9623203, 1.0], + [2.7991126, 0.8646282, 1.0], + [5.550562, 0.77532023, 1.0], + [0.0, 1.0, 1.0], + [0.27983877, 0.96956384, 1.0], + [0.464609, 0.9587286, 1.0], + [2.1685085, 0.8807419, 1.0], + [3.9939811, 0.8027935, 1.0], + [0.0, 1.0, 1.0], + [0.32165113, 0.96697015, 1.0], + [0.6435883, 0.93713695, 1.0], + [2.400229, 0.8268258, 1.0], + [3.7155714, 0.7548948, 1.0], + [0.0, 1.0, 1.0], + [0.13139115, 0.98465353, 1.0], + [0.26204187, 0.9602435, 1.0], + [1.2602012, 0.87366205, 1.0], + [2.4989963, 0.7911792, 1.0], + [0.0, 1.0, 1.0], + [0.04954044, 0.990862, 1.0], + [0.15550658, 0.9640937, 1.0], + [0.25354654, 0.9320879, 1.0], + [0.37589833, 0.8832426, 1.0], + [0.0, 1.0, 1.0], + [0.045748647, 0.99577636, 1.0], + [0.049044535, 0.9851228, 1.0], + [0.006159843, 0.9524085, 1.0], + [0.02588024, 0.8944849, 1.0], + [0.0, 1.0, 1.0], + [0.0030355332, 0.99710935, 1.0], + [-0.011863776, 0.9914036, 1.0], + [-0.054379225, 0.972176, 1.0], + [-0.039397053, 0.9642003, 1.0], + [0.0, 1.0, 1.0], + [-0.008150871, 0.9936041, 1.0], + [-0.037190154, 0.9887421, 1.0], + [-0.06706805, 0.96539193, 1.0], + [-0.0317106, 0.9736436, 1.0], + [0.0, 1.0, 1.0], + [-0.0109262755, 0.9985575, 1.0], + [-0.024314428, 0.99118114, 1.0], + [-0.047992434, 0.9725764, 1.0], + [-0.012975532, 0.9771226, 1.0], + [0.0, 1.0, 1.0], + [-0.011895402, 0.99871415, 1.0], + [-0.05900639, 0.9952044, 1.0], + [-0.07541064, 0.9911421, 1.0], + [-0.010232872, 0.99422073, 1.0], + [0.0, 1.0, 1.0], + [-0.014660832, 0.9972283, 1.0], + [-0.028494045, 0.9946739, 1.0], + [-0.04470135, 0.9915807, 1.0], + [-0.08405579, 0.9945791, 1.0], + [0.0, 1.0, 1.0], + [-0.033616997, 0.9969934, 1.0], + [-0.045168266, 0.993965, 1.0], + [-0.059918057, 0.98309493, 1.0], + [-0.062201336, 0.98883307, 1.0], + [0.0, 1.0, 1.0], + [-0.2381658, 0.99073696, 1.0], + [-0.47134387, 0.97534996, 1.0], + [-0.15076974, 0.978513, 1.0], + [-0.027898252, 0.9923376, 1.0], + [0.0, 1.0, 1.0], + [-0.094118536, 0.99575883, 1.0], + [-0.22728153, 0.9851976, 1.0], + [-0.14446811, 0.98079014, 1.0], + [-0.026381236, 0.9947049, 1.0], + [0.0, 1.0, 1.0], + [-0.027151769, 0.9987402, 1.0], + [-0.055821914, 0.9954352, 1.0], + [-0.07118323, 0.9875545, 1.0], + [-0.024681333, 0.9936937, 1.0], + [0.0, 1.0, 1.0], + [-0.057406988, 0.9985795, 1.0], + [-0.075127326, 0.99687934, 1.0], + [-0.012029578, 0.99905646, 1.0], + [-0.0011061644, 0.99975455, 1.0], + [0.0, 1.0, 1.0], + [-0.02290934, 0.99975824, 1.0], + [-0.025390672, 0.9995626, 1.0], + [-0.0026628294, 0.99994296, 1.0], + [-3.556243e-05, 0.9999988, 1.0], + [0.0, 1.0, 1.0], + [-0.017105369, 0.99992365, 1.0], + [-0.01626235, 0.9998567, 1.0], + [-0.0023494267, 0.9999825, 1.0], + [-3.9371847e-05, 0.9999998, 1.0], + [0.0, 1.0, 1.0], + [-0.01157836, 0.999962, 1.0], + [-0.002812421, 0.99997854, 1.0], + [-0.0007163898, 0.9999961, 1.0], + [-1.7496699e-05, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.014018655, 0.99996704, 1.0], + [-0.002332822, 0.9999732, 1.0], + [-0.0003527423, 0.99999386, 1.0], + [-5.771106e-06, 0.9999999, 1.0], + [0.0, 1.0, 1.0], + [-0.010408213, 0.99997354, 1.0], + [-0.0040600216, 0.99995077, 1.0], + [-0.0005498676, 0.999989, 1.0], + [-7.1796394e-06, 0.9999998, 1.0], + [0.0, 1.0, 1.0], + [-0.0023898028, 0.9999025, 1.0], + [-0.0021971867, 0.99995613, 1.0], + [-0.00040569165, 0.99999076, 1.0], + [-5.7337793e-06, 0.9999999, 1.0], + [0.0, 1.0, 1.0], + [-0.006176083, 0.99988633, 1.0], + [-0.0033466935, 0.99995404, 1.0], + [-0.00018771198, 0.99999684, 1.0], + [-1.5582656e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.0078279935, 0.99997926, 1.0], + [-0.0021727988, 0.99997437, 1.0], + [-8.390261e-05, 0.9999989, 1.0], + [-7.6212564e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.011409035, 0.9999373, 1.0], + [-0.0012485707, 0.99999255, 1.0], + [-5.873547e-05, 0.99999946, 1.0], + [-1.1412731e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.0320137, 0.9996883, 1.0], + [-0.0022234167, 0.99998236, 1.0], + [-4.2978718e-05, 0.99999964, 1.0], + [-8.9091026e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.060883675, 0.99925774, 1.0], + [-0.0041872207, 0.9999481, 1.0], + [-0.00016332162, 0.99999785, 1.0], + [-2.8002216e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.026953986, 0.999662, 1.0], + [-0.00503322, 0.9999406, 1.0], + [-0.0005006954, 0.9999944, 1.0], + [-6.1892397e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.019095117, 0.9997323, 1.0], + [-0.0048538097, 0.9999398, 1.0], + [-0.00037457192, 0.9999961, 1.0], + [-3.6742229e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.03751212, 0.9993693, 1.0], + [-0.010522507, 0.99987054, 1.0], + [-0.0009128494, 0.99999034, 1.0], + [-7.887352e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.05509656, 0.99895823, 1.0], + [-0.005975121, 0.99990004, 1.0], + [-0.00018607976, 0.9999967, 1.0], + [-2.4801245e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.06763289, 0.9990122, 1.0], + [-0.026763257, 0.99962497, 1.0], + [-0.0033132737, 0.9999459, 1.0], + [-5.9110884e-05, 0.999999, 1.0], + [0.0, 1.0, 1.0], + [-0.06290056, 0.9987752, 1.0], + [-0.06590056, 0.9988833, 1.0], + [-0.016112784, 0.9997368, 1.0], + [-0.00030407612, 0.9999951, 1.0], + [0.0, 1.0, 1.0], + [-0.06050088, 0.9985156, 1.0], + [-0.017029036, 0.99951345, 1.0], + [-0.0026492565, 0.9999436, 1.0], + [-4.364142e-05, 0.9999992, 1.0], + [0.0, 1.0, 1.0], + [-0.21668494, 0.99283016, 1.0], + [-0.19338678, 0.9937941, 1.0], + [-0.029703936, 0.99916244, 1.0], + [-0.00055253773, 0.9999868, 1.0], + [0.0, 1.0, 1.0], + [-1.0463094, 0.95176995, 1.0], + [-1.3973116, 0.9536363, 1.0], + [-0.50282496, 0.98618114, 1.0], + [-0.019256245, 0.9995079, 1.0], + [0.0, 1.0, 1.0], + [-3.5435355, 0.84623945, 1.0], + [-5.1535788, 0.84832674, 1.0], + [-2.824056, 0.9316529, 1.0], + [-0.15195742, 0.9963698, 1.0], + [0.0, 1.0, 1.0], + [-8.673897, 0.7131587, 1.0], + [-12.270074, 0.68430734, 1.0], + [-11.376253, 0.73719627, 1.0], + [-1.8281991, 0.95513356, 1.0], + [0.0, 1.0, 1.0], + [-12.039047, 0.6432662, 1.0], + [-13.724231, 0.6535759, 1.0], + [-14.0326, 0.6799867, 1.0], + [-9.946063, 0.780004, 1.0], + [0.0, 1.0, 1.0], + [-12.528839, 0.6221673, 1.0], + [-13.425968, 0.65002877, 1.0], + [-13.075199, 0.67871696, 1.0], + [-10.942493, 0.7442244, 1.0], + [0.0, 1.0, 1.0], + [-10.306666, 0.6389583, 1.0], + [-12.0551195, 0.66896164, 1.0], + [-9.823601, 0.7308576, 1.0], + [-5.281826, 0.83818936, 1.0], + [0.0, 1.0, 1.0], + [-5.3867707, 0.65209216, 1.0], + [-5.8347864, 0.71964204, 1.0], + [-3.59081, 0.8329669, 1.0], + [-1.4847257, 0.9156909, 1.0], + [0.0, 1.0, 1.0], + [-1.754784, 0.74886227, 1.0], + [-0.24797672, 0.74751395, 1.0], + [0.6299296, 0.7613501, 1.0], + [0.82416815, 0.8632212, 1.0], + [0.0, 1.0, 1.0], + [1.4033656, 0.9101428, 1.0], + [2.4547875, 0.81642723, 1.0], + [3.6370852, 0.787311, 1.0], + [3.6531272, 0.8031764, 1.0], + [0.0, 1.0, 1.0], + [1.9459922, 0.951957, 1.0], + [2.5553524, 0.8894862, 1.0], + [3.6345732, 0.81872904, 1.0], + [2.785436, 0.8552696, 1.0], + [0.0, 1.0, 1.0], + [0.679637, 0.9787555, 1.0], + [3.8709927, 0.9172711, 1.0], + [5.7068896, 0.8849159, 1.0], + [1.1118813, 0.9758798, 1.0], + [0.0, 1.0, 1.0], + [0.3420551, 0.98117673, 1.0], + [1.5517482, 0.967525, 1.0], + [2.653233, 0.95270234, 1.0], + [0.44305915, 0.9921006, 1.0], + [0.0, 1.0, 1.0], + [0.23155965, 0.9924181, 1.0], + [0.11397146, 0.99741286, 1.0], + [0.13187227, 0.9980809, 1.0], + [0.020392587, 0.9996969, 1.0], + [0.0, 1.0, 1.0], + [0.13352406, 0.99550617, 1.0], + [0.025518132, 0.99892914, 1.0], + [-0.0005152045, 0.99990207, 1.0], + [-0.000118601536, 0.99999446, 1.0], + [0.0, 1.0, 1.0], + [0.15706417, 0.99707514, 1.0], + [0.02578307, 0.9993154, 1.0], + [-0.00202729, 0.9999331, 1.0], + [-0.00021436776, 0.9999964, 1.0], + [0.0, 1.0, 1.0], + [0.3615966, 0.99366856, 1.0], + [0.087646805, 0.99867254, 1.0], + [0.002637883, 0.99996406, 1.0], + [3.6361645e-05, 0.99999946, 1.0], + [0.0, 1.0, 1.0], + [0.1291499, 0.9977887, 1.0], + [0.046300918, 0.99929285, 1.0], + [0.012817254, 0.9998544, 1.0], + [0.00041612334, 0.9999955, 1.0], + [0.0, 1.0, 1.0], + [0.011200566, 0.9983723, 1.0], + [-0.008214572, 0.99942863, 1.0], + [0.0021838315, 0.9999416, 1.0], + [0.000100621306, 0.9999986, 1.0], + [0.0, 1.0, 1.0], + [-0.060381968, 0.9930565, 1.0], + [-0.06464544, 0.9983384, 1.0], + [-0.007711393, 0.99990016, 1.0], + [-0.00011417703, 0.9999988, 1.0], + [0.0, 1.0, 1.0], + [-0.11693042, 0.99224305, 1.0], + [-0.11525121, 0.9982498, 1.0], + [-0.027416473, 0.99979055, 1.0], + [-0.00054621015, 0.99999607, 1.0], + [0.0, 1.0, 1.0], + [0.014975694, 0.99728876, 1.0], + [-0.056130975, 0.9989796, 1.0], + [-0.0222607, 0.9997437, 1.0], + [-0.00058228534, 0.9999938, 1.0], + [0.0, 1.0, 1.0], + [0.05060913, 0.99668014, 1.0], + [-0.2700596, 0.99520206, 1.0], + [-0.14083444, 0.9978208, 1.0], + [-0.005215654, 0.9999189, 1.0], + [0.0, 1.0, 1.0], + [-0.05793456, 0.99310505, 1.0], + [-1.3339288, 0.97664934, 1.0], + [-1.0737243, 0.98243177, 1.0], + [-0.06070526, 0.9990094, 1.0], + [0.0, 1.0, 1.0], + [-0.22500885, 0.9885798, 1.0], + [-2.394239, 0.9580366, 1.0], + [-1.8970352, 0.9690168, 1.0], + [-0.109246016, 0.99819875, 1.0], + [0.0, 1.0, 1.0], + [-0.39491686, 0.98909, 1.0], + [-2.9813097, 0.9401541, 1.0], + [-2.3468347, 0.9552055, 1.0], + [-0.15643519, 0.99701655, 1.0], + [0.0, 1.0, 1.0], + [-1.8292645, 0.9594883, 1.0], + [-13.511781, 0.763661, 1.0], + [-14.536935, 0.7543218, 1.0], + [-2.2238204, 0.95809615, 1.0], + [0.0, 1.0, 1.0], + [-1.0053811, 0.97643167, 1.0], + [-12.578439, 0.77343625, 1.0], + [-15.1597395, 0.73498416, 1.0], + [-2.625323, 0.9481359, 1.0], + [0.0, 1.0, 1.0], + [-0.55154705, 0.9794314, 1.0], + [-6.9208384, 0.85034823, 1.0], + [-10.053682, 0.79368883, 1.0], + [-1.6742866, 0.96229243, 1.0], + [0.0, 1.0, 1.0], + [-0.69403476, 0.96975285, 1.0], + [-3.2779868, 0.9127137, 1.0], + [-4.176942, 0.9027185, 1.0], + [-0.5743538, 0.98639596, 1.0] + ], + "srgb_value": true + } +} diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 260068d1c..56cecdf39 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -32,7 +32,8 @@ use lightcraft_raw::{ /// 5: dark JPEG luminance pairs for the camera toe (#1307). /// 6: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected. /// 7: the bundled NIKON Z 6_2 profile. -pub const LOOK_VERSION: u32 = 7; +/// 8: the bundled NIKON Z 8 and NIKON D5300 profiles. +pub const LOOK_VERSION: u32 = 8; #[derive(Clone, Debug)] pub(crate) struct CameraLook { @@ -2501,14 +2502,14 @@ mod tests { assert_eq!(look.hue_sat, bundled.hue_sat, "the look's table is not the profile's"); } - /// The public Z 6II NEF (skipped without the corpus) takes its colour from the bundled Z 6II profile. It wasn't - /// one of the photos the profile was fitted to. - #[test] - fn corpus_z6ii_uses_the_bundled_profile() { + /// A public corpus raw of `model` (skipped without the corpus) takes its colour from that model's bundled profile: + /// its fit is accepted and uses exactly the profile's matrix and table. + fn assert_corpus_raw_uses_the_bundled_profile(file: &str, model: &str) { let path = std::env::var_os("LIGHTCRAFT_CORPUS") .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw/nef-nikon-z6ii-lossless.nef"); + .join("raw") + .join(file); let bytes = match std::fs::read(&path) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { @@ -2517,12 +2518,12 @@ mod tests { } Err(e) => panic!("{}: {e}", path.display()), }; - let (_, json) = crate::camera_profiles::BUNDLED.iter().find(|(m, _)| *m == "NIKON Z 6_2").expect("the Z 6II profile is built in"); + let (_, json) = crate::camera_profiles::BUNDLED.iter().find(|(m, _)| *m == model).expect("the profile is built in"); let bundled: crate::camera_profiles::CameraProfile = serde_json::from_str(json).unwrap(); - let used = crate::camera_profiles::get("NIKON Z 6_2").expect("Z 6II profile"); - assert_eq!(*used, bundled, "a local NIKON_Z_6_2 profile overrides the built-in one; remove it to run this test"); + let used = crate::camera_profiles::get(model).expect("a profile"); + assert_eq!(*used, bundled, "a local {model} profile overrides the built-in one; remove it to run this test"); let raw = lightcraft_raw::decode(&bytes).unwrap(); - assert_eq!(raw.metadata.model.as_deref(), Some("NIKON Z 6_2")); + assert_eq!(raw.metadata.model.as_deref(), Some(model)); let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); let look = fit_preview(&raw, &bytes, &transform, None).0.expect("fit accepted"); let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap()); @@ -2534,6 +2535,18 @@ mod tests { assert_eq!(look.hue_sat, bundled.hue_sat, "the look's table is not the profile's"); } + /// The public Z 6II NEF wasn't one of the photos its bundled profile was fitted to. + #[test] + fn corpus_z6ii_uses_the_bundled_profile() { + assert_corpus_raw_uses_the_bundled_profile("nef-nikon-z6ii-lossless.nef", "NIKON Z 6_2"); + } + + /// The public Z 8 NEF (Z 14-24 f/2.8 S) wasn't one of the photos its bundled profile was fitted to. + #[test] + fn corpus_z8_uses_the_bundled_profile() { + assert_corpus_raw_uses_the_bundled_profile("nef-nikon-z8-lossless.nef", "NIKON Z 8"); + } + /// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white /// balance is relative to the as-shot look. #[test] diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs index 41fc9ca2f..efbf3506a 100644 --- a/crates/engine/src/camera_profiles.rs +++ b/crates/engine/src/camera_profiles.rs @@ -28,6 +28,7 @@ pub const BUNDLED: &[(&str, &str)] = &[ ("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")), ("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")), ("NIKON Z 6_2", include_str!("../../../assets/camera-profiles/NIKON_Z_6_2.json")), + ("NIKON Z 8", include_str!("../../../assets/camera-profiles/NIKON_Z_8.json")), ("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")), ("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")), ]; diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 5ca637774..106879fa2 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -113,7 +113,7 @@ A pooled profile was tried and is not bundled. Pooling the 46 photos (158,549 co One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs, NEFs or RAFs per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos. -Profiles are JSON files `.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos (below); X-H2S, 201 photos; X-T4, 545 photos; NIKON Z 6_2, 296 photos (below). They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt. +Profiles are JSON files `.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos (below); X-H2S, 201 photos; X-T4, 545 photos; NIKON Z 6_2, 296 photos; NIKON Z 8, 261 photos (below). They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt. ### Sony ILCE-7CR @@ -132,6 +132,12 @@ For validation every fifth photo in sorted name order (59) was held back and a p The public raw.pixls.us Z 6II file (`nef-nikon-z6ii-lossless.nef`, Vivid picture control with the Z 24-200, not used for fitting) is accepted with the profile and goes from 5.37 to 5.25. +### Nikon Z 8 + +Fitted from 261 usable photos of 278 Z 8 NEFs (one photographer, 2023 and 2026, almost all Standard picture control, lossless 14-bit, with the NIKKOR Z 24-120mm f/4 S and the AF-S 70-200mm f/2.8G on the FTZ; the High Efficiency files can't be decoded and were skipped), 969,860 colour pairs, collected the same way as the Z 6II's with Nikon's distortion correction read. Holding back every fifth photo (55) and fitting the other four fifths, mean ΔE on the held-back photos went from 2.55 to 2.33 (median 2.31 to 2.16), 39 closer and 10 farther, the largest by 0.46. The public raw.pixls.us Z 8 file (`nef-nikon-z8-lossless.nef`, Z 14-24 f/2.8 S, not used for fitting) is accepted with the profile and stays about the same, 3.22 to 3.27. + +The same was tried for the photographer's D700, D750 and D80 (about 300 photos each) and it didn't help: on held-back photos the D750 stayed at 3.2 and the D700 and D80 got slightly worse, even fitting only their Standard (or normal saturation) photos. Their per-photo fits are already close and those bodies were shot with a mix of picture controls, so they have no profile. + ## Sony Dynamic Range Optimizer (DRO) Sony bodies apply DRO, a local tone operator that brightens darker regions, to their JPEG (and the preview embedded in the ARW) only; the raw data are unchanged, and the factory default is DRO Auto. The tone curve fitted to that JPEG carried the brightening into the raw's starting look, so DRO photos opened lighter than in Lightroom, darktable, Photomator or Affinity, which develop the raw without it (issue #244). On 24 ILCE-7CR photos (Standard creative style) with Lightroom 9.5.1 default exports, mean L* at 200 px: with DRO off, Lightroom matches the camera JPEG (+0.15 L*, all within ±1.4) and LightCraft matches Lightroom (−0.19 L*, median ΔE76 3.6); with DRO Lv3 or Auto (8 photos), Lightroom is 2.4–10.7 L* darker than the camera JPEG and LightCraft followed the JPEG, +6.4 L* from Lightroom (median ΔE76 7.5). The tone curves fitted to 131 ILCE-7CR photos show where: at scene luminance 0.01, 0.02, 0.05 and 0.1 the median DRO Lv3/Auto curve is 8, 10, 9 and 6 L* above the DRO-off one, and from 0.3 up they agree. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 0e1a7bb49..c7b15805a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -234,7 +234,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); the fit pairs the sensor moved into the file's lens-warp geometry when that lines up better with a lens-corrected camera JPEG (29 CC0 raws: alignment +0.13, mean ΔE 3.73 → 3.61); bundled NIKON Z 6_2 profile (296 photos; 59 held-back photos mean ΔE 2.73 → 2.54) | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); the fit pairs the sensor moved into the file's lens-warp geometry when that lines up better with a lens-corrected camera JPEG (29 CC0 raws: alignment +0.13, mean ΔE 3.73 → 3.61); bundled NIKON Z 6_2 profile (296 photos; 59 held-back photos mean ΔE 2.73 → 2.54) and NIKON Z 8 profile (261 photos; 55 held back 2.55 → 2.33) | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | @@ -817,6 +817,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-11 | minor | Model setup folder action, opener error and retry (LR-MASK-OBJECTS). | | 2026-10-13 | minor | LR-PROF-CAMERACOLOR: the camera look follows the lens warp of lens-corrected camera JPEGs; status unchanged (🟡) | | 2026-10-13 | minor | LR-PROF-CAMERACOLOR: bundled Nikon Z 6II camera profile; status unchanged (🟡) | +| 2026-10-13 | minor | LR-PROF-CAMERACOLOR: bundled Nikon Z 8 camera profile; status unchanged (🟡) | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index 19edcfc06..9e6f5fea9 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,8 +2,8 @@ ## 2026-10-14 -### Nikon Z 6II colour profile -- Raws from the Nikon Z 6II now start from a colour profile built from a few hundred Z 6II photos, the same way the +### Nikon Z 6II and Z 8 colour profiles +- Raws from the Nikon Z 6II and Z 8 now start from a colour profile built from a few hundred photos from each camera, the same way the Sony and Fujifilm profiles were made, instead of fitting each photo's colour on its own. Colours that one photo shows only a little of come out closer to the camera's JPEG. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 365cd8130..fce4e9355 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -652,6 +652,12 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/getfile.php/4160/nice/Nikon%20-%20Z%206_2%20-%2014bit%20compressed%20(Lossless)%20(3:2).NEF", "1b67d15817dbd4143a1f1089e2f09e319a2a2327c124e2643284509dd7621030", ), + ( + // Z 8, NIKKOR Z 14-24 f/2.8 S (not used to fit the bundled Z 8 profile) + "nef-nikon-z8-lossless.nef", + "https://raw.pixls.us/data/Nikon/Z%208/Nikon_Z8_raw_14_bit_lossless_compression.NEF", + "c13a8675294456b9c979e6c1e556951654f35377b2870af2874bfb556f21ce40", + ), ( "nrw-nikon-b700-uncompressed.nrw", "https://raw.pixls.us/getfile.php/1621/nice/Nikon%20-%20COOLPIX%20B700%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", From 975b08c334bb6c4482c4bc1416144df5646678e0 Mon Sep 17 00:00:00 2001 From: Michael Anderson Date: Tue, 13 Oct 2026 10:57:50 -0500 Subject: [PATCH 07/12] LR-PROF-CAMERACOLOR: Add a bundled Nikon Z 6II camera profile Fitted with lightcraft-cli calibrate to 296 of my own Z 6II photos. --- README.md | 2 +- README.zh-hans.md | 2 +- assets/ATTRIBUTION.md | 1 + assets/camera-profiles/NIKON_Z_6_2.json | 1819 +++++++++++++++++++++++ crates/engine/src/camera_preview.rs | 36 +- crates/engine/src/camera_profiles.rs | 1 + docs/camera-preview-colour.md | 10 +- docs/parity-checklist.md | 3 +- docs/whats-new.md | 5 + xtask/src/main.rs | 6 + 10 files changed, 1880 insertions(+), 5 deletions(-) create mode 100644 assets/camera-profiles/NIKON_Z_6_2.json diff --git a/README.md b/README.md index f041111a1..3e93b41ee 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det casual use.** It's great for JPEG/DNG and most Sony / Panasonic / Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera. - **The biggest gaps:** - - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; + - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S, X-T4 and Nikon Z 6II profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; - **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md); - **AI masks and denoise:** subject and sky selection are classical heuristics; - **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md); diff --git a/README.zh-hans.md b/README.zh-hans.md index 0a7feb32c..5183d40a8 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -304,7 +304,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**( - **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/ 较老佳能的 raw 都很出色。 - **最大的缺口:** - - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S 和 X-T4 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; + - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S、X-T4 和尼康 Z 6II 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; - **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md); - **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法; - **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。** diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 7ade9dac2..a5e1716d7 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -14,6 +14,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via ``, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` | | `assets/camera-profiles/ILCE-7CR.json` | Sony ILCE-7CR camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @AlexChambers | original work: fitted by `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131, September 2025 to September 2026, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-09 | none | | `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none | +| `assets/camera-profiles/NIKON_Z_6_2.json` | Nikon Z 6II camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 296 usable Z 6II NEFs of my own (of 299, 2024 to 2026, Standard and Auto picture controls; NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none | | `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix | | `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none | | `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a | diff --git a/assets/camera-profiles/NIKON_Z_6_2.json b/assets/camera-profiles/NIKON_Z_6_2.json new file mode 100644 index 000000000..b00e045c1 --- /dev/null +++ b/assets/camera-profiles/NIKON_Z_6_2.json @@ -0,0 +1,1819 @@ +{ + "version": 1, + "model": "NIKON Z 6_2", + "files": 296, + "samples": 1099207, + "matrix": [ + [1.182910144251291, -0.29205257488744785, 0.10076600427689364], + [-0.06715969465291372, 1.185204734495203, -0.11541627485450512], + [-0.04243207284624334, -0.8237200432609093, 1.8732041320081247] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [0.0, 1.0, 1.0], + [6.2229915, 1.153762, 1.0], + [3.9428349, 1.1697679, 1.0], + [2.9512422, 1.1150318, 1.0], + [1.4521159, 1.0443524, 1.0], + [0.0, 1.0, 1.0], + [6.067863, 1.1530151, 1.0], + [2.674163, 1.1492246, 1.0], + [1.6906267, 1.0755203, 1.0], + [1.0559876, 1.0193381, 1.0], + [0.0, 1.0, 1.0], + [6.406609, 1.1438, 1.0], + [2.0468237, 1.126483, 1.0], + [0.6318166, 1.0328915, 1.0], + [0.38942862, 0.9951044, 1.0], + [0.0, 1.0, 1.0], + [5.6572795, 1.1365504, 1.0], + [1.088494, 1.1316147, 1.0], + [-0.7465848, 1.038763, 1.0], + [-1.7078602, 0.9750448, 1.0], + [0.0, 1.0, 1.0], + [5.6649947, 1.1077542, 1.0], + [0.8204385, 1.1192347, 1.0], + [-1.5888842, 1.0491564, 1.0], + [-3.0090246, 0.96298206, 1.0], + [0.0, 1.0, 1.0], + [5.256699, 1.0831563, 1.0], + [0.6007586, 1.0977919, 1.0], + [-2.570202, 1.0551977, 1.0], + [-5.4068832, 0.9654456, 1.0], + [0.0, 1.0, 1.0], + [4.539097, 1.0591519, 1.0], + [-0.10774251, 1.0788635, 1.0], + [-4.641281, 1.0305448, 1.0], + [-8.283271, 0.93709576, 1.0], + [0.0, 1.0, 1.0], + [4.142809, 1.0373175, 1.0], + [-0.8308222, 1.0613977, 1.0], + [-5.655344, 1.0214658, 1.0], + [-9.607277, 0.93629843, 1.0], + [0.0, 1.0, 1.0], + [3.8500762, 1.0362741, 1.0], + [-1.0345856, 1.0639124, 1.0], + [-5.4233723, 1.0284288, 1.0], + [-9.0873, 0.9531294, 1.0], + [0.0, 1.0, 1.0], + [4.0558853, 1.0385702, 1.0], + [-0.52777964, 1.0754012, 1.0], + [-4.6981053, 1.0426258, 1.0], + [-7.390084, 0.97722876, 1.0], + [0.0, 1.0, 1.0], + [4.439531, 1.0652789, 1.0], + [0.86604506, 1.0861824, 1.0], + [-2.8576968, 1.0505832, 1.0], + [-5.8478637, 0.9746698, 1.0], + [0.0, 1.0, 1.0], + [4.3485656, 1.0967733, 1.0], + [1.9174945, 1.1038177, 1.0], + [0.058721844, 1.0518842, 1.0], + [-1.5929213, 0.9253342, 1.0], + [0.0, 1.0, 1.0], + [3.6591005, 1.1211772, 1.0], + [2.2590137, 1.1025198, 1.0], + [1.1305307, 1.0139761, 1.0], + [-0.1372095, 0.87818533, 1.0], + [0.0, 1.0, 1.0], + [2.6356182, 1.1266937, 1.0], + [1.7848063, 1.0862155, 1.0], + [1.2136673, 0.99129325, 1.0], + [0.49023673, 0.8714401, 1.0], + [0.0, 1.0, 1.0], + [1.4648303, 1.118881, 1.0], + [0.9687011, 1.0753107, 1.0], + [0.7960804, 0.99398947, 1.0], + [0.7395835, 0.8858062, 1.0], + [0.0, 1.0, 1.0], + [0.44883335, 1.1299157, 1.0], + [0.61828953, 1.0732124, 1.0], + [0.9684744, 0.99130034, 1.0], + [1.4130936, 0.9186135, 1.0], + [0.0, 1.0, 1.0], + [0.40853867, 1.140463, 1.0], + [0.99736977, 1.0603918, 1.0], + [1.5362628, 0.9824557, 1.0], + [2.16631, 0.9292637, 1.0], + [0.0, 1.0, 1.0], + [0.20395991, 1.1486486, 1.0], + [0.8272985, 1.0560744, 1.0], + [1.4990557, 0.9908201, 1.0], + [2.295185, 0.93907094, 1.0], + [0.0, 1.0, 1.0], + [-0.57153416, 1.1716017, 1.0], + [0.24325375, 1.0848265, 1.0], + [1.1403633, 1.0200775, 1.0], + [2.1166058, 0.95587236, 1.0], + [0.0, 1.0, 1.0], + [-1.7980459, 1.2015567, 1.0], + [-0.9087, 1.1328977, 1.0], + [0.12065623, 1.0612612, 1.0], + [1.1421617, 0.9739673, 1.0], + [0.0, 1.0, 1.0], + [-3.3530312, 1.227411, 1.0], + [-2.557493, 1.1682405, 1.0], + [-1.6862918, 1.0950687, 1.0], + [-0.6257788, 0.9961956, 1.0], + [0.0, 1.0, 1.0], + [-5.1431966, 1.2525787, 1.0], + [-4.3500066, 1.1942875, 1.0], + [-3.3942263, 1.1264504, 1.0], + [-0.860235, 1.0129987, 1.0], + [0.0, 1.0, 1.0], + [-7.172523, 1.2782786, 1.0], + [-6.2652287, 1.2184532, 1.0], + [-5.2175336, 1.1654094, 1.0], + [-0.90600926, 1.02157, 1.0], + [0.0, 1.0, 1.0], + [-9.301277, 1.3098416, 1.0], + [-8.319625, 1.254189, 1.0], + [-6.29361, 1.1807519, 1.0], + [-0.2820965, 1.0070943, 1.0], + [0.0, 1.0, 1.0], + [-11.833535, 1.3462554, 1.0], + [-10.534334, 1.2848289, 1.0], + [-5.5140915, 1.1255784, 1.0], + [-0.11413354, 1.0005571, 1.0], + [0.0, 1.0, 1.0], + [-13.837044, 1.3168585, 1.0], + [-11.592036, 1.241745, 1.0], + [-3.8057983, 1.0537387, 1.0], + [-0.12250334, 0.9994377, 1.0], + [0.0, 1.0, 1.0], + [-15.302334, 1.2547938, 1.0], + [-12.304744, 1.171094, 1.0], + [-2.6331367, 1.0254496, 1.0], + [-0.02462376, 1.0000972, 1.0], + [0.0, 1.0, 1.0], + [-16.3649, 1.2033144, 1.0], + [-13.840853, 1.121017, 1.0], + [-4.7440042, 1.016522, 1.0], + [-0.09164992, 1.0001389, 1.0], + [0.0, 1.0, 1.0], + [-17.23536, 1.1622376, 1.0], + [-14.924817, 1.0792886, 1.0], + [-6.874241, 1.0002234, 1.0], + [-0.19945428, 0.9995216, 1.0], + [0.0, 1.0, 1.0], + [-17.24167, 1.1120819, 1.0], + [-13.978684, 1.0264746, 1.0], + [-5.8763328, 0.98856574, 1.0], + [-0.13876384, 0.999489, 1.0], + [0.0, 1.0, 1.0], + [-18.172258, 1.0878747, 1.0], + [-15.457481, 1.0080547, 1.0], + [-6.565444, 0.9856226, 1.0], + [-0.13294418, 0.9996134, 1.0], + [0.0, 1.0, 1.0], + [-16.83038, 1.0693645, 1.0], + [-15.625549, 0.9914287, 1.0], + [-7.72421, 0.9804178, 1.0], + [-0.15811352, 0.9995089, 1.0], + [0.0, 1.0, 1.0], + [-16.11028, 1.0584325, 1.0], + [-14.346652, 0.99943095, 1.0], + [-6.310089, 0.9858199, 1.0], + [-0.109850876, 0.9996651, 1.0], + [0.0, 1.0, 1.0], + [-14.929288, 1.0361999, 1.0], + [-12.50551, 0.9834405, 1.0], + [-5.2874904, 0.98251873, 1.0], + [-0.0921156, 0.99959683, 1.0], + [0.0, 1.0, 1.0], + [-14.727734, 1.0360364, 1.0], + [-12.03307, 0.9717191, 1.0], + [-5.2304645, 0.97766334, 1.0], + [-0.07621693, 0.9996011, 1.0], + [0.0, 1.0, 1.0], + [-15.998532, 1.0374138, 1.0], + [-13.555987, 0.9710816, 1.0], + [-6.071474, 0.97758615, 1.0], + [-0.088387944, 0.9996019, 1.0], + [0.0, 1.0, 1.0], + [-16.750172, 1.027443, 1.0], + [-14.493484, 0.9749124, 1.0], + [-5.9644837, 0.9815559, 1.0], + [-0.09535428, 0.9994739, 1.0], + [0.0, 1.0, 1.0], + [-17.231142, 0.9994733, 1.0], + [-14.346948, 0.95981574, 1.0], + [-6.037715, 0.9591468, 1.0], + [-0.38386485, 0.9952976, 1.0], + [0.0, 1.0, 1.0], + [-17.424784, 0.9555782, 1.0], + [-14.198873, 0.9357824, 1.0], + [-6.6123714, 0.94408447, 1.0], + [-0.4532846, 0.9941679, 1.0], + [0.0, 1.0, 1.0], + [-17.279493, 0.90192854, 1.0], + [-13.564478, 0.90127003, 1.0], + [-6.147515, 0.93834627, 1.0], + [-0.2323653, 0.9971496, 1.0], + [0.0, 1.0, 1.0], + [-17.093803, 0.85399765, 1.0], + [-12.756323, 0.87821823, 1.0], + [-6.1378803, 0.92416245, 1.0], + [-0.26953444, 0.9962813, 1.0], + [0.0, 1.0, 1.0], + [-16.701775, 0.8148734, 1.0], + [-11.737119, 0.86213005, 1.0], + [-4.978892, 0.925974, 1.0], + [-0.2315494, 0.9961441, 1.0], + [0.0, 1.0, 1.0], + [-16.116898, 0.79390544, 1.0], + [-11.100497, 0.8544334, 1.0], + [-3.051835, 0.9431366, 1.0], + [-0.095544234, 0.99742615, 1.0], + [0.0, 1.0, 1.0], + [-15.288991, 0.78051233, 1.0], + [-9.944976, 0.8618912, 1.0], + [-2.3829138, 0.9570897, 1.0], + [-0.033786688, 0.9981832, 1.0], + [0.0, 1.0, 1.0], + [-12.088607, 0.799739, 1.0], + [-6.9067097, 0.9043021, 1.0], + [-2.2045915, 0.98223805, 1.0], + [-0.04008953, 0.9986963, 1.0], + [0.0, 1.0, 1.0], + [-8.372293, 0.8433635, 1.0], + [-3.43054, 0.96173584, 1.0], + [-0.43066922, 1.0183202, 1.0], + [0.19982383, 1.0013425, 1.0], + [0.0, 1.0, 1.0], + [-7.0979204, 0.87468654, 1.0], + [-2.605678, 0.9790638, 1.0], + [0.7438621, 1.0240322, 1.0], + [0.90940267, 1.007664, 1.0], + [0.0, 1.0, 1.0], + [-7.001, 0.881765, 1.0], + [-1.34574, 1.0225254, 1.0], + [1.5278748, 1.0623524, 1.0], + [1.7761368, 1.0346187, 1.0], + [0.0, 1.0, 1.0], + [-6.164434, 0.8987546, 1.0], + [-0.2458219, 1.0717543, 1.0], + [1.9966408, 1.1075406, 1.0], + [2.03719, 1.0744351, 1.0], + [0.0, 1.0, 1.0], + [-5.634933, 0.86535674, 1.0], + [-1.0753672, 1.0728083, 1.0], + [1.0631428, 1.1154276, 1.0], + [0.6964589, 1.0430381, 1.0], + [0.0, 1.0, 1.0], + [-2.4912696, 0.92214453, 1.0], + [-0.93249446, 1.0711535, 1.0], + [-0.41212073, 1.0973893, 1.0], + [-0.1942435, 1.0235159, 1.0], + [0.0, 1.0, 1.0], + [-0.8377413, 1.0034848, 1.0], + [-0.45683056, 1.0996051, 1.0], + [-0.7544672, 1.0989084, 1.0], + [-0.3452393, 1.0226208, 1.0], + [0.0, 1.0, 1.0], + [-0.1365077, 1.0087785, 1.0], + [0.0327875, 1.0931748, 1.0], + [0.03409552, 1.0605692, 1.0], + [-0.08136579, 1.0068369, 1.0], + [0.0, 1.0, 1.0], + [1.215217, 0.95414764, 1.0], + [1.6130612, 1.0335056, 1.0], + [0.07605914, 1.0273331, 1.0], + [-0.20885852, 1.003798, 1.0], + [0.0, 1.0, 1.0], + [2.3572938, 0.92316496, 1.0], + [2.6421683, 1.0025958, 1.0], + [0.9645612, 1.0110464, 1.0], + [0.10840102, 1.0010912, 1.0], + [0.0, 1.0, 1.0], + [3.770162, 0.91984165, 1.0], + [3.5076504, 0.99966085, 1.0], + [1.6117474, 1.0087022, 1.0], + [0.47452623, 0.99906003, 1.0], + [0.0, 1.0, 1.0], + [3.279187, 0.9259939, 1.0], + [2.1051095, 1.0003723, 1.0], + [0.7558728, 1.0085037, 1.0], + [0.4204648, 0.999157, 1.0], + [0.0, 1.0, 1.0], + [3.1316261, 0.9648295, 1.0], + [1.2332041, 1.0157939, 1.0], + [0.23060545, 1.0078584, 1.0], + [0.014259016, 1.0006881, 1.0], + [0.0, 1.0, 1.0], + [3.3989656, 0.99194896, 1.0], + [2.2900262, 1.03556, 1.0], + [1.1375499, 1.013088, 1.0], + [0.113812156, 1.0003917, 1.0], + [0.0, 1.0, 1.0], + [4.0414777, 1.0378546, 1.0], + [2.3057628, 1.1105787, 1.0], + [1.6360228, 1.0446037, 1.0], + [0.24664895, 1.0011342, 1.0], + [0.0, 1.0, 1.0], + [4.806446, 1.0598252, 1.0], + [2.9564526, 1.1106843, 1.0], + [0.9111372, 1.0506293, 1.0], + [-0.017489238, 1.0016158, 1.0], + [0.0, 1.0, 1.0], + [4.3138623, 1.0589161, 1.0], + [2.0492713, 1.1007633, 1.0], + [1.2071747, 1.0373629, 1.0], + [0.13990282, 1.0014288, 1.0], + [0.0, 1.0, 1.0], + [4.150446, 1.0142206, 1.0], + [2.3013182, 1.0552701, 1.0], + [1.7414474, 1.0204091, 1.0], + [0.2009622, 1.0014528, 1.0], + [0.0, 1.0, 1.0], + [5.0740504, 0.9837976, 1.0], + [2.0655751, 1.0382036, 1.0], + [0.7234968, 1.018726, 1.0], + [-0.1094017, 1.0020746, 1.0], + [0.0, 1.0, 1.0], + [5.1731286, 0.99559367, 1.0], + [2.5818305, 1.019053, 1.0], + [1.2261839, 1.0031092, 1.0], + [0.17011318, 1.0003996, 1.0], + [0.0, 1.0, 1.0], + [4.2959146, 1.0069213, 1.0], + [1.4468756, 1.0213524, 1.0], + [0.6366564, 1.0068634, 1.0], + [0.06898734, 1.0005128, 1.0], + [0.0, 1.0, 1.0], + [6.0099535, 1.005978, 1.0], + [2.4273057, 1.0169591, 1.0], + [-0.13818303, 1.0067599, 1.0], + [-0.0033633148, 1.0006784, 1.0], + [0.0, 1.0, 1.0], + [7.5230107, 1.0331137, 1.0], + [4.7096148, 1.0529838, 1.0], + [1.6526409, 1.0215665, 1.0], + [0.09062616, 1.0016879, 1.0], + [0.0, 1.0, 1.0], + [8.177528, 1.0500778, 1.0], + [5.78432, 1.0727233, 1.0], + [4.1867056, 1.0556285, 1.0], + [0.946918, 1.0117143, 1.0], + [0.0, 1.0, 1.0], + [7.787752, 1.0514582, 1.0], + [6.581295, 1.0929804, 1.0], + [5.1642475, 1.0660969, 1.0], + [1.3973913, 1.0133713, 1.0], + [0.0, 1.0, 1.0], + [7.352549, 1.0832101, 1.0], + [6.488305, 1.1071424, 1.0], + [4.7150044, 1.0691316, 1.0], + [0.9782577, 1.0079756, 1.0], + [0.0, 1.0, 1.0], + [7.44746, 1.1423436, 1.0], + [5.2333174, 1.1424847, 1.0], + [4.2587075, 1.0928718, 1.0], + [1.1760778, 1.0190315, 1.0], + [0.0, 1.0, 1.0], + [4.7819715, 1.0929513, 1.0], + [3.038743, 1.1225566, 1.0], + [3.0403032, 1.0947937, 1.0], + [2.7168245, 1.0584737, 1.0], + [0.0, 1.0, 1.0], + [4.2442985, 1.0740716, 1.0], + [1.8085717, 1.092445, 1.0], + [1.6521245, 1.0550505, 1.0], + [1.2793007, 1.015843, 1.0], + [0.0, 1.0, 1.0], + [3.8161194, 1.048953, 1.0], + [1.0271565, 1.0645669, 1.0], + [0.37108362, 1.0149966, 1.0], + [0.16207772, 0.98473, 1.0], + [0.0, 1.0, 1.0], + [2.9961936, 1.0367951, 1.0], + [0.3735622, 1.0575669, 1.0], + [-1.3138187, 0.9924495, 1.0], + [-2.2027087, 0.9511259, 1.0], + [0.0, 1.0, 1.0], + [3.2774894, 1.0240017, 1.0], + [0.48561475, 1.0381898, 1.0], + [-1.8494679, 0.98786247, 1.0], + [-3.2887905, 0.9338637, 1.0], + [0.0, 1.0, 1.0], + [3.4378223, 1.0108881, 1.0], + [0.52244306, 1.0189863, 1.0], + [-1.8643115, 0.99323344, 1.0], + [-5.1015615, 0.92938703, 1.0], + [0.0, 1.0, 1.0], + [3.1067812, 0.9985392, 1.0], + [-0.12131784, 1.0145019, 1.0], + [-4.1442766, 0.986398, 1.0], + [-8.339385, 0.9153509, 1.0], + [0.0, 1.0, 1.0], + [2.4298422, 0.96142745, 1.0], + [-1.1118385, 1.0061582, 1.0], + [-5.695069, 0.9836191, 1.0], + [-9.741408, 0.91440827, 1.0], + [0.0, 1.0, 1.0], + [2.01661, 0.9458497, 1.0], + [-1.662954, 0.999966, 1.0], + [-5.8223696, 0.9850643, 1.0], + [-9.232703, 0.9267809, 1.0], + [0.0, 1.0, 1.0], + [2.2962167, 0.9605364, 1.0], + [-1.1373878, 1.0126402, 1.0], + [-4.8479958, 0.99816114, 1.0], + [-7.4450336, 0.9406932, 1.0], + [0.0, 1.0, 1.0], + [3.036456, 0.99115914, 1.0], + [0.17963961, 1.0285513, 1.0], + [-3.3088403, 1.0056642, 1.0], + [-6.273956, 0.93629354, 1.0], + [0.0, 1.0, 1.0], + [3.0664618, 1.026951, 1.0], + [1.2165159, 1.0476551, 1.0], + [-0.42700893, 0.9974062, 1.0], + [-1.4580182, 0.8679089, 1.0], + [0.0, 1.0, 1.0], + [2.7013292, 1.0606134, 1.0], + [1.5602528, 1.0500852, 1.0], + [0.58767015, 0.96606606, 1.0], + [-0.12461443, 0.83817023, 1.0], + [0.0, 1.0, 1.0], + [1.75421, 1.0628642, 1.0], + [1.077012, 1.0321119, 1.0], + [0.81344783, 0.9464788, 1.0], + [0.4725721, 0.8391105, 1.0], + [0.0, 1.0, 1.0], + [0.6471407, 1.0532293, 1.0], + [0.40457213, 1.0171094, 1.0], + [0.61659175, 0.9494374, 1.0], + [0.7804061, 0.8600364, 1.0], + [0.0, 1.0, 1.0], + [-0.15095921, 1.0584109, 1.0], + [0.24909112, 0.999217, 1.0], + [0.7718789, 0.9383637, 1.0], + [1.2283664, 0.8916653, 1.0], + [0.0, 1.0, 1.0], + [-0.23281652, 1.0636709, 1.0], + [0.5478948, 0.98645896, 1.0], + [1.1050255, 0.9356548, 1.0], + [1.721518, 0.89987594, 1.0], + [0.0, 1.0, 1.0], + [-0.45712063, 1.0652657, 1.0], + [0.3815263, 0.98859835, 1.0], + [1.0404433, 0.94592875, 1.0], + [1.8172855, 0.90899503, 1.0], + [0.0, 1.0, 1.0], + [-1.1051946, 1.0864792, 1.0], + [-0.06119209, 1.0147051, 1.0], + [0.7248705, 0.9713246, 1.0], + [1.5626872, 0.92918205, 1.0], + [0.0, 1.0, 1.0], + [-2.237749, 1.127092, 1.0], + [-1.070423, 1.0662243, 1.0], + [-0.104194425, 1.0130713, 1.0], + [0.804536, 0.9474472, 1.0], + [0.0, 1.0, 1.0], + [-3.6953864, 1.1598293, 1.0], + [-2.686826, 1.106682, 1.0], + [-1.7835299, 1.0456588, 1.0], + [-0.8053292, 0.9597566, 1.0], + [0.0, 1.0, 1.0], + [-5.2643313, 1.1904461, 1.0], + [-4.4016128, 1.1447512, 1.0], + [-3.6761749, 1.0970792, 1.0], + [-2.1196303, 0.9963984, 1.0], + [0.0, 1.0, 1.0], + [-7.199227, 1.2223662, 1.0], + [-6.308516, 1.1835574, 1.0], + [-5.5572577, 1.15013, 1.0], + [-2.1184824, 1.0233642, 1.0], + [0.0, 1.0, 1.0], + [-9.357493, 1.2634833, 1.0], + [-8.469821, 1.2300229, 1.0], + [-7.221032, 1.1844004, 1.0], + [-0.7896582, 1.0104114, 1.0], + [0.0, 1.0, 1.0], + [-11.875645, 1.3014976, 1.0], + [-10.859666, 1.2672151, 1.0], + [-7.7761583, 1.1516356, 1.0], + [-0.47165853, 0.9992604, 1.0], + [0.0, 1.0, 1.0], + [-14.108668, 1.2699456, 1.0], + [-12.638117, 1.2220073, 1.0], + [-7.2701273, 1.0625613, 1.0], + [-0.6548264, 0.99727935, 1.0], + [0.0, 1.0, 1.0], + [-15.724136, 1.204452, 1.0], + [-13.639737, 1.1411229, 1.0], + [-5.4015455, 1.0294274, 1.0], + [-0.093046986, 0.99997, 1.0], + [0.0, 1.0, 1.0], + [-16.673388, 1.1604823, 1.0], + [-15.056745, 1.0991567, 1.0], + [-6.999325, 1.0093077, 1.0], + [-0.15357149, 0.99979734, 1.0], + [0.0, 1.0, 1.0], + [-17.081741, 1.1213652, 1.0], + [-15.57136, 1.0569661, 1.0], + [-8.844844, 0.9832494, 1.0], + [-0.32678962, 0.9983225, 1.0], + [0.0, 1.0, 1.0], + [-17.33926, 1.0855446, 1.0], + [-15.313985, 1.0192273, 1.0], + [-8.119184, 0.9764423, 1.0], + [-0.23862769, 0.9987188, 1.0], + [0.0, 1.0, 1.0], + [-18.433193, 1.0620961, 1.0], + [-16.90102, 0.9942711, 1.0], + [-9.617896, 0.9711072, 1.0], + [-0.2552796, 0.99904865, 1.0], + [0.0, 1.0, 1.0], + [-17.276731, 1.0457346, 1.0], + [-16.739477, 0.9782363, 1.0], + [-11.156491, 0.96119857, 1.0], + [-0.34167433, 0.9984216, 1.0], + [0.0, 1.0, 1.0], + [-17.084091, 1.0438286, 1.0], + [-15.816032, 0.984013, 1.0], + [-9.323119, 0.9641214, 1.0], + [-0.2387637, 0.9986463, 1.0], + [0.0, 1.0, 1.0], + [-15.596954, 1.0198183, 1.0], + [-13.655594, 0.9686297, 1.0], + [-7.797318, 0.955375, 1.0], + [-0.22518365, 0.9980864, 1.0], + [0.0, 1.0, 1.0], + [-15.301703, 1.0141279, 1.0], + [-12.989882, 0.93955874, 1.0], + [-7.9458184, 0.9391862, 1.0], + [-0.2038498, 0.9980101, 1.0], + [0.0, 1.0, 1.0], + [-16.792349, 1.0134387, 1.0], + [-14.75936, 0.9455252, 1.0], + [-8.760609, 0.9422921, 1.0], + [-0.30783197, 0.9958334, 1.0], + [0.0, 1.0, 1.0], + [-17.005386, 1.0050753, 1.0], + [-15.469527, 0.9674358, 1.0], + [-8.49741, 0.95576, 1.0], + [-0.41260514, 0.9953345, 1.0], + [0.0, 1.0, 1.0], + [-17.273808, 0.9787281, 1.0], + [-15.69969, 0.942322, 1.0], + [-12.234762, 0.87553686, 1.0], + [-2.3212836, 0.9701262, 1.0], + [0.0, 1.0, 1.0], + [-17.132599, 0.9134147, 1.0], + [-15.41619, 0.8991917, 1.0], + [-12.281813, 0.8682994, 1.0], + [-2.1571953, 0.970933, 1.0], + [0.0, 1.0, 1.0], + [-16.799068, 0.85166913, 1.0], + [-14.772821, 0.8631338, 1.0], + [-9.500277, 0.8921043, 1.0], + [-0.7092388, 0.98975027, 1.0], + [0.0, 1.0, 1.0], + [-16.442728, 0.8058451, 1.0], + [-13.375286, 0.8466388, 1.0], + [-8.537738, 0.8891218, 1.0], + [-0.5375653, 0.99202126, 1.0], + [0.0, 1.0, 1.0], + [-15.213923, 0.78132874, 1.0], + [-11.185756, 0.8387474, 1.0], + [-7.113949, 0.8830028, 1.0], + [-0.51550055, 0.98982215, 1.0], + [0.0, 1.0, 1.0], + [-14.168373, 0.7644356, 1.0], + [-10.289349, 0.830659, 1.0], + [-4.6552444, 0.8868689, 1.0], + [-0.362867, 0.9811054, 1.0], + [0.0, 1.0, 1.0], + [-12.229682, 0.75506926, 1.0], + [-8.255229, 0.8390729, 1.0], + [-3.1724656, 0.88942176, 1.0], + [-0.36211884, 0.96008885, 1.0], + [0.0, 1.0, 1.0], + [-8.780003, 0.78203535, 1.0], + [-4.8128314, 0.88308793, 1.0], + [-1.7214035, 0.9332362, 1.0], + [0.07604427, 0.9700093, 1.0], + [0.0, 1.0, 1.0], + [-5.1902246, 0.84770036, 1.0], + [-2.0365455, 0.94785786, 1.0], + [0.075043306, 0.98688334, 1.0], + [1.348575, 0.9891258, 1.0], + [0.0, 1.0, 1.0], + [-3.9187067, 0.8880616, 1.0], + [-0.8643225, 0.98719597, 1.0], + [1.677873, 1.0176314, 1.0], + [3.0216281, 1.0148586, 1.0], + [0.0, 1.0, 1.0], + [-3.5886059, 0.8924353, 1.0], + [0.30670318, 1.0308015, 1.0], + [2.7104156, 1.0678495, 1.0], + [3.735997, 1.0629262, 1.0], + [0.0, 1.0, 1.0], + [-3.084609, 0.91431457, 1.0], + [1.0545353, 1.082462, 1.0], + [2.8006148, 1.1123718, 1.0], + [3.2798169, 1.1044176, 1.0], + [0.0, 1.0, 1.0], + [-3.0868382, 0.88934803, 1.0], + [0.24395227, 1.0737534, 1.0], + [1.8516512, 1.1117501, 1.0], + [1.8256167, 1.0846347, 1.0], + [0.0, 1.0, 1.0], + [-1.303041, 0.91892815, 1.0], + [-0.3339426, 1.0717436, 1.0], + [-0.5027457, 1.1226003, 1.0], + [-0.55369043, 1.0714196, 1.0], + [0.0, 1.0, 1.0], + [0.009014121, 0.99058026, 1.0], + [-0.30335096, 1.1036884, 1.0], + [-1.4451755, 1.1425995, 1.0], + [-1.269934, 1.0913438, 1.0], + [0.0, 1.0, 1.0], + [0.61342883, 0.9911708, 1.0], + [-0.14296348, 1.1055524, 1.0], + [-1.336663, 1.1335871, 1.0], + [-1.1314107, 1.0589578, 1.0], + [0.0, 1.0, 1.0], + [1.7491869, 0.94608873, 1.0], + [1.4192203, 1.0620985, 1.0], + [-0.24427313, 1.079302, 1.0], + [-1.2224454, 1.0283191, 1.0], + [0.0, 1.0, 1.0], + [3.2000449, 0.9136502, 1.0], + [3.1475732, 1.0253253, 1.0], + [2.1547606, 1.0377923, 1.0], + [0.30383298, 1.0079229, 1.0], + [0.0, 1.0, 1.0], + [4.7772436, 0.9144093, 1.0], + [4.162533, 1.047513, 1.0], + [4.2758074, 1.0457861, 1.0], + [3.105151, 0.9908633, 1.0], + [0.0, 1.0, 1.0], + [3.676512, 0.9309974, 1.0], + [2.7957842, 1.0558958, 1.0], + [3.4883666, 1.0355694, 1.0], + [3.137294, 0.9896551, 1.0], + [0.0, 1.0, 1.0], + [3.122498, 0.9589433, 1.0], + [1.543551, 1.0560359, 1.0], + [1.3497539, 1.0383536, 1.0], + [0.11099989, 1.0035237, 1.0], + [0.0, 1.0, 1.0], + [3.580221, 0.9780591, 1.0], + [1.9945487, 1.0655055, 1.0], + [2.4221385, 1.0402309, 1.0], + [0.4563281, 1.0015888, 1.0], + [0.0, 1.0, 1.0], + [4.7698708, 1.0119603, 1.0], + [2.4278777, 1.1139587, 1.0], + [3.0077763, 1.075531, 1.0], + [0.89499116, 1.0035012, 1.0], + [0.0, 1.0, 1.0], + [5.788685, 1.0530056, 1.0], + [2.6037643, 1.1413025, 1.0], + [1.4215078, 1.0968238, 1.0], + [0.20484339, 1.0055182, 1.0], + [0.0, 1.0, 1.0], + [5.1022477, 1.0453085, 1.0], + [2.2648408, 1.1255327, 1.0], + [3.5084808, 1.0749761, 1.0], + [0.8860151, 1.0050836, 1.0], + [0.0, 1.0, 1.0], + [4.840788, 0.99767727, 1.0], + [2.867805, 1.0597396, 1.0], + [5.0557113, 1.041923, 1.0], + [1.229561, 1.0051966, 1.0], + [0.0, 1.0, 1.0], + [7.0307097, 0.97708684, 1.0], + [4.806399, 1.047505, 1.0], + [1.4926684, 1.0392994, 1.0], + [-0.316868, 1.0058483, 1.0], + [0.0, 1.0, 1.0], + [6.4268966, 0.97928965, 1.0], + [4.44971, 1.0286714, 1.0], + [3.9176347, 1.0229295, 1.0], + [0.89289904, 1.0034719, 1.0], + [0.0, 1.0, 1.0], + [5.0285277, 0.9728169, 1.0], + [2.574475, 1.0172137, 1.0], + [3.1116145, 1.0253292, 1.0], + [0.8804246, 1.0046561, 1.0], + [0.0, 1.0, 1.0], + [5.9326806, 0.9673203, 1.0], + [3.0191274, 0.9986364, 1.0], + [2.0088856, 1.0064441, 1.0], + [0.8739533, 1.0035771, 1.0], + [0.0, 1.0, 1.0], + [6.88084, 0.9650284, 1.0], + [3.5343342, 0.9909499, 1.0], + [2.52684, 0.99866265, 1.0], + [1.0010276, 1.0015429, 1.0], + [0.0, 1.0, 1.0], + [6.6674595, 0.9901648, 1.0], + [3.5274854, 1.0219513, 1.0], + [4.0995092, 1.0413644, 1.0], + [2.9521806, 1.0259401, 1.0], + [0.0, 1.0, 1.0], + [7.1322083, 1.0172622, 1.0], + [4.796819, 1.0498525, 1.0], + [4.7010384, 1.0518665, 1.0], + [3.3101153, 1.0278659, 1.0], + [0.0, 1.0, 1.0], + [6.23181, 1.0341, 1.0], + [4.575992, 1.0661216, 1.0], + [4.1811743, 1.0555745, 1.0], + [2.9168804, 1.0209843, 1.0], + [0.0, 1.0, 1.0], + [5.773051, 1.0802647, 1.0], + [3.930077, 1.1002141, 1.0], + [3.9242337, 1.0825543, 1.0], + [3.3296824, 1.0521122, 1.0], + [0.0, 1.0, 1.0], + [2.4754193, 0.95264477, 1.0], + [1.6237425, 0.9976076, 1.0], + [1.6940635, 1.0124187, 1.0], + [1.4032439, 1.0027901, 1.0], + [0.0, 1.0, 1.0], + [1.8328916, 0.9358476, 1.0], + [0.5054257, 0.9516976, 1.0], + [0.82207125, 0.96641874, 1.0], + [0.867466, 0.96557885, 1.0], + [0.0, 1.0, 1.0], + [1.107456, 0.9178314, 1.0], + [-0.11612688, 0.9198909, 1.0], + [-0.13824676, 0.9118656, 1.0], + [-0.0028314982, 0.9240737, 1.0], + [0.0, 1.0, 1.0], + [1.2662044, 0.9126919, 1.0], + [0.20858723, 0.91308266, 1.0], + [-0.74156207, 0.8892692, 1.0], + [-0.96805483, 0.8813321, 1.0], + [0.0, 1.0, 1.0], + [1.5846848, 0.8996693, 1.0], + [0.3940221, 0.9022535, 1.0], + [-1.0160103, 0.8844732, 1.0], + [-1.9547969, 0.869879, 1.0], + [0.0, 1.0, 1.0], + [1.8889635, 0.8861786, 1.0], + [0.46730384, 0.8902896, 1.0], + [-0.8314566, 0.8740593, 1.0], + [-3.0556507, 0.85314214, 1.0], + [0.0, 1.0, 1.0], + [1.786228, 0.87517726, 1.0], + [-0.25766176, 0.8843216, 1.0], + [-2.6286218, 0.87064177, 1.0], + [-6.709825, 0.8475933, 1.0], + [0.0, 1.0, 1.0], + [1.1630952, 0.8485678, 1.0], + [-1.315835, 0.88361984, 1.0], + [-4.564759, 0.8761655, 1.0], + [-7.6052637, 0.84420526, 1.0], + [0.0, 1.0, 1.0], + [0.65808207, 0.8359365, 1.0], + [-1.8502634, 0.871772, 1.0], + [-4.9014773, 0.88665104, 1.0], + [-6.9419703, 0.85948473, 1.0], + [0.0, 1.0, 1.0], + [0.98892176, 0.85553217, 1.0], + [-1.2973236, 0.88800645, 1.0], + [-3.9453318, 0.8810778, 1.0], + [-5.0194716, 0.83465356, 1.0], + [0.0, 1.0, 1.0], + [1.4077076, 0.8883648, 1.0], + [-0.20262001, 0.89855146, 1.0], + [-2.0743167, 0.87382066, 1.0], + [-2.846246, 0.8157335, 1.0], + [0.0, 1.0, 1.0], + [1.6395962, 0.92741174, 1.0], + [0.7942348, 0.92847836, 1.0], + [-0.10936606, 0.878342, 1.0], + [-0.28163967, 0.78697026, 1.0], + [0.0, 1.0, 1.0], + [1.9163837, 0.9670593, 1.0], + [1.1606812, 0.94956505, 1.0], + [0.48494318, 0.86900467, 1.0], + [0.48800853, 0.77839893, 1.0], + [0.0, 1.0, 1.0], + [0.9139721, 0.9643932, 1.0], + [0.70910484, 0.9468291, 1.0], + [0.70515186, 0.87377256, 1.0], + [0.7435633, 0.7895271, 1.0], + [0.0, 1.0, 1.0], + [-0.021806521, 0.9712936, 1.0], + [0.09517821, 0.94683015, 1.0], + [0.48132607, 0.8953816, 1.0], + [0.7116937, 0.82242763, 1.0], + [0.0, 1.0, 1.0], + [-0.79750377, 0.9793866, 1.0], + [-0.08199019, 0.933896, 1.0], + [0.54560524, 0.8938053, 1.0], + [0.91736573, 0.8542098, 1.0], + [0.0, 1.0, 1.0], + [-0.93426365, 0.9845968, 1.0], + [0.15814182, 0.92910755, 1.0], + [0.72163683, 0.89656097, 1.0], + [1.2182864, 0.86374855, 1.0], + [0.0, 1.0, 1.0], + [-1.0705345, 0.98736614, 1.0], + [-0.011547462, 0.9383716, 1.0], + [0.6337991, 0.90952563, 1.0], + [1.2993928, 0.87798977, 1.0], + [0.0, 1.0, 1.0], + [-1.6194162, 1.0018165, 1.0], + [-0.49097732, 0.9606046, 1.0], + [0.2521988, 0.9307922, 1.0], + [0.9645536, 0.8885482, 1.0], + [0.0, 1.0, 1.0], + [-2.80128, 1.0367984, 1.0], + [-1.5576428, 0.9923525, 1.0], + [-0.66330856, 0.9425935, 1.0], + [0.1666689, 0.83448035, 1.0], + [0.0, 1.0, 1.0], + [-4.1835513, 1.0688453, 1.0], + [-3.0496554, 1.0094827, 1.0], + [-2.0194643, 0.9350747, 1.0], + [-0.84885937, 0.81810623, 1.0], + [0.0, 1.0, 1.0], + [-5.472717, 1.096382, 1.0], + [-4.410626, 1.0342624, 1.0], + [-3.3867917, 0.9620032, 1.0], + [-1.1987106, 0.92667925, 1.0], + [0.0, 1.0, 1.0], + [-7.2029138, 1.1229113, 1.0], + [-5.953019, 1.0709114, 1.0], + [-3.932608, 1.0097591, 1.0], + [-0.7465921, 0.9776096, 1.0], + [0.0, 1.0, 1.0], + [-8.683279, 1.1355549, 1.0], + [-7.128312, 1.0855685, 1.0], + [-3.1864233, 1.005719, 1.0], + [-0.26363567, 0.99284154, 1.0], + [0.0, 1.0, 1.0], + [-10.163454, 1.1477863, 1.0], + [-7.8144236, 1.0853112, 1.0], + [-1.8801234, 1.0063028, 1.0], + [-0.08024162, 0.9992318, 1.0], + [0.0, 1.0, 1.0], + [-12.115101, 1.1146804, 1.0], + [-8.96621, 1.0459248, 1.0], + [-1.6161847, 0.9987317, 1.0], + [-0.071704574, 0.99979985, 1.0], + [0.0, 1.0, 1.0], + [-13.706088, 1.0729254, 1.0], + [-8.66254, 1.008506, 1.0], + [-1.0726118, 0.99519026, 1.0], + [-0.013879563, 0.999924, 1.0], + [0.0, 1.0, 1.0], + [-13.639081, 1.0313063, 1.0], + [-8.5867815, 0.9870387, 1.0], + [-1.1318198, 0.9935133, 1.0], + [-0.014718597, 0.99989676, 1.0], + [0.0, 1.0, 1.0], + [-13.590664, 1.0171928, 1.0], + [-7.5184603, 0.98305726, 1.0], + [-0.9713564, 0.9937742, 1.0], + [-0.017022038, 0.9998613, 1.0], + [0.0, 1.0, 1.0], + [-13.854863, 1.0014843, 1.0], + [-7.721969, 0.9806506, 1.0], + [-0.77561605, 0.99587494, 1.0], + [-0.009547515, 0.9999292, 1.0], + [0.0, 1.0, 1.0], + [-14.060616, 0.98336315, 1.0], + [-8.691673, 0.94924486, 1.0], + [-1.2617085, 0.9861989, 1.0], + [-0.017596014, 0.99978256, 1.0], + [0.0, 1.0, 1.0], + [-12.874084, 0.9567539, 1.0], + [-9.545912, 0.9260754, 1.0], + [-2.5305426, 0.97063804, 1.0], + [-0.05151436, 0.99930036, 1.0], + [0.0, 1.0, 1.0], + [-13.672559, 0.9840259, 1.0], + [-8.942783, 0.9562326, 1.0], + [-1.7533314, 0.983495, 1.0], + [-0.031896364, 0.99963516, 1.0], + [0.0, 1.0, 1.0], + [-12.389929, 0.9684237, 1.0], + [-7.423202, 0.9658616, 1.0], + [-1.1646813, 0.9892395, 1.0], + [-0.019982396, 0.9997543, 1.0], + [0.0, 1.0, 1.0], + [-11.921755, 0.9305603, 1.0], + [-8.221304, 0.9161024, 1.0], + [-2.065746, 0.9699598, 1.0], + [-0.05210479, 0.999025, 1.0], + [0.0, 1.0, 1.0], + [-12.236582, 0.92396444, 1.0], + [-8.276435, 0.9221221, 1.0], + [-3.3240492, 0.9396533, 1.0], + [-0.477805, 0.9889453, 1.0], + [0.0, 1.0, 1.0], + [-11.884224, 0.9080763, 1.0], + [-6.9323835, 0.9483917, 1.0], + [-2.8353944, 0.9600339, 1.0], + [-0.33724767, 0.9937283, 1.0], + [0.0, 1.0, 1.0], + [-13.285868, 0.8697298, 1.0], + [-7.293664, 0.9262688, 1.0], + [-2.6733654, 0.9643648, 1.0], + [-0.293086, 0.9961857, 1.0], + [0.0, 1.0, 1.0], + [-14.295206, 0.8048045, 1.0], + [-9.883155, 0.8632042, 1.0], + [-3.227856, 0.95656085, 1.0], + [-0.23363292, 0.9969017, 1.0], + [0.0, 1.0, 1.0], + [-14.586395, 0.75223583, 1.0], + [-11.311147, 0.81547827, 1.0], + [-2.238798, 0.96711767, 1.0], + [-0.07408935, 0.998945, 1.0], + [0.0, 1.0, 1.0], + [-14.474079, 0.7245708, 1.0], + [-11.533241, 0.7828741, 1.0], + [-2.736401, 0.9435411, 1.0], + [-0.044651125, 0.9990903, 1.0], + [0.0, 1.0, 1.0], + [-13.674288, 0.71236974, 1.0], + [-9.81334, 0.7972127, 1.0], + [-3.45957, 0.9197005, 1.0], + [-0.07574147, 0.9975253, 1.0], + [0.0, 1.0, 1.0], + [-12.7195, 0.69227666, 1.0], + [-8.596455, 0.7779914, 1.0], + [-2.3802116, 0.8463873, 1.0], + [-0.35825908, 0.95749724, 1.0], + [0.0, 1.0, 1.0], + [-10.18446, 0.6873084, 1.0], + [-6.0542045, 0.7841001, 1.0], + [-2.1212237, 0.8281544, 1.0], + [-0.7646297, 0.9053229, 1.0], + [0.0, 1.0, 1.0], + [-6.1229663, 0.74606425, 1.0], + [-3.4445412, 0.8351193, 1.0], + [-1.6135985, 0.87599313, 1.0], + [-0.2056776, 0.9368623, 1.0], + [0.0, 1.0, 1.0], + [-3.7523043, 0.8158876, 1.0], + [-1.8315514, 0.894766, 1.0], + [-0.27596512, 0.93012476, 1.0], + [1.0396978, 0.9575369, 1.0], + [0.0, 1.0, 1.0], + [-2.7688127, 0.8445321, 1.0], + [-0.50951934, 0.9420834, 1.0], + [1.4284167, 0.9780485, 1.0], + [2.5512972, 0.9817148, 1.0], + [0.0, 1.0, 1.0], + [-2.1246834, 0.8524671, 1.0], + [0.29334772, 0.96627426, 1.0], + [2.560665, 1.0186502, 1.0], + [3.3895814, 1.0229484, 1.0], + [0.0, 1.0, 1.0], + [-1.3665168, 0.8436846, 1.0], + [1.2660046, 1.012508, 1.0], + [2.7319925, 1.0737249, 1.0], + [3.0757403, 1.0803311, 1.0], + [0.0, 1.0, 1.0], + [-0.5887472, 0.81296784, 1.0], + [0.699834, 0.98561674, 1.0], + [1.4461241, 1.0658891, 1.0], + [1.3923068, 1.0570195, 1.0], + [0.0, 1.0, 1.0], + [1.096663, 0.8274317, 1.0], + [0.40805075, 0.98812795, 1.0], + [-0.045402966, 1.0472325, 1.0], + [-0.06040587, 1.0157863, 1.0], + [0.0, 1.0, 1.0], + [1.9790232, 0.87913215, 1.0], + [0.37345043, 1.0132964, 1.0], + [-0.79287934, 1.0823095, 1.0], + [-0.34175304, 1.0432074, 1.0], + [0.0, 1.0, 1.0], + [2.5627797, 0.87573963, 1.0], + [0.6304921, 1.0118407, 1.0], + [-0.9373828, 1.0851029, 1.0], + [-0.46959534, 1.031097, 1.0], + [0.0, 1.0, 1.0], + [3.7361374, 0.8472772, 1.0], + [1.9630458, 0.98144615, 1.0], + [-0.21733184, 1.0450604, 1.0], + [-0.49014735, 1.0128133, 1.0], + [0.0, 1.0, 1.0], + [4.3971963, 0.8324688, 1.0], + [2.660751, 0.9585303, 1.0], + [0.16532291, 1.0232745, 1.0], + [-0.14636081, 1.0036846, 1.0], + [0.0, 1.0, 1.0], + [5.0258904, 0.8367818, 1.0], + [2.8924615, 0.9749669, 1.0], + [1.7615348, 1.0238429, 1.0], + [0.81233835, 0.9984574, 1.0], + [0.0, 1.0, 1.0], + [5.2452407, 0.85815537, 1.0], + [2.6645274, 0.9761913, 1.0], + [1.4570304, 1.0139804, 1.0], + [1.0984119, 0.997529, 1.0], + [0.0, 1.0, 1.0], + [4.93689, 0.8569606, 1.0], + [3.1328692, 0.9457437, 1.0], + [0.033365987, 1.0082009, 1.0], + [-0.05564574, 1.0016743, 1.0], + [0.0, 1.0, 1.0], + [5.0655975, 0.8770879, 1.0], + [2.174103, 0.9875497, 1.0], + [0.2545323, 1.0086097, 1.0], + [0.022092488, 1.000235, 1.0], + [0.0, 1.0, 1.0], + [4.4488134, 0.9094009, 1.0], + [1.7526729, 1.0061622, 1.0], + [0.42274538, 1.0103294, 1.0], + [0.051791485, 1.0002624, 1.0], + [0.0, 1.0, 1.0], + [5.1548405, 0.95060486, 1.0], + [1.8016412, 1.0232372, 1.0], + [0.20820145, 1.0119417, 1.0], + [0.027733212, 1.0003631, 1.0], + [0.0, 1.0, 1.0], + [5.4379053, 0.94287974, 1.0], + [1.6865833, 1.0055557, 1.0], + [0.5884739, 1.0089288, 1.0], + [0.10115034, 1.0003462, 1.0], + [0.0, 1.0, 1.0], + [3.8398051, 0.90459824, 1.0], + [1.1598605, 0.9777082, 1.0], + [0.77112013, 1.0046481, 1.0], + [0.15015621, 1.0003301, 1.0], + [0.0, 1.0, 1.0], + [5.6665335, 0.91051537, 1.0], + [3.33516, 0.98394734, 1.0], + [0.32077682, 1.0035998, 1.0], + [-0.005901165, 1.00032, 1.0], + [0.0, 1.0, 1.0], + [4.3764243, 0.9138666, 1.0], + [1.7997692, 0.97097856, 1.0], + [0.87327296, 0.9996552, 1.0], + [0.14200021, 1.0003701, 1.0], + [0.0, 1.0, 1.0], + [2.8900363, 0.8881567, 1.0], + [-0.016681926, 0.9412745, 1.0], + [1.2008874, 0.9925353, 1.0], + [0.2819057, 1.0004404, 1.0], + [0.0, 1.0, 1.0], + [3.0338037, 0.8888064, 1.0], + [0.49603212, 0.92353123, 1.0], + [0.344872, 0.9546143, 1.0], + [0.23514952, 0.99573493, 1.0], + [0.0, 1.0, 1.0], + [3.2889633, 0.8941952, 1.0], + [0.12481757, 0.90704453, 1.0], + [-0.89585435, 0.91424674, 1.0], + [-0.24134572, 0.9783999, 1.0], + [0.0, 1.0, 1.0], + [2.7199328, 0.9107902, 1.0], + [0.096942216, 0.9292534, 1.0], + [-0.3109405, 0.9336825, 1.0], + [-0.33917436, 0.96199673, 1.0], + [0.0, 1.0, 1.0], + [3.5014708, 0.9283932, 1.0], + [1.2909425, 0.95882636, 1.0], + [0.26298514, 0.95131576, 1.0], + [-0.69164455, 0.9573067, 1.0], + [0.0, 1.0, 1.0], + [3.5789986, 0.93125904, 1.0], + [2.0657613, 0.9823736, 1.0], + [1.2339572, 0.9823017, 1.0], + [0.035981927, 0.9774156, 1.0], + [0.0, 1.0, 1.0], + [3.2489352, 0.947897, 1.0], + [2.3317747, 1.0011578, 1.0], + [1.9685928, 1.009779, 1.0], + [1.1930741, 1.0035145, 1.0], + [0.0, 1.0, 1.0], + [0.41370514, 0.86071473, 1.0], + [-0.63482434, 0.8487081, 1.0], + [-0.96838796, 0.8932299, 1.0], + [-0.16537139, 0.98455006, 1.0], + [0.0, 1.0, 1.0], + [0.7274317, 0.8435787, 1.0], + [-0.7413707, 0.8246945, 1.0], + [-0.6780908, 0.84962696, 1.0], + [-0.03611998, 0.9278284, 1.0], + [0.0, 1.0, 1.0], + [-0.025755988, 0.7956887, 1.0], + [-0.7080098, 0.7706736, 1.0], + [-0.00667306, 0.80417067, 1.0], + [1.2645549, 0.8267063, 1.0], + [0.0, 1.0, 1.0], + [1.844674, 0.77885765, 1.0], + [1.358191, 0.74993426, 1.0], + [1.8842225, 0.771222, 1.0], + [3.6828177, 0.75069827, 1.0], + [0.0, 1.0, 1.0], + [2.147642, 0.7875774, 1.0], + [1.6485577, 0.7667687, 1.0], + [2.232393, 0.75781333, 1.0], + [3.8045251, 0.73986065, 1.0], + [0.0, 1.0, 1.0], + [2.1638684, 0.78218484, 1.0], + [1.5419188, 0.7789207, 1.0], + [1.5175326, 0.76315284, 1.0], + [2.9210594, 0.7496939, 1.0], + [0.0, 1.0, 1.0], + [1.6404829, 0.76603454, 1.0], + [0.3848709, 0.76939934, 1.0], + [-0.5066958, 0.7645504, 1.0], + [0.97468865, 0.73737776, 1.0], + [0.0, 1.0, 1.0], + [0.65317655, 0.7593511, 1.0], + [-0.66478103, 0.7523295, 1.0], + [0.07674445, 0.7192325, 1.0], + [0.11743816, 0.7180049, 1.0], + [0.0, 1.0, 1.0], + [-0.07553896, 0.75588036, 1.0], + [-0.7599607, 0.7307774, 1.0], + [0.7937273, 0.702063, 1.0], + [1.210313, 0.71042067, 1.0], + [0.0, 1.0, 1.0], + [0.6146166, 0.7519787, 1.0], + [-0.11158234, 0.7340125, 1.0], + [1.8024358, 0.6884776, 1.0], + [2.2258494, 0.6841371, 1.0], + [0.0, 1.0, 1.0], + [0.7284711, 0.81019914, 1.0], + [0.0031727236, 0.7846696, 1.0], + [2.4180965, 0.6961533, 1.0], + [3.2767165, 0.68659955, 1.0], + [0.0, 1.0, 1.0], + [0.7535696, 0.863907, 1.0], + [0.36523274, 0.8374792, 1.0], + [1.2830526, 0.791074, 1.0], + [1.2069559, 0.8067631, 1.0], + [0.0, 1.0, 1.0], + [1.3411092, 0.88778365, 1.0], + [0.7838614, 0.86403745, 1.0], + [1.0240868, 0.81532073, 1.0], + [0.9583133, 0.7903267, 1.0], + [0.0, 1.0, 1.0], + [-0.12238092, 0.85272485, 1.0], + [0.07477853, 0.8615265, 1.0], + [0.6215121, 0.80922437, 1.0], + [0.8200295, 0.7752551, 1.0], + [0.0, 1.0, 1.0], + [-0.82122755, 0.87185943, 1.0], + [-0.2457037, 0.8873393, 1.0], + [0.26640493, 0.85581523, 1.0], + [0.45366037, 0.81068766, 1.0], + [0.0, 1.0, 1.0], + [-1.3295536, 0.9104388, 1.0], + [-0.5824124, 0.87757504, 1.0], + [0.006645972, 0.8408192, 1.0], + [0.28378436, 0.8216209, 1.0], + [0.0, 1.0, 1.0], + [-1.7040519, 0.9045666, 1.0], + [-0.47549373, 0.86758405, 1.0], + [0.053788297, 0.83981687, 1.0], + [0.40476194, 0.81218266, 1.0], + [0.0, 1.0, 1.0], + [-1.5469458, 0.9090261, 1.0], + [-0.5149614, 0.88305897, 1.0], + [0.04461902, 0.8536314, 1.0], + [0.47533098, 0.8176997, 1.0], + [0.0, 1.0, 1.0], + [-1.9928781, 0.9177581, 1.0], + [-1.1592609, 0.8999771, 1.0], + [-0.47069812, 0.8662861, 1.0], + [0.24946758, 0.8074272, 1.0], + [0.0, 1.0, 1.0], + [-2.8105757, 0.9419143, 1.0], + [-1.9706584, 0.9134377, 1.0], + [-1.0593511, 0.8243537, 1.0], + [-0.37524036, 0.77119154, 1.0], + [0.0, 1.0, 1.0], + [-3.1471803, 0.9753915, 1.0], + [-2.3176653, 0.91906554, 1.0], + [-1.3088802, 0.86219937, 1.0], + [-0.58739233, 0.86786795, 1.0], + [0.0, 1.0, 1.0], + [-2.94802, 0.97012186, 1.0], + [-2.1835663, 0.95009077, 1.0], + [-1.0128062, 0.9544606, 1.0], + [-0.15606245, 0.98380494, 1.0], + [0.0, 1.0, 1.0], + [-3.345666, 0.98280185, 1.0], + [-1.4991432, 0.95262855, 1.0], + [-0.4551724, 0.9883049, 1.0], + [-0.03270482, 0.9987078, 1.0], + [0.0, 1.0, 1.0], + [-3.8461266, 0.9927924, 1.0], + [-1.276193, 0.98380685, 1.0], + [-0.1276723, 0.99596465, 1.0], + [-0.006702845, 0.9997529, 1.0], + [0.0, 1.0, 1.0], + [-2.713865, 0.98152876, 1.0], + [-0.75546634, 0.9879916, 1.0], + [-0.049746733, 0.9991844, 1.0], + [-0.0007843447, 0.9999856, 1.0], + [0.0, 1.0, 1.0], + [-3.2784758, 0.98056763, 1.0], + [-1.0423217, 0.98997813, 1.0], + [-0.050068997, 0.9994019, 1.0], + [-0.00034191704, 0.9999971, 1.0], + [0.0, 1.0, 1.0], + [-2.9297116, 0.96755266, 1.0], + [-0.5981317, 0.9923545, 1.0], + [-0.017654736, 0.999798, 1.0], + [-9.46762e-05, 0.9999992, 1.0], + [0.0, 1.0, 1.0], + [-3.1329167, 0.9680987, 1.0], + [-0.42209783, 0.9959619, 1.0], + [-0.012668612, 0.9998711, 1.0], + [-8.0989346e-05, 0.9999992, 1.0], + [0.0, 1.0, 1.0], + [-3.681753, 0.96301955, 1.0], + [-0.42579386, 0.9962166, 1.0], + [-0.00814088, 0.9999096, 1.0], + [-4.4159344e-05, 0.9999995, 1.0], + [0.0, 1.0, 1.0], + [-3.2585168, 0.9649381, 1.0], + [-0.30428812, 0.9962269, 1.0], + [-0.005310869, 0.999927, 1.0], + [-2.5505675e-05, 0.99999964, 1.0], + [0.0, 1.0, 1.0], + [-2.2021565, 0.9749354, 1.0], + [-0.3944129, 0.9904369, 1.0], + [-0.025527034, 0.9992587, 1.0], + [-0.00025198492, 0.9999939, 1.0], + [0.0, 1.0, 1.0], + [-1.929304, 0.971067, 1.0], + [-0.45261085, 0.99142736, 1.0], + [-0.05011053, 0.9990594, 1.0], + [-0.0008144145, 0.99998575, 1.0], + [0.0, 1.0, 1.0], + [-2.7514565, 0.9707775, 1.0], + [-0.364937, 0.9964542, 1.0], + [-0.020018931, 0.9997449, 1.0], + [-0.00028382998, 0.9999959, 1.0], + [0.0, 1.0, 1.0], + [-2.7956183, 0.9632996, 1.0], + [-0.42287976, 0.9969505, 1.0], + [-0.013087578, 0.9999144, 1.0], + [-8.082379e-05, 0.99999905, 1.0], + [0.0, 1.0, 1.0], + [-2.4113169, 0.9547617, 1.0], + [-0.33804947, 0.9945204, 1.0], + [-0.026301317, 0.9995303, 1.0], + [-0.0012762488, 0.99997115, 1.0], + [0.0, 1.0, 1.0], + [-2.6816075, 0.95678437, 1.0], + [-0.44680336, 0.99332184, 1.0], + [-0.13794503, 0.99693584, 1.0], + [-0.024087088, 0.9994248, 1.0], + [0.0, 1.0, 1.0], + [-3.1641667, 0.9557707, 1.0], + [-0.37171954, 0.99482054, 1.0], + [-0.076554835, 0.99849296, 1.0], + [-0.012065996, 0.9997304, 1.0], + [0.0, 1.0, 1.0], + [-5.1045394, 0.91970074, 1.0], + [-0.6387288, 0.9893497, 1.0], + [-0.016694926, 0.9997221, 1.0], + [-0.0008751128, 0.99998766, 1.0], + [0.0, 1.0, 1.0], + [-6.9304733, 0.85944414, 1.0], + [-1.2397609, 0.9733297, 1.0], + [-0.039723754, 0.99928564, 1.0], + [-0.0009279736, 0.9999874, 1.0], + [0.0, 1.0, 1.0], + [-8.17317, 0.7962485, 1.0], + [-2.5968554, 0.9279736, 1.0], + [-0.06445126, 0.9980414, 1.0], + [-0.00037242688, 0.99999243, 1.0], + [0.0, 1.0, 1.0], + [-10.704245, 0.7364805, 1.0], + [-5.6246877, 0.8459256, 1.0], + [-0.26169717, 0.99058115, 1.0], + [-0.0014548219, 0.9999466, 1.0], + [0.0, 1.0, 1.0], + [-10.675443, 0.68334174, 1.0], + [-4.872382, 0.8478102, 1.0], + [-0.4881562, 0.98677355, 1.0], + [-0.009578875, 0.99971837, 1.0], + [0.0, 1.0, 1.0], + [-10.985089, 0.61614233, 1.0], + [-6.222056, 0.75582373, 1.0], + [-1.2719929, 0.92699444, 1.0], + [-0.07923371, 0.99300826, 1.0], + [0.0, 1.0, 1.0], + [-9.835739, 0.6035176, 1.0], + [-6.378802, 0.7114187, 1.0], + [-2.099836, 0.85796636, 1.0], + [-0.28848997, 0.9751244, 1.0], + [0.0, 1.0, 1.0], + [-6.6503634, 0.67487407, 1.0], + [-5.500912, 0.7487064, 1.0], + [-4.2971406, 0.8258655, 1.0], + [-0.6571109, 0.96886605, 1.0], + [0.0, 1.0, 1.0], + [-4.7830634, 0.73914504, 1.0], + [-4.141989, 0.79455334, 1.0], + [-5.006245, 0.8116664, 1.0], + [-1.3780211, 0.94300467, 1.0], + [0.0, 1.0, 1.0], + [-3.9277482, 0.75783825, 1.0], + [-2.659795, 0.83125865, 1.0], + [-2.5174627, 0.85422933, 1.0], + [-1.3893734, 0.9027223, 1.0], + [0.0, 1.0, 1.0], + [-2.7880778, 0.79374677, 1.0], + [-1.5678242, 0.8652207, 1.0], + [0.2077058, 0.92604816, 1.0], + [0.5667539, 0.94809216, 1.0], + [0.0, 1.0, 1.0], + [-1.7917917, 0.7941517, 1.0], + [-0.65741813, 0.895106, 1.0], + [0.75534123, 0.9829742, 1.0], + [0.4768409, 0.98621637, 1.0], + [0.0, 1.0, 1.0], + [-1.1052668, 0.73317593, 1.0], + [-0.6049394, 0.8575412, 1.0], + [-0.15478949, 0.99293196, 1.0], + [-0.25519162, 0.99671155, 1.0], + [0.0, 1.0, 1.0], + [1.3859553, 0.7583256, 1.0], + [0.3529042, 0.9013461, 1.0], + [-0.09280269, 0.99014723, 1.0], + [-0.19439623, 0.9756913, 1.0], + [0.0, 1.0, 1.0], + [2.662148, 0.78581506, 1.0], + [1.3001701, 0.8985178, 1.0], + [0.46722722, 0.9815502, 1.0], + [0.23440441, 0.98131305, 1.0], + [0.0, 1.0, 1.0], + [2.7134244, 0.7862562, 1.0], + [1.8330814, 0.8773475, 1.0], + [0.75808954, 0.9856246, 1.0], + [0.15871423, 0.9977693, 1.0], + [0.0, 1.0, 1.0], + [3.707434, 0.78545547, 1.0], + [2.0999856, 0.88736033, 1.0], + [0.21350783, 0.9968301, 1.0], + [0.0005532017, 1.0001154, 1.0], + [0.0, 1.0, 1.0], + [4.2265773, 0.7976756, 1.0], + [2.41513, 0.9033128, 1.0], + [0.098965846, 0.99737674, 1.0], + [-0.0019273323, 1.0000342, 1.0], + [0.0, 1.0, 1.0], + [3.586104, 0.8242546, 1.0], + [1.1944336, 0.94521993, 1.0], + [0.018398829, 0.999946, 1.0], + [0.0061938893, 1.00001, 1.0], + [0.0, 1.0, 1.0], + [3.3312273, 0.85412824, 1.0], + [0.8018384, 0.96504784, 1.0], + [-0.038536284, 1.0002738, 1.0], + [0.0041213674, 1.0000454, 1.0], + [0.0, 1.0, 1.0], + [3.5602465, 0.86023563, 1.0], + [0.8536984, 0.97106135, 1.0], + [-0.06325424, 1.0002065, 1.0], + [-0.009787895, 1.0001173, 1.0], + [0.0, 1.0, 1.0], + [3.319309, 0.88970965, 1.0], + [0.4574435, 0.98576313, 1.0], + [0.0016855623, 0.9999831, 1.0], + [-0.000406475, 1.0000062, 1.0], + [0.0, 1.0, 1.0], + [2.0391257, 0.92719066, 1.0], + [-0.11533654, 0.9924606, 1.0], + [-0.022894483, 1.0000801, 1.0], + [-3.8161572e-05, 1.0000013, 1.0], + [0.0, 1.0, 1.0], + [2.221341, 0.9522132, 1.0], + [0.16617404, 0.99605083, 1.0], + [-0.0031928865, 0.999991, 1.0], + [1.2969534e-05, 1.0000004, 1.0], + [0.0, 1.0, 1.0], + [2.2095737, 0.94623184, 1.0], + [0.2084119, 0.9924171, 1.0], + [-0.0017147859, 0.999864, 1.0], + [0.00015271064, 1.0000001, 1.0], + [0.0, 1.0, 1.0], + [1.7305284, 0.92747825, 1.0], + [0.26084626, 0.99035066, 1.0], + [0.002197185, 0.9998796, 1.0], + [0.0002519184, 1.0, 1.0], + [0.0, 1.0, 1.0], + [1.83777, 0.9303522, 1.0], + [0.49865678, 0.99259496, 1.0], + [0.010380797, 0.99993306, 1.0], + [1.543077e-05, 1.0000001, 1.0], + [0.0, 1.0, 1.0], + [0.6540057, 0.93718725, 1.0], + [-0.056169994, 0.98687667, 1.0], + [-0.009431662, 0.9991736, 1.0], + [0.00055399764, 0.9999875, 1.0], + [0.0, 1.0, 1.0], + [1.219968, 0.9170335, 1.0], + [-1.04431, 0.9552193, 1.0], + [-0.7334884, 0.9844318, 1.0], + [-0.067632034, 0.9988193, 1.0], + [0.0, 1.0, 1.0], + [1.3329756, 0.9103736, 1.0], + [-4.453365, 0.87807673, 1.0], + [-7.3110423, 0.86146367, 1.0], + [-1.0087034, 0.9803414, 1.0], + [0.0, 1.0, 1.0], + [0.96455824, 0.8981043, 1.0], + [-5.200631, 0.8460088, 1.0], + [-11.297041, 0.7854038, 1.0], + [-2.6635964, 0.9481161, 1.0], + [0.0, 1.0, 1.0], + [0.67527205, 0.9010675, 1.0], + [-6.0442433, 0.8281746, 1.0], + [-12.542688, 0.7473031, 1.0], + [-5.324665, 0.8921913, 1.0], + [0.0, 1.0, 1.0], + [1.2026789, 0.89397043, 1.0], + [-4.445598, 0.84913737, 1.0], + [-11.919539, 0.74464583, 1.0], + [-6.795066, 0.85686606, 1.0], + [0.0, 1.0, 1.0], + [0.6662235, 0.8867006, 1.0], + [-3.937991, 0.83842826, 1.0], + [-13.422844, 0.71948135, 1.0], + [-8.835538, 0.8229743, 1.0], + [0.0, 1.0, 1.0], + [0.7172698, 0.8660123, 1.0], + [-0.94286054, 0.8551379, 1.0], + [-5.286918, 0.83392274, 1.0], + [-2.4993267, 0.9417325, 1.0], + [0.0, 1.0, 1.0], + [0.0017951994, 0.9914302, 1.0], + [-0.06927332, 0.9921463, 1.0], + [-0.057490297, 0.9959994, 1.0], + [-0.008928111, 0.99963653, 1.0], + [0.0, 1.0, 1.0], + [-0.041254885, 0.98150927, 1.0], + [-0.1306019, 0.97009796, 1.0], + [-0.048590906, 0.9860053, 1.0], + [0.0030063766, 0.99852145, 1.0], + [0.0, 1.0, 1.0], + [-0.112703465, 0.95625734, 1.0], + [-0.2643623, 0.9157722, 1.0], + [0.17177379, 0.9519122, 1.0], + [0.89937246, 0.95642823, 1.0], + [0.0, 1.0, 1.0], + [0.29111728, 0.94092214, 1.0], + [0.3868302, 0.90032035, 1.0], + [3.1862643, 0.84815085, 1.0], + [6.1715746, 0.7603379, 1.0], + [0.0, 1.0, 1.0], + [0.538952, 0.9446369, 1.0], + [0.83218, 0.90219474, 1.0], + [3.4132833, 0.844821, 1.0], + [6.4263897, 0.7552901, 1.0], + [0.0, 1.0, 1.0], + [0.90486366, 0.9366256, 1.0], + [1.3550385, 0.9041044, 1.0], + [4.7555075, 0.83666027, 1.0], + [9.834919, 0.7355414, 1.0], + [0.0, 1.0, 1.0], + [0.749637, 0.92996854, 1.0], + [1.2060188, 0.9036133, 1.0], + [10.243389, 0.71855605, 1.0], + [15.236945, 0.6319401, 1.0], + [0.0, 1.0, 1.0], + [0.38263044, 0.9178865, 1.0], + [1.6851017, 0.8404756, 1.0], + [6.062546, 0.71273714, 1.0], + [7.5150943, 0.7055973, 1.0], + [0.0, 1.0, 1.0], + [0.2073275, 0.8944352, 1.0], + [1.694259, 0.7952898, 1.0], + [4.963602, 0.71330947, 1.0], + [4.4856033, 0.76623136, 1.0], + [0.0, 1.0, 1.0], + [0.42934892, 0.91428727, 1.0], + [1.7023742, 0.81033534, 1.0], + [4.653338, 0.67880553, 1.0], + [4.1607633, 0.7217924, 1.0], + [0.0, 1.0, 1.0], + [0.7662586, 0.92551315, 1.0], + [1.281609, 0.8594126, 1.0], + [4.2112913, 0.7075525, 1.0], + [3.2996652, 0.7721644, 1.0], + [0.0, 1.0, 1.0], + [0.36315376, 0.96427274, 1.0], + [0.17142291, 0.9671226, 1.0], + [0.3704626, 0.96229166, 1.0], + [0.16843563, 0.98521745, 1.0], + [0.0, 1.0, 1.0], + [0.14203277, 0.980844, 1.0], + [0.08888949, 0.98635364, 1.0], + [0.08142525, 0.99080217, 1.0], + [0.04689812, 0.99329925, 1.0], + [0.0, 1.0, 1.0], + [-0.041995242, 0.97444767, 1.0], + [-0.015486327, 0.9832361, 1.0], + [0.025345497, 0.9886648, 1.0], + [0.061056096, 0.9832061, 1.0], + [0.0, 1.0, 1.0], + [-0.06349996, 0.9847081, 1.0], + [-0.010158759, 0.993606, 1.0], + [0.0070079323, 0.9965746, 1.0], + [0.012292187, 0.9953988, 1.0], + [0.0, 1.0, 1.0], + [-0.02714656, 0.99495846, 1.0], + [-0.0047707465, 0.995372, 1.0], + [-0.011022978, 0.9921308, 1.0], + [-0.0004924384, 0.99648327, 1.0], + [0.0, 1.0, 1.0], + [-0.065880194, 0.99450415, 1.0], + [-0.041692242, 0.99075663, 1.0], + [-0.022810183, 0.9813757, 1.0], + [0.0066275946, 0.9910071, 1.0], + [0.0, 1.0, 1.0], + [-0.051800754, 0.9933403, 1.0], + [-0.031969808, 0.9909193, 1.0], + [-0.0138383275, 0.98399764, 1.0], + [0.0023257341, 0.99193186, 1.0], + [0.0, 1.0, 1.0], + [-0.064492755, 0.99565995, 1.0], + [-0.056850635, 0.993179, 1.0], + [-0.03568528, 0.9925555, 1.0], + [-0.006500367, 0.9941837, 1.0], + [0.0, 1.0, 1.0], + [-0.076604635, 0.9975212, 1.0], + [-0.04835303, 0.99697053, 1.0], + [-0.05276575, 0.9912819, 1.0], + [-0.048627503, 0.98763776, 1.0], + [0.0, 1.0, 1.0], + [-0.07185075, 0.99944353, 1.0], + [-0.049465895, 0.99844307, 1.0], + [-0.03390795, 0.9961937, 1.0], + [-0.01572451, 0.99718726, 1.0], + [0.0, 1.0, 1.0], + [-0.06696204, 0.99883705, 1.0], + [-0.059485506, 0.99909484, 1.0], + [-0.020033833, 0.99961084, 1.0], + [-0.001215999, 0.9999084, 1.0], + [0.0, 1.0, 1.0], + [-0.06329192, 0.9984661, 1.0], + [-0.030843416, 0.99811184, 1.0], + [-0.010421576, 0.99985075, 1.0], + [-0.000277232, 0.99999887, 1.0], + [0.0, 1.0, 1.0], + [-0.09967728, 0.9996282, 1.0], + [-0.024422867, 0.9996509, 1.0], + [-0.0013948235, 0.9999416, 1.0], + [-1.7647946e-05, 0.99999917, 1.0], + [0.0, 1.0, 1.0], + [-0.061887965, 0.9996889, 1.0], + [-0.0055153067, 0.9999299, 1.0], + [-0.00014356381, 0.9999968, 1.0], + [-7.3728444e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.06313205, 0.9991679, 1.0], + [-0.0105344625, 0.99976623, 1.0], + [-0.00022694842, 0.99999404, 1.0], + [-6.2425016e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.07309622, 0.998452, 1.0], + [-0.010137941, 0.99966383, 1.0], + [-0.00015650428, 0.999994, 1.0], + [-2.0530616e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.12139271, 0.99886787, 1.0], + [-0.006217619, 0.9999299, 1.0], + [-2.767876e-05, 0.9999996, 1.0], + [-2.8060315e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.11297469, 0.9986093, 1.0], + [-0.005666369, 0.99993217, 1.0], + [-2.3441991e-05, 0.99999976, 1.0], + [-1.6374731e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.11892667, 0.9984859, 1.0], + [-0.0054892004, 0.9999281, 1.0], + [-1.8891958e-05, 0.9999997, 1.0], + [-1.42802365e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.063609995, 0.9991952, 1.0], + [-0.003612312, 0.9999425, 1.0], + [-5.6865858e-05, 0.99999845, 1.0], + [-2.8125933e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.03687637, 0.99945694, 1.0], + [-0.0022629017, 0.99996173, 1.0], + [-4.7338475e-05, 0.9999989, 1.0], + [-3.8873702e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.066277154, 0.9989287, 1.0], + [-0.0041808677, 0.9999447, 1.0], + [-2.6149986e-05, 0.9999997, 1.0], + [-7.8076916e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.18022063, 0.99584043, 1.0], + [-0.009360226, 0.99979895, 1.0], + [-4.573966e-05, 0.99999934, 1.0], + [-4.968432e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.057497833, 0.9985912, 1.0], + [-0.0030033966, 0.99992764, 1.0], + [-2.1283115e-05, 0.9999996, 1.0], + [-7.399287e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.02698668, 0.99940234, 1.0], + [-0.0018560152, 0.9999619, 1.0], + [-0.00011015628, 0.9999977, 1.0], + [-1.6312291e-05, 0.9999996, 1.0], + [0.0, 1.0, 1.0], + [-0.020353582, 0.99962854, 1.0], + [-0.0012829889, 0.99997663, 1.0], + [-4.823795e-05, 0.999999, 1.0], + [-7.425042e-06, 0.9999998, 1.0], + [0.0, 1.0, 1.0], + [-0.046696484, 0.9990762, 1.0], + [-0.0027994127, 0.999945, 1.0], + [-1.47203e-05, 0.9999997, 1.0], + [-1.4775334e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.11455002, 0.9970088, 1.0], + [-0.008082145, 0.9997811, 1.0], + [-5.997455e-05, 0.9999983, 1.0], + [-2.153083e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.20616151, 0.9932033, 1.0], + [-0.026683627, 0.9989487, 1.0], + [-0.0006210741, 0.9999699, 1.0], + [-1.9223974e-06, 0.9999999, 1.0], + [0.0, 1.0, 1.0], + [-0.42821977, 0.98413795, 1.0], + [-0.09130513, 0.9955624, 1.0], + [-0.0039294222, 0.99976504, 1.0], + [-1.938189e-05, 0.9999988, 1.0], + [0.0, 1.0, 1.0], + [-0.737228, 0.9656451, 1.0], + [-0.11107813, 0.99402, 1.0], + [-0.005023841, 0.9997979, 1.0], + [-7.540838e-05, 0.9999977, 1.0], + [0.0, 1.0, 1.0], + [-2.0423653, 0.89530706, 1.0], + [-0.39499432, 0.9775829, 1.0], + [-0.021451676, 0.9990429, 1.0], + [-0.00062327104, 0.9999673, 1.0], + [0.0, 1.0, 1.0], + [-4.7882614, 0.7621558, 1.0], + [-1.4943185, 0.919121, 1.0], + [-0.26310703, 0.9902079, 1.0], + [-0.026021129, 0.9991246, 1.0], + [0.0, 1.0, 1.0], + [-5.967163, 0.70525557, 1.0], + [-6.2708335, 0.7825963, 1.0], + [-3.0123074, 0.9135249, 1.0], + [-0.16591884, 0.99503696, 1.0], + [0.0, 1.0, 1.0], + [-6.747682, 0.693125, 1.0], + [-11.015906, 0.6835952, 1.0], + [-10.588246, 0.7426725, 1.0], + [-1.4011235, 0.96407425, 1.0], + [0.0, 1.0, 1.0], + [-6.8046284, 0.6869158, 1.0], + [-12.226663, 0.67317563, 1.0], + [-14.401511, 0.68768495, 1.0], + [-5.656612, 0.8701306, 1.0], + [0.0, 1.0, 1.0], + [-5.2980156, 0.7554591, 1.0], + [-8.451796, 0.7457393, 1.0], + [-10.891963, 0.76741225, 1.0], + [-4.9737005, 0.8988167, 1.0], + [0.0, 1.0, 1.0], + [-3.7862368, 0.7663066, 1.0], + [-6.968383, 0.74598277, 1.0], + [-7.1698184, 0.82501894, 1.0], + [-3.5572019, 0.9294954, 1.0], + [0.0, 1.0, 1.0], + [-2.0710254, 0.80158305, 1.0], + [-2.644557, 0.82318443, 1.0], + [-2.829198, 0.9057661, 1.0], + [-3.1149986, 0.90927655, 1.0], + [0.0, 1.0, 1.0], + [0.5783251, 0.8887635, 1.0], + [0.14086528, 0.92088306, 1.0], + [-1.3454764, 0.91390127, 1.0], + [-2.1663988, 0.89477795, 1.0], + [0.0, 1.0, 1.0], + [0.90967906, 0.9097722, 1.0], + [0.40966874, 0.96211725, 1.0], + [0.012563121, 0.9758351, 1.0], + [-0.12836997, 0.9668173, 1.0], + [0.0, 1.0, 1.0], + [0.7635332, 0.9177408, 1.0], + [0.26205018, 0.9728322, 1.0], + [0.017903045, 0.99900806, 1.0], + [0.0027794365, 0.9998005, 1.0], + [0.0, 1.0, 1.0], + [0.70256805, 0.9528801, 1.0], + [0.12026947, 0.989671, 1.0], + [0.0014969448, 0.9997942, 1.0], + [4.7565347e-05, 0.99999905, 1.0], + [0.0, 1.0, 1.0], + [0.43385658, 0.97707945, 1.0], + [0.08405786, 0.99513173, 1.0], + [0.001577933, 0.99989617, 1.0], + [2.1989558e-06, 0.99999976, 1.0], + [0.0, 1.0, 1.0], + [0.17016695, 0.990982, 1.0], + [0.020933557, 0.99893415, 1.0], + [0.00020579397, 0.99998814, 1.0], + [1.8093911e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.118497156, 0.9939799, 1.0], + [0.009560065, 0.99949676, 1.0], + [-6.1264276e-05, 0.99999744, 1.0], + [-7.5626076e-06, 1.0000001, 1.0], + [0.0, 1.0, 1.0], + [0.18632784, 0.9930626, 1.0], + [0.016471203, 0.9994041, 1.0], + [1.850781e-05, 0.99999624, 1.0], + [-1.3992527e-05, 1.0000001, 1.0], + [0.0, 1.0, 1.0], + [0.12490073, 0.99491405, 1.0], + [0.009683974, 0.9996138, 1.0], + [5.5344124e-05, 0.9999977, 1.0], + [-6.122279e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.046315443, 0.9974161, 1.0], + [0.0011095641, 0.99975616, 1.0], + [-9.514568e-05, 0.9999985, 1.0], + [-4.2969023e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.06989265, 0.99835086, 1.0], + [0.0046474864, 0.9999081, 1.0], + [8.086032e-06, 0.99999964, 1.0], + [-6.4283086e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.05445273, 0.9984405, 1.0], + [0.0056540556, 0.9999072, 1.0], + [3.1231557e-05, 0.99999934, 1.0], + [-1.4891012e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.049770087, 0.9989236, 1.0], + [0.0066248314, 0.9999419, 1.0], + [5.813844e-05, 0.99999964, 1.0], + [3.4580864e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.011926558, 0.9986787, 1.0], + [0.0030024045, 0.99992526, 1.0], + [5.3523254e-05, 0.9999996, 1.0], + [7.105971e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.0051469407, 0.99917525, 1.0], + [-0.0015877569, 0.99988997, 1.0], + [-0.0019601185, 0.99996835, 1.0], + [-0.00019134225, 0.9999972, 1.0], + [0.0, 1.0, 1.0], + [-0.0026346056, 0.9979655, 1.0], + [-0.6399264, 0.9890465, 1.0], + [-1.0591722, 0.9829664, 1.0], + [-0.1191338, 0.9980818, 1.0], + [0.0, 1.0, 1.0], + [-0.33427265, 0.9909866, 1.0], + [-6.1553082, 0.8892826, 1.0], + [-8.770668, 0.85096085, 1.0], + [-1.2606072, 0.9774506, 1.0], + [0.0, 1.0, 1.0], + [-0.47517955, 0.9840748, 1.0], + [-8.44668, 0.82929236, 1.0], + [-13.877107, 0.7396451, 1.0], + [-3.6484365, 0.9244635, 1.0], + [0.0, 1.0, 1.0], + [-0.47713858, 0.9821221, 1.0], + [-11.744733, 0.7621532, 1.0], + [-19.645943, 0.6432617, 1.0], + [-8.809313, 0.82319057, 1.0], + [0.0, 1.0, 1.0], + [-0.22001219, 0.98877585, 1.0], + [-9.559413, 0.79853284, 1.0], + [-19.126335, 0.6495684, 1.0], + [-10.234527, 0.7984638, 1.0], + [0.0, 1.0, 1.0], + [-0.15887243, 0.9931774, 1.0], + [-6.930633, 0.85245496, 1.0], + [-16.76443, 0.69196665, 1.0], + [-9.0214, 0.82428676, 1.0], + [0.0, 1.0, 1.0], + [-0.016224792, 0.9942058, 1.0], + [-0.8251402, 0.9754959, 1.0], + [-3.2789445, 0.9271614, 1.0], + [-1.1487821, 0.97528994, 1.0] + ], + "srgb_value": true + } +} diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index a958b9666..260068d1c 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -31,7 +31,8 @@ use lightcraft_raw::{ /// 4: a tone curve from black-and-white camera JPEGs (#577). /// 5: dark JPEG luminance pairs for the camera toe (#1307). /// 6: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected. -pub const LOOK_VERSION: u32 = 6; +/// 7: the bundled NIKON Z 6_2 profile. +pub const LOOK_VERSION: u32 = 7; #[derive(Clone, Debug)] pub(crate) struct CameraLook { @@ -2500,6 +2501,39 @@ mod tests { assert_eq!(look.hue_sat, bundled.hue_sat, "the look's table is not the profile's"); } + /// The public Z 6II NEF (skipped without the corpus) takes its colour from the bundled Z 6II profile. It wasn't + /// one of the photos the profile was fitted to. + #[test] + fn corpus_z6ii_uses_the_bundled_profile() { + let path = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw/nef-nikon-z6ii-lossless.nef"); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + eprintln!("skip: {} absent", path.display()); + return; + } + Err(e) => panic!("{}: {e}", path.display()), + }; + let (_, json) = crate::camera_profiles::BUNDLED.iter().find(|(m, _)| *m == "NIKON Z 6_2").expect("the Z 6II profile is built in"); + let bundled: crate::camera_profiles::CameraProfile = serde_json::from_str(json).unwrap(); + let used = crate::camera_profiles::get("NIKON Z 6_2").expect("Z 6II profile"); + assert_eq!(*used, bundled, "a local NIKON_Z_6_2 profile overrides the built-in one; remove it to run this test"); + let raw = lightcraft_raw::decode(&bytes).unwrap(); + assert_eq!(raw.metadata.model.as_deref(), Some("NIKON Z 6_2")); + let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let look = fit_preview(&raw, &bytes, &transform, None).0.expect("fit accepted"); + let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap()); + for (row, want) in look.matrix.0.iter().zip(expected.0) { + for (got, want) in row.iter().zip(want) { + assert!((got - want).abs() < 1e-12, "the look's matrix is not the profile's: {:?} vs {:?}", look.matrix.0, expected.0); + } + } + assert_eq!(look.hue_sat, bundled.hue_sat, "the look's table is not the profile's"); + } + /// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white /// balance is relative to the as-shot look. #[test] diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs index 49dfad408..41fc9ca2f 100644 --- a/crates/engine/src/camera_profiles.rs +++ b/crates/engine/src/camera_profiles.rs @@ -27,6 +27,7 @@ const MAX_FILE: u64 = 4 << 20; pub const BUNDLED: &[(&str, &str)] = &[ ("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")), ("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")), + ("NIKON Z 6_2", include_str!("../../../assets/camera-profiles/NIKON_Z_6_2.json")), ("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")), ("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")), ]; diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index a12cfc326..5ca637774 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -113,7 +113,7 @@ A pooled profile was tried and is not bundled. Pooling the 46 photos (158,549 co One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs, NEFs or RAFs per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos. -Profiles are JSON files `.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos (below); X-H2S, 201 photos; X-T4, 545 photos. They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt. +Profiles are JSON files `.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos (below); X-H2S, 201 photos; X-T4, 545 photos; NIKON Z 6_2, 296 photos (below). They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt. ### Sony ILCE-7CR @@ -124,6 +124,14 @@ Without a profile, 123 of the 131 photos accept their own fit; the other 8 open The nine public raw.pixls.us ILCE-7CR files are one scene in every coding the camera writes (uncompressed, lossless compressed L, M and S, compressed, full frame and APS-C crop). They were not used for fitting. All nine accept their own fit at ΔE 1.3–2.1 and stay accepted with the profile, at ΔE 1.5–2.9 (mean 1.59 → 1.99). On this colourful daylight scene the per-file fit is already close. The subsampled M and S codings, which decode with unity white-balance gains, move most (2.0 → 2.8). This is a camera-JPEG look estimate like the others, not measured calibration or Lightroom parity. +### Nikon Z 6II + +Fitted from 296 usable photos of 299 Z 6II NEFs (one photographer, 2024 to 2026, Standard and Auto picture controls, lossless 14-bit, with the NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and the AF-S 70-200mm f/2.8G on the FTZ), 1,099,207 colour pairs. The pairs were collected with Nikon's in-file distortion correction read and the sensor proxy following it (see "Lens-corrected camera JPEGs" above), since the camera corrects every JPEG from these lenses. Measured as above: rendered at 1200 px with `lightcraft-cli render`, mean CIE76 ΔE against each file's embedded JPEG at 200 px. + +For validation every fifth photo in sorted name order (59) was held back and a profile fitted to the other 240 (237 usable). On the held-back photos mean ΔE went from 2.73 with per-file fitting to 2.54 with the profile (median 2.49 to 2.21), 43 closer and 8 farther, the largest by 0.64. With the final profile across all 299 photos it goes from 2.68 to 2.53, but that's in-sample. The few that get noticeably worse are ISO 25600 night shots (up to +2.5), where the camera JPEG's noise reduction changes the colour a lot and a per-photo fit does better. + +The public raw.pixls.us Z 6II file (`nef-nikon-z6ii-lossless.nef`, Vivid picture control with the Z 24-200, not used for fitting) is accepted with the profile and goes from 5.37 to 5.25. + ## Sony Dynamic Range Optimizer (DRO) Sony bodies apply DRO, a local tone operator that brightens darker regions, to their JPEG (and the preview embedded in the ARW) only; the raw data are unchanged, and the factory default is DRO Auto. The tone curve fitted to that JPEG carried the brightening into the raw's starting look, so DRO photos opened lighter than in Lightroom, darktable, Photomator or Affinity, which develop the raw without it (issue #244). On 24 ILCE-7CR photos (Standard creative style) with Lightroom 9.5.1 default exports, mean L* at 200 px: with DRO off, Lightroom matches the camera JPEG (+0.15 L*, all within ±1.4) and LightCraft matches Lightroom (−0.19 L*, median ΔE76 3.6); with DRO Lv3 or Auto (8 photos), Lightroom is 2.4–10.7 L* darker than the camera JPEG and LightCraft followed the JPEG, +6.4 L* from Lightroom (median ΔE76 7.5). The tone curves fitted to 131 ILCE-7CR photos show where: at scene luminance 0.01, 0.02, 0.05 and 0.1 the median DRO Lv3/Auto curve is 8, 10, 9 and 6 L* above the DRO-off one, and from 0.3 up they agree. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 46e126f6b..0e1a7bb49 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -234,7 +234,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); the fit pairs the sensor moved into the file's lens-warp geometry when that lines up better with a lens-corrected camera JPEG (29 CC0 raws: alignment +0.13, mean ΔE 3.73 → 3.61) | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); the fit pairs the sensor moved into the file's lens-warp geometry when that lines up better with a lens-corrected camera JPEG (29 CC0 raws: alignment +0.13, mean ΔE 3.73 → 3.61); bundled NIKON Z 6_2 profile (296 photos; 59 held-back photos mean ΔE 2.73 → 2.54) | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | @@ -816,6 +816,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | minor | LR-EDIT-OPTICS-PROFILE: Nikon Z 6II / Z 8 / Z f NEFs use the camera's in-file distortion correction (NikonNEFInfo); status unchanged (🟡) | | 2026-10-11 | minor | Model setup folder action, opener error and retry (LR-MASK-OBJECTS). | | 2026-10-13 | minor | LR-PROF-CAMERACOLOR: the camera look follows the lens warp of lens-corrected camera JPEGs; status unchanged (🟡) | +| 2026-10-13 | minor | LR-PROF-CAMERACOLOR: bundled Nikon Z 6II camera profile; status unchanged (🟡) | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index cd0fd0935..19edcfc06 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-14 +### Nikon Z 6II colour profile +- Raws from the Nikon Z 6II now start from a colour profile built from a few hundred Z 6II photos, the same way the + Sony and Fujifilm profiles were made, instead of fitting each photo's colour on its own. Colours that one photo + shows only a little of come out closer to the camera's JPEG. + ### Closer camera colour for lens-corrected raws - Raws from cameras that correct their own JPEG for lens distortion (Panasonic and Fujifilm with that setting on, the Sony A7R IVA) now start from colours closer to the camera's. The starting look was being fitted against a JPEG that diff --git a/xtask/src/main.rs b/xtask/src/main.rs index c392d0890..365cd8130 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -646,6 +646,12 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/download/data/Nikon/Z%20f/DSC_0040.NEF", "83c82be0be8865d796096dfbcc8ef2abf5af1bd37db44dfad6715070b0c99d15", ), + ( + // Z 6II, NIKKOR Z 24-200 at 70 mm, Vivid picture control (not used to fit the bundled Z 6II profile) + "nef-nikon-z6ii-lossless.nef", + "https://raw.pixls.us/getfile.php/4160/nice/Nikon%20-%20Z%206_2%20-%2014bit%20compressed%20(Lossless)%20(3:2).NEF", + "1b67d15817dbd4143a1f1089e2f09e319a2a2327c124e2643284509dd7621030", + ), ( "nrw-nikon-b700-uncompressed.nrw", "https://raw.pixls.us/getfile.php/1621/nice/Nikon%20-%20COOLPIX%20B700%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", From ead24aded9ae4bfae95a392a28f3be23abe994b1 Mon Sep 17 00:00:00 2001 From: Michael Anderson Date: Tue, 13 Oct 2026 11:29:27 -0500 Subject: [PATCH 08/12] LR-PROF-CAMERACOLOR: Add a bundled Nikon D5300 camera profile Fitted with lightcraft-cli calibrate to 264 of my own D5300 photos. --- README.md | 2 +- README.zh-hans.md | 2 +- assets/ATTRIBUTION.md | 1 + assets/camera-profiles/NIKON_D5300.json | 1819 +++++++++++++++++++++++ crates/engine/src/camera_profiles.rs | 1 + docs/camera-preview-colour.md | 6 +- docs/parity-checklist.md | 3 +- docs/whats-new.md | 4 +- 8 files changed, 1832 insertions(+), 6 deletions(-) create mode 100644 assets/camera-profiles/NIKON_D5300.json diff --git a/README.md b/README.md index c16ded23c..324a3b226 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det casual use.** It's great for JPEG/DNG and most Sony / Panasonic / Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera. - **The biggest gaps:** - - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II and Z 8 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; + - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; - **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md); - **AI masks and denoise:** subject and sky selection are classical heuristics; - **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md); diff --git a/README.zh-hans.md b/README.zh-hans.md index 0bc8239d6..6371f9012 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -304,7 +304,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**( - **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/ 较老佳能的 raw 都很出色。 - **最大的缺口:** - - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II 和 Z 8 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; + - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; - **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md); - **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法; - **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。** diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index d3fbc4d7b..2592c5501 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -16,6 +16,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none | | `assets/camera-profiles/NIKON_Z_6_2.json` | Nikon Z 6II camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 296 usable Z 6II NEFs of my own (of 299, 2024 to 2026, Standard and Auto picture controls; NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none | | `assets/camera-profiles/NIKON_Z_8.json` | Nikon Z 8 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 261 usable Z 8 NEFs of my own (of 278, 2023 and 2026, nearly all Standard picture control, lossless 14-bit; NIKKOR Z 24-120mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none | +| `assets/camera-profiles/NIKON_D5300.json` | Nikon D5300 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 264 usable D5300 NEFs of my own (of 273, 2014 to 2017, mostly Standard picture control, lossy 12-bit, mostly the AF-S DX 35mm f/1.8G) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none | | `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix | | `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none | | `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a | diff --git a/assets/camera-profiles/NIKON_D5300.json b/assets/camera-profiles/NIKON_D5300.json new file mode 100644 index 000000000..5ab1f062e --- /dev/null +++ b/assets/camera-profiles/NIKON_D5300.json @@ -0,0 +1,1819 @@ +{ + "version": 1, + "model": "NIKON D5300", + "files": 264, + "samples": 964687, + "matrix": [ + [1.1916187747366802, -0.24231291824981582, 0.06983947565237258], + [-0.07690142035770187, 1.1730235959883188, -0.10538788116290476], + [0.03036949208278017, -0.9047958593485248, 1.8955506437912675] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [0.0, 1.0, 1.0], + [-0.27515984, 1.1516873, 1.0], + [1.6639282, 1.1351492, 1.0], + [3.788241, 1.0706222, 1.0], + [2.7755148, 1.0334021, 1.0], + [0.0, 1.0, 1.0], + [0.14424162, 1.1334472, 1.0], + [1.3097183, 1.1570547, 1.0], + [2.2948222, 1.0653765, 1.0], + [2.48696, 1.0126696, 1.0], + [0.0, 1.0, 1.0], + [0.39059362, 1.108461, 1.0], + [0.91269153, 1.1615747, 1.0], + [1.2193394, 1.1302495, 1.0], + [1.564095, 1.0433257, 1.0], + [0.0, 1.0, 1.0], + [0.37582052, 1.0874242, 1.0], + [0.37180975, 1.12975, 1.0], + [0.08854759, 1.119602, 1.0], + [0.12284424, 1.0544983, 1.0], + [0.0, 1.0, 1.0], + [0.41708267, 1.0888891, 1.0], + [0.06426376, 1.1006688, 1.0], + [-0.8100427, 1.0653179, 1.0], + [-1.3206435, 1.0191233, 1.0], + [0.0, 1.0, 1.0], + [0.13243157, 1.080272, 1.0], + [-0.96863246, 1.0961237, 1.0], + [-2.4892373, 1.0579139, 1.0], + [-3.6368277, 1.0045699, 1.0], + [0.0, 1.0, 1.0], + [-0.5086707, 1.0768719, 1.0], + [-2.4455884, 1.090032, 1.0], + [-4.392341, 1.0430846, 1.0], + [-5.70884, 0.984795, 1.0], + [0.0, 1.0, 1.0], + [-0.9826923, 1.0976027, 1.0], + [-3.1522026, 1.0984454, 1.0], + [-5.0928793, 1.0361301, 1.0], + [-6.190662, 0.9671035, 1.0], + [0.0, 1.0, 1.0], + [-0.83517295, 1.1328652, 1.0], + [-2.3040426, 1.1309464, 1.0], + [-3.782865, 1.0633757, 1.0], + [-5.345632, 0.9701844, 1.0], + [0.0, 1.0, 1.0], + [0.5699928, 1.1778497, 1.0], + [-0.5092877, 1.1737088, 1.0], + [-1.6260748, 1.0975113, 1.0], + [-3.6590147, 0.97431755, 1.0], + [0.0, 1.0, 1.0], + [1.965812, 1.2052466, 1.0], + [1.2205318, 1.1937522, 1.0], + [0.32865468, 1.1070911, 1.0], + [-0.5527478, 0.9777932, 1.0], + [0.0, 1.0, 1.0], + [2.1100607, 1.198459, 1.0], + [2.1056213, 1.1429148, 1.0], + [1.3356481, 1.0316085, 1.0], + [0.5096412, 0.93477476, 1.0], + [0.0, 1.0, 1.0], + [1.4636977, 1.2118869, 1.0], + [1.7222096, 1.1241792, 1.0], + [0.98221314, 1.0024475, 1.0], + [0.21372011, 0.9131384, 1.0], + [0.0, 1.0, 1.0], + [0.5342778, 1.1797847, 1.0], + [0.957812, 1.0956764, 1.0], + [0.90817916, 1.0003859, 1.0], + [0.27506226, 0.91949815, 1.0], + [0.0, 1.0, 1.0], + [0.18069139, 1.1459695, 1.0], + [1.0440427, 1.0779425, 1.0], + [1.4630631, 0.9941303, 1.0], + [1.2396606, 0.87395155, 1.0], + [0.0, 1.0, 1.0], + [0.18410955, 1.0979248, 1.0], + [1.3498847, 1.0526143, 1.0], + [2.2518015, 0.97271824, 1.0], + [2.5790663, 0.85005623, 1.0], + [0.0, 1.0, 1.0], + [0.86806947, 1.0538092, 1.0], + [2.255343, 1.0105029, 1.0], + [3.711173, 0.92738235, 1.0], + [4.5281615, 0.85241896, 1.0], + [0.0, 1.0, 1.0], + [1.1754395, 1.0296917, 1.0], + [2.5134487, 0.98583174, 1.0], + [3.7772336, 0.90998256, 1.0], + [4.45152, 0.85479385, 1.0], + [0.0, 1.0, 1.0], + [1.1445321, 1.0435532, 1.0], + [2.0396032, 0.9995336, 1.0], + [3.0725362, 0.9361685, 1.0], + [3.631579, 0.8885794, 1.0], + [0.0, 1.0, 1.0], + [0.7827189, 1.0589092, 1.0], + [1.0505862, 1.0227683, 1.0], + [1.7957299, 0.98822904, 1.0], + [1.967721, 0.9724715, 1.0], + [0.0, 1.0, 1.0], + [-0.29322907, 1.06687, 1.0], + [-0.08487194, 1.0363688, 1.0], + [0.55795294, 1.0033514, 1.0], + [0.38443246, 0.9958227, 1.0], + [0.0, 1.0, 1.0], + [-0.20798732, 1.0558138, 1.0], + [-0.22798188, 1.0087736, 1.0], + [-0.27102852, 0.97902, 1.0], + [-0.030924844, 0.9963477, 1.0], + [0.0, 1.0, 1.0], + [-0.6630197, 1.0661411, 1.0], + [-1.1126446, 1.0020812, 1.0], + [-0.8216712, 0.96949637, 1.0], + [-0.05875481, 0.99697804, 1.0], + [0.0, 1.0, 1.0], + [-1.306994, 1.0804613, 1.0], + [-1.9421868, 1.0075339, 1.0], + [-0.4435876, 0.9711658, 1.0], + [-0.009687971, 0.9980309, 1.0], + [0.0, 1.0, 1.0], + [-1.1269542, 1.0775944, 1.0], + [-1.3976283, 1.0217266, 1.0], + [-0.24913609, 0.98865783, 1.0], + [-0.0031247279, 0.9994072, 1.0], + [0.0, 1.0, 1.0], + [-1.375694, 1.0512048, 1.0], + [-0.75242716, 1.0082058, 1.0], + [-0.06595289, 0.99760807, 1.0], + [-0.0031345324, 0.99984926, 1.0], + [0.0, 1.0, 1.0], + [-1.7031127, 1.0458598, 1.0], + [-1.1684496, 1.0118368, 1.0], + [-0.1479137, 0.9961412, 1.0], + [-0.0055244397, 0.9997797, 1.0], + [0.0, 1.0, 1.0], + [-1.7284406, 1.0431077, 1.0], + [-0.73593587, 1.002617, 1.0], + [0.02080173, 0.9911072, 1.0], + [0.00041606076, 0.9996808, 1.0], + [0.0, 1.0, 1.0], + [-1.2268276, 1.0388371, 1.0], + [-1.0188804, 1.0028651, 1.0], + [-0.10369495, 0.9951023, 1.0], + [-0.00074021873, 0.99985033, 1.0], + [0.0, 1.0, 1.0], + [-0.4187577, 1.0261325, 1.0], + [-1.5957482, 0.9980721, 1.0], + [-0.1547406, 0.99747145, 1.0], + [0.006172743, 0.99982816, 1.0], + [0.0, 1.0, 1.0], + [0.17795917, 1.0130396, 1.0], + [-0.6837107, 0.98732287, 1.0], + [-0.06094904, 0.9973522, 1.0], + [0.004337359, 0.99988484, 1.0], + [0.0, 1.0, 1.0], + [2.0028615, 1.0350925, 1.0], + [0.68680644, 1.000726, 1.0], + [0.019622738, 0.99780047, 1.0], + [0.0016065001, 0.999882, 1.0], + [0.0, 1.0, 1.0], + [2.0603294, 1.019478, 1.0], + [0.38180757, 0.983154, 1.0], + [-0.23340763, 0.98130405, 1.0], + [-0.0110910535, 0.9989576, 1.0], + [0.0, 1.0, 1.0], + [2.5435147, 1.0271243, 1.0], + [0.99150276, 0.989704, 1.0], + [-0.28383088, 0.98094606, 1.0], + [-0.020072438, 0.9989133, 1.0], + [0.0, 1.0, 1.0], + [3.08294, 1.0426484, 1.0], + [1.818317, 1.0104465, 1.0], + [0.061887734, 0.99672973, 1.0], + [-0.003478195, 0.99980235, 1.0], + [0.0, 1.0, 1.0], + [3.420556, 1.0468477, 1.0], + [1.3910332, 0.9856526, 1.0], + [0.014045477, 0.992967, 1.0], + [-0.0012650526, 0.99988115, 1.0], + [0.0, 1.0, 1.0], + [1.0608708, 1.0270871, 1.0], + [-0.91338277, 0.92517686, 1.0], + [-0.4462344, 0.9653749, 1.0], + [-0.00868847, 0.99921674, 1.0], + [0.0, 1.0, 1.0], + [-0.41799447, 1.0205152, 1.0], + [-1.6170602, 0.9293957, 1.0], + [-0.6444141, 0.96566117, 1.0], + [-0.013291724, 0.99908036, 1.0], + [0.0, 1.0, 1.0], + [0.30684704, 1.0309951, 1.0], + [-0.32407844, 0.9751206, 1.0], + [-0.2714902, 0.9828883, 1.0], + [-0.00732241, 0.9995556, 1.0], + [0.0, 1.0, 1.0], + [0.23834689, 1.0267385, 1.0], + [0.17927068, 0.98710644, 1.0], + [0.18505995, 0.988422, 1.0], + [0.007564359, 0.99952435, 1.0], + [0.0, 1.0, 1.0], + [0.9417192, 1.0186211, 1.0], + [0.4331768, 0.98655975, 1.0], + [0.5120104, 0.973346, 1.0], + [0.046577834, 0.9975678, 1.0], + [0.0, 1.0, 1.0], + [0.37069434, 1.015258, 1.0], + [-0.33970982, 1.0002911, 1.0], + [0.21809609, 0.9738671, 1.0], + [0.05885403, 0.996412, 1.0], + [0.0, 1.0, 1.0], + [0.3410674, 1.0226996, 1.0], + [-0.46905938, 1.0094384, 1.0], + [1.1913799, 0.9839222, 1.0], + [0.27729467, 0.9954679, 1.0], + [0.0, 1.0, 1.0], + [1.8399196, 1.0310917, 1.0], + [1.3589114, 1.0043445, 1.0], + [3.2229822, 0.9866625, 1.0], + [0.79395044, 0.9953453, 1.0], + [0.0, 1.0, 1.0], + [3.6512873, 1.0852343, 1.0], + [3.6366863, 1.0575762, 1.0], + [4.409545, 1.0124124, 1.0], + [1.2255954, 0.9980612, 1.0], + [0.0, 1.0, 1.0], + [3.5640118, 1.103434, 1.0], + [3.857803, 1.0989252, 1.0], + [5.013495, 1.0628691, 1.0], + [2.1981053, 1.0128543, 1.0], + [0.0, 1.0, 1.0], + [3.750485, 1.1216393, 1.0], + [4.4771504, 1.1242076, 1.0], + [5.554026, 1.098979, 1.0], + [4.0766697, 1.0487705, 1.0], + [0.0, 1.0, 1.0], + [3.716854, 1.1420878, 1.0], + [3.9434807, 1.149656, 1.0], + [4.698269, 1.1275396, 1.0], + [3.355159, 1.0637993, 1.0], + [0.0, 1.0, 1.0], + [3.1019642, 1.152162, 1.0], + [3.018406, 1.1701936, 1.0], + [3.5190942, 1.1440179, 1.0], + [1.6282337, 1.0440116, 1.0], + [0.0, 1.0, 1.0], + [3.0533535, 1.1430386, 1.0], + [2.6682806, 1.1609282, 1.0], + [1.6297065, 1.1209588, 1.0], + [0.3274113, 1.0186641, 1.0], + [0.0, 1.0, 1.0], + [2.7143104, 1.1457591, 1.0], + [2.010889, 1.1753439, 1.0], + [1.1698594, 1.1809243, 1.0], + [0.26954132, 1.0447038, 1.0], + [0.0, 1.0, 1.0], + [1.9450912, 1.1625692, 1.0], + [1.4925617, 1.1576607, 1.0], + [0.73646855, 1.1099164, 1.0], + [0.041240156, 1.0114015, 1.0], + [0.0, 1.0, 1.0], + [1.4923553, 1.1628035, 1.0], + [0.9318059, 1.1155237, 1.0], + [-0.08695841, 1.0332942, 1.0], + [-0.048176043, 1.0041637, 1.0], + [0.0, 1.0, 1.0], + [1.084317, 1.1418072, 1.0], + [0.077919334, 1.1205045, 1.0], + [-0.37862262, 1.0519394, 1.0], + [-0.020748373, 1.0024971, 1.0], + [0.0, 1.0, 1.0], + [0.70962113, 1.1346613, 1.0], + [0.040982615, 1.1304044, 1.0], + [-0.59360516, 1.0895824, 1.0], + [-0.044442892, 1.0063348, 1.0], + [0.0, 1.0, 1.0], + [0.044502545, 1.1331831, 1.0], + [-0.31845695, 1.1328877, 1.0], + [-0.70814407, 1.0888221, 1.0], + [-0.053025763, 1.0063571, 1.0], + [0.0, 1.0, 1.0], + [0.102819525, 1.0983768, 1.0], + [-0.41320392, 1.0724556, 1.0], + [-0.34162834, 1.0306761, 1.0], + [-0.015480887, 1.0013198, 1.0], + [0.0, 1.0, 1.0], + [-0.45333293, 1.1152055, 1.0], + [-0.36607388, 1.0825105, 1.0], + [-0.061023094, 1.0260044, 1.0], + [-0.00076291023, 1.0008935, 1.0], + [0.0, 1.0, 1.0], + [-1.5181267, 1.1252426, 1.0], + [-0.7772274, 1.0954831, 1.0], + [0.07648249, 1.0305958, 1.0], + [0.007827844, 1.0011276, 1.0], + [0.0, 1.0, 1.0], + [-2.7173598, 1.1436788, 1.0], + [-1.1496428, 1.0798888, 1.0], + [0.13708575, 1.0121282, 1.0], + [0.0056166523, 1.0002311, 1.0], + [0.0, 1.0, 1.0], + [-2.8031726, 1.1485914, 1.0], + [-1.1996536, 1.065685, 1.0], + [-0.12146866, 1.0105418, 1.0], + [-0.001331464, 1.000203, 1.0], + [0.0, 1.0, 1.0], + [-3.7381613, 1.1670295, 1.0], + [-2.4761944, 1.0762824, 1.0], + [-0.30495355, 1.010074, 1.0], + [-0.003095148, 1.0001698, 1.0], + [0.0, 1.0, 1.0], + [-2.7574484, 1.1367188, 1.0], + [-1.0683595, 1.0591327, 1.0], + [0.3032825, 1.0109986, 1.0], + [0.0240107, 1.0003924, 1.0], + [0.0, 1.0, 1.0], + [-4.4444375, 1.1478848, 1.0], + [-2.10396, 1.077343, 1.0], + [0.39878115, 1.0108056, 1.0], + [0.04858238, 1.0006601, 1.0], + [0.0, 1.0, 1.0], + [-3.6460211, 1.1262141, 1.0], + [-0.94227576, 1.0764561, 1.0], + [0.46847248, 1.0121009, 1.0], + [0.056671463, 1.0005428, 1.0], + [0.0, 1.0, 1.0], + [-3.941849, 1.1401461, 1.0], + [-1.4121957, 1.0967879, 1.0], + [0.3250404, 1.0172433, 1.0], + [0.05624255, 1.0007424, 1.0], + [0.0, 1.0, 1.0], + [-3.3003352, 1.1373972, 1.0], + [-1.2239021, 1.0964172, 1.0], + [0.4203376, 1.0171689, 1.0], + [0.097768836, 1.0010349, 1.0], + [0.0, 1.0, 1.0], + [-1.3678734, 1.1021602, 1.0], + [0.18997674, 1.0791068, 1.0], + [1.9053582, 1.0272077, 1.0], + [0.36325264, 1.0027457, 1.0], + [0.0, 1.0, 1.0], + [-1.888127, 1.1030134, 1.0], + [0.9222494, 1.0793839, 1.0], + [4.407062, 1.0317812, 1.0], + [1.2517463, 1.0053726, 1.0], + [0.0, 1.0, 1.0], + [-0.87627596, 1.1100941, 1.0], + [2.1658633, 1.0810852, 1.0], + [6.2091784, 1.0344337, 1.0], + [2.7591643, 1.0083429, 1.0], + [0.0, 1.0, 1.0], + [0.58831537, 1.1441597, 1.0], + [3.26025, 1.1017427, 1.0], + [5.9591794, 1.0396717, 1.0], + [2.5558283, 1.009373, 1.0], + [0.0, 1.0, 1.0], + [0.6157835, 1.1522069, 1.0], + [2.7376304, 1.1033771, 1.0], + [5.0446105, 1.0609956, 1.0], + [2.545954, 1.0267637, 1.0], + [0.0, 1.0, 1.0], + [2.567323, 1.050905, 1.0], + [3.219447, 1.086401, 1.0], + [5.309476, 1.0623287, 1.0], + [5.3261275, 1.0507418, 1.0], + [0.0, 1.0, 1.0], + [2.4884005, 1.039593, 1.0], + [2.2780461, 1.0956311, 1.0], + [3.2725804, 1.0421693, 1.0], + [3.57421, 1.0101761, 1.0], + [0.0, 1.0, 1.0], + [2.709007, 1.016824, 1.0], + [1.9036032, 1.1006229, 1.0], + [1.8250041, 1.09863, 1.0], + [2.2753541, 1.0252498, 1.0], + [0.0, 1.0, 1.0], + [2.8476596, 1.0087867, 1.0], + [1.692206, 1.0733916, 1.0], + [0.98378277, 1.0820075, 1.0], + [0.71567744, 1.0277224, 1.0], + [0.0, 1.0, 1.0], + [2.7584476, 1.0095731, 1.0], + [1.4141955, 1.0379248, 1.0], + [0.30707088, 1.0278721, 1.0], + [-0.48991957, 0.99570155, 1.0], + [0.0, 1.0, 1.0], + [2.3251288, 1.0099978, 1.0], + [0.40751106, 1.034089, 1.0], + [-1.266682, 1.0152951, 1.0], + [-2.8792267, 0.97016555, 1.0], + [0.0, 1.0, 1.0], + [1.676751, 1.0196159, 1.0], + [-1.0043784, 1.0388186, 1.0], + [-3.3858006, 1.0105125, 1.0], + [-5.2836957, 0.95619494, 1.0], + [0.0, 1.0, 1.0], + [1.4230912, 1.0371325, 1.0], + [-1.6468483, 1.0507761, 1.0], + [-4.205406, 1.0050639, 1.0], + [-5.696899, 0.9382988, 1.0], + [0.0, 1.0, 1.0], + [2.215277, 1.0757174, 1.0], + [-0.13039589, 1.0800881, 1.0], + [-2.5378978, 1.0185709, 1.0], + [-4.5910883, 0.941271, 1.0], + [0.0, 1.0, 1.0], + [3.7552316, 1.1195593, 1.0], + [1.9935799, 1.1178633, 1.0], + [-0.1950342, 1.0336235, 1.0], + [-2.9394403, 0.9406914, 1.0], + [0.0, 1.0, 1.0], + [4.6019835, 1.1451467, 1.0], + [3.4129214, 1.1365347, 1.0], + [2.2300456, 1.0304257, 1.0], + [0.6699211, 0.8971019, 1.0], + [0.0, 1.0, 1.0], + [4.556115, 1.1341692, 1.0], + [3.8200648, 1.0857233, 1.0], + [3.1856856, 0.96298456, 1.0], + [2.2841954, 0.8578174, 1.0], + [0.0, 1.0, 1.0], + [3.4975002, 1.1417533, 1.0], + [2.8865607, 1.0605808, 1.0], + [2.4713562, 0.93972397, 1.0], + [1.982328, 0.83509517, 1.0], + [0.0, 1.0, 1.0], + [2.0893435, 1.1080577, 1.0], + [1.7293227, 1.0274619, 1.0], + [1.6507962, 0.9354451, 1.0], + [1.3905607, 0.8366557, 1.0], + [0.0, 1.0, 1.0], + [1.6415445, 1.0806029, 1.0], + [1.506959, 1.0187534, 1.0], + [1.6388204, 0.9282385, 1.0], + [1.8973097, 0.82975906, 1.0], + [0.0, 1.0, 1.0], + [1.7991877, 1.0542749, 1.0], + [2.0936208, 1.0012176, 1.0], + [2.6603212, 0.9100169, 1.0], + [3.5249443, 0.80724543, 1.0], + [0.0, 1.0, 1.0], + [2.1576753, 1.0242636, 1.0], + [2.7907417, 0.9638929, 1.0], + [3.652071, 0.87338793, 1.0], + [4.5204115, 0.78994405, 1.0], + [0.0, 1.0, 1.0], + [2.1243021, 1.008548, 1.0], + [2.7552125, 0.9425203, 1.0], + [3.522211, 0.8644462, 1.0], + [4.1264706, 0.8124369, 1.0], + [0.0, 1.0, 1.0], + [1.8834304, 1.0225105, 1.0], + [2.3353438, 0.9708129, 1.0], + [3.0901859, 0.9099647, 1.0], + [3.7211735, 0.86348975, 1.0], + [0.0, 1.0, 1.0], + [1.4581822, 1.0330464, 1.0], + [1.5594033, 0.99513304, 1.0], + [2.0912547, 0.96644175, 1.0], + [2.5819116, 0.94535977, 1.0], + [0.0, 1.0, 1.0], + [0.60412645, 1.0405735, 1.0], + [0.5539686, 1.0038283, 1.0], + [0.8588483, 0.9796771, 1.0], + [0.7651864, 0.98082924, 1.0], + [0.0, 1.0, 1.0], + [0.19108707, 1.0352545, 1.0], + [-0.16049986, 0.99251735, 1.0], + [-0.5080386, 0.96194357, 1.0], + [-0.33787724, 0.98708415, 1.0], + [0.0, 1.0, 1.0], + [-0.24877733, 1.0316597, 1.0], + [-0.70576507, 0.97597045, 1.0], + [-0.8016234, 0.9442628, 1.0], + [-0.11087948, 0.9908073, 1.0], + [0.0, 1.0, 1.0], + [-0.8790754, 1.0514538, 1.0], + [-1.4108989, 0.9916971, 1.0], + [-1.0544907, 0.9481936, 1.0], + [-0.10507189, 0.99393654, 1.0], + [0.0, 1.0, 1.0], + [-1.3867714, 1.0623717, 1.0], + [-2.1656637, 1.0158321, 1.0], + [-1.1665387, 0.9772894, 1.0], + [-0.049957793, 0.99827534, 1.0], + [0.0, 1.0, 1.0], + [-2.4962778, 1.047387, 1.0], + [-3.1633992, 1.0073104, 1.0], + [-1.4553053, 0.95946413, 1.0], + [-0.13852488, 0.99207044, 1.0], + [0.0, 1.0, 1.0], + [-3.1582537, 1.039661, 1.0], + [-3.3594494, 0.99911463, 1.0], + [-1.4694877, 0.9412946, 1.0], + [-0.15061137, 0.9906891, 1.0], + [0.0, 1.0, 1.0], + [-3.7993233, 1.040663, 1.0], + [-3.003692, 0.9947964, 1.0], + [-0.8132878, 0.94909513, 1.0], + [-0.09164102, 0.99424165, 1.0], + [0.0, 1.0, 1.0], + [-3.1300888, 1.0289713, 1.0], + [-2.916435, 0.98529565, 1.0], + [-0.3949024, 0.9686243, 1.0], + [0.057513624, 0.99605733, 1.0], + [0.0, 1.0, 1.0], + [-2.870443, 1.0128809, 1.0], + [-3.22342, 0.97333175, 1.0], + [1.0390259, 0.9529574, 1.0], + [0.3107253, 0.99131966, 1.0], + [0.0, 1.0, 1.0], + [-1.9573697, 1.0006245, 1.0], + [-1.8365934, 0.9674953, 1.0], + [0.7572013, 0.9682651, 1.0], + [0.17691553, 0.9942757, 1.0], + [0.0, 1.0, 1.0], + [-0.28490174, 1.0081447, 1.0], + [-0.29309836, 0.97951233, 1.0], + [0.18393767, 0.98060596, 1.0], + [0.007778787, 0.997274, 1.0], + [0.0, 1.0, 1.0], + [0.2726132, 0.99976957, 1.0], + [-0.4088234, 0.95960116, 1.0], + [0.0077163163, 0.9512176, 1.0], + [0.019507347, 0.9959901, 1.0], + [0.0, 1.0, 1.0], + [0.7798813, 0.9985067, 1.0], + [0.76253676, 0.9651048, 1.0], + [-0.1965283, 0.95622796, 1.0], + [-0.024494128, 0.99734217, 1.0], + [0.0, 1.0, 1.0], + [1.2395806, 1.0156081, 1.0], + [1.9043974, 0.98719865, 1.0], + [0.2547382, 0.9865172, 1.0], + [-0.0017040195, 0.99939626, 1.0], + [0.0, 1.0, 1.0], + [2.3556528, 1.0269231, 1.0], + [1.6541926, 0.94798446, 1.0], + [0.19142385, 0.96310043, 1.0], + [0.00077345694, 0.99907255, 1.0], + [0.0, 1.0, 1.0], + [0.8628551, 1.0025055, 1.0], + [-0.8925193, 0.86057365, 1.0], + [-0.89507776, 0.8785938, 1.0], + [-0.02676659, 0.9942501, 1.0], + [0.0, 1.0, 1.0], + [-0.69604295, 0.9841022, 1.0], + [-1.5614027, 0.86573595, 1.0], + [-1.0682615, 0.8759204, 1.0], + [-0.053552028, 0.992165, 1.0], + [0.0, 1.0, 1.0], + [-1.3843973, 0.9732926, 1.0], + [-1.6747307, 0.9024153, 1.0], + [-1.1190381, 0.90966296, 1.0], + [-0.0430858, 0.995535, 1.0], + [0.0, 1.0, 1.0], + [-1.4726397, 0.9728779, 1.0], + [-0.48264512, 0.9410372, 1.0], + [0.31483912, 0.93758917, 1.0], + [0.029700607, 0.99565494, 1.0], + [0.0, 1.0, 1.0], + [-1.5699832, 0.9680764, 1.0], + [-0.5216335, 0.9524717, 1.0], + [1.1201954, 0.9122498, 1.0], + [0.2576845, 0.9783837, 1.0], + [0.0, 1.0, 1.0], + [-1.9922228, 0.9569479, 1.0], + [-1.1839337, 0.9620515, 1.0], + [0.49700478, 0.90824383, 1.0], + [0.2757639, 0.9656649, 1.0], + [0.0, 1.0, 1.0], + [-1.8267179, 0.97236925, 1.0], + [-1.3503194, 0.98332787, 1.0], + [-0.21534178, 0.9398797, 1.0], + [0.3592183, 0.9531863, 1.0], + [0.0, 1.0, 1.0], + [-1.3845943, 0.9576693, 1.0], + [-0.88324434, 0.95311546, 1.0], + [1.0021961, 0.92629516, 1.0], + [1.4048795, 0.9467104, 1.0], + [0.0, 1.0, 1.0], + [0.21868584, 0.9868099, 1.0], + [1.2409965, 0.98529285, 1.0], + [3.478748, 0.96618646, 1.0], + [3.547536, 0.9636579, 1.0], + [0.0, 1.0, 1.0], + [0.72944003, 1.011865, 1.0], + [2.2030432, 1.0459692, 1.0], + [4.3416624, 1.0307989, 1.0], + [4.5565796, 1.0013582, 1.0], + [0.0, 1.0, 1.0], + [0.7054098, 1.0304452, 1.0], + [2.7193584, 1.0797284, 1.0], + [4.6252937, 1.0782566, 1.0], + [5.346714, 1.0554028, 1.0], + [0.0, 1.0, 1.0], + [0.8206882, 1.0563322, 1.0], + [2.66612, 1.105401, 1.0], + [4.257176, 1.1014127, 1.0], + [5.333192, 1.0896508, 1.0], + [0.0, 1.0, 1.0], + [0.6858709, 1.0573044, 1.0], + [2.1161485, 1.1302112, 1.0], + [3.9404676, 1.1377732, 1.0], + [4.928983, 1.1188648, 1.0], + [0.0, 1.0, 1.0], + [0.4658943, 1.0244572, 1.0], + [1.4814435, 1.1276039, 1.0], + [2.0604527, 1.1611818, 1.0], + [1.7115017, 1.0927407, 1.0], + [0.0, 1.0, 1.0], + [0.66739404, 1.0136315, 1.0], + [1.41378, 1.1620725, 1.0], + [1.2858282, 1.2084693, 1.0], + [0.8847436, 1.1387153, 1.0], + [0.0, 1.0, 1.0], + [0.22088455, 1.0020697, 1.0], + [0.8503165, 1.1110431, 1.0], + [0.8370134, 1.1636014, 1.0], + [0.16764726, 1.0484313, 1.0], + [0.0, 1.0, 1.0], + [-0.032334913, 0.99697274, 1.0], + [-0.12844399, 1.0419574, 1.0], + [-0.9318103, 1.0669361, 1.0], + [-0.2286045, 1.0165362, 1.0], + [0.0, 1.0, 1.0], + [-0.1669066, 0.99338514, 1.0], + [-0.8307293, 1.0627798, 1.0], + [-1.2913208, 1.0905485, 1.0], + [-0.08585524, 1.0079323, 1.0], + [0.0, 1.0, 1.0], + [0.017506301, 0.9974502, 1.0], + [-0.4714752, 1.0923178, 1.0], + [-1.2507538, 1.1427293, 1.0], + [-0.15650667, 1.0198009, 1.0], + [0.0, 1.0, 1.0], + [0.5337322, 0.98601866, 1.0], + [0.4784788, 1.0887345, 1.0], + [-0.95893174, 1.1386591, 1.0], + [-0.15236506, 1.0194373, 1.0], + [0.0, 1.0, 1.0], + [0.78718185, 0.9737713, 1.0], + [0.8975688, 1.024816, 1.0], + [-0.2945547, 1.0546055, 1.0], + [-0.03156037, 1.0033555, 1.0], + [0.0, 1.0, 1.0], + [0.24685815, 0.98656416, 1.0], + [0.55160105, 1.0425034, 1.0], + [-0.11143197, 1.0422429, 1.0], + [-0.003670774, 1.0018142, 1.0], + [0.0, 1.0, 1.0], + [-0.77873033, 1.0003996, 1.0], + [0.19153872, 1.0427284, 1.0], + [0.37716967, 1.041646, 1.0], + [0.016879126, 1.0023743, 1.0], + [0.0, 1.0, 1.0], + [-1.7304914, 1.0388753, 1.0], + [-0.7251529, 1.0577725, 1.0], + [0.34579277, 1.0164489, 1.0], + [0.009596101, 1.0004158, 1.0], + [0.0, 1.0, 1.0], + [-1.6364738, 1.067764, 1.0], + [-0.9167525, 1.0778942, 1.0], + [0.103218675, 1.0217229, 1.0], + [0.0034776113, 1.0004051, 1.0], + [0.0, 1.0, 1.0], + [-1.7374398, 1.0553571, 1.0], + [-0.8997327, 1.072175, 1.0], + [0.018124623, 1.0228853, 1.0], + [0.0031013442, 1.0004023, 1.0], + [0.0, 1.0, 1.0], + [-1.9461782, 1.0329045, 1.0], + [-0.9284798, 1.0640512, 1.0], + [0.5232761, 1.0302181, 1.0], + [0.0713816, 1.0012505, 1.0], + [0.0, 1.0, 1.0], + [-1.7830942, 1.0167092, 1.0], + [-1.0461223, 1.0536364, 1.0], + [0.71468955, 1.031544, 1.0], + [0.1528099, 1.0023446, 1.0], + [0.0, 1.0, 1.0], + [-0.901455, 1.0192658, 1.0], + [0.3789207, 1.0608308, 1.0], + [1.4712181, 1.0287849, 1.0], + [0.17150511, 1.0013422, 1.0], + [0.0, 1.0, 1.0], + [-1.4228317, 1.0136043, 1.0], + [-0.4306419, 1.0521969, 1.0], + [0.79203665, 1.0235791, 1.0], + [0.17930758, 1.0008605, 1.0], + [0.0, 1.0, 1.0], + [-0.89692557, 1.0078237, 1.0], + [0.22381781, 1.0376579, 1.0], + [1.2225298, 1.0229918, 1.0], + [0.30199468, 1.0024537, 1.0], + [0.0, 1.0, 1.0], + [0.96971756, 0.998565, 1.0], + [2.4854388, 1.0386328, 1.0], + [5.093825, 1.0409368, 1.0], + [1.5859087, 1.0088303, 1.0], + [0.0, 1.0, 1.0], + [2.0574582, 0.99701333, 1.0], + [3.9885697, 1.0335468, 1.0], + [7.3134866, 1.02023, 1.0], + [4.876025, 1.0077791, 1.0], + [0.0, 1.0, 1.0], + [2.9773273, 1.0016919, 1.0], + [4.8199377, 1.0256822, 1.0], + [7.568321, 1.0072774, 1.0], + [6.5177317, 0.99610305, 1.0], + [0.0, 1.0, 1.0], + [3.3437927, 1.0140332, 1.0], + [4.7947435, 1.036412, 1.0], + [7.550965, 1.0280272, 1.0], + [6.326854, 1.0115026, 1.0], + [0.0, 1.0, 1.0], + [2.9055889, 1.0224042, 1.0], + [3.8671165, 1.0454612, 1.0], + [6.3752146, 1.0593462, 1.0], + [5.871097, 1.0545919, 1.0], + [0.0, 1.0, 1.0], + [4.4711437, 0.89240575, 1.0], + [3.8707209, 0.975318, 1.0], + [5.54684, 1.0295455, 1.0], + [5.4792643, 1.0330564, 1.0], + [0.0, 1.0, 1.0], + [4.5822687, 0.86561245, 1.0], + [3.369101, 0.9498307, 1.0], + [4.0309362, 0.99107826, 1.0], + [4.1218996, 0.97818476, 1.0], + [0.0, 1.0, 1.0], + [4.7380633, 0.856774, 1.0], + [3.2887201, 0.9503168, 1.0], + [2.9449494, 1.0068572, 1.0], + [3.3715508, 0.97625136, 1.0], + [0.0, 1.0, 1.0], + [4.9660296, 0.8707871, 1.0], + [3.211986, 0.9548858, 1.0], + [1.9611124, 1.0027668, 1.0], + [1.4650759, 0.9794454, 1.0], + [0.0, 1.0, 1.0], + [4.919991, 0.8776082, 1.0], + [2.96986, 0.92894495, 1.0], + [1.43205, 0.9510039, 1.0], + [0.48539585, 0.9417541, 1.0], + [0.0, 1.0, 1.0], + [4.378387, 0.883634, 1.0], + [2.1083007, 0.919261, 1.0], + [0.5318521, 0.9173686, 1.0], + [-0.7582033, 0.896065, 1.0], + [0.0, 1.0, 1.0], + [3.8246558, 0.8962875, 1.0], + [0.9034894, 0.928397, 1.0], + [-1.1259035, 0.92528087, 1.0], + [-3.3147051, 0.8988185, 1.0], + [0.0, 1.0, 1.0], + [4.153484, 0.91500145, 1.0], + [0.48695508, 0.940206, 1.0], + [-2.1672661, 0.9259778, 1.0], + [-3.7963305, 0.88363916, 1.0], + [0.0, 1.0, 1.0], + [5.7889557, 0.9511915, 1.0], + [2.4093935, 0.9637889, 1.0], + [-0.28017357, 0.9176229, 1.0], + [-1.7246062, 0.86704445, 1.0], + [0.0, 1.0, 1.0], + [6.9804792, 1.0035709, 1.0], + [4.4882298, 0.9964915, 1.0], + [2.0359013, 0.9096808, 1.0], + [0.44025534, 0.8560936, 1.0], + [0.0, 1.0, 1.0], + [7.1492724, 1.0114399, 1.0], + [5.4943357, 1.0019382, 1.0], + [4.05172, 0.8888965, 1.0], + [2.2645717, 0.7902095, 1.0], + [0.0, 1.0, 1.0], + [6.758939, 0.99153984, 1.0], + [5.431977, 0.95972574, 1.0], + [4.6545696, 0.84344786, 1.0], + [3.4948368, 0.77209806, 1.0], + [0.0, 1.0, 1.0], + [5.388168, 0.986505, 1.0], + [4.070344, 0.92283, 1.0], + [3.5232341, 0.83252263, 1.0], + [3.0174634, 0.7662801, 1.0], + [0.0, 1.0, 1.0], + [3.7689843, 0.96089333, 1.0], + [2.5116751, 0.8785204, 1.0], + [2.3376532, 0.8223458, 1.0], + [2.414712, 0.7608956, 1.0], + [0.0, 1.0, 1.0], + [3.2908578, 0.9508312, 1.0], + [2.2224586, 0.88289684, 1.0], + [2.228365, 0.8166788, 1.0], + [2.6414301, 0.7484262, 1.0], + [0.0, 1.0, 1.0], + [3.3916478, 0.93619686, 1.0], + [2.823667, 0.87227386, 1.0], + [3.0609424, 0.79408795, 1.0], + [3.626718, 0.72579795, 1.0], + [0.0, 1.0, 1.0], + [3.1597862, 0.9192608, 1.0], + [3.008894, 0.85837466, 1.0], + [3.3871465, 0.79258764, 1.0], + [3.9049165, 0.7342933, 1.0], + [0.0, 1.0, 1.0], + [2.9172287, 0.9121524, 1.0], + [2.6648562, 0.85023427, 1.0], + [2.947294, 0.8032925, 1.0], + [3.3702457, 0.76370627, 1.0], + [0.0, 1.0, 1.0], + [2.5077455, 0.9239658, 1.0], + [2.4199598, 0.87722534, 1.0], + [2.618067, 0.84640473, 1.0], + [2.995166, 0.8136889, 1.0], + [0.0, 1.0, 1.0], + [1.9437534, 0.9129008, 1.0], + [1.9712167, 0.87672514, 1.0], + [2.1036656, 0.8706977, 1.0], + [2.0733418, 0.87078476, 1.0], + [0.0, 1.0, 1.0], + [1.4246463, 0.91300803, 1.0], + [1.1752675, 0.8862555, 1.0], + [0.9968104, 0.90140474, 1.0], + [0.23840506, 0.97830564, 1.0], + [0.0, 1.0, 1.0], + [0.67706054, 0.9196341, 1.0], + [0.109584466, 0.9024811, 1.0], + [-0.27386528, 0.9419742, 1.0], + [-0.059076753, 0.9978306, 1.0], + [0.0, 1.0, 1.0], + [0.07224172, 0.93229127, 1.0], + [-0.34362468, 0.914439, 1.0], + [-0.17629111, 0.96382296, 1.0], + [-0.011366743, 0.9988268, 1.0], + [0.0, 1.0, 1.0], + [-0.81656486, 0.955494, 1.0], + [-1.0913409, 0.94884336, 1.0], + [-0.44080573, 0.982739, 1.0], + [-0.030075505, 0.9989954, 1.0], + [0.0, 1.0, 1.0], + [-1.3378361, 0.9686959, 1.0], + [-1.5182222, 0.96549654, 1.0], + [-0.4606443, 0.9918533, 1.0], + [-0.019811057, 0.9994661, 1.0], + [0.0, 1.0, 1.0], + [-2.243623, 0.95517683, 1.0], + [-2.0646274, 0.93799746, 1.0], + [-0.90104544, 0.95197743, 1.0], + [-0.10999995, 0.99141395, 1.0], + [0.0, 1.0, 1.0], + [-2.8076265, 0.9409817, 1.0], + [-2.058747, 0.9154531, 1.0], + [-1.5804225, 0.88738924, 1.0], + [-0.23936853, 0.9801497, 1.0], + [0.0, 1.0, 1.0], + [-3.9064386, 0.95459986, 1.0], + [-2.3988228, 0.896239, 1.0], + [-1.7098186, 0.8685506, 1.0], + [-0.25361952, 0.9790636, 1.0], + [0.0, 1.0, 1.0], + [-4.2093053, 0.92354167, 1.0], + [-2.2733746, 0.9118433, 1.0], + [0.16433056, 0.9224389, 1.0], + [0.32686478, 0.98159236, 1.0], + [0.0, 1.0, 1.0], + [-4.749688, 0.9219642, 1.0], + [-1.7706362, 0.91556644, 1.0], + [1.4995979, 0.9179761, 1.0], + [0.58829814, 0.9714674, 1.0], + [0.0, 1.0, 1.0], + [-3.9969661, 0.9133036, 1.0], + [-1.1233542, 0.9314691, 1.0], + [0.91808987, 0.9250387, 1.0], + [0.36256704, 0.96524554, 1.0], + [0.0, 1.0, 1.0], + [-2.4866922, 0.91120887, 1.0], + [-0.59065604, 0.9415378, 1.0], + [-0.78037834, 0.9368204, 1.0], + [-0.3913863, 0.9704481, 1.0], + [0.0, 1.0, 1.0], + [-2.3026705, 0.90438557, 1.0], + [-0.8718288, 0.94404954, 1.0], + [-0.15336794, 0.977961, 1.0], + [-0.09629151, 0.9925991, 1.0], + [0.0, 1.0, 1.0], + [-1.9070312, 0.90280366, 1.0], + [-0.55553955, 0.935856, 1.0], + [0.25413927, 0.988254, 1.0], + [0.009861473, 0.9995575, 1.0], + [0.0, 1.0, 1.0], + [-1.9049702, 0.9080516, 1.0], + [-0.266718, 0.9349882, 1.0], + [0.13215524, 0.9933759, 1.0], + [0.0041504498, 0.99984777, 1.0], + [0.0, 1.0, 1.0], + [-0.8701549, 0.90498465, 1.0], + [1.0092131, 0.8918811, 1.0], + [0.3660149, 0.9696158, 1.0], + [0.0074352697, 0.9991519, 1.0], + [0.0, 1.0, 1.0], + [-1.0172404, 0.8942851, 1.0], + [0.6829563, 0.8349779, 1.0], + [0.48213816, 0.91718537, 1.0], + [0.016518895, 0.99731606, 1.0], + [0.0, 1.0, 1.0], + [-1.7463626, 0.8773616, 1.0], + [-0.36728278, 0.80656576, 1.0], + [-0.05267751, 0.8861593, 1.0], + [-0.0062499205, 0.99655586, 1.0], + [0.0, 1.0, 1.0], + [-2.645922, 0.85724914, 1.0], + [-1.6585516, 0.8101157, 1.0], + [-0.77995527, 0.8782376, 1.0], + [-0.014658492, 0.99659693, 1.0], + [0.0, 1.0, 1.0], + [-3.6578364, 0.8668074, 1.0], + [-2.0196826, 0.8535868, 1.0], + [-0.37624604, 0.921359, 1.0], + [0.00017735251, 0.99736357, 1.0], + [0.0, 1.0, 1.0], + [-4.0841975, 0.8596372, 1.0], + [-2.4710217, 0.8740145, 1.0], + [-0.1209378, 0.920483, 1.0], + [0.045910586, 0.99135536, 1.0], + [0.0, 1.0, 1.0], + [-4.430148, 0.85118824, 1.0], + [-2.7032092, 0.8731887, 1.0], + [-0.7946189, 0.8594394, 1.0], + [-0.09909849, 0.9567725, 1.0], + [0.0, 1.0, 1.0], + [-4.8029838, 0.8495463, 1.0], + [-2.6373093, 0.8836435, 1.0], + [-1.0274059, 0.83784634, 1.0], + [-0.34142062, 0.8863734, 1.0], + [0.0, 1.0, 1.0], + [-5.0252833, 0.8179336, 1.0], + [-3.0253406, 0.8344459, 1.0], + [-1.3676816, 0.83761764, 1.0], + [0.08377362, 0.8903176, 1.0], + [0.0, 1.0, 1.0], + [-3.5889053, 0.8333059, 1.0], + [-1.7982966, 0.8581034, 1.0], + [-0.067006715, 0.87093455, 1.0], + [1.3765932, 0.9137696, 1.0], + [0.0, 1.0, 1.0], + [-2.6394725, 0.8462031, 1.0], + [-0.5374585, 0.88883096, 1.0], + [1.3434483, 0.91526973, 1.0], + [2.196534, 0.948764, 1.0], + [0.0, 1.0, 1.0], + [-1.8830673, 0.91449887, 1.0], + [0.3436676, 0.948412, 1.0], + [2.4071665, 0.9718761, 1.0], + [3.2197835, 0.9905023, 1.0], + [0.0, 1.0, 1.0], + [-2.1540444, 0.9479222, 1.0], + [0.77035385, 0.9906655, 1.0], + [3.5416546, 1.0201747, 1.0], + [4.4329867, 1.036617, 1.0], + [0.0, 1.0, 1.0], + [-2.6539636, 0.8664964, 1.0], + [0.31695533, 0.9838804, 1.0], + [3.5114965, 1.0515474, 1.0], + [4.3682404, 1.0739245, 1.0], + [0.0, 1.0, 1.0], + [-2.889564, 0.7832875, 1.0], + [-0.47233972, 0.94827306, 1.0], + [1.9158661, 1.0925105, 1.0], + [1.7773509, 1.0825669, 1.0], + [0.0, 1.0, 1.0], + [-2.359935, 0.7418968, 1.0], + [0.13793384, 0.9809765, 1.0], + [1.3180828, 1.1471351, 1.0], + [0.7645996, 1.0599663, 1.0], + [0.0, 1.0, 1.0], + [-2.2418065, 0.7301813, 1.0], + [0.35360616, 0.91816384, 1.0], + [0.9692918, 1.0641508, 1.0], + [0.07664488, 1.006114, 1.0], + [0.0, 1.0, 1.0], + [-2.0775743, 0.7516581, 1.0], + [-0.124465264, 0.8922322, 1.0], + [0.04122935, 0.9999263, 1.0], + [-0.016318215, 1.0011346, 1.0], + [0.0, 1.0, 1.0], + [-1.3793565, 0.7586109, 1.0], + [0.04257845, 0.8988754, 1.0], + [-0.082526416, 0.98730445, 1.0], + [-0.0054138596, 0.9999747, 1.0], + [0.0, 1.0, 1.0], + [-0.505196, 0.7803437, 1.0], + [0.40779415, 0.90821487, 1.0], + [-0.1287433, 1.0026283, 1.0], + [-0.011499701, 1.0009819, 1.0], + [0.0, 1.0, 1.0], + [-0.058046483, 0.78796697, 1.0], + [0.93765765, 0.894442, 1.0], + [0.043037504, 1.004575, 1.0], + [-0.008396565, 1.0010221, 1.0], + [0.0, 1.0, 1.0], + [-0.043535788, 0.8018015, 1.0], + [0.80214524, 0.9042569, 1.0], + [0.15616187, 0.9971092, 1.0], + [-0.00031897958, 1.0001391, 1.0], + [0.0, 1.0, 1.0], + [-0.13094118, 0.81996274, 1.0], + [-0.057957847, 0.9010969, 1.0], + [-0.15578985, 0.9838379, 1.0], + [-0.0028997594, 0.9998916, 1.0], + [0.0, 1.0, 1.0], + [-0.24140343, 0.8421498, 1.0], + [-0.33087662, 0.8944223, 1.0], + [-0.11538279, 0.96010375, 1.0], + [-0.0023777992, 0.99934727, 1.0], + [0.0, 1.0, 1.0], + [-0.6291469, 0.8663977, 1.0], + [-0.93443865, 0.8860551, 1.0], + [-0.52833366, 0.9360109, 1.0], + [-0.013429848, 0.99847513, 1.0], + [0.0, 1.0, 1.0], + [-0.90612704, 0.8846548, 1.0], + [-1.0758501, 0.9156333, 1.0], + [-0.31288663, 0.95635, 1.0], + [-0.0065308507, 0.9988607, 1.0], + [0.0, 1.0, 1.0], + [-0.8505977, 0.8680036, 1.0], + [-0.6228383, 0.9419045, 1.0], + [0.008568136, 0.99068564, 1.0], + [0.00062457524, 0.9997916, 1.0], + [0.0, 1.0, 1.0], + [-1.3649141, 0.8661397, 1.0], + [-1.1351817, 0.9570879, 1.0], + [-0.4343645, 0.99677765, 1.0], + [-0.007229383, 0.9998938, 1.0], + [0.0, 1.0, 1.0], + [-0.3357523, 0.86801755, 1.0], + [-0.53919494, 0.9580416, 1.0], + [-0.3332547, 1.0017875, 1.0], + [0.0020524773, 1.0001712, 1.0], + [0.0, 1.0, 1.0], + [1.0389299, 0.87367016, 1.0], + [1.5910575, 0.95255834, 1.0], + [1.0409293, 0.9986148, 1.0], + [0.06169785, 0.99989563, 1.0], + [0.0, 1.0, 1.0], + [0.69167626, 0.864867, 1.0], + [0.517416, 0.9371735, 1.0], + [0.7297484, 0.9740146, 1.0], + [0.14100057, 0.9978946, 1.0], + [0.0, 1.0, 1.0], + [1.1966501, 0.8564259, 1.0], + [0.7971627, 0.9295377, 1.0], + [0.98976326, 0.9878759, 1.0], + [0.10663256, 0.9997394, 1.0], + [0.0, 1.0, 1.0], + [2.9381518, 0.84076357, 1.0], + [2.7677243, 0.9219802, 1.0], + [4.2827673, 0.9931431, 1.0], + [0.7215513, 1.0000048, 1.0], + [0.0, 1.0, 1.0], + [4.0477877, 0.82670283, 1.0], + [3.4655888, 0.91149926, 1.0], + [5.5720844, 0.97649837, 1.0], + [2.4715145, 0.9948263, 1.0], + [0.0, 1.0, 1.0], + [4.8419666, 0.8251362, 1.0], + [4.0095496, 0.90738267, 1.0], + [5.8746896, 0.9661354, 1.0], + [4.1081214, 0.98024684, 1.0], + [0.0, 1.0, 1.0], + [4.5143023, 0.835298, 1.0], + [3.7496395, 0.9078515, 1.0], + [5.115886, 0.9541289, 1.0], + [3.3494966, 0.97551304, 1.0], + [0.0, 1.0, 1.0], + [4.479901, 0.8648893, 1.0], + [3.820371, 0.9325536, 1.0], + [5.326134, 0.97972375, 1.0], + [4.717001, 1.0019616, 1.0], + [0.0, 1.0, 1.0], + [6.5529094, 0.68653643, 1.0], + [4.249971, 0.8358243, 1.0], + [3.886182, 0.9036481, 1.0], + [3.1052408, 0.93986005, 1.0], + [0.0, 1.0, 1.0], + [6.0453534, 0.69864804, 1.0], + [4.088292, 0.8079499, 1.0], + [4.4767594, 0.8848239, 1.0], + [4.920347, 0.8965313, 1.0], + [0.0, 1.0, 1.0], + [5.8496194, 0.71566397, 1.0], + [4.111261, 0.7927633, 1.0], + [4.182716, 0.8936798, 1.0], + [4.9267282, 0.9027427, 1.0], + [0.0, 1.0, 1.0], + [6.4675403, 0.7260139, 1.0], + [4.864559, 0.7873533, 1.0], + [3.662903, 0.8703417, 1.0], + [3.33834, 0.89759904, 1.0], + [0.0, 1.0, 1.0], + [6.627538, 0.73187256, 1.0], + [4.7362914, 0.7925029, 1.0], + [2.762565, 0.85528374, 1.0], + [1.6968315, 0.8718531, 1.0], + [0.0, 1.0, 1.0], + [6.1378064, 0.74603015, 1.0], + [3.7333937, 0.8095638, 1.0], + [1.7157373, 0.84111804, 1.0], + [0.7313545, 0.84846157, 1.0], + [0.0, 1.0, 1.0], + [5.8278027, 0.73261493, 1.0], + [3.218722, 0.7893107, 1.0], + [1.7261162, 0.8081886, 1.0], + [1.3808451, 0.80620646, 1.0], + [0.0, 1.0, 1.0], + [6.5486107, 0.735842, 1.0], + [3.34487, 0.7877779, 1.0], + [1.9852514, 0.7963513, 1.0], + [3.2250917, 0.7770536, 1.0], + [0.0, 1.0, 1.0], + [8.3918, 0.7492594, 1.0], + [5.8002057, 0.76848954, 1.0], + [3.2627735, 0.7929065, 1.0], + [4.1515646, 0.7706826, 1.0], + [0.0, 1.0, 1.0], + [9.339295, 0.7832515, 1.0], + [7.126709, 0.778491, 1.0], + [3.6896281, 0.8016613, 1.0], + [3.1497123, 0.78488094, 1.0], + [0.0, 1.0, 1.0], + [9.593153, 0.7601979, 1.0], + [7.8867235, 0.7450819, 1.0], + [5.9123063, 0.7422206, 1.0], + [3.8759134, 0.74508727, 1.0], + [0.0, 1.0, 1.0], + [8.585278, 0.7479115, 1.0], + [7.529606, 0.7175019, 1.0], + [6.162009, 0.7064114, 1.0], + [4.4188056, 0.7255969, 1.0], + [0.0, 1.0, 1.0], + [6.858168, 0.7368539, 1.0], + [5.492259, 0.7144793, 1.0], + [4.347751, 0.70099527, 1.0], + [3.5681868, 0.7093534, 1.0], + [0.0, 1.0, 1.0], + [5.2282605, 0.73686594, 1.0], + [3.2710242, 0.70864916, 1.0], + [2.9356287, 0.6760259, 1.0], + [3.0854452, 0.64669275, 1.0], + [0.0, 1.0, 1.0], + [4.4789567, 0.74254066, 1.0], + [2.5880811, 0.7186022, 1.0], + [2.387628, 0.66940564, 1.0], + [2.4265919, 0.6265305, 1.0], + [0.0, 1.0, 1.0], + [4.528232, 0.7247535, 1.0], + [2.7541575, 0.7074213, 1.0], + [2.5300722, 0.6434275, 1.0], + [2.7180758, 0.5980046, 1.0], + [0.0, 1.0, 1.0], + [4.102856, 0.728164, 1.0], + [2.6525943, 0.71301305, 1.0], + [2.7899814, 0.67262053, 1.0], + [3.1614673, 0.6270317, 1.0], + [0.0, 1.0, 1.0], + [3.6348686, 0.73153895, 1.0], + [2.726432, 0.6877879, 1.0], + [2.54031, 0.7097242, 1.0], + [2.7431397, 0.7150978, 1.0], + [0.0, 1.0, 1.0], + [2.9502785, 0.69625, 1.0], + [2.6242392, 0.6294526, 1.0], + [2.2081497, 0.66211057, 1.0], + [1.3193871, 0.82756114, 1.0], + [0.0, 1.0, 1.0], + [2.0414326, 0.6955794, 1.0], + [2.0578754, 0.6632495, 1.0], + [1.7014283, 0.7117629, 1.0], + [0.3407154, 0.9488139, 1.0], + [0.0, 1.0, 1.0], + [1.1289076, 0.7163192, 1.0], + [1.151557, 0.7172398, 1.0], + [0.52380514, 0.86909467, 1.0], + [0.019359209, 0.9961916, 1.0], + [0.0, 1.0, 1.0], + [0.67299646, 0.7355766, 1.0], + [0.51002353, 0.7648463, 1.0], + [0.06944082, 0.96125567, 1.0], + [0.000119109, 0.9998196, 1.0], + [0.0, 1.0, 1.0], + [-0.031164411, 0.77996194, 1.0], + [0.014347508, 0.86290824, 1.0], + [-0.007677018, 0.99158084, 1.0], + [-6.689423e-05, 0.9999727, 1.0], + [0.0, 1.0, 1.0], + [-0.6832901, 0.8222584, 1.0], + [-0.3419607, 0.92960227, 1.0], + [-0.022380346, 0.9983181, 1.0], + [-0.00018521023, 0.9999936, 1.0], + [0.0, 1.0, 1.0], + [-1.0638311, 0.89357316, 1.0], + [-0.2623169, 0.97307545, 1.0], + [-0.008422446, 0.99948406, 1.0], + [-0.00014290158, 0.99999386, 1.0], + [0.0, 1.0, 1.0], + [-1.1319717, 0.89954185, 1.0], + [-0.28591886, 0.9751361, 1.0], + [-0.01649866, 0.9978075, 1.0], + [-0.0016126642, 0.99978495, 1.0], + [0.0, 1.0, 1.0], + [-2.0644026, 0.86974543, 1.0], + [-0.26208007, 0.9613298, 1.0], + [-0.054121625, 0.9739053, 1.0], + [-0.018682964, 0.996442, 1.0], + [0.0, 1.0, 1.0], + [-1.9141648, 0.88052386, 1.0], + [-0.27626938, 0.93415976, 1.0], + [-0.24882263, 0.9263471, 1.0], + [-0.06974276, 0.9880197, 1.0], + [0.0, 1.0, 1.0], + [-2.0521605, 0.8621233, 1.0], + [-0.4830994, 0.962234, 1.0], + [-0.02020153, 0.9847871, 1.0], + [0.019950174, 0.99770623, 1.0], + [0.0, 1.0, 1.0], + [-3.3087368, 0.84192723, 1.0], + [-0.65065837, 0.9700698, 1.0], + [0.11342574, 0.99164987, 1.0], + [0.050568797, 0.99678665, 1.0], + [0.0, 1.0, 1.0], + [-3.7450078, 0.8366232, 1.0], + [-0.49405074, 0.9702762, 1.0], + [0.107314065, 0.9858985, 1.0], + [0.053864308, 0.99334574, 1.0], + [0.0, 1.0, 1.0], + [-2.5509698, 0.8247367, 1.0], + [-0.20142983, 0.96617097, 1.0], + [-0.2560394, 0.981325, 1.0], + [-0.1159319, 0.9916455, 1.0], + [0.0, 1.0, 1.0], + [-1.5854938, 0.81745756, 1.0], + [-0.14268312, 0.97136134, 1.0], + [-0.06509467, 0.9953569, 1.0], + [-0.029058143, 0.9980467, 1.0], + [0.0, 1.0, 1.0], + [-0.8367958, 0.8402703, 1.0], + [-0.10091935, 0.97176695, 1.0], + [-0.0010153912, 0.9995565, 1.0], + [-3.0446154e-05, 0.9999878, 1.0], + [0.0, 1.0, 1.0], + [-2.1198392, 0.84143376, 1.0], + [-0.03249274, 0.952964, 1.0], + [0.012029766, 0.99843806, 1.0], + [8.835511e-05, 0.9999943, 1.0], + [0.0, 1.0, 1.0], + [-1.9262636, 0.86367226, 1.0], + [0.5532922, 0.93086356, 1.0], + [0.09160686, 0.99354273, 1.0], + [0.00081093685, 0.9999354, 1.0], + [0.0, 1.0, 1.0], + [-1.3739588, 0.8016463, 1.0], + [1.2970295, 0.7846802, 1.0], + [0.45966804, 0.9381906, 1.0], + [0.0061520343, 0.99921715, 1.0], + [0.0, 1.0, 1.0], + [-1.4077954, 0.7208454, 1.0], + [0.4733461, 0.6787829, 1.0], + [0.47692603, 0.80988336, 1.0], + [0.012340703, 0.9957405, 1.0], + [0.0, 1.0, 1.0], + [-2.542054, 0.69510156, 1.0], + [-1.0206543, 0.66685224, 1.0], + [-0.3861955, 0.77343684, 1.0], + [-0.0016055064, 0.9925781, 1.0], + [0.0, 1.0, 1.0], + [-4.949766, 0.7236445, 1.0], + [-2.2197113, 0.72735757, 1.0], + [-0.622677, 0.88092417, 1.0], + [-0.015985137, 0.996581, 1.0], + [0.0, 1.0, 1.0], + [-6.6489305, 0.68735474, 1.0], + [-3.5802984, 0.78218496, 1.0], + [-0.5320879, 0.92443025, 1.0], + [-0.022485942, 0.9953605, 1.0], + [0.0, 1.0, 1.0], + [-7.177222, 0.67137307, 1.0], + [-3.1258667, 0.780959, 1.0], + [-1.202351, 0.83195996, 1.0], + [-0.23152742, 0.9466141, 1.0], + [0.0, 1.0, 1.0], + [-7.1036143, 0.67803013, 1.0], + [-3.811027, 0.75741714, 1.0], + [-1.5931911, 0.8057145, 1.0], + [-0.4153525, 0.8942517, 1.0], + [0.0, 1.0, 1.0], + [-6.7684326, 0.6596717, 1.0], + [-5.32512, 0.69404334, 1.0], + [-3.1082761, 0.7684906, 1.0], + [-1.2460512, 0.894522, 1.0], + [0.0, 1.0, 1.0], + [-5.8683157, 0.6405401, 1.0], + [-4.9210896, 0.68566376, 1.0], + [-6.001439, 0.7500565, 1.0], + [-7.3015804, 0.812211, 1.0], + [0.0, 1.0, 1.0], + [-5.0999904, 0.6463381, 1.0], + [-3.7537494, 0.71320873, 1.0], + [-3.3960533, 0.7802192, 1.0], + [-3.351788, 0.85274297, 1.0], + [0.0, 1.0, 1.0], + [-4.22911, 0.68255895, 1.0], + [-2.943262, 0.75258833, 1.0], + [-2.539806, 0.79581076, 1.0], + [-1.4436098, 0.85693127, 1.0], + [0.0, 1.0, 1.0], + [-4.421207, 0.6897405, 1.0], + [-2.546169, 0.7558628, 1.0], + [-0.93006057, 0.8137628, 1.0], + [0.48019657, 0.90172833, 1.0], + [0.0, 1.0, 1.0], + [-4.955394, 0.63199955, 1.0], + [-3.2294798, 0.7054303, 1.0], + [-1.6279404, 0.7905365, 1.0], + [0.42406368, 0.95621544, 1.0], + [0.0, 1.0, 1.0], + [-4.406013, 0.61143196, 1.0], + [-2.4224486, 0.6885289, 1.0], + [0.15226217, 0.94315904, 1.0], + [0.3682204, 1.0079356, 1.0], + [0.0, 1.0, 1.0], + [-2.6070106, 0.59719855, 1.0], + [-0.5420019, 0.6935155, 1.0], + [0.49049014, 0.98263836, 1.0], + [0.093591675, 1.0030893, 1.0], + [0.0, 1.0, 1.0], + [-1.4122118, 0.62467295, 1.0], + [0.61009103, 0.7372408, 1.0], + [0.48575926, 0.9820844, 1.0], + [0.025345104, 1.0000371, 1.0], + [0.0, 1.0, 1.0], + [-0.9076017, 0.68767226, 1.0], + [0.656146, 0.8025899, 1.0], + [0.37165537, 0.9719272, 1.0], + [0.009354364, 0.9996975, 1.0], + [0.0, 1.0, 1.0], + [-0.519386, 0.7165023, 1.0], + [0.67302865, 0.8780051, 1.0], + [0.5275675, 0.94928, 1.0], + [0.022246271, 0.9979151, 1.0], + [0.0, 1.0, 1.0], + [0.09680573, 0.738186, 1.0], + [0.793014, 0.8750658, 1.0], + [0.381764, 0.9613324, 1.0], + [0.009429865, 0.99904037, 1.0], + [0.0, 1.0, 1.0], + [1.8834604, 0.77035594, 1.0], + [1.7601224, 0.8644663, 1.0], + [0.14496735, 0.98939514, 1.0], + [0.00014458288, 0.999937, 1.0], + [0.0, 1.0, 1.0], + [1.8881173, 0.79435354, 1.0], + [2.1193182, 0.87771165, 1.0], + [0.18743913, 0.9930873, 1.0], + [0.00059748656, 0.999982, 1.0], + [0.0, 1.0, 1.0], + [0.8984065, 0.7908348, 1.0], + [0.8870811, 0.86748314, 1.0], + [0.060655933, 0.9830568, 1.0], + [-0.00023914597, 0.9998897, 1.0], + [0.0, 1.0, 1.0], + [-0.052016236, 0.79481345, 1.0], + [-0.2686036, 0.8499417, 1.0], + [-0.24796923, 0.94703746, 1.0], + [-0.00565591, 0.99915326, 1.0], + [0.0, 1.0, 1.0], + [-1.2217782, 0.81121093, 1.0], + [-1.5196391, 0.8464336, 1.0], + [-0.6995659, 0.93161744, 1.0], + [-0.015096771, 0.99854493, 1.0], + [0.0, 1.0, 1.0], + [-1.9182163, 0.7860046, 1.0], + [-1.7838149, 0.8694839, 1.0], + [-0.3889935, 0.966303, 1.0], + [-0.0060358397, 0.99937606, 1.0], + [0.0, 1.0, 1.0], + [-1.4767361, 0.76479936, 1.0], + [-0.9574383, 0.89968514, 1.0], + [-0.093257755, 0.9917394, 1.0], + [-0.0010219052, 0.99989015, 1.0], + [0.0, 1.0, 1.0], + [-1.1596841, 0.762179, 1.0], + [-1.0841764, 0.8807078, 1.0], + [-0.13100374, 0.99240035, 1.0], + [-0.0010621684, 0.9999584, 1.0], + [0.0, 1.0, 1.0], + [-0.6849198, 0.7800176, 1.0], + [-1.0038462, 0.8779829, 1.0], + [-0.09007008, 0.9921065, 1.0], + [-0.0005381258, 0.9999664, 1.0], + [0.0, 1.0, 1.0], + [0.28321254, 0.7977584, 1.0], + [-0.16388126, 0.890086, 1.0], + [0.03192479, 0.9929511, 1.0], + [0.001241174, 0.9999684, 1.0], + [0.0, 1.0, 1.0], + [0.86829543, 0.7936024, 1.0], + [-0.37591735, 0.89833724, 1.0], + [-0.14845619, 0.9868604, 1.0], + [0.00082641013, 0.99980897, 1.0], + [0.0, 1.0, 1.0], + [2.0176198, 0.7752524, 1.0], + [1.6908506, 0.8934624, 1.0], + [0.44986808, 0.9873634, 1.0], + [0.008859725, 0.99985033, 1.0], + [0.0, 1.0, 1.0], + [3.6487887, 0.748154, 1.0], + [2.6644409, 0.8853934, 1.0], + [0.99475205, 0.98551035, 1.0], + [0.050193034, 0.9997457, 1.0], + [0.0, 1.0, 1.0], + [4.480023, 0.7250523, 1.0], + [1.926729, 0.8424514, 1.0], + [0.040736295, 0.92375875, 1.0], + [0.051179513, 0.99580204, 1.0], + [0.0, 1.0, 1.0], + [5.1756477, 0.71742564, 1.0], + [1.3003006, 0.80706257, 1.0], + [-1.707145, 0.8457335, 1.0], + [-0.49415466, 0.96840185, 1.0], + [0.0, 1.0, 1.0], + [4.975246, 0.7181208, 1.0], + [2.2769587, 0.81511086, 1.0], + [-0.6684549, 0.8240774, 1.0], + [-1.1093187, 0.9021424, 1.0], + [0.0, 1.0, 1.0], + [5.6670837, 0.7181022, 1.0], + [3.5531816, 0.83718723, 1.0], + [2.3386168, 0.85608834, 1.0], + [1.0339524, 0.91459143, 1.0], + [0.0, 1.0, 1.0], + [4.161144, 0.74439704, 1.0], + [1.2685431, 0.90642077, 1.0], + [1.2386372, 0.91409504, 1.0], + [0.9295507, 0.9615406, 1.0], + [0.0, 1.0, 1.0], + [4.1777663, 0.7336535, 1.0], + [1.6435673, 0.89623827, 1.0], + [4.6341896, 0.8448458, 1.0], + [5.328556, 0.8743724, 1.0], + [0.0, 1.0, 1.0], + [3.8456101, 0.74945605, 1.0], + [1.7118593, 0.8730719, 1.0], + [4.7893443, 0.8741666, 1.0], + [5.7356167, 0.8831293, 1.0], + [0.0, 1.0, 1.0], + [4.541483, 0.68340665, 1.0], + [3.1775188, 0.750212, 1.0], + [2.7961404, 0.89943826, 1.0], + [2.240247, 0.94229096, 1.0], + [0.0, 1.0, 1.0], + [5.315433, 0.6658273, 1.0], + [4.4101477, 0.6992003, 1.0], + [2.1726768, 0.87961835, 1.0], + [0.8891901, 0.96283895, 1.0], + [0.0, 1.0, 1.0], + [5.594221, 0.6638776, 1.0], + [5.8431273, 0.65800124, 1.0], + [5.264457, 0.7205041, 1.0], + [2.3550959, 0.89265925, 1.0], + [0.0, 1.0, 1.0], + [6.089242, 0.6326394, 1.0], + [7.3321614, 0.59986347, 1.0], + [8.751942, 0.6218272, 1.0], + [7.835094, 0.73740655, 1.0], + [0.0, 1.0, 1.0], + [6.8648624, 0.6189883, 1.0], + [7.854025, 0.5970248, 1.0], + [9.556352, 0.6305344, 1.0], + [9.962623, 0.70967585, 1.0], + [0.0, 1.0, 1.0], + [8.106201, 0.6309747, 1.0], + [8.538228, 0.6175027, 1.0], + [9.568188, 0.6525294, 1.0], + [9.985585, 0.7052805, 1.0], + [0.0, 1.0, 1.0], + [8.281834, 0.64497524, 1.0], + [7.932338, 0.6421112, 1.0], + [6.0639887, 0.72733206, 1.0], + [5.3430767, 0.77500343, 1.0], + [0.0, 1.0, 1.0], + [8.019873, 0.6824282, 1.0], + [7.110651, 0.6669911, 1.0], + [5.331789, 0.7284526, 1.0], + [3.3326175, 0.8244816, 1.0], + [0.0, 1.0, 1.0], + [7.284438, 0.6928456, 1.0], + [7.2835546, 0.6664346, 1.0], + [4.883378, 0.72301525, 1.0], + [1.6401588, 0.8878237, 1.0], + [0.0, 1.0, 1.0], + [5.3979216, 0.7423805, 1.0], + [5.322938, 0.73012125, 1.0], + [2.8791666, 0.7680131, 1.0], + [1.2293904, 0.8762204, 1.0], + [0.0, 1.0, 1.0], + [3.9055657, 0.8100785, 1.0], + [3.3159246, 0.7462504, 1.0], + [2.3902402, 0.6859098, 1.0], + [1.833428, 0.74496704, 1.0], + [0.0, 1.0, 1.0], + [2.3105671, 0.8353257, 1.0], + [1.5842186, 0.76920885, 1.0], + [1.6503354, 0.6779311, 1.0], + [1.5094707, 0.6925992, 1.0], + [0.0, 1.0, 1.0], + [1.6453714, 0.83882755, 1.0], + [1.2651672, 0.7836908, 1.0], + [1.7122288, 0.64048153, 1.0], + [1.8864015, 0.6182712, 1.0], + [0.0, 1.0, 1.0], + [1.4681439, 0.84673464, 1.0], + [1.016748, 0.7926556, 1.0], + [1.5301808, 0.6895232, 1.0], + [1.7087327, 0.6774361, 1.0], + [0.0, 1.0, 1.0], + [1.2655282, 0.8380442, 1.0], + [1.400092, 0.7222438, 1.0], + [0.9317295, 0.751648, 1.0], + [0.38670647, 0.9075669, 1.0], + [0.0, 1.0, 1.0], + [0.99011, 0.8104178, 1.0], + [1.5600725, 0.6673882, 1.0], + [0.8698622, 0.75667554, 1.0], + [0.05312782, 0.9771482, 1.0], + [0.0, 1.0, 1.0], + [0.57059133, 0.84510267, 1.0], + [0.82562035, 0.7819899, 1.0], + [0.26795024, 0.91805077, 1.0], + [0.007893546, 0.9974623, 1.0], + [0.0, 1.0, 1.0], + [-0.025850505, 0.885807, 1.0], + [0.16424465, 0.907354, 1.0], + [0.02480482, 0.9894822, 1.0], + [0.0002904646, 0.9998891, 1.0], + [0.0, 1.0, 1.0], + [0.0051441705, 0.905744, 1.0], + [0.052886356, 0.94857466, 1.0], + [0.003892918, 0.99737555, 1.0], + [1.5296924e-05, 0.9999911, 1.0], + [0.0, 1.0, 1.0], + [-0.13219802, 0.9432007, 1.0], + [0.0067049917, 0.9855628, 1.0], + [0.00048594674, 0.99958736, 1.0], + [1.3410622e-06, 0.99999905, 1.0], + [0.0, 1.0, 1.0], + [-0.10593759, 0.96154904, 1.0], + [-0.007101243, 0.9942198, 1.0], + [-5.9125785e-05, 0.99992687, 1.0], + [-1.7132953e-07, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.1380907, 0.98662883, 1.0], + [-0.0150175765, 0.99846256, 1.0], + [-0.00010678602, 0.9999852, 1.0], + [-7.602713e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.13843897, 0.99187297, 1.0], + [-0.014456171, 0.9993438, 1.0], + [-0.00010089983, 0.9999929, 1.0], + [-1.5361991e-06, 0.99999964, 1.0], + [0.0, 1.0, 1.0], + [-0.25314486, 0.9803601, 1.0], + [-0.015702732, 0.9983346, 1.0], + [-0.0008912772, 0.99966294, 1.0], + [-0.00028706418, 0.9999485, 1.0], + [0.0, 1.0, 1.0], + [-0.13319662, 0.9884858, 1.0], + [-0.008045408, 0.9985032, 1.0], + [-0.00546511, 0.9987218, 1.0], + [-0.0013116907, 0.9997901, 1.0], + [0.0, 1.0, 1.0], + [-0.14743957, 0.98442274, 1.0], + [-0.010547765, 0.9988189, 1.0], + [-0.00075808156, 0.99986076, 1.0], + [-8.594952e-05, 0.99998194, 1.0], + [0.0, 1.0, 1.0], + [-0.37524244, 0.9815919, 1.0], + [-0.030857285, 0.9987118, 1.0], + [-7.657946e-06, 0.9999798, 1.0], + [7.531104e-05, 0.9999943, 1.0], + [0.0, 1.0, 1.0], + [-0.30199113, 0.97909504, 1.0], + [-0.019221604, 0.99835676, 1.0], + [0.000142119, 0.99995494, 1.0], + [0.000114501025, 0.99998343, 1.0], + [0.0, 1.0, 1.0], + [-0.07426342, 0.9718809, 1.0], + [0.006918636, 0.99745023, 1.0], + [-0.00086405553, 0.99991477, 1.0], + [-0.0004274567, 0.99996984, 1.0], + [0.0, 1.0, 1.0], + [0.1214249, 0.97155046, 1.0], + [0.020241432, 0.9978329, 1.0], + [-6.842438e-05, 0.9999722, 1.0], + [-0.000104643506, 0.99999297, 1.0], + [0.0, 1.0, 1.0], + [-0.00912883, 0.98002875, 1.0], + [0.009914126, 0.998089, 1.0], + [0.00014658475, 0.99998343, 1.0], + [-3.147069e-07, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.34022465, 0.97413856, 1.0], + [-0.001044419, 0.99583006, 1.0], + [0.0007858895, 0.9998906, 1.0], + [2.2824117e-06, 0.99999976, 1.0], + [0.0, 1.0, 1.0], + [-0.234471, 0.98301566, 1.0], + [0.012073258, 0.9957116, 1.0], + [0.0027358236, 0.9997176, 1.0], + [1.8471012e-05, 0.9999981, 1.0], + [0.0, 1.0, 1.0], + [-0.37233362, 0.96034855, 1.0], + [0.20355557, 0.9555334, 1.0], + [0.040644884, 0.99291646, 1.0], + [0.00042774918, 0.99992293, 1.0], + [0.0, 1.0, 1.0], + [-0.26744676, 0.88313514, 1.0], + [0.25718036, 0.8152884, 1.0], + [0.09190484, 0.9529967, 1.0], + [0.0019344173, 0.999235, 1.0], + [0.0, 1.0, 1.0], + [-0.92890036, 0.8132875, 1.0], + [-0.62145543, 0.75051594, 1.0], + [-0.07648181, 0.9260143, 1.0], + [0.0015313957, 0.9985089, 1.0], + [0.0, 1.0, 1.0], + [-1.4036704, 0.87832355, 1.0], + [-0.62560636, 0.879744, 1.0], + [-0.07835409, 0.97920424, 1.0], + [-0.001020268, 0.9996515, 1.0], + [0.0, 1.0, 1.0], + [-2.5662296, 0.83067703, 1.0], + [-0.6488212, 0.94305354, 1.0], + [-0.052740045, 0.9922627, 1.0], + [-0.0016239518, 0.9996923, 1.0], + [0.0, 1.0, 1.0], + [-4.7540584, 0.71597546, 1.0], + [-2.4036474, 0.8318091, 1.0], + [-0.7855407, 0.9302143, 1.0], + [-0.030755855, 0.9963339, 1.0], + [0.0, 1.0, 1.0], + [-6.401625, 0.6375166, 1.0], + [-4.2524567, 0.73438454, 1.0], + [-1.7984731, 0.8661054, 1.0], + [-0.076227136, 0.9925712, 1.0], + [0.0, 1.0, 1.0], + [-7.093013, 0.57054, 1.0], + [-7.138467, 0.61704755, 1.0], + [-10.662155, 0.7250832, 1.0], + [-4.8741217, 0.8936352, 1.0], + [0.0, 1.0, 1.0], + [-6.7872076, 0.543695, 1.0], + [-9.125988, 0.5788258, 1.0], + [-17.282015, 0.6463619, 1.0], + [-15.361004, 0.7047228, 1.0], + [0.0, 1.0, 1.0], + [-6.5472207, 0.5460788, 1.0], + [-7.3952637, 0.5873735, 1.0], + [-9.985295, 0.6460461, 1.0], + [-9.715605, 0.75660294, 1.0], + [0.0, 1.0, 1.0], + [-6.143126, 0.5428474, 1.0], + [-6.979643, 0.59978616, 1.0], + [-7.3462987, 0.654454, 1.0], + [-4.6714454, 0.7781302, 1.0], + [0.0, 1.0, 1.0], + [-5.6845136, 0.5430165, 1.0], + [-7.2012515, 0.5718516, 1.0], + [-8.748415, 0.59380674, 1.0], + [-4.6859508, 0.76675576, 1.0], + [0.0, 1.0, 1.0], + [-5.1071506, 0.54315025, 1.0], + [-5.8883495, 0.56850153, 1.0], + [-7.7368093, 0.6292215, 1.0], + [-1.4560128, 0.9221482, 1.0], + [0.0, 1.0, 1.0], + [-2.574775, 0.5935165, 1.0], + [-1.5976609, 0.6629436, 1.0], + [-0.42182118, 0.9376009, 1.0], + [0.0032954898, 0.99905676, 1.0], + [0.0, 1.0, 1.0], + [-0.5040222, 0.66888565, 1.0], + [0.22192046, 0.765007, 1.0], + [0.039829105, 0.97707367, 1.0], + [0.0013656623, 0.99990016, 1.0], + [0.0, 1.0, 1.0], + [0.13229428, 0.750091, 1.0], + [0.26277184, 0.88919055, 1.0], + [0.020233026, 0.99522144, 1.0], + [0.00035138454, 0.99998504, 1.0], + [0.0, 1.0, 1.0], + [0.24901535, 0.8024303, 1.0], + [0.3177278, 0.91495854, 1.0], + [0.051968362, 0.9940321, 1.0], + [0.0007878256, 0.9999375, 1.0], + [0.0, 1.0, 1.0], + [0.40787914, 0.8393718, 1.0], + [0.6778435, 0.91640764, 1.0], + [0.2564198, 0.97801465, 1.0], + [0.0053125066, 0.99956983, 1.0], + [0.0, 1.0, 1.0], + [0.9363007, 0.86411923, 1.0], + [0.7308819, 0.92368954, 1.0], + [0.13012709, 0.9888115, 1.0], + [0.0020543067, 0.99983764, 1.0], + [0.0, 1.0, 1.0], + [1.792059, 0.9070631, 1.0], + [0.8328082, 0.961077, 1.0], + [0.03201244, 0.9983668, 1.0], + [0.00010289253, 0.99999255, 1.0], + [0.0, 1.0, 1.0], + [0.61971796, 0.93544394, 1.0], + [0.28986993, 0.98064864, 1.0], + [0.011807639, 0.99949574, 1.0], + [3.4211585e-05, 0.99999887, 1.0], + [0.0, 1.0, 1.0], + [0.36694813, 0.9191711, 1.0], + [0.15008779, 0.9748141, 1.0], + [0.004682179, 0.9991603, 1.0], + [5.24191e-06, 0.9999966, 1.0], + [0.0, 1.0, 1.0], + [-0.04590827, 0.94109243, 1.0], + [-0.035157125, 0.9765446, 1.0], + [-0.007650317, 0.99820185, 1.0], + [-0.00011332178, 0.99998254, 1.0], + [0.0, 1.0, 1.0], + [-0.15313512, 0.97539425, 1.0], + [-0.13065065, 0.9864903, 1.0], + [-0.017957566, 0.9982736, 1.0], + [-0.00023828977, 0.99997765, 1.0], + [0.0, 1.0, 1.0], + [-0.32719183, 0.960239, 1.0], + [-0.13606764, 0.9877723, 1.0], + [-0.007543968, 0.99940944, 1.0], + [-6.793774e-05, 0.9999944, 1.0], + [0.0, 1.0, 1.0], + [-0.33494267, 0.960886, 1.0], + [-0.12677892, 0.9896274, 1.0], + [-0.00804112, 0.9995762, 1.0], + [-5.0966377e-05, 0.99999756, 1.0], + [0.0, 1.0, 1.0], + [-0.56956315, 0.94241995, 1.0], + [-0.83334297, 0.9581462, 1.0], + [-0.09486739, 0.9961979, 1.0], + [-0.00065249624, 0.99997574, 1.0], + [0.0, 1.0, 1.0], + [-0.39292914, 0.943953, 1.0], + [-0.43260995, 0.9667326, 1.0], + [-0.036218695, 0.99800736, 1.0], + [-0.00021563526, 0.99999046, 1.0], + [0.0, 1.0, 1.0], + [-0.32144308, 0.95278865, 1.0], + [-0.25995246, 0.9713513, 1.0], + [-0.016442856, 0.9985246, 1.0], + [-9.176571e-05, 0.9999942, 1.0], + [0.0, 1.0, 1.0], + [-0.2966282, 0.9744063, 1.0], + [-0.86246514, 0.9668585, 1.0], + [-0.17140412, 0.9942151, 1.0], + [-0.0021118673, 0.9999324, 1.0], + [0.0, 1.0, 1.0], + [0.08769655, 0.97262937, 1.0], + [-0.25923535, 0.9713818, 1.0], + [-0.06498976, 0.99482614, 1.0], + [-0.00078246964, 0.99993557, 1.0], + [0.0, 1.0, 1.0], + [0.5253878, 0.9505631, 1.0], + [-0.16767718, 0.9614315, 1.0], + [-0.24291219, 0.98733723, 1.0], + [-0.009154632, 0.99959785, 1.0], + [0.0, 1.0, 1.0], + [0.57957804, 0.9173889, 1.0], + [-3.944535, 0.831082, 1.0], + [-2.9686522, 0.8881784, 1.0], + [-0.14177988, 0.9944771, 1.0], + [0.0, 1.0, 1.0], + [0.42895818, 0.90997833, 1.0], + [-5.41419, 0.7882363, 1.0], + [-5.786541, 0.80714077, 1.0], + [-0.8622991, 0.97064185, 1.0], + [0.0, 1.0, 1.0], + [1.3849322, 0.89100575, 1.0], + [-3.418151, 0.8283688, 1.0], + [-7.0915565, 0.766481, 1.0], + [-2.9420247, 0.89867216, 1.0], + [0.0, 1.0, 1.0], + [2.6696517, 0.8438243, 1.0], + [-0.2037993, 0.8984032, 1.0], + [-3.0600338, 0.83977926, 1.0], + [-1.4190359, 0.93247384, 1.0] + ], + "srgb_value": true + } +} diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs index efbf3506a..70ff6f2df 100644 --- a/crates/engine/src/camera_profiles.rs +++ b/crates/engine/src/camera_profiles.rs @@ -27,6 +27,7 @@ const MAX_FILE: u64 = 4 << 20; pub const BUNDLED: &[(&str, &str)] = &[ ("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")), ("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")), + ("NIKON D5300", include_str!("../../../assets/camera-profiles/NIKON_D5300.json")), ("NIKON Z 6_2", include_str!("../../../assets/camera-profiles/NIKON_Z_6_2.json")), ("NIKON Z 8", include_str!("../../../assets/camera-profiles/NIKON_Z_8.json")), ("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")), diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 106879fa2..9e1459188 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -113,7 +113,7 @@ A pooled profile was tried and is not bundled. Pooling the 46 photos (158,549 co One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs, NEFs or RAFs per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos. -Profiles are JSON files `.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos (below); X-H2S, 201 photos; X-T4, 545 photos; NIKON Z 6_2, 296 photos; NIKON Z 8, 261 photos (below). They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt. +Profiles are JSON files `.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos (below); X-H2S, 201 photos; X-T4, 545 photos; NIKON Z 6_2, 296 photos; NIKON Z 8, 261 photos; NIKON D5300, 264 photos (below). They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt. ### Sony ILCE-7CR @@ -136,6 +136,10 @@ The public raw.pixls.us Z 6II file (`nef-nikon-z6ii-lossless.nef`, Vivid picture Fitted from 261 usable photos of 278 Z 8 NEFs (one photographer, 2023 and 2026, almost all Standard picture control, lossless 14-bit, with the NIKKOR Z 24-120mm f/4 S and the AF-S 70-200mm f/2.8G on the FTZ; the High Efficiency files can't be decoded and were skipped), 969,860 colour pairs, collected the same way as the Z 6II's with Nikon's distortion correction read. Holding back every fifth photo (55) and fitting the other four fifths, mean ΔE on the held-back photos went from 2.55 to 2.33 (median 2.31 to 2.16), 39 closer and 10 farther, the largest by 0.46. The public raw.pixls.us Z 8 file (`nef-nikon-z8-lossless.nef`, Z 14-24 f/2.8 S, not used for fitting) is accepted with the profile and stays about the same, 3.22 to 3.27. +### Nikon D5300 + +Fitted from 264 usable photos of 273 D5300 NEFs (one photographer, 2014 to 2017, mostly Standard picture control with some Portrait and Landscape, lossy 12-bit, nearly all with the AF-S DX 35mm f/1.8G), 964,687 colour pairs. On the 53 held-back photos mean ΔE went from 2.86 to 2.66 (median 2.78 to 2.60), 44 closer and 6 farther, the largest by 0.63. There's no public D5300 sample in the corpus to check it against. + The same was tried for the photographer's D700, D750 and D80 (about 300 photos each) and it didn't help: on held-back photos the D750 stayed at 3.2 and the D700 and D80 got slightly worse, even fitting only their Standard (or normal saturation) photos. Their per-photo fits are already close and those bodies were shot with a mix of picture controls, so they have no profile. ## Sony Dynamic Range Optimizer (DRO) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index c7b15805a..383f1703c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -234,7 +234,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); the fit pairs the sensor moved into the file's lens-warp geometry when that lines up better with a lens-corrected camera JPEG (29 CC0 raws: alignment +0.13, mean ΔE 3.73 → 3.61); bundled NIKON Z 6_2 profile (296 photos; 59 held-back photos mean ΔE 2.73 → 2.54) and NIKON Z 8 profile (261 photos; 55 held back 2.55 → 2.33) | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); the fit pairs the sensor moved into the file's lens-warp geometry when that lines up better with a lens-corrected camera JPEG (29 CC0 raws: alignment +0.13, mean ΔE 3.73 → 3.61); bundled NIKON Z 6_2 profile (296 photos; 59 held-back photos mean ΔE 2.73 → 2.54) NIKON Z 8 profile (261 photos; 55 held back 2.55 → 2.33) and NIKON D5300 profile (264 photos; 53 held back 2.86 → 2.66) | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | @@ -818,6 +818,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | minor | LR-PROF-CAMERACOLOR: the camera look follows the lens warp of lens-corrected camera JPEGs; status unchanged (🟡) | | 2026-10-13 | minor | LR-PROF-CAMERACOLOR: bundled Nikon Z 6II camera profile; status unchanged (🟡) | | 2026-10-13 | minor | LR-PROF-CAMERACOLOR: bundled Nikon Z 8 camera profile; status unchanged (🟡) | +| 2026-10-13 | minor | LR-PROF-CAMERACOLOR: bundled Nikon D5300 camera profile; status unchanged (🟡) | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index 9e6f5fea9..779a91e65 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,8 +2,8 @@ ## 2026-10-14 -### Nikon Z 6II and Z 8 colour profiles -- Raws from the Nikon Z 6II and Z 8 now start from a colour profile built from a few hundred photos from each camera, the same way the +### Nikon Z 6II, Z 8 and D5300 colour profiles +- Raws from the Nikon Z 6II, Z 8 and D5300 now start from a colour profile built from a few hundred photos from each camera, the same way the Sony and Fujifilm profiles were made, instead of fitting each photo's colour on its own. Colours that one photo shows only a little of come out closer to the camera's JPEG. From 62da9e85819870e63b1183dc44f3878849cd1574 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 14 Oct 2026 20:24:53 -0400 Subject: [PATCH 09/12] LRC-LIB-LIBFOLDERS: drag photos from the grid onto a Folders row to move them there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photos dragged from the grid onto a Folders row move there on disk and in the library (`photo.moveToFolder`: files and XMP sidecars, virtual copies follow, never overwrites, one undo step), as Move Selected Photos Here in the row's menu does. - The row lights up, and the label following the pointer says where they go: "Move 2 photos to “Porto”" (onto an album the same gesture only adds them, so the two read apart). - A closed folder held under the pointer for 0.7 s opens, so a folder inside it can be reached. - Refused, with the reason on release and a not-allowed pointer: a row that covers several disks, and Local photos (not in the library). Photos already there say "Already in “Porto”". - A drop asks first, as Lightroom Classic does: "Move 2 photos to “Porto”?" (their files and XMP sidecars move on disk; Edit → Undo moves them back), with Don't ask again, kept in Settings as "Confirm before moving photos to another folder on disk" (on by default). The menu's Move Selected Photos Here, chosen by name, goes at once. A question left open while another library opens (the menu bar and agents reach past a dialog) moves nothing: photo ids are per library. - The toast says how many moved and that Edit → Undo moves them back; the menu item says the same. - Photos that leave the view leave the selection, so a Delete or edit next can't reach them unseen. For NAS and network shares: the copy across volumes now streams (it read the whole file into memory, a problem for a large raw or video) into a free hidden name that takes the photo's name only once complete (a share dropping mid-copy left a truncated file under the photo's name, which a retry reported as the photo already there). New engine tests cover a share going away: Undo with the share offline or read-only is refused with the reason and kept until it can run, nothing is deleted and the library stays linked; a connection lost partway through a move puts back what moved; a file the share can't give back rolls the whole Undo back; and a real move across devices where the machine has a second file system (`/dev/shm`). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 2 + crates/engine/src/rename.rs | 31 +- crates/engine/src/tests_move_to_share.rs | 286 ++++++++++++++++ crates/ui-egui/locales/de-formats.json | 4 + crates/ui-egui/locales/de.json | 9 + crates/ui-egui/locales/es-formats.json | 4 + crates/ui-egui/locales/es.json | 9 + crates/ui-egui/locales/fr-formats.json | 4 + crates/ui-egui/locales/fr.json | 9 + crates/ui-egui/locales/ja-formats.json | 4 + crates/ui-egui/locales/ja.json | 9 + crates/ui-egui/locales/pt-br-formats.json | 4 + crates/ui-egui/locales/pt-br.json | 9 + crates/ui-egui/locales/ru-formats.json | 4 + crates/ui-egui/locales/ru.json | 9 + crates/ui-egui/locales/uk-formats.json | 4 + crates/ui-egui/locales/uk.json | 9 + crates/ui-egui/locales/zh-hans-formats.json | 4 + crates/ui-egui/locales/zh-hans.json | 9 + crates/ui-egui/locales/zh-hant-formats.json | 4 + crates/ui-egui/locales/zh-hant.json | 9 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/dialogs.rs | 34 ++ crates/ui-egui/src/panels/grid.rs | 18 +- crates/ui-egui/src/panels/left.rs | 134 +++++++- crates/ui-egui/src/panels/settings.rs | 1 + crates/ui-egui/src/state.rs | 15 + crates/ui-egui/src/tests_folder_drop.rs | 343 ++++++++++++++++++++ docs/parity-checklist.md | 2 +- 29 files changed, 969 insertions(+), 16 deletions(-) create mode 100644 crates/engine/src/tests_move_to_share.rs create mode 100644 crates/ui-egui/src/tests_folder_drop.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7e05fcffd..8c8af3a08 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1036,6 +1036,8 @@ mod tests_lightroom_folder; #[cfg(test)] mod tests_merge; #[cfg(test)] +mod tests_move_to_share; +#[cfg(test)] mod tests_organize; #[cfg(test)] mod tests_persist; diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index e49355c08..80f9624c0 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -416,12 +416,31 @@ impl MoveFs for RealFs { } fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - use std::io::Write; - let bytes = std::fs::read(a)?; - let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(b)?; - f.write_all(&bytes).and_then(|_| f.sync_all()).inspect_err(|_| { - let _ = std::fs::remove_file(b); - }) + if self.exists(b) { + return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display()))); + } + // Streamed (a large raw or video is never read into memory whole), into a hidden name + // beside `b` that only takes `b`'s name once complete: a share that drops mid-copy, so + // the partial file can't be removed either, leaves no truncated file under the photo's + // name for a retry to take for the photo. + let name = b.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + // a free name: one left by a copy the share cut off (it couldn't be removed then) doesn't + // block the next + let partial = (0..1000u32) + .map(|k| b.with_file_name(format!(".{name}.lc-partial-{}-{k}", std::process::id()))) + .find(|p| !self.exists(p)) + .ok_or_else(|| std::io::Error::other(format!("no free temporary name beside {}", b.display())))?; + let copied = (|| { + let mut from = std::fs::File::open(a)?; + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&partial)?; + std::io::copy(&mut from, &mut f)?; + f.sync_all() + })(); + let placed = copied.and_then(|()| self.rename_no_replace(&partial, b)); + if placed.is_err() { + let _ = std::fs::remove_file(&partial); + } + placed } fn remove_file(&self, p: &Path) -> std::io::Result<()> { diff --git a/crates/engine/src/tests_move_to_share.rs b/crates/engine/src/tests_move_to_share.rs new file mode 100644 index 000000000..ed824b29d --- /dev/null +++ b/crates/engine/src/tests_move_to_share.rs @@ -0,0 +1,286 @@ +//! Moving photos to a folder on a NAS or network share (`photo.moveToFolder`, which dropping them +//! on a Folders row runs), where the share can go away between the move and its undo: +//! +//! * The share offline when Undo runs: Undo is refused with the reason and keeps the step; +//! nothing is deleted, the library still points at the files where they are, and once the share +//! is back the same Undo moves them home. +//! * The share read-only when Undo runs: the same. +//! * The connection lost partway through a move: the files already moved go back, the library is +//! unchanged and no history step is left. +//! * One file the share can't give back during Undo: the files already brought back go out again, +//! so every photo points at its file and the step can run once the share answers. +//! * Across devices (a share is another volume): the file is copied, checked and only then +//! removed; the copy goes to a hidden name and takes the photo's name only once complete. + +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::Session; + +/// A scratch folder that goes away with the test, however it ends. +struct Scratch(std::path::PathBuf); + +impl Scratch { + fn new(tag: &str) -> Scratch { + let dir = std::env::temp_dir().join(format!("lc-share-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("local")).unwrap(); + std::fs::create_dir_all(dir.join("share")).unwrap(); + Scratch(dir) + } + fn path(&self, rel: &str) -> String { + self.0.join(rel).components().collect::().to_string_lossy().to_string() + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn add_file(s: &mut Session, path: &str, body: &str) -> PhotoId { + std::fs::write(path, body).unwrap(); + let id = s.catalog.alloc_photo_id(); + let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy().to_string(); + let p = Photo::new(id, Source::File { path: path.into() }, &name, "JPEG", 60, 40, "2026-01-01T10:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id +} + +fn file_of(s: &Session, id: PhotoId) -> String { + match &s.catalog.photo(id).unwrap().source { + Source::File { path } => path.clone(), + _ => panic!("not a file"), + } +} + +/// Two photos on the local disk moved to the share. +fn moved_to_share(dir: &Scratch) -> (Session, PhotoId, PhotoId) { + let mut s = Session::new(); + let a = add_file(&mut s, &dir.path("local/a.jpg"), "A"); + std::fs::write(dir.path("local/a.xmp"), "").unwrap(); + let b = add_file(&mut s, &dir.path("local/b.jpg"), "B"); + let r = s.execute("photo.moveToFolder", &json!({"ids": [a.0, b.0], "folder": dir.path("share")})).unwrap(); + assert_eq!(r["moved"], 2); + (s, a, b) +} + +#[test] +fn undo_with_the_share_offline_is_refused_keeps_the_step_and_works_once_it_is_back() { + let dir = Scratch::new("offline"); + let (mut s, a, b) = moved_to_share(&dir); + let steps = s.undo.len(); + // the share goes offline: its folder is no longer there + let away = dir.0.join("share-unmounted"); + std::fs::rename(dir.0.join("share"), &away).unwrap(); + let e = s.execute("edit.undo", &json!({})).unwrap_err().to_string(); + assert!(e.contains("can't move the files back"), "says why: {e}"); + assert_eq!(s.undo.len(), steps, "the step stays, to undo once the share is back"); + // nothing deleted, nothing half-moved, and the library still points at the share + assert!(away.join("a.jpg").is_file() && away.join("a.xmp").is_file() && away.join("b.jpg").is_file()); + assert!(!dir.0.join("local/a.jpg").exists() && !dir.0.join("local/b.jpg").exists()); + assert_eq!(file_of(&s, a), dir.path("share/a.jpg")); + assert_eq!(file_of(&s, b), dir.path("share/b.jpg")); + // the share is back: the same Undo moves them home, sidecar and all + std::fs::rename(&away, dir.0.join("share")).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(std::fs::read_to_string(dir.path("local/a.jpg")).unwrap(), "A"); + assert!(dir.0.join("local/a.xmp").is_file() && dir.0.join("local/b.jpg").is_file()); + assert_eq!(file_of(&s, a), dir.path("local/a.jpg")); + assert_eq!(file_of(&s, b), dir.path("local/b.jpg")); +} + +#[cfg(unix)] +#[test] +fn undo_from_a_read_only_share_is_refused_and_deletes_nothing() { + use std::os::unix::fs::PermissionsExt; + let dir = Scratch::new("readonly"); + let (mut s, a, _) = moved_to_share(&dir); + let steps = s.undo.len(); + std::fs::set_permissions(dir.0.join("share"), std::fs::Permissions::from_mode(0o555)).unwrap(); + // running as root ignores permissions: nothing to check there + let root = std::fs::write(dir.0.join("share/probe"), "x").is_ok(); + if !root { + assert!(s.execute("edit.undo", &json!({})).is_err()); + assert_eq!(s.undo.len(), steps, "the step stays"); + assert!(dir.0.join("share/a.jpg").is_file() && dir.0.join("share/b.jpg").is_file(), "nothing deleted"); + assert!(!dir.0.join("local/a.jpg").exists(), "no second copy left behind"); + assert_eq!(file_of(&s, a), dir.path("share/a.jpg")); + } + std::fs::set_permissions(dir.0.join("share"), std::fs::Permissions::from_mode(0o755)).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(dir.0.join("local/a.jpg").is_file()); +} + +/// The model volume spells paths with `/`; the code under test joins them with the host's separator. +fn host(path: &str) -> String { + path.replace('/', std::path::MAIN_SEPARATOR_STR) +} + +fn model_session(fs_files: &[&str]) -> (Session, Vec) { + let mut s = Session::new(); + let ids = fs_files + .iter() + .map(|path| { + let id = s.catalog.alloc_photo_id(); + let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy().to_string(); + let p = Photo::new(id, Source::File { path: host(path) }, &name, "JPEG", 1, 1, "2026-01-01T00:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }) + .collect(); + (s, ids) +} + +fn plans(s: &Session, ids: &[PhotoId], into: &str) -> Vec { + ids.iter() + .map(|id| { + let from = file_of(s, *id); + let name = std::path::Path::new(&from).file_name().unwrap().to_string_lossy().to_string(); + crate::rename::RenamePlan { + id: id.0, + from: name.clone(), + to: name.clone(), + from_path: Some(from), + to_path: Some(host(&format!("{into}/{name}"))), + } + }) + .collect() +} + +/// Every photo points at a file the model volume holds (or, for `offline`, one it held). +fn assert_linked(s: &Session, fs: &crate::rename::tests::FakeFs, offline: &[&str]) { + let files = fs.listing(); + for p in s.catalog.photos() { + let Source::File { path } = &p.source else { continue }; + let path = path.replace('\\', "/"); + assert!( + files.iter().any(|(f, _)| *f == path) || offline.contains(&path.as_str()), + "photo {} points at {path}, which isn't there: {files:?}", + p.id.0 + ); + } +} + +#[test] +fn a_connection_lost_partway_through_a_move_puts_back_what_moved() { + let fs = crate::rename::tests::FakeFs::new(false, &[("/local/a.jpg", "A"), ("/local/b.jpg", "B"), ("/local/c.jpg", "C")]); + let (mut s, ids) = model_session(&["/local/a.jpg", "/local/b.jpg", "/local/c.jpg"]); + let plans = plans(&s, &ids, "/share"); + // the share stops answering when the second file goes + fs.fail.borrow_mut().push("/local/b.jpg".into()); + let steps = s.undo.len(); + assert!(s.apply_file_plans(&fs, &plans, &|_| "Move to Folder".into()).is_err()); + assert_eq!( + fs.listing(), + vec![("/local/a.jpg".into(), "A".into()), ("/local/b.jpg".into(), "B".into()), ("/local/c.jpg".into(), "C".into())], + "all back home" + ); + assert_eq!(s.undo.len(), steps, "no step for a move that didn't happen"); + assert_linked(&s, &fs, &[]); +} + +#[test] +fn a_file_the_share_cannot_give_back_rolls_the_whole_undo_back() { + let fs = crate::rename::tests::FakeFs::new(false, &[("/local/a.jpg", "A"), ("/local/b.jpg", "B")]); + let (mut s, ids) = model_session(&["/local/a.jpg", "/local/b.jpg"]); + let plans = plans(&s, &ids, "/share"); + s.apply_file_plans(&fs, &plans, &|_| "Move to Folder".into()).unwrap(); + assert_eq!(fs.listing(), vec![("/share/a.jpg".into(), "A".into()), ("/share/b.jpg".into(), "B".into())]); + // b can't be read back: the share answers for a but not for b + fs.fail.borrow_mut().push("/share/b.jpg".into()); + let e = s.undo_step_with(&fs).unwrap_err().to_string(); + assert!(e.contains("can't move the files back"), "{e}"); + // a came back before b failed: it went out again, so the step is whole and every photo + // points at its file, none lost + assert_linked(&s, &fs, &[]); + let contents: Vec = fs.listing().into_iter().map(|(_, c)| c).collect(); + assert_eq!(contents, vec!["A".to_string(), "B".to_string()], "both files still exist once: {:?}", fs.listing()); + // the share answers again: undoing whatever is left brings both home + fs.fail.borrow_mut().clear(); + while s.undo_step_with(&fs).is_ok() && fs.listing().iter().any(|(p, _)| p.starts_with("/share")) {} + assert_eq!(fs.listing(), vec![("/local/a.jpg".into(), "A".into()), ("/local/b.jpg".into(), "B".into())]); + assert_linked(&s, &fs, &[]); +} + +/// Where this machine has a second file system to write to (Linux's `/dev/shm`), a real move +/// across devices: copied, checked, the original removed, and Undo copies it back. Skipped where +/// there is none (a network share can't be mounted in a test). +#[test] +fn a_move_across_devices_copies_checks_and_removes_and_undoes_the_same_way() { + let dir = Scratch::new("devices"); + let Some(other) = other_device(&dir.0) else { return }; + let share = other.join(format!("lc-share-other-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&share); + std::fs::create_dir_all(&share).unwrap(); + let mut s = Session::new(); + let body: String = (0..300_000).map(|i| char::from(b'a' + (i % 26) as u8)).collect(); + let a = add_file(&mut s, &dir.path("local/a.jpg"), &body); + let r = s.execute("photo.moveToFolder", &json!({"ids": [a.0], "folder": share.to_string_lossy()})); + let moved = + r.is_ok() && std::fs::read_to_string(share.join("a.jpg")).ok().as_deref() == Some(body.as_str()) && !dir.0.join("local/a.jpg").exists(); + let undone = moved + && s.execute("edit.undo", &json!({})).is_ok() + && std::fs::read_to_string(dir.0.join("local/a.jpg")).ok().as_deref() == Some(body.as_str()); + let gone_from_share = !share.join("a.jpg").exists(); + let _ = std::fs::remove_dir_all(&share); + assert!(moved, "moved across devices: {r:?}"); + assert!(undone, "and back"); + assert!(gone_from_share, "no copy left on the other device"); +} + +/// A writable folder on another device than `here`, if there is one. +fn other_device(here: &std::path::Path) -> Option { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let dev = std::fs::metadata(here).ok()?.dev(); + ["/dev/shm", "/run/shm"] + .iter() + .map(std::path::PathBuf::from) + .find(|p| { + std::fs::metadata(p).is_ok_and(|m| m.dev() != dev) + && std::fs::write(p.join(format!(".lc-probe-{}", std::process::id())), b"x").is_ok() + }) + .inspect(|p| { + let _ = std::fs::remove_file(p.join(format!(".lc-probe-{}", std::process::id()))); + }) + } + #[cfg(not(unix))] + { + let _ = here; + None + } +} + +#[test] +fn the_copy_across_volumes_is_whole_or_absent_and_never_replaces() { + use crate::rename::MoveFs; + let dir = Scratch::new("copy"); + let body: Vec = (0..(3usize << 20) + 17).map(|i| (i % 251) as u8).collect(); + let (from, to) = (dir.0.join("local/big.raw"), dir.0.join("share/big.raw")); + std::fs::write(&from, &body).unwrap(); + crate::rename::RealFs.copy_no_replace(&from, &to).unwrap(); + assert_eq!(std::fs::read(&to).unwrap(), body); + // nothing left under a hidden name beside it + let left: Vec<_> = std::fs::read_dir(dir.0.join("share")).unwrap().flatten().map(|e| e.file_name()).collect(); + assert_eq!(left, vec![std::ffi::OsString::from("big.raw")], "{left:?}"); + // a partial left by a copy the share cut off doesn't block the next copy + let stale = dir.0.join(format!("share/.again.raw.lc-partial-{}-0", std::process::id())); + std::fs::write(&stale, b"cut off").unwrap(); + crate::rename::RealFs.copy_no_replace(&from, &dir.0.join("share/again.raw")).unwrap(); + assert_eq!(std::fs::read(dir.0.join("share/again.raw")).unwrap(), body); + std::fs::remove_file(&stale).unwrap(); + std::fs::remove_file(dir.0.join("share/again.raw")).unwrap(); + // a copy that can't start (its source unreadable) leaves nothing at all under the photo's name + let gone = dir.0.join("local/missing.raw"); + assert!(crate::rename::RealFs.copy_no_replace(&gone, &dir.0.join("share/missing.raw")).is_err()); + assert!(!dir.0.join("share/missing.raw").exists()); + let left: Vec<_> = std::fs::read_dir(dir.0.join("share")).unwrap().flatten().map(|e| e.file_name()).collect(); + assert_eq!(left.len(), 1, "no partial file left: {left:?}"); + // a name taken is never replaced, and what is there stays + std::fs::write(&to, b"other").unwrap(); + assert!(crate::rename::RealFs.copy_no_replace(&from, &to).is_err()); + assert_eq!(std::fs::read(&to).unwrap(), b"other"); +} diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index dff2ddb75..3c61bb5e7 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -27,6 +27,7 @@ "All {n} selected photos move by the same amount.": "Alle {n} ausgewählten Fotos werden um denselben Betrag verschoben.", "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Bereits in der Bibliothek unter „Zuletzt gelöscht“: {} Foto{} ausgewählt · Rechtsklick → Wiederherstellen, oder endgültig löschen, um neu zu importieren", "Already in the library: {} photo{} selected in All Photos": "Bereits in der Bibliothek: {} Foto{} in „Alle Fotos“ ausgewählt", + "Already in “{}”": "Bereits in „{}“", "Auto ({})": "Automatisch ({})", "Auto Import: added {} photo{}": "Automatischer Import: {} Foto{} hinzugefügt", "Auto Sync: edits apply to {n} photos": "Automatische Synchronisierung: Bearbeitungen gelten für {n} Fotos", @@ -61,6 +62,9 @@ "Import cancelled · {} photo{} added": "Import abgebrochen · {} Foto{} hinzugefügt", "Import failed: {e}": "Import fehlgeschlagen: {e}", "Import warnings ({n}):\n{warnings}": "Importwarnungen ({n}):\n{warnings}", + "Move {n} photo{} to “{}”": "{n} Foto{0} nach „{1}“ verschieben", + "Move {n} photo{} to “{}”?": "{n} Foto{0} nach „{1}“ verschieben?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} Foto{0} nach „{1}“ verschoben. Bearbeiten → Rückgängig holt sie zurück.", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "XMP-Profil \"{profile}\" wurde für {path} nicht angewendet; Farben können von Lightroom abweichen.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Lightroom-Geometrie wurde für {path} nicht beibehalten; {mode} wird neu berechnet.", "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Transformation wird ignoriert, weil Upright deaktiviert ist.", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..6276cce40 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -53,6 +53,9 @@ "Add to Favorites": "Zu Favoriten hinzufügen", "Add to Local": "Zu „Lokal“ hinzufügen", "Add to My Photos": "Zu „Meine Fotos“ hinzufügen", + "Can't be moved here": "Hierher nicht verschiebbar", + "This row covers more than one disk: drop on a folder inside it": "Diese Zeile umfasst mehr als ein Laufwerk: auf einem Ordner darin ablegen", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Lokale Fotos sind noch nicht in der Bibliothek: erst zu „Meine Fotos“ hinzufügen, dann verschieben", "Add to Selected Photos": "Zu ausgewählten Fotos hinzufügen", "Add to Target Album": "Zu Zielalbum hinzufügen", "Add to this mask": "Zu dieser Maske hinzufügen", @@ -288,6 +291,12 @@ "Concrete Cool": "Kühler Beton", "Confirm before deleting": "Vor dem Löschen bestätigen", "Confirm before moving photos to Recently Deleted": "Vor dem Verschieben nach „Zuletzt gelöscht“ bestätigen", + "Move Photos": "Fotos verschieben", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Ihre Dateien und XMP-Filialdateien werden auf dem Datenträger verschoben. Bearbeiten → Rückgängig holt sie zurück.", + "Don't ask again": "Nicht mehr fragen", + "Another library was opened since the photos were dropped: nothing was moved": "Seit dem Ablegen der Fotos wurde eine andere Bibliothek geöffnet: Es wurde nichts verschoben", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Einstellungen → „Vor dem Verschieben in einen anderen Ordner auf dem Datenträger bestätigen“ schaltet es wieder ein", + "Confirm before moving photos to another folder on disk": "Vor dem Verschieben in einen anderen Ordner auf dem Datenträger bestätigen", "Constrain Crop": "Zuschnitt beschränken", "Continue": "Fortfahren", "Continue Without Saving": "Ohne Speichern fortfahren", diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 452fc6621..bb053c0ac 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -27,6 +27,7 @@ "All {n} selected photos move by the same amount.": "Las {n} fotos seleccionadas se desplazan la misma cantidad.", "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Ya en la biblioteca, en Eliminadas recientemente: {0} foto{1} seleccionada{1} · clic derecho → Restaurar, o Eliminar definitivamente para importar de nuevo", "Already in the library: {} photo{} selected in All Photos": "Ya en la biblioteca: {0} foto{1} seleccionada{1} en Todas las fotos", + "Already in “{}”": "Ya está en “{}”", "Auto ({})": "Automático ({})", "Auto Import: added {} photo{}": "Importación automática: {0} foto{1} añadida{1}", "Auto Sync: edits apply to {n} photos": "Sincronización automática: las ediciones se aplican a {n} fotos", @@ -61,6 +62,9 @@ "Import cancelled · {} photo{} added": "Importación cancelada · {0} foto{1} añadida{1}", "Import failed: {e}": "Error de importación: {e}", "Import warnings ({n}):\n{warnings}": "Avisos de importación ({n}):\n{warnings}", + "Move {n} photo{} to “{}”": "Mover {n} foto{0} a “{1}”", + "Move {n} photo{} to “{}”?": "¿Mover {n} foto{0} a “{1}”?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} a “{1}”. Editar → Deshacer las devuelve.", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "El perfil XMP \"{profile}\" no se aplicó a {path}; los colores pueden diferir de Lightroom.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "La geometría de Lightroom no se conservó para {path}; {mode} se recalculará.", "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "La geometría de Lightroom no se conservó para {path}; la transformación guardada se ignora porque Vertical está desactivado.", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..31f39c747 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -38,6 +38,9 @@ "Add to Favorites": "Añadir a Favoritos", "Add to Local": "Añadir a Local", "Add to My Photos": "Añadir a Mis fotos", + "Can't be moved here": "No se pueden mover aquí", + "This row covers more than one disk: drop on a folder inside it": "Esta fila abarca más de un disco: suéltalas en una carpeta dentro de ella", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Las fotos locales aún no están en la biblioteca: primero Añadir a Mis fotos y luego muévelas", "Add to Selected Photos": "Añadir a las fotos seleccionadas", "Add to Target Album": "Añadir al álbum de destino", "Add to this mask": "Añadir a esta máscara", @@ -268,6 +271,12 @@ "Concrete Cool": "Hormigón frío", "Confirm before deleting": "Confirmar antes de eliminar", "Confirm before moving photos to Recently Deleted": "Confirmar antes de mover fotos a Eliminadas recientemente", + "Move Photos": "Mover fotos", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Sus archivos y archivos XMP auxiliares se mueven en el disco. Editar → Deshacer los devuelve.", + "Don't ask again": "No volver a preguntar", + "Another library was opened since the photos were dropped: nothing was moved": "Se abrió otra biblioteca después de soltar las fotos: no se movió nada", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Ajustes → Confirmar antes de mover fotos a otra carpeta del disco lo vuelve a activar", + "Confirm before moving photos to another folder on disk": "Confirmar antes de mover fotos a otra carpeta del disco", "Constrain Crop": "Restringir recorte", "Continue": "Continuar", "Continue Without Saving": "Continuar sin guardar", diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index d85e837a3..89c3d0a4a 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -27,6 +27,7 @@ "All {n} selected photos move by the same amount.": "Les {n} photos sélectionnées se déplacent de la même valeur.", "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Déjà dans la bibliothèque, dans « Supprimés récemment » : {} photo{} sélectionnée(s) · clic droit → Restaurer, ou Supprimer définitivement pour importer à nouveau", "Already in the library: {} photo{} selected in All Photos": "Déjà dans la bibliothèque : {} photo{} sélectionnée(s) dans Toutes les photos", + "Already in “{}”": "Déjà dans « {} »", "Auto ({})": "Automatique ({})", "Auto Import: added {} photo{}": "Importation automatique : {} photo{} ajoutée(s)", "Auto Sync: edits apply to {n} photos": "Synchronisation automatique : les modifications s’appliquent à {n} photos", @@ -65,6 +66,9 @@ "Imported {n} preset{}": "{n} préréglage{} importé(s)", "Imported {n} preset{}, {f} file{} not readable": "{n} préréglage{} importé(s), {f} fichier{} illisible(s)", "Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles} profil{} importé(s) (Navigateur de profils → leurs groupes)", + "Move {n} photo{} to “{}”": "Déplacer {n} photo{} vers « {} »", + "Move {n} photo{} to “{}”?": "Déplacer {n} photo{} vers « {} » ?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} photo{} déplacée(s) vers « {} ». Modifier → Annuler les remet en place.", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Le profil XMP « {profile} » n’a pas été appliqué à {path} ; les couleurs peuvent différer de Lightroom.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "La géométrie Lightroom de {path} n’a pas été conservée ; {mode} sera recalculé.", "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "La géométrie Lightroom de {path} n’a pas été conservée ; la transformation enregistrée est ignorée, car Upright est désactivé.", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..909918d11 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -38,6 +38,9 @@ "Add to Favorites": "Ajouter aux favoris", "Add to Local": "Ajouter à Local", "Add to My Photos": "Ajouter à Mes photos", + "Can't be moved here": "Impossible de les déplacer ici", + "This row covers more than one disk: drop on a folder inside it": "Cette ligne couvre plusieurs disques : déposez-les sur un dossier qu’elle contient", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Les photos locales ne sont pas encore dans la bibliothèque : Ajouter à Mes photos d’abord, puis déplacez-les", "Add to Selected Photos": "Ajouter aux photos sélectionnées", "Add to Target Album": "Ajouter à l'album cible", "Add to this mask": "Ajouter à ce masque", @@ -262,6 +265,12 @@ "Concrete Cool": "Béton froid", "Confirm before deleting": "Confirmer avant de supprimer", "Confirm before moving photos to Recently Deleted": "Confirmer avant de déplacer des photos vers Supprimées récemment", + "Move Photos": "Déplacer les photos", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Leurs fichiers et fichiers annexes XMP sont déplacés sur le disque. Modifier → Annuler les remet en place.", + "Don't ask again": "Ne plus demander", + "Another library was opened since the photos were dropped: nothing was moved": "Une autre bibliothèque a été ouverte depuis le dépôt des photos : rien n’a été déplacé", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Réglages → Confirmer avant de déplacer des photos vers un autre dossier sur le disque le réactive", + "Confirm before moving photos to another folder on disk": "Confirmer avant de déplacer des photos vers un autre dossier sur le disque", "Constrain Crop": "Contraindre le recadrage", "Continue": "Continuer", "Continue Without Saving": "Continuer sans enregistrer", diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 211e47e29..a27c45666 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -13,6 +13,7 @@ "Added {n} photo{} to My Photos": "マイフォトに{n}枚を追加しました{:.0}", "Added {n} photo{} to “{}”": "「{1}」に{n}枚を追加しました{0:.0}", "All {n} selected photos move by the same amount.": "選択中の{n}枚を同じ時間だけずらします", + "Already in “{}”": "すでに「{}」にあります", "Auto Sync: edits apply to {n} photos": "自動同期:{n}枚に編集を適用", "Automatic ({} MB)": "自動({}MB)", "Click {}:1": "クリック時 {}:1", @@ -39,7 +40,10 @@ "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraftのウィンドウを開けませんでした: {e}\n\n詳しくはログファイルをご覧ください: {path}", "Matches: {}": "一致条件:{}", "Merging… {stage} {:.0}%": "合成中… {stage} {:.0}%", + "Move {n} photo{} to “{}”": "{n}枚を「{1}」に移動{0:.0}", + "Move {n} photo{} to “{}”?": "{n}枚を「{1}」に移動しますか?{0:.0}", "Move {what} to Recently Deleted?": "{what}を「最近削除した写真」に移動しますか?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n}枚を「{1}」に移動しました。編集 → 取り消す で元に戻せます。{0:.0}", "Named ({named_n})": "名前付き({named_n})", "Paste into {n} photo{} — only settings that were copied are pasted": "{n}枚に貼り付け{:.0}(コピーされた設定だけを適用)", "Preset: {name}": "プリセット:{name}", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..fa5e8d38c 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -39,6 +39,9 @@ "Add to Favorites": "お気に入りに追加", "Add to Local": "ローカルに追加", "Add to My Photos": "マイフォトに追加", + "Can't be moved here": "ここには移動できません", + "This row covers more than one disk: drop on a folder inside it": "この行は複数のディスクにまたがっています。中のフォルダにドロップしてください", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "ローカルの写真はまだライブラリにありません。先に「マイフォトに追加」してから移動してください", "Add to Selected Photos": "選択した写真に追加", "Add to Target Album": "追加先アルバムに追加", "Add to this mask": "このマスクに追加", @@ -258,6 +261,12 @@ "Concrete Cool": "クールコンクリート", "Confirm before deleting": "削除前に確認", "Confirm before moving photos to Recently Deleted": "「最近削除した写真」に移動する前に確認", + "Move Photos": "写真を移動", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "ファイルと XMP サイドカーがディスク上で移動します。編集 → 取り消す で元に戻せます。", + "Don't ask again": "今後確認しない", + "Another library was opened since the photos were dropped: nothing was moved": "写真をドロップした後に別のライブラリが開かれました。何も移動していません", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "設定 → 「ディスク上の別のフォルダに移動する前に確認」で元に戻せます", + "Confirm before moving photos to another folder on disk": "ディスク上の別のフォルダに移動する前に確認", "Constrain Crop": "切り抜きを制限", "Continue": "続行", "Contrast": "コントラスト", diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index e81969e0f..ab97f7ec5 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -17,6 +17,7 @@ "All {n} selected photos move by the same amount.": "Todas as {n} fotos selecionadas se movem pela mesma quantidade.", "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Já na biblioteca, em Excluídos recentemente: {0} foto{1} selecionada{1} · clique com o botão direito → Restaurar, ou Excluir permanentemente para importar de novo", "Already in the library: {} photo{} selected in All Photos": "Já na biblioteca: {0} foto{1} selecionada{1} em Todas as fotos", + "Already in “{}”": "Já está em “{}”", "Auto ({})": "Automático ({})", "Auto Sync: edits apply to {n} photos": "Sincronização automática: edições aplicadas a {n} fotos", "Automatic ({} MB)": "Automático ({} MB)", @@ -56,7 +57,10 @@ "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "O LightCraft não conseguiu abrir sua janela: {e}\n\nO arquivo de log tem os detalhes: {path}", "Matches: {}": "Correspondências: {}", "Merging… {stage} {:.0}%": "Mesclando… {stage} {:.0}%", + "Move {n} photo{} to “{}”": "Mover {n} foto{0} para “{1}”", + "Move {n} photo{} to “{}”?": "Mover {n} foto{0} para “{1}”?", "Move {what} to Recently Deleted?": "Mover {what} para Excluídos recentemente?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} para “{1}”. Editar → Desfazer as traz de volta.", "Named ({named_n})": "Nomeadas ({named_n})", "Paste into {n} photo{} — only settings that were copied are pasted": "Colar em {n} foto{} — somente os ajustes copiados são colados", "Preset: {name}": "Predefinição: {name}", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f8e4753e5..3d577d9b1 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -40,6 +40,9 @@ "Add to Favorites": "Adicionar aos favoritos", "Add to Local": "Adicionar a Local", "Add to My Photos": "Adicionar às Minhas fotos", + "Can't be moved here": "Não podem ser movidas para cá", + "This row covers more than one disk: drop on a folder inside it": "Esta linha abrange mais de um disco: solte em uma pasta dentro dela", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "As fotos locais ainda não estão na biblioteca: primeiro Adicionar às Minhas fotos, depois mova-as", "Add to Selected Photos": "Adicionar às fotos selecionadas", "Add to Target Album": "Adicionar ao álbum de destino", "Add to this mask": "Adicionar a esta máscara", @@ -270,6 +273,12 @@ "Concrete Cool": "Concreto frio", "Confirm before deleting": "Confirmar antes de excluir", "Confirm before moving photos to Recently Deleted": "Confirmar antes de mover as fotos para Excluídos recentemente", + "Move Photos": "Mover fotos", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Os arquivos e os arquivos XMP auxiliares são movidos no disco. Editar → Desfazer os traz de volta.", + "Don't ask again": "Não perguntar novamente", + "Another library was opened since the photos were dropped: nothing was moved": "Outra biblioteca foi aberta depois que as fotos foram soltas: nada foi movido", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Ajustes → Confirmar antes de mover fotos para outra pasta no disco o reativa", + "Confirm before moving photos to another folder on disk": "Confirmar antes de mover fotos para outra pasta no disco", "Constrain Crop": "Restringir recorte", "Continue": "Continuar", "Continue Without Saving": "Continuar sem salvar", diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 38e28e356..27170aa6c 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -29,6 +29,7 @@ "All {n} selected photos move by the same amount.": "Все выбранные фото ({n}) сдвигаются на одно и то же значение.", "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Уже в библиотеке, в «Недавно удалённых»: выбрано фото: {}{:.0} · щёлкните правой кнопкой → Восстановить или Удалить навсегда, чтобы импортировать заново", "Already in the library: {} photo{} selected in All Photos": "Уже в библиотеке: выбрано фото в разделе «Все фото»: {}{:.0}", + "Already in “{}”": "Уже в «{}»", "Auto ({})": "Авто ({})", "Auto Import: added {} photo{}": "Автоимпорт: добавлено фото: {}{:.0}", "Auto Sync: edits apply to {n} photos": "Автосинхронизация: правки применяются к фото: {n}", @@ -64,6 +65,9 @@ "Import cancelled · {} photo{} added": "Импорт отменён · добавлено фото: {}{:.0}", "Import failed: {e}": "Ошибка импорта: {e}", "Import warnings ({n}):\n{warnings}": "Предупреждения импорта ({n}):\n{warnings}", + "Move {n} photo{} to “{}”": "Переместить в «{1}» фото: {n}{0:.0}", + "Move {n} photo{} to “{}”?": "Переместить в «{1}» фото: {n}{0:.0}?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "В «{1}» перемещено фото: {n}{0:.0}. Правка → Отменить вернёт их.", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Профиль XMP \"{profile}\" не применён к {path}; цвета могут отличаться от Lightroom.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Геометрия Lightroom для {path} не сохранена; режим {mode} будет пересчитан.", "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Геометрия Lightroom для {path} не сохранена; сохранённое преобразование игнорируется, поскольку Upright выключен.", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..b6f7a2e11 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -23,6 +23,9 @@ "Add to Album": "Добавить в альбом", "Add to Favorites": "Добавить в избранное", "Add to My Photos": "Добавить в «Мои фото»", + "Can't be moved here": "Сюда переместить нельзя", + "This row covers more than one disk: drop on a folder inside it": "Эта строка охватывает несколько дисков: перетащите на папку внутри неё", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Локальных фото ещё нет в библиотеке: сначала «Добавить в «Мои фото»», затем перемещайте", "Add to Selected Photos": "Добавить к выбранным фото", "Add to Target Album": "Добавить в целевой альбом", "Add to this mask": "Добавить к этой маске", @@ -221,6 +224,12 @@ "Concrete Cool": "Холодный бетон", "Confirm before deleting": "Подтверждать удаление", "Confirm before moving photos to Recently Deleted": "Подтверждать перемещение фото в «Недавно удалённые»", + "Move Photos": "Перемещение фото", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Их файлы и XMP-файлы перемещаются на диске. Правка → Отменить вернёт их.", + "Don't ask again": "Больше не спрашивать", + "Another library was opened since the photos were dropped: nothing was moved": "После перетаскивания фото была открыта другая библиотека: ничего не перемещено", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Настройки → «Подтверждать перемещение фото в другую папку на диске» включает его снова", + "Confirm before moving photos to another folder on disk": "Подтверждать перемещение фото в другую папку на диске", "Constrain Crop": "Ограничить кадрирование", "Continue Without Saving": "Продолжить без сохранения", "Contrast": "Контрастность", diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 55636422f..91eaeacc3 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -27,6 +27,7 @@ "All {n} selected photos move by the same amount.": "Усі вибрані фото ({n}) зміщуються на однакове значення.", "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Уже в бібліотеці, у нещодавно видалених: вибрано фото: {}{:.0} · права кнопка → Відновити або Видалити назавжди, щоб імпортувати заново", "Already in the library: {} photo{} selected in All Photos": "Уже в бібліотеці: вибрано фото в усіх фото: {}{:.0}", + "Already in “{}”": "Уже в «{}»", "Auto ({})": "Автоматично ({})", "Auto Import: added {} photo{}": "Автоімпорт: додано фото: {}{:.0}", "Auto Sync: edits apply to {n} photos": "Автосинхронізація: зміни застосовуються до фото: {n}", @@ -65,6 +66,9 @@ "Imported {n} preset{}": "Імпортовано наборів налаштувань: {n}{:.0}", "Imported {n} preset{}, {f} file{} not readable": "Імпортовано наборів налаштувань: {n}{:.0}, файлів не читається: {f}{:.0}", "Imported {profiles} profile{} (Profile browser ▸ their groups)": "Імпортовано профілів: {profiles}{:.0} (Оглядач профілів → їхні групи)", + "Move {n} photo{} to “{}”": "Перемістити до «{1}» фото: {n}{0:.0}", + "Move {n} photo{} to “{}”?": "Перемістити до «{1}» фото: {n}{0:.0}?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "До «{1}» переміщено фото: {n}{0:.0}. Редагування → Скасувати поверне їх.", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Профіль XMP «{profile}» не застосовано до {path}; кольори можуть відрізнятися від Lightroom.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Геометрію Lightroom для {path} не збережено; режим {mode} буде перераховано.", "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Геометрію Lightroom для {path} не збережено; збережене перетворення ігнорується, оскільки Upright вимкнено.", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..5b1c7443f 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -376,6 +376,9 @@ "Add to Favorites": "Додати до улюблених", "Add to Local": "Додати до локальних", "Add to My Photos": "Додати до моїх фото", + "Can't be moved here": "Сюди перемістити не можна", + "This row covers more than one disk: drop on a folder inside it": "Цей рядок охоплює кілька дисків: перетягніть на теку всередині нього", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Локальних фото ще немає в бібліотеці: спершу «Додати до моїх фото», потім переміщуйте", "Add to Selected Photos": "Додати до вибраних фото", "Add to Target Album": "Додати до цільового альбому", "Add to this mask": "Додати до цієї маски", @@ -547,6 +550,12 @@ "Concrete Cool": "Холодний бетон", "Confirm before deleting": "Підтверджувати перед видаленням", "Confirm before moving photos to Recently Deleted": "Підтверджувати перед переміщенням фото до нещодавно видалених", + "Move Photos": "Переміщення фото", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Їхні файли та XMP-файли переміщуються на диску. Редагування → Скасувати поверне їх.", + "Don't ask again": "Більше не питати", + "Another library was opened since the photos were dropped: nothing was moved": "Після перетягування фото було відкрито іншу бібліотеку: нічого не переміщено", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Налаштування → «Підтверджувати перед переміщенням фото до іншої теки на диску» вмикає його знову", + "Confirm before moving photos to another folder on disk": "Підтверджувати перед переміщенням фото до іншої теки на диску", "Constrain Crop": "Обмежити кадрування", "Continue": "Продовжити", "Convert to DNG": "Перетворити на DNG", diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 782f8d731..ae4d75cd4 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -13,6 +13,7 @@ "Added {n} photo{} to My Photos": "已将 {n} 张照片添加到我的照片{0:.0}", "Added {n} photo{} to “{}”": "已将 {n} 张照片添加到“{1}”{0:.0}", "All {n} selected photos move by the same amount.": "所选 {n} 张照片将按相同幅度移动。", + "Already in “{}”": "已在“{}”中", "Auto Sync: edits apply to {n} photos": "自动同步:编辑将应用到 {n} 张照片", "Automatic ({} MB)": "自动({0} MB)", "Click {}:1": "单击 {0}:1", @@ -39,7 +40,10 @@ "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft 无法打开窗口:{e}\n\n详细信息见日志文件:{path}", "Matches: {}": "匹配项:{0}", "Merging… {stage} {:.0}%": "合成中… {stage} {0:.0}%", + "Move {n} photo{} to “{}”": "将 {n} 张照片移到“{1}”{0:.0}", + "Move {n} photo{} to “{}”?": "将 {n} 张照片移到“{1}”?{0:.0}", "Move {what} to Recently Deleted?": "将 {what} 移到最近删除?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "已将 {n} 张照片移到“{1}”{0:.0}。编辑 → 撤销 可移回。", "Named ({named_n})": "已命名({named_n})", "Paste into {n} photo{} — only settings that were copied are pasted": "粘贴到 {n} 张照片 — 仅粘贴已复制的设置{0:.0}", "Preset: {name}": "预设:{name}", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..41f470bb1 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -40,6 +40,9 @@ "Add to Favorites": "添加到收藏夹", "Add to Local": "添加到本地", "Add to My Photos": "添加到我的照片", + "Can't be moved here": "无法移到这里", + "This row covers more than one disk: drop on a folder inside it": "此行涵盖多个磁盘:请拖放到其中的文件夹", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "本地照片尚未在图库中:请先“添加到我的照片”,再移动", "Add to Selected Photos": "添加到所选照片", "Add to Target Album": "添加到目标相册", "Add to this mask": "添加到此蒙版", @@ -268,6 +271,12 @@ "Concrete Cool": "冷调混凝土", "Confirm before deleting": "删除前确认", "Confirm before moving photos to Recently Deleted": "将照片移到“最近删除”前确认", + "Move Photos": "移动照片", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "它们的文件和 XMP 附属文件会在磁盘上移动。编辑 → 撤销 可移回。", + "Don't ask again": "不再询问", + "Another library was opened since the photos were dropped: nothing was moved": "拖放照片后打开了另一个图库:未移动任何内容", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "设置 → “将照片移到磁盘上其他文件夹前确认”可重新开启", + "Confirm before moving photos to another folder on disk": "将照片移到磁盘上其他文件夹前确认", "Constrain Crop": "限制裁剪", "Continue": "继续", "Continue Without Saving": "不保存继续", diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 56db559c6..6adf54e47 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -14,6 +14,7 @@ "Added {n} photo{} to “{}”": "已將 {n} 張照片加入「{1}」{0:.0}", "Adding photos… {} of {}": "正在新增照片… {}/{}", "All {n} selected photos move by the same amount.": "所有 {n} 張選取的照片會調整相同的時間。", + "Already in “{}”": "已在「{}」中", "Auto ({})": "自動({})", "Auto Sync: edits apply to {n} photos": "自動同步:編輯會套用至 {n} 張照片", "Automatic ({} MB)": "自動({} MB)", @@ -53,7 +54,10 @@ "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft 無法開啟視窗:{e}\n\n詳細資訊請見記錄檔:{path}", "Matches: {}": "符合條件:{}", "Merging… {stage} {:.0}%": "正在合併… {stage} {:.0}%", + "Move {n} photo{} to “{}”": "將 {n} 張照片移到「{1}」{0:.0}", + "Move {n} photo{} to “{}”?": "將 {n} 張照片移到「{1}」?{0:.0}", "Move {what} to Recently Deleted?": "要將{what}移至「最近刪除」嗎?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "已將 {n} 張照片移到「{1}」{0:.0}。編輯 → 復原 可移回。", "Named ({named_n})": "已命名({named_n})", "Paste into {n} photo{} — only settings that were copied are pasted": "貼上至 {n} 張照片{:.0} — 只貼上已複製的設定", "Preset: {name}": "預設集:{name}", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..b08c03662 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -40,6 +40,9 @@ "Add to Favorites": "加入喜好項目", "Add to Local": "加入本機", "Add to My Photos": "加入我的照片", + "Can't be moved here": "無法移到這裡", + "This row covers more than one disk: drop on a folder inside it": "此列涵蓋多個磁碟:請拖放到其中的資料夾", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "本機照片尚未在圖庫中:請先「加入我的照片」,再移動", "Add to Selected Photos": "加入選取的照片", "Add to Target Album": "加入目標相簿", "Add to this mask": "加入此遮色片", @@ -268,6 +271,12 @@ "Concrete Cool": "冷調混凝土", "Confirm before deleting": "刪除前確認", "Confirm before moving photos to Recently Deleted": "將照片移至「最近刪除」前確認", + "Move Photos": "移動照片", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "它們的檔案和 XMP 附屬檔案會在磁碟上移動。編輯 → 復原 可移回。", + "Don't ask again": "不再詢問", + "Another library was opened since the photos were dropped: nothing was moved": "拖放照片後開啟了另一個圖庫:未移動任何內容", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "設定 → 「將照片移至磁碟上其他資料夾前確認」可重新開啟", + "Confirm before moving photos to another folder on disk": "將照片移至磁碟上其他資料夾前確認", "Constrain Crop": "限制裁切範圍", "Continue": "繼續", "Continue Without Saving": "繼續使用但不儲存", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..a84eb6c15 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -69,6 +69,8 @@ mod tests_error_kinds; #[cfg(test)] mod tests_filmstrip; #[cfg(test)] +mod tests_folder_drop; +#[cfg(test)] mod tests_grading; #[cfg(test)] mod tests_grid; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index a79261fdc..fe13ec8b4 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -254,6 +254,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Merge { opts } => opts.title(), Dialog::Settings { .. } => "Settings", Dialog::ConfirmDelete { .. } => "Delete Photos", + Dialog::MovePhotos { .. } => "Move Photos", Dialog::LightroomCatalog { .. } => "Lightroom Classic Catalog", Dialog::RemoveFolder { disk: true, .. } => "Remove Disk from Library", Dialog::RemoveFolder { .. } => "Remove Folder from Library", @@ -1118,6 +1119,15 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what)); ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim)); } + Dialog::MovePhotos { ids, name, dont_ask, .. } => { + let n = ids.len(); + ui.label(crate::i18n::tr_format!("Move {n} photo{} to “{}”?", if n == 1 { "" } else { "s" }, name, n = n)); + ui.label(egui::RichText::new(crate::i18n::tr("Their files and XMP sidecars move on disk. Edit → Undo moves them back.")).color(t.text_dim)); + ui.add_space(4.0); + let c = ui.checkbox(dont_ask, crate::i18n::tr("Don't ask again")); + crate::widgets::register(ui.ctx(), "check:moveDontAsk", c.rect); + c.on_hover_text(crate::i18n::tr("Settings → Confirm before moving photos to another folder on disk turns it back on")); + } d @ Dialog::SynchronizeFolder { .. } => crate::sync::body(app, ui, d), Dialog::LightroomCatalog { folder, catalog } => { ui.label(crate::i18n::tr("This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?")); @@ -1234,6 +1244,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::FaceModel { info, .. } if !informational && info["download"].is_string() => "Download", Dialog::FaceModel { .. } if !informational => "Install", Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } => "Delete", + Dialog::MovePhotos { .. } => "Move", // (not "Merge": that is photo merging, HDR / panorama, in some languages) Dialog::MoveKeyword { .. } => "Merge Keywords", Dialog::KeywordSet { .. } => "Save", @@ -1322,6 +1333,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { crate::panels::grid::toast_skipped(app, ctx, &r); close = true; } + // a move says how it went, or why nothing moved + Ok(r) if matches!(dlg, Dialog::MovePhotos { .. }) => { + if let Dialog::MovePhotos { name, .. } = &dlg { + let text = crate::panels::left::moved_text(r["moved"].as_u64().unwrap_or(0), name); + app.toast(ctx, text); + } + close = true; + } + Err(e) if matches!(dlg, Dialog::MovePhotos { .. }) => { + app.toast(ctx, e); + close = true; + } Ok(_) if keeps_open(app, &dlg) => { if let Dialog::SamModel { error, .. } = &mut dlg { *error = None; @@ -1722,6 +1745,17 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result crate::merge::start_final(app, opts), Dialog::Import { opts } => crate::import::start(app, opts), Dialog::ConfirmDelete { .. } => app.run("photo.delete", json!({})), + Dialog::MovePhotos { ids, folder, dont_ask, library, .. } => { + // another library opened meanwhile (from the menu bar, an agent): these numbers are + // other photos there + if *library != app.session.library_generation() { + return Err(crate::i18n::tr("Another library was opened since the photos were dropped: nothing was moved").to_string()); + } + if *dont_ask { + app.ui.settings.ask_before_move = false; + } + crate::panels::left::move_photos(app, Some(ids), folder).map(|n| json!({"moved": n})) + } // the catalog found: no file dialog; none: File ▸ Import Lightroom Catalog… asks for it Dialog::LightroomCatalog { catalog, .. } => { app.run("file.importLightroom", catalog.as_ref().map_or_else(|| json!({}), |c| json!({"path": c}))) diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 52edfa460..216c36f61 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -856,6 +856,13 @@ fn folder_breadcrumbs(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str) { /// While photos are dragged: a badge at the pointer; the drag ends when the button is up /// (drop targets act on the release frame, before this runs). +/// What the label following dragged photos says this frame instead of how many there are: where a +/// drop would put them (set by the drop target under the pointer, read by [`drag_feedback`]). +pub fn drag_hint(ctx: &egui::Context, text: String) { + let frame = ctx.cumulative_frame_nr(); + ctx.data_mut(|d| d.insert_temp(egui::Id::new("drag-photos-hint"), (frame, text))); +} + pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(ids) = &app.ui.dragging_photos else { return }; let (released, down, pos) = ctx.input(|i| (i.pointer.any_released(), i.pointer.any_down(), i.pointer.latest_pos())); @@ -867,13 +874,14 @@ pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let n = ids.len(); ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + // a drop target under the pointer says where they would go (only this frame's) + let frame = ctx.cumulative_frame_nr(); + let hint = ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("drag-photos-hint"))).filter(|(f, _)| *f == frame).map(|(_, text)| text); + let text = hint.unwrap_or_else(|| crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)); egui::Area::new(egui::Id::new("drag-photos")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show(ctx, |ui| { egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { - ui.label( - egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)) - .color(Color32::WHITE) - .font(t.semibold(12.0)), - ); + let label = ui.add(egui::Label::new(egui::RichText::new(text).color(Color32::WHITE).font(t.semibold(12.0))).extend()); + crate::widgets::register(ui.ctx(), "dragPhotos", label.rect); }); }); } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index fcaab2926..495b99b02 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1771,6 +1771,7 @@ fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &s }; let mark = n.label.map(crate::theme::label_color); let resp = row_sensed(app, ui, id, Icon::Folder, name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark); + folder_drop_target(app, ui, &resp, n, open_id, open); let mut toggled = false; if !n.children.is_empty() { let tr = tree_toggle(ui, &resp, indent, open_id, open, format!("libraryFolderToggle:{}", n.path)); @@ -1792,6 +1793,134 @@ fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &s row_menu(app, &resp, n); } +/// How long photos dragged over a closed Folders row wait before it opens, so a folder inside it +/// can be reached. +const SPRING_OPEN_SECONDS: f64 = 0.7; + +/// A Folders row while photos are dragged from the grid: a release moves their files there on +/// disk (`photo.moveToFolder`, one undo step). The row lights up and the dragged label says where +/// they will go, as the same gesture onto an album only adds them to it. Held over a closed row, +/// it opens. Photos that can't go there leave it dark (a row covering several disks; Local photos, +/// not in the library) and a release says why; photos already there say so. +fn folder_drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, n: &FolderNode, open_id: egui::Id, open: &mut bool) { + let Some(ids) = app.ui.dragging_photos.clone() else { return }; + // only where the row can be seen: not scrolled out of the sidebar or beneath the pinned header + if !pointer_over_row(ui, resp.rect) { + return; + } + spring_open(ui, n, open_id, open); + let name = std::path::Path::new(&n.path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| n.name.clone()); + let photos: Vec<_> = ids.iter().filter_map(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(*id))).collect(); + let refused = if !n.selectable { + Some(crate::i18n::tr("This row covers more than one disk: drop on a folder inside it")) + } else if photos.iter().any(|p| p.local) { + Some(crate::i18n::tr("Local photos aren't in the library yet: Add to My Photos first, then move them")) + } else { + None + }; + let here = |p: &lightcraft_catalog::Photo| match &p.source { + lightcraft_catalog::Source::File { path } => std::path::Path::new(path).parent().is_some_and(|d| same_folder(&d.to_string_lossy(), &n.path)), + _ => false, + }; + let all_here = !photos.is_empty() && photos.iter().all(|p| here(p)); + let count = photos.len(); + let released = ui.input(|i| i.pointer.any_released()); + let hint = match (&refused, all_here) { + (Some(_), _) => crate::i18n::tr("Can't be moved here").to_string(), + (None, true) => crate::i18n::tr_format!("Already in “{}”", name), + (None, false) => crate::i18n::tr_format!("Move {n} photo{} to “{}”", if count == 1 { "" } else { "s" }, name, n = count), + }; + super::grid::drag_hint(ui.ctx(), hint); + if let Some(why) = refused { + ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed); + if released { + app.toast(ui.ctx(), why); + app.ui.dragging_photos = None; + } + return; + } + if all_here { + if released { + app.ui.dragging_photos = None; + } + return; + } + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + if released { + app.ui.dragging_photos = None; + // a drop moves files on disk: asked first (as Lightroom Classic does), unless the person + // said not to; the menu's Move Selected Photos Here is asked for by name and goes at once + if app.ui.settings.ask_before_move { + let library = app.session.library_generation(); + app.ui.dialog = Some(crate::state::Dialog::MovePhotos { ids, folder: n.path.clone(), name, dont_ask: false, library }); + } else { + move_photos_here(app, ui.ctx(), Some(&ids), &n.path, &name); + } + } +} + +/// Photos dragged over the closed row `n` for [`SPRING_OPEN_SECONDS`] open it (a row with folders +/// inside it). Restarts on each row the pointer comes to, and when it comes back to one after +/// leaving it (or after another drag). +fn spring_open(ui: &egui::Ui, n: &FolderNode, open_id: egui::Id, open: &mut bool) { + if *open || n.children.is_empty() { + return; + } + let key = egui::Id::new("folder-spring-open"); + let (now, frame) = (ui.input(|i| i.time), ui.ctx().cumulative_frame_nr()); + // held without a break: over this row last frame too + let since = match ui.data(|d| d.get_temp::<(String, f64, u64)>(key)) { + Some((path, since, seen)) if path == n.path && seen.saturating_add(1) >= frame => since, + _ => now, + }; + ui.data_mut(|d| d.insert_temp(key, (n.path.clone(), since, frame))); + if now - since >= SPRING_OPEN_SECONDS { + *open = true; + set_tree_open(ui, open_id, true); + ui.data_mut(|d| d.remove::<(String, f64, u64)>(key)); + } else { + // keep the clock running while the pointer rests + ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + } +} + +/// Move photos (`ids`, else the selection) into the folder `path` on disk (`photo.moveToFolder`). +/// Photos that leave the view leave the selection, so nothing done next (Delete, an edit) reaches +/// photos no longer on screen. How many moved, or why none did. +pub(crate) fn move_photos(app: &mut LightcraftApp, ids: Option<&[u64]>, path: &str) -> Result { + let mut params = json!({"folder": path}); + if let Some(ids) = ids { + params["ids"] = json!(ids); + } + let r = app.run("photo.moveToFolder", params)?; + let visible: std::collections::HashSet = app.session.visible_cloned().into_iter().collect(); + let sel = &mut app.session.selection; + sel.ids.retain(|id| visible.contains(id)); + if sel.active.is_some_and(|a| !visible.contains(&a)) { + sel.active = sel.ids.first().copied(); + } + Ok(r["moved"].as_u64().unwrap_or(0)) +} + +/// What a move of `n` photos into the folder `name` says: how many moved and that Undo moves them +/// back, or that they were there already. +pub(crate) fn moved_text(n: u64, name: &str) -> String { + if n == 0 { + crate::i18n::tr_format!("Already in “{}”", name) + } else { + crate::i18n::tr_format!("Moved {n} photo{} to “{}”. Edit → Undo moves them back.", if n == 1 { "" } else { "s" }, name, n = n) + } +} + +/// [`move_photos`], saying how it went in a toast. +fn move_photos_here(app: &mut LightcraftApp, ctx: &egui::Context, ids: Option<&[u64]>, path: &str, name: &str) { + match move_photos(app, ids, path) { + Ok(n) => app.toast(ctx, moved_text(n, name)), + Err(e) => app.toast(ctx, e), + } +} + /// The context menu of a Folders row: a folder's own, a disk's (remove it), none for the /// startup disk. fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { @@ -1937,10 +2066,7 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F .on_hover_text(crate::i18n::tr("Moves the files and their XMP sidecars into this folder; never overwrites")) .clicked() { - match app.run("photo.moveToFolder", json!({"folder": path})) { - Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved {} photo(s)", r["moved"])), - Err(e) => app.toast(ui.ctx(), e), - } + move_photos_here(app, ui.ctx(), None, path, &name); ui.close(); } if app.services.reveal.is_some() diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index b95b55b19..a734f6f1c 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -142,6 +142,7 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { heading(ui, t, crate::i18n::tr("Culling")); check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging"); check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted"); + check(ui, "settings.askBeforeMove", &mut app.ui.settings.ask_before_move, "Confirm before moving photos to another folder on disk"); heading(ui, t, crate::i18n::tr("External Editing")); row(ui, t, crate::i18n::tr("PhotoCraft"), |ui| { crate::text_field::TextField::singleline("field:photocraftPath", &mut app.ui.settings.photocraft_path) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index cc583e7b3..948269738 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -246,6 +246,9 @@ pub struct AppSettings { /// `confirmDelete: false` by default (#1239). #[serde(rename = "askBeforeDelete")] pub confirm_delete: bool, + /// Ask before photos dropped on a Folders row move there on disk (the dialog's "Don't ask + /// again" turns it off). + pub ask_before_move: bool, /// GPU rendering allowed (`app.gpu`). pub gpu: bool, /// Largest long edge (pixels) the user lets the loupe render at; 0 = Automatic (the size it is @@ -292,6 +295,7 @@ impl Default for AppSettings { recent_libraries: Vec::new(), startup_view: StartupView::Last, confirm_delete: true, + ask_before_move: true, gpu: true, preview_limit: 0, external_editor: String::new(), @@ -990,6 +994,17 @@ pub enum Dialog { ConfirmDelete { count: usize, }, + /// Confirm moving photos dropped on a Folders row into that folder on disk + /// (`photo.moveToFolder`). `name`: the folder's name, as the question says it. + MovePhotos { + ids: Vec, + folder: String, + name: String, + dont_ask: bool, + /// The library the photos were dropped in (`Session::library_generation`): ids are + /// numbered per library, so in another one they name other photos. + library: u64, + }, /// Confirm taking a folder's photos out of the library (`library.removeFolder`). RemoveFolder { path: String, diff --git a/crates/ui-egui/src/tests_folder_drop.rs b/crates/ui-egui/src/tests_folder_drop.rs new file mode 100644 index 000000000..9b4cb860a --- /dev/null +++ b/crates/ui-egui/src/tests_folder_drop.rs @@ -0,0 +1,343 @@ +//! Photos dragged from the grid onto a Folders row move there on disk: the row lights up, the +//! dragged label says where they will go, a closed folder held under the pointer opens, and a +//! release asks first (unless the person said not to), then moves the files (one undo step). +//! Photos that can't go there, or are there already, say so and stay where they are. + +use std::path::{Path, PathBuf}; + +use egui::{Pos2, Rect}; +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +/// Folders `trip` and `home` in a fresh temporary folder, with a photo file in each. +fn folders(tag: &str) -> (PathBuf, PathBuf, PathBuf) { + let root = std::env::temp_dir().join(format!("lc-folder-drop-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let (trip, home) = (root.join("trip"), root.join("home")); + for (dir, file) in [(&trip, "beach.png"), (&home, "garden.png")] { + std::fs::create_dir_all(dir).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 120, 160, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join(file), png).unwrap(); + } + (root, trip, home) +} + +/// The photos at `paths` in the catalog (`local`: browsed, not in the library), in the grid with +/// the sidebar out, tall enough for the Folders section. +fn app(paths: &[(PathBuf, bool)]) -> Headless { + use lightcraft_catalog::{Op, Photo, Source}; + let mut session = lightcraft_engine::Session::new().with_fs(); + for (path, local) in paths { + let id = session.catalog.alloc_photo_id(); + let name = path.file_name().unwrap().to_string_lossy().to_string(); + let mut p = Photo::new(id, Source::File { path: path.to_string_lossy().into() }, &name, "PNG", 8, 8, "2026-01-01T10:00:00"); + p.local = *local; + session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + let mut h = Headless::new(LightcraftApp::new(session, Services { png: None, ..Default::default() }), [1300.0, 1600.0], 1.0); + let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true, "sidebar": ["folders"]}), std::time::Duration::from_secs(10)); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..6 { + h.step(); + } + h +} + +fn widget(h: &Headless, id: &str) -> Option { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +fn folder_row(h: &Headless, dir: &Path) -> Option { + widget(h, &format!("source:libfolder:{}", dir.to_string_lossy())) +} + +fn id_of(h: &Headless, file: &str) -> u64 { + h.app.session.catalog.photos().find(|p| p.file_name == file).map(|p| p.id.0).unwrap() +} + +fn press(h: &mut Headless, at: Pos2) { + h.app.synthetic.push(egui::Event::PointerMoved(at)); + h.app.synthetic.push(egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); +} + +/// Move the pointer, pressed, from `from` to `to` over a few frames. +fn glide(h: &mut Headless, from: Pos2, to: Pos2) { + for i in 1..=8 { + h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } +} + +fn release(h: &mut Headless, at: Pos2) { + h.app.synthetic.push(egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.step(); +} + +/// What the dragged label says this frame instead of the count, if a drop target set it. +fn hint(h: &Headless) -> Option { + h.view.ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("drag-photos-hint"))).map(|(_, text)| text) +} + +/// Click a widget the way a person does (press and release on it). +fn click(h: &mut Headless, id: &str) { + let at = widget(h, id).unwrap_or_else(|| panic!("no {id}")).center(); + press(h, at); + release(h, at); +} + +fn toast(h: &Headless) -> String { + h.app.ui.toast.as_ref().map(|(t, _, _)| t.clone()).unwrap_or_default() +} + +#[test] +fn photos_dropped_on_a_folder_move_there_on_disk() { + let (root, trip, home) = folders("move"); + let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); + let beach = id_of(&h, "beach.png"); + // the two folders sit in one that starts closed: only it is there to drop on + let parent = folder_row(&h, &root).unwrap_or_else(|| { + panic!("the folder holding both: {:?}", h.app.widgets.iter().filter(|(w, _)| w.starts_with("source:")).collect::>()) + }); + assert!(folder_row(&h, &home).is_none(), "closed"); + // Given the beach photo dragged from the grid and held over the closed folder + let from = widget(&h, &format!("thumb:{beach}")).unwrap().center(); + press(&mut h, from); + glide(&mut h, from, parent.center()); + assert_eq!(h.app.ui.dragging_photos, Some(vec![beach])); + for _ in 0..60 { + h.step(); + } + // Then it opens, so a folder inside it can be reached + let target = folder_row(&h, &home).expect("held over, the folder opened"); + // When it is brought over home: the label says where it will go + glide(&mut h, parent.center(), target.center()); + assert_eq!(hint(&h).as_deref(), Some("Move 1 photo to “home”")); + // and released there: it asks first, nothing moved yet + release(&mut h, target.center()); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::MovePhotos { .. })), "{:?}", h.app.ui.dialog); + assert!(trip.join("beach.png").is_file(), "not before Move"); + h.step(); + click(&mut h, "button:dialogOk"); + // Then the file moved, the photo follows it, and the toast says Undo moves it back + assert!(home.join("beach.png").is_file() && !trip.join("beach.png").exists(), "moved on disk"); + let photo = h.app.session.catalog.photo(lightcraft_catalog::PhotoId(beach)).unwrap(); + assert!(matches!(&photo.source, lightcraft_catalog::Source::File { path } if Path::new(path) == home.join("beach.png"))); + assert!(toast(&h).starts_with("Moved 1 photo to “home”. Edit → Undo moves them back."), "{}", toast(&h)); + assert!(h.app.ui.dragging_photos.is_none(), "the drag ended"); + // and one Undo moves it back + h.app.run("edit.undo", json!({})).unwrap(); + assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "back where it was"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn local_photos_are_refused_with_the_reason() { + let (root, trip, home) = folders("local"); + // the garden photo imported; the beach one only looked at under Local, where the grid shows it + let mut h = app(&[(home.join("garden.png"), false)]); + let r = h.request( + "engine.execute", + json!({"command": "library.browse", "params": {"path": trip.to_string_lossy()}}), + std::time::Duration::from_secs(10), + ); + assert_eq!(r["ok"], true, "{r}"); + // (the folder is read in the background) + let shown = |h: &Headless| { + h.app.session.catalog.photos().any(|p| p.file_name == "beach.png") && h.app.widgets.iter().any(|(w, _)| w.starts_with("thumb:")) + }; + h.step_until(std::time::Duration::from_secs(20), |h| shown(h)); + let beach = id_of(&h, "beach.png"); + assert!(h.app.session.catalog.photo(lightcraft_catalog::PhotoId(beach)).unwrap().local); + let target = folder_row(&h, &home).expect("the imported folder"); + let from = widget(&h, &format!("thumb:{beach}")).unwrap().center(); + press(&mut h, from); + glide(&mut h, from, target.center()); + // Then the row says it can't take them, and a release says why + assert_eq!(hint(&h).as_deref(), Some("Can't be moved here")); + release(&mut h, target.center()); + assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "stays"); + assert!(toast(&h).contains("Add to My Photos"), "{}", toast(&h)); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn photos_already_in_the_folder_stay_put() { + let (root, trip, home) = folders("here"); + let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); + // open the folder holding both + let toggle = widget(&h, &format!("libraryFolderToggle:{}", root.to_string_lossy())).unwrap(); + h.app.synthetic.push(egui::Event::PointerMoved(toggle.center())); + h.app.synthetic.push(egui::Event::PointerButton { + pos: toggle.center(), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + release(&mut h, toggle.center()); + let target = folder_row(&h, &home).expect("opened"); + let garden = id_of(&h, "garden.png"); + h.app.ui.dragging_photos = Some(vec![garden]); + h.app.synthetic.push(egui::Event::PointerMoved(target.center())); + h.app.synthetic.push(egui::Event::PointerButton { + pos: target.center(), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + assert_eq!(hint(&h).as_deref(), Some("Already in “home”")); + let undo = h.app.session.undo.len(); + release(&mut h, target.center()); + assert!(home.join("garden.png").is_file()); + assert_eq!(h.app.session.undo.len(), undo, "nothing done"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn photos_that_leave_the_view_leave_the_selection() { + let (root, trip, home) = folders("selection"); + std::fs::copy(trip.join("beach.png"), trip.join("dunes.png")).unwrap(); + let mut h = app(&[(trip.join("beach.png"), false), (trip.join("dunes.png"), false), (home.join("garden.png"), false)]); + // shown by folder: trip's two photos, both selected + let r = h.request( + "engine.execute", + json!({"command": "library.source", "params": {"kind": "libraryFolder", "path": trip.to_string_lossy()}}), + std::time::Duration::from_secs(10), + ); + assert_eq!(r["ok"], true, "{r}"); + let (beach, dunes) = (id_of(&h, "beach.png"), id_of(&h, "dunes.png")); + h.app.session.selection = + lightcraft_engine::Selection { ids: vec![lightcraft_catalog::PhotoId(beach)], active: Some(lightcraft_catalog::PhotoId(beach)) }; + for _ in 0..4 { + h.step(); + } + let target = folder_row(&h, &home).expect("the other folder is listed beside it"); + let from = widget(&h, &format!("thumb:{beach}")).unwrap().center(); + press(&mut h, from); + glide(&mut h, from, target.center()); + release(&mut h, target.center()); + h.step(); + click(&mut h, "button:dialogOk"); + assert!(home.join("beach.png").is_file(), "moved"); + // it left trip's view, and the selection with it: a Delete next can't reach it unseen + assert!(!h.app.session.selection.contains(lightcraft_catalog::PhotoId(beach)), "{:?}", h.app.session.selection); + assert!(h.app.session.visible_cloned().contains(&lightcraft_catalog::PhotoId(dunes))); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_folder_left_and_come_back_to_waits_again_before_opening() { + let (root, trip, home) = folders("spring"); + let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); + let beach = id_of(&h, "beach.png"); + let parent = folder_row(&h, &root).unwrap(); + let from = widget(&h, &format!("thumb:{beach}")).unwrap().center(); + press(&mut h, from); + // over the closed folder for a moment, then away, longer than it waits + glide(&mut h, from, parent.center()); + glide(&mut h, parent.center(), from); + for _ in 0..60 { + h.step(); + } + // back over it: it doesn't open at once + glide(&mut h, from, parent.center()); + assert!(folder_row(&h, &home).is_none(), "came back to: it waits again"); + for _ in 0..60 { + h.step(); + } + assert!(folder_row(&h, &home).is_some(), "held: it opens"); + release(&mut h, parent.center()); + let _ = std::fs::remove_dir_all(&root); +} + +/// Drag the beach photo from the grid onto `target` and let go. +fn drop_beach(h: &mut Headless, beach: u64, target: Rect) { + let from = widget(h, &format!("thumb:{beach}")).unwrap().center(); + press(h, from); + glide(h, from, target.center()); + release(h, target.center()); + h.step(); +} + +#[test] +fn cancelling_the_question_moves_nothing() { + let (root, trip, home) = folders("cancel"); + let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); + let beach = id_of(&h, "beach.png"); + let target = folder_row(&h, &home).unwrap_or_else(|| folder_row(&h, &root).unwrap()); + let target = if folder_row(&h, &home).is_some() { + target + } else { + click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy())); + folder_row(&h, &home).unwrap() + }; + let undo = h.app.session.undo.len(); + drop_beach(&mut h, beach, target); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::MovePhotos { .. }))); + click(&mut h, "button:dialogCancel"); + assert!(h.app.ui.dialog.is_none()); + assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "nothing moved"); + assert_eq!(h.app.session.undo.len(), undo); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn dont_ask_again_moves_the_next_drop_at_once() { + let (root, trip, home) = folders("dont-ask"); + std::fs::copy(trip.join("beach.png"), trip.join("dunes.png")).unwrap(); + let mut h = app(&[(trip.join("beach.png"), false), (trip.join("dunes.png"), false), (home.join("garden.png"), false)]); + click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy())); + let target = folder_row(&h, &home).unwrap(); + let beach = id_of(&h, "beach.png"); + h.app.session.selection = lightcraft_engine::Selection::single(lightcraft_catalog::PhotoId(beach)); + drop_beach(&mut h, beach, target); + // the question, with Don't ask again ticked + click(&mut h, "check:moveDontAsk"); + click(&mut h, "button:dialogOk"); + assert!(home.join("beach.png").is_file()); + assert!(!h.app.ui.settings.ask_before_move, "kept in Settings"); + // the next drop goes at once + let dunes = id_of(&h, "dunes.png"); + h.app.session.selection = lightcraft_engine::Selection::single(lightcraft_catalog::PhotoId(dunes)); + h.step(); + let target = folder_row(&h, &home).unwrap(); + drop_beach(&mut h, dunes, target); + assert!(h.app.ui.dialog.is_none(), "not asked"); + assert!(home.join("dunes.png").is_file(), "moved at once"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_question_left_open_while_another_library_opens_moves_nothing() { + let (root, trip, home) = folders("other-library"); + let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); + click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy())); + let beach = id_of(&h, "beach.png"); + let target = folder_row(&h, &home).unwrap(); + drop_beach(&mut h, beach, target); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::MovePhotos { .. }))); + // another library opens meanwhile (the menu bar and agents reach past a dialog) + let other = root.join("other-library"); + h.app.session.open_library(other.join("lib"), false).unwrap(); + let r = h.request("ui.dialog.confirm", json!({}), std::time::Duration::from_secs(10)); + assert_eq!(r["ok"], false, "{r}"); + assert!(r["error"].as_str().unwrap_or_default().contains("Another library was opened"), "{r}"); + assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "nothing moved"); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..561711147 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -696,7 +696,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) | | LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); a library that can't be opened leaves the current one open | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | -| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu: files and XMP sidecars move, virtual copies follow, across volumes copied and verified before the source goes, a name taken in the destination refuses the whole move, one undo step), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | +| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu, and photos dragged from the grid onto a Folders row: the row lights up, the dragged label says “Move 3 photos to “Trip””, a closed folder held under the pointer opens, a row covering several disks and Local photos are refused with the reason, photos already there say so; a drop asks first (“Move 2 photos to “Porto”?”, Don't ask again, Settings → Confirm before moving photos to another folder on disk), and photos that leave the view leave the selection; files and XMP sidecars move, virtual copies follow, across volumes copied (streamed) and verified before the source goes, a name taken in the destination refuses the whole move, one undo step; an undo with the share offline or read-only is refused with the reason and kept until it can run, nothing deleted), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: no rating 0–5 is 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | From 73ee54fe62e66aab2c22704f7c8d31181f86d8d8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 15 Oct 2026 01:48:46 -0400 Subject: [PATCH 10/12] Batch 38: format catalogs list the strings this batch adds, in English until translated Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/it-formats.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json index 70a1bafbe..8edab510a 100644 --- a/crates/ui-egui/locales/it-formats.json +++ b/crates/ui-egui/locales/it-formats.json @@ -291,5 +291,9 @@ "Effect: {name}": "Effect: {name}", " in {g} group{}": " in {g} group{}", "Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)", - "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages" + "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages", + "Already in “{}”": "Already in “{}”", + "Move {n} photo{} to “{}”": "Move {n} photo{} to “{}”", + "Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back." } From e44e7157fc683984c69192d9d65998cf5edf406c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 15 Oct 2026 01:58:56 -0400 Subject: [PATCH 11/12] Batch 38: rustfmt and parity summary after the merges Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/camera_preview.rs | 3 +-- docs/parity-checklist.md | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 50ba5641e..614c58c60 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -88,8 +88,7 @@ pub(crate) fn starting_colour( lens: Option<&LensGeometry>, embedded: Option<&lightcraft_develop::EmbeddedLens>, ) -> (Xy, CameraTransform, Option) { - let (xy, t, look) = - starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t, lens), |raw, t| monochrome_look(raw, bytes, t, lens)); + let (xy, t, look) = starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t, lens), |raw, t| monochrome_look(raw, bytes, t, lens)); if look.is_none() && dng_tone_eligible(raw, &t) { let look = fit_dng_tone(raw, bytes, &t, embedded); return (xy, t, look); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 5ac842798..2f4758687 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 77 | 4 | 2 | 1 | 49/52 (94%) | 23/25 (92%) | | Lightroom Classic extras | 40 | 23 | 23 | 8 | — | 24/26 (92%) | -| **Total** | 417 | 61 | 59 | 35 | 193/201 (96%) | 147/160 (92%) | +| **Total** | 417 | 62 | 59 | 35 | 193/201 (96%) | 147/160 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.3%** of 537 in-scope rows — P0 98.0% of 201 · P1 95.0% of 160 · P2 55.7% of 175. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.3%** of 538 in-scope rows — P0 98.0% of 201 · P1 95.0% of 160 · P2 55.7% of 176. ## Top gaps and shortcut notes From a94fc6bc84b3161afe195242b9e2fecd858e178c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 15 Oct 2026 01:59:50 -0400 Subject: [PATCH 12/12] Batch 38: What's New lists the Nikon profile and lens-corrected colour notes once Co-Authored-By: Claude Opus 5.5 --- docs/whats-new.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/docs/whats-new.md b/docs/whats-new.md index cf0bb1c06..177d7b140 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -127,15 +127,6 @@ #1236). The D7000, D5100, D800, D800E, Df, D4, D3, D3S, D700, D300, D300S, D600, D610, D810, D4S, D5600, Coolpix A, D3000, D40X, D60, D80, D2Xs, D200, D3200, D3100, D5000, D40, D50, D70, D70s and Nikon 1 V1 now show the area the camera itself frames, a few pixels smaller than before. -### Nikon Z 6II colour profile -- Raws from the Nikon Z 6II now start from a colour profile built from a few hundred Z 6II photos, the same way the - Sony and Fujifilm profiles were made, instead of fitting each photo's colour on its own. Colours that one photo - shows only a little of come out closer to the camera's JPEG. - -- A command given photos it can't read as photo ids (`"id": "5"`, or a list with something else in it) from a - script, the control channel or MCP is now refused (issue #1506). It used to act on the selected photos instead, - or on the part of the list it could read, so a mistyped id could rate, move or delete the wrong photos. - ### An empty library leads to Import - A library with no photos now opens on the Photo Grid with the sidebar (Square Grid stays as it is), instead of a Detail view that pointed at an empty grid. The grid and Detail view offer Import Photos… and, in the desktop app, @@ -155,11 +146,6 @@ - In dialogs, the confirm button is on the right with Cancel to its left, and filled with the accent when it makes or brings in something (Import N Photos, Merge, Save, Create, Download…). Delete and Remove stay plain, so a destructive choice is never the eye-catching default. -### Closer camera colour for lens-corrected raws -- Raws from cameras that correct their own JPEG for lens distortion (Panasonic and Fujifilm with that setting on, the - Sony A7R IVA) now start from colours closer to the camera's. The starting look was being fitted against a JPEG that - didn't line up with the raw near the edges. Photos you already imported pick this up when their previews are rebuilt. - ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged.