From 5ce171da234b42cedc82fb05902b1631f27ca61d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 19:23:08 +0000 Subject: [PATCH 01/40] Docking: the workspace, its commands and their parser come from craft-ui (craft-libs 16fb3d4) craft-ui and craft-docpath are pinned to craft-libs 16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3. The saved workspace is craft_ui::docking::Workspace; settings written before it had a version load as version 1. ui.dock params parse as the shared DockCommand and apply through Workspace::apply_all. LightCraft keeps its panels, permissions, default layout, photo-view checks, the fallback beside the photo view for a panel with no recorded place, and its settings file. The photo view takes side splits (accepts_splits on PROTECTED), so panels still open and move beside it. Tests: a version 1 workspace file loads and reopens its hidden panel; a drag of a group by its bar moves both of its panels. --- Cargo.lock | 8 +- Cargo.toml | 4 +- apps/lightcraft/src/main.rs | 6 +- crates/ui-egui/src/docking.rs | 418 +++++++----------- .../ui-egui/src/tests_docking_acceptance.rs | 10 +- crates/ui-egui/tests/it/docking_workflows.rs | 36 +- docs/control-protocol.md | 4 +- 7 files changed, 221 insertions(+), 265 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 78de058c3..5d17222c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,14 +1084,18 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" dependencies = [ "egui", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "serde", ] diff --git a/Cargo.toml b/Cargo.toml index c22395b8a..3564f8491 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ rust-version = "1.95" repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 7d30e69cd..d89a71ef8 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -265,7 +265,7 @@ fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> { let fields = saved.as_object_mut().ok_or("settings must be an object")?; let recovered = fields.get("docking").is_some_and(|value| match serde_json::from_value::>(value.clone()) { - Ok(Some(workspace)) => workspace.validate().is_err(), + Ok(Some(workspace)) => lightcraft_ui_egui::docking::validate(&workspace).is_err(), Ok(None) => false, Err(_) => true, }); @@ -376,7 +376,7 @@ impl PrefsWriter { } let Some(path) = self.path.clone() else { return Ok(()) }; if let Some(workspace) = &ui.docking { - workspace.validate()?; + lightcraft_ui_egui::docking::validate(workspace)?; } let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?; if bytes == self.written { @@ -1245,7 +1245,7 @@ mod tests { restored_app.ui = restored.unwrap(); restored_app.run("ui.dock", serde_json::json!({"operation":"open", "panel":"tools"})).unwrap(); assert_eq!( - restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect, + restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.contains(&Panel::Tools)).unwrap().rect, [70.0, 80.0, 310.0, 500.0] ); std::fs::remove_dir_all(d).unwrap(); diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index e9f51a56f..4128b1628 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -1,7 +1,10 @@ //! Application panel identities and persistence around the shared docking renderer. //! Document contents remain owned by their existing view implementations. -use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone}; +use craft_ui::docking::{ + Action, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Layout, Node, PanelLimits, PanelState, Permissions, Placement, + UiDock, Zone, +}; use craft_ui::layout::{SplitAxis, SplitSize}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -41,13 +44,11 @@ impl Panel { Self::Presets => Zone::Right, } } - fn parse(id: &str) -> Result { - Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}")) - } } fn permissions(panel: &Panel) -> Permissions { - if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() } + // Panels open and split beside the photo view, but never join it as tabs. + if *panel == Panel::Canvas { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() } } fn tabs(panel: Panel) -> Node { @@ -58,233 +59,119 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node, - /// Closed panels retain their most recent group, side, size or floating rectangle. - pub hidden: Vec<(Panel, Location)>, +/// The saved arrangement. Closed panels keep their last group, side, size or floating rect. +pub type Workspace = craft_ui::docking::Workspace; + +pub fn default_workspace() -> Workspace { + Workspace::new(Layout { + root: Some(split( + SplitAxis::Horizontal, + SplitSize::FixedFirst(crate::state::LEFT_WIDTH.default), + tabs(Panel::Library), + split( + SplitAxis::Horizontal, + SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.default), + split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)), + tabs(Panel::Tools), + ), + )), + floating: Vec::new(), + }) } -impl Default for Workspace { - fn default() -> Self { - Self { - layout: Layout { - root: Some(split( - SplitAxis::Horizontal, - SplitSize::FixedFirst(crate::state::LEFT_WIDTH.default), - tabs(Panel::Library), - split( - SplitAxis::Horizontal, - SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.default), - split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)), - tabs(Panel::Tools), - ), - )), - floating: Vec::new(), - }, - hidden: Vec::new(), - } - } +fn error(error: DockError) -> String { + error.to_string() } -impl Workspace { - pub fn validate(&self) -> Result<(), String> { - self.layout.validate().map_err(|error| error.to_string())?; - if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() { - return Err("The panel layout must contain its document view and bounded hidden panels".into()); - } - // Saved settings cannot turn the protected document view into a floating or closable tab. - if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) { - return Err("The document view cannot float".into()); - } - let mut nodes: Vec<_> = self.layout.root.iter().collect(); - while let Some(node) = nodes.pop() { - match node { - Node::Split { first, second, .. } => { - nodes.push(first); - nodes.push(second); - } - Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => { - return Err("The document view cannot share a tab group".into()); - } - Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => { - return Err("The document view cannot be collapsed".into()); - } - _ => {} +/// The shared checks plus LightCraft's own: saved settings cannot float, hide, share or +/// collapse the photo view, nor send a hidden panel back onto it. +pub fn validate(workspace: &Workspace) -> Result<(), String> { + workspace.validate().map_err(error)?; + let mut nodes: Vec<_> = workspace.layout.root.iter().collect(); + let mut alone = false; + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => { + nodes.push(first); + nodes.push(second); } + Node::Tabs { panels, .. } => alone |= panels.as_slice() == [Panel::Canvas], + _ => {} } - let mut seen = std::collections::HashSet::new(); - for (panel, location) in &self.hidden { - if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) { - return Err("A hidden panel has an invalid or duplicate identity".into()); - } - if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) { - return Err("A hidden panel cannot restore over the document view".into()); - } - let mut probe = self.layout.clone(); - if let Err(error) = probe.restore(*panel, location) - && error != craft_ui::docking::DockError::MissingTarget - { - return Err(error.to_string()); - } - } - Ok(()) } + if !alone { + return Err("The document view must stay docked in a group of its own".into()); + } + if workspace.hidden.iter().any(|(_, location)| { + location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) + }) { + return Err("A hidden panel cannot restore over the document view".into()); + } + Ok(()) +} - pub fn apply(&mut self, action: Action) -> Result<(), String> { - self.validate()?; - let mut next = self.clone(); - if let Action::Close { panel } = &action { - let location = next.layout.location(panel).map_err(|error| error.to_string())?; - next.hidden.retain(|(id, _)| id != panel); - next.hidden.push((*panel, location)); - } else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action { - next.hidden.retain(|(id, _)| id != panel); - } - next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?; - next.validate()?; - *self = next; - Ok(()) - } +fn apply(workspace: &mut Workspace, action: Action) -> Result<(), String> { + let mut next = workspace.clone(); + next.apply(action, permissions).map_err(error)?; + validate(&next)?; + *workspace = next; + Ok(()) +} - pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> { - if self.layout.contains(&panel) == visible { - return Ok(()); - } - if !visible { - return self.apply(Action::Close { panel }); - } - self.validate()?; - let mut next = self.clone(); - let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone()); - next.hidden.retain(|(id, _)| *id != panel); - if let Some(saved) = &mut location - && saved.floating.is_some() - && saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor))) - { - // The other members of a floating group may have been closed too. - saved.anchor = None; - saved.placement = Placement::Tab { before: None }; - } - let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok()); - if !restored { - next.layout - .apply_with_permissions( - Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }, - permissions, - ) - .map_err(|error| error.to_string())?; - } - next.validate()?; - *self = next; - Ok(()) +/// Shows a panel at its recorded place, else beside the photo on its usual side. A panel that +/// already has the asked visibility keeps its tab selection. +fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result<(), String> { + if workspace.layout.contains(&panel) == visible { + return Ok(()); } + if !visible { + return apply(workspace, Action::Close { panel }); + } + validate(workspace)?; + let mut next = workspace.clone(); + if next.set_visible(panel, true, None, permissions).is_ok() && validate(&next).is_ok() { + *workspace = next; + return Ok(()); + } + apply(workspace, Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }) +} - /// Membership is passive state; an explicit command also exposes the panel's body. - pub fn reveal(&mut self, panel: Panel) -> Result<(), String> { - self.set_visible(panel, true)?; - self.apply(Action::Activate { panel }) +/// Membership is passive state; an explicit command also exposes the panel's body. +fn reveal(workspace: &mut Workspace, panel: Panel) -> Result<(), String> { + set_visible(workspace, panel, true)?; + apply(workspace, Action::Activate { panel })?; + if workspace.layout.panel_state(&panel) != PanelState::Front { + // A minimized window or group opens again; where nothing is minimized the action is + // refused and the layout stays as it is. + let _ = apply(workspace, Action::SetMinimized { panel, minimized: false }); } + Ok(()) +} - pub fn exposed(&self, panel: Panel) -> bool { - if self.layout.floating.iter().any(|group| group.panels.get(group.active) == Some(&panel)) { - return true; - } - let mut nodes = self.layout.root.as_ref().map(|node| vec![node]).unwrap_or_default(); - while let Some(node) = nodes.pop() { - match node { - Node::Tabs { panels, active } if panels.get(*active) == Some(&panel) => return true, - Node::Stack { entries } if entries.iter().any(|entry| entry.panel == panel && entry.open) => return true, - Node::Split { first, second, .. } => { - nodes.push(first); - nodes.push(second); - } - _ => {} - } - } - false - } +pub fn exposed_in(workspace: &Workspace, panel: Panel) -> bool { + workspace.layout.panel_state(&panel) == PanelState::Front +} - fn command(&mut self, params: &Value) -> Result<(), String> { - self.validate()?; - if let Some(action) = params.get("action") { - let action: Action = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?; - return self.apply(action); - } - let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?; - if operation == "resizeSplit" { - let path: Vec = - serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?; - let size: SplitSize = - serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?; - return self.apply(Action::ResizeSplit { path, size }); - } - let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?; - let action = match operation { - "open" => return self.reveal(panel), - "close" => Action::Close { panel }, - "activate" => Action::Activate { panel }, - "setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? }, - "resizeStack" => { - let value = params.get("height").ok_or("height is required")?; - let height = if value.is_null() { - None - } else { - Some( - value - .as_f64() - .filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height)) - .ok_or("height must be finite and bounded")? as f32, - ) - }; - Action::ResizeStack { panel, height } - } - "float" | "moveFloating" => { - let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?; - let mut values = [0.0f32; 4]; - for (output, value) in values.iter_mut().zip(rect) { - *output = value - .as_f64() - .filter(|number| number.is_finite() && number.abs() <= 1_000_000.0) - .ok_or("rect values must be finite and bounded")? as f32; - } - if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } } - } - "move" => { - let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?; - let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") { - "center" => Placement::Split(Zone::Center), - "left" => Placement::Split(Zone::Left), - "right" => Placement::Split(Zone::Right), - "top" => Placement::Split(Zone::Top), - "bottom" => Placement::Split(Zone::Bottom), - _ => return Err("zone must be center, left, right, top or bottom".into()), - }; - let placement = if let Some(before) = params.get("before") { - if !matches!(placement, Placement::Split(Zone::Center)) { - return Err("before only applies to a center move".into()); - } - Placement::Tab { - before: if before.is_null() { - None - } else { - Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?) - }, - } - } else { - placement - }; - Action::Move { panel, anchor, placement } - } - _ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()), - }; - self.apply(action) +/// `ui.dock` params, parsed by the shared command vocabulary. +fn run(workspace: &mut Workspace, params: &Value) -> Result<(), String> { + validate(workspace)?; + let mut tagged = params.clone(); + let fields = tagged.as_object_mut().ok_or("ui.dock params must be an object")?; + fields.insert("command".into(), json!("ui.dock")); + let command: DockCommand = serde_json::from_value(tagged).map_err(|error| error.to_string())?; + if let DockCommand::UiDock(UiDock::Operation(DockOperation::Open { panel })) = command { + return reveal(workspace, panel); } + let mut next = workspace.clone(); + // `ui.dock` operations carry their own rects; only `window.panel.float` reads the space. + command.apply(&mut next, false, &FloatSpace::new(egui::Rect::ZERO), permissions).map_err(error)?; + validate(&next)?; + *workspace = next; + Ok(()) } fn from_ui(app: &crate::LightcraftApp) -> Workspace { - let mut workspace = Workspace::default(); + let mut workspace = default_workspace(); if let Some(Node::Split { size, second, .. }) = workspace.layout.root.as_mut() { *size = SplitSize::FixedFirst(crate::state::LEFT_WIDTH.clamp(app.ui.left_width)); if let Node::Split { size, .. } = second.as_mut() { @@ -295,9 +182,9 @@ fn from_ui(app: &crate::LightcraftApp) -> Workspace { } fn sync(workspace: &mut Workspace, app: &crate::LightcraftApp) -> Result<(), String> { - workspace.set_visible(Panel::Library, app.ui.left_panel)?; - workspace.set_visible(Panel::Tools, app.ui.right != crate::state::RightPanel::None)?; - workspace.set_visible(Panel::Presets, app.ui.presets) + set_visible(workspace, Panel::Library, app.ui.left_panel)?; + set_visible(workspace, Panel::Tools, app.ui.right != crate::state::RightPanel::None)?; + set_visible(workspace, Panel::Presets, app.ui.presets) } fn publish_visibility(app: &mut crate::LightcraftApp, workspace: &Workspace) { @@ -328,7 +215,7 @@ fn publish_visibility(app: &mut crate::LightcraftApp, workspace: &Workspace) { } pub(crate) fn exposed(app: &crate::LightcraftApp, panel: Panel) -> bool { - app.ui.docking.as_ref().is_none_or(|workspace| workspace.exposed(panel)) + app.ui.docking.as_ref().is_none_or(|workspace| exposed_in(workspace, panel)) } pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result<(), String> { @@ -349,7 +236,7 @@ pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result if let Some(panel) = panel { let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app)); sync(&mut workspace, app)?; - workspace.reveal(panel)?; + reveal(&mut workspace, panel)?; cancel_replaced_gesture(app, Some(&workspace)); app.ui.docking = Some(workspace); } @@ -381,13 +268,13 @@ pub fn command(app: &mut crate::LightcraftApp, reset: bool, params: &Value) -> R fn command_impl(app: &mut crate::LightcraftApp, reset: bool, params: &Value, renderer_action: bool) -> Result { let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app)); if reset { - workspace = Workspace::default(); + workspace = default_workspace(); // The usual LightCraft starting workspace shows its current tool beside the photo. - workspace.set_visible(Panel::Library, false)?; - workspace.set_visible(Panel::Presets, false)?; + set_visible(&mut workspace, Panel::Library, false)?; + set_visible(&mut workspace, Panel::Presets, false)?; } else { sync(&mut workspace, app)?; - workspace.command(params)?; + run(&mut workspace, params)?; } if !renderer_action { cancel_replaced_gesture(app, Some(&workspace)); @@ -400,7 +287,7 @@ fn command_impl(app: &mut crate::LightcraftApp, reset: bool, params: &Value, ren pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(ui.ctx()); let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app)); - if let Err(error) = workspace.validate().and_then(|()| sync(&mut workspace, app)) { + if let Err(error) = validate(&workspace).and_then(|()| sync(&mut workspace, app)) { app.ui.toast = Some((format!("Panel layout could not be restored: {error}"), ui.input(|input| input.time) + 8.0, None)); workspace = from_ui(app); let _ = sync(&mut workspace, app); @@ -413,7 +300,7 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { style.min_pane = 96.0; style.float_label = crate::i18n::tr("Float panel").into(); style.close_label = crate::i18n::tr("Close panel").into(); - style.move_label = crate::i18n::tr("Move to group").into(); + style.move_panel_to_label = crate::i18n::tr("Move to group").into(); style.panels_label = crate::i18n::tr("Panels").into(); style.resize_label = crate::i18n::tr("Resize panels").into(); style.resize_window_label = crate::i18n::tr("Resize panel window").into(); @@ -427,7 +314,7 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { style.font = t.font(12.0); // Tool and preset panels publish hover previews before the photo consumes them. let mut canvas = None; - let output = DockArea::new(egui::Id::new("lightcraft-docking")).show_with_limits( + let output = DockArea::new(egui::Id::new("lightcraft-docking")).locked(workspace.locked).show_with_limits( ui, &workspace.layout, &style, @@ -500,7 +387,7 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( let Some(workspace) = state.docking.as_mut() else { return Ok(()); }; - workspace.validate()?; + validate(workspace)?; for (key, panel, width) in [("leftWidth", Panel::Library, state.left_width), ("rightWidth", Panel::Tools, state.right_width)] { if params.get(key).is_none() { continue; @@ -516,10 +403,10 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( } continue; } - if let Some(group) = workspace.layout.floating.iter().find(|group| group.panels.contains(&panel)) { + if let Some(group) = workspace.layout.floating.iter().find(|group| group.contains(&panel)) { let mut rect = group.rect; rect[2] = width; - workspace.apply(Action::MoveFloating { panel, rect })?; + apply(workspace, Action::MoveFloating { panel, rect })?; continue; } let mut nodes = workspace.layout.root.as_ref().map(|node| vec![(node, Vec::new())]).unwrap_or_default(); @@ -529,7 +416,7 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( let contains = |node: &Node| match node { Node::Tabs { panels, .. } => panels.contains(&panel), Node::Stack { entries } => entries.iter().any(|entry| entry.panel == panel), - Node::Split { .. } => false, + _ => false, }; if *axis == SplitAxis::Horizontal { if contains(first) { @@ -548,7 +435,7 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( } } if let Some(action) = change { - workspace.apply(action)?; + apply(workspace, action)?; } } Ok(()) @@ -583,7 +470,7 @@ mod tests { split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)), ); let root = if first { split(axis, size, tabs(Panel::Tools), rest) } else { split(axis, size, rest, tabs(Panel::Tools)) }; - Workspace { layout: Layout { root: Some(root), floating: Vec::new() }, hidden: Vec::new() } + Workspace::new(Layout { root: Some(root), floating: Vec::new() }) } #[test] @@ -594,23 +481,20 @@ mod tests { .into_iter() .map(|panel| craft_ui::docking::StackEntry { panel, open: panel != Panel::Tools, height: Some(180.0) }) .collect(); - app.ui.docking = Some(Workspace { - layout: Layout { - root: Some(split(SplitAxis::Horizontal, SplitSize::FixedSecond(350.0), tabs(Panel::Canvas), Node::Stack { entries })), - floating: Vec::new(), - }, - hidden: Vec::new(), - }); + app.ui.docking = Some(Workspace::new(Layout { + root: Some(split(SplitAxis::Horizontal, SplitSize::FixedSecond(350.0), tabs(Panel::Canvas), Node::Stack { entries })), + floating: Vec::new(), + })); let undo = app.session.undo.len(); app.run("panel.crop", json!({})).unwrap(); assert_eq!(app.ui.right, crate::state::RightPanel::Crop); let workspace = app.ui.docking.as_ref().unwrap(); - assert!(workspace.exposed(Panel::Tools)); + assert!(exposed_in(workspace, Panel::Tools)); assert_eq!(app.session.undo.len(), undo); let saved = serde_json::to_string(workspace).unwrap(); let reloaded: Workspace = serde_json::from_str(&saved).unwrap(); - reloaded.validate().unwrap(); - assert!(reloaded.exposed(Panel::Tools)); + validate(&reloaded).unwrap(); + assert!(exposed_in(&reloaded, Panel::Tools)); } #[test] @@ -622,7 +506,7 @@ mod tests { (SplitAxis::Horizontal, SplitSize::FixedFirst(180.0), false), ] { let workspace = tools_split(axis, size, first); - workspace.validate().unwrap(); + validate(&workspace).unwrap(); let mut app = app_with_widths(); publish_visibility(&mut app, &workspace); assert_eq!((app.ui.left_width, app.ui.right_width), (333.0, 410.0), "{axis:?} {size:?}, first={first}"); @@ -637,8 +521,8 @@ mod tests { publish_visibility(&mut app, &workspace); assert_eq!((app.ui.left_width, app.ui.right_width), (333.0, 310.0)); } - let mut workspace = Workspace::default(); - workspace.apply(Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap(); + let mut workspace = default_workspace(); + apply(&mut workspace, Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap(); let mut app = app_with_widths(); publish_visibility(&mut app, &workspace); assert_eq!(app.ui.right_width, 390.0); @@ -646,12 +530,50 @@ mod tests { #[test] fn legacy_width_update_preserves_hidden_top_panel_height() { - let mut workspace = Workspace::default(); - workspace.apply(Action::Move { panel: Panel::Tools, anchor: Panel::Canvas, placement: Placement::Split(Zone::Top) }).unwrap(); - workspace.set_visible(Panel::Tools, false).unwrap(); + let mut workspace = default_workspace(); + apply(&mut workspace, Action::Move { panel: Panel::Tools, anchor: Panel::Canvas, placement: Placement::Split(Zone::Top) }).unwrap(); + set_visible(&mut workspace, Panel::Tools, false).unwrap(); let height = workspace.hidden.iter().find(|(panel, _)| *panel == Panel::Tools).unwrap().1.size; let mut state = crate::UiState { docking: Some(workspace), right_width: 410.0, ..Default::default() }; set_widths(&mut state, &json!({"rightWidth":410.0})).unwrap(); assert_eq!(state.docking.as_ref().unwrap().hidden.iter().find(|(panel, _)| *panel == Panel::Tools).unwrap().1.size, height); } + + /// Settings written before workspaces had a version: `{layout, hidden}`, a floating + /// window as one group, hidden locations without dock state. + #[test] + fn version_one_workspace_file_loads_and_reopens_its_hidden_panel() { + let v1 = json!({ + "layout": { + "root": {"Split": { + "axis": "Horizontal", + "size": {"FixedFirst": 300.0}, + "first": {"Tabs": {"panels": ["library"], "active": 0}}, + "second": {"Tabs": {"panels": ["canvas"], "active": 0}} + }}, + "floating": [{"panels": ["tools"], "active": 0, "rect": [120.0, 90.0, 320.0, 600.0]}] + }, + "hidden": [["presets", { + "floating": null, + "anchor": "canvas", + "placement": {"Split": "Right"}, + "size": {"FixedSecond": 280.0}, + "stack": null, + "sibling": ["canvas"] + }]] + }); + let mut workspace: Workspace = serde_json::from_value(v1).unwrap(); + validate(&workspace).unwrap(); + assert_eq!(workspace.version, 1); + let window = workspace.layout.floating.first().unwrap(); + assert!(window.contains(&Panel::Tools)); + assert_eq!(window.rect, [120.0, 90.0, 320.0, 600.0]); + assert!(exposed_in(&workspace, Panel::Library)); + set_visible(&mut workspace, Panel::Presets, true).unwrap(); + let location = workspace.layout.location(&Panel::Presets).unwrap(); + assert_eq!((location.anchor, location.size), (Some(Panel::Canvas), Some(SplitSize::FixedSecond(280.0)))); + let saved = serde_json::to_value(&workspace).unwrap(); + assert_eq!(saved["version"], 2); + assert_eq!(saved["layout"]["floating"][0]["panels"], json!(["tools"]), "a one-group window keeps the v1 shape"); + } } diff --git a/crates/ui-egui/src/tests_docking_acceptance.rs b/crates/ui-egui/src/tests_docking_acceptance.rs index 1dc97892e..5e496b822 100644 --- a/crates/ui-egui/src/tests_docking_acceptance.rs +++ b/crates/ui-egui/src/tests_docking_acceptance.rs @@ -53,7 +53,7 @@ fn inactive_same_mode_commands_reveal_without_closing_or_editing() { let undo = h.app.session.undo.len(); let photos = serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::>()).unwrap(); h.app.run(command, json!({})).unwrap(); - assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Tools), "{command}"); + assert!(crate::docking::exposed_in(h.app.ui.docking.as_ref().unwrap(), Panel::Tools), "{command}"); assert_ne!(h.app.ui.right, RightPanel::None); assert_eq!(h.app.session.undo.len(), undo); assert_eq!(serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::>()).unwrap(), photos); @@ -64,10 +64,10 @@ fn inactive_same_mode_commands_reveal_without_closing_or_editing() { dock(&mut h, json!({"operation":"move","panel":"presets","target":"library","zone":"center"})); dock(&mut h, json!({"operation":"activate","panel":"library"})); h.app.run("panel.presets", json!({})).unwrap(); - assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Presets)); + assert!(crate::docking::exposed_in(h.app.ui.docking.as_ref().unwrap(), Panel::Presets)); dock(&mut h, json!({"operation":"activate","panel":"library"})); dock(&mut h, json!({"operation":"open","panel":"presets"})); - assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Presets)); + assert!(crate::docking::exposed_in(h.app.ui.docking.as_ref().unwrap(), Panel::Presets)); } #[test] @@ -81,7 +81,7 @@ fn crop_canvas_double_click_reveals_edit_in_actual_custom_workspace() { dock(&mut h, json!({"operation":"move","panel":"tools","target":"presets","zone":"center"})); dock(&mut h, json!({"operation":"activate","panel":"presets"})); assert_eq!(h.app.ui.right, RightPanel::Crop); - assert!(!h.app.ui.docking.as_ref().unwrap().exposed(Panel::Tools)); + assert!(!crate::docking::exposed_in(h.app.ui.docking.as_ref().unwrap(), Panel::Tools)); let library = h.app.ui.docking.as_ref().unwrap().layout.location(&Panel::Library).unwrap(); let id = h.app.session.active().unwrap(); let photo = serde_json::to_value(h.app.session.catalog.photo(id).unwrap().as_ref()).unwrap(); @@ -94,7 +94,7 @@ fn crop_canvas_double_click_reveals_edit_in_actual_custom_workspace() { } assert_eq!(h.app.ui.right, RightPanel::Edit, "the real Canvas double-click completes Crop"); let workspace = h.app.ui.docking.as_ref().unwrap(); - assert!(workspace.exposed(Panel::Tools), "tool.done reveals Tools after the Canvas callback"); + assert!(crate::docking::exposed_in(workspace, Panel::Tools), "tool.done reveals Tools after the Canvas callback"); assert_eq!(workspace.layout.location(&Panel::Tools).unwrap().anchor, Some(Panel::Presets)); assert_eq!(workspace.layout.location(&Panel::Library).unwrap(), library); assert_eq!(h.app.session.active(), Some(id)); diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs index aa80e8301..7538946b1 100644 --- a/crates/ui-egui/tests/it/docking_workflows.rs +++ b/crates/ui-egui/tests/it/docking_workflows.rs @@ -1,4 +1,5 @@ //! Real library/tool panel moves preserve the selected tool and saved arrangement. +use craft_ui::docking::{DockArea, Hit, Node}; use egui::{Event, Modifiers, PointerButton, vec2}; use egui_kittest::{Harness, kittest::Queryable}; use lightcraft_ui_egui::{LightcraftApp, Services, UiState, docking::Panel, state::RightPanel}; @@ -63,7 +64,7 @@ fn real_drag_float_close_reopen_and_state_reload_keep_photo_tool_mode() { h.run_steps(3); h.get_by_label("Float panel").click(); h.run_steps(5); - let rect = h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect; + let rect = h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.contains(&Panel::Tools)).unwrap().rect; h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").click_button(PointerButton::Secondary); h.run_steps(3); h.get_by_label("Close panel").click(); @@ -71,7 +72,7 @@ fn real_drag_float_close_reopen_and_state_reload_keep_photo_tool_mode() { assert_eq!(h.state().ui.right, RightPanel::None); h.state_mut().ui.right = RightPanel::Crop; h.run_steps(4); - assert_eq!(h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect, rect); + assert_eq!(h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.contains(&Panel::Tools)).unwrap().rect, rect); let saved = serde_json::to_string(&h.state().ui).unwrap(); let expected = serde_json::to_value(&h.state().ui.docking).unwrap(); let mut reloaded = harness(Some(serde_json::from_str(&saved).unwrap()), 1400.0, 1.0); @@ -86,6 +87,35 @@ fn real_drag_float_close_reopen_and_state_reload_keep_photo_tool_mode() { assert_eq!(reloaded.state().ui.right, RightPanel::Crop); } +#[test] +fn dragging_a_group_by_its_bar_moves_the_whole_group() { + let mut h = harness(None, 1400.0, 1.0); + h.state_mut().ui.presets = true; + h.run_steps(4); + h.state_mut().run("ui.dock", json!({"operation":"move","panel":"presets","target":"tools","zone":"center"})).unwrap(); + h.run_steps(6); + let bar = DockArea::::hit_rects(&h.ctx, egui::Id::new("lightcraft-docking")) + .into_iter() + .find_map(|(hit, rect)| matches!(hit, Hit::DragBar(Panel::Tools | Panel::Presets)).then_some(rect)) + .expect("the Tools and Presets group draws its drag bar"); + let target = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Library").rect().center(); + drag(&mut h, bar.center(), target); + let layout = &h.state().ui.docking.as_ref().unwrap().layout; + let mut nodes: Vec<_> = layout.root.iter().collect(); + let mut groups = Vec::new(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Tabs { panels, .. } => groups.push(panels.clone()), + _ => {} + } + } + let mut library = groups.into_iter().find(|panels| panels.contains(&Panel::Library)).unwrap(); + library.sort_by_key(|panel| panel.id()); + assert_eq!(library, [Panel::Library, Panel::Presets, Panel::Tools], "both panels of the dragged group joined Library"); + assert_eq!(h.state().ui.right, RightPanel::Edit); +} + #[test] fn invalid_docking_requests_preserve_layout_and_selected_tool() { let mut h = harness(None, 1400.0, 1.0); @@ -115,7 +145,7 @@ fn docking_renders_real_photo_panels_default_floating_and_redocked() { } h.run_steps(8); settle_photo(&mut h); - h.state().ui.docking.as_ref().unwrap().validate().unwrap(); + lightcraft_ui_egui::docking::validate(h.state().ui.docking.as_ref().unwrap()).unwrap(); if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") { std::fs::create_dir_all(&directory).unwrap(); h.render().unwrap().save(format!("{directory}/light-{width}-{scale}-{state}.png")).unwrap(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7a8033aa5..5e374da64 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -364,11 +364,11 @@ the pending name. `ui.dialog.confirm` starts the same import into the selected a ## Panel docking -Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents. +Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences as a craft-ui docking workspace (`docking`: `version`, `layout`, `hidden`, `saved`, `locked`); settings written before workspaces had a `version` load as version 1. These operations do not edit document contents. Panel IDs: `library`, `tools`, `presets`. The protected center is `canvas`. -`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. +`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Both commands return `{"docking": workspace}`. Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tools","rect":[100,100,320,440]}}`. From c964b7f654d930db29cdfe0c3cc75979e2d254af Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 19:44:32 +0000 Subject: [PATCH 02/40] Docking: a dragged divider beside Library or Tools keeps that panel's width craft-ui stores a moved divider as a ratio. After each frame the split beside a side panel takes the width the panel was drawn at, so the panel keeps its width when the window resizes and left_width / right_width follow the drag. --- crates/ui-egui/src/docking.rs | 50 +++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 4128b1628..52914dd91 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -314,6 +314,7 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { style.font = t.font(12.0); // Tool and preset panels publish hover previews before the photo consumes them. let mut canvas = None; + let (mut library_width, mut tools_width) = (None, None); let output = DockArea::new(egui::Id::new("lightcraft-docking")).locked(workspace.locked).show_with_limits( ui, &workspace.layout, @@ -326,10 +327,12 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { canvas = Some(ui.new_child(egui::UiBuilder::new().id_salt("photo-view").max_rect(ui.max_rect()))); } Panel::Library => { + library_width = Some(ui.max_rect().width()); crate::widgets::register(ui.ctx(), "panel:left_panel", ui.max_rect()); crate::panels::left::show(app, ui); } Panel::Tools => { + tools_width = Some(ui.max_rect().width()); crate::widgets::register(ui.ctx(), "panel:right_panel", ui.max_rect()); crate::panels::right::show(app, ui); } @@ -364,6 +367,8 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { let stale = workspace.layout != rendered.layout || workspace.hidden != rendered.hidden; if stale { craft_ui::docking::cancel_drag::(ui.ctx(), egui::Id::new("lightcraft-docking")); + } else { + keep_side_widths(&mut workspace, [(Panel::Library, library_width), (Panel::Tools, tools_width)]); } publish_visibility(app, &workspace); app.ui.docking = Some(workspace); @@ -380,6 +385,45 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { } } +/// A dragged divider stores a ratio. The side panels keep the width they were drawn at instead, +/// so a window resize leaves them as they are and `left_width` / `right_width` follow them. +fn keep_side_widths(workspace: &mut Workspace, drawn: [(Panel, Option); 2]) { + let mut nodes = workspace.layout.root.as_ref().map(|node| vec![(node, Vec::new())]).unwrap_or_default(); + let mut changes = Vec::new(); + while let Some((node, path)) = nodes.pop() { + let Node::Split { axis, size, first, second } = node else { continue }; + if *axis == SplitAxis::Horizontal && matches!(size, SplitSize::Ratio(_)) { + let fixed = drawn.iter().find_map(|(panel, width)| { + // A width drawn from a ratio carries float noise; in hundredths a panel at its + // limit stores the limit itself. + let width = width.filter(|width| width.is_finite() && *width > 0.0).map(|width| (width * 100.0).round() / 100.0)?; + if holds(first, *panel) { Some(SplitSize::FixedFirst(width)) } else { holds(second, *panel).then_some(SplitSize::FixedSecond(width)) } + }); + if let Some(size) = fixed { + changes.push(Action::ResizeSplit { path: path.clone(), size }); + } + } + for (child, side) in [(second, true), (first, false)] { + let mut path = path.clone(); + path.push(side); + nodes.push((child, path)); + } + } + for action in changes { + // A size the layout refuses leaves the ratio, which still draws the same widths. + let _ = apply(workspace, action); + } +} + +/// Whether `node` is the group (tabs or accordion) holding `panel`. +fn holds(node: &Node, panel: Panel) -> bool { + match node { + Node::Tabs { panels, .. } => panels.contains(&panel), + Node::Stack { entries } => entries.iter().any(|entry| entry.panel == panel), + _ => false, + } +} + /// Keep the established ui.set width controls connected to the panel's current parent split. pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(), String> { state.left_width = crate::state::LEFT_WIDTH.clamp(state.left_width); @@ -413,11 +457,7 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( let mut change = None; while let Some((node, path)) = nodes.pop() { if let Node::Split { axis, first, second, .. } = node { - let contains = |node: &Node| match node { - Node::Tabs { panels, .. } => panels.contains(&panel), - Node::Stack { entries } => entries.iter().any(|entry| entry.panel == panel), - _ => false, - }; + let contains = |node: &Node| holds(node, panel); if *axis == SplitAxis::Horizontal { if contains(first) { change = Some(Action::ResizeSplit { path: path.clone(), size: SplitSize::FixedFirst(width) }); From a70d7638a07fc4ceb433ac833c0fcee79f360ab3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 20:45:00 +0000 Subject: [PATCH 03/40] Docking: repin craft-libs 1774443; a dragged divider keeps its panel's width without app code craft-ui and craft-docpath are pinned to craft-libs 17744430be327d0257496a6a31c8d38fe20a6756, where a divider drag keeps the split's sizing mode. LightCraft no longer turns dragged ratios back into side-panel widths. --- Cargo.lock | 4 +-- Cargo.toml | 4 +-- crates/ui-egui/src/docking.rs | 50 ++++------------------------------- 3 files changed, 9 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5d17222c1..a15fe2744 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,12 +1084,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" +source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" +source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" dependencies = [ "egui", "log", diff --git a/Cargo.toml b/Cargo.toml index 3564f8491..20118c218 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ rust-version = "1.95" repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3", features = ["serde"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 52914dd91..4128b1628 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -314,7 +314,6 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { style.font = t.font(12.0); // Tool and preset panels publish hover previews before the photo consumes them. let mut canvas = None; - let (mut library_width, mut tools_width) = (None, None); let output = DockArea::new(egui::Id::new("lightcraft-docking")).locked(workspace.locked).show_with_limits( ui, &workspace.layout, @@ -327,12 +326,10 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { canvas = Some(ui.new_child(egui::UiBuilder::new().id_salt("photo-view").max_rect(ui.max_rect()))); } Panel::Library => { - library_width = Some(ui.max_rect().width()); crate::widgets::register(ui.ctx(), "panel:left_panel", ui.max_rect()); crate::panels::left::show(app, ui); } Panel::Tools => { - tools_width = Some(ui.max_rect().width()); crate::widgets::register(ui.ctx(), "panel:right_panel", ui.max_rect()); crate::panels::right::show(app, ui); } @@ -367,8 +364,6 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { let stale = workspace.layout != rendered.layout || workspace.hidden != rendered.hidden; if stale { craft_ui::docking::cancel_drag::(ui.ctx(), egui::Id::new("lightcraft-docking")); - } else { - keep_side_widths(&mut workspace, [(Panel::Library, library_width), (Panel::Tools, tools_width)]); } publish_visibility(app, &workspace); app.ui.docking = Some(workspace); @@ -385,45 +380,6 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { } } -/// A dragged divider stores a ratio. The side panels keep the width they were drawn at instead, -/// so a window resize leaves them as they are and `left_width` / `right_width` follow them. -fn keep_side_widths(workspace: &mut Workspace, drawn: [(Panel, Option); 2]) { - let mut nodes = workspace.layout.root.as_ref().map(|node| vec![(node, Vec::new())]).unwrap_or_default(); - let mut changes = Vec::new(); - while let Some((node, path)) = nodes.pop() { - let Node::Split { axis, size, first, second } = node else { continue }; - if *axis == SplitAxis::Horizontal && matches!(size, SplitSize::Ratio(_)) { - let fixed = drawn.iter().find_map(|(panel, width)| { - // A width drawn from a ratio carries float noise; in hundredths a panel at its - // limit stores the limit itself. - let width = width.filter(|width| width.is_finite() && *width > 0.0).map(|width| (width * 100.0).round() / 100.0)?; - if holds(first, *panel) { Some(SplitSize::FixedFirst(width)) } else { holds(second, *panel).then_some(SplitSize::FixedSecond(width)) } - }); - if let Some(size) = fixed { - changes.push(Action::ResizeSplit { path: path.clone(), size }); - } - } - for (child, side) in [(second, true), (first, false)] { - let mut path = path.clone(); - path.push(side); - nodes.push((child, path)); - } - } - for action in changes { - // A size the layout refuses leaves the ratio, which still draws the same widths. - let _ = apply(workspace, action); - } -} - -/// Whether `node` is the group (tabs or accordion) holding `panel`. -fn holds(node: &Node, panel: Panel) -> bool { - match node { - Node::Tabs { panels, .. } => panels.contains(&panel), - Node::Stack { entries } => entries.iter().any(|entry| entry.panel == panel), - _ => false, - } -} - /// Keep the established ui.set width controls connected to the panel's current parent split. pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(), String> { state.left_width = crate::state::LEFT_WIDTH.clamp(state.left_width); @@ -457,7 +413,11 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( let mut change = None; while let Some((node, path)) = nodes.pop() { if let Node::Split { axis, first, second, .. } = node { - let contains = |node: &Node| holds(node, panel); + let contains = |node: &Node| match node { + Node::Tabs { panels, .. } => panels.contains(&panel), + Node::Stack { entries } => entries.iter().any(|entry| entry.panel == panel), + _ => false, + }; if *axis == SplitAxis::Horizontal { if contains(first) { change = Some(Action::ResizeSplit { path: path.clone(), size: SplitSize::FixedFirst(width) }); From 5b7a4e9a9458b0821429663e365c61bdc36e0d3d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 21:31:46 +0000 Subject: [PATCH 04/40] Docs: ui.dock move lists the tab, above and below zones --- docs/control-protocol.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5e374da64..c25deb98c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -368,7 +368,7 @@ Library, Tools, and Presets panels can be regrouped, split, floated within the a Panel IDs: `library`, `tools`, `presets`. The protected center is `canvas`. -`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Both commands return `{"docking": workspace}`. +`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|tab|left|right|top|bottom|above|below`; PascalCase names also parse). Center and tab group with the target; side zones split beside it; above and below put the panel in a new group above or below the target's. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Both commands return `{"docking": workspace}`. Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tools","rect":[100,100,320,440]}}`. From a27d8c05d49d609c443f5edb20b1e2835b011a78 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 22:19:29 +0000 Subject: [PATCH 05/40] Docs: ui.dock above and below name the target's group --- docs/control-protocol.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c25deb98c..ebaebcbb6 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -368,7 +368,7 @@ Library, Tools, and Presets panels can be regrouped, split, floated within the a Panel IDs: `library`, `tools`, `presets`. The protected center is `canvas`. -`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|tab|left|right|top|bottom|above|below`; PascalCase names also parse). Center and tab group with the target; side zones split beside it; above and below put the panel in a new group above or below the target's. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Both commands return `{"docking": workspace}`. +`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|tab|left|right|top|bottom|above|below`; PascalCase names also parse). Center and tab group with the target; side zones split beside it; above and below put the panel in a new group above or below the target's group. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Both commands return `{"docking": workspace}`. Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tools","rect":[100,100,320,440]}}`. From b2f624631425ff10949ccdbfb9bdc957ada5134a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 22:29:00 +0000 Subject: [PATCH 06/40] Docking: repin craft-libs 6e7f285 (the docking follow-ups) --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a15fe2744..5c90435b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,12 +1084,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "egui", "log", diff --git a/Cargo.toml b/Cargo.toml index 20118c218..136644fce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ rust-version = "1.95" repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", features = ["serde"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From ee1576682abc0ec7e6672c7cae815ba6fb4000ec Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:33:27 +0000 Subject: [PATCH 07/40] Docking: a drop at the window's edge makes no dock column The side panels are frames whose widths LightCraft tracks, so new_dock_zones is off. Test: an edge drop of Tools leaves the layout without a Node::Dock. --- crates/ui-egui/src/docking.rs | 2 ++ crates/ui-egui/tests/it/docking_workflows.rs | 23 ++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 4128b1628..636081967 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -298,6 +298,8 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { app.ui.docking = Some(workspace.clone()); let mut style = DockStyle::from_ui(ui); style.min_pane = 96.0; + // The side panels are frames whose widths LightCraft tracks; dock columns are not offered. + style.new_dock_zones = false; style.float_label = crate::i18n::tr("Float panel").into(); style.close_label = crate::i18n::tr("Close panel").into(); style.move_panel_to_label = crate::i18n::tr("Move to group").into(); diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs index 7538946b1..a8e6c3d51 100644 --- a/crates/ui-egui/tests/it/docking_workflows.rs +++ b/crates/ui-egui/tests/it/docking_workflows.rs @@ -116,6 +116,29 @@ fn dragging_a_group_by_its_bar_moves_the_whole_group() { assert_eq!(h.state().ui.right, RightPanel::Edit); } +/// LightCraft's side panels live in frames; a drop at the window's edge makes no dock column. +#[test] +fn an_edge_drop_does_not_create_a_dock() { + for at_right in [false, true] { + let mut h = harness(None, 1400.0, 1.0); + let library = h.state().widgets.iter().find(|(id, _)| id == "panel:left_panel").unwrap().1; + let tools = h.state().widgets.iter().find(|(id, _)| id == "panel:right_panel").unwrap().1; + let x = if at_right { tools.right() - 4.0 } else { library.left() + 4.0 }; + let source = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").rect().center(); + drag(&mut h, source, egui::pos2(x, 500.0)); + let layout = &h.state().ui.docking.as_ref().unwrap().layout; + assert!(layout.contains(&Panel::Tools)); + let mut nodes: Vec<_> = layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Dock { .. } => panic!("a drop at x = {x} made a dock: {layout:?}"), + _ => {} + } + } + } +} + #[test] fn invalid_docking_requests_preserve_layout_and_selected_tool() { let mut h = harness(None, 1400.0, 1.0); From e442e3d220a8ac43460e8927da2afe56da76392f Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:36:03 +0000 Subject: [PATCH 08/40] Docking: a panel with no recorded place opens beside the photo on its side Showing a panel that has no hidden entry skips craft-ui's right-edge fallback and opens it beside the photo view on the panel's default side. Test: Library opens to the left of the photo, Presets to the right. --- crates/ui-egui/src/docking.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 636081967..017ffe842 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -128,10 +128,14 @@ fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result return apply(workspace, Action::Close { panel }); } validate(workspace)?; - let mut next = workspace.clone(); - if next.set_visible(panel, true, None, permissions).is_ok() && validate(&next).is_ok() { - *workspace = next; - return Ok(()); + // Without a recorded place craft-ui opens the panel at the right edge; LightCraft puts it + // beside the photo instead. + if workspace.hidden.iter().any(|(hidden, _)| *hidden == panel) { + let mut next = workspace.clone(); + if next.set_visible(panel, true, None, permissions).is_ok() && validate(&next).is_ok() { + *workspace = next; + return Ok(()); + } } apply(workspace, Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }) } @@ -578,4 +582,15 @@ mod tests { assert_eq!(saved["version"], 2); assert_eq!(saved["layout"]["floating"][0]["panels"], json!(["tools"]), "a one-group window keeps the v1 shape"); } + + #[test] + fn a_panel_without_a_recorded_place_opens_beside_the_photo_on_its_side() { + for (panel, zone) in [(Panel::Library, Zone::Left), (Panel::Presets, Zone::Right)] { + let root = split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Tools)); + let mut workspace = Workspace::new(Layout { root: Some(root), floating: Vec::new() }); + set_visible(&mut workspace, panel, true).unwrap(); + let location = workspace.layout.location(&panel).unwrap(); + assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(zone)), "{panel:?}"); + } + } } From 63ab092576a8d2e52ad565f281d25ea74378cd13 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:37:41 +0000 Subject: [PATCH 09/40] View: F8 on a right panel that isn't showing brings it forward in its mode When the right panel is open but minimized or behind another tab, F8 keeps its mode instead of switching to the one it last hid, and view.rightPanel reveals Tools. Test: F8 on a minimized floating Tools window in Crop opens the window and stays in Crop. --- crates/ui-egui/src/docking.rs | 1 + crates/ui-egui/src/menus.rs | 4 +++- crates/ui-egui/src/shortcuts.rs | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 017ffe842..2fc69e0da 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -229,6 +229,7 @@ pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result // the sidebar it scrolls, also when it is a tab behind another panel Some(Panel::Library) } else if (id.starts_with("panel.") && id != "panel.close" && id != "panel.presets" + || id == "view.rightPanel" || id.starts_with("section.") || id.starts_with("tool.") && id != "tool.none") && app.ui.right != crate::state::RightPanel::None diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a162e6ca6..baf3d4514 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -865,7 +865,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option RightPanel::Edit, p => p, @@ -878,6 +878,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 032548f6a..435936190 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -863,6 +863,24 @@ mod tests { assert_eq!(h.app.ui.right, RightPanel::Edit); } + /// F8 on a minimized Tools window opens it again in the mode it had. + #[test] + fn classic_f8_shows_a_minimized_tools_window_and_keeps_its_mode() { + use crate::{docking::Panel, state::RightPanel}; + let mut h = library(); + h.app.ui.view = crate::state::ViewMode::Detail; + h.app.ui.right = RightPanel::Edit; + h.settle(std::time::Duration::from_secs(10)); + h.app.run("ui.dock", json!({"operation": "float", "panel": "tools", "rect": [400, 100, 320, 500]})).unwrap(); + h.app.run("ui.dock", json!({"action": {"SetMinimized": {"panel": "tools", "minimized": true}}})).unwrap(); + h.app.ui.right = RightPanel::Crop; + h.step(); + assert!(!crate::docking::exposed(&h.app, Panel::Tools)); + key(&mut h, "F8", false); + assert_eq!(h.app.ui.right, RightPanel::Crop); + assert!(crate::docking::exposed(&h.app, Panel::Tools), "the window opens"); + } + #[test] fn shift_culling_keys_advance_exactly_once() { use lightcraft_catalog::{ColorLabel, Flag, PhotoId}; From 1e4cc14dfbceb3c26c3294d5a208fde9d3af776f Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:41:54 +0000 Subject: [PATCH 10/40] Docking: group and window menu labels go through tr() in sentence case --- crates/ui-egui/src/docking.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 2fc69e0da..be4c9d060 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -311,6 +311,16 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { style.panels_label = crate::i18n::tr("Panels").into(); style.resize_label = crate::i18n::tr("Resize panels").into(); style.resize_window_label = crate::i18n::tr("Resize panel window").into(); + style.move_group_label = crate::i18n::tr("Move tab group").into(); + style.move_group_to_label = crate::i18n::tr("Move group to").into(); + style.float_group_label = crate::i18n::tr("Float tab group").into(); + style.close_group_label = crate::i18n::tr("Close tab group").into(); + style.close_others_label = crate::i18n::tr("Close other panels in group").into(); + style.minimize_label = crate::i18n::tr("Minimize").into(); + style.expand_label = crate::i18n::tr("Expand").into(); + style.move_window_label = crate::i18n::tr("Move panel window").into(); + style.close_window_label = crate::i18n::tr("Close panel window").into(); + style.float_hint_label = crate::i18n::tr("Hold {key} to undock").into(); style.background = t.chrome; style.tab_background = t.chrome; style.active_background = t.canvas; From 02aa1e0f59e80f2ee271afa64fa5212fd2d0347a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:41:54 +0000 Subject: [PATCH 11/40] Docs: only the five split zones of ui.dock move have PascalCase names --- docs/control-protocol.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ebaebcbb6..e4c03a9d7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -368,7 +368,7 @@ Library, Tools, and Presets panels can be regrouped, split, floated within the a Panel IDs: `library`, `tools`, `presets`. The protected center is `canvas`. -`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|tab|left|right|top|bottom|above|below`; PascalCase names also parse). Center and tab group with the target; side zones split beside it; above and below put the panel in a new group above or below the target's group. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Both commands return `{"docking": workspace}`. +`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|tab|left|right|top|bottom|above|below`; `Center`, `Left`, `Right`, `Top` and `Bottom` also parse, while `tab`, `above` and `below` are lowercase only). Center and tab group with the target; side zones split beside it; above and below put the panel in a new group above or below the target's group. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Both commands return `{"docking": workspace}`. Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tools","rect":[100,100,320,440]}}`. From 8e16c05cbb1e3cf609cef990bf4f94ccd359a20c Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 00:34:42 +0000 Subject: [PATCH 12/40] Docking: a panel whose saved place is gone opens beside the photo on its side When a hidden panel's anchor is neither shown nor hidden, craft-ui falls back to the given anchor's tabs. The photo view is that anchor and takes no tabs, so the panel opens beside it on its default side instead of at the right edge. Test: Library anchored on an absent Presets opens to the left of the photo. --- crates/ui-egui/src/docking.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index be4c9d060..6feebe5cb 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -132,7 +132,9 @@ fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result // beside the photo instead. if workspace.hidden.iter().any(|(hidden, _)| *hidden == panel) { let mut next = workspace.clone(); - if next.set_visible(panel, true, None, permissions).is_ok() && validate(&next).is_ok() { + // A saved place that can't be rebuilt falls back to joining the fallback's tabs. The + // photo view takes no tabs, so that is refused and the panel opens beside it below. + if next.set_visible(panel, true, Some(Panel::Canvas), permissions).is_ok() && validate(&next).is_ok() { *workspace = next; return Ok(()); } @@ -604,4 +606,25 @@ mod tests { assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(zone)), "{panel:?}"); } } + + #[test] + fn a_panel_whose_saved_place_is_gone_opens_beside_the_photo_on_its_side() { + let root = split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Tools)); + let mut workspace = Workspace::new(Layout { root: Some(root), floating: Vec::new() }); + // Presets, the panel Library went back beside, is neither shown nor hidden. + let location: craft_ui::docking::Location = serde_json::from_value(json!({ + "floating": null, + "anchor": "presets", + "placement": {"Split": "Left"}, + "size": {"FixedFirst": 280.0}, + "stack": null, + "sibling": ["presets"] + })) + .unwrap(); + workspace.hidden.push((Panel::Library, location)); + validate(&workspace).unwrap(); + set_visible(&mut workspace, Panel::Library, true).unwrap(); + let location = workspace.layout.location(&Panel::Library).unwrap(); + assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(Zone::Left))); + } } From e8588b925b477043e5947584884d9f0c5fda7acb Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 03:59:13 +0000 Subject: [PATCH 13/40] Docking: repin craft-ui and craft-docpath to craft-libs 8a503cf --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5c90435b2..d1d45b277 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,12 +1084,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" dependencies = [ "egui", "log", diff --git a/Cargo.toml b/Cargo.toml index 136644fce..e9d36bd16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ rust-version = "1.95" repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", features = ["serde"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From 4e6c210a5f4cd728d8001f2701f1320c05f95927 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 05:57:50 +0000 Subject: [PATCH 14/40] Docking: repin craft-ui and craft-docpath to craft-libs 4dc543c --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d1d45b277..eb0c8168a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,12 +1084,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" +source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" +source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" dependencies = [ "egui", "log", diff --git a/Cargo.toml b/Cargo.toml index e9d36bd16..70b4db861 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ rust-version = "1.95" repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee", features = ["serde"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From 8ae15d4f5e891605394f977920e1d6e847219bd3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 12:17:28 +0000 Subject: [PATCH 15/40] Docking: side panels in dock columns; saved split-frame layouts reset once --- apps/lightcraft/src/main.rs | 4 +- crates/ui-egui/src/docking.rs | 285 +++++++++++++++--- crates/ui-egui/src/state.rs | 4 + .../ui-egui/src/tests_docking_acceptance.rs | 30 +- crates/ui-egui/tests/it/docking_workflows.rs | 59 ++-- 5 files changed, 302 insertions(+), 80 deletions(-) diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 0cab9e189..b971e5519 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -259,10 +259,12 @@ fn load_prefs(in_memory: bool) -> (Option, Option, bool) { load_prefs_at(&path, !in_memory) } -/// Compatibility recovery is limited to persisted docking, not runtime UiState commands. +/// Compatibility recovery is limited to persisted docking, not runtime UiState commands. An +/// arrangement from an older docking version starts again from the default without a warning. fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> { let mut saved: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| error.to_string())?; let fields = saved.as_object_mut().ok_or("settings must be an object")?; + lightcraft_ui_egui::docking::drop_outdated(fields); let recovered = fields.get("docking").is_some_and(|value| match serde_json::from_value::>(value.clone()) { Ok(Some(workspace)) => lightcraft_ui_egui::docking::validate(&workspace).is_err(), diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 6feebe5cb..59c00b0f6 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -2,8 +2,8 @@ //! Document contents remain owned by their existing view implementations. use craft_ui::docking::{ - Action, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Layout, Node, PanelLimits, PanelState, Permissions, Placement, - UiDock, Zone, + Action, Column, ColumnLocation, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Group, GroupLocation, Layout, Location, + Node, PanelLimits, PanelState, Permissions, Placement, Side, UiDock, Zone, }; use craft_ui::layout::{SplitAxis, SplitSize}; use serde::{Deserialize, Serialize}; @@ -36,16 +36,38 @@ impl Panel { Self::Presets => "Presets", } } - fn default_side(self) -> Zone { + /// The dock the panel opens in when it has no recorded place. + fn home_side(self) -> Side { + if self == Self::Library { Side::Start } else { Side::End } + } + /// The width of the panel's own column in the default workspace. + fn home_width(self) -> f32 { match self { - Self::Canvas => Zone::Right, - Self::Library => Zone::Left, - Self::Tools => Zone::Right, - Self::Presets => Zone::Right, + Self::Canvas => crate::state::MIN_PHOTO_WIDTH, + Self::Library => crate::state::LEFT_WIDTH.default, + Self::Tools => crate::state::RIGHT_WIDTH.default, + Self::Presets => PRESETS_WIDTH, } } } +const PRESETS_WIDTH: f32 = 300.0; + +/// The arrangement generation saved as `UiState::docking_version`. Settings without it hold the +/// side panels in split frames; they start again from [`default_workspace`]. +pub const DOCKING_VERSION: u32 = 1; + +/// Drops saved settings' arrangement from before [`DOCKING_VERSION`], with the places of its +/// closed panels, so the app starts from the default workspace; every other setting stays. +/// Returns true when an arrangement was dropped. +pub fn drop_outdated(settings: &mut serde_json::Map) -> bool { + if settings.get("dockingVersion").and_then(Value::as_u64).is_some_and(|version| version >= u64::from(DOCKING_VERSION)) { + return false; + } + settings.insert("dockingVersion".into(), json!(DOCKING_VERSION)); + settings.remove("docking").is_some_and(|docking| !docking.is_null()) +} + fn permissions(panel: &Panel) -> Permissions { // Panels open and split beside the photo view, but never join it as tabs. if *panel == Panel::Canvas { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() } @@ -62,23 +84,49 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node; -pub fn default_workspace() -> Workspace { +fn column(panel: Panel, width: f32) -> Column { + Column::new(vec![Group::new(vec![panel], 0)], width) +} + +/// Library in a start dock and Tools in an end dock beside the photo. Presets has a column of +/// its own on Tools' photo side rather than a group under Tools: both are tall lists that are +/// read side by side while trying presets, and stacking them would halve both. +fn docked(left: f32, right: f32) -> Workspace { + let start = Node::Dock { side: Side::Start, columns: vec![column(Panel::Library, left)] }; + let end = Node::Dock { side: Side::End, columns: vec![column(Panel::Tools, right), column(Panel::Presets, PRESETS_WIDTH)] }; Workspace::new(Layout { root: Some(split( SplitAxis::Horizontal, - SplitSize::FixedFirst(crate::state::LEFT_WIDTH.default), - tabs(Panel::Library), - split( - SplitAxis::Horizontal, - SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.default), - split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)), - tabs(Panel::Tools), - ), + SplitSize::FixedFirst(left), + start, + split(SplitAxis::Horizontal, SplitSize::FixedSecond(right + PRESETS_WIDTH), tabs(Panel::Canvas), end), )), floating: Vec::new(), }) } +pub fn default_workspace() -> Workspace { + docked(crate::state::LEFT_WIDTH.default, crate::state::RIGHT_WIDTH.default) +} + +/// Where a panel without a usable recorded place opens: its own column at its dock's window +/// edge, or Presets on the photo side of Tools while Tools is in the end dock. +fn home(workspace: &Workspace, panel: Panel) -> Location { + let column = ColumnLocation { side: panel.home_side(), width: panel.home_width(), iconic: false, iconic_width: None }; + let docked_at = + |other: Panel| workspace.layout.location(&other).ok().and_then(|location| location.group).and_then(|group| group.column).map(|c| c.side); + let beside = (panel == Panel::Presets && docked_at(Panel::Tools) == Some(Side::End)).then_some(Panel::Tools); + Location { + floating: None, + anchor: beside, + placement: if beside.is_some() { Placement::Column { after: true } } else { Placement::Tab { before: None } }, + size: None, + stack: None, + sibling: Vec::new(), + group: Some(GroupLocation { minimized: false, height: None, column: Some(column) }), + } +} + fn error(error: DockError) -> String { error.to_string() } @@ -118,7 +166,7 @@ fn apply(workspace: &mut Workspace, action: Action) -> Result<(), String> Ok(()) } -/// Shows a panel at its recorded place, else beside the photo on its usual side. A panel that +/// Shows a panel at its recorded place, else in its dock column ([`home`]). A panel that /// already has the asked visibility keeps its tab selection. fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result<(), String> { if workspace.layout.contains(&panel) == visible { @@ -128,18 +176,23 @@ fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result return apply(workspace, Action::Close { panel }); } validate(workspace)?; - // Without a recorded place craft-ui opens the panel at the right edge; LightCraft puts it - // beside the photo instead. + // A recorded place that can't be rebuilt falls back to joining the fallback's tabs. The + // photo view takes no tabs, so that is refused and the panel goes to its home column below. if workspace.hidden.iter().any(|(hidden, _)| *hidden == panel) { let mut next = workspace.clone(); - // A saved place that can't be rebuilt falls back to joining the fallback's tabs. The - // photo view takes no tabs, so that is refused and the panel opens beside it below. if next.set_visible(panel, true, Some(Panel::Canvas), permissions).is_ok() && validate(&next).is_ok() { *workspace = next; return Ok(()); } } - apply(workspace, Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }) + let mut next = workspace.clone(); + next.hidden.retain(|(hidden, _)| *hidden != panel); + let location = home(&next, panel); + next.hidden.push((panel, location)); + next.set_visible(panel, true, Some(Panel::Canvas), permissions).map_err(error)?; + validate(&next)?; + *workspace = next; + Ok(()) } /// Membership is passive state; an explicit command also exposes the panel's body. @@ -177,14 +230,7 @@ fn run(workspace: &mut Workspace, params: &Value) -> Result<(), String> { } fn from_ui(app: &crate::LightcraftApp) -> Workspace { - let mut workspace = default_workspace(); - if let Some(Node::Split { size, second, .. }) = workspace.layout.root.as_mut() { - *size = SplitSize::FixedFirst(crate::state::LEFT_WIDTH.clamp(app.ui.left_width)); - if let Node::Split { size, .. } = second.as_mut() { - *size = SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.clamp(app.ui.right_width)); - } - } - workspace + docked(crate::state::LEFT_WIDTH.clamp(app.ui.left_width), crate::state::RIGHT_WIDTH.clamp(app.ui.right_width)) } fn sync(workspace: &mut Workspace, app: &crate::LightcraftApp) -> Result<(), String> { @@ -207,10 +253,12 @@ fn publish_visibility(app: &mut crate::LightcraftApp, workspace: &Workspace) { let Ok(location) = workspace.layout.location(&panel) else { continue; }; - let value = match (location.floating, location.placement, location.size) { - (Some([_, _, width, _]), _, _) - | (None, Placement::Split(Zone::Left), Some(SplitSize::FixedFirst(width))) - | (None, Placement::Split(Zone::Right), Some(SplitSize::FixedSecond(width))) => Some(width), + let column = location.group.and_then(|group| group.column); + let value = match (location.floating, column, location.placement, location.size) { + (Some([_, _, width, _]), ..) => Some(width), + (None, Some(column), ..) => Some(column.width), + (None, None, Placement::Split(Zone::Left), Some(SplitSize::FixedFirst(width))) + | (None, None, Placement::Split(Zone::Right), Some(SplitSize::FixedSecond(width))) => Some(width), // A vertical split stores height, and the opposite fixed side sizes a sibling. _ => None, }; @@ -305,8 +353,10 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { app.ui.docking = Some(workspace.clone()); let mut style = DockStyle::from_ui(ui); style.min_pane = 96.0; - // The side panels are frames whose widths LightCraft tracks; dock columns are not offered. - style.new_dock_zones = false; + // Free docking: a panel dropped at an empty window edge makes a dock, and the photo takes + // drops on all four sides. + style.new_dock_zones = true; + style.side_edge_zones = false; style.float_label = crate::i18n::tr("Float panel").into(); style.close_label = crate::i18n::tr("Close panel").into(); style.move_panel_to_label = crate::i18n::tr("Move to group").into(); @@ -320,6 +370,12 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { style.close_others_label = crate::i18n::tr("Close other panels in group").into(); style.minimize_label = crate::i18n::tr("Minimize").into(); style.expand_label = crate::i18n::tr("Expand").into(); + style.move_column_label = crate::i18n::tr("Move panel column").into(); + style.collapse_to_icons_label = crate::i18n::tr("Collapse to Icons").into(); + style.expand_panels_label = crate::i18n::tr("Expand Panels").into(); + style.collapse_column_label = crate::i18n::tr("Collapse column").into(); + style.expand_column_label = crate::i18n::tr("Expand column").into(); + style.auto_collapse_label = crate::i18n::tr("Auto-Collapse Iconic Panels").into(); style.move_window_label = crate::i18n::tr("Move panel window").into(); style.close_window_label = crate::i18n::tr("Close panel window").into(); style.float_hint_label = crate::i18n::tr("Hold {key} to undock").into(); @@ -399,7 +455,8 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { } } -/// Keep the established ui.set width controls connected to the panel's current parent split. +/// Keep the established ui.set width controls connected to the panel's dock column, floating +/// window or parent split. pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(), String> { state.left_width = crate::state::LEFT_WIDTH.clamp(state.left_width); state.right_width = crate::state::RIGHT_WIDTH.clamp(state.right_width); @@ -415,6 +472,10 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( if let Some(rect) = &mut location.floating { rect[2] = width; } + if let Some(column) = location.group.as_mut().and_then(|group| group.column.as_mut()) { + column.width = width; + continue; + } match location.placement { Placement::Split(Zone::Left) => location.size = Some(SplitSize::FixedFirst(width)), Placement::Split(Zone::Right) => location.size = Some(SplitSize::FixedSecond(width)), @@ -428,6 +489,10 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<( apply(workspace, Action::MoveFloating { panel, rect })?; continue; } + if workspace.layout.location(&panel).is_ok_and(|location| location.group.is_some_and(|group| group.column.is_some())) { + apply(workspace, Action::ResizeColumn { panel, width })?; + continue; + } let mut nodes = workspace.layout.root.as_ref().map(|node| vec![(node, Vec::new())]).unwrap_or_default(); let mut change = None; while let Some((node, path)) = nodes.pop() { @@ -470,6 +535,23 @@ fn panel_limits(panel: &Panel) -> PanelLimits { PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) } } +/// The columns of the dock on `side`, from the window edge inwards: width and the panels of +/// each group. +#[cfg(test)] +pub(crate) fn columns(workspace: &Workspace, side: Side) -> Vec<(f32, Vec>)> { + let mut nodes: Vec<_> = workspace.layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Dock { side: dock, columns } if *dock == side => { + return columns.iter().map(|column| (column.width, column.groups.iter().map(|group| group.panels.clone()).collect())).collect(); + } + _ => {} + } + } + Vec::new() +} + #[cfg(test)] mod tests { use super::*; @@ -533,7 +615,7 @@ mod tests { } #[test] - fn legacy_width_publication_reads_own_horizontal_and_floating_widths() { + fn legacy_width_publication_reads_own_split_and_floating_widths() { for (size, first) in [(SplitSize::FixedFirst(310.0), true), (SplitSize::FixedSecond(310.0), false)] { let workspace = tools_split(SplitAxis::Horizontal, size, first); let mut app = app_with_widths(); @@ -597,18 +679,125 @@ mod tests { } #[test] - fn a_panel_without_a_recorded_place_opens_beside_the_photo_on_its_side() { - for (panel, zone) in [(Panel::Library, Zone::Left), (Panel::Presets, Zone::Right)] { - let root = split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Tools)); - let mut workspace = Workspace::new(Layout { root: Some(root), floating: Vec::new() }); - set_visible(&mut workspace, panel, true).unwrap(); - let location = workspace.layout.location(&panel).unwrap(); - assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(zone)), "{panel:?}"); + fn default_workspace_docks_library_at_the_start_and_tools_and_presets_at_the_end() { + let workspace = default_workspace(); + validate(&workspace).unwrap(); + assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]); + assert_eq!( + columns(&workspace, Side::End), + [(crate::state::RIGHT_WIDTH.default, vec![vec![Panel::Tools]]), (PRESETS_WIDTH, vec![vec![Panel::Presets]])], + "Presets has its own column on Tools' photo side" + ); + let Some(Node::Split { second, .. }) = &workspace.layout.root else { panic!("{:?}", workspace.layout.root) }; + let Node::Split { first, .. } = &**second else { panic!("{second:?}") }; + assert_eq!(**first, tabs(Panel::Canvas), "the photo sits between the docks"); + assert!(workspace.layout.floating.is_empty()); + let app = app_with_widths(); + let workspace = from_ui(&app); + assert_eq!(columns(&workspace, Side::Start).first().map(|column| column.0), Some(333.0)); + assert_eq!(columns(&workspace, Side::End).first().map(|column| column.0), Some(410.0)); + } + + #[test] + fn dock_column_widths_publish_and_follow_width_controls() { + let mut app = app_with_widths(); + let workspace = docked(300.0, 310.0); + publish_visibility(&mut app, &workspace); + assert_eq!((app.ui.left_width, app.ui.right_width), (300.0, 310.0)); + let mut state = crate::UiState { docking: Some(workspace), left_width: 350.0, right_width: 400.0, ..Default::default() }; + set_visible(state.docking.as_mut().unwrap(), Panel::Library, false).unwrap(); + set_widths(&mut state, &json!({"leftWidth":350.0, "rightWidth":400.0})).unwrap(); + let workspace = state.docking.as_mut().unwrap(); + assert_eq!(columns(workspace, Side::End).first().map(|column| column.0), Some(400.0), "Tools' column"); + set_visible(workspace, Panel::Library, true).unwrap(); + assert_eq!(columns(workspace, Side::Start), [(350.0, vec![vec![Panel::Library]])], "the closed Library's column"); + } + + #[test] + fn a_panel_without_a_recorded_place_opens_in_its_dock() { + let root = split( + SplitAxis::Horizontal, + SplitSize::FixedSecond(300.0), + tabs(Panel::Canvas), + Node::Dock { side: Side::End, columns: vec![column(Panel::Tools, 300.0)] }, + ); + let mut workspace = Workspace::new(Layout { root: Some(root), floating: Vec::new() }); + set_visible(&mut workspace, Panel::Presets, true).unwrap(); + set_visible(&mut workspace, Panel::Library, true).unwrap(); + validate(&workspace).unwrap(); + assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]); + assert_eq!(columns(&workspace, Side::End), [(300.0, vec![vec![Panel::Tools]]), (PRESETS_WIDTH, vec![vec![Panel::Presets]])]); + // Without Tools in the end dock, Presets takes the dock's window edge. + let mut workspace = default_workspace(); + set_visible(&mut workspace, Panel::Presets, false).unwrap(); + workspace.hidden.clear(); + apply(&mut workspace, Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap(); + set_visible(&mut workspace, Panel::Presets, true).unwrap(); + assert_eq!(columns(&workspace, Side::End), [(PRESETS_WIDTH, vec![vec![Panel::Presets]])]); + } + + fn saved_settings(docking: Value, version: Option) -> serde_json::Map { + let ui = crate::UiState { thumb_size: 210.0, filmstrip: false, presets: true, ..Default::default() }; + let mut saved = serde_json::to_value(&ui).unwrap(); + let fields = saved.as_object_mut().unwrap(); + fields.insert("docking".into(), docking); + match version { + Some(version) => fields.insert("dockingVersion".into(), json!(version)), + None => fields.remove("dockingVersion"), + }; + fields.clone() + } + + #[test] + fn a_saved_split_frame_layout_without_a_docking_version_loads_as_the_default_docks() { + let frames = split( + SplitAxis::Horizontal, + SplitSize::FixedFirst(333.0), + tabs(Panel::Library), + split(SplitAxis::Horizontal, SplitSize::FixedSecond(410.0), tabs(Panel::Canvas), tabs(Panel::Tools)), + ); + let frames = Workspace::new(Layout { root: Some(frames), floating: Vec::new() }); + validate(&frames).unwrap(); + for version in [None, Some(0)] { + let mut fields = saved_settings(serde_json::to_value(&frames).unwrap(), version); + assert!(drop_outdated(&mut fields)); + let ui: crate::UiState = serde_json::from_value(Value::Object(fields)).unwrap(); + assert!(ui.docking.is_none()); + assert_eq!(ui.docking_version, DOCKING_VERSION); + assert_eq!((ui.thumb_size, ui.filmstrip, ui.presets), (210.0, false, true), "other settings stay"); + let mut app = app_with_widths(); + app.ui = ui; + command(&mut app, false, &json!({"operation":"activate","panel":"tools"})).unwrap(); + let workspace = app.ui.docking.as_ref().unwrap(); + assert!(!workspace.layout.contains(&Panel::Library), "the Library panel stays closed"); + assert_eq!( + columns(workspace, Side::End), + [(crate::state::RIGHT_WIDTH.default, vec![vec![Panel::Tools]]), (PRESETS_WIDTH, vec![vec![Panel::Presets]])] + ); } } #[test] - fn a_panel_whose_saved_place_is_gone_opens_beside_the_photo_on_its_side() { + fn a_saved_dock_layout_round_trips() { + let mut workspace = default_workspace(); + apply(&mut workspace, Action::ResizeColumn { panel: Panel::Tools, width: 333.0 }).unwrap(); + apply( + &mut workspace, + Action::Move { panel: Panel::Presets, anchor: Panel::Library, placement: Placement::Group { below: true, height: None } }, + ) + .unwrap(); + set_visible(&mut workspace, Panel::Library, false).unwrap(); + let mut fields = saved_settings(serde_json::to_value(&workspace).unwrap(), Some(DOCKING_VERSION)); + assert!(!drop_outdated(&mut fields)); + let ui: crate::UiState = serde_json::from_value(Value::Object(fields)).unwrap(); + let loaded = ui.docking.unwrap(); + validate(&loaded).unwrap(); + assert_eq!(loaded, workspace); + assert_eq!(serde_json::to_value(crate::UiState::default()).unwrap()["dockingVersion"], DOCKING_VERSION, "saves write the version"); + } + + #[test] + fn a_panel_whose_saved_place_is_gone_opens_in_its_dock() { let root = split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Tools)); let mut workspace = Workspace::new(Layout { root: Some(root), floating: Vec::new() }); // Presets, the panel Library went back beside, is neither shown nor hidden. @@ -624,7 +813,7 @@ mod tests { workspace.hidden.push((Panel::Library, location)); validate(&workspace).unwrap(); set_visible(&mut workspace, Panel::Library, true).unwrap(); - let location = workspace.layout.location(&Panel::Library).unwrap(); - assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(Zone::Left))); + validate(&workspace).unwrap(); + assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]); } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 0bbd619d8..f9da46444 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -782,6 +782,9 @@ pub struct UiState { pub window: Option, /// Shared panel arrangement; absent in legacy settings until their first layout. pub docking: Option, + /// The arrangement generation `docking` was saved in ([`crate::docking::DOCKING_VERSION`]); + /// settings files are read through [`crate::docking::drop_outdated`]. + pub docking_version: u32, #[serde(default = "crate::i18n::default_language")] pub language: crate::i18n::Locale, /// The Build Previews run last announced (its identity, finished?). @@ -1482,6 +1485,7 @@ impl Default for UiState { UiState { window: None, docking: None, + docking_version: crate::docking::DOCKING_VERSION, language: crate::i18n::default_language(), preview_build_seen: None, unsaved_seen: false, diff --git a/crates/ui-egui/src/tests_docking_acceptance.rs b/crates/ui-egui/src/tests_docking_acceptance.rs index 5e496b822..940451f19 100644 --- a/crates/ui-egui/src/tests_docking_acceptance.rs +++ b/crates/ui-egui/src/tests_docking_acceptance.rs @@ -44,6 +44,30 @@ fn release(h: &mut Headless, pos: egui::Pos2) { h.step(); } +#[test] +fn window_menu_reopens_closed_panels_in_their_dock_columns() { + use craft_ui::docking::Side; + let mut h = app(); + let columns = |h: &Headless, side| { + crate::docking::columns(h.app.ui.docking.as_ref().unwrap(), side).into_iter().map(|(_, groups)| groups).collect::>() + }; + let end = vec![vec![vec![Panel::Tools]], vec![vec![Panel::Presets]]]; + h.app.run("panel.presets", json!({})).unwrap(); + h.step(); + assert_eq!(columns(&h, Side::End), end, "Presets opens on Tools' photo side"); + dock(&mut h, json!({"operation":"close","panel":"tools"})); + assert_eq!(h.app.ui.right, RightPanel::None); + h.app.run("panel.edit", json!({})).unwrap(); + h.step(); + assert_eq!(columns(&h, Side::End), end, "Tools reopens at the window edge"); + let width = crate::docking::columns(h.app.ui.docking.as_ref().unwrap(), Side::Start).first().unwrap().0; + dock(&mut h, json!({"operation":"close","panel":"library"})); + assert!(!h.app.ui.left_panel); + h.app.run("view.leftPanel", json!({})).unwrap(); + h.step(); + assert_eq!(crate::docking::columns(h.app.ui.docking.as_ref().unwrap(), Side::Start), [(width, vec![vec![Panel::Library]])]); +} + #[test] fn inactive_same_mode_commands_reveal_without_closing_or_editing() { let mut h = app(); @@ -271,8 +295,8 @@ fn covered_keyword_rejects_hierarchy_move_and_uncovered_target_moves_exact_keywo #[test] fn reset_during_held_divider_drag_keeps_replacement_layout_on_later_movement() { let mut h = app(); - let divider = egui::Id::new("lightcraft-docking").with(("split", Vec::::new())); - let from = h.view.ctx.read_response(divider).expect("the custom workspace root divider is rendered").rect.center(); + let divider = egui::Id::new("lightcraft-docking").with(("column-resize", Some(Panel::Library))); + let from = h.view.ctx.read_response(divider).expect("the Library column's divider is rendered").rect.center(); let before = h.app.ui.docking.as_ref().unwrap().layout.clone(); h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); h.step(); @@ -303,7 +327,7 @@ fn reset_during_held_divider_drag_keeps_replacement_layout_on_later_movement() { fn held_divider_replacement_route(route: &str) { let mut h = app(); - let divider = egui::Id::new("lightcraft-docking").with(("split", Vec::::new())); + let divider = egui::Id::new("lightcraft-docking").with(("column-resize", Some(Panel::Library))); let from = h.view.ctx.read_response(divider).unwrap().rect.center(); let photos = serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::>()).unwrap(); h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs index a8e6c3d51..1d360a6b2 100644 --- a/crates/ui-egui/tests/it/docking_workflows.rs +++ b/crates/ui-egui/tests/it/docking_workflows.rs @@ -1,5 +1,5 @@ //! Real library/tool panel moves preserve the selected tool and saved arrangement. -use craft_ui::docking::{DockArea, Hit, Node}; +use craft_ui::docking::{DockArea, Hit, Node, Side}; use egui::{Event, Modifiers, PointerButton, vec2}; use egui_kittest::{Harness, kittest::Queryable}; use lightcraft_ui_egui::{LightcraftApp, Services, UiState, docking::Panel, state::RightPanel}; @@ -100,42 +100,45 @@ fn dragging_a_group_by_its_bar_moves_the_whole_group() { .expect("the Tools and Presets group draws its drag bar"); let target = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Library").rect().center(); drag(&mut h, bar.center(), target); - let layout = &h.state().ui.docking.as_ref().unwrap().layout; - let mut nodes: Vec<_> = layout.root.iter().collect(); - let mut groups = Vec::new(); - while let Some(node) = nodes.pop() { - match node { - Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), - Node::Tabs { panels, .. } => groups.push(panels.clone()), - _ => {} - } - } - let mut library = groups.into_iter().find(|panels| panels.contains(&Panel::Library)).unwrap(); + let mut library = start_dock(&h).into_iter().flatten().find(|panels| panels.contains(&Panel::Library)).unwrap(); library.sort_by_key(|panel| panel.id()); assert_eq!(library, [Panel::Library, Panel::Presets, Panel::Tools], "both panels of the dragged group joined Library"); assert_eq!(h.state().ui.right, RightPanel::Edit); } -/// LightCraft's side panels live in frames; a drop at the window's edge makes no dock column. +/// The start dock's columns from the window edge inwards, each as its groups' panels. +fn start_dock(h: &Harness<'_, LightcraftApp>) -> Vec>> { + let layout = &h.state().ui.docking.as_ref().unwrap().layout; + let mut nodes: Vec<_> = layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Dock { side: Side::Start, columns } => { + return columns.iter().map(|column| column.groups.iter().map(|group| group.panels.clone()).collect()).collect(); + } + _ => {} + } + } + Vec::new() +} + +/// In a dock column, a group's top third stacks above it, its photo-side third adds a column +/// there and its middle joins its tabs. #[test] -fn an_edge_drop_does_not_create_a_dock() { - for at_right in [false, true] { +fn a_tab_dropped_on_a_dock_group_stacks_adds_a_column_or_joins_by_where_it_lands() { + let cases: [(fn(egui::Rect) -> egui::Pos2, Vec>>); 3] = [ + (|r| egui::pos2(r.center().x, r.top() + r.height() / 6.0), vec![vec![vec![Panel::Tools], vec![Panel::Library]]]), + (|r| egui::pos2(r.right() - r.width() / 6.0, r.center().y), vec![vec![vec![Panel::Library]], vec![vec![Panel::Tools]]]), + (|r| r.center(), vec![vec![vec![Panel::Library, Panel::Tools]]]), + ]; + for (at, expected) in cases { let mut h = harness(None, 1400.0, 1.0); let library = h.state().widgets.iter().find(|(id, _)| id == "panel:left_panel").unwrap().1; - let tools = h.state().widgets.iter().find(|(id, _)| id == "panel:right_panel").unwrap().1; - let x = if at_right { tools.right() - 4.0 } else { library.left() + 4.0 }; let source = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").rect().center(); - drag(&mut h, source, egui::pos2(x, 500.0)); - let layout = &h.state().ui.docking.as_ref().unwrap().layout; - assert!(layout.contains(&Panel::Tools)); - let mut nodes: Vec<_> = layout.root.iter().collect(); - while let Some(node) = nodes.pop() { - match node { - Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), - Node::Dock { .. } => panic!("a drop at x = {x} made a dock: {layout:?}"), - _ => {} - } - } + drag(&mut h, source, at(library)); + assert_eq!(start_dock(&h), expected, "dropped at {:?} of {library:?}", at(library)); + lightcraft_ui_egui::docking::validate(h.state().ui.docking.as_ref().unwrap()).unwrap(); + assert_eq!(h.state().ui.right, RightPanel::Edit); } } From b477efd3da21c815c14069f78c2ba350dacde21e Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 12:17:31 +0000 Subject: [PATCH 16/40] Docking: repin craft-ui and craft-docpath to craft-libs b6d35e2 --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eb0c8168a..c5d73c571 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,12 +1084,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" +source = "git+https://github.com/storytold/craft-libs?rev=b6d35e28f4d19145c9c7595742cc3ea6fb8352d5#b6d35e28f4d19145c9c7595742cc3ea6fb8352d5" [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" +source = "git+https://github.com/storytold/craft-libs?rev=b6d35e28f4d19145c9c7595742cc3ea6fb8352d5#b6d35e28f4d19145c9c7595742cc3ea6fb8352d5" dependencies = [ "egui", "log", diff --git a/Cargo.toml b/Cargo.toml index 70b4db861..8a87d0e24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ rust-version = "1.95" repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e", features = ["serde"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "b6d35e28f4d19145c9c7595742cc3ea6fb8352d5", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b6d35e28f4d19145c9c7595742cc3ea6fb8352d5" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From 4bc5d2b587fc2f64de333f59e62ed3945a3f8c4c Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 12:17:42 +0000 Subject: [PATCH 17/40] Docking: the web app resets a saved split-frame layout once too --- apps/lightcraft-web/src/web.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 8cdb32462..fe77ce1e1 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -472,7 +472,12 @@ impl WebApp { let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone())) .with_chinese_font(chinese_font); let ui_written = files.get("ui.json").unwrap_or_default(); - if let Ok(ui) = serde_json::from_slice::(&ui_written) { + // an arrangement from an older docking version starts again from the default + let mut saved = serde_json::from_slice::(&ui_written).unwrap_or_default(); + if let Some(fields) = saved.as_object_mut() { + lightcraft_ui_egui::docking::drop_outdated(fields); + } + if let Ok(ui) = serde_json::from_value::(saved) { app.ui = ui; } app.ui = app.ui.sanitized(); From 577b6cfcedb83a648a973b85c3ed8f0df7b49b48 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 12:35:44 +0000 Subject: [PATCH 18/40] Top bar: the title-bar drag uses craft-window's title_bar, and a panel over the bar keeps the window still on macOS --- Cargo.lock | 12 +++++ Cargo.toml | 1 + crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/lib.rs | 8 ++- crates/ui-egui/src/panels/topbar.rs | 40 +++------------ crates/ui-egui/src/tests_titlebar.rs | 22 ++++++++ crates/ui-egui/src/titlebar.rs | 76 ---------------------------- 7 files changed, 50 insertions(+), 110 deletions(-) delete mode 100644 crates/ui-egui/src/titlebar.rs diff --git a/Cargo.lock b/Cargo.lock index 78de058c3..7b7dbfb41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1095,6 +1095,17 @@ dependencies = [ "serde", ] +[[package]] +name = "craft-window" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=8177bad411b577f3204deb0a0e074516d76a0b93#8177bad411b577f3204deb0a0e074516d76a0b93" +dependencies = [ + "egui", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "wayland-client", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -3522,6 +3533,7 @@ name = "lightcraft-ui-egui" version = "0.13.0" dependencies = [ "craft-ui", + "craft-window", "egui", "egui_kittest", "lightcraft-catalog", diff --git a/Cargo.toml b/Cargo.toml index c22395b8a..b641f471a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "8177bad411b577f3204deb0a0e074516d76a0b93", features = ["egui"] } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 5a1e214dd..0673e087b 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] craft-ui = { workspace = true } +craft-window = { workspace = true } lightcraft-geom = { workspace = true } lightcraft-meta = { workspace = true } lightcraft-color = { workspace = true } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4fc43172c..e7d1af303 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -49,7 +49,6 @@ pub mod sync; pub mod tasks; pub mod text_field; pub mod theme; -pub mod titlebar; pub mod widgets; pub mod window_place; @@ -388,6 +387,8 @@ pub struct LightcraftApp { pub perf: Perf, /// macOS: the host draws the traffic lights over our top bar. pub integrated_titlebar: bool, + /// The `movable` flag last given to the window (macOS, `craft_window::title_bar::sync_movable`). + pub window_movable: Option, /// The host installed a native menu bar (no in-window menus then). pub native_menu: bool, /// Shortcuts the native menu bar currently handles (`Cmd+Z`, `G`…): the egui shortcut handler @@ -545,6 +546,7 @@ impl LightcraftApp { perf: Perf::default(), caches: Caches::default(), integrated_titlebar: false, + window_movable: None, native_menu: false, native_shortcuts: Default::default(), recording_shortcut: None, @@ -1624,6 +1626,10 @@ impl LightcraftApp { panels::keyword_list::drag_feedback(self, &ctx); panels::toast(self, &ctx); drop_overlay::show(self, &ctx); + // AppKit moves the window on a drag anywhere in the title-bar band unless the window isn't + // movable: it stops being movable while the pointer is on a bar control or a panel over it + #[cfg(target_os = "macos")] + craft_window::title_bar::sync_movable(&ctx, &mut self.window_movable); self.widgets = widgets::take_registry(&ctx); self.text_focus = ctx.egui_wants_keyboard_input(); self.end_frame(&ctx, t0); diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index e26d1088f..7d1cf6530 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -9,39 +9,13 @@ use crate::theme::Tokens; use crate::widgets::{icon_button, register}; /// The bar doubles as the window's title bar: dragging its empty space moves the window and a -/// double-click zooms or restores it. Registered before the bar's widgets so they win the click. -fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, bar: Rect, search_rect: Rect) { - use crate::titlebar::{Gesture, WindowState, command_for}; +/// double-click acts as the system setting says. Made before the bar's widgets so they win the click. +fn window_handle(ui: &mut egui::Ui, bar: Rect) { register(ui.ctx(), "region:titlebar", bar); - let handle = egui::Id::new("titlebar-handle"); - let resp = ui.interact(bar, handle, Sense::click_and_drag()); - crate::widgets::announce(&resp, crate::i18n::tr("Title bar")); - // a press that began on a button or the search field is theirs: dragging off it must not move the window - // (while pressing, egui hovers every widget under the pointer; once the drag starts, only the dragged one) - let ctx = ui.ctx().clone(); - let memo = egui::Id::new("titlebar-press-on-widget"); - if resp.is_pointer_button_down_on() && ctx.input(|i| i.pointer.primary_pressed()) { - let hovered = ctx.interaction_snapshot(|s| s.hovered.clone()); - let on_widget = - hovered.iter().any(|id| *id != handle && ctx.read_response(*id).is_some_and(|r| r.sense.senses_click() || r.sense.senses_drag())); - ctx.data_mut(|d| d.insert_temp(memo, on_widget)); - } - let on_widget = ctx.data(|d| d.get_temp::(memo)).unwrap_or(false); - // The search field is drawn after this handle. Its hit-test response may not exist - // yet when the press is recorded; the press origin still identifies text selection. - let on_search = ui.input(|i| i.pointer.press_origin().is_some_and(|p| search_rect.contains(p))); - let gesture = if resp.double_clicked_by(egui::PointerButton::Primary) && !on_search { - Some(Gesture::DoubleClicked) - } else if resp.drag_started_by(egui::PointerButton::Primary) && !on_widget && !on_search { - Some(Gesture::DragStarted) - } else { - None - }; - if let Some(g) = gesture { - let window = WindowState { maximized: ui.input(|i| i.viewport().maximized).unwrap_or(false), fullscreen: app.window_is_fullscreen }; - if let Some(cmd) = command_for(g, window) { - ui.ctx().send_viewport_cmd(cmd); - } + craft_window::title_bar::empty_area(ui, bar); + // (the empty area is the widget `empty_area` makes under this id) + if let Some(resp) = ui.ctx().read_response(ui.id().with("title_bar_empty")) { + crate::widgets::announce(&resp, crate::i18n::tr("Title bar")); } } @@ -75,7 +49,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let search_left = bar.center().x - sw / 2.0; let sr = Rect::from_center_size(pos2(bar.center().x, full.center().y), vec2(sw, 28.0)); if app.integrated_titlebar { - window_handle(app, ui, bar, sr); + window_handle(ui, bar); } ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 4.0; diff --git a/crates/ui-egui/src/tests_titlebar.rs b/crates/ui-egui/src/tests_titlebar.rs index 36598190c..8d40f212e 100644 --- a/crates/ui-egui/src/tests_titlebar.rs +++ b/crates/ui-egui/src/tests_titlebar.rs @@ -169,3 +169,25 @@ fn full_screen_moves_the_left_buttons_into_the_traffic_lights_space() { assert!(!h.app.window_is_fullscreen); assert_eq!(widget(&h, "icon:sidebar").left(), windowed, "leaving full screen restores the room for the traffic lights"); } + +// Scenario: a panel floating over the bar keeps a drag on it from moving the window (macOS's +// title-bar band would otherwise take the drag); a bar control does too +#[test] +fn a_panel_floating_over_the_bar_keeps_the_window_from_moving() { + let mut h = integrated(); + let bar = widget(&h, "region:titlebar"); + let empty = empty_spot(&h); + let hover = |h: &mut Headless, p: egui::Pos2| { + h.events.push(egui::Event::PointerMoved(p)); + play(h); + craft_window::title_bar::window_movable(&h.view.ctx) + }; + assert!(hover(&mut h, empty), "the empty bar moves the window"); + let help = widget(&h, "icon:help").center(); + assert!(!hover(&mut h, help), "a bar control"); + let params = json!({"operation": "float", "panel": "tools", "rect": [bar.left(), bar.top() + 4.0, 330.0, 500.0]}); + let r = h.request("engine.execute", json!({"command": "ui.dock", "params": params}), T); + assert_eq!(r["ok"], true, "{r}"); + play(&mut h); + assert!(!hover(&mut h, empty), "the floating panel covers the empty bar there"); +} diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs deleted file mode 100644 index 40c8975cd..000000000 --- a/crates/ui-egui/src/titlebar.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! The integrated title bar's window gestures (macOS: the native title bar is hidden and the top -//! bar takes its place, so dragging and double-click-to-zoom have to be done here). -//! -//! Pure policy, no egui widgets: [`command_for`] maps a gesture on the bar's empty space and the -//! window's current state to the viewport command the host should run. - -use egui::ViewportCommand; - -/// What the user did on the bar's empty space. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Gesture { - /// A primary-button drag began: the window follows the pointer. - DragStarted, - /// A primary-button double-click: zoom the window, or restore it when already zoomed. - DoubleClicked, -} - -/// The window as the host reports it. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct WindowState { - /// Zoomed (macOS) / maximized (elsewhere). - pub maximized: bool, - /// In the OS's full-screen mode. - pub fullscreen: bool, -} - -/// The viewport command for `gesture`, if any. Full screen has no title bar to drag or zoom. -pub fn command_for(gesture: Gesture, window: WindowState) -> Option { - if window.fullscreen { - return None; - } - match gesture { - Gesture::DragStarted => Some(ViewportCommand::StartDrag), - Gesture::DoubleClicked => Some(ViewportCommand::Maximized(!window.maximized)), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const NORMAL: WindowState = WindowState { maximized: false, fullscreen: false }; - const ZOOMED: WindowState = WindowState { maximized: true, fullscreen: false }; - const FULL: WindowState = WindowState { maximized: false, fullscreen: true }; - - // Scenario: dragging the title bar moves the window - #[test] - fn given_a_normal_window_when_dragging_then_the_window_follows_the_pointer() { - assert_eq!(command_for(Gesture::DragStarted, NORMAL), Some(ViewportCommand::StartDrag)); - } - - // Scenario: a zoomed window can still be dragged (macOS lets you pull it free) - #[test] - fn given_a_zoomed_window_when_dragging_then_the_window_follows_the_pointer() { - assert_eq!(command_for(Gesture::DragStarted, ZOOMED), Some(ViewportCommand::StartDrag)); - } - - // Scenario: double-click zooms - #[test] - fn given_a_normal_window_when_double_clicking_then_it_zooms() { - assert_eq!(command_for(Gesture::DoubleClicked, NORMAL), Some(ViewportCommand::Maximized(true))); - } - - // Scenario: double-click again restores - #[test] - fn given_a_zoomed_window_when_double_clicking_then_it_is_restored() { - assert_eq!(command_for(Gesture::DoubleClicked, ZOOMED), Some(ViewportCommand::Maximized(false))); - } - - // Scenario: full screen ignores both - #[test] - fn given_a_full_screen_window_then_neither_gesture_does_anything() { - assert_eq!(command_for(Gesture::DragStarted, FULL), None); - assert_eq!(command_for(Gesture::DoubleClicked, FULL), None); - } -} From 19ab8214779e65c4c9858bf747a9ef17df392a3d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 12:41:42 +0000 Subject: [PATCH 19/40] Top bar: repin craft-window to craft-libs 4093ea4; the title bar is announced from empty_area's response --- Cargo.lock | 14 +++++++------- Cargo.toml | 2 +- crates/ui-egui/src/panels/topbar.rs | 7 ++----- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7b7dbfb41..6d9311c48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1098,7 +1098,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=8177bad411b577f3204deb0a0e074516d76a0b93#8177bad411b577f3204deb0a0e074516d76a0b93" +source = "git+https://github.com/storytold/craft-libs?rev=4093ea468c6691a0498849ff11683c5243d56272#4093ea468c6691a0498849ff11683c5243d56272" dependencies = [ "egui", "objc2 0.6.4", @@ -1684,7 +1684,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3764,7 +3764,7 @@ dependencies = [ "once_cell", "png", "thiserror 2.0.21", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5270,7 +5270,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5804,7 +5804,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6003,7 +6003,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6591,7 +6591,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b641f471a..712ea59d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } -craft-window = { git = "https://github.com/storytold/craft-libs", rev = "8177bad411b577f3204deb0a0e074516d76a0b93", features = ["egui"] } +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "4093ea468c6691a0498849ff11683c5243d56272", features = ["egui"] } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 7d1cf6530..acee425ed 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -12,11 +12,8 @@ use crate::widgets::{icon_button, register}; /// double-click acts as the system setting says. Made before the bar's widgets so they win the click. fn window_handle(ui: &mut egui::Ui, bar: Rect) { register(ui.ctx(), "region:titlebar", bar); - craft_window::title_bar::empty_area(ui, bar); - // (the empty area is the widget `empty_area` makes under this id) - if let Some(resp) = ui.ctx().read_response(ui.id().with("title_bar_empty")) { - crate::widgets::announce(&resp, crate::i18n::tr("Title bar")); - } + let resp = craft_window::title_bar::empty_area(ui, bar); + crate::widgets::announce(&resp, crate::i18n::tr("Title bar")); } pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { From 901a115bf77fda6237ac8f1194e85bd1670b684d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 13:53:10 +0000 Subject: [PATCH 20/40] Top bar: repin craft-window to craft-libs main ea4c823 --- Cargo.lock | 10 +++++----- Cargo.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6d9311c48..eb2d9597a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1098,7 +1098,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=4093ea468c6691a0498849ff11683c5243d56272#4093ea468c6691a0498849ff11683c5243d56272" +source = "git+https://github.com/storytold/craft-libs?rev=ea4c823abb46fa9d21361604ed1c6d21c54e9ca8#ea4c823abb46fa9d21361604ed1c6d21c54e9ca8" dependencies = [ "egui", "objc2 0.6.4", @@ -1684,7 +1684,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5270,7 +5270,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5804,7 +5804,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6591,7 +6591,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 712ea59d6..4932b96d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } -craft-window = { git = "https://github.com/storytold/craft-libs", rev = "4093ea468c6691a0498849ff11683c5243d56272", features = ["egui"] } +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "ea4c823abb46fa9d21361604ed1c6d21c54e9ca8", features = ["egui"] } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From 1291d391d981b7a39059b181f5781e0c39bbccda Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 16:25:25 +0000 Subject: [PATCH 21/40] Docking: repin craft-ui and craft-docpath to craft-libs main 5bbe9f6 --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e7f385f62..c6bb9c9d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,12 +1084,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b6d35e28f4d19145c9c7595742cc3ea6fb8352d5#b6d35e28f4d19145c9c7595742cc3ea6fb8352d5" +source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5" [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b6d35e28f4d19145c9c7595742cc3ea6fb8352d5#b6d35e28f4d19145c9c7595742cc3ea6fb8352d5" +source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5" dependencies = [ "egui", "log", diff --git a/Cargo.toml b/Cargo.toml index 12cfba073..72d732c6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ rust-version = "1.95" repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "b6d35e28f4d19145c9c7595742cc3ea6fb8352d5", features = ["serde"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b6d35e28f4d19145c9c7595742cc3ea6fb8352d5" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From 649e84b0ed6cedfbf395a277e834b8b0e1b56a8d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 16:25:27 +0000 Subject: [PATCH 22/40] Top bar: repin craft-window to craft-libs main 5bbe9f6 --- Cargo.lock | 23 +++++++---------------- Cargo.toml | 2 +- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3abafa245..f3e4b5bf7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1004,7 +1004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1098,7 +1098,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ea4c823abb46fa9d21361604ed1c6d21c54e9ca8#ea4c823abb46fa9d21361604ed1c6d21c54e9ca8" +source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5" dependencies = [ "egui", "objc2 0.6.4", @@ -1684,7 +1684,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5257,7 +5257,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5270,7 +5270,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5804,7 +5804,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6591,7 +6591,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6704,15 +6704,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index f0ff5da6e..a41d0d9ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } -craft-window = { git = "https://github.com/storytold/craft-libs", rev = "ea4c823abb46fa9d21361604ed1c6d21c54e9ca8", features = ["egui"] } +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5", features = ["egui"] } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From d4cfd2e6d54b0cf474104dd726f0245fb47a24d8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 17:07:16 +0000 Subject: [PATCH 23/40] LR-BEHAV-TITLEBAR: the checklist row follows craft-window's title_bar --- docs/parity-checklist.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 080680b9f..afbed8675 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-24 · **Change:** minor (KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **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 @@ -485,7 +485,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | | LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights | | LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | | -| LR-BEHAV-TITLEBAR | Title bar drag and double-click zoom (macOS) | P2 | 🟡 | `crates/ui-egui/src/titlebar.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/tests_titlebar.rs` | the native title bar is hidden on macOS, so the top bar's empty space (including the traffic-light margin) moves the window and a double-click zooms or restores it; buttons and the search field keep their clicks; ignored in full screen, where the traffic lights are gone and the bar's left buttons move into their space (#1763). Zoom is winit's `Maximized`: it does not follow System Settings › “Double-click a window's title bar to” (Fill / Minimize). a press that starts on a button or the search field never moves the window. Known gap: egui-winit reads the zoomed state only at start-up and after our own `Maximized` command, so a window zoomed another way (green button, Window ▸ Zoom, snapping) makes the first double-click a no-op before the toggle catches up. Checked by hand on a real macOS window (drag, zoom, restore) | +| LR-BEHAV-TITLEBAR | Title bar drag and double-click zoom (macOS) | P2 | 🟡 | `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/tests_titlebar.rs` | the native title bar is hidden on macOS, so the top bar's empty space (including the traffic-light margin) moves the window and a double-click does what System Settings › “Double-click a window's title bar to” says (zoom or restore, minimize, nothing; craft-window's `title_bar`); buttons and the search field keep their clicks; ignored in full screen, where the traffic lights are gone and the bar's left buttons move into their space (#1763). A press that starts on a button, the search field or a panel floating over the bar never moves the window (AppKit's `movable` is off while the pointer is there). Known gap: egui-winit reads the zoomed state only at start-up and after our own `Maximized` command, so a window zoomed another way (green button, Window ▸ Zoom, snapping) makes the first double-click a no-op before the toggle catches up. Checked by hand on a real macOS window (drag, zoom, restore) | ## Y. Menus @@ -799,6 +799,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-25 | patch | LR-BEHAV-TITLEBAR: the top bar's title-bar gestures come from craft-window's `title_bar`: a double-click follows the system setting, and a press on a button, the search field or a panel floating over the bar never moves the window; status counts unchanged | | 2026-10-24 | minor | KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 (`keyword.nextSet`, `keyword.previousSet`) go to the next / previous keyword set; status counts unchanged (KEYC-METADATA stays 🟡) | | 2026-10-24 | minor | KEYC-METADATA: ⌘K / Ctrl+K (`panel.addKeywords`, Add Keywords) shows the Keywords panel, opens Keywording and puts the cursor in the Add keyword box; status counts unchanged (KEYC-METADATA stays 🟡) | | 2026-10-24 | patch | LR-LIB-RATING, LR-LIB-DELETE: a slider drag still open when a rating, flag or label takes the photo out of the view, or when the photo is deleted, ends as its own undo step before the selection moves on, as moving to the next photo does; LR-BEHAV-LOCALIZE lists all 18 languages; status unchanged | From df05e59474d9a1afb42904c788a543a7154985ac Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 17:08:39 +0000 Subject: [PATCH 24/40] Docking: the Info fields test window is tall enough for the place fields in the dock column --- crates/ui-egui/src/headless.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 8686dbdf5..1d71e0b88 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2860,7 +2860,8 @@ mod tests { /// accessibility and place fields reach the photo. #[test] fn info_fields_keep_typing_and_save() { - let mut h = demo([1300.0, 1000.0]); + // (tall enough for the place fields to show without scrolling) + let mut h = demo([1300.0, 1200.0]); let t = Duration::from_secs(10); h.request("ui.set", json!({"view": "detail", "right": "info"}), t); h.settle(SETTLE); From 2b9317519cfda78367a9a2735b9706023b886c4b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 14:19:39 -0400 Subject: [PATCH 25/40] feat: startup update prompt modal and direct self-updating The update flow (check at start or on request, the startup prompt, the self-update worker) is craft-update's Updater; the app only supplies the suite, its close path and its status window. The check at start is opt-in for the desktop app, and the Windows update helper runs before argument parsing. Ref: storytold/craft-libs#319 --- Cargo.lock | 373 ++++++++++++++++++++++++++++++- Cargo.toml | 1 + apps/lightcraft/Cargo.toml | 3 + apps/lightcraft/src/main.rs | 6 + crates/ui-egui/Cargo.toml | 3 + crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/menubar.rs | 1 + crates/ui-egui/src/menus.rs | 2 + crates/ui-egui/src/updates_ui.rs | 159 +++++++++++++ 9 files changed, 546 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/updates_ui.rs diff --git a/Cargo.lock b/Cargo.lock index 6e932cba8..ebe47ab97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -580,6 +580,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -829,7 +835,7 @@ dependencies = [ "safetensors 0.7.0", "thiserror 2.0.21", "yoke 0.8.3", - "zip", + "zip 7.2.0", ] [[package]] @@ -1038,6 +1044,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1051,7 +1067,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -1064,7 +1080,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] @@ -1091,6 +1107,31 @@ dependencies = [ "serde", ] +[[package]] +name = "craft-update" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=13ceaf2f31620531f39a54f6b994d581a6082a93#13ceaf2f31620531f39a54f6b994d581a6082a93" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip 9.1.0", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1215,6 +1256,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "dhat" version = "0.3.3" @@ -1304,6 +1351,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-stack" version = "0.10.0" @@ -1784,6 +1837,16 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -2342,8 +2405,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "wasm-bindgen", ] [[package]] @@ -2614,6 +2679,22 @@ dependencies = [ "digest", ] +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "image" version = "0.25.10" @@ -3105,6 +3186,7 @@ name = "lightcraft" version = "0.14.0" dependencies = [ "chrono", + "craft-update", "eframe", "egui", "lightcraft-codecs", @@ -3371,7 +3453,7 @@ version = "0.14.0" dependencies = [ "lightcraft-develop", "lightcraft-geom", - "quick-xml", + "quick-xml 0.41.0", ] [[package]] @@ -3409,7 +3491,7 @@ dependencies = [ "lightcraft-geom", "lightcraft-tiff", "proptest", - "quick-xml", + "quick-xml 0.41.0", "serde", "serde_json", ] @@ -3537,6 +3619,7 @@ name = "lightcraft-ui-egui" version = "0.14.0" dependencies = [ "craft-ui", + "craft-update", "egui", "egui_kittest", "lightcraft-catalog", @@ -3859,6 +3942,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "no_std_io2" version = "0.9.4" @@ -3939,6 +4034,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -4376,6 +4477,12 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.55" @@ -4652,6 +4759,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.18.1" @@ -4738,6 +4858,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -4894,6 +5020,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -5303,13 +5438,27 @@ version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki 0.103.15", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -5319,6 +5468,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.15", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-rustcrypto" version = "0.0.2-alpha" @@ -5419,6 +5595,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -5445,6 +5630,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -5456,6 +5664,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "send_wrapper" @@ -5824,6 +6036,16 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -5906,6 +6128,36 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tokio" version = "1.53.1" @@ -6030,6 +6282,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typenum" version = "1.20.1" @@ -6140,6 +6398,41 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "uuid" version = "1.26.1" @@ -6189,6 +6482,16 @@ dependencies = [ "vello_common", ] +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", +] + [[package]] name = "version_check" version = "0.9.5" @@ -6404,7 +6707,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -6440,6 +6743,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -6936,7 +7248,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", @@ -6981,6 +7293,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "winresource" version = "0.1.31" @@ -6991,6 +7313,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "winsafe" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -7328,7 +7656,24 @@ dependencies = [ "crc32fast", "indexmap", "memchr", - "typed-path", + "typed-path 0.12.3", +] + +[[package]] +name = "zip" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix", + "typed-path 0.13.0", + "windows-sys 0.61.2", + "zopfli", ] [[package]] @@ -7343,6 +7688,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index 0dc85dfe2..a8c96ad2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc", features = ["egui"] } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } diff --git a/apps/lightcraft/Cargo.toml b/apps/lightcraft/Cargo.toml index f93bd2ccd..d00bc41e9 100644 --- a/apps/lightcraft/Cargo.toml +++ b/apps/lightcraft/Cargo.toml @@ -55,3 +55,6 @@ objc2-foundation = { workspace = true } [dev-dependencies] # tests record why they skip (`cargo xtask ci` lists the skips) lightcraft-testkit = { workspace = true } + +[target.'cfg(windows)'.dependencies] +craft-update = { workspace = true } diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index b666a0cdc..815fbd70f 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -881,6 +881,11 @@ fn panic_log_path_with(env: impl Fn(&str) -> Option, temp: s } fn main() -> eframe::Result { + // Windows: the update helper is a copy of this program started with `--finish-update`. + #[cfg(windows)] + if let Some(result) = craft_update::self_update::finish(&craft_update::suite::LIGHTCRAFT, &std::env::args_os().skip(1).collect::>()) { + std::process::exit(i32::from(result.is_err())); + } // First, so every start-up record is kept for the log file (`logging`). let logger = logging::install(); lightcraft_engine::guard::install_hook(panic_log_path_with(|name| std::env::var_os(name), std::env::temp_dir())); @@ -1009,6 +1014,7 @@ fn main() -> eframe::Result { // HDR display: SDR unless the surface, platform and display all allow HDR hdr_display::report(cc.wgpu_render_state.as_ref().map(|r| r.target_format)); let mut app = LightcraftApp::new(session, services(cc, app_log_file.as_deref())); + app.set_startup_update_check(true); if let Some(ui) = prefs { app.ui = ui; } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 5ee1c0233..8dab2b967 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -44,3 +44,6 @@ lightcraft-merge = { workspace = true } [build-dependencies] serde_json = { workspace = true } toml = "1" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +craft-update = { workspace = true } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1b5fea8f1..71c2cef13 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -50,6 +50,7 @@ pub mod tasks; pub mod text_field; pub mod theme; pub mod titlebar; +pub mod updates_ui; pub mod widgets; pub mod window_place; @@ -409,6 +410,7 @@ pub struct LightcraftApp { pub headless_host: bool, /// Warnings to show one at a time (damaged settings files…, issue #103). pub notices: Vec, + pub(crate) updates_rt: updates_ui::Runtime, /// Quitting was stopped to ask first: changes couldn't be saved, or tasks are running. pub quit_prompt: Option, /// Keyboard/focus/input state for the prompt above, including its one-frame creation barrier. @@ -615,6 +617,7 @@ impl LightcraftApp { text_focus: false, headless_host: false, notices: vec![], + updates_rt: Default::default(), quit_prompt: None, notice_input: Default::default(), quit_confirmed: false, @@ -1966,6 +1969,7 @@ impl LightcraftApp { lightroom_import::tick(self, ctx); recheck::tick(self); tasks::poll(self, ctx); + updates_ui::tick(self, ctx); self.preview_build_status(ctx); self.save_status(ctx); self.ai_mask_detail(ctx, now); @@ -2626,6 +2630,7 @@ impl LightcraftApp { panels::notices::hide_background_pointer(&ctx); } panels::dialogs::show(self, &ctx, notice_blocks); + updates_ui::show(self, &ctx); panels::library_problem::show(self, &ctx, notice_blocks); panels::toast(self, &ctx); drop_overlay::show(self, &ctx); @@ -2656,6 +2661,7 @@ impl LightcraftApp { panels::notices::hide_background_pointer(&ctx); } panels::dialogs::show(self, &ctx, notice_blocks); + updates_ui::show(self, &ctx); panels::library_problem::show(self, &ctx, notice_blocks); export_task::poll(self, &ctx); slideshow_export::poll(self, &ctx); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 9b3d88036..ba4a8a7c2 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -322,6 +322,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "app.systemInfo", "app.openLogFolder", "---", + "app.checkForUpdates", "app.about", ], ), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 03841d14a..84ec8ae0c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -263,6 +263,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ // Settings ▸ Display ("app.displayProfile", "Display Profile", None, ""), ("app.about", "About LightCraft", None, "Help"), + ("app.checkForUpdates", "Check for Updates…", None, "Help"), ("app.systemInfo", "System Info…", None, "Help"), ("app.openLogFolder", "Open Log Folder", None, "Help"), // the frame-rate display (`frame_rate`): a debugging aid, never saved @@ -1587,6 +1588,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::updates_ui::check(app), "photo.editInPhotoCraft" => edit_in(app, &ctx, p, true), "photo.editInExternal" => edit_in(app, &ctx, p, false), "photo.openOriginalInExternal" => open_original_in(app, &ctx, p), diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs new file mode 100644 index 000000000..423f9a7c7 --- /dev/null +++ b/crates/ui-egui/src/updates_ui.rs @@ -0,0 +1,159 @@ +//! Software updates, background check at startup, and startup prompt modal. + +use serde_json::Value; + +use crate::LightcraftApp; + +#[cfg(not(target_arch = "wasm32"))] +use craft_update::updater::{Action, Status, Updater}; + +pub(crate) struct Runtime { + #[cfg(not(target_arch = "wasm32"))] + updater: Updater, + /// The desktop app turns the check at start on; tests and the web build leave it off. + #[cfg(not(target_arch = "wasm32"))] + startup_check: bool, + #[cfg(not(target_arch = "wasm32"))] + check_requested: bool, + pub(crate) manual_window_open: bool, + pub(crate) status_message: Option, +} + +impl Default for Runtime { + fn default() -> Self { + Self { + #[cfg(not(target_arch = "wasm32"))] + updater: Updater::new(&craft_update::suite::LIGHTCRAFT, env!("CARGO_PKG_VERSION")), + #[cfg(not(target_arch = "wasm32"))] + startup_check: false, + #[cfg(not(target_arch = "wasm32"))] + check_requested: false, + manual_window_open: false, + status_message: None, + } + } +} + +impl LightcraftApp { + /// Whether the app asks for a newer release when it starts (off until the desktop app turns it on). + #[cfg(not(target_arch = "wasm32"))] + pub fn set_startup_update_check(&mut self, on: bool) { + self.updates_rt.startup_check = on; + } +} + +pub(crate) fn check(app: &mut LightcraftApp) -> Result { + #[cfg(not(target_arch = "wasm32"))] + { + app.updates_rt.check_requested = true; + Ok(Value::Null) + } + #[cfg(target_arch = "wasm32")] + { + app.updates_rt.manual_window_open = true; + app.updates_rt.status_message = Some("Update check is not supported in the web browser.".into()); + Ok(Value::Null) + } +} + +pub(crate) fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + { + let rt = &mut app.updates_rt; + if std::mem::take(&mut rt.check_requested) { + rt.updater.check_now(ctx); + } else if rt.startup_check { + rt.updater.check_at_start(ctx); + } + rt.updater.tick(ctx); + } + #[cfg(target_arch = "wasm32")] + let _ = (app, ctx); +} + +pub(crate) fn show(app: &mut LightcraftApp, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + { + match app.updates_rt.updater.show(ctx) { + Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), + Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + Action::None => {} + } + // The window answers Help ▸ Check for Updates, so it follows the updater's status. + let rt = &mut app.updates_rt; + (rt.manual_window_open, rt.status_message) = match rt.updater.status() { + Status::Idle => (false, None), + Status::Checking => (true, Some("Checking for updates…".to_string())), + Status::UpToDate => (true, Some(format!("LightCraft {} is currently the newest version available.", env!("CARGO_PKG_VERSION")))), + Status::Failed(e) => (true, Some(format!("Could not check for updates:\n{e}"))), + }; + } + + if app.updates_rt.manual_window_open { + let mut open = true; + egui::Window::new(crate::i18n::tr("LightCraft Updates")) + .id(egui::Id::new("lightcraft_manual_updates")) + .open(&mut open) + .collapsible(false) + .resizable(false) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) + .show(ctx, |ui| { + if let Some(msg) = &app.updates_rt.status_message { + ui.label(msg); + } + ui.add_space(8.0); + if ui.button(crate::i18n::tr("OK")).clicked() { + app.updates_rt.manual_window_open = false; + } + }); + if !open { + app.updates_rt.manual_window_open = false; + } + } + + // Closing the window dismisses the outcome it showed. + #[cfg(not(target_arch = "wasm32"))] + if !app.updates_rt.manual_window_open { + app.updates_rt.updater.dismiss_status(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_engine::Session; + + #[test] + fn default_runtime_is_idle() { + let rt = Runtime::default(); + assert!(!rt.manual_window_open); + #[cfg(not(target_arch = "wasm32"))] + { + assert!(!rt.startup_check); + assert!(!rt.updater.prompt_open()); + assert_eq!(rt.updater.status(), &Status::Idle); + } + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn startup_prompt_shows_and_closes() { + let mut app = LightcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + let release = craft_update::Release { + tag: "v99.0.0".into(), + version: craft_update::parse_version("99.0.0"), + name: "LightCraft 99.0.0".into(), + html_url: "https://github.com/storytold/lightcraft/releases/tag/v99.0.0".into(), + ..Default::default() + }; + app.updates_rt.updater.offer(release); + + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + show(&mut app, ui.ctx()); + }); + output.textures_delta.clear(); + assert!(app.updates_rt.updater.prompt_open()); + } +} From 8e5a6fc2e77376b55caa40c08c7faad16597a175 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 14:53:33 -0400 Subject: [PATCH 26/40] refactor(updates): hold the install back while documents are unsaved --- Cargo.lock | 18 +++++++++--------- crates/ui-egui/src/updates_ui.rs | 4 +++- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ebe47ab97..d85066e8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1110,7 +1110,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=13ceaf2f31620531f39a54f6b994d581a6082a93#13ceaf2f31620531f39a54f6b994d581a6082a93" +source = "git+https://github.com/storytold/craft-libs?rev=5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc#5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc" dependencies = [ "chrono", "dunce", @@ -1720,7 +1720,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3850,7 +3850,7 @@ dependencies = [ "once_cell", "png", "thiserror 2.0.21", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4519,7 +4519,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5429,7 +5429,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5486,7 +5486,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6056,7 +6056,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6302,7 +6302,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6956,7 +6956,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 423f9a7c7..9d09ff09e 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -74,7 +74,9 @@ pub(crate) fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { pub(crate) fn show(app: &mut LightcraftApp, ctx: &egui::Context) { #[cfg(not(target_arch = "wasm32"))] { - match app.updates_rt.updater.show(ctx) { + // The restart helper is scheduled before the app closes, so no update starts over unsaved work. + let clean = app.session.unsaved().is_none(); + match app.updates_rt.updater.show_if(ctx, clean) { Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close), Action::None => {} From 2d3c7275b2ace1c1548b5e5b6910434af61dcf7a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 15:07:57 -0400 Subject: [PATCH 27/40] fix(updates): drop the manual window's custom id; pin craft-update to craft-libs main (db19c37) --- Cargo.lock | 18 +++++++++--------- Cargo.toml | 2 +- crates/ui-egui/src/updates_ui.rs | 1 - 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d85066e8b..11c7b1d73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1110,7 +1110,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc#5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc" +source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b" dependencies = [ "chrono", "dunce", @@ -1720,7 +1720,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3850,7 +3850,7 @@ dependencies = [ "once_cell", "png", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4519,7 +4519,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5429,7 +5429,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5486,7 +5486,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6056,7 +6056,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6302,7 +6302,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6956,7 +6956,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a8c96ad2f..2bb309f91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", features = ["egui"] } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 9d09ff09e..6ef3a97bc 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -94,7 +94,6 @@ pub(crate) fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if app.updates_rt.manual_window_open { let mut open = true; egui::Window::new(crate::i18n::tr("LightCraft Updates")) - .id(egui::Id::new("lightcraft_manual_updates")) .open(&mut open) .collapsible(false) .resizable(false) From ee487850909ab73e3334bd5ef0c19a981c03f842 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 15:17:03 -0400 Subject: [PATCH 28/40] fix(updates): address review feedback Wake the UI when a manual check is queued from a menu callback; the update prompt owns the keyboard while it is open (LightCraft); Download opens through the desktop URL hook (FilmCraft); the Help menu dispatches through menus::invoke (FigCraft); print jobs and comment drafts hold an install back (WordCraft); Check for Updates is a registered command (GridCraft); an install holds back while documents are unsaved. --- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/updates_ui.rs | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 71c2cef13..be1a9e879 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2127,7 +2127,7 @@ impl LightcraftApp { // Recent-library checks and launch-list keys read focus/time/keys: they belong to the UI // phase, not the hidden-capable `logic` tick (plan §4.5 / migration input contract). panels::library_problem::logic(self, ctx); - if self.fonts_ready { + if self.fonts_ready && !updates_ui::blocks_input(self) { shortcuts::handle(self, ctx); } } diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 6ef3a97bc..92f421e45 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -15,6 +15,9 @@ pub(crate) struct Runtime { startup_check: bool, #[cfg(not(target_arch = "wasm32"))] check_requested: bool, + /// The context of the last frame, to wake the UI when a check is queued from a menu callback. + #[cfg(not(target_arch = "wasm32"))] + repaint: Option, pub(crate) manual_window_open: bool, pub(crate) status_message: Option, } @@ -28,6 +31,8 @@ impl Default for Runtime { startup_check: false, #[cfg(not(target_arch = "wasm32"))] check_requested: false, + #[cfg(not(target_arch = "wasm32"))] + repaint: None, manual_window_open: false, status_message: None, } @@ -42,10 +47,26 @@ impl LightcraftApp { } } +/// The update prompt owns the keyboard while it is open: shortcuts must not reach the library behind it. +pub(crate) fn blocks_input(app: &LightcraftApp) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + app.updates_rt.updater.prompt_open() + } + #[cfg(target_arch = "wasm32")] + { + let _ = app; + false + } +} + pub(crate) fn check(app: &mut LightcraftApp) -> Result { #[cfg(not(target_arch = "wasm32"))] { app.updates_rt.check_requested = true; + if let Some(ctx) = &app.updates_rt.repaint { + ctx.request_repaint(); + } Ok(Value::Null) } #[cfg(target_arch = "wasm32")] @@ -60,6 +81,7 @@ pub(crate) fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { #[cfg(not(target_arch = "wasm32"))] { let rt = &mut app.updates_rt; + rt.repaint = Some(ctx.clone()); if std::mem::take(&mut rt.check_requested) { rt.updater.check_now(ctx); } else if rt.startup_check { From 015ca9eebae768d45149c8e32d1c4c2d60993ad8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 15:34:06 -0400 Subject: [PATCH 29/40] fix(updates): localize the update status messages and format the sources --- crates/ui-egui/src/updates_ui.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 92f421e45..5e159a0f4 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -107,9 +107,15 @@ pub(crate) fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let rt = &mut app.updates_rt; (rt.manual_window_open, rt.status_message) = match rt.updater.status() { Status::Idle => (false, None), - Status::Checking => (true, Some("Checking for updates…".to_string())), - Status::UpToDate => (true, Some(format!("LightCraft {} is currently the newest version available.", env!("CARGO_PKG_VERSION")))), - Status::Failed(e) => (true, Some(format!("Could not check for updates:\n{e}"))), + Status::Checking => (true, Some(crate::i18n::tr("Checking for updates…").to_string())), + Status::UpToDate => ( + true, + Some( + crate::i18n::tr("LightCraft {version} is currently the newest version available.") + .replace("{version}", env!("CARGO_PKG_VERSION")), + ), + ), + Status::Failed(e) => (true, Some(crate::i18n::tr("Could not check for updates:\n{error}").replace("{error}", e))), }; } From b88f72b9df68f07a080772d7fb16a92d6f7db837 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 22:56:20 -0400 Subject: [PATCH 30/40] sec: bound heic-rs 0.1.4 work around hostile HEIF containers heic-rs has no aggregate work budget in container parsing: ipma association entries rescan earlier item ids after each other and an iloc construction-method-2 extent rebuilds its source item afresh. Cap the ipma/iloc boxes before heic-rs reads them, and stop probe paying for a reconstruction heft beyond the file's own size. decode: refuse files carrying more than 12 container transforms (each would re-copy the full picture; CodeRabbit row #44), check a grid's payload canvas against the pixel limit before allocating for it (#48), and to_rgb16 refuses an oversized w*h*ch rather than asking for a multi-gigabyte buffer. --- crates/heif/src/census.rs | 122 +++++++++++++++++++++++++++++ crates/heif/src/lib.rs | 42 +++++++++- crates/heif/src/tests_synthetic.rs | 13 +++ crates/heif/src/ycc.rs | 26 ++++++ 4 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 crates/heif/src/census.rs diff --git a/crates/heif/src/census.rs b/crates/heif/src/census.rs new file mode 100644 index 000000000..6dbe035fc --- /dev/null +++ b/crates/heif/src/census.rs @@ -0,0 +1,122 @@ +//! Caps on the container's metadata boxes, counted before heic-rs touches the file. +//! +//! heic-rs 0.1.4 (pinned `=0.1.4`) has no aggregate work budget inside its container parsing: +//! every `ipma` association entry rescans the vector of already-seen item ids (quadratic in +//! the number of entries), and an `iloc` extent using construction method 2 reconstructs its +//! source item afresh for every extent that references it, with no shared cache. Legitimate +//! phone-photo metadata never comes near these caps — real corpora keep both well under a few +//! kilobytes — so anything above them is hostile or corrupt and is refused before the +//! countable CPU work happens. +//! +//! [`depth_boxes`] stops silently at structural blips: heic-rs's own parser reports those +//! better than this walk could, and an `ipma` box past the cap is caught wherever it parses. + +use heic_rs::boxes::BoxIter; + +use crate::Error; + +/// The largest `ipma` box (item ↔ property associations) payload accepted, 64 KiB. An entry +/// costs at least 3 bytes, so this bounds heic-rs's per-entry rescan of earlier item ids to +/// ~21 000 entries ≈ 220 million comparisons: long enough to be annoying, never minutes. +const MAX_IPMA_BYTES: usize = 64 << 10; + +/// The largest `iloc` box (item locations) payload accepted, 64 KiB. An extent costs at least +/// 12 bytes, so this bounds both the parse itself and how many extents a +/// construction-method-2 reconstruction chain can visit. +const MAX_ILOC_BYTES: usize = 64 << 10; + +/// Falls through unless the container declares more `ipma` or `iloc` payload than a +/// legitimate phone photo ever does. +pub fn cap(bytes: &[u8]) -> Result<(), Error> { + let mut c = Census::default(); + depth_boxes(&mut c, BoxIter::new(bytes), 0); + if c.ipma > MAX_IPMA_BYTES { + return Err(Error::Limit(format!("the image carries {} bytes of property associations, over the {MAX_IPMA_BYTES}-byte cap", c.ipma))); + } + if c.iloc > MAX_ILOC_BYTES { + return Err(Error::Limit(format!("the image carries {} bytes of item locations, over the {MAX_ILOC_BYTES}-byte cap", c.iloc))); + } + Ok(()) +} + +/// The metadata-box payload sizes seen so far. +#[derive(Default)] +struct Census { + ipma: usize, + iloc: usize, +} + +/// Walks boxes, adding the payload sizes of those named in [`Census`]. +/// +/// Only `meta` and `iprp` (the two nesting boxes the metadata is built from) are descended +/// into, no deeper than that depth, so a nested-box trick can't hide a box from the count. +fn depth_boxes(c: &mut Census, boxes: BoxIter<'_>, depth: u32) { + if depth > 3 { + return; + } + for b in boxes { + // A structural error stops the walk; `heic_rs::probe` reports it properly afterwards. + let Ok(b) = b else { return }; + if b.is(b"ipma") { + c.ipma = c.ipma.saturating_add(b.payload.len()); + } else if b.is(b"iloc") { + c.iloc = c.iloc.saturating_add(b.payload.len()); + } else if b.is(b"meta") { + // `meta` is a FullBox: version and flags sit between its header and its children. + if b.payload.len() >= 4 { + depth_boxes(c, BoxIter::new(&b.payload[4..]), depth + 1); + } + } else if b.is(b"iprp") { + depth_boxes(c, b.children(), depth + 1); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Assembles ftyp + meta(version/flags + iprp(ipco, ipma)) by hand; only the structure + /// the census walks matters, no item is ever defined. + fn file_with(ipma_payload: &[u8]) -> Vec { + let mut out = b"\0\0\0\x18ftypheic\0\0\0\0mif1heic".to_vec(); + let mut iprp = Vec::new(); + iprp.extend(box_of(b"ipco", b"")); + iprp.extend(box_of(b"ipma", ipma_payload)); + let mut meta = vec![0u8, 0, 0, 0]; // version 0, flags 0 + meta.extend(box_of(b"iprp", &iprp)); + out.extend(box_of(b"meta", &meta)); + out + } + + fn box_of(kind: &[u8; 4], body: &[u8]) -> Vec { + let mut out = Vec::with_capacity(8 + body.len()); + let size = u32::try_from(8 + body.len()).expect("test box fits in u32"); + out.extend_from_slice(&size.to_be_bytes()); + out.extend_from_slice(kind); + out.extend_from_slice(body); + out + } + + #[test] + fn an_oversized_ipma_is_refused() { + // 40 000 entries of 6 bytes: 240 KiB of ipma, far over the cap, and quadratic if + // parsed (each entry rescans the earlier item ids in heic-rs 0.1.4). + let mut ipma = Vec::new(); + for _ in 0..40_000u32 { + ipma.extend_from_slice(&[0u8, 0, 0, 0, 0, 0]); + } + let bytes = file_with(&ipma); + let r = crate::probe(&bytes).map(|_| ()); + assert!(matches!(r, Err(Error::Limit(ref m)) if m.contains("associations")), "{r:?}"); + } + + #[test] + fn a_undersized_ipma_is_untouched_by_the_caps() { + // The same shape with a normal ipma (two entries): the caps pass, and whatever error + // follows must not come from them. + let bytes = file_with(&[0, 0, 4, 1, 1, 2, 0, 0]); + let r = crate::probe(&bytes); + assert!(!matches!(r, Err(Error::Limit(ref m)) if m.contains("cap")), "{r:?}"); + } +} diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs index 21b9cf647..58b2be254 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -19,6 +19,7 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod census; mod orient; /// Lossless synthetic HEIF files for tests, here and in the crates above (feature `testdata`). #[cfg(any(test, feature = "testdata"))] @@ -149,6 +150,7 @@ pub fn probe(bytes: &[u8]) -> Result { } fn probe_unguarded(bytes: &[u8]) -> Result { + census::cap(bytes)?; let info = heic_rs::probe(bytes).map_err(|e| match e { // No `meta` box: no still image, only a `moov` image sequence (a video track). heic_rs::Error::MissingBox("meta") => Error::Unsupported(SEQUENCE), @@ -156,13 +158,28 @@ fn probe_unguarded(bytes: &[u8]) -> Result { })?; let ctx = Context::open(bytes).map_err(err)?; let id = ctx.meta.primary; - let coded = match ctx.grid(id).map_err(err)? { + let mut coded = match ctx.grid(id).map_err(err)? { Some((_, tiles)) => tiles, None => vec![id], }; + // A `dimg` listing may name the same tile more than once; reconstrutable (`iloc` + // construction method 2) items are moreover rebuilt for every extent that references + // them in heic-rs 0.1.4, so our probe would pay the reconstruction again per tile. Each + // unique item only once, then the total reconstruction (all of it assembled from `bytes` + // by heic-rs) is held below one copy of the file — above that a repeat or chain wants + // more bytes than exist (CodeRabbit #21/#50 on this sweep). Order doesn't matter here: + // this only checks that every tile's bytes lie in the file. + coded.sort_unstable(); + coded.dedup(); + let mut reconstructed = 0_usize; for item in coded { - // Borrowed from `bytes` unless split across extents: cheap. - ctx.item_data(item).map_err(err)?; + reconstructed = reconstructed.saturating_add(ctx.item_data(item).map_err(err)?.len()); + if reconstructed > bytes.len() { + return Err(Error::Limit(format!( + "the container holds {reconstructed} bytes of item data, over the {} bytes of the file itself", + bytes.len() + ))); + } } Ok(Info { width: info.width, @@ -232,6 +249,13 @@ fn decode_unguarded(bytes: &[u8], options: &Options) -> Result { /// `with_alpha` and the item has an alpha auxiliary image. fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool) -> Result { let p = ctx.props(id).map_err(err)?; + // heic-rs 0.1.4 keeps every associated transform and its [`orient::apply`] allocates the + // full picture again for each: a file could chain the repetition and multiply decode CPU + // without changing any checked size. Real files carry at most one `clap`, `irot` and + // `imir`, so refuse anything approaching [`MAX_TRANSFORMS`] (CodeRabbit #44). + if p.transforms.len() > MAX_TRANSFORMS { + return Err(Error::Limit(format!("the image carries {} transform properties, over the {MAX_TRANSFORMS}-property cap", p.transforms.len()))); + } let limit = options.max_pixels; // The declared sizes are refused before anything is decoded or allocated. let (cw, ch) = ctx.coded_size(id, &p).map_err(err)?; @@ -245,6 +269,14 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool) let signal = ycc::Signal { matrix: colour.matrix, full_range: colour.full_range }; let alpha_id = if with_alpha { ctx.alpha_item(id).map_err(err)? } else { None }; let (w, h) = planes.size(); + // A grid's canvas dimensions come from its payload, not the checked `ispe` (CodeRabbit + // #48): check the real one, before [`ycc::to_rgb16`] allocates for it. + match (u32::try_from(w), u32::try_from(h)) { + (Ok(wu), Ok(hu)) => { + heic_rs::image::check_pixels(wu, hu, limit).map_err(err)?; + } + _ => return Err(Error::Limit(format!("the image canvas is {w}x{h}, beyond the {limit}-pixel limit"))), + } let mode = if options.apply_transforms && orient::odd_crop(w, h, &p.transforms)? { ycc::Upsampling::Bilinear } else { ycc::Upsampling::Nearest }; let mut data = ycc::to_rgb16(&planes, signal, alpha_id.is_some(), mode) .ok_or_else(|| Error::Malformed("the decoded pictures don't cover the image".into()))?; @@ -281,6 +313,10 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool) /// cover its canvas rounded up to whole tiles, so legitimate grids sit near 1. const GRID_BUDGET: u64 = 2; +/// The most transform properties (`clap`/`irot`/`imir`) one item may carry. Real files have at +/// most one of each, so anything approaching this is a reordered or hostile association list. +const MAX_TRANSFORMS: usize = 12; + /// The decoded pictures of an item: one, or a grid's tiles in `dimg` order. struct Coded<'a> { frames: Vec, diff --git a/crates/heif/src/tests_synthetic.rs b/crates/heif/src/tests_synthetic.rs index 3259dc96d..1286ff82a 100644 --- a/crates/heif/src/tests_synthetic.rs +++ b/crates/heif/src/tests_synthetic.rs @@ -185,3 +185,16 @@ fn truncated_and_corrupted_files_are_errors_not_panics() { let _ = decode_thumbnail(&b, 1, &Options::default()); } } + +/// CodeRabbit #44: heic-rs keeps every associated transform and each one re-allocates the +/// whole picture, so 13 of them are over the [`crate::MAX_TRANSFORMS`] cap and refused. +#[test] +fn too_many_transform_properties_are_refused() { + let bytes = build(&Spec { props: std::iter::repeat_n(Prop::Irot(0), 13).collect(), ..Spec::new(32, 32, &grey) }); + let r = decode(&bytes, &Options::default()); + assert!(matches!(r, Err(Error::Limit(ref m)) if m.contains("transform")), "{r:?}"); + // At the cap they're not: 12 identity rotations decode normally. + let bytes = build(&Spec { props: std::iter::repeat_n(Prop::Irot(0), 12).collect(), ..Spec::new(32, 32, &grey) }); + let d = decode(&bytes, &Options::default()).unwrap(); + assert_eq!((d.width, d.height), (32, 32)); +} diff --git a/crates/heif/src/ycc.rs b/crates/heif/src/ycc.rs index a0429b453..384d25f2a 100644 --- a/crates/heif/src/ycc.rs +++ b/crates/heif/src/ycc.rs @@ -161,6 +161,13 @@ pub fn to_rgb16(planes: &Planes<'_>, signal: Signal, alpha: bool, mode: Upsampli if !(8..=16).contains(&bd) || w == 0 || h == 0 { return None; } + // The default caller limit is 2²8 pixels, at most four channels per pixel, so the largest + // legitimate `w·h·ch` is 2²30 samples. Anything more is a malformed or hostile canvas the + // decode path would have refused before calling here (CodeRabbit #48): refuse rather than + // ask for a multi-gigabuffer (or worse, over usize). + if w.checked_mul(h)?.checked_mul(ch)? > 1 << 30 { + return None; + } let max = ((1u32 << bd) - 1) as f32; let half = (1u32 << (bd - 1)) as f32; let scale = (1u32 << (bd - 8)) as f32; @@ -323,4 +330,23 @@ mod tests { .is_none() ); } + + /// CodeRabbit #48: a plane claiming 500 000 × 500 000 pixels must be refused without ever + /// allocating for it (before the cap this vec was never built; upper-bounding allocation + /// would have been a 7.5-gibabuffer or a panic instead of an error). + #[test] + fn an_oversized_canvas_is_refused_without_allocation() { + let f = Frame { + width: 500_000, + height: 500_000, + bit_depth: 8, + chroma: ChromaFormat::Yuv420, + y: vec![128], + cb: vec![128; 4], + cr: vec![128; 4], + y_stride: 0, + c_stride: 0, + }; + assert!(to_rgb16(&Planes::Picture(&f), Signal { matrix: 6, full_range: true }, false, Upsampling::Nearest).is_none()); + } } From 178eef790bd8872a6f60f7b1a0fb7b67499acc59 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 08:22:10 +0000 Subject: [PATCH 31/40] heif: bound iloc item-extent visits before reconstructing in probe --- crates/heif/src/lib.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs index 58b2be254..723ffe5d9 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -171,6 +171,14 @@ fn probe_unguarded(bytes: &[u8]) -> Result { // this only checks that every tile's bytes lie in the file. coded.sort_unstable(); coded.dedup(); + // heic-rs 0.1.4 caps the size of what it assembles, not the work: an item built from other + // items (`iloc` construction method 2) is rebuilt once per extent, so a chain of such items + // multiplies at every level while each result stays small. Count those visits from the + // `iloc` table before reconstructing anything. + let mut visits = 0_usize; + for &item in &coded { + item_visits(&ctx, item, 0, &mut visits)?; + } let mut reconstructed = 0_usize; for item in coded { reconstructed = reconstructed.saturating_add(ctx.item_data(item).map_err(err)?.len()); @@ -191,6 +199,36 @@ fn probe_unguarded(bytes: &[u8]) -> Result { }) } +/// Most extent visits heic-rs may spend reconstructing the primary image's coded items; real +/// files need one per tile (plus a few for the rare derived item). +const MAX_ITEM_VISITS: usize = 4096; +/// Deepest chain of items built from items we follow (heic-rs refuses beyond eight itself). +const MAX_ITEM_CHAIN: u32 = 8; + +/// Adds to `visits` the extents heic-rs would read to reconstruct `id`, following items built +/// from other items, and fails once that passes [`MAX_ITEM_VISITS`] or the chain is too deep. +fn item_visits(ctx: &Context<'_>, id: u32, depth: u32, visits: &mut usize) -> Result<(), Error> { + use heic_rs::meta::iloc::Construction; + if depth > MAX_ITEM_CHAIN { + return Err(Error::Limit(format!("items built from items nest deeper than {MAX_ITEM_CHAIN} levels"))); + } + let Some(loc) = ctx.meta.location(id) else { return Ok(()) }; + *visits = visits.saturating_add(loc.extents.len().max(1)); + if *visits > MAX_ITEM_VISITS { + return Err(Error::Limit(format!("reconstructing the image takes over {MAX_ITEM_VISITS} item extent reads"))); + } + if loc.construction == Construction::Item { + for e in &loc.extents { + if let Ok(source) = u32::try_from(e.index) + && source != id + { + item_visits(ctx, source, depth + 1, visits)?; + } + } + } + Ok(()) +} + /// Decodes the primary image, with its metadata. pub fn decode(bytes: &[u8], options: &Options) -> Result { guarded(|| decode_unguarded(bytes, options)) From d00df6319cd382b67c25f2d4bd540b87261318eb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 04:13:32 -0400 Subject: [PATCH 32/40] security/raw: bound hostile-memory paths in raw decoders Sparse walkers, truncated MEF/lossy-JPEG strips, embedded JPEG sizes, the CR3 preview marker walk, OpcodeList3 full-image passes and caller-supplied Deflate tile edges each let a small file or one bad argument multiply allocations or CPU work thousands of times. Each path now is bounded, every fix with a regression test that failed (or blew up) before it, plus a tiff::image single_chunk helper so preview extraction never builds a descriptor vector for a hostile multi-strip layout. --- crates/raw/src/dngwrite.rs | 57 +++++++++++++++++++++- crates/raw/src/opcodes.rs | 45 ++++++++++++++++- crates/raw/src/preview.rs | 73 +++++++++++++++++++++++----- crates/raw/src/sparse.rs | 91 +++++++++++++++++++++++++++++++++-- crates/raw/src/tiffraw.rs | 89 ++++++++++++++++++++++++++++++++-- crates/raw/src/vendor/mef.rs | 59 ++++++++++++++++++++++- crates/raw/src/wb_estimate.rs | 2 +- crates/tiff/src/image.rs | 40 +++++++++++++-- 8 files changed, 427 insertions(+), 29 deletions(-) diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index d7ba42776..fae6015b6 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -94,6 +94,17 @@ pub const SOFTWARE: &str = "LightCraft DNG 2"; /// (3.9 s vs 0.55 s on 4 cores) to save 3 % of the file (266 vs 275 MB); 5–12 % on raw and rendered DNGs. const DEFLATE_LEVEL: u8 = 1; +/// Largest Deflate tile edge the writer pads to (`tile` is caller-supplied in [`DngCompression::Deflate`]): +/// square-tile buffers grow with the square, so an unclamped edge lets a request pad a tiny valid image into +/// a multi-gigabyte buffer and billions of edge-replicate iterations. 2048² samples (≤ 24 MB of f32) is far +/// above the 256 default and every real tile this writer makes. +const MAX_DEFLATE_TILE: u32 = 2048; + +/// `tile` aligned down to a multiple of 16 and clamped to [`MAX_DEFLATE_TILE`]. +fn deflate_tile(tile: u32) -> usize { + (tile.max(16) & !15).min(MAX_DEFLATE_TILE) as usize +} + /// IEEE 754 binary32 → binary16 (round to nearest even; overflow → ±inf, NaN kept). pub fn f32_to_f16(v: f32) -> u16 { let b = v.to_bits(); @@ -318,7 +329,7 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { match (&raw.data, opts.compression) { (RawData::F32(v), DngCompression::Deflate { tile, half }) => { - let tile = (tile.max(16) & !15) as usize; + let tile = deflate_tile(tile); let bp = if half { 2 } else { 4 }; ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![(bp * 8) as u16; cpp])); ifd.set(t::SAMPLE_FORMAT, Value::Short(vec![3; cpp])); @@ -417,7 +428,7 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { ifd.set_image(ImageData::Strips { rows_per_strip: rows as u32, strips }); } DngCompression::Deflate { tile, .. } => { - let tile = (tile.max(16) & !15) as usize; + let tile = deflate_tile(tile); ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16; cpp])); ifd.set(t::COMPRESSION, Value::Short(vec![compression::ADOBE_DEFLATE])); ifd.set(t::PREDICTOR, Value::Short(vec![2])); @@ -546,3 +557,45 @@ pub fn write_rendered_dng( let order = ByteOrder::Little; write_dng(&raw, &DngWriteOptions { compression: DngCompression::Deflate { tile: 256, half: true }, order, xmp }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{BlackLevel, OpcodeLists, RawData, RawFormat, RawImage, Rect}; + + /// Deflate tiles of `tile × tile` pad into a square buffer that grows with the square: a + /// caller-supplied edge of `u32::MAX` would make the writer pad an 8 × 8 image into billions of + /// edge-replicate iterations and a multi-gigabyte buffer. The clamp keeps the bounded shape — same + /// file as requesting the cap exactly, and a DNG either way. + #[test] + fn a_hostile_deflate_tile_edge_is_clamped() { + let (w, h) = (8usize, 8usize); + let full = Rect::new(0, 0, w, h); + let raw = RawImage { + format: RawFormat::Dng, + width: w, + height: h, + cpp: 1, + data: RawData::U16((0..64).map(|i| i as u16 * 129).collect()), + cfa: None, + bits: 12, + black: BlackLevel::uniform(0.0), + white: vec![4095.0], + active_area: full, + crop: full, + orientation: lightcraft_geom::Orientation::Normal, + color: crate::ColorData::default(), + wb_multipliers: None, + linearized: false, + pixel_aspect: 1.0, + opcodes: OpcodeLists::default(), + metadata: Default::default(), + lens_metadata: Default::default(), + }; + let opts = |tile: u32| DngWriteOptions { compression: DngCompression::Deflate { tile, half: false }, ..DngWriteOptions::default() }; + let hostile = write_dng(&raw, &opts(u32::MAX)).expect("the clamped tile writes"); + assert_eq!(hostile, write_dng(&raw, &opts(2048)).expect("the cap itself writes"), "u32::MAX is clamped to the cap"); + assert_ne!(hostile, write_dng(&raw, &opts(256)).expect("a normal tile writes"), "the clamp is not a no-op"); + assert_eq!(crate::probe(&hostile), Some(RawFormat::Dng), "the clamped output is still a DNG"); + } +} diff --git a/crates/raw/src/opcodes.rs b/crates/raw/src/opcodes.rs index 6cf224984..d5a3ffd1e 100644 --- a/crates/raw/src/opcodes.rs +++ b/crates/raw/src/opcodes.rs @@ -656,18 +656,38 @@ fn fix_pixels(buf: &mut [f32], w: usize, h: usize, cpp: usize, cfa: Option<&Cfa> } } +/// Most full-image passes of one `OpcodeList3`: every warp record is a full-image allocation and resample +/// pass and every bad-pixel record a full flatten-and-rebuild, and a hostile record may repeat either +/// thousands of times. Real DNGs carry at most a couple; passes past the cap are skipped. +const MAX_LIST3_PASSES: usize = 8; + /// Apply `OpcodeList3` (on the demosaiced RGB image, values normalised to [0, 1]). pub fn apply_list3(list: &[Opcode], img: &mut Rgb32f) { if list.is_empty() { return; } let (w, h) = (img.width, img.height); + let mut passes = 0usize; for op in applied_ops(list) { match op { - Opcode::WarpRectilinear { planes, center } => *img = warp_rectilinear(img, planes, *center), - Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => *img = warp_rectilinear2(img, planes, *center, *reciprocal), + Opcode::WarpRectilinear { planes, center } => { + if passes < MAX_LIST3_PASSES { + passes += 1; + *img = warp_rectilinear(img, planes, *center); + } + } + Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => { + if passes < MAX_LIST3_PASSES { + passes += 1; + *img = warp_rectilinear2(img, planes, *center, *reciprocal); + } + } Opcode::WarpFisheye { .. } | Opcode::TrimBounds { .. } | Opcode::Unknown { .. } => {} other => { + if passes >= MAX_LIST3_PASSES { + continue; + } + passes += 1; let mut flat: Vec = img.data.iter().flat_map(|p| *p).collect(); apply_list(std::slice::from_ref(other), &mut flat, w, h, 3, None, 1.0); for (d, c) in img.data.iter_mut().zip(flat.as_chunks::<3>().0) { @@ -765,6 +785,27 @@ pub(crate) mod tests { a.iter().flat_map(|v| v.to_be_bytes()).collect() } + /// Warp and bad-pixel records each cost a full-image pass, and a hostile `OpcodeList3` repeats them + /// thousands of times ([`MAX_LIST3_PASSES`] bounds the multiplier); identity warps below the cap must + /// still apply, exactly, so the same image comes out. + #[test] + fn list3_pass_cap_skips_repeated_records() { + let mut img = Rgb32f::new(8, 6); + for (i, px) in img.data.iter_mut().enumerate() { + *px = [i as f32 / 2.0, (i % 8) as f32, (i / 8) as f32]; + } + let orig: Vec<[f32; 3]> = img.data.clone(); + let list: Vec = + (0..MAX_LIST3_PASSES * 5).map(|_| Opcode::WarpRectilinear { planes: vec![[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }).collect(); + apply_list3(&list, &mut img); + assert_eq!(img.data, orig, "identity warps change nothing, and the cap skips the repeated rest"); + + // a real warp below the cap is still applied + let one = [Opcode::WarpRectilinear { planes: vec![[2.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }]; + apply_list3(&one, &mut img); + assert_ne!(img.data, orig, "a genuine warp record is not skipped"); + } + #[test] fn parse_and_apply_area_ops() { let (w, h) = (4usize, 3usize); diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 95f9bdcaa..8a7e180c7 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -103,8 +103,6 @@ fn jpeg_frame(b: &[u8]) -> Option<(u32, u32, u8)> { let be16 = |k: usize| Some(u32::from(u16::from_be_bytes([at(k)?, at(k + 1)?]))); Some((be16(7)?, be16(5)?, at(9)?)) } - -/// Whether `b` is a JPEG XL file: a bare codestream or the ISO-BMFF container's signature box. fn is_jxl(b: &[u8]) -> bool { b.starts_with(&[0xff, 0x0a]) || b.starts_with(&[0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a]) } @@ -128,20 +126,17 @@ fn candidates<'a>(data: &'a [u8], ifd: &Ifd, base: u64, out: &mut Vec<&'a [u8]>) } if matches!(ifd.u16(t::COMPRESSION), Some(6) | Some(7) | Some(34892)) && let Ok(info) = ifd.image() + && let Some(chunk) = info.single_chunk(data.len() as u64) + && let Some(s) = chunk_bytes(data, &chunk) { - let chunks = info.chunks(data.len() as u64); - if chunks.len() == 1 - && let Some(s) = chunk_bytes(data, &chunks[0]) - { - out.push(s); - } + out.push(s); } // a rendered (RGB or grey, never CFA/LinearRaw/mask) JPEG XL preview in one chunk if ifd.u16(t::COMPRESSION) == Some(t::compression::JPEG_XL) && matches!(ifd.u16(t::PHOTOMETRIC), Some(t::photometric::BLACK_IS_ZERO | t::photometric::RGB)) && let Ok(info) = ifd.image() - && let [chunk] = info.chunks(data.len() as u64).as_slice() - && let Some(s) = chunk_bytes(data, chunk) + && let Some(chunk) = info.single_chunk(data.len() as u64) + && let Some(s) = chunk_bytes(data, &chunk) && is_jxl(s) { out.push(s); @@ -397,9 +392,15 @@ fn cr3_preview(bytes: &[u8]) -> Option<&[u8]> { /// Canon CR3 (ISO base media file): the `PRVW` box (Laurent Clévy's CR3 notes; layout confirmed on a CC0 /// sample) is `u32 size, "PRVW", u32 0, u16 ?, u16 width, u16 height, u16 ?, u32 jpeg length, JPEG`; the smaller /// `THMB` box has the same shape. Returns the larger valid one. +/// Marker-walk budget of the CR3 fallback: candidates can overlap (each claiming the rest of the file) and +/// [`is_dct_jpeg`] walks each marker by marker, so walking every candidate would be quadratic in file size. +/// The `THMB`/`PRVW` previews this finds are far below the cap together; once it is spent the scan stops. +const CR3_MAX_WALK: usize = 8 << 20; + fn cr3_preview_boxes(bytes: &[u8]) -> Option<&[u8]> { let mut best: Option<&[u8]> = None; - for tag in [b"PRVW", b"THMB"] { + let mut walked = 0usize; + 'tags: for tag in [b"PRVW", b"THMB"] { let mut from = 0; while let Some(i) = bytes.get(from..).and_then(|s| s.windows(4).position(|w| w == tag)).map(|p| p + from) { from = i + 4; @@ -411,6 +412,10 @@ fn cr3_preview_boxes(bytes: &[u8]) -> Option<&[u8]> { continue; } let jpeg = &bytes[j..j + len]; + if walked + jpeg.len() > CR3_MAX_WALK { + break 'tags; + } + walked += jpeg.len(); if is_dct_jpeg(jpeg) { if best.is_none_or(|b| b.len() < jpeg.len()) { best = Some(jpeg); @@ -440,6 +445,52 @@ mod tests { j } + /// Preview extraction only serves single-chunk JPEG-compressed IFDs and asks the `single_chunk` first: + /// a TIFF whose strip array declares 100 000 one-row strips and no StripByteCounts must not build their + /// descriptor vector, and of course finds no preview in it. + #[test] + fn a_multi_strip_jpeg_ifd_costs_no_descriptor_vector() { + let mut ifd = IfdBuilder::new(); + ifd.set(t::COMPRESSION, Value::Short(vec![7])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![16])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![100_000])); + ifd.set(t::ROWS_PER_STRIP, Value::Short(vec![1])); + ifd.set(t::STRIP_OFFSETS, Value::Byte((0..100_000).map(|i| (i % 255 + 1) as u8).collect())); + let file = TiffWriter::default().write(&[ifd]).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(embedded_preview(&file))); + assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(2)), Ok(None)); + } + + /// CR3 candidates claim overlapping extents: each block's first bytes double as a 40-byte jump marker so + /// a walk jogs past the next header and reaches the end of the file, making every one of the 9000 + /// candidates walk its whole suffix before the marker-walk budget — quadratic without it. Nothing here + /// is a JPEG (no frame header), so the answer is `None` either way. + #[test] + fn cr3_preview_boxes_stop_walking_when_the_budget_is_spent() { + // one 256-byte block per candidate, all marker-aligned so walks chain through later blocks: + // 0..4, size (itself a jump marker of 40 bytes); 4..8, "PRVW"; 20..24, the claimed length + // (to the file end); 24..28, an SOI plus a marker jumping to the fill at 40; 64..256, a fill of + // 4-byte markers (`ff 01 00 02`). + let blocks = 9000usize; + let total = blocks * 256; + let mut f = Vec::with_capacity(total); + for k in 0..blocks { + let base = k * 256; + f.extend_from_slice(&[0xff, 0x01, 0x00, 0x26, b'P', b'R', b'V', b'W', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + f.extend_from_slice(&u32::try_from(total - base - 24).unwrap().to_be_bytes()); + f.extend_from_slice(&[0xff, 0xd8, 0x00, 0x24, 0xff, 0x01, 0x00, 0x26, 0, 0, 0, 0, 0, 0, 0, 0]); + f.resize(base + 64, 0); // 40..64 filler: walks jump over it, so its bytes are free + for _ in 0..48 { + f.extend_from_slice(&[0xff, 0x01, 0x00, 0x02]); + } + } + assert_eq!(f.len(), total); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(cr3_preview_boxes(&f).map(|s| s.len()))); + assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(2)), Ok(None), "no PRVW candidate is a JPEG"); + } + /// A minimal RAF: the fixed header (JPEG offset/length at 84/88), the JPEG, then a raw tail the /// caller must not have to read. `jpeg_len` is written as declared, so a test can claim more than /// the JPEG holds the way a padded container does. diff --git a/crates/raw/src/sparse.rs b/crates/raw/src/sparse.rs index d67a92b40..a1a718f14 100644 --- a/crates/raw/src/sparse.rs +++ b/crates/raw/src/sparse.rs @@ -35,6 +35,12 @@ const MAX_ENTRIES: u64 = 8192; const MAX_BOXES: usize = 4096; /// Child IFDs listed by one `SubIFDs` entry. const MAX_CHILDREN: u64 = 4096; +/// Pending child-IFD offsets the walker may queue at once: no more than it can ever visit +/// (the same [`MAX_IFDS`] the seen set is held to, deduplicated as they are popped), so an alley +/// of hostile `SubIFDs` entries can't grow the queue into the tens of millions of pointers. +/// The plan never guesses: the walk refuses and the plan reports the whole file when it would go +/// past the cap. +const MAX_PENDING: usize = MAX_IFDS; /// The RAF raw container's pointer to its raw IFD. const RAF_RAW_IFD: u16 = 0xf000; @@ -308,6 +314,10 @@ fn walk_tiff(w: &mut Want, origin: u64, data: &[u8], extra: &[u16]) -> Result = Vec::new(); while let Some(off) = stack.pop() { if off == 0 || off >= len || seen.len() >= MAX_IFDS || !seen.insert(off) { continue; @@ -329,7 +339,7 @@ fn walk_tiff(w: &mut Want, origin: u64, data: &[u8], extra: &[u16]) -> Result Result inline { - meta.insert(at(start), at(value_end)); + spans.push((at(start), at(value_end))); } let child = matches!(tag, t::SUB_IFDS | t::EXIF_IFD | t::GPS_IFD | t::INTEROP_IFD) || extra.contains(&tag); let locates = child || matches!(tag, t::STRIP_OFFSETS | t::STRIP_BYTE_COUNTS | t::TILE_OFFSETS | t::TILE_BYTE_COUNTS); @@ -359,14 +369,21 @@ fn walk_tiff(w: &mut Want, origin: u64, data: &[u8], extra: &[u16]) -> Result= MAX_PENDING { + return Err("too many child IFDs queued to visit"); + } stack.push(v); } } } if complete && let Some(next) = offset_at(table_end) { + if stack.len() >= MAX_PENDING { + return Err("too many child IFDs queued to visit"); + } stack.push(next); } } + meta.extend(spans); Ok(meta) } @@ -585,8 +602,74 @@ mod tests { assert_eq!(run(&file, 16, 0).0, HeaderPlan::Whole("an ARW6 header probe reads its tiles")); } - /// A TIFF with a million one-byte strips listed in descending order and all byte counts 0: the plan is - /// built in one sort and merge, not one pass over all strips (or all holes) per strip. + /// An IFD whose `SubIFDs` entries would queue 33 million child pointers (8192 entries of + /// 4096 values apiece, all pointing at the same array) is refused at the walker's pending + /// cap instead of growing the queue — and the plan reports the whole file, never guessing. + #[test] + fn a_subifds_alley_is_refused_at_the_pending_cap() { + // First read everything, so the walk reaches the directory table itself. + let n = 8192u32; + let len = 8 + 2 + (n * 12 + 4 + 4 * 4096) as usize; + let mut tiff = b"II*\0\x08\0\0\0".to_vec(); + tiff.extend_from_slice(&(n as u16).to_le_bytes()); + let mut entry = |tag: u16, typ: u16, count: u32, value: u32| { + tiff.extend_from_slice(&tag.to_le_bytes()); + tiff.extend_from_slice(&typ.to_le_bytes()); + tiff.extend_from_slice(&count.to_le_bytes()); + tiff.extend_from_slice(&value.to_le_bytes()); + }; + // header (8) + count field (2) + the {n} numbers and pointer (4), then the value array + let values_at = (8 + 2u64 + u64::from(n) * 12 + 4) as u32; + for _ in 0..n { + entry(t::SUB_IFDS, 4, MAX_CHILDREN as u32, values_at); + } + tiff.extend_from_slice(&0u32.to_le_bytes()); + tiff.extend((1..=4096u32).flat_map(u32::to_le_bytes)); + let mut loaded = Ranges::new(); + loaded.insert(0, len as u64); + let mut w = Want::new(&tiff, &loaded); + assert_eq!(walk_tiff(&mut w, 0, &tiff, &[]), Err("too many child IFDs queued to visit")); + } + + /// A TIFF with many out-of-line values across 512 directories is planned in one sort and + /// merge, not one pass over all accumulated ranges per value (CodeRabbit #52: before, each + /// `Ranges::insert` copied every range found before it — the ~million disjoint values of the + /// fixture below ask for more copying than the timeout allows). + #[test] + fn many_out_of_line_values_plan_fast() { + let (ifds, values) = (512usize, 2048usize); + let ifd_len = 2 + values * 12 + 4; + let values_at = 8 + ifds * ifd_len; + let len = values_at + values * 8; + let mut tiff = vec![0u8; len]; + tiff[..8].copy_from_slice(b"II*\0\x08\0\0\0"); + for i in 0..ifds { + let at = 8 + i * ifd_len; + // {values} out-of-line strings (count 8 > the 4 inline bytes), then the next-IFD + // pointer; every value is a disjoint 8-byte range for the merge to handle. + tiff[at..at + 2].copy_from_slice(&(values as u16).to_le_bytes()); + for k in 0..values { + let e = at + 2 + k * 12; + tiff[e..e + 2].copy_from_slice(&t::MAKE.to_le_bytes()); + tiff[e + 2..e + 4].copy_from_slice(&2u16.to_le_bytes()); // ASCII: 8 bytes > inline — a span per entry + tiff[e + 4..e + 8].copy_from_slice(&8u32.to_le_bytes()); + tiff[e + 8..e + 12].copy_from_slice(&(values_at as u32 + (k * 8) as u32).to_le_bytes()); + } + let next = if i == ifds - 1 { 0u32 } else { (at + ifd_len) as u32 }; + tiff[at + ifd_len - 4..at + ifd_len].copy_from_slice(&next.to_le_bytes()); + } + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut loaded = Ranges::new(); + loaded.insert(0, len as u64); + let _ = tx.send(header_plan(&tiff, &loaded)); + }); + rx.recv_timeout(std::time::Duration::from_secs(20)).expect("header_plan took longer than 20 s"); + } + + /// A TIFF with a million one-byte strips listed in descending order and all byte counts 0: + /// the plan is built in one sort and merge, not one pass over all strips (or all holes) per + /// strip. #[test] fn many_strips_without_byte_counts_plan_fast() { let n = 1_000_000u32; diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index a781151b4..8cd6a13e2 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -179,7 +179,7 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack } comp::LOSSY_JPEG => { // lossy DNG: each chunk is a baseline (DCT) JPEG of 8-bit samples - let (px, jw, jh, jc) = lossy_jpeg(src, cpp)?; + let (px, jw, jh, jc) = lossy_jpeg(src, cpp, MAX_JPEG_SAMPLES)?; let mut v = vec![0u16; n]; for y in 0..ch.min(jh) { for x in 0..cw.min(jw) { @@ -197,7 +197,23 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack // the gate caps at MAX_SAMPLES (≤ 2^30), so the `usize` cast below is lossless on 32-bit targets too let inflate_len = crate::checked_pixel_count(row_bytes, ch as u64).ok_or_else(|| RawError::Corrupt("bad TIFF chunk dimensions".into()))?; + // Deflate compresses at most about 1:1032, so a source this far under half of its + // nominal length cannot reconstruct the chunk: refused before the nominal buffer is + // inflated and predicted, which padding-only work would make gigabytes. + if (src.len() as u64).saturating_mul(2048) < inflate_len { + return Err(RawError::Corrupt(format!("a deflated chunk of {inflate_len} bytes is packed into only {} bytes", src.len()))); + } let raw = inflate(src, inflate_len as usize)?; + // The stream has to reach the last row inside the image (edge strips may stop there); + // it is laid out in nominal rows, so shorter is a truncated chunk, not a picture. + let last = if float { vw as u64 * cpp as u64 * bytes_per as u64 } else { (vw as u64 * cpp as u64 * bits as u64).div_ceil(8) }; + let visible = row_bytes.saturating_mul(vh.saturating_sub(1) as u64).saturating_add(last); + if (raw.len() as u64) < visible { + return Err(RawError::Corrupt(format!( + "a deflated chunk holds {} bytes, fewer than the {visible} bytes its in-image samples span", + raw.len() + ))); + } unpack_chunk(&raw, order, packing, cw, ch, cpp, bits, float, info.predictor) } comp::JPEG_XL => jxl_chunk(src, cw, ch, vw, vh, cpp, bits, float), @@ -228,19 +244,31 @@ fn jxl_chunk_check(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: jxl_chunk(src, cw, ch, vw, vh, cpp, bits, float).map(|_| ()) } +/// The most samples a tiled JPEG chunk or a white-balance preview may decode to: far more than +/// any real lossy tile or preview carries, and small enough that a JPEG header alone cannot size +/// a multi-gigabyte allocation. +pub(crate) const MAX_JPEG_SAMPLES: usize = 1 << 26; + /// Decode one baseline JPEG chunk to 8-bit samples: (samples, width, height, channels). -pub(crate) fn lossy_jpeg(src: &[u8], cpp: usize) -> Result<(Vec, usize, usize, usize)> { +pub(crate) fn lossy_jpeg(src: &[u8], cpp: usize, max_samples: usize) -> Result<(Vec, usize, usize, usize)> { use zune_core::bytestream::ZCursor; use zune_core::colorspace::ColorSpace; use zune_core::options::DecoderOptions; let cs = if cpp == 1 { ColorSpace::Luma } else { ColorSpace::RGB }; let opts = DecoderOptions::default().set_max_width(1 << 16).set_max_height(1 << 16).set_strict_mode(false).jpeg_set_out_colorspace(cs); let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(src), opts); - let px = d.decode().map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}")))?; + // The header is read first: the JPEG's own dimensions, not the enclosing tile's, size every + // allocation `decode` makes, and they may claim billions of pixels. + d.decode_headers().map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}")))?; let info = d.info().ok_or_else(|| RawError::Corrupt("lossy JPEG tile without a header".into()))?; let (w, h) = (info.width as usize, info.height as usize); let c = if cpp == 1 { 1 } else { 3 }; - if w == 0 || h == 0 || px.len() < w * h * c { + let out = w as u64 * h as u64 * c as u64; + if w == 0 || h == 0 || out > max_samples as u64 { + return Err(RawError::Corrupt(format!("a lossy JPEG of {w} × {h} × {c} would exceed the {max_samples} output samples allowed"))); + } + let px = d.decode().map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}")))?; + if px.len() < w * h * c { return Err(RawError::Corrupt("lossy JPEG tile: short output".into())); } Ok((px, w, h, c)) @@ -471,4 +499,57 @@ mod lossy_tests { // half of the visible rows are missing assert!(decode(8, 4).is_err()); } + + /// A lossy JPEG whose header alone claims more samples than any real tile or preview (here + /// 40000 × 40000 × 3) is refused from the header, before the decoder allocates its output + /// from the JPEG's own dimensions. + #[test] + fn an_oversized_lossy_jpeg_header_is_refused_before_decoding() { + let mut j = vec![0xff, 0xd8]; // SOI + // SOF0: precision 8, 40000 (0x9c40) x 40000, three components + j.extend([0xff, 0xc0, 0x00, 0x11, 0x08, 0x9c, 0x40, 0x9c, 0x40, 0x03, 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01]); + // SOS with three components so header parsing completes normally, then EOI + j.extend([0xff, 0xda, 0x00, 0x0c, 0x03, 0x01, 0x00, 0x02, 0x11, 0x00, 0x03, 0x11, 0x00, 0x00, 0x3f, 0x00, 0x00, 0xff, 0xd9]); + let e = lossy_jpeg(&j, 3, MAX_JPEG_SAMPLES).unwrap_err(); + assert!(matches!(e, RawError::Corrupt(_)), "{e:?}"); + // a header inside the budget passes the check (and then fails to decode only for lack of data) + j[12..14].copy_from_slice(&1024u16.to_be_bytes()); + j[14..16].copy_from_slice(&1024u16.to_be_bytes()); + assert!(lossy_jpeg(&j, 3, MAX_JPEG_SAMPLES).is_ok() || matches!(lossy_jpeg(&j, 3, MAX_JPEG_SAMPLES), Err(RawError::Corrupt(_)))); + } + + /// A deflated tile is refused when its packed bytes are too few for deflate to have produced + /// the nominal chunk, or when the stream stops short of the rows inside the image; a fully + /// covered flat tile still decodes. + #[test] + fn a_short_or_undercovering_deflated_tile_is_refused() { + let info = |byte_counts: u64| ImageInfo { + width: 512, + height: 512, + bits_per_sample: vec![16], + samples_per_pixel: 1, + compression: comp::ADOBE_DEFLATE, + photometric: 32803, + planar: 1, + predictor: 1, + sample_format: 1, + new_subfile_type: 0, + layout: Layout::Tiles { tile_width: 512, tile_height: 512 }, + offsets: vec![0], + byte_counts: vec![byte_counts], + }; + // a complete flat tile: 512 KiB of zeros compresses to a few hundred bytes (well within + // deflate's packing bound) and inflates to every nominal byte the tile claims + let zlib = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 512 * 512 * 2], 6); + assert_eq!(read_image(&zlib, &info(zlib.len() as u64), ByteOrder::Little, Packing::Msb), Ok(RawData::U16(vec![0; 512 * 512]))); + // the same 1 KiB packed, but the stream claims a whole tile: a few hundred bytes cannot + // reconstruct 512 KiB from a decoder that compresses no further than about 1:1032 + let zlib = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 1024], 6); + assert!(matches!(read_image(&zlib, &info(512 * 512 * 2), ByteOrder::Little, Packing::Msb), Err(RawError::Corrupt(_))), "{zlib:?}"); + // 500 KiB of zeros compress to a stream within the packing bound and inflating to more + // bytes than the in-image samples span, but 24 KiB short of the whole nominal tile: + // refused as truncated rather than filled with the predictor's padding + let zlib = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 500 * 1000], 6); + assert!(matches!(read_image(&zlib, &info(zlib.len() as u64), ByteOrder::Little, Packing::Msb), Err(RawError::Corrupt(_)))); + } } diff --git a/crates/raw/src/vendor/mef.rs b/crates/raw/src/vendor/mef.rs index 207bc8871..d5c5d83b4 100644 --- a/crates/raw/src/vendor/mef.rs +++ b/crates/raw/src/vendor/mef.rs @@ -89,6 +89,13 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { // the white balance is measured from the samples, so the header probe reads them too let chunks = info.chunks(bytes.len() as u64); let src = chunks.first().and_then(|c| chunk_bytes(bytes, c)).ok_or_else(|| RawError::Corrupt("Mef strip outside file".into()))?; + // 12-bit MSB packing takes 3 bytes per 2 samples; a strip that cannot hold the declared + // samples is a truncated file, not a picture — unpacking it would run past the real bytes + // through every output sample (a short strip asks for the whole allocation and more). + let need = n.div_ceil(2).saturating_mul(3); + if src.len() < need { + return Err(RawError::Corrupt(format!("a Mef strip of {} bytes cannot hold the declared {n} samples", src.len()))); + } let mut d = vec![0u16; n]; crate::unpack::unpack_msb(src, 12, &mut d); let white = super::white_from_data(&d, 12); @@ -138,6 +145,10 @@ mod tests { } fn sample(w: u32, h: u32, px: &[u16]) -> Vec { + sample_strip(w, h, &pack(px)) + } + + fn sample_strip(w: u32, h: u32, strip: &[u8]) -> Vec { let mut raw = IfdBuilder::new(); raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); @@ -148,7 +159,7 @@ mod tests { raw.set(t::ORIENTATION, Value::Short(vec![6])); raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); - raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![pack(px)] }); + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![strip.to_vec()] }); let mut ifd0 = IfdBuilder::new(); ifd0.set(t::MAKE, Value::Ascii("Mamiya-OP Co.,Ltd.".into())); ifd0.set(t::MODEL, Value::Ascii("MAMIYA ZD".into())); @@ -156,6 +167,52 @@ mod tests { TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap() } + /// A strip that cannot hold the declared samples is a truncated file, refused as corrupt + /// instead of unpacked: `unpack_msb` would otherwise run `n` samples through the few real + /// bytes, filling the rest from nothing. + #[test] + fn a_strip_too_short_for_the_declared_samples_is_refused() { + let px: Vec = (0..16 * 6).map(|i| (i * 41 % 4096) as u16).collect(); + let f = sample(16, 6, &px); + assert!(crate::decode(&f).is_ok()); + // A truncated file shaped like a real one: IFD tables first, strip last — only its first + // 20 packed bytes remain, while the IFD still declares the full 1.5 B/sample length. The + // probe accepts the file on the declared length; before the byte gate below, `unpack_msb` + // ran the dimension-sized allocation through those 20 bytes, filling it from nothing. + let u32be = |b: &[u8], a: usize| u32::from_be_bytes([b[a], b[a + 1], b[a + 2], b[a + 3]]); + let full = pack(&px); + let at = f.windows(full.len()).position(|w| w == full).expect("strip bytes in the file"); + let after_strip = at + full.len(); + let moved = after_strip - (at + 20); + let mut g = f[..8].to_vec(); + g.extend(std::iter::repeat_n(0u8, at + 20 - 8)); + g.extend_from_slice(&f[after_strip..]); + // repoint the first-IFD pointer and ifd0's MAKE / MODEL / SubIfds offsets (the tables now + // sit `moved` earlier); the raw sub-IFD's strip offset and byte count are patched below + let poke = |b: &mut [u8], a: usize| { + let v = u32::from_be_bytes([b[a], b[a + 1], b[a + 2], b[a + 3]]); + if v as usize >= after_strip { + b[a..a + 4].copy_from_slice(&(v - moved as u32).to_be_bytes()); + } + }; + let ifd0_at = u32be(&g, 4) as usize - moved; + g[4..8].copy_from_slice(&(ifd0_at as u32).to_be_bytes()); + let n = u16::from_be_bytes([g[ifd0_at], g[ifd0_at + 1]]) as usize; + for i in 0..n { + poke(&mut g, ifd0_at + 2 + i * 12 + 8); + } + // the raw sub-IFD: entries follow its count; tag 273 (StripOffsets) is the seventh, held + // inline — repoint it at the strip's new home, and append the truncated strip for real + let sub_at = u32be(&g, ifd0_at + 2 + 2 * 12 + 8) as usize; + let e_offsets = sub_at + 2 + 6 * 12 + 8; + let strip_at = g.len() as u32; + g[e_offsets..e_offsets + 4].copy_from_slice(&strip_at.to_be_bytes()); + g.extend_from_slice(&full[..20]); + assert_eq!(crate::probe(&g), Some(RawFormat::Mef)); + let r = crate::decode(&g); + assert!(matches!(r, Err(RawError::Corrupt(_))), "{r:?}"); + } + #[test] fn decodes_packed_12_bit() { let px: Vec = (0..16 * 6).map(|i| (i * 41 % 4096) as u16).collect(); diff --git a/crates/raw/src/wb_estimate.rs b/crates/raw/src/wb_estimate.rs index 17a86a2a5..13cb9f8da 100644 --- a/crates/raw/src/wb_estimate.rs +++ b/crates/raw/src/wb_estimate.rs @@ -66,7 +66,7 @@ fn median(v: &mut [f64]) -> Option { fn from_preview(bytes: &[u8], f: &Frame) -> Option<[f32; 3]> { let jpeg = crate::preview::embedded_preview(bytes)?; - let (px, pw, ph, ch) = crate::tiffraw::lossy_jpeg(&jpeg, 3).ok()?; + let (px, pw, ph, ch) = crate::tiffraw::lossy_jpeg(&jpeg, 3, crate::tiffraw::MAX_JPEG_SAMPLES).ok()?; if ch != 3 { return None; } diff --git a/crates/tiff/src/image.rs b/crates/tiff/src/image.rs index ca3ff8b69..16e24f367 100644 --- a/crates/tiff/src/image.rs +++ b/crates/tiff/src/image.rs @@ -118,14 +118,34 @@ impl ImageInfo { } } - /// All chunks that have an offset. Missing byte counts are inferred as "to the next offset / end of data" - /// using `data_len`. - pub fn chunks(&self, data_len: u64) -> Vec { + /// The chunk count the image declares (offsets present, cut to the layout's plane capacity) with the + /// geometry `chunks` needs to place each one. No descriptors are built, so a pathological strip array + /// costs it a length check. + fn chunk_grid(&self) -> (usize, u32, usize) { let (across, down) = self.grid(); let per_plane = across as usize * down as usize; let planes = if self.planar == 2 { self.samples_per_pixel as usize } else { 1 }; + (self.offsets.len().min(per_plane.saturating_mul(planes)), across, per_plane) + } + + fn declared_chunk_count(&self) -> usize { + self.chunk_grid().0 + } + + /// The one chunk when the image has exactly one, else `None` — and no descriptor vector beyond it. Preview + /// extraction only serves single-chunk images, so it asks here before serving a hostile multi-strip layout. + pub fn single_chunk(&self, data_len: u64) -> Option { + if self.declared_chunk_count() != 1 { + return None; + } + self.chunks(data_len).into_iter().next() + } + + /// All chunks that have an offset. Missing byte counts are inferred as "to the next offset / end of data" + /// using `data_len`. + pub fn chunks(&self, data_len: u64) -> Vec { + let (n, across, per_plane) = self.chunk_grid(); let (cw, ch) = self.chunk_size(); - let n = self.offsets.len().min(per_plane.saturating_mul(planes)); let mut out = Vec::with_capacity(n); // the distinct offsets in order, for chunks without a byte count (sorted once, not scanned per chunk) let mut sorted: Vec = Vec::new(); @@ -192,6 +212,18 @@ mod tests { } } + /// One offset is one chunk, and `single_chunk` refuses to build descriptors when the image declares more. + #[test] + fn single_chunk_is_the_only_chunk() { + let one = strips(vec![50], vec![10]); + assert_eq!(one.single_chunk(100).map(|c| (c.index, c.offset, c.len)), one.chunks(100).first().map(|c| (c.index, c.offset, c.len))); + // the inferred length matches too, and never falls back past the data + let inferred = strips(vec![70], vec![]); + assert_eq!(inferred.single_chunk(100).map(|c| c.len), Some(30)); + assert!(strips(vec![50, 10], vec![10, 5]).single_chunk(100).is_none(), "a multi-chunk image has no single chunk"); + assert!(strips(Vec::new(), Vec::new()).single_chunk(100).is_none()); + } + #[test] fn missing_byte_counts_run_to_the_next_larger_offset() { let info = strips(vec![50, 10, 30, 10, 70], vec![0, 0, 5]); From d8ceb3a6af2b5bd86fdcb947eb8a4f5b1c372125 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 05:17:19 -0400 Subject: [PATCH 33/40] security/meta,merge,raw,tiff,denoise: bound memory sized by attacker-shaped inputs XMP flattening, sparse-focus EXIF decode (plus the maker-note and TIFF value budgets), lossy-DNG chunk staging, panorama stitch frames, the reference ONNX executor's tensors and the denoise product reader all now reject or cap what a malformed file can make them allocate: each result is checked before the memory behind it is reserved, every regression carrying a test that fails without its bound. --- Cargo.lock | 1 + crates/denoise-core/Cargo.toml | 1 + crates/denoise-core/src/product.rs | 37 ++++++- crates/denoise-core/tests/product_heap.rs | 50 +++++++++ crates/denoise/src/reference.rs | 76 +++++++++++++- crates/merge/src/lib.rs | 2 + crates/merge/src/pano/camera.rs | 10 +- crates/merge/src/pano/mod.rs | 8 ++ crates/merge/tests/it/pano.rs | 12 +++ crates/meta/src/exif.rs | 12 ++- crates/meta/src/focus_read.rs | 10 +- crates/meta/src/lib.rs | 2 +- crates/meta/src/xmp.rs | 69 +++++++++++-- crates/raw/src/tiffraw.rs | 118 ++++++++++++++++------ crates/tiff/src/makernote.rs | 35 ++++++- crates/tiff/src/reader.rs | 19 ++++ 16 files changed, 406 insertions(+), 56 deletions(-) create mode 100644 crates/denoise-core/tests/product_heap.rs diff --git a/Cargo.lock b/Cargo.lock index 6e932cba8..9d7e4ec18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3221,6 +3221,7 @@ dependencies = [ name = "lightcraft-denoise-core" version = "0.14.0" dependencies = [ + "dhat", "lightcraft-raster", "miniz_oxide 0.8.9", "proptest", diff --git a/crates/denoise-core/Cargo.toml b/crates/denoise-core/Cargo.toml index b65f90ae4..d0a0b10c1 100644 --- a/crates/denoise-core/Cargo.toml +++ b/crates/denoise-core/Cargo.toml @@ -24,3 +24,4 @@ web-time = { workspace = true } [dev-dependencies] proptest = { workspace = true } +dhat = { workspace = true } diff --git a/crates/denoise-core/src/product.rs b/crates/denoise-core/src/product.rs index 5a607c8f4..c33b1d4c2 100644 --- a/crates/denoise-core/src/product.rs +++ b/crates/denoise-core/src/product.rs @@ -9,7 +9,7 @@ //! compressed length and CRC-32, then the strips' bytes. `key` says what the product was made from (the photo's //! content, the model, this code's version, the settings it depends on); a file with another key is stale. -use std::io::{Read, Write}; +use std::io::{Read, Seek, Write}; use std::path::Path; use lightcraft_raster::Rgb32f; @@ -216,6 +216,10 @@ fn read_start(r: &mut impl Read) -> Result { r.read_exact(&mut e)?; let l = u32::from_le_bytes([e[0], e[1], e[2], e[3]]) as usize; let c = u32::from_le_bytes([e[4], e[5], e[6], e[7]]); + // a strip never compresses to nothing (it has at least one row, so at least one deflated pixel) + if l == 0 { + return Err(ProductError::Format("a strip is empty".into())); + } // a strip never compresses to much more than its raw size if l > 12 * h.strip * h.width + 4096 { return Err(ProductError::Format("a strip is larger than it can be".into())); @@ -305,7 +309,6 @@ pub fn read_window(path: &Path, key: &str, window: Option, factor: usize } // the picture rows and columns the blocks cover let (x_end, y_end) = if whole { (w.x + w.width, w.y + w.height) } else { (w.x + ow * factor, w.y + oh * factor) }; - let mut out = Rgb32f { width: ow, height: oh, data: allocate(ow * oh, [0.0; 3])? }; let lut = f16_table(); let value = |raw: &[u8], c: usize, n: usize, i: usize| -> f32 { let (lo, hi) = (raw.get(2 * c * n + i).copied().unwrap_or(0), raw.get((2 * c + 1) * n + i).copied().unwrap_or(0)); @@ -318,6 +321,21 @@ pub fn read_window(path: &Path, key: &str, window: Option, factor: usize let strips: Vec = (0..table.len()).filter(|&s| wanted(s)).collect(); // skip the strips before the first one wanted let skipped: usize = table.iter().take(strips.first().copied().unwrap_or(0)).map(|e| e.0).sum(); + // The output is reserved only after the wanted strips' compressed bytes are present and match their checksums: + // a malformed cache whose strips are empty, damaged or short are rejected here, before the (large) result is + // allocated, not after. Reading them twice is cheap next to decoding them, and the fold below re-seeks here. + let strips_at = f.stream_position()?; + f.seek_relative(i64::try_from(skipped).map_err(|_| ProductError::Format("the file is too large".into()))?)?; + for &s in &strips { + let Some(&(len, expected)) = table.get(s) else { return Err(ProductError::Format("a strip is missing".into())) }; + let mut packed = allocate(len, 0u8)?; + f.read_exact(&mut packed)?; + if crc(&packed) != expected { + return Err(ProductError::Format(format!("strip {s} is damaged"))); + } + } + let mut out = Rgb32f { width: ow, height: oh, data: allocate(ow * oh, [0.0; 3])? }; + f.seek(std::io::SeekFrom::Start(strips_at))?; f.seek_relative(i64::try_from(skipped).map_err(|_| ProductError::Format("the file is too large".into()))?)?; for group in strips.chunks(GROUP) { let mut packed = Vec::with_capacity(group.len()); @@ -422,6 +440,21 @@ mod tests { let _ = std::fs::remove_file(p); } + #[test] + fn an_empty_strip_entry_is_rejected_by_the_header() { + let p = temp("empty-strip"); + let header = serde_json::to_vec(&Header { width: 16, height: 1, strip: STRIP_ROWS, key: "k".into() }).unwrap(); + let mut bytes = Vec::from(MAGIC.as_slice()); + bytes.extend_from_slice(&[VERSION, 0, 0, 0]); + bytes.extend_from_slice(&(header.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&header); + // one strip with a zero-length entry: it has a row, so its deflated bytes are never empty + bytes.extend_from_slice(&[0; 8]); + std::fs::write(&p, bytes).unwrap(); + assert!(matches!(read_header_at(&p), Err(ProductError::Format(why)) if why.contains("empty"))); + let _ = std::fs::remove_file(p); + } + fn temp(name: &str) -> std::path::PathBuf { std::env::temp_dir().join(format!("lc-denoise-{name}-{}.lcdn", std::process::id())) } diff --git a/crates/denoise-core/tests/product_heap.rs b/crates/denoise-core/tests/product_heap.rs new file mode 100644 index 000000000..40c170861 --- /dev/null +++ b/crates/denoise-core/tests/product_heap.rs @@ -0,0 +1,50 @@ +//! Peak heap of a `read_window` that must reject a product's strips (`crates/denoise-core/src/product.rs`), measured +//! with dhat's testing mode. The result is allocated only after the wanted strips' checksums are verified, so a cache +//! whose compressed bytes are damaged or absent fails without first reserving the (multi-megabyte) output. One test +//! per binary (dhat's profiler is global). + +use lightcraft_denoise_core::product::read_window; + +#[global_allocator] +static ALLOC: dhat::Alloc = dhat::Alloc; + +fn temp(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("lc-denoise-{name}-{}.lcdn", std::process::id())) +} + +/// A 2000 × 1024 product of key `k` (a 24 MB result): its 8 strips each claim 8 bytes that do not match their +/// checksum, and the file is exactly the length its header and table declare. A 24 MB peak means the output was +/// reserved; the cap here bounds the header, the table and the strips' own bytes. +fn damaged_product() -> Vec { + let header = br#"{"width":2000,"height":1024,"strip":128,"key":"k"}"#; + const STRIP_LEN: usize = 8; + let mut bytes = Vec::from(b"LCDN".as_slice()); + bytes.extend_from_slice(&[1, 0, 0, 0]); + bytes.extend_from_slice(&(header.len() as u32).to_le_bytes()); + bytes.extend_from_slice(header); + let expected_len = 12 + header.len() + 8 * 8 + 8 * STRIP_LEN; + for _ in 0..8 { + // 8 claimed bytes whose recorded checksum does not match theirs (0xAA bytes do not have this CRC) + bytes.extend_from_slice(&(STRIP_LEN as u32).to_le_bytes()); + bytes.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]); + } + for _ in 0..8 { + bytes.extend_from_slice(&[0xAAu8; STRIP_LEN]); + } + assert_eq!(bytes.len(), expected_len, "the crafted file lies about its length"); + bytes +} + +#[test] +fn a_damaged_product_is_rejected_before_its_output_is_allocated() { + let bytes = damaged_product(); + let p = temp("damaged-product-heap"); + std::fs::write(&p, &bytes).unwrap(); + let _profiler = dhat::Profiler::builder().testing().build(); + let why = read_window(&p, "k", None, 1, None).err().unwrap().to_string(); + let peak = dhat::HeapStats::get().max_bytes; + let _ = std::fs::remove_file(p); + assert!(why.contains("damaged"), "{why}"); + eprintln!("peak heap {peak} bytes"); + assert!(peak < 4 << 20, "peak heap {peak} bytes: the output was reserved before the strips were checked"); +} diff --git a/crates/denoise/src/reference.rs b/crates/denoise/src/reference.rs index ef58b5b9c..488e9a7b7 100644 --- a/crates/denoise/src/reference.rs +++ b/crates/denoise/src/reference.rs @@ -4,6 +4,12 @@ use crate::net::{Net, NetError, Op, TensorId}; use crate::run::{Error, TileRunner}; +/// The tensors' size cap: the same one the real runner's buffers share (`crates/denoise/src/cpu.rs`). A hostile `Net` +/// only ever has to satisfy the final shape: a `ConvT2` doubles the sides and a `MaxPool` halves them, so a small +/// accepted graph can zoom its intermediates far out and back. Every tensor is counted here, and a run whose layers +/// would retain more than one real tile's whole working set is refused before the memory behind it is reserved. +const MAX_VALUES: usize = 128 * 1024 * 1024; + /// A tensor in `[channels][side][side]` order. struct T { channels: usize, @@ -11,12 +17,36 @@ struct T { data: Vec, } +/// A tensor's plain float count, in bounds. +fn count(channels: usize, side: usize) -> Result { + channels + .checked_mul(side) + .and_then(|n| n.checked_mul(side)) + .filter(|&n| n > 0 && n <= MAX_VALUES) + .ok_or_else(|| NetError::Invalid("a tensor has no size or is too large".into())) +} + +/// A zeroed tensor, keeping the memory the run keeps alive in bounds (`tensors` retains every layer's result). +fn tensor(channels: usize, side: usize, total: &mut usize) -> Result, NetError> { + let n = count(channels, side)?; + *total = (*total).saturating_add(n); + if *total > MAX_VALUES { + return Err(NetError::Invalid("the tile needs too much working memory".into())); + } + let mut data = Vec::new(); + data.try_reserve_exact(n).map_err(|_| NetError::Invalid("not enough memory for a reference tile".into()))?; + data.resize(n, 0.0); + Ok(data) +} + /// Run `net` on `input` (`in_channels` planes of `tile × tile`). pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result, NetError> { if !net.fits_tile(tile) || input.len() != net.in_channels * tile * tile { return Err(NetError::Invalid("the tile does not fit the network".into())); } - let mut tensors: Vec = vec![T { channels: net.in_channels, side: tile, data: input.to_vec() }]; + let input = input.to_vec(); + let mut total = input.len(); + let mut tensors = vec![T { channels: net.in_channels, side: tile, data: input }]; let get = |tensors: &[T], id: TensorId| -> Result { if id < tensors.len() { Ok(id) } else { Err(NetError::Invalid("a layer reads a tensor that does not exist yet".into())) } }; @@ -27,7 +57,7 @@ pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result, NetError> let b = c.src2.map(|s| get(&tensors, s)).transpose()?; let side = tensors[a].side; let pad = (c.k - 1) / 2; - let mut data = vec![0f32; c.cout * side * side]; + let mut data = tensor(c.cout, side, &mut total)?; // the input channels in order: the first source's, then the second's let planes: Vec<&[f32]> = std::iter::once(&tensors[a]).chain(b.map(|b| &tensors[b])).flat_map(|t| t.data.chunks(side * side)).collect(); @@ -56,7 +86,7 @@ pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result, NetError> let a = get(&tensors, c.src)?; let side = tensors[a].side; let out_side = 2 * side; - let mut data = vec![0f32; c.cout * out_side * out_side]; + let mut data = tensor(c.cout, out_side, &mut total)?; for co in 0..c.cout { for y in 0..side { for x in 0..side { @@ -77,7 +107,7 @@ pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result, NetError> Op::MaxPool2 { src } => { let a = get(&tensors, *src)?; let (channels, side) = (tensors[a].channels, tensors[a].side / 2); - let mut data = vec![0f32; channels * side * side]; + let mut data = tensor(channels, side, &mut total)?; for ch in 0..channels { for y in 0..side { for x in 0..side { @@ -92,7 +122,7 @@ pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result, NetError> let a = get(&tensors, *src)?; let (channels, side) = (tensors[a].channels / 4, tensors[a].side); let out_side = 2 * side; - let mut data = vec![0f32; channels * out_side * out_side]; + let mut data = tensor(channels, out_side, &mut total)?; for ch in 0..channels { for y in 0..side { for x in 0..side { @@ -131,3 +161,39 @@ impl TileRunner for ReferenceRunner { run(&self.net, self.tile, input).map_err(|e| Error::Runtime(e.to_string())) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::net::ConvT2; + + fn convt(src: TensorId, cin: usize, cout: usize) -> Op { + Op::ConvT2(ConvT2 { src, cin, cout, weight: vec![0.1; cin * cout * 4], bias: vec![0.0; cout], leaky: None }) + } + + /// A graph that fits the final-shape check while zooming its intermediates out and back: 256 `ConvT2`s double + /// the sides (256 tiers), 255 `MaxPool`s halve them back to the layer above the input. + fn hostile() -> Net { + let mut ops = Vec::new(); + let mut last = 0usize; + for _ in 0..256 { + ops.push(convt(last, 1, 1)); + last += 1; + } + for _ in 0..255 { + ops.push(Op::MaxPool2 { src: last }); + last += 1; + } + Net::new(1, ops).unwrap() + } + + /// The zoomed-out layers' memory was never reserved: either one tensor or the retained whole (the two bounds + /// share the cap) says why the run stops. + #[test] + fn a_graph_that_zooms_its_intermediates_out_and_back_is_refused_before_their_memory_is_reserved() { + assert!(matches!( + run(&hostile(), 1, &[1.0]), + Err(NetError::Invalid(why)) if why.contains("too large") || why.contains("too much working memory") + )); + } +} diff --git a/crates/merge/src/lib.rs b/crates/merge/src/lib.rs index b5ccd4bb8..ca7f91821 100644 --- a/crates/merge/src/lib.rs +++ b/crates/merge/src/lib.rs @@ -34,6 +34,8 @@ pub use pano::{PanoOptions, PanoResult, Projection, stitch}; pub enum MergeError { #[error("{0} photo(s) selected; at least {1} are needed")] TooFew(usize, usize), + #[error("{0} photo(s) selected; a stitch of more than {1} is refused: matching every pair would take far too long")] + TooMany(usize, usize), #[error("{0}")] Mismatch(String), #[error("could not read a photo: {0}")] diff --git a/crates/merge/src/pano/camera.rs b/crates/merge/src/pano/camera.rs index 7625170c3..4589f88db 100644 --- a/crates/merge/src/pano/camera.rs +++ b/crates/merge/src/pano/camera.rs @@ -61,10 +61,12 @@ pub struct PairMatch { /// Match every pair of images. `feats[k]` are in work coordinates scaled by `scales[k]`. pub fn match_pairs(feats: &[Features], scales: &[f64]) -> Vec { let n = feats.len(); - let pairs: Vec<(usize, usize)> = (0..n).flat_map(|i| (i + 1..n).map(move |j| (i, j))).collect(); - let mut out: Vec = pairs - .par_iter() - .filter_map(|&(i, j)| { + // the pairs are generated lazily: materialising all n(n−1)/2 up front is quadratic memory, including + // when every comparison comes up empty (no usable features) + let mut out: Vec = (0..n) + .into_par_iter() + .flat_map_iter(|i| ((i + 1)..n).map(move |j| (i, j))) + .filter_map(|(i, j)| { let m = features::match_features(&feats[i], &feats[j], 0.8); if m.len() < 12 { return None; diff --git a/crates/merge/src/pano/mod.rs b/crates/merge/src/pano/mod.rs index 5ce439681..ba7e1c63f 100644 --- a/crates/merge/src/pano/mod.rs +++ b/crates/merge/src/pano/mod.rs @@ -53,6 +53,11 @@ impl Default for PanoOptions { /// The largest panorama, in megapixels (Lightroom's limit too); a bigger one is scaled down. pub const MAX_MEGAPIXELS: f64 = 512.0; +/// Most frames one stitch accepts: pair matching compares every pair of frames (n(n−1)/2 runs), so an +/// unbounded frame count is quadratic CPU as well as quadratic memory while the frames load. A panorama +/// with more than [`MAX_FRAMES`] shots is refused with an error, not attempted. +pub const MAX_FRAMES: usize = 256; + /// The longest edge of a panorama, in pixels (Lightroom's limit too); a longer one is scaled down. pub const MAX_EDGE: f64 = 65_000.0; @@ -224,6 +229,9 @@ pub fn stitch(mut frames: Vec, opts: &PanoOptions, progress: &Progress) - if n < 2 { return Err(MergeError::TooFew(n, 2)); } + if n > MAX_FRAMES { + return Err(MergeError::TooMany(n, MAX_FRAMES)); + } check(progress, 0.01, "Finding features")?; let (cams, rms_before, rms) = register(&frames, progress)?; let used: Vec = (0..n).filter(|&i| cams[i].is_some()).collect(); diff --git a/crates/merge/tests/it/pano.rs b/crates/merge/tests/it/pano.rs index fdb736524..aeecde64c 100644 --- a/crates/merge/tests/it/pano.rs +++ b/crates/merge/tests/it/pano.rs @@ -246,6 +246,18 @@ fn unrelated_photos_do_not_stitch() { assert!(stitch(frames, &PanoOptions::default(), &no_progress).is_err()); } +/// Pair matching compares every pair of frames before anything is known about them: an unbounded +/// photo count is quadratic CPU and memory (issue #1519). More than `MAX_FRAMES` is refused at once, +/// before any frame is worked on. +#[test] +fn a_stitch_of_more_than_the_frame_cap_is_refused_up_front() { + let frames: Vec = (0..lightcraft_merge::pano::MAX_FRAMES + 1) + .map(|i| Frame::from_linear_rec2020(Rgb32f::from_fn(8, 8, |_, _| [(i % 255) as f32 / 255.0, 0.0, 0.0]), None)) + .collect(); + let err = stitch(frames, &PanoOptions::default(), &no_progress).unwrap_err(); + assert!(err.to_string().contains("refused"), "the frame cap errors, it does not begin the quadratic match: {err}"); +} + /// Issue #1455: a panorama came out at 40 MP whatever its photos' resolution. It now keeps their /// own resolution unless a size is asked for (up to Lightroom's 512 MP); a size scales it down, /// and a NaN one (from an agent) is no size at all, not a 0.1 MP thumbnail. diff --git a/crates/meta/src/exif.rs b/crates/meta/src/exif.rs index f24bb5f2f..adcba0f80 100644 --- a/crates/meta/src/exif.rs +++ b/crates/meta/src/exif.rs @@ -1,7 +1,7 @@ //! Exif / TIFF tags → [`Metadata`] (CIPA DC-008 Exif 2.32, TIFF 6.0, DNG 1.7 tag semantics). use crate::{DateTime, Flash, Gps, Metadata, Orientation}; -use lightcraft_tiff::{Ifd, Tiff, TiffError, tags as t}; +use lightcraft_tiff::{Ifd, ParseOptions, Tiff, TiffError, tags as t}; /// Strip an `Exif\0\0` (or `Exif\0\xff`) prefix as found in JPEG APP1 and some WebP `EXIF` chunks. pub fn strip_exif_header(b: &[u8]) -> &[u8] { @@ -16,8 +16,16 @@ 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 { + try_read_exif_with(bytes, &ParseOptions::default()) +} + +/// [`try_read_exif`] with the caller's parser options (its value allocation budget in particular). +/// A decoder working from a sparse buffer — only selected ranges of the logical file are actually +/// loaded, the rest is zeros — must not size its allocations by the logical file length, which +/// unrelated bytes of the file can inflate at no cost to the attacker. +pub fn try_read_exif_with(bytes: &[u8], opts: &ParseOptions) -> Result { let data = strip_exif_header(bytes); - let tiff = Tiff::parse(data)?; + let tiff = Tiff::parse_with(data, opts)?; let mut m = from_tiff(&tiff); // Panasonic writes no LensModel, only the name in its maker note (and in a RW2 that note is not in the raw's own Exif) if m.lens_model.is_none() { diff --git a/crates/meta/src/focus_read.rs b/crates/meta/src/focus_read.rs index 5a5d349d1..65adbee75 100644 --- a/crates/meta/src/focus_read.rs +++ b/crates/meta/src/focus_read.rs @@ -171,7 +171,15 @@ fn tiff(r: &mut Reader, head: Vec) -> io::Result> { 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::Ready => { + // The decoder's allocation budget follows the bytes actually fetched, not the logical + // file length: only the loaded ranges are data, and an unrelated entry that claims a + // huge value must be cut by what leaving it cost, not by what the buffer claims to hold. + // The same 8 × / + 1 MiB shape the general TIFF reader uses, over `reader.bytes` instead. + let opts = + lightcraft_tiff::ParseOptions { value_budget: Some(r.bytes.saturating_mul(8).saturating_add(1 << 20)), ..Default::default() }; + return Ok(crate::try_read_exif_with(&buf, &opts).ok().and_then(|m| m.focus)); + } Step::Nothing => return Ok(None), Step::Need(ranges) => { for (a, b) in ranges { diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index baebbfe0f..46c7a1043 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -27,7 +27,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 exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, try_read_exif_with, write_exif}; pub use focus::FocusPoint; pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx}; pub use iptc::parse_iptc; diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index 2dbf59482..d2f6f8ee2 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -154,6 +154,11 @@ const MAX_NODES: usize = 200_000; // Expanding a long namespace URI on many short names must not amplify a small packet without bound. pub(crate) const MAX_OPAQUE_NAME_BYTES: usize = 8 * 1024 * 1024; +/// Most text a converter's flattened property keys may add up to. Keys repeat their whole ancestor +/// path (`parent/field`), so a document with a long nested name and a wide child fan-out otherwise +/// retains its prefix once per property: node limits alone cannot bound that duplication. +const MAX_KEY_BYTES: usize = 1 << 20; + /// Names canonicalised by namespace URI (see [`NAMESPACES`]); conflicting foreign prefixes are opaque. pub(crate) fn canonical_name(qname: &str, scopes: &[Vec<(String, String)>], opaque_budget: &mut usize) -> Result { canonical(qname, scopes, opaque_budget) @@ -332,18 +337,29 @@ fn is_rdf_meta_attr(k: &str) -> bool { matches!(k, "rdf:about" | "rdf:ID" | "rdf:nodeID" | "xml:lang" | "rdf:parseType" | "rdf:resource" | "rdf:datatype") || k.starts_with("xmlns") } -fn collect_description(d: &Node, prefix: &str, props: &mut BTreeMap>) { +fn collect_description(d: &Node, prefix: &str, props: &mut BTreeMap>, budget: &mut usize) { for (k, v) in &d.attrs { - if !is_rdf_meta_attr(k) { + if !is_rdf_meta_attr(k) && take_key_bytes(&format!("{prefix}{k}"), budget) { props.entry(format!("{prefix}{k}")).or_default().push(v.clone()); } } for c in &d.children { - collect_property(c, prefix, props); + collect_property(c, prefix, props, budget); } } -fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap>) { +/// Charge one flattened key's length to the key budget; `false` when the budget is spent (the +/// property is then left out, like other elements a limit has cut). +fn take_key_bytes(key: &str, budget: &mut usize) -> bool { + let n = key.len(); + if *budget < n { + return false; + } + *budget -= n; + true +} + +fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap>, budget: &mut usize) { let key = format!("{prefix}{}", p.name); if let Some(arr) = p.children.iter().find(|c| matches!(c.name.as_str(), "rdf:Seq" | "rdf:Bag" | "rdf:Alt")) { let mut items: Vec<(bool, String)> = arr @@ -357,29 +373,36 @@ fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap = p.attrs.iter().filter(|(k, _)| !is_rdf_meta_attr(k)).collect(); if !field_attrs.is_empty() && p.children.is_empty() && p.text.trim().is_empty() { for (k, v) in field_attrs { - props.entry(format!("{struct_prefix}{k}")).or_default().push(v.clone()); + let full = format!("{struct_prefix}{k}"); + if take_key_bytes(&full, budget) { + props.entry(full).or_default().push(v.clone()); + } } return; } let v = p.attr("rdf:resource").map(str::to_string).unwrap_or_else(|| p.text.trim().to_string()); - props.entry(key).or_default().push(v); + if take_key_bytes(&key, budget) { + props.entry(key).or_default().push(v); + } } fn find_descriptions<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { @@ -498,7 +521,8 @@ pub fn parse_xmp(s: &str) -> Result { let mut props = BTreeMap::new(); let mut values = BTreeMap::new(); for d in descs { - collect_description(d, "", &mut props); + let mut key_budget = MAX_KEY_BYTES; + collect_description(d, "", &mut props, &mut key_budget); if let XmpValue::Struct(m) = node_struct(d) { values.extend(m); } @@ -1171,4 +1195,29 @@ mod tests { assert_eq!(entity("#x41"), Some('A')); assert_eq!(entity("#9"), Some('\t')); } + + /// Flattened keys repeat their whole ancestor path, so a long nested name with a wide child + /// fan-out would otherwise retain its prefix once per leaf: the cumulative key budget stops it + /// (a 65 KB document with 3000 leaves must not retain ~2.4 MB of key text). + #[test] + fn flattened_keys_stay_within_the_cumulative_budget() { + let level = "n".repeat(200); + let mut doc = String::from( + "", + ); + for _ in 0..4 { + doc.push_str(&format!("")); + } + doc.push_str(&"".repeat(3000)); + doc.push_str(""); + for _ in 0..4 { + doc.push_str(&format!("")); + } + doc.push_str(""); + let d = parse_xmp(&doc).unwrap(); + let total: usize = d.properties.keys().map(String::len).sum(); + assert!(total <= MAX_KEY_BYTES, "flattened-key text {total} is over the {MAX_KEY_BYTES} budget"); + assert!(d.properties.len() < 3000, "keys stop being added once the budget is spent"); + assert!(!d.properties.is_empty(), "properties parsed before the budget are kept"); + } } diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index 8cd6a13e2..34bd7e53b 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -105,48 +105,72 @@ pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Pack let chunks = info.chunks(data.len() as u64); let planar = info.planar == 2 && cpp > 1; let ccpp = if planar { 1 } else { cpp }; - let decoded: Vec> = - chunks.par_iter().map(|c| decode_chunk(data, info, c, order, packing, ccpp, bits, float).map(|p| (*c, p))).collect(); + // The chunks are decoded and copied into the output in groups whose combined nominal size stays within + // a few times the output buffer's: everything decoded at once would retain the whole image twice, and + // a hostile tile table's chosen padding could stretch that much further (issue #1517). One group + // still decodes in parallel; the grid order groups make, the copies stay disjoint. + let group_samples = (total / 4).max(1 << 20); let mut out_u16 = if float { Vec::new() } else { vec![0u16; total] }; let mut out_f32 = if float { vec![0f32; total] } else { Vec::new() }; - for r in decoded { - // a chunk that cannot be decoded is not a partial image: the file stays preview-only - let (c, px) = r?; - let (cw, ch) = (c.width as usize, c.height as usize); - let (x0, y0) = (c.x as usize, c.y as usize); - if x0 >= w || y0 >= h { - continue; + let mut decode_from = 0usize; + while decode_from < chunks.len() { + let mut to = decode_from; + let mut taken = 0usize; + for (k, c) in chunks[decode_from..].iter().enumerate() { + let n = chunk_samples(c, ccpp); + if taken > 0 && taken + n > group_samples { + break; + } + taken = taken.saturating_add(n); + to = decode_from + k + 1; } - let copy_w = cw.min(w - x0); - let copy_h = ch.min(h - y0); - for y in 0..copy_h { - for x in 0..copy_w { - for s in 0..ccpp { - let src = (y * cw + x) * ccpp + s; - let dst_s = if planar { c.plane as usize } else { s }; - if dst_s >= cpp { - continue; - } - let dst = ((y0 + y) * w + x0 + x) * cpp + dst_s; - match &px { - ChunkPx::U16(v) => { - if let (Some(o), Some(&s)) = (out_u16.get_mut(dst), v.get(src)) { - *o = s; - } + let decoded: Vec> = + chunks[decode_from..to].par_iter().map(|c| decode_chunk(data, info, c, order, packing, ccpp, bits, float).map(|p| (*c, p))).collect(); + for r in decoded { + // a chunk that cannot be decoded is not a partial image: the file stays preview-only + let (c, px) = r?; + let (cw, ch) = (c.width as usize, c.height as usize); + let (x0, y0) = (c.x as usize, c.y as usize); + if x0 >= w || y0 >= h { + continue; + } + let copy_w = cw.min(w - x0); + let copy_h = ch.min(h - y0); + for y in 0..copy_h { + for x in 0..copy_w { + for s in 0..ccpp { + let src = (y * cw + x) * ccpp + s; + let dst_s = if planar { c.plane as usize } else { s }; + if dst_s >= cpp { + continue; } - ChunkPx::F32(v) => { - if let (Some(o), Some(&s)) = (out_f32.get_mut(dst), v.get(src)) { - *o = s; + let dst = ((y0 + y) * w + x0 + x) * cpp + dst_s; + match &px { + ChunkPx::U16(v) => { + if let (Some(o), Some(&s)) = (out_u16.get_mut(dst), v.get(src)) { + *o = s; + } + } + ChunkPx::F32(v) => { + if let (Some(o), Some(&s)) = (out_f32.get_mut(dst), v.get(src)) { + *o = s; + } } } } } } } + decode_from = to; } Ok(if float { RawData::F32(out_f32) } else { RawData::U16(out_u16) }) } +/// A chunk's nominal decoded size in samples (per plane, as the decoder builds it). +fn chunk_samples(c: &Chunk, ccpp: usize) -> usize { + (c.width as usize).saturating_mul(c.height as usize).saturating_mul(ccpp) +} + #[allow(clippy::too_many_arguments)] fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, packing: Packing, cpp: usize, bits: u32, float: bool) -> Result { let src = chunk_bytes(data, c).ok_or_else(|| RawError::Corrupt("chunk offset past end of file".into()))?; @@ -180,8 +204,12 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack comp::LOSSY_JPEG => { // lossy DNG: each chunk is a baseline (DCT) JPEG of 8-bit samples let (px, jw, jh, jc) = lossy_jpeg(src, cpp, MAX_JPEG_SAMPLES)?; - let mut v = vec![0u16; n]; - for y in 0..ch.min(jh) { + // the buffer holds the rows the decoded JPEG covers (the caller zero-fills the tile's + // remainder and reads only rows it fills): a tile-many-larger decoded JPEG does not + // get the nominal tile's full buffer (issue #1517) + let rows = ch.min(jh); + let mut v = vec![0u16; rows.max(1).saturating_mul(cw).saturating_mul(cpp)]; + for y in 0..rows { for x in 0..cw.min(jw) { for k in 0..cpp { v[(y * cw + x) * cpp + k] = px[(y * jw + x) * jc + k.min(jc - 1)] as u16; @@ -518,6 +546,36 @@ mod lossy_tests { assert!(lossy_jpeg(&j, 3, MAX_JPEG_SAMPLES).is_ok() || matches!(lossy_jpeg(&j, 3, MAX_JPEG_SAMPLES), Err(RawError::Corrupt(_)))); } + /// A lossy tile whose JPEG decodes far less than the nominal tile is fine: the rows the JPEG + /// covers hold its pixels, the rest of the output stays zero (and the tile's intermediate buffer + /// is sized by the JPEG, not by the nominal tile). + #[test] + fn a_small_lossy_jpeg_in_a_large_nominal_tile_decodes_with_zeros_below() { + let px: Vec = (0..8 * 8).flat_map(|_| [30u8, 90, 150]).collect(); + let mut j = Vec::new(); + jpeg_encoder::Encoder::new(&mut j, 100).encode(&px, 8, 8, jpeg_encoder::ColorType::Rgb).unwrap(); + let info = ImageInfo { + width: 16, + height: 16, + bits_per_sample: vec![8, 8, 8], + samples_per_pixel: 3, + compression: comp::LOSSY_JPEG, + photometric: 34892, + planar: 1, + predictor: 1, + sample_format: 1, + new_subfile_type: 0, + layout: Layout::Tiles { tile_width: 16, tile_height: 16 }, + offsets: vec![0], + byte_counts: vec![j.len() as u64], + }; + let RawData::U16(v) = read_image(&j, &info, ByteOrder::Little, Packing::Msb).unwrap() else { panic!("integer samples") }; + assert_eq!(v.len(), 16 * 16 * 3, "the output is the full nominal image"); + let px = |x: usize, y: usize| &v[(y * 16 + x) * 3..(y * 16 + x) * 3 + 3]; + assert!((px(3, 3)[0] as i32 - 30).abs() <= 3 && (px(3, 3)[2] as i32 - 150).abs() <= 3, "covered rows: {:?}", px(3, 3)); + assert!(px(3, 12).iter().all(|&s| s == 0), "rows below the JPEG stay zero: {:?}", px(3, 12)); + } + /// A deflated tile is refused when its packed bytes are too few for deflate to have produced /// the nominal chunk, or when the stream stops short of the rows inside the image; a fully /// covered flat tile still decodes. diff --git a/crates/tiff/src/makernote.rs b/crates/tiff/src/makernote.rs index 7ed5e1997..288b366f6 100644 --- a/crates/tiff/src/makernote.rs +++ b/crates/tiff/src/makernote.rs @@ -55,7 +55,11 @@ pub fn parse_makernote(data: &[u8], offset: u64, len: u64, order: ByteOrder, mak Some(b"MM") => Some(ByteOrder::Big), _ => None, }; - let opts = ParseOptions { max_ifds: 64, max_depth: 3, ..Default::default() }; + // A fixed value budget: a maker note is at most tens of kilobytes of values in any honest file, and + // computing the budget from the buffer's length multiplies the file length (a hostile entry in a + // sparse header-read buffer, where the buffer holds the file's logical size but only a few + // kilobytes are loaded, would decode allocations no actual data backs). + let opts = ParseOptions { max_ifds: 64, max_depth: 3, value_budget: Some(1 << 20), ..Default::default() }; let try_parse = |kind: MakerNoteKind, ifd_at: u64, base: u64, ord: ByteOrder| -> Option { let (ifd, _) = parse_ifd_at(data, ifd_at, ord, base, false, &opts).ok()?; (!ifd.entries.is_empty()).then_some(MakerNote { kind, order: ord, base, ifd }) @@ -192,6 +196,35 @@ mod tests { assert_eq!(m.ifd.u64s(2).unwrap(), vec![9, 0]); } + /// A maker note's value budget is fixed, not computed from the length of the buffer it sits in: + /// an entry claiming more than the note budget (here 2 MB) is dropped, a fitting one is read. In a + /// sparse header read the buffer holds the file's logical size while only kilobytes were loaded, + /// so a length-scaled budget would let an unrelated entry decode megabytes of zeros (see the focus + /// reader, try_read_exif_with). + #[test] + fn a_note_entry_past_the_fixed_value_budget_is_dropped() { + let order = ByteOrder::Little; + let mut note = b"SONY DSC \0\0\0".to_vec(); + // the Sony IFD directly after the 12-byte header, one fitting BYTE entry and a decoy + // BYTE[2 MB] entry pointing into the file's own (zero-filled) body + order.put_u16(&mut note, 2); + order.put_u16(&mut note, 0x201b); + order.put_u16(&mut note, 1); + order.put_u32(&mut note, 1); + note.extend([3, 0, 0, 0]); + order.put_u16(&mut note, 0x9900); + order.put_u16(&mut note, 1); + order.put_u32(&mut note, 2 << 20); + order.put_u32(&mut note, 200); + order.put_u32(&mut note, 0); + let mut bytes = note.clone(); + bytes.resize(200 + (2 << 20), 0); // the decoy's value range is inside the real bytes + let m = parse_makernote(&bytes, 0, note.len() as u64, order, "SONY").unwrap(); + assert_eq!(m.kind, MakerNoteKind::Sony); + assert!(m.ifd.get(0x201b).is_some(), "the fitting entry is read"); + assert!(m.ifd.get(0x9900).is_none(), "the decoy is dropped by the value budget"); + } + #[test] fn garbage_note_is_none() { let (bytes, off, len) = with_note(ByteOrder::Little, vec![0xff; 40]); diff --git a/crates/tiff/src/reader.rs b/crates/tiff/src/reader.rs index bd728ca9c..70c66c5a7 100644 --- a/crates/tiff/src/reader.rs +++ b/crates/tiff/src/reader.rs @@ -338,6 +338,25 @@ pub fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value { #[cfg(test)] mod tests { use super::*; + use crate::Value; + use crate::tags as t; + use crate::writer::{IfdBuilder, TiffWriter}; + + /// A value budget the caller sets bounds what is decoded: an entry claiming more value bytes than + /// the budget allows is dropped, entries that fit are read (the sparse header-read path, where the + /// buffer holds the file's logical size but only a few kilobytes were ever loaded, sizes its + /// budget from what was fetched, not from whatever length the file claims). + #[test] + fn the_value_budget_drops_over_budget_entries_and_keeps_the_rest() { + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Keep".into())); + ifd0.set(0x9000, Value::Byte(vec![0; 2 << 20])); + let file = TiffWriter { order: ByteOrder::Little, bigtiff: false }.write(&[ifd0]).unwrap(); + let opts = ParseOptions { value_budget: Some(1 << 20), ..Default::default() }; + let tiff = Tiff::parse_with(&file, &opts).unwrap(); + assert_eq!(tiff.ifds[0].string(t::MAKE), Some("Keep".to_string()), "a fitting entry is read"); + assert!(tiff.ifds[0].get(0x9000).is_none(), "the over-budget entry is dropped"); + } /// A BigTIFF whose first IFD, at 80, declares `declared` entries but holds one, followed by a next-IFD pointer /// `next`; a valid one-entry IFD sits at 16. From 269495cd3867e9feee1876ac641627ba23c7dabb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 15:45:31 -0400 Subject: [PATCH 34/40] security(heif): budget every reconstruction before it runs The primary item and the selected thumbnail are scanned before Context::grid rebuilds their descriptors, every tile reference is counted (repeats included) before the size check dedupes them, and the Exif and XMP items are scanned before they are read. --- crates/heif/src/lib.rs | 48 +++++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs index 723ffe5d9..6491489fb 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -158,27 +158,28 @@ fn probe_unguarded(bytes: &[u8]) -> Result { })?; let ctx = Context::open(bytes).map_err(err)?; let id = ctx.meta.primary; + // heic-rs 0.1.4 caps the size of what it assembles, not the work: an item built from other + // items (`iloc` construction method 2) is rebuilt once per extent, so a chain of such items + // multiplies at every level while each result stays small. Count those visits from the + // `iloc` table before reconstructing anything, the primary item (whose grid descriptor + // `Context::grid` rebuilds) first and then every tile reference, repeats included. + let mut visits = 0_usize; + item_visits(&ctx, id, 0, &mut visits)?; let mut coded = match ctx.grid(id).map_err(err)? { Some((_, tiles)) => tiles, None => vec![id], }; - // A `dimg` listing may name the same tile more than once; reconstrutable (`iloc` - // construction method 2) items are moreover rebuilt for every extent that references - // them in heic-rs 0.1.4, so our probe would pay the reconstruction again per tile. Each - // unique item only once, then the total reconstruction (all of it assembled from `bytes` - // by heic-rs) is held below one copy of the file — above that a repeat or chain wants - // more bytes than exist (CodeRabbit #21/#50 on this sweep). Order doesn't matter here: - // this only checks that every tile's bytes lie in the file. - coded.sort_unstable(); - coded.dedup(); - // heic-rs 0.1.4 caps the size of what it assembles, not the work: an item built from other - // items (`iloc` construction method 2) is rebuilt once per extent, so a chain of such items - // multiplies at every level while each result stays small. Count those visits from the - // `iloc` table before reconstructing anything. - let mut visits = 0_usize; - for &item in &coded { + for &item in coded.iter().filter(|&&item| item != id) { item_visits(&ctx, item, 0, &mut visits)?; } + // A `dimg` listing may name the same tile more than once, and reconstructable items are + // moreover rebuilt for every extent that references them in heic-rs 0.1.4. For the size + // check each unique item counts once: the total reconstruction (all of it assembled from + // `bytes` by heic-rs) is held below one copy of the file, above which a repeat or chain + // wants more bytes than exist. Order doesn't matter here: this only checks that every + // tile's bytes lie in the file. + coded.sort_unstable(); + coded.dedup(); let mut reconstructed = 0_usize; for item in coded { reconstructed = reconstructed.saturating_add(ctx.item_data(item).map_err(err)?.len()); @@ -252,6 +253,15 @@ pub fn decode_thumbnail(bytes: &[u8], min_edge: u32, options: &Options) -> Resul } } let Some((id, _)) = best else { return Ok(None) }; + // The same pre-scan as `probe`: the thumbnail's own descriptor and every tile reference + // are counted before heic-rs rebuilds any of them. + let mut visits = 0_usize; + item_visits(&ctx, id, 0, &mut visits)?; + if let Some((_, tiles)) = ctx.grid(id).map_err(err)? { + for &tile in &tiles { + item_visits(&ctx, tile, 0, &mut visits)?; + } + } let mut decoded = decode_item(&ctx, id, options, false)?; if decoded.icc.is_none() { decoded.icc = item_icc(&ctx, primary); @@ -444,8 +454,12 @@ fn colour(p: &ItemProps<'_>, coded: &ItemProps<'_>) -> Colour { /// metadata item never fails a decode whose pixels came out fine. fn metadata(ctx: &Context<'_>) -> (Option>, Option) { let id = ctx.meta.primary; - let exif = ctx.exif(id).ok().flatten().map(<[u8]>::to_vec); - let xmp = ctx.xmp_item(id).and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d { + // Metadata is best effort, so an item that would cost too much to rebuild is skipped, not an error. + let mut visits = 0_usize; + let cheap = |item: Option, visits: &mut usize| item.is_none_or(|item| item_visits(ctx, item, 0, visits).is_ok()); + let exif = if cheap(ctx.exif_item(id), &mut visits) { ctx.exif(id).ok().flatten().map(<[u8]>::to_vec) } else { None }; + let xmp_item = ctx.xmp_item(id).filter(|&x| cheap(Some(x), &mut visits)); + let xmp = xmp_item.and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d { Cow::Borrowed(b) => String::from_utf8(b.to_vec()).ok(), Cow::Owned(b) => String::from_utf8(b).ok(), }); From 0c71f75325b5f707bbe8e7ef6324e6e14a8f51e6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 16:06:39 -0400 Subject: [PATCH 35/40] security(heif): scan the primary item before heic_rs::probe, the alpha plane, and each thumbnail candidate heic_rs::probe rebuilds a grid primary's descriptor, so the pre-scan runs before it; the alpha item that decode reconstructs is counted too; and a thumbnail candidate is scanned before its coded size is resolved, with all candidates sharing one budget. --- crates/heif/src/lib.rs | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs index 6491489fb..5b20e31d7 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -151,20 +151,25 @@ pub fn probe(bytes: &[u8]) -> Result { fn probe_unguarded(bytes: &[u8]) -> Result { census::cap(bytes)?; - let info = heic_rs::probe(bytes).map_err(|e| match e { - // No `meta` box: no still image, only a `moov` image sequence (a video track). + // No `meta` box: no still image, only a `moov` image sequence (a video track). + let open_error = |e| match e { heic_rs::Error::MissingBox("meta") => Error::Unsupported(SEQUENCE), e => err(e), - })?; - let ctx = Context::open(bytes).map_err(err)?; + }; + let ctx = Context::open(bytes).map_err(open_error)?; let id = ctx.meta.primary; // heic-rs 0.1.4 caps the size of what it assembles, not the work: an item built from other // items (`iloc` construction method 2) is rebuilt once per extent, so a chain of such items // multiplies at every level while each result stays small. Count those visits from the - // `iloc` table before reconstructing anything, the primary item (whose grid descriptor - // `Context::grid` rebuilds) first and then every tile reference, repeats included. + // `iloc` table before reconstructing anything: the primary item first (`heic_rs::probe` and + // `Context::grid` both rebuild its descriptor), then its alpha plane, then every tile + // reference, repeats included. let mut visits = 0_usize; item_visits(&ctx, id, 0, &mut visits)?; + let info = heic_rs::probe(bytes).map_err(open_error)?; + if let Some(alpha) = ctx.alpha_item(id).map_err(err)? { + item_visits(&ctx, alpha, 0, &mut visits)?; + } let mut coded = match ctx.grid(id).map_err(err)? { Some((_, tiles)) => tiles, None => vec![id], @@ -244,7 +249,13 @@ pub fn decode_thumbnail(bytes: &[u8], min_edge: u32, options: &Options) -> Resul let ctx = Context::open(bytes).map_err(err)?; let primary = ctx.meta.primary; let mut best: Option<(u32, u32)> = None; + // A thumbnail whose own reconstruction would cost too much is not a candidate; the visits + // of all the candidates share one budget. + let mut candidate_visits = 0_usize; for r in ctx.meta.refs.iter().filter(|r| r.kind == heic_rs::meta::iref::RefKind::Thmb && r.to.contains(&primary)) { + if item_visits(&ctx, r.from, 0, &mut candidate_visits).is_err() { + continue; + } let Ok(p) = ctx.props(r.from) else { continue }; let Ok((w, h)) = ctx.coded_size(r.from, &p) else { continue }; let edge = w.max(h); From 3022baa8615a2956e239b7febf3fe4ff98289885 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 16:12:03 -0400 Subject: [PATCH 36/40] security(raw,meta): follow-ups from review An oversized CR3 candidate is skipped without ending the search, repeated child IFD references count once toward the pending cap, deflated chunks lay their rows out as the unpacker reads them (16-bit words, 32-bit word alignment), the tile grid's nominal size is checked against the image before anything is decoded, chunk grouping saturates, and the XMP flattened-key budget is document-wide. The JPEG-header test now parses a real header. --- crates/meta/src/xmp.rs | 8 ++++-- crates/raw/src/preview.rs | 5 ++-- crates/raw/src/sparse.rs | 8 +++++- crates/raw/src/tiffraw.rs | 60 +++++++++++++++++++++++++++++---------- 4 files changed, 61 insertions(+), 20 deletions(-) diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index d2f6f8ee2..ac0a8808a 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -520,8 +520,9 @@ pub fn parse_xmp(s: &str) -> Result { find_descriptions(&tree, &mut descs); let mut props = BTreeMap::new(); let mut values = BTreeMap::new(); + // One budget for the whole document: several descriptions cannot each spend a fresh one. + let mut key_budget = MAX_KEY_BYTES; for d in descs { - let mut key_budget = MAX_KEY_BYTES; collect_description(d, "", &mut props, &mut key_budget); if let XmpValue::Struct(m) = node_struct(d) { values.extend(m); @@ -1208,7 +1209,10 @@ mod tests { for _ in 0..4 { doc.push_str(&format!("")); } - doc.push_str(&"".repeat(3000)); + // each leaf has a name of its own, so each one adds a key + for i in 0..3000 { + doc.push_str(&format!("")); + } doc.push_str(""); for _ in 0..4 { doc.push_str(&format!("")); diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 8a7e180c7..4bcf6963e 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -400,7 +400,7 @@ const CR3_MAX_WALK: usize = 8 << 20; fn cr3_preview_boxes(bytes: &[u8]) -> Option<&[u8]> { let mut best: Option<&[u8]> = None; let mut walked = 0usize; - 'tags: for tag in [b"PRVW", b"THMB"] { + for tag in [b"PRVW", b"THMB"] { let mut from = 0; while let Some(i) = bytes.get(from..).and_then(|s| s.windows(4).position(|w| w == tag)).map(|p| p + from) { from = i + 4; @@ -412,8 +412,9 @@ fn cr3_preview_boxes(bytes: &[u8]) -> Option<&[u8]> { continue; } let jpeg = &bytes[j..j + len]; + // One that doesn't fit the budget left is skipped; a smaller one later may still fit. if walked + jpeg.len() > CR3_MAX_WALK { - break 'tags; + continue; } walked += jpeg.len(); if is_dct_jpeg(jpeg) { diff --git a/crates/raw/src/sparse.rs b/crates/raw/src/sparse.rs index a1a718f14..8a404a25e 100644 --- a/crates/raw/src/sparse.rs +++ b/crates/raw/src/sparse.rs @@ -314,6 +314,9 @@ fn walk_tiff(w: &mut Want, origin: u64, data: &[u8], extra: &[u16]) -> Result Result= len || !queued.insert(v) { + continue; + } if stack.len() >= MAX_PENDING { return Err("too many child IFDs queued to visit"); } @@ -376,7 +382,7 @@ fn walk_tiff(w: &mut Want, origin: u64, data: &[u8], extra: &[u16]) -> Result= MAX_PENDING { return Err("too many child IFDs queued to visit"); } diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index 34bd7e53b..57878b881 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -109,6 +109,12 @@ pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Pack // a few times the output buffer's: everything decoded at once would retain the whole image twice, and // a hostile tile table's chosen padding could stretch that much further (issue #1517). One group // still decodes in parallel; the grid order groups make, the copies stay disjoint. + // The tile grid decides how much is decoded, whatever the file's bytes: the nominal sizes of all + // the chunks stay within a few times the image (edge tiles pad it) before any is decoded. + let nominal = chunks.iter().fold(0usize, |sum, c| sum.saturating_add(chunk_samples(c, ccpp))); + if nominal > total.saturating_mul(4).saturating_add(1 << 24) { + return Err(RawError::Corrupt("the tile grid is much larger than the image".into())); + } let group_samples = (total / 4).max(1 << 20); let mut out_u16 = if float { Vec::new() } else { vec![0u16; total] }; let mut out_f32 = if float { vec![0f32; total] } else { Vec::new() }; @@ -118,7 +124,7 @@ pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Pack let mut taken = 0usize; for (k, c) in chunks[decode_from..].iter().enumerate() { let n = chunk_samples(c, ccpp); - if taken > 0 && taken + n > group_samples { + if taken > 0 && taken.saturating_add(n) > group_samples { break; } taken = taken.saturating_add(n); @@ -166,6 +172,21 @@ pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Pack Ok(if float { RawData::F32(out_f32) } else { RawData::U16(out_u16) }) } +/// Bytes that `n` samples take in a row of an uncompressed or inflated chunk, laid out as +/// [`unpack_chunk`] reads them: whole bytes or 16-bit words, or 32-bit words for `Words32Msb`. +fn packed_row_bytes(n: u64, bits: u32, packing: Packing, float: bool) -> u64 { + if float { + return n.saturating_mul(u64::from(bits.div_ceil(8))); + } + let bit_len = n.saturating_mul(u64::from(bits)); + match (bits, packing) { + (8, _) => n, + (16, _) | (_, Packing::Word16) => n.saturating_mul(2), + (_, Packing::Msb | Packing::Lsb) => bit_len.div_ceil(8), + (_, Packing::Words32Msb) => bit_len.div_ceil(32).saturating_mul(4), + } +} + /// A chunk's nominal decoded size in samples (per plane, as the decoder builds it). fn chunk_samples(c: &Chunk, ccpp: usize) -> usize { (c.width as usize).saturating_mul(c.height as usize).saturating_mul(ccpp) @@ -219,9 +240,8 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack Ok(ChunkPx::U16(v)) } comp::ADOBE_DEFLATE | comp::DEFLATE => { - let bytes_per = if float { bits.div_ceil(8) as usize } else { 0 }; // row bytes in u64: a wrapping `cw · cpp · …` in usize could come out small on 32-bit targets and slip past the gate - let row_bytes = if float { cw as u64 * cpp as u64 * bytes_per as u64 } else { (cw as u64 * cpp as u64 * bits as u64).div_ceil(8) }; + let row_bytes = packed_row_bytes(cw as u64 * cpp as u64, bits, packing, float); // the gate caps at MAX_SAMPLES (≤ 2^30), so the `usize` cast below is lossless on 32-bit targets too let inflate_len = crate::checked_pixel_count(row_bytes, ch as u64).ok_or_else(|| RawError::Corrupt("bad TIFF chunk dimensions".into()))?; @@ -234,7 +254,7 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack let raw = inflate(src, inflate_len as usize)?; // The stream has to reach the last row inside the image (edge strips may stop there); // it is laid out in nominal rows, so shorter is a truncated chunk, not a picture. - let last = if float { vw as u64 * cpp as u64 * bytes_per as u64 } else { (vw as u64 * cpp as u64 * bits as u64).div_ceil(8) }; + let last = packed_row_bytes(vw as u64 * cpp as u64, bits, packing, float); let visible = row_bytes.saturating_mul(vh.saturating_sub(1) as u64).saturating_add(last); if (raw.len() as u64) < visible { return Err(RawError::Corrupt(format!( @@ -533,17 +553,27 @@ mod lossy_tests { /// from the JPEG's own dimensions. #[test] fn an_oversized_lossy_jpeg_header_is_refused_before_decoding() { - let mut j = vec![0xff, 0xd8]; // SOI - // SOF0: precision 8, 40000 (0x9c40) x 40000, three components - j.extend([0xff, 0xc0, 0x00, 0x11, 0x08, 0x9c, 0x40, 0x9c, 0x40, 0x03, 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01]); - // SOS with three components so header parsing completes normally, then EOI - j.extend([0xff, 0xda, 0x00, 0x0c, 0x03, 0x01, 0x00, 0x02, 0x11, 0x00, 0x03, 0x11, 0x00, 0x00, 0x3f, 0x00, 0x00, 0xff, 0xd9]); - let e = lossy_jpeg(&j, 3, MAX_JPEG_SAMPLES).unwrap_err(); - assert!(matches!(e, RawError::Corrupt(_)), "{e:?}"); - // a header inside the budget passes the check (and then fails to decode only for lack of data) - j[12..14].copy_from_slice(&1024u16.to_be_bytes()); - j[14..16].copy_from_slice(&1024u16.to_be_bytes()); - assert!(lossy_jpeg(&j, 3, MAX_JPEG_SAMPLES).is_ok() || matches!(lossy_jpeg(&j, 3, MAX_JPEG_SAMPLES), Err(RawError::Corrupt(_)))); + // A real 8 × 8 baseline JPEG, whose frame header then claims other dimensions: the decoder + // parses the header, and the size check is what refuses it. + let px = vec![128u8; 8 * 8 * 3]; + let mut j = Vec::new(); + jpeg_encoder::Encoder::new(&mut j, 90).encode(&px, 8, 8, jpeg_encoder::ColorType::Rgb).unwrap(); + // SOF0: marker, length, precision, then the height and the width + let sof = j.windows(2).position(|w| w == [0xff, 0xc0]).expect("a baseline frame header"); + let claim = |j: &mut Vec, size: u16| { + j[sof + 5..sof + 7].copy_from_slice(&size.to_be_bytes()); + j[sof + 7..sof + 9].copy_from_slice(&size.to_be_bytes()); + }; + claim(&mut j, 40_000); + let RawError::Corrupt(refused) = lossy_jpeg(&j, 3, MAX_JPEG_SAMPLES).unwrap_err() else { panic!("a corrupt-data error") }; + assert!(refused.contains("would exceed"), "{refused}"); + // a header inside the budget passes the size check (and then fails to decode only for lack of data) + claim(&mut j, 1024); + match lossy_jpeg(&j, 3, MAX_JPEG_SAMPLES) { + Ok(_) => {} + Err(RawError::Corrupt(m)) => assert!(!m.contains("would exceed"), "the smaller header was refused for its size: {m}"), + Err(other) => panic!("unexpected error: {other:?}"), + } } /// A lossy tile whose JPEG decodes far less than the nominal tile is fine: the rows the JPEG From 522beb0c3e6a8521c8298c16209f7db3d84d2608 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 17:55:29 -0400 Subject: [PATCH 37/40] build: pin craft-update to craft-libs main (495e597) (#2990) Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down. Part of tracking issue storytold/craft-libs#319 --- Cargo.lock | 18 +++++++++--------- Cargo.toml | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4513c9a66..b69533958 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1110,7 +1110,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" dependencies = [ "chrono", "dunce", @@ -1720,7 +1720,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3851,7 +3851,7 @@ dependencies = [ "once_cell", "png", "thiserror 2.0.21", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4520,7 +4520,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5430,7 +5430,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5487,7 +5487,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6057,7 +6057,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6303,7 +6303,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6957,7 +6957,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2bb309f91..5c0418d30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From 387bed199fca44d76af5aa1c24dd7cc3de4c8db2 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 17:30:51 -0600 Subject: [PATCH 38/40] chore(deps): refresh safe dependency bumps (#2994) --- Cargo.lock | 406 ++++++++++++++++++++++----------------- Cargo.toml | 2 +- crates/engine/Cargo.toml | 2 +- xtask/Cargo.toml | 2 +- 4 files changed, 229 insertions(+), 183 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b69533958..a9a326ab8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -159,7 +159,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -213,13 +213,28 @@ version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + [[package]] name = "alloc-stdlib" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ - "alloc-no-stdlib", + "alloc-no-stdlib 2.0.4", +] + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib 3.0.0", ] [[package]] @@ -283,14 +298,14 @@ dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -592,15 +607,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - [[package]] name = "bit-set" version = "0.10.0" @@ -611,10 +617,13 @@ dependencies = [ ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "bit-set" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", +] [[package]] name = "bit-vec" @@ -622,6 +631,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -673,7 +692,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -690,14 +709,38 @@ dependencies = [ ] [[package]] -name = "brotli" -version = "8.0.4" +name = "borsh" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "brotli" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" +dependencies = [ + "alloc-no-stdlib 3.0.0", + "alloc-stdlib 0.3.0", + "brotli-decompressor 6.0.1", ] [[package]] @@ -706,8 +749,18 @@ version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", + "alloc-no-stdlib 2.0.4", + "alloc-stdlib 0.2.4", +] + +[[package]] +name = "brotli-decompressor" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" +dependencies = [ + "alloc-no-stdlib 3.0.0", + "alloc-stdlib 0.3.0", ] [[package]] @@ -845,7 +898,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fdfe9d06de16ce49961e49084e5b79a75a9bdf157246e7c7b6328e87a7aa25d" dependencies = [ "half", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", "objc2-metal 0.3.2", "once_cell", @@ -922,7 +975,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", ] [[package]] @@ -932,7 +996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -1084,6 +1148,12 @@ dependencies = [ "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1093,6 +1163,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "craft-docpath" version = "0.1.0" @@ -1134,9 +1213,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -1225,7 +1304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -1318,7 +1397,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -1448,7 +1527,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "parking_lot", @@ -1516,7 +1595,7 @@ dependencies = [ "egui", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", @@ -1647,7 +1726,7 @@ dependencies = [ "emath", "epaint_default_fonts", "font-types", - "harfrust", + "harfrust 0.12.0", "log", "nohash-hasher", "parking_lot", @@ -1720,7 +1799,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -2408,6 +2487,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -2490,7 +2570,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -2599,7 +2679,20 @@ checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" dependencies = [ "bitflags 2.13.2", "bytemuck", - "read-fonts", + "read-fonts 0.41.0", + "smallvec", +] + +[[package]] +name = "harfrust" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948d0741125ba89cd3e1c23e5642415b6ade7e1d29d67ba25fb925b533e989d6" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "once_cell", + "read-fonts 0.43.3", "smallvec", ] @@ -2981,7 +3074,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e35d032bcec660647828527ff42c6f5776d2fd44b8357f9f6d9ac6dc07218e46" dependencies = [ - "brotli-decompressor", + "brotli-decompressor 5.0.3", "jxl-bitstream", "jxl-frame", "jxl-grid", @@ -3013,7 +3106,7 @@ version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d36c662923f47586880211f3bc7c0d83fb3a9b410d278c7bde93450748abeef3" dependencies = [ - "brotli-decompressor", + "brotli-decompressor 5.0.3", "jxl-bitstream", "jxl-color", "jxl-frame", @@ -3139,9 +3232,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.189" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libfuzzer-sys" @@ -3197,7 +3290,7 @@ dependencies = [ "lightcraft-ui-egui", "log", "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "pollster 1.0.1", @@ -3289,7 +3382,7 @@ dependencies = [ "faer-core", "lightcraft-denoise-core", "lightcraft-raster", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "proptest", "rayon", "serde", @@ -3305,7 +3398,7 @@ version = "0.14.0" dependencies = [ "dhat", "lightcraft-raster", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "proptest", "rayon", "serde", @@ -3321,7 +3414,7 @@ version = "0.14.0" dependencies = [ "lightcraft-color", "lightcraft-geom", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "proptest", "serde", "serde_json", @@ -3334,7 +3427,7 @@ dependencies = [ "ab_glyph", "chrono", "craft-docpath", - "harfrust", + "harfrust 0.13.3", "jxl-oxide", "libc", "lightcraft-catalog", @@ -3362,7 +3455,7 @@ dependencies = [ "lightcraft-testkit", "lightcraft-tiff", "log", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "rayon", "serde", "serde_json", @@ -3401,7 +3494,7 @@ dependencies = [ name = "lightcraft-geocode" version = "0.14.0" dependencies = [ - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "serde", ] @@ -3553,7 +3646,7 @@ dependencies = [ "lightcraft-raster", "lightcraft-testkit", "lightcraft-tiff", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "proptest", "rayon", "serde", @@ -3837,21 +3930,20 @@ dependencies = [ [[package]] name = "muda" -version = "0.21.0" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c" +checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" dependencies = [ "crossbeam-channel", "dpi", "keyboard-types", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", - "once_cell", "png", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4151,9 +4243,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -4182,7 +4274,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -4232,7 +4324,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -4243,7 +4335,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] @@ -4300,7 +4392,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -4311,7 +4403,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -4348,7 +4440,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -4373,7 +4465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -4418,7 +4510,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -4520,7 +4612,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -4818,7 +4910,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -4839,7 +4931,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -4928,16 +5020,17 @@ dependencies = [ [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.2", + "chacha20 0.10.2", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha 0.9.0", + "rand 0.10.3", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -5071,6 +5164,16 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -5109,6 +5212,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_distr" version = "0.5.1" @@ -5121,11 +5230,11 @@ dependencies = [ [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -5214,7 +5323,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", @@ -5257,6 +5366,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "read-fonts" +version = "0.43.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + [[package]] name = "reborrow" version = "0.5.5" @@ -5323,7 +5443,7 @@ dependencies = [ "js-sys", "libc", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -5430,14 +5550,14 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "log", "once_cell", @@ -5487,7 +5607,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -5714,9 +5834,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -5752,7 +5872,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5826,7 +5946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" dependencies = [ "bytemuck", - "read-fonts", + "read-fonts 0.41.0", ] [[package]] @@ -6054,10 +6174,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -6303,7 +6423,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6755,9 +6875,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] @@ -6896,7 +7016,7 @@ dependencies = [ "naga", "naga-types", "ndk-sys", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -6957,7 +7077,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -7067,7 +7187,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -7076,16 +7196,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -7103,31 +7214,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -7145,96 +7239,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" @@ -7306,9 +7352,9 @@ dependencies = [ [[package]] name = "winresource" -version = "0.1.31" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", diff --git a/Cargo.toml b/Cargo.toml index 5c0418d30..71fd2566c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,7 +62,7 @@ dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap) and h # formats quick-xml = "0.41" -miniz_oxide = "0.8" +miniz_oxide = "0.9" # codecs image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff", "webp"] } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 9caa8cdb0..0b203f795 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -39,7 +39,7 @@ thiserror = { workspace = true } log = { workspace = true } ab_glyph = { workspace = true } # Apply the font’s vertical substitutions and origins to Japanese watermarks. -harfrust = { version = "0.12", default-features = false, features = ["std"] } +harfrust = { version = "0.13", default-features = false, features = ["std"] } unicode-segmentation = "1.13" lightcraft-segment = { workspace = true, optional = true } diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 0f1b1f77d..c68c7dca2 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -14,4 +14,4 @@ serde_json = { workspace = true } sha2 = { workspace = true } # precompressed web bundle (`cargo xtask web`); pure Rust flate2 = "1" -brotli = { version = "8", default-features = false, features = ["std"] } +brotli = { version = "9", default-features = false, features = ["std"] } From 8020cd081ce9ccd5faf87072d277c4eb966a9572 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 21:13:45 -0400 Subject: [PATCH 39/40] showcase: check the file is readable without an empty with-block (#3001) --- docs/showcase/__pycache__/run.cpython-314.pyc | Bin 0 -> 2792 bytes docs/showcase/run.py | 3 +-- 2 files changed, 1 insertion(+), 2 deletions(-) create mode 100644 docs/showcase/__pycache__/run.cpython-314.pyc diff --git a/docs/showcase/__pycache__/run.cpython-314.pyc b/docs/showcase/__pycache__/run.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..de0f9a97e55aa8bc4e58bb35c5fccb0e63fcaf1a GIT binary patch literal 2792 zcma)8OKcm*8J>NS%ZEtHbZph8q?P0sEt#?%p>}B3hV4j}>O>+|DebnB2x_^OHleum z?9dN6Mp>Xh^++P7aS;Vb5cZ~=k^(&hXb(xyLymMLN6J90(KeU#q((UuxwQW*xe{Rs zXoukZyZ>`${_p>1I|B_~1f#h4*Oy0J2>pY-I76;IZ2pr24WuH1#*oSlaD=ldPk5UO zM6jtyM46Zb8kONMB=$KWw&@txbzu9g2O_fJ&=BhT z5Fz3p^(PKiCG~Sp?S;vY)}Av>2t^R+H`i-cpH<26yQM8K?Q7s@luNYiw18Fn9UBr- zU8AArKq(WkV~C0NT`fRww>`C@Ym*Si13p8lyIx~7lsLT8lPY(k9=^(VC<%}5-Nl|u z9N%dVdSPens;54l;|*5ydxSiw1+v?LqQ1f{@RHTWWBGeZ-@psKv6n3~msS2=e5l|; zPSMO9!Dd#$`mKU)5lgv=%_$|V{CIe1@I=A*3vx^nD(`;<;5%}jed7K?Q$?fEoL zPg(Zc1I5J3{dOy>>-qM$Vm*k)2BRDm6KOK3)sk;-MS>ZUBW`xtARO6}CAYwl0Qu9y zfkd85atY?+so}^S089Y*B8ZhhbtD|{I}D(0a6C!mN2ht`I9t&{1pa+>O0`FIODEB= zQ|d%-1?vu^ppk&1<3bsLUj()OA8UE(skJntm%y8W&I!%P;G)9xfT~+&_O`B&NnN?} z!ay}GX*^Mw(sSgToj?QYN;;?LcMXdexe4Hp3cES<}*~duX^9V-rVtD!^i>n1`fW z%*hdj?#-B*)oo3hceJ#ncLUx4h18d_A(&#CI0%~T$gKx3717teU`d#NZq*lgorf-mh0peI zFl)7H1tCJLzcPSsx`10mBaCN{M7>sx2M}u*`-94zsmbzulHU!!{}ZVE7^ezJq>4$d zTX5j2N?o-$jO+$m2bhipz;Fw`jw~;XungEEx#im_qzq&xf|7z7P+c5S-DhtgG?BM? ze4^2@+oo#JiIo?VqT^)(_8&nn{$#gEbv)6rt>4!OONu?tK7=0KT8sUR!`-INj^M76 zRgdabecQHvr20<_-Fye^C9!|o#(7F^#fI)Vhw94}Zl0${8+dWn3LQ4|z30^FGqIEK zI|W_OO^hksED*{Y8S1L`OzNt3OUii%H(As3Qr>)}k4ic#o#{rXw!E3Mbf{cS*E5zf z?NY|?5#5UNm>sjx1snh7KQIi0LMYlmU6!f6q7&MvK@}4{1#LX1Ys4^f)NSdxOgfvT zA}cd0WeQVyi@NljW@dEiVSdvXr|(fA3uP=~UAs;FFJ8KlQhSFJLxWJx4CP>Omw+QG zWzBTPib{Ada4|c0%2`wxFWjV@X0dL3AN0XnpDhIAE2m3$oc^n*C4P1W| zxV{|t>5BNXb#HjSvv_4axTo~&!ri&Mi&x5gSy=2}l2#%o*8B|%eY1U~GxI|;+>>Ca zbogsuV8)t1`%qhKd+X+7U*z3aSA3mJ?dt5+*9O-1HZ2}5@2_?$3FjI<+xf2 z4_ExdYtUc3v)%`~cC2js8 zDg`YsmBQ?^1GBFi?1qVo3>4JOI$qWow|gu;#H6hF?DN7NTMt_3Z7Q;R7h(}?P&lk(VrTO2TgK0vq=NHrVs`6)9~$m$pHIp8teS$~9S zL*O{>Z|Kew)Ut|N{(_n|yeRa8qW@db!-c-x=jRUo>i}wKeDmPq@cTF3xlvBP_w(h( z=$HQE)n*)@jnAJiKK}$dbgw|-G3kN0a|Dks Date: Mon, 26 Oct 2026 10:04:19 +0800 Subject: [PATCH 40/40] =?UTF-8?q?Camera=20profile:=20Sony=20ILCE-7RM5=20(?= =?UTF-8?q?=CE=B17R=20V)=20(#3022)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Camera profile: Sony ILCE-7RM5 (α7R V) * Camera profile: register the bundled ILCE-7RM5 profile * Camera profile: attribution for the ILCE-7RM5 profile * Camera profile: list ILCE-7RM5 among the built-in profiles in the README * Camera profile: list ILCE-7RM5 in the Simplified Chinese README * Camera profile: list ILCE-7RM5 in the Traditional Chinese README * Camera profile: ILCE-7RM5 calibration set used the Standard creative style only --- README.md | 2 +- README.zh-hans.md | 2 +- README.zh-hant.md | 2 +- assets/ATTRIBUTION.md | 1 + assets/camera-profiles/ILCE-7RM5.json | 9031 +++++++++++++++++++++++++ crates/engine/src/camera_profiles.rs | 1 + 6 files changed, 9036 insertions(+), 3 deletions(-) create mode 100644 assets/camera-profiles/ILCE-7RM5.json diff --git a/README.md b/README.md index 78b3b3234..71f30d4c2 100644 --- a/README.md +++ b/README.md @@ -348,7 +348,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 ILCA-99M2, 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; + - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, ILCE-7RM5, 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 quality and model setup:** Subject and Sky selection are classical heuristics; Object/Describe use an optional SAM 3 model. AI Denoise supports Bayer RAW with separately installed weights, while X-Trans and diff --git a/README.zh-hans.md b/README.zh-hans.md index 2800db410..2c6ca8191 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -305,7 +305,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**( - **作为日常使用的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/ 较老佳能的 raw 都很出色。 - **最大的缺口:** - - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; + - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、ILCE-7RM5、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; - **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md); - **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法; - **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。** diff --git a/README.zh-hant.md b/README.zh-hant.md index 12fcfb11b..ca624ce68 100644 --- a/README.zh-hant.md +++ b/README.zh-hant.md @@ -320,7 +320,7 @@ LightCraft 還很年輕,而且進展飛快:**Alpha 階段**。**老實說, 在單機上處理 JPEG/DNG,以及大多數 Sony/Panasonic/ Fujifilm/Nikon/較舊款 Canon 的 raw 都很出色;各機型詳情見 [docs/raw-parity.md](docs/raw-parity.md)。 - **最大的缺口:** - - **相機色彩校正:** Sony、Nikon、Panasonic、Fujifilm 和 Canon CR3 的 raw 會從各自的相機 JPEG 得到受保護的估計值,內建 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、Nikon Z 6II、Z 8 和 D5300 描述檔;仍缺少實測校正,其他 raw 或被拒絕的擬合會保留中性矩陣; + - **相機色彩校正:** Sony、Nikon、Panasonic、Fujifilm 和 Canon CR3 的 raw 會從各自的相機 JPEG 得到受保護的估計值,內建 ILCA-99M2、ILCE-7CR、ILCE-7M4、ILCE-7RM5、X-H2S、X-T4、Nikon Z 6II、Z 8 和 D5300 描述檔;仍缺少實測校正,其他 raw 或被拒絕的擬合會保留中性矩陣; - **壓縮的 Olympus raw 與不受支援的 CR3 變體:** 這些在有內嵌 JPEG 預覽時會改用預覽畫面。Fujifilm 無損/有損壓縮 RAF 現在能解碼感光元件資料;[驗證與現有照片庫重新載入說明](docs/raf-compression.md); - **AI 遮色片與降噪:** 主體與天空選取目前是傳統啟發式演算法; - **網路照片庫:** 預覽只讀取 raw 的標頭 — 或者,對於 CR3 或預覽標籤位於其後的 TIFF raw,只讀取稀疏規劃器指定的範圍 — 以及其內嵌的 JPEG(56.5 MB → 5.9 MB,10 秒 → 1 秒,測得於測試用的網路共用資料夾),但*顯影後*的畫面仍需要透過網路傳輸感光元件資料;詳見 [docs/raw-preview-reads.md](docs/raw-preview-reads.md); diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 4bc2e927e..e5ed8090e 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -22,6 +22,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | `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 | +| `assets/camera-profiles/ILCE-7RM5.json` | Sony ILCE-7RM5 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Yule (Brian) Wang (@peaperfish) | original work: fitted by `lightcraft-cli calibrate` to 220 usable ILCE-7RM5 ARWs of my own (of 241, 2025 to 2026, Standard creative style; Sigma 24-70mm F2.8 DG DN II Art and Sony FE 200-600mm F5.6-6.3 G OSS) 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-26 | 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/ILCE-7RM5.json b/assets/camera-profiles/ILCE-7RM5.json new file mode 100644 index 000000000..787bfb062 --- /dev/null +++ b/assets/camera-profiles/ILCE-7RM5.json @@ -0,0 +1,9031 @@ +{ + "version": 1, + "model": "ILCE-7RM5", + "files": 220, + "samples": 790553, + "matrix": [ + [ + 1.249584732786861, + -0.3300570108987344, + 0.07738524309065298 + ], + [ + -0.09371116474358132, + 1.2038743929103433, + -0.1120131503658719 + ], + [ + -0.03422832387281005, + -0.8688172281950557, + 1.937872050399747 + ] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.6575432, + 1.0169075, + 1.0 + ], + [ + 0.045065638, + 1.0544671, + 1.0 + ], + [ + 0.2621381, + 1.0540911, + 1.0 + ], + [ + 0.5927048, + 1.0288227, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.568119, + 1.0037564, + 1.0 + ], + [ + -0.4427149, + 1.0345306, + 1.0 + ], + [ + -0.41775778, + 1.0446787, + 1.0 + ], + [ + 0.086332865, + 1.0200883, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.272573, + 1.0058666, + 1.0 + ], + [ + -0.5253304, + 0.9976382, + 1.0 + ], + [ + -1.19747, + 1.0018761, + 1.0 + ], + [ + -0.79130363, + 0.98982924, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.8305438, + 1.013145, + 1.0 + ], + [ + -0.64092255, + 1.0028768, + 1.0 + ], + [ + -1.6476865, + 0.98830545, + 1.0 + ], + [ + -1.8518519, + 0.9801241, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.9341725, + 1.02301, + 1.0 + ], + [ + -1.0185463, + 1.0157639, + 1.0 + ], + [ + -2.5776067, + 0.99842805, + 1.0 + ], + [ + -3.2994924, + 0.9785849, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.5423042, + 1.0483397, + 1.0 + ], + [ + -1.4740537, + 1.032285, + 1.0 + ], + [ + -3.7110608, + 1.0046949, + 1.0 + ], + [ + -5.396528, + 0.967783, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.6425831, + 1.0778131, + 1.0 + ], + [ + -2.4365096, + 1.0424513, + 1.0 + ], + [ + -4.570945, + 1.0063766, + 1.0 + ], + [ + -6.660486, + 0.95838326, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.14437525, + 1.0805831, + 1.0 + ], + [ + -3.1429949, + 1.0347922, + 1.0 + ], + [ + -4.7743, + 1.0044911, + 1.0 + ], + [ + -6.743695, + 0.9554359, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.7822553, + 1.099207, + 1.0 + ], + [ + -2.1562428, + 1.0492018, + 1.0 + ], + [ + -4.6814117, + 0.9976052, + 1.0 + ], + [ + -7.334759, + 0.9328078, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.4186356, + 1.1193541, + 1.0 + ], + [ + -1.2648852, + 1.0724756, + 1.0 + ], + [ + -4.010651, + 1.001225, + 1.0 + ], + [ + -6.9021163, + 0.93410987, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.0874636, + 1.1538868, + 1.0 + ], + [ + 0.23989654, + 1.0897437, + 1.0 + ], + [ + -2.9069233, + 1.0008082, + 1.0 + ], + [ + -6.942227, + 0.9103677, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.2319875, + 1.1964883, + 1.0 + ], + [ + 1.6361779, + 1.1026742, + 1.0 + ], + [ + -1.7622405, + 0.99407303, + 1.0 + ], + [ + -7.6075315, + 0.87470096, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.200221, + 1.2336057, + 1.0 + ], + [ + 2.4819555, + 1.1164554, + 1.0 + ], + [ + -0.67436194, + 0.9800342, + 1.0 + ], + [ + -6.7233605, + 0.8354304, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.7635767, + 1.258256, + 1.0 + ], + [ + 2.755172, + 1.1187295, + 1.0 + ], + [ + 0.58612347, + 0.95909715, + 1.0 + ], + [ + -3.4647129, + 0.8201187, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.7450001, + 1.2654432, + 1.0 + ], + [ + 2.2866368, + 1.1127598, + 1.0 + ], + [ + 1.7782547, + 0.96788186, + 1.0 + ], + [ + 0.64800644, + 0.8437623, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.4756403, + 1.2495352, + 1.0 + ], + [ + 1.8023143, + 1.1058493, + 1.0 + ], + [ + 2.4051156, + 0.97594136, + 1.0 + ], + [ + 3.1766913, + 0.8713939, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.15415932, + 1.2347906, + 1.0 + ], + [ + 1.3704019, + 1.0905327, + 1.0 + ], + [ + 2.8927534, + 0.95705473, + 1.0 + ], + [ + 4.9556255, + 0.85762197, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.45234466, + 1.2126856, + 1.0 + ], + [ + 1.3297142, + 1.0610285, + 1.0 + ], + [ + 3.5847495, + 0.92722595, + 1.0 + ], + [ + 6.0845156, + 0.84810686, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.0763642, + 1.1915482, + 1.0 + ], + [ + 1.087915, + 1.0379584, + 1.0 + ], + [ + 3.107092, + 0.9336573, + 1.0 + ], + [ + 4.6096416, + 0.87201273, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.8927168, + 1.1797738, + 1.0 + ], + [ + 0.3604307, + 1.0387832, + 1.0 + ], + [ + 1.8885725, + 0.9472845, + 1.0 + ], + [ + 2.5527375, + 0.8916758, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -2.8154569, + 1.1746649, + 1.0 + ], + [ + -0.75731444, + 1.0539931, + 1.0 + ], + [ + 0.15679865, + 0.99418485, + 1.0 + ], + [ + 0.26365867, + 0.97036415, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -3.6557531, + 1.1808895, + 1.0 + ], + [ + -2.146421, + 1.0872095, + 1.0 + ], + [ + -1.216422, + 1.0318732, + 1.0 + ], + [ + -0.14712709, + 1.0002339, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -4.503687, + 1.1711676, + 1.0 + ], + [ + -2.6333165, + 1.0882001, + 1.0 + ], + [ + -0.9562701, + 1.0260637, + 1.0 + ], + [ + -0.035742544, + 1.0006093, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -5.3313165, + 1.1779693, + 1.0 + ], + [ + -3.511713, + 1.0934967, + 1.0 + ], + [ + -0.77989405, + 1.0125425, + 1.0 + ], + [ + -0.15130846, + 0.9985428, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -6.7568846, + 1.1773652, + 1.0 + ], + [ + -4.3852744, + 1.0850232, + 1.0 + ], + [ + -2.9159627, + 0.9900688, + 1.0 + ], + [ + -2.1400974, + 0.9775968, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -6.7892175, + 1.1440657, + 1.0 + ], + [ + -4.395312, + 1.0594687, + 1.0 + ], + [ + -3.1968632, + 0.9905179, + 1.0 + ], + [ + -1.8385077, + 0.98149014, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -7.615582, + 1.1392046, + 1.0 + ], + [ + -4.00538, + 1.0335472, + 1.0 + ], + [ + -0.9200221, + 0.99787354, + 1.0 + ], + [ + -0.11120706, + 0.9992187, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.523595, + 1.1210346, + 1.0 + ], + [ + -4.8383403, + 1.0227709, + 1.0 + ], + [ + -1.0064363, + 0.9959147, + 1.0 + ], + [ + -0.049839716, + 0.99975395, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.567221, + 1.109696, + 1.0 + ], + [ + -3.7778788, + 1.0227439, + 1.0 + ], + [ + -0.3094657, + 0.99754596, + 1.0 + ], + [ + -0.01160631, + 0.9998476, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -7.9242926, + 1.1014805, + 1.0 + ], + [ + -2.6735833, + 1.0197709, + 1.0 + ], + [ + -0.10607915, + 0.9988134, + 1.0 + ], + [ + -0.0016143449, + 0.99994075, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -7.489351, + 1.0988349, + 1.0 + ], + [ + -2.0020316, + 1.0113986, + 1.0 + ], + [ + -0.060160246, + 0.9993989, + 1.0 + ], + [ + -0.00045636468, + 0.99998945, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -7.7909346, + 1.0950327, + 1.0 + ], + [ + -3.1690855, + 1.0192778, + 1.0 + ], + [ + -0.20098883, + 1.0008932, + 1.0 + ], + [ + -0.0010797405, + 1.0000042, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.812833, + 1.0921811, + 1.0 + ], + [ + -3.6212065, + 1.016884, + 1.0 + ], + [ + -0.2872315, + 1.000208, + 1.0 + ], + [ + -0.0022921215, + 0.9999986, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -7.7691603, + 1.0748369, + 1.0 + ], + [ + -2.2901673, + 1.0061964, + 1.0 + ], + [ + -0.2540361, + 0.9997461, + 1.0 + ], + [ + -0.002999882, + 0.9999967, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -7.285355, + 1.0752903, + 1.0 + ], + [ + -2.8977985, + 1.008037, + 1.0 + ], + [ + -0.389354, + 1.0001613, + 1.0 + ], + [ + -0.0048222253, + 1.0000017, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.331701, + 1.0774896, + 1.0 + ], + [ + -3.3062727, + 1.0165391, + 1.0 + ], + [ + -0.24283074, + 1.0005523, + 1.0 + ], + [ + -0.0019910862, + 1.0000027, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.720209, + 1.0690521, + 1.0 + ], + [ + -4.8786044, + 1.0175062, + 1.0 + ], + [ + -0.48560837, + 1.0014203, + 1.0 + ], + [ + -0.0033854088, + 1.0000086, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -11.556501, + 1.0456746, + 1.0 + ], + [ + -4.985604, + 1.0003452, + 1.0 + ], + [ + -0.41515103, + 0.9995448, + 1.0 + ], + [ + -0.003054782, + 0.9999918, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -11.729045, + 1.0217354, + 1.0 + ], + [ + -3.521123, + 0.98780435, + 1.0 + ], + [ + -0.30965137, + 0.99440813, + 1.0 + ], + [ + -0.008598875, + 0.9997742, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -12.221271, + 0.98520076, + 1.0 + ], + [ + -4.486184, + 0.97454756, + 1.0 + ], + [ + -0.41762093, + 0.9899926, + 1.0 + ], + [ + -0.03436083, + 0.9983804, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -13.70429, + 0.9197576, + 1.0 + ], + [ + -6.0378523, + 0.93986017, + 1.0 + ], + [ + -0.41832632, + 0.99216044, + 1.0 + ], + [ + -0.01641741, + 0.99925196, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -14.093513, + 0.8699881, + 1.0 + ], + [ + -6.8281274, + 0.9147011, + 1.0 + ], + [ + -0.30128792, + 0.99418074, + 1.0 + ], + [ + -0.0026745356, + 0.9999154, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -12.529678, + 0.84677386, + 1.0 + ], + [ + -7.347991, + 0.897593, + 1.0 + ], + [ + -0.39531615, + 0.9915446, + 1.0 + ], + [ + -0.000654911, + 0.9999173, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -10.297008, + 0.8246169, + 1.0 + ], + [ + -6.991395, + 0.8822463, + 1.0 + ], + [ + -0.8904233, + 0.98326087, + 1.0 + ], + [ + -0.005210178, + 0.99982196, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -7.1014814, + 0.870403, + 1.0 + ], + [ + -4.2737536, + 0.9510795, + 1.0 + ], + [ + -1.0143899, + 0.9928547, + 1.0 + ], + [ + 0.0069134138, + 0.9990259, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -2.2389834, + 1.0089513, + 1.0 + ], + [ + 0.59233487, + 1.0836766, + 1.0 + ], + [ + 2.109882, + 1.0953512, + 1.0 + ], + [ + 0.57811993, + 1.0165061, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.42605868, + 1.0736833, + 1.0 + ], + [ + 2.6112878, + 1.1127571, + 1.0 + ], + [ + 3.734751, + 1.1181016, + 1.0 + ], + [ + 2.895584, + 1.0769328, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.7645093, + 1.0585829, + 1.0 + ], + [ + 2.7074714, + 1.1086032, + 1.0 + ], + [ + 4.612825, + 1.121013, + 1.0 + ], + [ + 4.2159166, + 1.090578, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.317804, + 1.0096856, + 1.0 + ], + [ + 2.410078, + 1.0813844, + 1.0 + ], + [ + 4.022777, + 1.0866787, + 1.0 + ], + [ + 1.5960958, + 1.0285214, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.7581048, + 0.94621634, + 1.0 + ], + [ + 3.344702, + 1.031301, + 1.0 + ], + [ + 1.6271458, + 1.0360217, + 1.0 + ], + [ + 0.045802835, + 1.0040928, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.2102604, + 0.8877613, + 1.0 + ], + [ + 2.5063229, + 0.9831511, + 1.0 + ], + [ + 1.0589613, + 1.0087296, + 1.0 + ], + [ + 0.044849876, + 1.0006486, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.8181234, + 0.84908885, + 1.0 + ], + [ + 1.2979459, + 0.9558577, + 1.0 + ], + [ + 0.95269287, + 0.9990587, + 1.0 + ], + [ + 0.13956869, + 0.99899775, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.4218108, + 0.8245256, + 1.0 + ], + [ + 0.980773, + 0.95178086, + 1.0 + ], + [ + 0.44153172, + 1.004565, + 1.0 + ], + [ + 0.083737634, + 0.9998738, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.3112286, + 0.81245995, + 1.0 + ], + [ + 1.2433338, + 0.94633913, + 1.0 + ], + [ + -0.45147318, + 1.0128739, + 1.0 + ], + [ + -0.040671885, + 1.0012027, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.0959775, + 0.82676107, + 1.0 + ], + [ + 0.19708328, + 0.9494545, + 1.0 + ], + [ + -0.71126884, + 1.0130774, + 1.0 + ], + [ + -0.066266306, + 1.0014489, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.0682087, + 0.85580033, + 1.0 + ], + [ + 0.33310115, + 0.9754608, + 1.0 + ], + [ + -0.038120847, + 1.0069447, + 1.0 + ], + [ + -0.0039369226, + 1.0006703, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.4665134, + 0.8869471, + 1.0 + ], + [ + 1.394241, + 0.9861353, + 1.0 + ], + [ + 0.39206934, + 1.0029631, + 1.0 + ], + [ + 0.012310251, + 1.0002207, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.840249, + 0.885747, + 1.0 + ], + [ + 2.030714, + 0.97694564, + 1.0 + ], + [ + 0.48313063, + 0.9986679, + 1.0 + ], + [ + 0.015092909, + 1.0000398, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 6.364709, + 0.93935746, + 1.0 + ], + [ + 3.27353, + 1.0063933, + 1.0 + ], + [ + 0.47039714, + 1.0006171, + 1.0 + ], + [ + 0.0095254835, + 0.9999786, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.708454, + 0.9936273, + 1.0 + ], + [ + 3.0144715, + 1.0396764, + 1.0 + ], + [ + 0.76019174, + 1.0015196, + 1.0 + ], + [ + 0.031606715, + 0.9997963, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.081264, + 1.007644, + 1.0 + ], + [ + 0.9569218, + 1.0373055, + 1.0 + ], + [ + -0.29632938, + 1.0081192, + 1.0 + ], + [ + -0.020595783, + 1.0002273, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.4333196, + 0.9922466, + 1.0 + ], + [ + 0.8624292, + 1.0051231, + 1.0 + ], + [ + -0.124487296, + 1.0007266, + 1.0 + ], + [ + 0.0014794656, + 1.0001725, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.0025764, + 0.9966404, + 1.0 + ], + [ + 0.32528183, + 1.0222802, + 1.0 + ], + [ + -1.4467791, + 1.0029583, + 1.0 + ], + [ + -0.09876483, + 1.0001086, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.9974155, + 0.9894437, + 1.0 + ], + [ + 2.0329673, + 1.0394019, + 1.0 + ], + [ + -0.49002603, + 1.0067779, + 1.0 + ], + [ + -0.091952406, + 0.9999813, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.90883, + 0.9736301, + 1.0 + ], + [ + 2.7153323, + 1.0357952, + 1.0 + ], + [ + 0.37018985, + 1.0083603, + 1.0 + ], + [ + -0.0096038, + 1.0001452, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.1170373, + 0.98292226, + 1.0 + ], + [ + 1.2927599, + 1.035257, + 1.0 + ], + [ + 0.3150436, + 1.0090348, + 1.0 + ], + [ + 0.0004185312, + 1.0000962, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.3353724, + 0.9857392, + 1.0 + ], + [ + 0.9401049, + 1.0468453, + 1.0 + ], + [ + -0.13837458, + 1.0182233, + 1.0 + ], + [ + -0.024829892, + 1.0004286, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.5100236, + 1.0068982, + 1.0 + ], + [ + 0.93617123, + 1.0617714, + 1.0 + ], + [ + 0.047028802, + 1.0331767, + 1.0 + ], + [ + 0.0046950956, + 1.0010607, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.6971107, + 1.0113055, + 1.0 + ], + [ + -0.20543085, + 1.0561123, + 1.0 + ], + [ + -0.47802702, + 1.0383303, + 1.0 + ], + [ + 0.07525184, + 1.0021627, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.8135715, + 1.0148535, + 1.0 + ], + [ + 0.23203266, + 1.0550131, + 1.0 + ], + [ + -0.023389658, + 1.04285, + 1.0 + ], + [ + 0.19465668, + 1.002981, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.6685414, + 1.0124575, + 1.0 + ], + [ + 0.59984475, + 1.0448725, + 1.0 + ], + [ + 0.69587445, + 1.0372711, + 1.0 + ], + [ + 0.60554105, + 1.005815, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.5002728, + 1.0161368, + 1.0 + ], + [ + 0.56248856, + 1.058378, + 1.0 + ], + [ + 0.9347412, + 1.0511781, + 1.0 + ], + [ + 0.8927735, + 1.0201077, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.8510935, + 1.0004079, + 1.0 + ], + [ + -0.13690601, + 1.0438355, + 1.0 + ], + [ + 0.5490022, + 1.0381058, + 1.0 + ], + [ + 1.4847147, + 1.0113307, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.2298164, + 0.98695815, + 1.0 + ], + [ + -0.5338396, + 1.008648, + 1.0 + ], + [ + -0.1529312, + 1.0192995, + 1.0 + ], + [ + 1.3007274, + 0.98695505, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.9663947, + 0.9861699, + 1.0 + ], + [ + 0.3281501, + 0.9828017, + 1.0 + ], + [ + -0.9316485, + 0.9753089, + 1.0 + ], + [ + -0.45177975, + 0.9492595, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.8109272, + 0.98814505, + 1.0 + ], + [ + -0.06094338, + 0.99206847, + 1.0 + ], + [ + -1.5175383, + 0.982531, + 1.0 + ], + [ + -1.9885272, + 0.954517, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.5236363, + 0.9898073, + 1.0 + ], + [ + -0.5354513, + 1.0057786, + 1.0 + ], + [ + -2.319607, + 1.0002798, + 1.0 + ], + [ + -3.4267511, + 0.97033256, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.6655283, + 1.0100801, + 1.0 + ], + [ + -1.0105478, + 1.0113044, + 1.0 + ], + [ + -3.3712995, + 0.9907962, + 1.0 + ], + [ + -5.3614125, + 0.957974, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.55430204, + 1.0288806, + 1.0 + ], + [ + -2.2823086, + 1.0114845, + 1.0 + ], + [ + -4.29412, + 0.9850471, + 1.0 + ], + [ + -6.70813, + 0.94654834, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.28653958, + 1.029249, + 1.0 + ], + [ + -2.9026222, + 1.0021783, + 1.0 + ], + [ + -4.3481326, + 0.98601437, + 1.0 + ], + [ + -6.742446, + 0.94504565, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.840411, + 1.0304451, + 1.0 + ], + [ + -1.7543678, + 1.0166836, + 1.0 + ], + [ + -4.263933, + 0.98349094, + 1.0 + ], + [ + -7.782672, + 0.9144162, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.9039959, + 1.0615926, + 1.0 + ], + [ + -0.6004897, + 1.0442605, + 1.0 + ], + [ + -3.2590501, + 0.9852762, + 1.0 + ], + [ + -6.0829635, + 0.92023736, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.113609, + 1.1155086, + 1.0 + ], + [ + 0.7005239, + 1.0668454, + 1.0 + ], + [ + -1.9628628, + 0.9854875, + 1.0 + ], + [ + -5.3814754, + 0.90371615, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.0877285, + 1.1595732, + 1.0 + ], + [ + 1.9560493, + 1.0802444, + 1.0 + ], + [ + -0.77529097, + 0.9800677, + 1.0 + ], + [ + -5.565922, + 0.8722543, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.288913, + 1.2051771, + 1.0 + ], + [ + 2.74089, + 1.1004237, + 1.0 + ], + [ + 0.012938409, + 0.9697918, + 1.0 + ], + [ + -5.393885, + 0.8341304, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.8245485, + 1.2282706, + 1.0 + ], + [ + 2.9895375, + 1.1039253, + 1.0 + ], + [ + 1.2145354, + 0.95314676, + 1.0 + ], + [ + -2.3480604, + 0.8217049, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.0588024, + 1.2314723, + 1.0 + ], + [ + 2.7796125, + 1.0972726, + 1.0 + ], + [ + 2.4223437, + 0.9613367, + 1.0 + ], + [ + 1.6759906, + 0.8447568, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.8357061, + 1.210052, + 1.0 + ], + [ + 2.2408845, + 1.0848253, + 1.0 + ], + [ + 2.808005, + 0.9623734, + 1.0 + ], + [ + 3.4482584, + 0.8608864, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.69474894, + 1.1947756, + 1.0 + ], + [ + 1.6912163, + 1.0640128, + 1.0 + ], + [ + 3.070553, + 0.93303436, + 1.0 + ], + [ + 5.026965, + 0.83831453, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.02924209, + 1.1594467, + 1.0 + ], + [ + 1.437672, + 1.0297697, + 1.0 + ], + [ + 3.5909321, + 0.90887827, + 1.0 + ], + [ + 5.7714977, + 0.8360717, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.73282367, + 1.1322978, + 1.0 + ], + [ + 1.0370998, + 1.0152173, + 1.0 + ], + [ + 2.9553826, + 0.93621993, + 1.0 + ], + [ + 4.670117, + 0.86914015, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.619349, + 1.1252851, + 1.0 + ], + [ + 0.15239729, + 1.0244952, + 1.0 + ], + [ + 1.6071638, + 0.960652, + 1.0 + ], + [ + 2.7668586, + 0.8957443, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -2.4471934, + 1.1201713, + 1.0 + ], + [ + -0.9853024, + 1.0402389, + 1.0 + ], + [ + -0.10679894, + 1.0003766, + 1.0 + ], + [ + 0.38707855, + 0.9576168, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -3.6268017, + 1.1333272, + 1.0 + ], + [ + -2.5076745, + 1.071064, + 1.0 + ], + [ + -1.8380454, + 1.0375947, + 1.0 + ], + [ + -0.49257776, + 1.0004728, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -4.8864617, + 1.1463132, + 1.0 + ], + [ + -3.6842506, + 1.0934086, + 1.0 + ], + [ + -2.3747447, + 1.0497583, + 1.0 + ], + [ + -0.121673696, + 1.0016263, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -6.1770673, + 1.1691642, + 1.0 + ], + [ + -4.8939047, + 1.1140888, + 1.0 + ], + [ + -2.1122801, + 1.0347022, + 1.0 + ], + [ + -0.2781079, + 0.9971531, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -7.824204, + 1.1899806, + 1.0 + ], + [ + -6.7190065, + 1.1285441, + 1.0 + ], + [ + -5.1226296, + 0.9854947, + 1.0 + ], + [ + -4.016408, + 0.9494332, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.926484, + 1.1482611, + 1.0 + ], + [ + -7.6249037, + 1.0958742, + 1.0 + ], + [ + -5.6792645, + 0.9760871, + 1.0 + ], + [ + -4.0723734, + 0.94808024, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.670957, + 1.1153084, + 1.0 + ], + [ + -8.038458, + 1.0422579, + 1.0 + ], + [ + -5.8509502, + 0.9850574, + 1.0 + ], + [ + -0.8264564, + 0.9961184, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -10.342434, + 1.0856272, + 1.0 + ], + [ + -9.040556, + 1.0213751, + 1.0 + ], + [ + -7.0447855, + 0.9760523, + 1.0 + ], + [ + -0.8563134, + 0.9967798, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -11.15017, + 1.0738698, + 1.0 + ], + [ + -8.293659, + 1.0015079, + 1.0 + ], + [ + -3.465691, + 0.95468867, + 1.0 + ], + [ + -0.26872262, + 0.996449, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -12.361571, + 1.0671077, + 1.0 + ], + [ + -8.069062, + 0.9878739, + 1.0 + ], + [ + -1.8393602, + 0.9616174, + 1.0 + ], + [ + -0.062451784, + 0.99806386, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -11.813561, + 1.0733019, + 1.0 + ], + [ + -6.277449, + 0.9938862, + 1.0 + ], + [ + -0.91688836, + 0.98827964, + 1.0 + ], + [ + -0.015721286, + 0.99975455, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -11.033608, + 1.0607992, + 1.0 + ], + [ + -5.8924174, + 1.0059938, + 1.0 + ], + [ + -0.4630321, + 0.99918526, + 1.0 + ], + [ + -0.0028961068, + 0.9999799, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -13.24634, + 1.0676687, + 1.0 + ], + [ + -8.926855, + 1.0089554, + 1.0 + ], + [ + -1.2582607, + 0.998966, + 1.0 + ], + [ + -0.01142765, + 0.9999851, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -14.128018, + 1.0785146, + 1.0 + ], + [ + -8.119614, + 1.0094862, + 1.0 + ], + [ + -1.2649353, + 0.99898714, + 1.0 + ], + [ + -0.015170822, + 0.9999889, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -12.451852, + 1.0737165, + 1.0 + ], + [ + -6.9139895, + 1.0113754, + 1.0 + ], + [ + -1.088725, + 1.0001466, + 1.0 + ], + [ + -0.013777146, + 1.0000031, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -12.380149, + 1.0823865, + 1.0 + ], + [ + -6.0622687, + 1.0224757, + 1.0 + ], + [ + -0.49693757, + 1.0009257, + 1.0 + ], + [ + -0.004322603, + 1.0000051, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -13.628408, + 1.0830122, + 1.0 + ], + [ + -7.603063, + 1.0293987, + 1.0 + ], + [ + -0.7915703, + 1.0023602, + 1.0 + ], + [ + -0.005485728, + 1.0000141, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -15.070874, + 1.025278, + 1.0 + ], + [ + -8.004763, + 0.9920342, + 1.0 + ], + [ + -0.6927761, + 0.99896514, + 1.0 + ], + [ + -0.0051751113, + 0.99998105, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -15.791659, + 0.9914543, + 1.0 + ], + [ + -7.6757627, + 0.9670585, + 1.0 + ], + [ + -0.94827396, + 0.98121995, + 1.0 + ], + [ + -0.042875327, + 0.99850494, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -15.504122, + 0.918028, + 1.0 + ], + [ + -9.548977, + 0.89464694, + 1.0 + ], + [ + -2.6915827, + 0.9125134, + 1.0 + ], + [ + -0.46877646, + 0.9763951, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -15.834205, + 0.86167675, + 1.0 + ], + [ + -11.029793, + 0.8641353, + 1.0 + ], + [ + -2.304663, + 0.940338, + 1.0 + ], + [ + -0.22860166, + 0.9885806, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -15.163353, + 0.80446744, + 1.0 + ], + [ + -10.703714, + 0.82697487, + 1.0 + ], + [ + -1.8142037, + 0.948325, + 1.0 + ], + [ + -0.0477379, + 0.998123, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -14.573051, + 0.76530474, + 1.0 + ], + [ + -10.532188, + 0.808058, + 1.0 + ], + [ + -1.7215453, + 0.94441307, + 1.0 + ], + [ + -0.0065259486, + 0.99902344, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -11.712678, + 0.78053474, + 1.0 + ], + [ + -9.12928, + 0.8284735, + 1.0 + ], + [ + -3.0647676, + 0.9377219, + 1.0 + ], + [ + -0.02522077, + 0.9987442, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -7.4553695, + 0.88699067, + 1.0 + ], + [ + -4.486537, + 0.9664551, + 1.0 + ], + [ + -1.3492911, + 1.0004994, + 1.0 + ], + [ + 0.20210887, + 0.98605, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -2.5626588, + 1.0187794, + 1.0 + ], + [ + -0.16849272, + 1.0859944, + 1.0 + ], + [ + 1.1092399, + 1.1023259, + 1.0 + ], + [ + 1.3777262, + 1.0548018, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.040617283, + 1.066993, + 1.0 + ], + [ + 1.53646, + 1.1038263, + 1.0 + ], + [ + 2.3726995, + 1.106449, + 1.0 + ], + [ + 2.8319159, + 1.0958794, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.8091955, + 1.042359, + 1.0 + ], + [ + 2.302053, + 1.0939299, + 1.0 + ], + [ + 3.1255443, + 1.0935813, + 1.0 + ], + [ + 3.7047086, + 1.0906761, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.4341065, + 0.9560042, + 1.0 + ], + [ + 2.3559563, + 1.0608075, + 1.0 + ], + [ + 3.3579242, + 1.0881857, + 1.0 + ], + [ + 3.2111607, + 1.0706342, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.0547562, + 0.8983519, + 1.0 + ], + [ + 3.383354, + 0.9991967, + 1.0 + ], + [ + 1.8556306, + 1.0425386, + 1.0 + ], + [ + 0.0885949, + 1.0138919, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.7682338, + 0.8603291, + 1.0 + ], + [ + 3.0132825, + 0.9588983, + 1.0 + ], + [ + 1.9995759, + 1.0111612, + 1.0 + ], + [ + 0.47482064, + 1.006286, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.6232177, + 0.8233142, + 1.0 + ], + [ + 2.0865932, + 0.9309424, + 1.0 + ], + [ + 2.7574844, + 0.97783035, + 1.0 + ], + [ + 0.95219344, + 0.99299604, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.6943194, + 0.79197764, + 1.0 + ], + [ + 1.8879877, + 0.920102, + 1.0 + ], + [ + 1.2647737, + 0.9986511, + 1.0 + ], + [ + 0.3897333, + 1.0005867, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.7574468, + 0.78670424, + 1.0 + ], + [ + 2.1780686, + 0.9121051, + 1.0 + ], + [ + -0.5525949, + 1.0213124, + 1.0 + ], + [ + -0.2346502, + 1.0064204, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.4999485, + 0.7841847, + 1.0 + ], + [ + 1.9435306, + 0.9184122, + 1.0 + ], + [ + -1.5851839, + 1.0305399, + 1.0 + ], + [ + -0.36774108, + 1.0069425, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.909163, + 0.80230415, + 1.0 + ], + [ + 2.1358707, + 0.935365, + 1.0 + ], + [ + -0.84193116, + 1.0221872, + 1.0 + ], + [ + -0.14351588, + 1.0037925, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 6.5533886, + 0.8221586, + 1.0 + ], + [ + 3.3628576, + 0.95415354, + 1.0 + ], + [ + 0.8394116, + 1.0065193, + 1.0 + ], + [ + 0.021697784, + 1.000693, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 8.034471, + 0.84136575, + 1.0 + ], + [ + 4.6422033, + 0.9475814, + 1.0 + ], + [ + 1.4912386, + 0.99571365, + 1.0 + ], + [ + 0.018330399, + 1.0000721, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 7.851116, + 0.9006105, + 1.0 + ], + [ + 5.2049785, + 0.9920339, + 1.0 + ], + [ + 1.2569566, + 0.99717367, + 1.0 + ], + [ + 0.0052652257, + 0.9997678, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 7.6648865, + 0.9650344, + 1.0 + ], + [ + 4.729117, + 1.0339372, + 1.0 + ], + [ + 2.091848, + 0.99583614, + 1.0 + ], + [ + 0.10322704, + 0.99907774, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 7.6680355, + 0.9877355, + 1.0 + ], + [ + 3.6761901, + 1.0451121, + 1.0 + ], + [ + -0.04351923, + 1.0153469, + 1.0 + ], + [ + -0.03283479, + 1.0004294, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 6.463548, + 0.96174765, + 1.0 + ], + [ + 2.6405559, + 1.0113807, + 1.0 + ], + [ + -0.0145218745, + 1.0030446, + 1.0 + ], + [ + 0.02793165, + 1.0006396, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 6.7405667, + 0.97143346, + 1.0 + ], + [ + 1.3641241, + 1.0326787, + 1.0 + ], + [ + -3.4862273, + 1.0015736, + 1.0 + ], + [ + -0.47250292, + 0.99943143, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 8.19507, + 0.96321476, + 1.0 + ], + [ + 3.4350495, + 1.058396, + 1.0 + ], + [ + -3.5258145, + 1.0094136, + 1.0 + ], + [ + -1.0841179, + 0.9979366, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 7.8372226, + 0.95261025, + 1.0 + ], + [ + 4.014715, + 1.0542111, + 1.0 + ], + [ + 0.26240677, + 1.0264678, + 1.0 + ], + [ + -0.21519896, + 1.0007783, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.1908545, + 0.95756376, + 1.0 + ], + [ + 1.8382027, + 1.0398521, + 1.0 + ], + [ + 0.32465455, + 1.0176383, + 1.0 + ], + [ + -0.07286978, + 0.99991214, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 6.5853324, + 0.9502199, + 1.0 + ], + [ + 0.9021832, + 1.0475446, + 1.0 + ], + [ + -0.38347927, + 1.0296501, + 1.0 + ], + [ + -0.10875285, + 1.0017142, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 7.5904784, + 0.95001745, + 1.0 + ], + [ + 0.3187001, + 1.0552145, + 1.0 + ], + [ + -0.065457314, + 1.0441794, + 1.0 + ], + [ + 0.1682706, + 1.0044458, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 7.727416, + 0.9608402, + 1.0 + ], + [ + -0.4404374, + 1.0511845, + 1.0 + ], + [ + 0.13549781, + 1.0374392, + 1.0 + ], + [ + 1.0298581, + 1.0050887, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 7.1869535, + 0.96325994, + 1.0 + ], + [ + 0.21250498, + 1.0488186, + 1.0 + ], + [ + 0.5558834, + 1.0353485, + 1.0 + ], + [ + 1.3571175, + 1.0007496, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 6.2386184, + 0.97497666, + 1.0 + ], + [ + 0.37397635, + 1.0394269, + 1.0 + ], + [ + 0.7773281, + 1.034625, + 1.0 + ], + [ + 1.4875422, + 1.0045793, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.65055, + 0.98630524, + 1.0 + ], + [ + 0.3505162, + 1.0497316, + 1.0 + ], + [ + 1.0327646, + 1.0434405, + 1.0 + ], + [ + 1.5126252, + 1.0201807, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.1305985, + 0.95614517, + 1.0 + ], + [ + -0.6686687, + 0.96170896, + 1.0 + ], + [ + 0.31405145, + 0.94526744, + 1.0 + ], + [ + 0.7571128, + 0.93466496, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.7789507, + 0.96007, + 1.0 + ], + [ + 0.025442632, + 0.9322739, + 1.0 + ], + [ + 1.002043, + 0.92365426, + 1.0 + ], + [ + 1.4359783, + 0.9207567, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.5217903, + 0.9649529, + 1.0 + ], + [ + 1.3698361, + 0.93013364, + 1.0 + ], + [ + 0.81097263, + 0.8966511, + 1.0 + ], + [ + 1.3134359, + 0.9056901, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.4688148, + 0.94516605, + 1.0 + ], + [ + 1.1258384, + 0.9285592, + 1.0 + ], + [ + -0.03493635, + 0.9379082, + 1.0 + ], + [ + -0.34919712, + 0.92182875, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.0855563, + 0.9416833, + 1.0 + ], + [ + 0.61865765, + 0.95953137, + 1.0 + ], + [ + -0.53801084, + 0.98488677, + 1.0 + ], + [ + -1.3929236, + 0.9545244, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.280524, + 0.9401836, + 1.0 + ], + [ + 0.5206633, + 0.91849095, + 1.0 + ], + [ + -0.5855471, + 0.9109686, + 1.0 + ], + [ + -1.857449, + 0.9108761, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.1066036, + 0.91177595, + 1.0 + ], + [ + -0.2379211, + 0.90215147, + 1.0 + ], + [ + -0.662678, + 0.8674564, + 1.0 + ], + [ + -1.6655282, + 0.8560494, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.3806509, + 0.91369975, + 1.0 + ], + [ + -1.1977674, + 0.9291132, + 1.0 + ], + [ + -2.1955984, + 0.9157609, + 1.0 + ], + [ + -3.2753534, + 0.8795514, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.27487, + 0.8657369, + 1.0 + ], + [ + 0.84525156, + 0.8752064, + 1.0 + ], + [ + -1.1533172, + 0.88577944, + 1.0 + ], + [ + -4.5261264, + 0.8575033, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.6447818, + 0.8502878, + 1.0 + ], + [ + 1.9611719, + 0.87647694, + 1.0 + ], + [ + -0.25773066, + 0.88421917, + 1.0 + ], + [ + -2.0909984, + 0.84323335, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.3915353, + 0.95410204, + 1.0 + ], + [ + 1.9980977, + 0.9531656, + 1.0 + ], + [ + 0.5931035, + 0.88896406, + 1.0 + ], + [ + -1.3361316, + 0.8371896, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.3264365, + 1.0299196, + 1.0 + ], + [ + 2.5364761, + 0.97348475, + 1.0 + ], + [ + 1.3221674, + 0.88718134, + 1.0 + ], + [ + -0.9853643, + 0.8285287, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.1308208, + 1.0718769, + 1.0 + ], + [ + 2.790149, + 0.9912555, + 1.0 + ], + [ + 1.4749019, + 0.8944228, + 1.0 + ], + [ + -1.1555754, + 0.8163407, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.2958386, + 1.088511, + 1.0 + ], + [ + 2.6916971, + 0.9975709, + 1.0 + ], + [ + 1.9740455, + 0.88601756, + 1.0 + ], + [ + 0.36857194, + 0.80993265, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.5474374, + 1.0788504, + 1.0 + ], + [ + 2.225414, + 0.97936237, + 1.0 + ], + [ + 1.8757105, + 0.88728917, + 1.0 + ], + [ + 1.5990429, + 0.8291341, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.2438161, + 1.036749, + 1.0 + ], + [ + 1.3613421, + 0.9619165, + 1.0 + ], + [ + 1.4527313, + 0.8871177, + 1.0 + ], + [ + 2.0332718, + 0.8303382, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.09551922, + 1.0317862, + 1.0 + ], + [ + 0.43391225, + 0.9460805, + 1.0 + ], + [ + 1.4488848, + 0.8737923, + 1.0 + ], + [ + 3.4753993, + 0.81625134, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.7019963, + 1.0077614, + 1.0 + ], + [ + -0.051761873, + 0.9282602, + 1.0 + ], + [ + 1.9012142, + 0.8732041, + 1.0 + ], + [ + 4.1987333, + 0.82554394, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.6378045, + 1.0135831, + 1.0 + ], + [ + -0.38506448, + 0.9445609, + 1.0 + ], + [ + 1.5109704, + 0.91002667, + 1.0 + ], + [ + 3.479174, + 0.8619353, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -2.4618201, + 1.033089, + 1.0 + ], + [ + -1.2937772, + 0.97869325, + 1.0 + ], + [ + 0.10574721, + 0.9512708, + 1.0 + ], + [ + 1.488456, + 0.92858106, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -3.1203666, + 1.0350168, + 1.0 + ], + [ + -2.2538068, + 0.99723995, + 1.0 + ], + [ + -1.1310667, + 0.97431004, + 1.0 + ], + [ + 0.084608465, + 0.98049074, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -4.3735642, + 1.0520235, + 1.0 + ], + [ + -3.6140828, + 1.0261108, + 1.0 + ], + [ + -1.7979896, + 1.003871, + 1.0 + ], + [ + -0.05716326, + 0.9986102, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -5.2478585, + 1.0614483, + 1.0 + ], + [ + -4.203233, + 1.0365448, + 1.0 + ], + [ + -1.1785761, + 1.0052958, + 1.0 + ], + [ + -0.012518613, + 0.999905, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -5.7618976, + 1.0709513, + 1.0 + ], + [ + -4.2346697, + 1.041181, + 1.0 + ], + [ + -0.55711687, + 1.0027059, + 1.0 + ], + [ + -0.012013823, + 0.99982893, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -7.164432, + 1.0977043, + 1.0 + ], + [ + -4.544374, + 1.0551589, + 1.0 + ], + [ + -0.9486156, + 1.0006344, + 1.0 + ], + [ + -0.2880987, + 0.99641603, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.89083, + 1.0878369, + 1.0 + ], + [ + -5.120464, + 1.0464156, + 1.0 + ], + [ + -0.8068461, + 0.99902976, + 1.0 + ], + [ + -0.2889861, + 0.9960283, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.618337, + 1.0371728, + 1.0 + ], + [ + -5.1906323, + 1.007005, + 1.0 + ], + [ + -2.135625, + 0.9941443, + 1.0 + ], + [ + -0.21111596, + 0.99932176, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.985677, + 1.0003744, + 1.0 + ], + [ + -5.3432465, + 0.98975044, + 1.0 + ], + [ + -2.6925411, + 0.99021024, + 1.0 + ], + [ + -0.2637423, + 0.99901223, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.557486, + 1.0012228, + 1.0 + ], + [ + -5.126186, + 0.97562605, + 1.0 + ], + [ + -1.487278, + 0.9767831, + 1.0 + ], + [ + -0.09417072, + 0.99855137, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.93245, + 0.9848428, + 1.0 + ], + [ + -5.005292, + 0.95344836, + 1.0 + ], + [ + -1.0024476, + 0.97530013, + 1.0 + ], + [ + -0.03428691, + 0.9989181, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -10.067035, + 0.99813294, + 1.0 + ], + [ + -3.760256, + 0.9666209, + 1.0 + ], + [ + -0.43569535, + 0.9912011, + 1.0 + ], + [ + -0.007490502, + 0.99986094, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.267269, + 0.975714, + 1.0 + ], + [ + -3.3425367, + 0.97395366, + 1.0 + ], + [ + -0.16193047, + 0.99658626, + 1.0 + ], + [ + -0.00091553305, + 0.9999671, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.993388, + 1.0000288, + 1.0 + ], + [ + -4.422481, + 0.985607, + 1.0 + ], + [ + -0.32398883, + 0.9973332, + 1.0 + ], + [ + -0.0023061063, + 0.99997354, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.084186, + 0.9982832, + 1.0 + ], + [ + -2.7218752, + 0.9938026, + 1.0 + ], + [ + -0.17255202, + 0.9993347, + 1.0 + ], + [ + -0.0015168872, + 0.99999547, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.26013, + 0.9777609, + 1.0 + ], + [ + -2.3467531, + 0.98704755, + 1.0 + ], + [ + -0.09092708, + 0.9993146, + 1.0 + ], + [ + -0.00067042705, + 0.9999976, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.507622, + 0.9864571, + 1.0 + ], + [ + -1.6202773, + 0.9965017, + 1.0 + ], + [ + -0.03161555, + 0.999892, + 1.0 + ], + [ + -0.00015287292, + 0.9999998, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.677804, + 0.9922909, + 1.0 + ], + [ + -1.5864677, + 1.0017024, + 1.0 + ], + [ + -0.029533267, + 1.0000547, + 1.0 + ], + [ + -0.00012411954, + 1.0000002, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.914085, + 0.95310205, + 1.0 + ], + [ + -2.334698, + 0.99508375, + 1.0 + ], + [ + -0.04895491, + 0.99995136, + 1.0 + ], + [ + -0.00017408784, + 0.9999993, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -13.237178, + 0.8746589, + 1.0 + ], + [ + -3.9431214, + 0.96841735, + 1.0 + ], + [ + -0.13730685, + 0.99794483, + 1.0 + ], + [ + -0.0053191665, + 0.999769, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -14.975474, + 0.80612254, + 1.0 + ], + [ + -5.441822, + 0.93426883, + 1.0 + ], + [ + -0.5847607, + 0.97942346, + 1.0 + ], + [ + -0.0973578, + 0.99491835, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -16.96701, + 0.7174082, + 1.0 + ], + [ + -7.501978, + 0.8796432, + 1.0 + ], + [ + -0.55444115, + 0.9846425, + 1.0 + ], + [ + -0.0493976, + 0.9975046, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -19.691572, + 0.6349826, + 1.0 + ], + [ + -11.398152, + 0.7708063, + 1.0 + ], + [ + -0.78708935, + 0.97114944, + 1.0 + ], + [ + -0.013223002, + 0.99905497, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -18.446426, + 0.61982644, + 1.0 + ], + [ + -14.164779, + 0.6866288, + 1.0 + ], + [ + -1.6122752, + 0.95558673, + 1.0 + ], + [ + -0.004578955, + 0.9995476, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -13.488046, + 0.67327017, + 1.0 + ], + [ + -11.041826, + 0.6985784, + 1.0 + ], + [ + -3.797718, + 0.8836202, + 1.0 + ], + [ + -0.021354746, + 0.9991143, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.215271, + 0.79168403, + 1.0 + ], + [ + -5.9288893, + 0.82115835, + 1.0 + ], + [ + -3.627646, + 0.8675918, + 1.0 + ], + [ + -0.10416387, + 0.9819024, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -3.4996793, + 0.90832794, + 1.0 + ], + [ + -1.8338926, + 0.95480883, + 1.0 + ], + [ + -1.2572361, + 0.96503127, + 1.0 + ], + [ + -0.7744646, + 0.9684802, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.1851404, + 0.9754471, + 1.0 + ], + [ + -0.38070896, + 0.9967761, + 1.0 + ], + [ + -0.20595744, + 0.99183595, + 1.0 + ], + [ + -0.04291819, + 0.991432, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.24601784, + 0.9362298, + 1.0 + ], + [ + 0.4131334, + 1.0015087, + 1.0 + ], + [ + 0.6097395, + 1.0106862, + 1.0 + ], + [ + 0.80979943, + 1.0157323, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.15171635, + 0.7862158, + 1.0 + ], + [ + 0.88354856, + 0.9524625, + 1.0 + ], + [ + 1.2969238, + 1.0249856, + 1.0 + ], + [ + 1.3480076, + 1.0304265, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.2697306, + 0.74219435, + 1.0 + ], + [ + 2.2566214, + 0.8504037, + 1.0 + ], + [ + 0.43116477, + 0.98642737, + 1.0 + ], + [ + -0.62197345, + 0.9856124, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.9409392, + 0.7197661, + 1.0 + ], + [ + 2.657014, + 0.82564926, + 1.0 + ], + [ + 0.75268286, + 0.9767613, + 1.0 + ], + [ + 0.055744026, + 0.9941756, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.8314111, + 0.7361832, + 1.0 + ], + [ + 2.2688785, + 0.83848625, + 1.0 + ], + [ + 0.86895645, + 0.979231, + 1.0 + ], + [ + 0.0529236, + 1.0007346, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.6000655, + 0.7524115, + 1.0 + ], + [ + 2.0968463, + 0.888389, + 1.0 + ], + [ + 0.18316315, + 0.9902795, + 1.0 + ], + [ + -0.048968393, + 0.99920696, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.5598903, + 0.763862, + 1.0 + ], + [ + 1.1714306, + 0.8932834, + 1.0 + ], + [ + -0.57951224, + 0.985895, + 1.0 + ], + [ + -0.13511501, + 0.99782085, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.1605225, + 0.7730435, + 1.0 + ], + [ + 1.3596042, + 0.9008872, + 1.0 + ], + [ + -0.47168514, + 0.99376947, + 1.0 + ], + [ + -0.07464302, + 1.000353, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.752158, + 0.79429775, + 1.0 + ], + [ + 0.96804696, + 0.9385464, + 1.0 + ], + [ + -1.3032031, + 0.99664253, + 1.0 + ], + [ + -0.2467647, + 0.99934804, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.692927, + 0.817162, + 1.0 + ], + [ + 1.2399592, + 0.9601324, + 1.0 + ], + [ + -0.2092135, + 0.99869674, + 1.0 + ], + [ + -0.04914082, + 0.9998154, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.3077126, + 0.8646981, + 1.0 + ], + [ + 1.169301, + 0.9719851, + 1.0 + ], + [ + -0.14287929, + 0.9983894, + 1.0 + ], + [ + -0.070898615, + 0.9997087, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.741663, + 0.89205533, + 1.0 + ], + [ + 0.64960855, + 0.9830752, + 1.0 + ], + [ + -0.11191467, + 0.9987406, + 1.0 + ], + [ + -0.047313914, + 0.99977046, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.5162182, + 0.94403803, + 1.0 + ], + [ + 0.07143193, + 0.9925508, + 1.0 + ], + [ + -0.72076577, + 0.9948075, + 1.0 + ], + [ + -0.1948217, + 0.9987798, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.585983, + 0.96052504, + 1.0 + ], + [ + 1.0876106, + 0.9904767, + 1.0 + ], + [ + -0.27299738, + 0.99780345, + 1.0 + ], + [ + -0.080156155, + 0.99953175, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.779256, + 0.9310572, + 1.0 + ], + [ + 0.7266878, + 0.986474, + 1.0 + ], + [ + 0.035173222, + 0.99929446, + 1.0 + ], + [ + 0.0016433608, + 1.0000198, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.0365615, + 0.94872975, + 1.0 + ], + [ + 0.30465418, + 0.9817023, + 1.0 + ], + [ + -0.34721652, + 0.99478245, + 1.0 + ], + [ + -0.061744135, + 0.99972767, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.8961987, + 0.92921335, + 1.0 + ], + [ + 0.23940657, + 0.9766385, + 1.0 + ], + [ + -1.319209, + 0.99436516, + 1.0 + ], + [ + -0.2552591, + 0.9993648, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.820354, + 0.9000567, + 1.0 + ], + [ + 0.93661356, + 0.9792745, + 1.0 + ], + [ + -1.2972772, + 1.0013816, + 1.0 + ], + [ + -0.2005057, + 1.0001276, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.8547032, + 0.8771825, + 1.0 + ], + [ + -0.40298477, + 0.92672235, + 1.0 + ], + [ + -0.76510406, + 0.983101, + 1.0 + ], + [ + -0.10773284, + 0.9988855, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.1437955, + 0.87208176, + 1.0 + ], + [ + -0.35798803, + 0.9345046, + 1.0 + ], + [ + -0.6719991, + 0.9889946, + 1.0 + ], + [ + -0.12287665, + 0.9989714, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.7768846, + 0.90311426, + 1.0 + ], + [ + -0.6762936, + 0.95233446, + 1.0 + ], + [ + -1.8836145, + 0.9920913, + 1.0 + ], + [ + -0.860416, + 0.9946867, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.8961434, + 0.8824976, + 1.0 + ], + [ + -0.33391425, + 0.92908406, + 1.0 + ], + [ + -0.3221192, + 0.9918024, + 1.0 + ], + [ + -0.09044509, + 0.9966171, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 6.8632536, + 0.90186435, + 1.0 + ], + [ + 0.58749425, + 0.9437153, + 1.0 + ], + [ + 0.48553416, + 0.98712, + 1.0 + ], + [ + 0.19696854, + 0.99386424, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.8013225, + 0.9050866, + 1.0 + ], + [ + -0.49256903, + 0.9264555, + 1.0 + ], + [ + -1.7974619, + 0.9501046, + 1.0 + ], + [ + -1.5920177, + 0.97445244, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.4498076, + 0.92861384, + 1.0 + ], + [ + -0.7343135, + 0.9649644, + 1.0 + ], + [ + -1.2369624, + 0.9541305, + 1.0 + ], + [ + -1.1794292, + 0.9540083, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.9042714, + 0.9180204, + 1.0 + ], + [ + -2.219778, + 0.86444235, + 1.0 + ], + [ + -2.4816806, + 0.823798, + 1.0 + ], + [ + -1.5569929, + 0.8375031, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.83755153, + 0.91707826, + 1.0 + ], + [ + 0.22283489, + 0.86605746, + 1.0 + ], + [ + 0.4647223, + 0.8345799, + 1.0 + ], + [ + 0.72768146, + 0.84671277, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.523536, + 0.9087665, + 1.0 + ], + [ + 1.2768013, + 0.85424995, + 1.0 + ], + [ + 2.1177902, + 0.8432673, + 1.0 + ], + [ + 2.3680518, + 0.8740304, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.2746108, + 0.81840515, + 1.0 + ], + [ + 2.7676985, + 0.7900528, + 1.0 + ], + [ + 1.9201541, + 0.8856915, + 1.0 + ], + [ + 1.8866074, + 0.9185119, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.6828327, + 0.8136951, + 1.0 + ], + [ + 2.175628, + 0.84009886, + 1.0 + ], + [ + 2.0939028, + 0.8847502, + 1.0 + ], + [ + 2.5929403, + 0.8947538, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.768817, + 0.7967659, + 1.0 + ], + [ + 3.2244751, + 0.7584971, + 1.0 + ], + [ + 3.6050847, + 0.7831417, + 1.0 + ], + [ + 4.268032, + 0.8045191, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.3562844, + 0.7890957, + 1.0 + ], + [ + 3.7188308, + 0.7199401, + 1.0 + ], + [ + 5.9207273, + 0.6976366, + 1.0 + ], + [ + 7.6120353, + 0.70215327, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.0832772, + 0.7600011, + 1.0 + ], + [ + 2.2862017, + 0.76166797, + 1.0 + ], + [ + 3.6557348, + 0.74157053, + 1.0 + ], + [ + 5.3892546, + 0.731114, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.7183886, + 0.6808121, + 1.0 + ], + [ + 3.3387003, + 0.70804834, + 1.0 + ], + [ + 3.3728793, + 0.7371294, + 1.0 + ], + [ + 4.041886, + 0.73437375, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.624325, + 0.6674578, + 1.0 + ], + [ + 4.5378666, + 0.6732078, + 1.0 + ], + [ + 4.398033, + 0.71216196, + 1.0 + ], + [ + 5.5895276, + 0.7182609, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 5.306699, + 0.747718, + 1.0 + ], + [ + 4.879655, + 0.70281416, + 1.0 + ], + [ + 5.1314225, + 0.7001976, + 1.0 + ], + [ + 5.486214, + 0.72053, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 4.146373, + 0.78986615, + 1.0 + ], + [ + 3.90747, + 0.7194833, + 1.0 + ], + [ + 4.436473, + 0.7135913, + 1.0 + ], + [ + 3.9732492, + 0.7439657, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.051203, + 0.8220685, + 1.0 + ], + [ + 2.5377185, + 0.73653644, + 1.0 + ], + [ + 3.6690881, + 0.73991066, + 1.0 + ], + [ + 2.8405402, + 0.76611686, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.0629168, + 0.88988215, + 1.0 + ], + [ + 1.5405259, + 0.80136687, + 1.0 + ], + [ + 2.3380709, + 0.755409, + 1.0 + ], + [ + 1.6457901, + 0.78002536, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.9272873, + 0.8686442, + 1.0 + ], + [ + 0.6143116, + 0.7969848, + 1.0 + ], + [ + 0.5216986, + 0.7725941, + 1.0 + ], + [ + 0.27696848, + 0.79700214, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.18347983, + 0.7820147, + 1.0 + ], + [ + -0.2920065, + 0.78137934, + 1.0 + ], + [ + -0.7294265, + 0.8024376, + 1.0 + ], + [ + -0.6821398, + 0.81814516, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.23992704, + 0.7879301, + 1.0 + ], + [ + -0.88038236, + 0.8075125, + 1.0 + ], + [ + -0.9809185, + 0.8284354, + 1.0 + ], + [ + -0.07751103, + 0.8428534, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.80605406, + 0.8354792, + 1.0 + ], + [ + -1.559409, + 0.80948555, + 1.0 + ], + [ + -0.7388032, + 0.83500856, + 1.0 + ], + [ + 0.69942343, + 0.88329875, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.6294653, + 0.85172844, + 1.0 + ], + [ + -1.7645487, + 0.8297184, + 1.0 + ], + [ + -0.7100293, + 0.8840055, + 1.0 + ], + [ + 0.2274858, + 0.9601816, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -2.2752452, + 0.8992305, + 1.0 + ], + [ + -2.2524478, + 0.9024475, + 1.0 + ], + [ + -0.6900573, + 0.96735644, + 1.0 + ], + [ + -0.01443623, + 0.99743444, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -2.3342621, + 0.9171061, + 1.0 + ], + [ + -2.169138, + 0.9233468, + 1.0 + ], + [ + -0.38522747, + 0.9775049, + 1.0 + ], + [ + 0.0013864042, + 0.9985635, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.9786882, + 0.9581507, + 1.0 + ], + [ + -1.0984265, + 0.9696264, + 1.0 + ], + [ + -0.10668975, + 0.9950265, + 1.0 + ], + [ + -0.0001844623, + 0.99983454, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.8451796, + 0.9728855, + 1.0 + ], + [ + -0.75453144, + 0.9875004, + 1.0 + ], + [ + -0.03869543, + 0.998888, + 1.0 + ], + [ + -0.00016324317, + 0.99999046, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.4833093, + 0.9710839, + 1.0 + ], + [ + -0.4503776, + 0.99415845, + 1.0 + ], + [ + -0.014969094, + 0.9998039, + 1.0 + ], + [ + -0.00005460353, + 0.99999875, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.155806, + 0.9922136, + 1.0 + ], + [ + -0.24781401, + 0.9983759, + 1.0 + ], + [ + -0.009547606, + 0.9999699, + 1.0 + ], + [ + -0.00066759216, + 0.9999967, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.4516653, + 0.99805427, + 1.0 + ], + [ + -0.20918414, + 0.999779, + 1.0 + ], + [ + -0.006043913, + 0.9999931, + 1.0 + ], + [ + -0.00048581572, + 0.99999654, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.9392998, + 0.98668915, + 1.0 + ], + [ + -0.14349174, + 0.99852246, + 1.0 + ], + [ + -0.01090069, + 0.99995995, + 1.0 + ], + [ + -0.00083713053, + 0.99999756, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.1864663, + 0.9834442, + 1.0 + ], + [ + -0.18628179, + 0.99777365, + 1.0 + ], + [ + -0.014109625, + 0.99992806, + 1.0 + ], + [ + -0.001064983, + 0.9999959, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.0547693, + 0.98770183, + 1.0 + ], + [ + -0.17180885, + 0.9951577, + 1.0 + ], + [ + -0.014593571, + 0.99926215, + 1.0 + ], + [ + -0.00052392326, + 0.99998504, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.3548598, + 0.97179633, + 1.0 + ], + [ + -0.51838243, + 0.95328885, + 1.0 + ], + [ + -0.06852023, + 0.99208575, + 1.0 + ], + [ + -0.00085176574, + 0.99991536, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.3932482, + 0.9842496, + 1.0 + ], + [ + -0.21709155, + 0.9691371, + 1.0 + ], + [ + -0.014957193, + 0.99505883, + 1.0 + ], + [ + -0.00015723599, + 0.99995285, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.95742047, + 0.9800242, + 1.0 + ], + [ + -0.09572666, + 0.9719048, + 1.0 + ], + [ + -0.0004223666, + 0.9954867, + 1.0 + ], + [ + 0.000010266345, + 0.9999555, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.8219641, + 0.97176534, + 1.0 + ], + [ + -0.10358055, + 0.9572014, + 1.0 + ], + [ + -0.0028769998, + 0.99260867, + 1.0 + ], + [ + -0.0000138675605, + 0.9999241, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.81078154, + 0.9853913, + 1.0 + ], + [ + -0.07060721, + 0.99701566, + 1.0 + ], + [ + -0.0011506415, + 0.99966466, + 1.0 + ], + [ + -0.0000053977897, + 0.99999666, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.8799515, + 0.9880938, + 1.0 + ], + [ + -0.09139433, + 0.99895227, + 1.0 + ], + [ + -0.0011206032, + 0.99998325, + 1.0 + ], + [ + -0.0000023958048, + 0.99999994, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -1.203021, + 0.98468393, + 1.0 + ], + [ + -0.09533536, + 0.9993307, + 1.0 + ], + [ + -0.0006138411, + 0.9999971, + 1.0 + ], + [ + -4.9110037e-7, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.98682314, + 0.98741007, + 1.0 + ], + [ + -0.07803176, + 0.99942106, + 1.0 + ], + [ + -0.0005139681, + 0.9999974, + 1.0 + ], + [ + -3.048391e-7, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -2.0533085, + 0.95174795, + 1.0 + ], + [ + -0.15526569, + 0.9967907, + 1.0 + ], + [ + -0.0011854534, + 0.9999825, + 1.0 + ], + [ + -0.0000012432189, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -4.3160176, + 0.8893779, + 1.0 + ], + [ + -0.37086096, + 0.99144685, + 1.0 + ], + [ + -0.0031809295, + 0.9999518, + 1.0 + ], + [ + -0.000015129414, + 0.9999994, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.65379, + 0.80011606, + 1.0 + ], + [ + -0.97619027, + 0.98192537, + 1.0 + ], + [ + -0.011185538, + 0.9998816, + 1.0 + ], + [ + -0.00027599925, + 0.999986, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -13.900999, + 0.6643516, + 1.0 + ], + [ + -2.2590377, + 0.93988913, + 1.0 + ], + [ + -0.021305703, + 0.99952126, + 1.0 + ], + [ + -0.00016454396, + 0.9999922, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -18.50311, + 0.5672969, + 1.0 + ], + [ + -6.324091, + 0.8226143, + 1.0 + ], + [ + -0.09798097, + 0.99709153, + 1.0 + ], + [ + -0.0001432513, + 0.99998575, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -17.64413, + 0.5391097, + 1.0 + ], + [ + -10.467655, + 0.66710144, + 1.0 + ], + [ + -0.2899716, + 0.9890324, + 1.0 + ], + [ + -0.00047773414, + 0.9999807, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -13.92279, + 0.5472615, + 1.0 + ], + [ + -10.895535, + 0.59090734, + 1.0 + ], + [ + -2.0764418, + 0.9041057, + 1.0 + ], + [ + -0.006843486, + 0.9997131, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.823085, + 0.59954053, + 1.0 + ], + [ + -8.007232, + 0.64031166, + 1.0 + ], + [ + -5.934222, + 0.7216933, + 1.0 + ], + [ + -0.21140227, + 0.98925734, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -6.0854692, + 0.68398213, + 1.0 + ], + [ + -4.9988737, + 0.7475099, + 1.0 + ], + [ + -4.5387487, + 0.783282, + 1.0 + ], + [ + -3.1189833, + 0.85521823, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -4.5944576, + 0.7649848, + 1.0 + ], + [ + -4.2297106, + 0.8085376, + 1.0 + ], + [ + -4.0575027, + 0.8316775, + 1.0 + ], + [ + -3.6946223, + 0.857067, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -4.07587, + 0.76617646, + 1.0 + ], + [ + -3.7157166, + 0.84208333, + 1.0 + ], + [ + -3.0963829, + 0.88380915, + 1.0 + ], + [ + -2.4804156, + 0.9129343, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -2.87624, + 0.6232938, + 1.0 + ], + [ + -2.6126328, + 0.7971155, + 1.0 + ], + [ + -1.7279347, + 0.92270774, + 1.0 + ], + [ + -1.2290946, + 0.95807064, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.7994735, + 0.57053953, + 1.0 + ], + [ + -0.84233916, + 0.6636242, + 1.0 + ], + [ + -2.5714352, + 0.87382954, + 1.0 + ], + [ + -3.015385, + 0.8965249, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.3739244, + 0.61843336, + 1.0 + ], + [ + 0.4184842, + 0.76101744, + 1.0 + ], + [ + -0.6304034, + 0.94809633, + 1.0 + ], + [ + -0.7494771, + 0.9597989, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.97610384, + 0.7327941, + 1.0 + ], + [ + -0.014398668, + 0.87566924, + 1.0 + ], + [ + -0.052118707, + 0.9924021, + 1.0 + ], + [ + 0.008521295, + 0.99918205, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.5996346, + 0.8233891, + 1.0 + ], + [ + 0.3935372, + 0.9229845, + 1.0 + ], + [ + -0.09391027, + 0.99190533, + 1.0 + ], + [ + -0.03780514, + 0.998876, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.9291275, + 0.9039172, + 1.0 + ], + [ + 0.74847955, + 0.9787055, + 1.0 + ], + [ + -0.16242525, + 0.9931886, + 1.0 + ], + [ + -0.072900325, + 0.9978617, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.3570204, + 0.94836956, + 1.0 + ], + [ + 0.2503319, + 0.99067676, + 1.0 + ], + [ + -0.06907828, + 0.9989688, + 1.0 + ], + [ + -0.017280824, + 0.9998503, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.26804498, + 0.9617563, + 1.0 + ], + [ + -0.30944353, + 0.9933201, + 1.0 + ], + [ + -0.83434904, + 0.9959094, + 1.0 + ], + [ + -0.16132122, + 0.999225, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.45194325, + 0.97071046, + 1.0 + ], + [ + -0.040852666, + 0.99767536, + 1.0 + ], + [ + -0.18153699, + 0.9991148, + 1.0 + ], + [ + -0.03368746, + 0.99983805, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.58231443, + 0.987187, + 1.0 + ], + [ + -0.11279778, + 0.9996504, + 1.0 + ], + [ + -0.007223121, + 0.999977, + 1.0 + ], + [ + -0.0013776413, + 0.9999941, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.27319077, + 0.9664841, + 1.0 + ], + [ + -0.21705957, + 0.9958635, + 1.0 + ], + [ + -0.017606068, + 0.99986523, + 1.0 + ], + [ + -0.0026193615, + 0.9999856, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.21634439, + 0.9787581, + 1.0 + ], + [ + -0.2651225, + 0.9962405, + 1.0 + ], + [ + -0.19124968, + 0.99883384, + 1.0 + ], + [ + -0.042182684, + 0.99975127, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.008343396, + 0.96074563, + 1.0 + ], + [ + -0.06034358, + 0.99365026, + 1.0 + ], + [ + -0.07409725, + 0.99946153, + 1.0 + ], + [ + -0.0167451, + 0.99990094, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.21916604, + 0.97861654, + 1.0 + ], + [ + 0.011656337, + 0.9968962, + 1.0 + ], + [ + 0.000026073394, + 0.9999265, + 1.0 + ], + [ + -0.00011303921, + 0.9999988, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.8516588, + 0.983058, + 1.0 + ], + [ + 0.05317016, + 0.9960106, + 1.0 + ], + [ + 0.0021729777, + 0.9996684, + 1.0 + ], + [ + -0.00008834622, + 0.9999944, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.33492264, + 0.97982824, + 1.0 + ], + [ + -0.023441888, + 0.9941379, + 1.0 + ], + [ + -0.012700134, + 0.9997447, + 1.0 + ], + [ + -0.0015705364, + 0.9999959, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.98577476, + 0.9558412, + 1.0 + ], + [ + 0.11146073, + 0.9861902, + 1.0 + ], + [ + -0.039014447, + 0.9995021, + 1.0 + ], + [ + -0.004039938, + 0.9999989, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 1.4198768, + 0.92014015, + 1.0 + ], + [ + 0.017510591, + 0.95569116, + 1.0 + ], + [ + -0.07619311, + 0.99643916, + 1.0 + ], + [ + -0.0026185187, + 0.99994844, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.02523843, + 0.90478045, + 1.0 + ], + [ + -1.0690092, + 0.92566043, + 1.0 + ], + [ + -0.27379805, + 0.99030024, + 1.0 + ], + [ + -0.012234708, + 0.9998211, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.112236924, + 0.94679946, + 1.0 + ], + [ + -0.619657, + 0.96057886, + 1.0 + ], + [ + -0.9257425, + 0.9906025, + 1.0 + ], + [ + -0.29495418, + 0.99792683, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.21400073, + 0.91529566, + 1.0 + ], + [ + -0.7458604, + 0.93239814, + 1.0 + ], + [ + -1.0380262, + 0.98454165, + 1.0 + ], + [ + -0.59706265, + 0.99349016, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.4457225, + 0.91300154, + 1.0 + ], + [ + -1.336635, + 0.9316419, + 1.0 + ], + [ + -2.2979734, + 0.9655022, + 1.0 + ], + [ + -1.344974, + 0.9845655, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.25027204, + 0.9015076, + 1.0 + ], + [ + -3.4001799, + 0.8766961, + 1.0 + ], + [ + -6.167441, + 0.87958527, + 1.0 + ], + [ + -3.0434742, + 0.9592666, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.8902801, + 0.87916595, + 1.0 + ], + [ + -3.0949912, + 0.86760527, + 1.0 + ], + [ + -5.8532324, + 0.81513554, + 1.0 + ], + [ + -4.114237, + 0.86317617, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.026975641, + 0.99708, + 1.0 + ], + [ + -0.64516276, + 0.9682286, + 1.0 + ], + [ + -2.8310719, + 0.86257124, + 1.0 + ], + [ + -1.7226968, + 0.90401584, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.082811065, + 0.9958276, + 1.0 + ], + [ + 0.029035931, + 0.98919505, + 1.0 + ], + [ + -0.10363939, + 0.9313849, + 1.0 + ], + [ + 0.010599485, + 0.93757856, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.30115137, + 0.995358, + 1.0 + ], + [ + 0.13033178, + 0.99306947, + 1.0 + ], + [ + 0.6179642, + 0.9668538, + 1.0 + ], + [ + 0.8925493, + 0.96109754, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.46534216, + 0.9799625, + 1.0 + ], + [ + 0.38193727, + 0.97773105, + 1.0 + ], + [ + 0.24684222, + 0.9894079, + 1.0 + ], + [ + 0.37070882, + 0.987069, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.28666112, + 0.9715252, + 1.0 + ], + [ + 0.6129641, + 0.9612684, + 1.0 + ], + [ + 1.5519472, + 0.9354917, + 1.0 + ], + [ + 2.0365298, + 0.9501482, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.5976872, + 0.95618755, + 1.0 + ], + [ + 2.4345472, + 0.8642554, + 1.0 + ], + [ + 4.334619, + 0.8086935, + 1.0 + ], + [ + 4.563015, + 0.845538, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.8365076, + 0.92973554, + 1.0 + ], + [ + 4.732671, + 0.7514679, + 1.0 + ], + [ + 8.749074, + 0.67412585, + 1.0 + ], + [ + 9.21404, + 0.70109785, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.420841, + 0.77831614, + 1.0 + ], + [ + 3.1613727, + 0.7190518, + 1.0 + ], + [ + 5.003848, + 0.7066909, + 1.0 + ], + [ + 6.8984137, + 0.7076504, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 3.2887714, + 0.6649921, + 1.0 + ], + [ + 3.2296984, + 0.6861676, + 1.0 + ], + [ + 3.542693, + 0.7290553, + 1.0 + ], + [ + 3.7580287, + 0.7677797, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 2.8353093, + 0.7206155, + 1.0 + ], + [ + 3.213582, + 0.7210075, + 1.0 + ], + [ + 3.050782, + 0.7906282, + 1.0 + ], + [ + 2.910971, + 0.86843896, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.73087895, + 0.9480793, + 1.0 + ], + [ + 1.3393304, + 0.90520597, + 1.0 + ], + [ + 1.8712325, + 0.8813981, + 1.0 + ], + [ + 1.7535455, + 0.9178119, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.2656817, + 0.9784777, + 1.0 + ], + [ + 0.6957168, + 0.93848366, + 1.0 + ], + [ + 1.0034746, + 0.92618793, + 1.0 + ], + [ + 0.6805362, + 0.95591986, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.07101682, + 0.9880643, + 1.0 + ], + [ + 0.3550513, + 0.96534646, + 1.0 + ], + [ + 0.7367733, + 0.952579, + 1.0 + ], + [ + 0.41733772, + 0.96733385, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.021202909, + 0.99511874, + 1.0 + ], + [ + 0.03845068, + 0.98727, + 1.0 + ], + [ + 0.13032648, + 0.9788461, + 1.0 + ], + [ + 0.08247721, + 0.9886881, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.019193187, + 0.99454707, + 1.0 + ], + [ + -0.01639945, + 0.9824369, + 1.0 + ], + [ + -0.034814365, + 0.97299594, + 1.0 + ], + [ + 0.000092838956, + 0.9879655, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.0020077226, + 0.98501676, + 1.0 + ], + [ + -0.01932977, + 0.98477453, + 1.0 + ], + [ + -0.08714962, + 0.9838237, + 1.0 + ], + [ + -0.05911569, + 0.99138635, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.028955147, + 0.9820292, + 1.0 + ], + [ + -0.0068421336, + 0.9893989, + 1.0 + ], + [ + -0.066570766, + 0.99078333, + 1.0 + ], + [ + -0.044429842, + 0.995392, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.032754235, + 0.99281675, + 1.0 + ], + [ + -0.024249062, + 0.9940026, + 1.0 + ], + [ + -0.016773215, + 0.99763656, + 1.0 + ], + [ + -0.00368944, + 0.99956965, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.013820456, + 0.9963605, + 1.0 + ], + [ + -0.023061087, + 0.99682546, + 1.0 + ], + [ + -0.006765129, + 0.99912953, + 1.0 + ], + [ + -0.00025614, + 0.99994946, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.03831513, + 0.9965622, + 1.0 + ], + [ + -0.026358912, + 0.9984241, + 1.0 + ], + [ + -0.0031044614, + 0.99983025, + 1.0 + ], + [ + -0.00004853841, + 0.99999666, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.042360652, + 0.99709636, + 1.0 + ], + [ + -0.02741898, + 0.99857044, + 1.0 + ], + [ + -0.0024811549, + 0.99984425, + 1.0 + ], + [ + -0.0000068021504, + 0.99999624, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.015694104, + 0.99947315, + 1.0 + ], + [ + -0.004300875, + 0.9996656, + 1.0 + ], + [ + -0.00022572257, + 0.99996036, + 1.0 + ], + [ + -2.7103656e-8, + 0.9999994, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.031804975, + 0.9993172, + 1.0 + ], + [ + -0.0036146475, + 0.9998796, + 1.0 + ], + [ + -0.00006266851, + 0.9999905, + 1.0 + ], + [ + -2.0947002e-7, + 0.99999994, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.03432048, + 0.99876255, + 1.0 + ], + [ + -0.0027313528, + 0.999917, + 1.0 + ], + [ + -0.00002901146, + 0.99999934, + 1.0 + ], + [ + -5.272942e-8, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.010446178, + 0.9998674, + 1.0 + ], + [ + -0.0009102314, + 0.9999884, + 1.0 + ], + [ + -0.000010674661, + 0.9999999, + 1.0 + ], + [ + -6.7580764e-8, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.0119908275, + 0.9999285, + 1.0 + ], + [ + -0.00078701315, + 0.9999947, + 1.0 + ], + [ + -0.0000061383644, + 0.99999994, + 1.0 + ], + [ + -3.791176e-8, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.005170042, + 0.99979734, + 1.0 + ], + [ + -0.0004141604, + 0.9999835, + 1.0 + ], + [ + -0.0000034080406, + 0.9999999, + 1.0 + ], + [ + -4.582497e-8, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.00895596, + 0.9998542, + 1.0 + ], + [ + -0.000709496, + 0.99998856, + 1.0 + ], + [ + -0.0000056245453, + 0.99999994, + 1.0 + ], + [ + -5.8625062e-8, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.0146880755, + 0.9997208, + 1.0 + ], + [ + -0.0018232282, + 0.9998647, + 1.0 + ], + [ + -0.00015326608, + 0.9999809, + 1.0 + ], + [ + -0.0000014805827, + 0.9999998, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.022968672, + 0.99913424, + 1.0 + ], + [ + -0.014809832, + 0.99827707, + 1.0 + ], + [ + -0.0020878739, + 0.9997331, + 1.0 + ], + [ + -0.000020184694, + 0.99999744, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.011431702, + 0.99970055, + 1.0 + ], + [ + -0.0039257053, + 0.999323, + 1.0 + ], + [ + -0.00048654518, + 0.9998965, + 1.0 + ], + [ + -0.000004697737, + 0.999999, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.0041034487, + 0.99944717, + 1.0 + ], + [ + -0.00006548014, + 0.99873835, + 1.0 + ], + [ + 0.000027589553, + 0.9997964, + 1.0 + ], + [ + 2.7555294e-7, + 0.999998, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.009381899, + 0.9987364, + 1.0 + ], + [ + -0.00043260615, + 0.9972713, + 1.0 + ], + [ + 0.000017650782, + 0.9995557, + 1.0 + ], + [ + 2.0419672e-7, + 0.99999547, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.008616836, + 0.99971557, + 1.0 + ], + [ + -0.00045231776, + 0.9998694, + 1.0 + ], + [ + -9.984535e-7, + 0.9999807, + 1.0 + ], + [ + 7.740104e-9, + 0.9999998, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.0056563327, + 0.9998474, + 1.0 + ], + [ + -0.00033137825, + 0.99999225, + 1.0 + ], + [ + -0.0000016325387, + 1.0, + 1.0 + ], + [ + -1.0257498e-9, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.014827684, + 0.99960244, + 1.0 + ], + [ + -0.0007938461, + 0.99998146, + 1.0 + ], + [ + -0.00000293517, + 0.99999994, + 1.0 + ], + [ + -8.377253e-10, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.011590649, + 0.9997409, + 1.0 + ], + [ + -0.00065725995, + 0.9999863, + 1.0 + ], + [ + -0.0000025172164, + 0.99999994, + 1.0 + ], + [ + -7.089178e-10, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.11034412, + 0.99728817, + 1.0 + ], + [ + -0.006188652, + 0.9998468, + 1.0 + ], + [ + -0.000022063721, + 0.99999946, + 1.0 + ], + [ + -5.30563e-9, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.11615519, + 0.9962155, + 1.0 + ], + [ + -0.006629864, + 0.9997772, + 1.0 + ], + [ + -0.000026530815, + 0.99999917, + 1.0 + ], + [ + -9.810042e-9, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.2699671, + 0.992111, + 1.0 + ], + [ + -0.01619867, + 0.99955577, + 1.0 + ], + [ + -0.0000889456, + 0.99999845, + 1.0 + ], + [ + -7.3219084e-8, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.84938484, + 0.9749287, + 1.0 + ], + [ + -0.0546363, + 0.99835104, + 1.0 + ], + [ + -0.00042785166, + 0.99998784, + 1.0 + ], + [ + -6.3949227e-7, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -2.9408712, + 0.91166395, + 1.0 + ], + [ + -0.28487697, + 0.99084896, + 1.0 + ], + [ + -0.0041404446, + 0.99987274, + 1.0 + ], + [ + -0.00000840042, + 0.99999976, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -6.1285825, + 0.7972419, + 1.0 + ], + [ + -0.82483685, + 0.96542543, + 1.0 + ], + [ + -0.007825383, + 0.99962455, + 1.0 + ], + [ + -0.0000082233155, + 0.9999996, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.914042, + 0.6662763, + 1.0 + ], + [ + -3.2296083, + 0.84786505, + 1.0 + ], + [ + -0.09797766, + 0.9948829, + 1.0 + ], + [ + -0.000322063, + 0.999986, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.49761, + 0.5991851, + 1.0 + ], + [ + -8.209785, + 0.6332046, + 1.0 + ], + [ + -2.2356362, + 0.88977855, + 1.0 + ], + [ + -0.03691413, + 0.9986572, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -8.16435, + 0.6105177, + 1.0 + ], + [ + -8.482127, + 0.6289409, + 1.0 + ], + [ + -8.195307, + 0.6653821, + 1.0 + ], + [ + -1.5681657, + 0.93180954, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.227832, + 0.6095772, + 1.0 + ], + [ + -9.688233, + 0.62955517, + 1.0 + ], + [ + -9.770227, + 0.6534116, + 1.0 + ], + [ + -7.684848, + 0.7312607, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -9.040249, + 0.61375934, + 1.0 + ], + [ + -9.414042, + 0.64601433, + 1.0 + ], + [ + -9.124609, + 0.6859369, + 1.0 + ], + [ + -6.8619404, + 0.76940507, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -4.1956196, + 0.65328926, + 1.0 + ], + [ + -6.4505258, + 0.6780245, + 1.0 + ], + [ + -6.7652173, + 0.777878, + 1.0 + ], + [ + -5.9136004, + 0.9036394, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.8208507, + 0.76508826, + 1.0 + ], + [ + -1.3313887, + 0.8808544, + 1.0 + ], + [ + -9.083516, + 0.86273944, + 1.0 + ], + [ + -10.492065, + 0.8505087, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.051133215, + 0.93782914, + 1.0 + ], + [ + -0.07260832, + 0.9849166, + 1.0 + ], + [ + -0.9264943, + 0.9826398, + 1.0 + ], + [ + -1.1677287, + 0.97983795, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.07751575, + 0.97048557, + 1.0 + ], + [ + -0.043188777, + 0.9823848, + 1.0 + ], + [ + -0.002534433, + 0.99882054, + 1.0 + ], + [ + -0.00013920956, + 0.9999524, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.07916742, + 0.97018796, + 1.0 + ], + [ + -0.031966496, + 0.97699416, + 1.0 + ], + [ + -0.0035643438, + 0.99836385, + 1.0 + ], + [ + -0.00040987533, + 0.99998116, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.01877379, + 0.9972593, + 1.0 + ], + [ + 0.01205297, + 0.9989108, + 1.0 + ], + [ + -0.0013474375, + 0.99986976, + 1.0 + ], + [ + -0.0007788474, + 0.9999769, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.00081947824, + 0.99965537, + 1.0 + ], + [ + 0.00408295, + 0.9999879, + 1.0 + ], + [ + -0.0004688936, + 0.9999954, + 1.0 + ], + [ + -0.00016395445, + 0.9999985, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.0022904403, + 0.9998439, + 1.0 + ], + [ + -0.0030316855, + 0.9999691, + 1.0 + ], + [ + -0.008458131, + 0.9999591, + 1.0 + ], + [ + -0.0015517423, + 0.99999255, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.008486173, + 0.9998458, + 1.0 + ], + [ + 0.00014019103, + 0.99998724, + 1.0 + ], + [ + -0.0017433885, + 0.99999154, + 1.0 + ], + [ + -0.00032057063, + 0.99999845, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.005678298, + 0.9997919, + 1.0 + ], + [ + -0.0006329326, + 0.99999183, + 1.0 + ], + [ + -0.000014824767, + 1.0, + 1.0 + ], + [ + -0.0000014804741, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.024680974, + 0.9990245, + 1.0 + ], + [ + 0.0004288364, + 0.9999345, + 1.0 + ], + [ + -0.000051545532, + 0.99999934, + 1.0 + ], + [ + -0.000005476217, + 0.99999994, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.0061284457, + 0.9994695, + 1.0 + ], + [ + -0.0008772137, + 0.9999514, + 1.0 + ], + [ + -0.0005471564, + 0.9999964, + 1.0 + ], + [ + -0.00011416538, + 0.99999934, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.019823374, + 0.99714184, + 1.0 + ], + [ + 0.0006233722, + 0.99973315, + 1.0 + ], + [ + -0.00020987287, + 0.99999684, + 1.0 + ], + [ + -0.000045174842, + 0.99999976, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.0055233804, + 0.99932915, + 1.0 + ], + [ + 0.00019314369, + 0.999932, + 1.0 + ], + [ + -0.000004102488, + 0.9999994, + 1.0 + ], + [ + -3.2671397e-7, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.017069608, + 0.99980605, + 1.0 + ], + [ + 0.0011378126, + 0.9999736, + 1.0 + ], + [ + 0.0000045061006, + 0.9999994, + 1.0 + ], + [ + 4.9558377e-8, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.0025704305, + 0.9998648, + 1.0 + ], + [ + -0.00016181398, + 0.9999653, + 1.0 + ], + [ + -0.000009765289, + 0.9999992, + 1.0 + ], + [ + -2.9514266e-7, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.011738374, + 0.99957114, + 1.0 + ], + [ + 0.0013190353, + 0.99990016, + 1.0 + ], + [ + 9.2287684e-7, + 0.99999744, + 1.0 + ], + [ + -0.0000011511228, + 1.0, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + 0.04013104, + 0.99884504, + 1.0 + ], + [ + 0.00018569271, + 0.99945897, + 1.0 + ], + [ + -0.0010192711, + 0.9999544, + 1.0 + ], + [ + -0.000013688198, + 0.9999996, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.02411725, + 0.99782336, + 1.0 + ], + [ + -0.09172129, + 0.9966743, + 1.0 + ], + [ + -0.017824568, + 0.9994446, + 1.0 + ], + [ + -0.00024212734, + 0.9999933, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.0085046105, + 0.9991983, + 1.0 + ], + [ + -0.032236904, + 0.9988923, + 1.0 + ], + [ + -0.03258688, + 0.9995004, + 1.0 + ], + [ + -0.022160465, + 0.99972326, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.047657337, + 0.9974346, + 1.0 + ], + [ + -0.17287616, + 0.9967666, + 1.0 + ], + [ + -1.7228909, + 0.97827595, + 1.0 + ], + [ + -1.5301245, + 0.9807228, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.1092619, + 0.9939699, + 1.0 + ], + [ + -0.28823772, + 0.9942406, + 1.0 + ], + [ + -2.4151208, + 0.9694442, + 1.0 + ], + [ + -2.120977, + 0.973369, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.046760842, + 0.99341387, + 1.0 + ], + [ + -0.46058732, + 0.9879337, + 1.0 + ], + [ + -0.9992375, + 0.97709274, + 1.0 + ], + [ + -0.27791965, + 0.9942543, + 1.0 + ], + [ + 0.0, + 1.0, + 1.0 + ], + [ + -0.055317637, + 0.988727, + 1.0 + ], + [ + -0.7323104, + 0.971684, + 1.0 + ], + [ + -2.933847, + 0.90179116, + 1.0 + ], + [ + -1.3042065, + 0.95250934, + 1.0 + ] + ], + "srgb_value": true + } +} \ No newline at end of file diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs index c7b6c437b..9e6d2094e 100644 --- a/crates/engine/src/camera_profiles.rs +++ b/crates/engine/src/camera_profiles.rs @@ -28,6 +28,7 @@ pub const BUNDLED: &[(&str, &str)] = &[ ("ILCA-99M2", include_str!("../../../assets/camera-profiles/ILCA-99M2.json")), ("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")), ("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")), + ("ILCE-7RM5", include_str!("../../../assets/camera-profiles/ILCE-7RM5.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")),