From 6d412571c7c53b3ef7ce6b28326e5e4e8d8b88a5 Mon Sep 17 00:00:00 2001 From: VimYoung Date: Thu, 3 Sep 2026 16:38:35 +0530 Subject: [PATCH 01/46] Broken: Basic Skeleton of DataFlow in a request --- .../clipboard/clipboard_message_handler.rs | 25 +++++++++++++------ .../messages/portfolio/portfolio_message.rs | 3 +++ .../portfolio/portfolio_message_handler.rs | 6 ++++- editor/src/node_graph_executor.rs | 5 ++++ editor/src/node_graph_executor/runtime.rs | 5 ++++ package-lock.json | 6 +++++ 6 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 package-lock.json diff --git a/editor/src/messages/clipboard/clipboard_message_handler.rs b/editor/src/messages/clipboard/clipboard_message_handler.rs index 73b72b7d6e8..e50888355d5 100644 --- a/editor/src/messages/clipboard/clipboard_message_handler.rs +++ b/editor/src/messages/clipboard/clipboard_message_handler.rs @@ -80,23 +80,31 @@ impl MessageHandler> for Clipboard } } ClipboardMessage::Write { content } => { - let text = match content { + match content { ClipboardContent::Svg(_) => { log::error!("SVG copying is not yet supported"); - return; + // Need to fix this. } ClipboardContent::Image { .. } => { log::error!("Image copying is not yet supported"); - return; } - ClipboardContent::Graphite(graphite) => format!("{CLIPBOARD_PREFIX}{graphite}"), - ClipboardContent::Text(text) => text, - }; - responses.add(FrontendMessage::TriggerClipboardWrite { content: text }); + // THis is where the text/json is getting copied from + // Idea is to rather than copy it only as text, I want to + // move it to the node to get the svg preview and trhen from + // there send both the data as a single write item. + ClipboardContent::Graphite(graphite) => { + let graphite_json = format!("{CLIPBOARD_PREFIX}{graphite}"); + responses.add(PortfolioMessage::RequestSvgTextCopy { graphite_json }); + } + ClipboardContent::Text(text) => { + responses.add(FrontendMessage::TriggerClipboardWrite { content: text }); + } + } } ClipboardMessage::CopyLayers => { if current_tool == &ToolType::Path { + log::debug!("Copying some path"); responses.add(PathToolMessage::Copy); return; } @@ -109,6 +117,7 @@ impl MessageHandler> for Clipboard responses.add(NodeGraphMessage::Copy); return; } + debug!("Copying something else"); let mut buffer = Vec::new(); @@ -209,12 +218,14 @@ impl MessageHandler> for Clipboard } if bytes_to_load.is_empty() { + log::debug!("Bytes to load are empty"); let mut items = items; items.extend(resources.into_iter().map(ClipboardItem::Resource)); if let Some(content) = serialize_clipboard(&items) { responses.add(ClipboardMessage::Write { content }); } } else { + log::debug!("Not empty instance of bytes"); // Load the embedded bytes from the resource storage, then write let load_handle = resource_storage.resources(); responses.add(async move { diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index 8edca5950d3..6378508a78e 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -219,6 +219,9 @@ pub enum PortfolioMessage { /// New sizes for the children at that split node. sizes: Vec, }, + RequestSvgTextCopy { + graphite_json: String, + }, } /// Clone helper for the non-serializable `gdd` payload: a cloned mount message carries no `Gdd`. diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 847662e4f0f..f23d721139b 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -198,7 +198,8 @@ impl MessageHandler> for Portfolio } } - responses.add(PortfolioMessage::GarbageCollectResources); + // responses.add(PortfolioMessage::GarbageCollectResources); + // } PortfolioMessage::AutoSaveDocument { document_id } => { let validate = preferences.validate_storage_round_trip; @@ -1689,6 +1690,9 @@ impl MessageHandler> for Portfolio responses.add(PortfolioMessage::RequestWelcomeScreenButtonsLayout); } } + PortfolioMessage::RequestSvgTextCopy { graphite_json } => { + self.executor.copy_svg_clipboard(graphite_json); + } } } diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index a7a9abf6ebf..c8b5f2dd9dd 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -812,6 +812,11 @@ impl NodeGraphExecutor { Ok(()) } + + pub fn copy_svg_clipboard(&self, graphite_json: String) { + // TODO: See if to propagat ethe error here or move it up. + self.runtime_io.send(GraphRuntimeRequest::CopySvgTextClipboard(graphite_json)); + } } // TODO: Eventually remove this document upgrade code diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index 8ce1ccea575..308dc7ffe2c 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -70,6 +70,7 @@ pub enum GraphRuntimeRequest { GraphUpdate(GraphUpdate), ExecutionRequest(ExecutionRequest), EditorPreferencesUpdate(EditorPreferences), + CopySvgTextClipboard(String), } #[derive(Debug, serde::Serialize, serde::Deserialize)] @@ -182,6 +183,7 @@ impl NodeRuntime { } } GraphRuntimeRequest::EditorPreferencesUpdate(_) => preferences = Some(request), + GraphRuntimeRequest::CopySvgTextClipboard(_) => todo!(), } } @@ -340,6 +342,9 @@ impl NodeRuntime { }); return texture; } + GraphRuntimeRequest::CopySvgTextClipboard(_) => { + todo!(); + } } } None diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000000..cabb54e6f1f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "Graphite", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} From 2581e7d575ef3c4c23af098552088cbc9a3acf71 Mon Sep 17 00:00:00 2001 From: VimYoung Date: Sun, 6 Sep 2026 10:37:47 +0530 Subject: [PATCH 02/46] Broken: Added selected nodes data flow across runtime --- editor/src/messages/portfolio/portfolio_message_handler.rs | 7 ++++++- editor/src/node_graph_executor.rs | 6 +++--- editor/src/node_graph_executor/runtime.rs | 4 ++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index f23d721139b..52b033727f5 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -1691,7 +1691,12 @@ impl MessageHandler> for Portfolio } } PortfolioMessage::RequestSvgTextCopy { graphite_json } => { - self.executor.copy_svg_clipboard(graphite_json); + if let Some(active_document) = self.active_document() { + let selected_nodes: Vec = active_document.network_interface.shallowest_unique_layers(&[]).map(|layer| layer.to_node()).collect(); + self.executor.copy_svg_clipboard(graphite_json, selected_nodes); + } else { + self.executor.copy_svg_clipboard(graphite_json, Vec::new()); + } } } } diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index c8b5f2dd9dd..c672aaa550a 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -52,6 +52,7 @@ pub enum NodeGraphUpdate { CompilationResponse(CompilationResponse), EyedropperPreview(Raster), NodeGraphUpdateMessage(NodeGraphUpdateMessage), + SvgTextCopyClipboard(String, String), } #[derive(Debug, Default)] @@ -813,9 +814,8 @@ impl NodeGraphExecutor { Ok(()) } - pub fn copy_svg_clipboard(&self, graphite_json: String) { - // TODO: See if to propagat ethe error here or move it up. - self.runtime_io.send(GraphRuntimeRequest::CopySvgTextClipboard(graphite_json)); + pub fn copy_svg_clipboard(&self, graphite_json: String, selected_nodes: Vec) { + self.runtime_io.send(GraphRuntimeRequest::CopySvgTextClipboard(graphite_json, selected_nodes)); } } diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index 308dc7ffe2c..055f2ea05a7 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -109,6 +109,10 @@ impl InternalNodeGraphUpdateSender { fn send_eyedropper_preview(&self, raster: Raster) { self.0.send(NodeGraphUpdate::EyedropperPreview(raster)).expect("Failed to send response") } + + fn send_svg_text_clipboard(&self, svg_string: String, text_string: String) { + self.0.send(NodeGraphUpdate::SvgTextCopyClipboard(svg_string, text_string)).expect("Failed to send response") + } } impl NodeGraphUpdateSender for InternalNodeGraphUpdateSender { From b897e65e6c86c03c2f7be40d713e1411570920e3 Mon Sep 17 00:00:00 2001 From: VimYoung Date: Sun, 6 Sep 2026 10:38:22 +0530 Subject: [PATCH 03/46] Fix: Extraction of nodes into svg and sending message --- editor/src/node_graph_executor/runtime.rs | 126 ++++++++++++++++++++-- 1 file changed, 120 insertions(+), 6 deletions(-) diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index 055f2ea05a7..72258f25434 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -70,7 +70,7 @@ pub enum GraphRuntimeRequest { GraphUpdate(GraphUpdate), ExecutionRequest(ExecutionRequest), EditorPreferencesUpdate(EditorPreferences), - CopySvgTextClipboard(String), + CopySvgTextClipboard(String, Vec), } #[derive(Debug, serde::Serialize, serde::Deserialize)] @@ -167,6 +167,7 @@ impl NodeRuntime { let mut graph = None; let mut eyedropper = None; let mut execution = None; + let mut svg_clipboard = None; for request in self.receiver.try_iter() { match request { GraphRuntimeRequest::GraphUpdate(_) => graph = Some(request), @@ -187,7 +188,7 @@ impl NodeRuntime { } } GraphRuntimeRequest::EditorPreferencesUpdate(_) => preferences = Some(request), - GraphRuntimeRequest::CopySvgTextClipboard(_) => todo!(), + GraphRuntimeRequest::CopySvgTextClipboard(..) => svg_clipboard = Some(request), } } @@ -199,7 +200,7 @@ impl NodeRuntime { eyedropper.render_config.pointer = execution.render_config.pointer; } - let requests = [preferences, graph, eyedropper, execution].into_iter().flatten(); + let requests = [preferences, graph, eyedropper, execution, svg_clipboard].into_iter().flatten(); for request in requests { match request { @@ -346,9 +347,91 @@ impl NodeRuntime { }); return texture; } - GraphRuntimeRequest::CopySvgTextClipboard(_) => { - todo!(); - } + GraphRuntimeRequest::CopySvgTextClipboard(text_string_clipboard, selected_node_ids) => { + let mut combined_graphics = List::::new(); + + for monitor_node_path in &self.monitor_nodes { + // Skip inspect monitor node if active + if self.inspect_state.as_ref().is_some_and(|state| monitor_node_path.last().copied() == Some(state.monitor_node)) { + continue; + } + + let Some(parent_network_node_id) = monitor_node_path.len().checked_sub(2).and_then(|index| monitor_node_path.get(index)).copied() else { + continue; + }; + + if selected_node_ids.contains(&parent_network_node_id) { + // Introspect using the full monitor node path + if let Ok(introspected_data) = self.executor.introspect(monitor_node_path) { + if let Some(io) = introspected_data.downcast_ref::>>() { + combined_graphics.extend(io.output.clone()); + } else if let Some(io) = introspected_data.downcast_ref::>>() { + combined_graphics.push(io.output.clone()); + } + } + } + } + + if combined_graphics.is_empty() { + self.sender.send_svg_text_clipboard(String::new(), text_string_clipboard); + return None; + } + + let bounds = graphene_std::renderer::graphic_list_bounding_box(&combined_graphics, DAffine2::IDENTITY); + let raw_bounds = match bounds { + RenderBoundingBox::Rectangle(bounds) if (bounds[1] - bounds[0]) != DVec2::ZERO => bounds, + _ => [DVec2::ZERO, DVec2::ONE], + }; + + let footprint = Footprint { + transform: DAffine2::from_translation(DVec2::new(raw_bounds[0].x, raw_bounds[0].y)), + resolution: UVec2::new((raw_bounds[1].x - raw_bounds[0].x).abs().ceil() as u32, (raw_bounds[1].y - raw_bounds[0].y).abs().ceil() as u32).max(UVec2::ONE), + quality: RenderQuality::Full, + }; + + let render_params = RenderParams { + footprint, + thumbnail: false, + ..Default::default() + }; + let mut render = SvgRender::new(); + combined_graphics.render_svg(&mut render, &render_params); + render.format_svg(raw_bounds[0], raw_bounds[1]); + + self.sender.send_svg_text_clipboard(render.svg.to_svg_string(), text_string_clipboard); + } // // self.thumbnail_renders.retain(|id, _| self.monitor_nodes.iter().any(|monitor_node_path| monitor_node_path.contains(id))); + // // let mut uninspected_nodes = Vec::new(); + // // for monitor_node_path in &self.monitor_nodes { + // // if !self + // // .inspect_state + // // .as_ref() + // // .is_some_and(|inspect_state| monitor_node_path.last().copied() == Some(inspect_state.monitor_node)) + // // { + // // uninspected_nodes.push(monitor_node_path); + // // } + // // } + // for node in self.monitor_nodes.iter().flatten() { + // if selected_node_ids.contains(node) {} + // } + // for monitor_node_path in &self.monitor_nodes { + // // The monitor nodes are located within a document node, and are thus children in that network, so this gets the parent document node's ID + // let Some(parent_network_node_id) = monitor_node_path.len().checked_sub(2).and_then(|index| monitor_node_path.get(index)).copied() else { + // warn!("Monitor node has invalid node id"); + // continue; + // }; + // // Extract the monitor node's stored `Graphic` data + // let Ok(introspected_data) = self.executor.introspect(monitor_node_path) else { + // // TODO: Fix the root of the issue causing the spam of this warning (this at least temporarily disables it in release builds) + // #[cfg(debug_assertions)] + // warn!("Failed to introspect monitor node {}", self.executor.introspect(monitor_node_path).unwrap_err()); + // continue; + // }; + // if let Some(io) = introspected_data.downcast_ref::>>() { + // let bounds = graphene_std::renderer::graphic_list_bounding_box(&io.output, DAffine2::IDENTITY); + // self.svg_clipboard_produce(text_string_clipboard, &io.output, bounds); + // } + // } + // } } } None @@ -538,6 +621,37 @@ impl NodeRuntime { *old_thumbnail_svg = new_thumbnail_svg; } } + + fn svg_clipboard_produce(&self, text_string_clipboard: String, graphic: &impl Render, bounds: RenderBoundingBox) { + let raw_bounds = match bounds { + RenderBoundingBox::Rectangle(bounds) if (bounds[1] - bounds[0]) != DVec2::ZERO => bounds, + _ => [DVec2::ZERO, DVec2::ONE], + }; + let bounds = expand_to_thumbnail_aspect(raw_bounds); + let new_thumbnail_svg = { + let footprint = Footprint { + transform: DAffine2::from_translation(DVec2::new(bounds[0].x, bounds[0].y)), + resolution: UVec2::new((bounds[1].x - bounds[0].x).abs() as u32, (bounds[1].y - bounds[0].y).abs() as u32), + quality: RenderQuality::Full, + }; + + // Render the thumbnail from a `Graphic` into an SVG string + let render_params = RenderParams { + footprint, + thumbnail: true, + ..Default::default() + }; + let mut render = SvgRender::new(); + graphic.render_svg(&mut render, &render_params); + + // And give the SVG a viewbox and outer ... wrapper tag + render.format_svg(bounds[0], bounds[1]); + + render.svg + }; + + self.sender.send_svg_text_clipboard(new_thumbnail_svg.to_svg_string(), text_string_clipboard); + } } /// Returns the union of the artboards' clipping rectangles, used as the thumbnail bounds for an artboard layer so the From eedb39ecb76b962ebd9e00eed70ba4fb9d814331 Mon Sep 17 00:00:00 2001 From: VimYoung Date: Sun, 6 Sep 2026 12:07:20 +0530 Subject: [PATCH 04/46] Fix: Removed comments and unused fn --- editor/src/node_graph_executor/runtime.rs | 65 +---------------------- 1 file changed, 1 insertion(+), 64 deletions(-) diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index 72258f25434..cf32b9d8e06 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -399,39 +399,7 @@ impl NodeRuntime { render.format_svg(raw_bounds[0], raw_bounds[1]); self.sender.send_svg_text_clipboard(render.svg.to_svg_string(), text_string_clipboard); - } // // self.thumbnail_renders.retain(|id, _| self.monitor_nodes.iter().any(|monitor_node_path| monitor_node_path.contains(id))); - // // let mut uninspected_nodes = Vec::new(); - // // for monitor_node_path in &self.monitor_nodes { - // // if !self - // // .inspect_state - // // .as_ref() - // // .is_some_and(|inspect_state| monitor_node_path.last().copied() == Some(inspect_state.monitor_node)) - // // { - // // uninspected_nodes.push(monitor_node_path); - // // } - // // } - // for node in self.monitor_nodes.iter().flatten() { - // if selected_node_ids.contains(node) {} - // } - // for monitor_node_path in &self.monitor_nodes { - // // The monitor nodes are located within a document node, and are thus children in that network, so this gets the parent document node's ID - // let Some(parent_network_node_id) = monitor_node_path.len().checked_sub(2).and_then(|index| monitor_node_path.get(index)).copied() else { - // warn!("Monitor node has invalid node id"); - // continue; - // }; - // // Extract the monitor node's stored `Graphic` data - // let Ok(introspected_data) = self.executor.introspect(monitor_node_path) else { - // // TODO: Fix the root of the issue causing the spam of this warning (this at least temporarily disables it in release builds) - // #[cfg(debug_assertions)] - // warn!("Failed to introspect monitor node {}", self.executor.introspect(monitor_node_path).unwrap_err()); - // continue; - // }; - // if let Some(io) = introspected_data.downcast_ref::>>() { - // let bounds = graphene_std::renderer::graphic_list_bounding_box(&io.output, DAffine2::IDENTITY); - // self.svg_clipboard_produce(text_string_clipboard, &io.output, bounds); - // } - // } - // } + } } } None @@ -621,37 +589,6 @@ impl NodeRuntime { *old_thumbnail_svg = new_thumbnail_svg; } } - - fn svg_clipboard_produce(&self, text_string_clipboard: String, graphic: &impl Render, bounds: RenderBoundingBox) { - let raw_bounds = match bounds { - RenderBoundingBox::Rectangle(bounds) if (bounds[1] - bounds[0]) != DVec2::ZERO => bounds, - _ => [DVec2::ZERO, DVec2::ONE], - }; - let bounds = expand_to_thumbnail_aspect(raw_bounds); - let new_thumbnail_svg = { - let footprint = Footprint { - transform: DAffine2::from_translation(DVec2::new(bounds[0].x, bounds[0].y)), - resolution: UVec2::new((bounds[1].x - bounds[0].x).abs() as u32, (bounds[1].y - bounds[0].y).abs() as u32), - quality: RenderQuality::Full, - }; - - // Render the thumbnail from a `Graphic` into an SVG string - let render_params = RenderParams { - footprint, - thumbnail: true, - ..Default::default() - }; - let mut render = SvgRender::new(); - graphic.render_svg(&mut render, &render_params); - - // And give the SVG a viewbox and outer ... wrapper tag - render.format_svg(bounds[0], bounds[1]); - - render.svg - }; - - self.sender.send_svg_text_clipboard(new_thumbnail_svg.to_svg_string(), text_string_clipboard); - } } /// Returns the union of the artboards' clipping rectangles, used as the thumbnail bounds for an artboard layer so the From 32fbb6481308f67dccd06a34c1a25b909cfc4dda Mon Sep 17 00:00:00 2001 From: VimYoung Date: Sun, 6 Sep 2026 13:41:21 +0530 Subject: [PATCH 05/46] Add: First support of svg compatible copy pasting --- editor/src/messages/frontend/frontend_message.rs | 4 ++++ editor/src/node_graph_executor.rs | 8 +++++++- frontend/src/managers/clipboard.ts | 15 +++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 29daaa269b9..1336320e49d 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -153,6 +153,10 @@ pub enum FrontendMessage { TriggerClipboardWrite { content: String, }, + TriggerClipboardSvgWrite { + svg_string: String, + graphite_json: String, + }, TriggerSelectionRead { cut: bool, }, diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index c672aaa550a..ea78e38ed92 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -467,6 +467,10 @@ impl NodeGraphExecutor { responses.add(EyedropperToolMessage::PreviewImage { data, width, height }); } NodeGraphUpdate::NodeGraphUpdateMessage(_) => {} + NodeGraphUpdate::SvgTextCopyClipboard(svg_string, graphite_json) => { + debug!("svg: {}", svg_string); + responses.add(FrontendMessage::TriggerClipboardSvgWrite { svg_string, graphite_json }); + } } } @@ -815,7 +819,9 @@ impl NodeGraphExecutor { } pub fn copy_svg_clipboard(&self, graphite_json: String, selected_nodes: Vec) { - self.runtime_io.send(GraphRuntimeRequest::CopySvgTextClipboard(graphite_json, selected_nodes)); + self.runtime_io + .send(GraphRuntimeRequest::CopySvgTextClipboard(graphite_json, selected_nodes)) + .expect("Failed to send runtime request"); } } diff --git a/frontend/src/managers/clipboard.ts b/frontend/src/managers/clipboard.ts index 54dcda5eb2e..60ccecb145a 100644 --- a/frontend/src/managers/clipboard.ts +++ b/frontend/src/managers/clipboard.ts @@ -23,6 +23,20 @@ export function createClipboardManager(subscriptions: SubscriptionsRouter, edito subscriptions.subscribeFrontendMessage("TriggerSelectionWrite", async (data) => { insertAtCaret(data.content); }); + + subscriptions.subscribeFrontendMessage("TriggerClipboardSvgWrite", (data) => { + // Adopted from https://developer.mozilla.org/en-US/docs/Web/API/ClipboardItem#browser_compatibility + if (ClipboardItem.supports("image/svg+xml")) { + navigator.clipboard?.write?.([ + new ClipboardItem({ + "image/svg+xml": data.svg_string, + "text/plain": data.graphite_json, + }), + ]); + } else { + navigator.clipboard?.writeText?.(data.graphite_json); + } + }); } export function destroyClipboardManager() { @@ -30,6 +44,7 @@ export function destroyClipboardManager() { if (!subscriptions) return; subscriptions.unsubscribeFrontendMessage("TriggerClipboardWrite"); + subscriptions.unsubscribeFrontendMessage("TriggerClipboardSvgWrite"); subscriptions.unsubscribeFrontendMessage("TriggerSelectionRead"); subscriptions.unsubscribeFrontendMessage("TriggerSelectionWrite"); } From a3dabc09627e379c0e736e335e60cd5eca93a202 Mon Sep 17 00:00:00 2001 From: VimYoung Date: Sun, 6 Sep 2026 14:52:23 +0530 Subject: [PATCH 06/46] Fix: Desktop copy intercept fix and unnecessary edits removal --- desktop/wrapper/src/intercept_frontend_message.rs | 3 +++ .../src/messages/clipboard/clipboard_message_handler.rs | 8 -------- .../src/messages/portfolio/portfolio_message_handler.rs | 3 +-- editor/src/node_graph_executor.rs | 1 - 4 files changed, 4 insertions(+), 11 deletions(-) diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index 6d13e4bc4c6..c2155077678 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -119,6 +119,9 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD FrontendMessage::TriggerClipboardWrite { content } => { dispatcher.respond(DesktopFrontendMessage::ClipboardWrite { content }); } + FrontendMessage::TriggerClipboardSvgWrite { graphite_json, .. } => { + dispatcher.respond(DesktopFrontendMessage::ClipboardWrite { content: graphite_json }); + } FrontendMessage::WindowPointerLock => { dispatcher.respond(DesktopFrontendMessage::PointerLock); } diff --git a/editor/src/messages/clipboard/clipboard_message_handler.rs b/editor/src/messages/clipboard/clipboard_message_handler.rs index e50888355d5..c8ba8544cdc 100644 --- a/editor/src/messages/clipboard/clipboard_message_handler.rs +++ b/editor/src/messages/clipboard/clipboard_message_handler.rs @@ -88,10 +88,6 @@ impl MessageHandler> for Clipboard ClipboardContent::Image { .. } => { log::error!("Image copying is not yet supported"); } - // THis is where the text/json is getting copied from - // Idea is to rather than copy it only as text, I want to - // move it to the node to get the svg preview and trhen from - // there send both the data as a single write item. ClipboardContent::Graphite(graphite) => { let graphite_json = format!("{CLIPBOARD_PREFIX}{graphite}"); responses.add(PortfolioMessage::RequestSvgTextCopy { graphite_json }); @@ -104,7 +100,6 @@ impl MessageHandler> for Clipboard ClipboardMessage::CopyLayers => { if current_tool == &ToolType::Path { - log::debug!("Copying some path"); responses.add(PathToolMessage::Copy); return; } @@ -117,7 +112,6 @@ impl MessageHandler> for Clipboard responses.add(NodeGraphMessage::Copy); return; } - debug!("Copying something else"); let mut buffer = Vec::new(); @@ -218,14 +212,12 @@ impl MessageHandler> for Clipboard } if bytes_to_load.is_empty() { - log::debug!("Bytes to load are empty"); let mut items = items; items.extend(resources.into_iter().map(ClipboardItem::Resource)); if let Some(content) = serialize_clipboard(&items) { responses.add(ClipboardMessage::Write { content }); } } else { - log::debug!("Not empty instance of bytes"); // Load the embedded bytes from the resource storage, then write let load_handle = resource_storage.resources(); responses.add(async move { diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 52b033727f5..3acb95b161d 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -198,8 +198,7 @@ impl MessageHandler> for Portfolio } } - // responses.add(PortfolioMessage::GarbageCollectResources); - // + responses.add(PortfolioMessage::GarbageCollectResources); } PortfolioMessage::AutoSaveDocument { document_id } => { let validate = preferences.validate_storage_round_trip; diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index ea78e38ed92..f55d5090f4c 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -468,7 +468,6 @@ impl NodeGraphExecutor { } NodeGraphUpdate::NodeGraphUpdateMessage(_) => {} NodeGraphUpdate::SvgTextCopyClipboard(svg_string, graphite_json) => { - debug!("svg: {}", svg_string); responses.add(FrontendMessage::TriggerClipboardSvgWrite { svg_string, graphite_json }); } } From c06bfacfb76230e7107b6153ede2c986fd5f2638 Mon Sep 17 00:00:00 2001 From: VimYoung Date: Sun, 6 Sep 2026 14:59:29 +0530 Subject: [PATCH 07/46] Fix: removed package-lock.json --- package-lock.json | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index cabb54e6f1f..00000000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Graphite", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} From e96a66e991452c79b9fcd080c0d47e6621c12676 Mon Sep 17 00:00:00 2001 From: VimYoung Date: Mon, 7 Sep 2026 18:12:07 +0530 Subject: [PATCH 08/46] Fix: added todo and removed Svg branch from clipboard data type --- .../wrapper/src/intercept_frontend_message.rs | 1 + .../clipboard/clipboard_message_handler.rs | 28 ++++++++----------- .../src/messages/clipboard/utility_types.rs | 1 - 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index c2155077678..0e64204e6f2 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -120,6 +120,7 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD dispatcher.respond(DesktopFrontendMessage::ClipboardWrite { content }); } FrontendMessage::TriggerClipboardSvgWrite { graphite_json, .. } => { + // TODO: Add support for svg after clipboard API change in desktop. dispatcher.respond(DesktopFrontendMessage::ClipboardWrite { content: graphite_json }); } FrontendMessage::WindowPointerLock => { diff --git a/editor/src/messages/clipboard/clipboard_message_handler.rs b/editor/src/messages/clipboard/clipboard_message_handler.rs index c8ba8544cdc..c5fc344dc4e 100644 --- a/editor/src/messages/clipboard/clipboard_message_handler.rs +++ b/editor/src/messages/clipboard/clipboard_message_handler.rs @@ -79,24 +79,18 @@ impl MessageHandler> for Clipboard responses.add(ClipboardMessage::CopyLayers); } } - ClipboardMessage::Write { content } => { - match content { - ClipboardContent::Svg(_) => { - log::error!("SVG copying is not yet supported"); - // Need to fix this. - } - ClipboardContent::Image { .. } => { - log::error!("Image copying is not yet supported"); - } - ClipboardContent::Graphite(graphite) => { - let graphite_json = format!("{CLIPBOARD_PREFIX}{graphite}"); - responses.add(PortfolioMessage::RequestSvgTextCopy { graphite_json }); - } - ClipboardContent::Text(text) => { - responses.add(FrontendMessage::TriggerClipboardWrite { content: text }); - } + ClipboardMessage::Write { content } => match content { + ClipboardContent::Image { .. } => { + log::error!("Image copying is not yet supported"); } - } + ClipboardContent::Graphite(graphite) => { + let graphite_json = format!("{CLIPBOARD_PREFIX}{graphite}"); + responses.add(PortfolioMessage::RequestSvgTextCopy { graphite_json }); + } + ClipboardContent::Text(text) => { + responses.add(FrontendMessage::TriggerClipboardWrite { content: text }); + } + }, ClipboardMessage::CopyLayers => { if current_tool == &ToolType::Path { diff --git a/editor/src/messages/clipboard/utility_types.rs b/editor/src/messages/clipboard/utility_types.rs index a4298222ca6..f47c14bc5f5 100644 --- a/editor/src/messages/clipboard/utility_types.rs +++ b/editor/src/messages/clipboard/utility_types.rs @@ -18,7 +18,6 @@ pub enum ClipboardContentRaw { pub enum ClipboardContent { Graphite(String), Text(String), - Svg(String), Image { data: Vec, width: u32, height: u32 }, } From 04dcb8494c11e3fbde966da3657774157f828e22 Mon Sep 17 00:00:00 2001 From: VimYoung Date: Mon, 7 Sep 2026 18:15:34 +0530 Subject: [PATCH 09/46] Fix: applied fix.patch to avoid message discarding --- .../clipboard/clipboard_message_handler.rs | 1 + .../document/document_message_handler.rs | 2 +- .../shapes/ellipse_shape.rs | 2 +- .../tool/tool_messages/artboard_tool.rs | 2 +- .../messages/tool/tool_messages/fill_tool.rs | 2 +- editor/src/node_graph_executor/runtime.rs | 2 +- editor/src/test_utils.rs | 17 +++++++++-------- 7 files changed, 15 insertions(+), 13 deletions(-) diff --git a/editor/src/messages/clipboard/clipboard_message_handler.rs b/editor/src/messages/clipboard/clipboard_message_handler.rs index c5fc344dc4e..5041830c884 100644 --- a/editor/src/messages/clipboard/clipboard_message_handler.rs +++ b/editor/src/messages/clipboard/clipboard_message_handler.rs @@ -524,6 +524,7 @@ mod test { .into_iter() .find_map(|message| match message { FrontendMessage::TriggerClipboardWrite { content } => Some(content), + FrontendMessage::TriggerClipboardSvgWrite { graphite_json, .. } => Some(graphite_json), _ => None, }) .expect("copying layers should write a payload to the clipboard") diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 5ed619fd178..d00edf33a2f 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -4317,7 +4317,7 @@ mod document_message_handler_tests { }) .await; - let instrumented = editor.eval_graph().await.unwrap(); + let (instrumented, _) = editor.eval_graph().await.unwrap(); // The emptiness guards keep these assertions honest: a wrong `Output` type on `grab_all_input` yields no records at all, which would otherwise pass without checking anything let base_lengths: Vec = instrumented diff --git a/editor/src/messages/tool/common_functionality/shapes/ellipse_shape.rs b/editor/src/messages/tool/common_functionality/shapes/ellipse_shape.rs index 5bc4745c879..945fd4a703a 100644 --- a/editor/src/messages/tool/common_functionality/shapes/ellipse_shape.rs +++ b/editor/src/messages/tool/common_functionality/shapes/ellipse_shape.rs @@ -64,7 +64,7 @@ mod test_ellipse { async fn get_ellipse(editor: &mut EditorTestUtils) -> Vec { let instrumented = match editor.eval_graph().await { - Ok(instrumented) => instrumented, + Ok((instrumented, _)) => instrumented, Err(e) => panic!("Failed to evaluate graph: {e}"), }; diff --git a/editor/src/messages/tool/tool_messages/artboard_tool.rs b/editor/src/messages/tool/tool_messages/artboard_tool.rs index 5ba52683c55..73451bba4b9 100644 --- a/editor/src/messages/tool/tool_messages/artboard_tool.rs +++ b/editor/src/messages/tool/tool_messages/artboard_tool.rs @@ -575,7 +575,7 @@ mod test_artboard { use graphene_std::list::List; async fn get_artboards(editor: &mut EditorTestUtils) -> List { - let instrumented = match editor.eval_graph().await { + let (instrumented, _) = match editor.eval_graph().await { Ok(instrumented) => instrumented, Err(e) => panic!("Failed to evaluate graph: {e}"), }; diff --git a/editor/src/messages/tool/tool_messages/fill_tool.rs b/editor/src/messages/tool/tool_messages/fill_tool.rs index e0755f0d6c8..272ad1c1255 100644 --- a/editor/src/messages/tool/tool_messages/fill_tool.rs +++ b/editor/src/messages/tool/tool_messages/fill_tool.rs @@ -269,7 +269,7 @@ mod test_fill { // The Fill tool writes solid colors, whose stored values the input monitor records as `Item` wires async fn get_fills(editor: &mut EditorTestUtils) -> Vec> { - let instrumented = match editor.eval_graph().await { + let (instrumented, _) = match editor.eval_graph().await { Ok(instrumented) => instrumented, Err(e) => panic!("Failed to evaluate graph: {e}"), }; diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index cf32b9d8e06..a9e03c91438 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -200,7 +200,7 @@ impl NodeRuntime { eyedropper.render_config.pointer = execution.render_config.pointer; } - let requests = [preferences, graph, eyedropper, execution, svg_clipboard].into_iter().flatten(); + let requests = [preferences, graph, eyedropper, svg_clipboard, execution].into_iter().flatten(); for request in requests { match request { diff --git a/editor/src/test_utils.rs b/editor/src/test_utils.rs index 57c567b82c8..118bf2f83ae 100644 --- a/editor/src/test_utils.rs +++ b/editor/src/test_utils.rs @@ -33,9 +33,9 @@ impl EditorTestUtils { Self { editor, runtime } } - pub fn eval_graph<'a>(&'a mut self) -> impl std::future::Future> + 'a { + pub fn eval_graph<'a>(&'a mut self) -> impl std::future::Future), String>> + 'a { // An inner function is required since async functions in traits are a bit weird - async fn run<'a>(editor: &'a mut Editor, runtime: &'a mut NodeRuntime) -> Result { + async fn run<'a>(editor: &'a mut Editor, runtime: &'a mut NodeRuntime) -> Result<(Instrumented, Vec), String> { let portfolio = &mut editor.dispatcher.message_handlers.portfolio_message_handler; let document_id = portfolio.active_document_id.unwrap(); let (executor, documents) = (&mut portfolio.executor, &mut portfolio.documents); @@ -55,24 +55,25 @@ impl EditorTestUtils { if let Err(e) = editor.poll_node_graph_evaluation(&mut messages) { return Err(format!("Graph should render\n\n{e}")); } - let frontend_messages = messages.into_iter().flat_map(|message| editor.handle_message(message)); + let frontend_messages = messages.into_iter().flat_map(|message| editor.handle_message(message)).collect::>(); - for message in frontend_messages { + for message in &frontend_messages { message.check_node_graph_error(); } - Ok(instrumented) + Ok((instrumented, frontend_messages)) } run(&mut self.editor, &mut self.runtime) } pub async fn handle_message(&mut self, message: impl Into) -> Vec { - let frontend_messages_from_msg = self.editor.handle_message(message); + let mut frontend_messages_from_msg = self.editor.handle_message(message); // Required to process any buffered messages - if let Err(e) = self.eval_graph().await { - panic!("Failed to evaluate graph: {e}"); + match self.eval_graph().await { + Ok((_, new_messages)) => frontend_messages_from_msg.extend(new_messages), + Err(e) => panic!("Failed to evaluate graph: {e}"), } // Sweep the network interface's structural invariants so any desync fails at the message that caused it From 9ef419f7b4ce5d6d743c090aba6960ef14320bed Mon Sep 17 00:00:00 2001 From: VimYoung Date: Mon, 7 Sep 2026 18:35:11 +0530 Subject: [PATCH 10/46] Fix: renamed TriggerClipboardSvgWrite to TriggerClipboardSvgAndJsonWrite --- desktop/wrapper/src/intercept_frontend_message.rs | 2 +- editor/src/messages/clipboard/clipboard_message_handler.rs | 2 +- editor/src/messages/frontend/frontend_message.rs | 2 +- editor/src/node_graph_executor.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index 0e64204e6f2..c53efcd89d3 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -119,7 +119,7 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD FrontendMessage::TriggerClipboardWrite { content } => { dispatcher.respond(DesktopFrontendMessage::ClipboardWrite { content }); } - FrontendMessage::TriggerClipboardSvgWrite { graphite_json, .. } => { + FrontendMessage::TriggerClipboardSvgAndJsonWrite { graphite_json, .. } => { // TODO: Add support for svg after clipboard API change in desktop. dispatcher.respond(DesktopFrontendMessage::ClipboardWrite { content: graphite_json }); } diff --git a/editor/src/messages/clipboard/clipboard_message_handler.rs b/editor/src/messages/clipboard/clipboard_message_handler.rs index 5041830c884..ff3e3f9087d 100644 --- a/editor/src/messages/clipboard/clipboard_message_handler.rs +++ b/editor/src/messages/clipboard/clipboard_message_handler.rs @@ -524,7 +524,7 @@ mod test { .into_iter() .find_map(|message| match message { FrontendMessage::TriggerClipboardWrite { content } => Some(content), - FrontendMessage::TriggerClipboardSvgWrite { graphite_json, .. } => Some(graphite_json), + FrontendMessage::TriggerClipboardSvgAndJsonWrite { graphite_json, .. } => Some(graphite_json), _ => None, }) .expect("copying layers should write a payload to the clipboard") diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 1336320e49d..765366bf7da 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -153,7 +153,7 @@ pub enum FrontendMessage { TriggerClipboardWrite { content: String, }, - TriggerClipboardSvgWrite { + TriggerClipboardSvgAndJsonWrite { svg_string: String, graphite_json: String, }, diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index f55d5090f4c..b02af25ba54 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -468,7 +468,7 @@ impl NodeGraphExecutor { } NodeGraphUpdate::NodeGraphUpdateMessage(_) => {} NodeGraphUpdate::SvgTextCopyClipboard(svg_string, graphite_json) => { - responses.add(FrontendMessage::TriggerClipboardSvgWrite { svg_string, graphite_json }); + responses.add(FrontendMessage::TriggerClipboardSvgAndJsonWrite { svg_string, graphite_json }); } } } From 491a7ac0f5fe2ec9c4031e2553f66dd79c82634c Mon Sep 17 00:00:00 2001 From: VimYoung Date: Mon, 7 Sep 2026 18:38:52 +0530 Subject: [PATCH 11/46] Fix: converted SvgTextCopyClipboard from typle to struct variant --- editor/src/node_graph_executor.rs | 4 ++-- editor/src/node_graph_executor/runtime.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index b02af25ba54..7f2cb5f481b 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -52,7 +52,7 @@ pub enum NodeGraphUpdate { CompilationResponse(CompilationResponse), EyedropperPreview(Raster), NodeGraphUpdateMessage(NodeGraphUpdateMessage), - SvgTextCopyClipboard(String, String), + SvgTextCopyClipboard { svg_string: String, graphite_json: String }, } #[derive(Debug, Default)] @@ -467,7 +467,7 @@ impl NodeGraphExecutor { responses.add(EyedropperToolMessage::PreviewImage { data, width, height }); } NodeGraphUpdate::NodeGraphUpdateMessage(_) => {} - NodeGraphUpdate::SvgTextCopyClipboard(svg_string, graphite_json) => { + NodeGraphUpdate::SvgTextCopyClipboard { svg_string, graphite_json } => { responses.add(FrontendMessage::TriggerClipboardSvgAndJsonWrite { svg_string, graphite_json }); } } diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index a9e03c91438..67ae24be7d6 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -110,8 +110,8 @@ impl InternalNodeGraphUpdateSender { self.0.send(NodeGraphUpdate::EyedropperPreview(raster)).expect("Failed to send response") } - fn send_svg_text_clipboard(&self, svg_string: String, text_string: String) { - self.0.send(NodeGraphUpdate::SvgTextCopyClipboard(svg_string, text_string)).expect("Failed to send response") + fn send_svg_text_clipboard(&self, svg_string: String, graphite_json: String) { + self.0.send(NodeGraphUpdate::SvgTextCopyClipboard { svg_string, graphite_json }).expect("Failed to send response") } } From 3f07cbced2e160573bad0365630e8cd5c9c47506 Mon Sep 17 00:00:00 2001 From: VimYoung Date: Mon, 7 Sep 2026 19:03:05 +0530 Subject: [PATCH 12/46] Fix: Fix trigger name in clipboard.ts --- frontend/src/managers/clipboard.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/managers/clipboard.ts b/frontend/src/managers/clipboard.ts index 60ccecb145a..0ed845a7c7d 100644 --- a/frontend/src/managers/clipboard.ts +++ b/frontend/src/managers/clipboard.ts @@ -24,7 +24,7 @@ export function createClipboardManager(subscriptions: SubscriptionsRouter, edito insertAtCaret(data.content); }); - subscriptions.subscribeFrontendMessage("TriggerClipboardSvgWrite", (data) => { + subscriptions.subscribeFrontendMessage("TriggerClipboardSvgAndJsonWrite", (data) => { // Adopted from https://developer.mozilla.org/en-US/docs/Web/API/ClipboardItem#browser_compatibility if (ClipboardItem.supports("image/svg+xml")) { navigator.clipboard?.write?.([ @@ -44,7 +44,7 @@ export function destroyClipboardManager() { if (!subscriptions) return; subscriptions.unsubscribeFrontendMessage("TriggerClipboardWrite"); - subscriptions.unsubscribeFrontendMessage("TriggerClipboardSvgWrite"); + subscriptions.unsubscribeFrontendMessage("TriggerClipboardSvgAndJsonWrite"); subscriptions.unsubscribeFrontendMessage("TriggerSelectionRead"); subscriptions.unsubscribeFrontendMessage("TriggerSelectionWrite"); } From be7c1436a3af3af709c9967368ec73eef68dbd3e Mon Sep 17 00:00:00 2001 From: VimYoung Date: Mon, 7 Sep 2026 21:22:56 +0530 Subject: [PATCH 13/46] Add: Fix network_interface to include TriggerClipboardSvgAndJsonWrite for fixing test --- .../portfolio/document/utility_types/network_interface.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index 50a11fb0340..5b6a71bb9ad 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -127,6 +127,7 @@ mod network_interface_tests { .into_iter() .find_map(|msg| match msg { FrontendMessage::TriggerClipboardWrite { content } => Some(content), + FrontendMessage::TriggerClipboardSvgAndJsonWrite { graphite_json, .. } => Some(graphite_json), _ => None, }) .expect("copy message should be dispatched"); From c54b027e94f25192ade34381a675bcd8d4e66668 Mon Sep 17 00:00:00 2001 From: VimYoung Date: Tue, 8 Sep 2026 17:20:50 +0530 Subject: [PATCH 14/46] Fix: merge text clipboard message and svgtext clipboard trigger into one --- desktop/wrapper/src/intercept_frontend_message.rs | 3 --- .../src/messages/clipboard/clipboard_message_handler.rs | 5 ++--- editor/src/messages/frontend/frontend_message.rs | 5 +---- .../portfolio/document/utility_types/network_interface.rs | 1 - editor/src/node_graph_executor.rs | 5 ++++- frontend/src/managers/clipboard.ts | 8 +------- 6 files changed, 8 insertions(+), 19 deletions(-) diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index c53efcd89d3..6cce36a2000 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -116,9 +116,6 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD FrontendMessage::TriggerClipboardRead => { dispatcher.respond(DesktopFrontendMessage::ClipboardRead); } - FrontendMessage::TriggerClipboardWrite { content } => { - dispatcher.respond(DesktopFrontendMessage::ClipboardWrite { content }); - } FrontendMessage::TriggerClipboardSvgAndJsonWrite { graphite_json, .. } => { // TODO: Add support for svg after clipboard API change in desktop. dispatcher.respond(DesktopFrontendMessage::ClipboardWrite { content: graphite_json }); diff --git a/editor/src/messages/clipboard/clipboard_message_handler.rs b/editor/src/messages/clipboard/clipboard_message_handler.rs index ff3e3f9087d..a36d08daaa9 100644 --- a/editor/src/messages/clipboard/clipboard_message_handler.rs +++ b/editor/src/messages/clipboard/clipboard_message_handler.rs @@ -87,8 +87,8 @@ impl MessageHandler> for Clipboard let graphite_json = format!("{CLIPBOARD_PREFIX}{graphite}"); responses.add(PortfolioMessage::RequestSvgTextCopy { graphite_json }); } - ClipboardContent::Text(text) => { - responses.add(FrontendMessage::TriggerClipboardWrite { content: text }); + ClipboardContent::Text(graphite_json) => { + responses.add(FrontendMessage::TriggerClipboardSvgAndJsonWrite { svg_string: None, graphite_json }); } }, @@ -523,7 +523,6 @@ mod test { .await .into_iter() .find_map(|message| match message { - FrontendMessage::TriggerClipboardWrite { content } => Some(content), FrontendMessage::TriggerClipboardSvgAndJsonWrite { graphite_json, .. } => Some(graphite_json), _ => None, }) diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 765366bf7da..85462efc7f4 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -150,11 +150,8 @@ pub enum FrontendMessage { url: String, }, TriggerClipboardRead, - TriggerClipboardWrite { - content: String, - }, TriggerClipboardSvgAndJsonWrite { - svg_string: String, + svg_string: Option, graphite_json: String, }, TriggerSelectionRead { diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index 5b6a71bb9ad..f29c108a12c 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -126,7 +126,6 @@ mod network_interface_tests { let clipboard = frontend_messages .into_iter() .find_map(|msg| match msg { - FrontendMessage::TriggerClipboardWrite { content } => Some(content), FrontendMessage::TriggerClipboardSvgAndJsonWrite { graphite_json, .. } => Some(graphite_json), _ => None, }) diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index 7f2cb5f481b..011a29b28df 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -468,7 +468,10 @@ impl NodeGraphExecutor { } NodeGraphUpdate::NodeGraphUpdateMessage(_) => {} NodeGraphUpdate::SvgTextCopyClipboard { svg_string, graphite_json } => { - responses.add(FrontendMessage::TriggerClipboardSvgAndJsonWrite { svg_string, graphite_json }); + responses.add(FrontendMessage::TriggerClipboardSvgAndJsonWrite { + svg_string: Some(svg_string), + graphite_json, + }); } } } diff --git a/frontend/src/managers/clipboard.ts b/frontend/src/managers/clipboard.ts index 0ed845a7c7d..831fdd89f9f 100644 --- a/frontend/src/managers/clipboard.ts +++ b/frontend/src/managers/clipboard.ts @@ -11,11 +11,6 @@ export function createClipboardManager(subscriptions: SubscriptionsRouter, edito subscriptionsRouter = subscriptions; editorWrapper = editor; - subscriptions.subscribeFrontendMessage("TriggerClipboardWrite", (data) => { - // If the Clipboard API is supported in the browser, copy text to the clipboard - navigator.clipboard?.writeText?.(data.content); - }); - subscriptions.subscribeFrontendMessage("TriggerSelectionRead", async (data) => { editor.readSelection(readAtCaret(data.cut), data.cut); }); @@ -26,7 +21,7 @@ export function createClipboardManager(subscriptions: SubscriptionsRouter, edito subscriptions.subscribeFrontendMessage("TriggerClipboardSvgAndJsonWrite", (data) => { // Adopted from https://developer.mozilla.org/en-US/docs/Web/API/ClipboardItem#browser_compatibility - if (ClipboardItem.supports("image/svg+xml")) { + if (ClipboardItem.supports("image/svg+xml") && data.svg_string !== undefined) { navigator.clipboard?.write?.([ new ClipboardItem({ "image/svg+xml": data.svg_string, @@ -43,7 +38,6 @@ export function destroyClipboardManager() { const subscriptions = subscriptionsRouter; if (!subscriptions) return; - subscriptions.unsubscribeFrontendMessage("TriggerClipboardWrite"); subscriptions.unsubscribeFrontendMessage("TriggerClipboardSvgAndJsonWrite"); subscriptions.unsubscribeFrontendMessage("TriggerSelectionRead"); subscriptions.unsubscribeFrontendMessage("TriggerSelectionWrite"); From f85130e2e3d3437f64319ea881ad6180c2b56e8e Mon Sep 17 00:00:00 2001 From: VimYoung Date: Tue, 8 Sep 2026 18:23:11 +0530 Subject: [PATCH 15/46] Fix: function abstractions in runtime.rs --- editor/src/node_graph_executor/runtime.rs | 83 +++++++++---------- .../libraries/core-types/src/transform.rs | 8 ++ 2 files changed, 45 insertions(+), 46 deletions(-) diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index 67ae24be7d6..e88fa4f8366 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -348,29 +348,7 @@ impl NodeRuntime { return texture; } GraphRuntimeRequest::CopySvgTextClipboard(text_string_clipboard, selected_node_ids) => { - let mut combined_graphics = List::::new(); - - for monitor_node_path in &self.monitor_nodes { - // Skip inspect monitor node if active - if self.inspect_state.as_ref().is_some_and(|state| monitor_node_path.last().copied() == Some(state.monitor_node)) { - continue; - } - - let Some(parent_network_node_id) = monitor_node_path.len().checked_sub(2).and_then(|index| monitor_node_path.get(index)).copied() else { - continue; - }; - - if selected_node_ids.contains(&parent_network_node_id) { - // Introspect using the full monitor node path - if let Ok(introspected_data) = self.executor.introspect(monitor_node_path) { - if let Some(io) = introspected_data.downcast_ref::>>() { - combined_graphics.extend(io.output.clone()); - } else if let Some(io) = introspected_data.downcast_ref::>>() { - combined_graphics.push(io.output.clone()); - } - } - } - } + let combined_graphics = self.collect_graphics(&selected_node_ids); if combined_graphics.is_empty() { self.sender.send_svg_text_clipboard(String::new(), text_string_clipboard); @@ -378,25 +356,16 @@ impl NodeRuntime { } let bounds = graphene_std::renderer::graphic_list_bounding_box(&combined_graphics, DAffine2::IDENTITY); - let raw_bounds = match bounds { + let final_bounds = match bounds { RenderBoundingBox::Rectangle(bounds) if (bounds[1] - bounds[0]) != DVec2::ZERO => bounds, _ => [DVec2::ZERO, DVec2::ONE], }; - let footprint = Footprint { - transform: DAffine2::from_translation(DVec2::new(raw_bounds[0].x, raw_bounds[0].y)), - resolution: UVec2::new((raw_bounds[1].x - raw_bounds[0].x).abs().ceil() as u32, (raw_bounds[1].y - raw_bounds[0].y).abs().ceil() as u32).max(UVec2::ONE), - quality: RenderQuality::Full, - }; - - let render_params = RenderParams { - footprint, - thumbnail: false, - ..Default::default() - }; + let footprint = Footprint::from_bounds(final_bounds, RenderQuality::Full); + let render_params = RenderParams { footprint, ..Default::default() }; let mut render = SvgRender::new(); combined_graphics.render_svg(&mut render, &render_params); - render.format_svg(raw_bounds[0], raw_bounds[1]); + render.format_svg(final_bounds[0], final_bounds[1]); self.sender.send_svg_text_clipboard(render.svg.to_svg_string(), text_string_clipboard); } @@ -452,11 +421,7 @@ impl NodeRuntime { for monitor_node_path in &self.monitor_nodes { // Skip the inspect monitor node - if self - .inspect_state - .as_ref() - .is_some_and(|inspect_state| monitor_node_path.last().copied() == Some(inspect_state.monitor_node)) - { + if self.is_insepect_monitor_node(monitor_node_path) { continue; } @@ -558,11 +523,7 @@ impl NodeRuntime { }; let bounds = expand_to_thumbnail_aspect(raw_bounds); let new_thumbnail_svg = { - let footprint = Footprint { - transform: DAffine2::from_translation(DVec2::new(bounds[0].x, bounds[0].y)), - resolution: UVec2::new((bounds[1].x - bounds[0].x).abs() as u32, (bounds[1].y - bounds[0].y).abs() as u32), - quality: RenderQuality::Full, - }; + let footprint = Footprint::from_bounds(bounds, RenderQuality::Full); // Render the thumbnail from a `Graphic` into an SVG string let render_params = RenderParams { @@ -589,6 +550,36 @@ impl NodeRuntime { *old_thumbnail_svg = new_thumbnail_svg; } } + + fn collect_graphics(&self, selected_node_ids: &Vec) -> List { + let mut combined_graphics = List::::new(); + for monitor_node_path in &self.monitor_nodes { + // Skip inspect monitor node if active + if self.is_insepect_monitor_node(monitor_node_path) { + continue; + } + + let Some(parent_network_node_id) = monitor_node_path.len().checked_sub(2).and_then(|index| monitor_node_path.get(index)).copied() else { + continue; + }; + + if selected_node_ids.contains(&parent_network_node_id) { + // Introspect using the full monitor node path + if let Ok(introspected_data) = self.executor.introspect(monitor_node_path) + && let Some(io) = introspected_data.downcast_ref::>>() + { + combined_graphics.extend(io.output.clone()); + } else { + warn!("No graphic type is matched while extracting svg"); + } + } + } + combined_graphics + } + + fn is_insepect_monitor_node(&self, monitor_node_path: &Vec) -> bool { + self.inspect_state.as_ref().is_some_and(|state| monitor_node_path.last().copied() == Some(state.monitor_node)) + } } /// Returns the union of the artboards' clipping rectangles, used as the thumbnail bounds for an artboard layer so the diff --git a/node-graph/libraries/core-types/src/transform.rs b/node-graph/libraries/core-types/src/transform.rs index 7f2aa272a62..a6c6ce31130 100644 --- a/node-graph/libraries/core-types/src/transform.rs +++ b/node-graph/libraries/core-types/src/transform.rs @@ -189,6 +189,14 @@ impl Footprint { quality: RenderQuality::Full, }; + pub fn from_bounds(bounds: [DVec2; 2], quality: RenderQuality) -> Self { + Footprint { + transform: DAffine2::from_translation(DVec2::new(bounds[0].x, bounds[0].y)), + resolution: UVec2::new((bounds[1].x - bounds[0].x).abs().ceil() as u32, (bounds[1].y - bounds[0].y).abs().ceil() as u32).max(UVec2::ONE), + quality, + } + } + pub fn viewport_bounds_in_local_space(&self) -> AxisAlignedBbox { let inverse = self.transform.inverse(); let res = self.resolution.as_dvec2(); From 72cbd1f1cd5ded597f117a9ac44882f5b6cfcc3f Mon Sep 17 00:00:00 2001 From: Ramayen <106333136+VimYoung@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:05:55 +0530 Subject: [PATCH 16/46] Apply suggestion from @0HyperCube Co-authored-by: James Lindsay <78500760+0HyperCube@users.noreply.github.com> --- node-graph/libraries/core-types/src/transform.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/node-graph/libraries/core-types/src/transform.rs b/node-graph/libraries/core-types/src/transform.rs index a6c6ce31130..20acedf247f 100644 --- a/node-graph/libraries/core-types/src/transform.rs +++ b/node-graph/libraries/core-types/src/transform.rs @@ -192,7 +192,8 @@ impl Footprint { pub fn from_bounds(bounds: [DVec2; 2], quality: RenderQuality) -> Self { Footprint { transform: DAffine2::from_translation(DVec2::new(bounds[0].x, bounds[0].y)), - resolution: UVec2::new((bounds[1].x - bounds[0].x).abs().ceil() as u32, (bounds[1].y - bounds[0].y).abs().ceil() as u32).max(UVec2::ONE), + transform: DAffine2::from_translation(bounds[0].min(bounds[1])), + resolution: (bounds[1] - bounds[0]).abs().ceil().as_uvec2().max(UVec2::ONE), quality, } } From 1a19e3944dcbef0008ba854ab8dc686494fb31ce Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Sat, 12 Sep 2026 08:22:54 +0000 Subject: [PATCH 17/46] Switch the Color struct back to storing unassociated alpha (#4518) * Switch Color struct back to storing unassociated alpha * Address review feedback * Update the Invert node and legacy image migration for straight alpha and add round-trip tests --------- Co-authored-by: Keavon Chambers --- .../document/document_message_handler.rs | 2 - .../messages/portfolio/document_migration.rs | 4 +- .../libraries/no-std-types/src/blending.rs | 20 ++++- .../no-std-types/src/color/color_traits.rs | 8 -- .../no-std-types/src/color/color_types.rs | 85 +++++++------------ .../libraries/raster-types/src/image.rs | 28 +++--- .../libraries/rendering/src/renderer.rs | 23 ++--- .../wgpu-executor/src/texture_conversion.rs | 4 +- .../nodes/gstd/src/platform_application_io.rs | 9 +- node-graph/nodes/raster/src/adjustments.rs | 29 ++++--- node-graph/nodes/raster/src/blending_nodes.rs | 9 +- node-graph/nodes/raster/src/filter.rs | 4 +- node-graph/nodes/raster/src/std_nodes.rs | 17 ++-- 13 files changed, 103 insertions(+), 139 deletions(-) diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 674cef08261..237bc66b38b 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -802,8 +802,6 @@ impl MessageHandler> for DocumentMes parent_and_insert_index, place_at_origin, } => { - // All the image's pixels have been converted to 0..=1, linear, and premultiplied by `Color::from_rgba8_srgb` - let layer_parent = self.new_layer_parent(true); let image_size = DVec2::new(image.width as f64, image.height as f64); diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index c83e51518ab..792aa807233 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -2123,7 +2123,9 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], _ => None, }); - if let Some(image) = image { + if let Some(mut image) = image { + // Legacy embedded pixel data is premultiplied, so restore straight alpha before encoding it + image.data.iter_mut().for_each(|pixel| *pixel = pixel.to_unassociated_alpha()); let hash = document.resources.embedded.store(Resource::new(image.to_png())); let resource_id = ResourceId::new(); diff --git a/node-graph/libraries/no-std-types/src/blending.rs b/node-graph/libraries/no-std-types/src/blending.rs index cd4cd1116ec..ea3a5763bb5 100644 --- a/node-graph/libraries/no-std-types/src/blending.rs +++ b/node-graph/libraries/no-std-types/src/blending.rs @@ -201,7 +201,7 @@ pub fn blend_colors(foreground: Color, background: Color, blend_mode: BlendMode, blend_mode => apply_blend_mode(foreground, background, blend_mode), }; - background.alpha_blend(target_color.apply_opacity(opacity)) + background.alpha_blend(target_color.with_alpha(target_color.a() * opacity)) } /// Mixes the two colors by the blend mode's own formula, leaving the alpha compositing to the caller. @@ -283,10 +283,10 @@ mod tests { } #[test] - fn darker_color_compares_unassociated_channels() { - // The premultiplied backdrop reads as 0.1 gray but is really 0.5 gray, so the 0.4 gray foreground is the darker color + fn darker_color_ignores_backdrop_alpha() { + // The backdrop's low alpha doesn't darken its color, so the 0.4 gray foreground is the darker color let foreground = Color::from_rgbaf32_unchecked(0.4, 0.4, 0.4, 1.); - let background = Color::from_rgbaf32_unchecked(0.1, 0.1, 0.1, 0.2); + let background = Color::from_rgbaf32_unchecked(0.5, 0.5, 0.5, 0.2); let blended = apply_blend_mode(foreground, background, BlendMode::DarkerColor); @@ -294,6 +294,18 @@ mod tests { assert!((blended.a() - 1.).abs() < 1e-5, "alpha was {}", blended.a()); } + #[test] + fn source_over_weights_straight_colors_by_alpha() { + let over = Color::from_rgbaf32_unchecked(1., 0., 0., 0.5); + let under = Color::from_rgbaf32_unchecked(0., 0., 1., 1.); + + let blended = under.alpha_blend(over); + + assert!((blended.r() - 0.5).abs() < 1e-5, "red was {}", blended.r()); + assert!((blended.b() - 0.5).abs() < 1e-5, "blue was {}", blended.b()); + assert!((blended.a() - 1.).abs() < 1e-5, "alpha was {}", blended.a()); + } + #[test] fn alpha_only_modes_fade_with_opacity() { let foreground = Color::from_rgbaf32_unchecked(0.9, 0.9, 0.9, 1.); diff --git a/node-graph/libraries/no-std-types/src/color/color_traits.rs b/node-graph/libraries/no-std-types/src/color/color_traits.rs index 945cd9e7add..5870b52516b 100644 --- a/node-graph/libraries/no-std-types/src/color/color_traits.rs +++ b/node-graph/libraries/no-std-types/src/color/color_traits.rs @@ -123,14 +123,6 @@ pub trait RGBMut: RGB { fn set_blue(&mut self, blue: Self::ColorChannel); } -pub trait AssociatedAlpha: RGB + Alpha { - fn to_unassociated(&self) -> Out; -} - -pub trait UnassociatedAlpha: RGB + Alpha { - fn to_associated(&self) -> Out; -} - pub trait Alpha { type AlphaChannel: LinearChannel; const TRANSPARENT: Self; diff --git a/node-graph/libraries/no-std-types/src/color/color_types.rs b/node-graph/libraries/no-std-types/src/color/color_types.rs index 13bbe379121..b35038ec54f 100644 --- a/node-graph/libraries/no-std-types/src/color/color_types.rs +++ b/node-graph/libraries/no-std-types/src/color/color_types.rs @@ -1,4 +1,4 @@ -use super::color_traits::{Alpha, AlphaMut, AssociatedAlpha, Luminance, Pixel, RGB, RGBMut, Rec709Primaries, SRGB}; +use super::color_traits::{Alpha, AlphaMut, Luminance, Pixel, RGB, RGBMut, Rec709Primaries, SRGB}; use super::discrete_srgb::{float_to_srgb_u8, srgb_u8_to_float}; use bytemuck::{Pod, Zeroable}; use core::fmt::Debug; @@ -72,7 +72,7 @@ impl Alpha for RGBA16F { type AlphaChannel = f32; #[inline(always)] fn alpha(&self) -> f32 { - self.alpha.to_f32() / 255. + self.alpha.to_f32() } const TRANSPARENT: Self = RGBA16F { @@ -83,9 +83,8 @@ impl Alpha for RGBA16F { }; fn multiplied_alpha(&self, alpha: Self::AlphaChannel) -> Self { - let alpha = alpha * 255.; let mut result = *self; - result.alpha = f16::from_f32(alpha * self.alpha()); + result.alpha = f16::from_f32(self.alpha() * alpha); result } } @@ -254,7 +253,7 @@ impl RGB for Luma { impl Pixel for Luma {} -/// Linear-light sRGB color with `f32` channels (alpha unassociated for swatch/UI colors, associated/premultiplied for pixel data inside [`Image`]). +/// Linear-light sRGB color with `f32` channels and unassociated (straight) alpha. /// /// Channels range from `0.` to `f32::MAX`, encoding brightness proportional to light intensity (cd/m² nits in HDR, or `0..=1` mapped to white for SDR). /// @@ -359,9 +358,7 @@ impl Pixel for Color { } fn from_bytes(bytes: &[u8]) -> Self { - // `Image` pixel convention is linear-light with associated (premultiplied) alpha. - let srgba = SRGBA8::new(bytes[0], bytes[1], bytes[2], bytes[3]); - Color::from(srgba).apply_opacity(bytes[3] as f32 / 255.) + SRGBA8::new(bytes[0], bytes[1], bytes[2], bytes[3]).into() } fn byte_size() -> usize { 4 @@ -378,18 +375,7 @@ impl Alpha for Color { } #[inline(always)] fn multiplied_alpha(&self, alpha: Self::AlphaChannel) -> Self { - Self { - red: self.red * alpha, - green: self.green * alpha, - blue: self.blue * alpha, - alpha: self.alpha * alpha, - } - } -} - -impl AssociatedAlpha for Color { - fn to_unassociated(&self) -> Out { - todo!() + Self { alpha: self.alpha * alpha, ..*self } } } @@ -443,12 +429,6 @@ impl Color { Color { red, green, blue, alpha } } - /// Construct a `Color` from unassociated (straight) RGBA channels, premultiplying the RGB channels by alpha. - #[inline(always)] - pub fn new_from_unassociated_rgba(red: f32, green: f32, blue: f32, alpha: f32) -> Color { - Color::from_rgbaf32_unchecked(red * alpha, green * alpha, blue * alpha, alpha) - } - /// Create a linear-light `Color` from HSL coordinates (all between 0 and 1). /// HSL is defined on sRGB display values, so the RGB produced by the HSL math is gamma-encoded and decoded to linear before being wrapped in `Color`. /// @@ -707,8 +687,7 @@ impl Color { /// Whole-color "Darker Color" blend: keeps whichever color has the lower mean RGB, with `other`'s alpha. #[inline(always)] pub fn blend_darker_color(&self, other: Color) -> Color { - let background = self.to_unassociated_alpha(); - let darker = if background.average_rgb_channels() <= other.average_rgb_channels() { background } else { other }; + let darker = if self.average_rgb_channels() <= other.average_rgb_channels() { *self } else { other }; darker.with_alpha(other.alpha) } @@ -740,8 +719,7 @@ impl Color { /// Whole-color "Lighter Color" blend: keeps whichever color has the higher mean RGB, with `other`'s alpha. #[inline(always)] pub fn blend_lighter_color(&self, other: Color) -> Color { - let background = self.to_unassociated_alpha(); - let lighter = if background.average_rgb_channels() >= other.average_rgb_channels() { background } else { other }; + let lighter = if self.average_rgb_channels() >= other.average_rgb_channels() { *self } else { other }; lighter.with_alpha(other.alpha) } @@ -824,25 +802,23 @@ impl Color { /// Whole-color "Hue" blend: source hue with this color's saturation and Rec.601 luma, with `c_s`'s alpha. pub fn blend_hue(&self, c_s: Color) -> Color { - let background = self.to_unassociated_alpha(); - let sat_b = background.chroma_range(); - let lum_b = background.luminance_rec_601(); + let sat_b = self.chroma_range(); + let lum_b = self.luminance_rec_601(); c_s.with_saturation(sat_b).with_luminance(lum_b).with_alpha(c_s.alpha) } /// Whole-color "Saturation" blend: this color's hue/luma with source saturation, with `c_s`'s alpha. pub fn blend_saturation(&self, c_s: Color) -> Color { - let background = self.to_unassociated_alpha(); let sat_s = c_s.chroma_range(); - let lum_b = background.luminance_rec_601(); + let lum_b = self.luminance_rec_601(); - background.with_saturation(sat_s).with_luminance(lum_b).with_alpha(c_s.alpha) + self.with_saturation(sat_s).with_luminance(lum_b).with_alpha(c_s.alpha) } /// Whole-color "Color" blend: source hue/saturation with this color's luma, with `c_s`'s alpha. pub fn blend_color(&self, c_s: Color) -> Color { - let lum_b = self.to_unassociated_alpha().luminance_rec_601(); + let lum_b = self.luminance_rec_601(); c_s.with_luminance(lum_b).with_alpha(c_s.alpha) } @@ -851,7 +827,7 @@ impl Color { pub fn blend_luminosity(&self, c_s: Color) -> Color { let lum_s = c_s.luminance_rec_601(); - self.to_unassociated_alpha().with_luminance(lum_s).with_alpha(c_s.alpha) + self.with_luminance(lum_s).with_alpha(c_s.alpha) } /// All four channels as `(red, green, blue, alpha)`. @@ -990,13 +966,13 @@ impl Color { Self::from_rgbaf32_unchecked(f(self.r()), f(self.g()), f(self.b()), self.a()) } - /// Multiply all four channels (including alpha) by `opacity`, applying an additional premultiplication factor to this Color. + /// Multiply RGB by alpha, giving the associated (premultiplied) form for compositing and filtering. #[inline(always)] - pub fn apply_opacity(&self, opacity: f32) -> Self { - Self::from_rgbaf32_unchecked(self.r() * opacity, self.g() * opacity, self.b() * opacity, self.a() * opacity) + pub fn to_associated_alpha(&self) -> Self { + self.map_rgb(|channel| channel * self.alpha) } - /// Divide RGB by alpha to recover unassociated (straight-alpha) channels; no-op if alpha is zero. + /// Divide RGB by alpha, undoing [`Self::to_associated_alpha`]; no-op if alpha is zero. #[inline(always)] pub fn to_unassociated_alpha(&self) -> Self { if self.alpha == 0. { @@ -1011,27 +987,30 @@ impl Color { } } - /// Apply a per-channel blend function to this color (unmultiplied) and `other`, returning a color with `other`'s alpha; channels are clamped to 0..1. + /// Apply a per-channel blend function to this color and `other`, returning a color with `other`'s alpha; channels are clamped to 0..1. #[inline(always)] pub fn blend_rgb f32>(&self, other: Color, f: F) -> Self { - let background = self.to_unassociated_alpha(); Color { - red: f(background.red, other.red).clamp(0., 1.), - green: f(background.green, other.green).clamp(0., 1.), - blue: f(background.blue, other.blue).clamp(0., 1.), + red: f(self.red, other.red).clamp(0., 1.), + green: f(self.green, other.green).clamp(0., 1.), + blue: f(self.blue, other.blue).clamp(0., 1.), alpha: other.alpha, } } - /// Porter-Duff "source over" composite of `other` over `self`. Both colors must use associated (premultiplied) alpha. + /// Porter-Duff "source over" composite of `other` over `self`. #[inline(always)] pub fn alpha_blend(&self, other: Color) -> Self { - let inv_alpha = 1. - other.alpha; + let under_weight = self.alpha * (1. - other.alpha); + let alpha = other.alpha + under_weight; + if alpha == 0. { + return Self::TRANSPARENT; + } Self { - red: self.red * inv_alpha + other.red, - green: self.green * inv_alpha + other.green, - blue: self.blue * inv_alpha + other.blue, - alpha: self.alpha * inv_alpha + other.alpha, + red: (other.red * other.alpha + self.red * under_weight) / alpha, + green: (other.green * other.alpha + self.green * under_weight) / alpha, + blue: (other.blue * other.alpha + self.blue * under_weight) / alpha, + alpha, } } diff --git a/node-graph/libraries/raster-types/src/image.rs b/node-graph/libraries/raster-types/src/image.rs index 16d4d5955a7..e87117a7b3a 100644 --- a/node-graph/libraries/raster-types/src/image.rs +++ b/node-graph/libraries/raster-types/src/image.rs @@ -144,14 +144,7 @@ impl Image

{ impl Image { /// Generate Image from some frontend image data (the canvas pixels as u8s in a flat array) pub fn from_image_data(image_data: &[u8], width: u32, height: u32) -> Self { - let data = image_data - .chunks_exact(4) - .map(|v| { - // `Image` pixels are stored linear-light with premultiplied alpha - let srgba = SRGBA8::new(v[0], v[1], v[2], v[3]); - Color::from(srgba).apply_opacity(v[3] as f32 / 255.) - }) - .collect(); + let data = image_data.chunks_exact(4).map(|v| SRGBA8::new(v[0], v[1], v[2], v[3]).into()).collect(); Image { width, height, @@ -171,7 +164,7 @@ impl Image { } use super::*; -impl Image

+impl Image

where P::ColorChannel: Linear,

::AlphaChannel: Linear, @@ -195,10 +188,9 @@ where // Smaller alpha values than this would map to fully transparent // anyway, avoid expensive encoding. if a >= 0.5 / 255. { - let undo_premultiply = 1. / a; - let r = color.r().to_f32() * undo_premultiply; - let g = color.g().to_f32() * undo_premultiply; - let b = color.b().to_f32() * undo_premultiply; + let r = color.r().to_f32(); + let g = color.g().to_f32(); + let b = color.b().to_f32(); // Compute new sRGB value if necessary. if r != last_r { @@ -287,4 +279,14 @@ mod test { assert_eq!(image, deserialized); } + + #[test] + fn image_data_round_trips_translucent_pixels() { + use super::*; + let bytes = [255, 0, 0, 128, 0, 255, 0, 1, 255, 255, 255, 41, 10, 20, 30, 255]; + + let image = Image::from_image_data(&bytes, 4, 1); + + assert_eq!(image.to_flat_u8().0, bytes); + } } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 932bf144484..e17a68c241c 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -22,7 +22,7 @@ use dyn_any::DynAny; use glam::{DAffine2, DMat2, DVec2}; use graphene_hash::CacheHashWrapper; use graphene_resource::Resource; -use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster, Texture}; +use graphic_types::raster_types::{CPU, GPU, Image, Raster, Texture}; use graphic_types::vector_types::gradient::{Gradient, GradientForm}; use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint}; use graphic_types::vector_types::vector::misc::dvec2_to_point; @@ -130,9 +130,7 @@ fn composite_paint_over(over: Color, under: Color, blend_mode: BlendMode) -> Col return Color::TRANSPARENT; } - // The blend formulas read their backdrop premultiplied - let premultiplied_under = Color::from_rgbaf32_unchecked(under.r() * under_alpha, under.g() * under_alpha, under.b() * under_alpha, under_alpha); - let mixed = apply_blend_mode(over, premultiplied_under, blend_mode); + let mixed = apply_blend_mode(over, under, blend_mode); // The mode only mixes where the backdrop has coverage, so its alpha interpolates each source channel from the raw color to the mixed color let source_channel = |over_channel: f32, mixed_channel: f32| over_channel * (1. - under_alpha) + mixed_channel * under_alpha; @@ -428,17 +426,8 @@ fn singular_values(transform: DAffine2) -> (f64, f64) { pub fn black_or_white_for_best_contrast(background: Option) -> Color { let Some(bg) = background else { return core_types::consts::LAYER_OUTLINE_STROKE_COLOR }; - let alpha = bg.a(); - - // Un-premultiply, then encode to gamma sRGB to do the composite in display space. - let (gamma_r, gamma_g, gamma_b) = if alpha > f32::EPSILON { - let [r, g, b, _] = Color::from_rgbaf32_unchecked(bg.r() / alpha, bg.g() / alpha, bg.b() / alpha, alpha).to_gamma_srgb_channels(); - (r, g, b) - } else { - (0., 0., 0.) - }; - - // Composite over black in sRGB space (premultiplied by alpha), then decode to linear for the luminance test. + // Composite over black in gamma sRGB space, then decode to linear for the luminance test. + let [gamma_r, gamma_g, gamma_b, alpha] = bg.to_gamma_srgb_channels(); let composited = Color::from_gamma_srgb_channels(gamma_r * alpha, gamma_g * alpha, gamma_b * alpha, 1.); let threshold = (1.05 * 0.05f32).sqrt() - 0.05; @@ -2295,9 +2284,7 @@ fn render_raster_cpu_item_svg(item: ItemRef<'_, Raster>, render: &mut SvgRe } if render_params.to_canvas() { - let mut image_copy = image.clone(); - image_copy.data_mut().map_pixels(|p| p.to_unassociated_alpha()); - let id = *render.image_data.entry(CacheHashWrapper(image_copy.into_data())).or_insert_with(generate_uuid); + let id = *render.image_data.entry(CacheHashWrapper(image.clone().into_data())).or_insert_with(generate_uuid); render.parent_tag( "foreignObject", diff --git a/node-graph/libraries/wgpu-executor/src/texture_conversion.rs b/node-graph/libraries/wgpu-executor/src/texture_conversion.rs index 1411a57f6b3..93fb12da34d 100644 --- a/node-graph/libraries/wgpu-executor/src/texture_conversion.rs +++ b/node-graph/libraries/wgpu-executor/src/texture_conversion.rs @@ -115,9 +115,7 @@ impl RasterGpuToRasterCpuConverter { let start = row * row_stride; let row_slice = &view[start..start + row_bytes]; for px in row_slice.chunks_exact(4) { - // `Image` pixels are stored linear-light with associated (premultiplied) alpha - let srgba = SRGBA8::new(px[0], px[1], px[2], px[3]); - cpu_data.push(Color::from(srgba).apply_opacity(px[3] as f32 / 255.)); + cpu_data.push(SRGBA8::new(px[0], px[1], px[2], px[3]).into()); } } diff --git a/node-graph/nodes/gstd/src/platform_application_io.rs b/node-graph/nodes/gstd/src/platform_application_io.rs index 5ed9eb366a2..269c1dc380e 100644 --- a/node-graph/nodes/gstd/src/platform_application_io.rs +++ b/node-graph/nodes/gstd/src/platform_application_io.rs @@ -171,14 +171,7 @@ fn decode_image(_: impl Ctx, data: Item) -> Item> { }; let image = image.to_rgba32f(); let image = Image { - data: image - .chunks(4) - .map(|pixel| { - // Decoded bytes are unassociated gamma sRGB; premultiply in gamma then lift to linear - let a = pixel[3]; - Color::from_gamma_srgb_channels(pixel[0] * a, pixel[1] * a, pixel[2] * a, a) - }) - .collect(), + data: image.chunks(4).map(|pixel| Color::from_gamma_srgb_channels(pixel[0], pixel[1], pixel[2], pixel[3])).collect(), width: image.width(), height: image.height(), ..Default::default() diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index 4367089d61f..e86cf04fe9e 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -144,12 +144,7 @@ fn make_opaque>( input: Item, ) -> Item { let mut input = input; - input.element_mut().adjust(|color| { - if color.a() == 0. { - return color.with_alpha(1.); - } - Color::from_rgbaf32_unchecked(color.r() / color.a(), color.g() / color.a(), color.b() / color.a(), 1.) - }); + input.element_mut().adjust(|color| color.with_alpha(1.)); input } @@ -502,11 +497,7 @@ fn invert>( input: Item, ) -> Item { let mut input = input; - input.element_mut().adjust(|color| { - // Invert in gamma space relative to alpha - let [r, g, b, a] = color.to_gamma_srgb_channels(); - Color::from_gamma_srgb_channels(a - r, a - g, a - b, a) - }); + input.element_mut().adjust(|color| color.map_gamma_rgb(|channel| 1. - channel)); input } @@ -1147,3 +1138,19 @@ mod _graphene_hash_impls { SelectiveColorChoice ); } + +#[cfg(all(feature = "std", test))] +mod test { + use super::*; + + #[test] + fn invert_flips_straight_channels_and_keeps_alpha() { + let color = Color::from_gamma_srgb_channels(1., 0.25, 0., 0.5); + + let inverted = invert((), Item::new_from_element(color)).into_element(); + + let [r, g, b, a] = inverted.to_gamma_srgb_channels(); + assert!((r - 0.).abs() < 1e-5 && (g - 0.75).abs() < 1e-5 && (b - 1.).abs() < 1e-5, "inverted channels were {r} {g} {b}"); + assert!((a - 0.5).abs() < 1e-5, "alpha was {a}"); + } +} diff --git a/node-graph/nodes/raster/src/blending_nodes.rs b/node-graph/nodes/raster/src/blending_nodes.rs index 7ed3c829caf..0340b068834 100644 --- a/node-graph/nodes/raster/src/blending_nodes.rs +++ b/node-graph/nodes/raster/src/blending_nodes.rs @@ -115,13 +115,10 @@ fn color_overlay>( let opacity = (opacity / 100.).clamp(0., 1.); image.element_mut().adjust(|pixel| { - let image = pixel.map_rgb(|channel| channel * (1. - opacity)); + let overlay = apply_blend_mode(color, *pixel, blend_mode); + let mix = |image: f32, overlay: f32| image + (overlay - image) * opacity; - // The apply blend mode function divides rgb by the alpha channel for the background. This undoes that. - let associated_pixel = Color::from_rgbaf32_unchecked(pixel.r() * pixel.a(), pixel.g() * pixel.a(), pixel.b() * pixel.a(), pixel.a()); - let overlay = apply_blend_mode(color, associated_pixel, blend_mode).map_rgb(|channel| channel * opacity); - - Color::from_rgbaf32_unchecked(image.r() + overlay.r(), image.g() + overlay.g(), image.b() + overlay.b(), pixel.a()) + Color::from_rgbaf32_unchecked(mix(pixel.r(), overlay.r()), mix(pixel.g(), overlay.g()), mix(pixel.b(), overlay.b()), pixel.a()) }); image } diff --git a/node-graph/nodes/raster/src/filter.rs b/node-graph/nodes/raster/src/filter.rs index bfe2001f2ee..58375b601b0 100644 --- a/node-graph/nodes/raster/src/filter.rs +++ b/node-graph/nodes/raster/src/filter.rs @@ -177,7 +177,7 @@ fn gaussian_blur_algorithm(buffer: Image, radius: f64, gamma: bool) -> Im unpremultiply_gamma_to_linear(blurred) } else { let mut working = buffer; - working.map_pixels(|px| px.apply_opacity(px.a())); + working.map_pixels(|px| px.to_associated_alpha()); let mut blurred = gaussian_separable(working, &kernel, Color::from_rgbaf32_unchecked); blurred.map_pixels(|px| px.to_unassociated_alpha()); blurred @@ -191,7 +191,7 @@ fn box_blur_algorithm(buffer: Image, radius: f64, gamma: bool) -> Image(_: impl Ctx, resource: Item) -> Item> }; let image = image.to_rgba32f(); let image = Image { - data: image - .chunks(4) - .map(|pixel| { - let alpha = pixel[3]; - Color::from_gamma_srgb_channels(pixel[0] * alpha, pixel[1] * alpha, pixel[2] * alpha, alpha) - }) - .collect(), + data: image.chunks(4).map(|pixel| Color::from_gamma_srgb_channels(pixel[0], pixel[1], pixel[2], pixel[3])).collect(), width: image.width(), height: image.height(), ..Default::default() From 32aa6514ec8a0b86753482e567867d6bb881a87c Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 12 Sep 2026 12:58:55 -0700 Subject: [PATCH 18/46] Add the Curves adjustment node with a Transfer Curve type and editor widget (#4520) * Add the Curves adjustment node with a Transfer Curve type and editor widget * Address review feedback on the Transfer Curve widget's edge cases --- .../messages/layout/layout_message_handler.rs | 31 ++ .../layout/utility_types/layout_widget.rs | 3 + .../utility_types/widgets/input_widgets.rs | 43 ++ .../data_panel/data_panel_message_handler.rs | 31 +- .../node_graph/document_node_definitions.rs | 1 + .../document/node_graph/node_properties.rs | 66 ++- .../src/components/widgets/WidgetSpan.svelte | 11 + .../widgets/inputs/TransferCurveInput.svelte | 418 ++++++++++++++++++ node-graph/graph-craft/src/document/value.rs | 14 + node-graph/graph-craft/src/proto.rs | 2 +- .../interpreted-executor/src/node_registry.rs | 5 + node-graph/libraries/core-types/src/lib.rs | 1 + .../core-types/src/transfer_curve.rs | 222 ++++++++++ node-graph/nodes/raster/src/adjustments.rs | 84 +++- 14 files changed, 926 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/widgets/inputs/TransferCurveInput.svelte create mode 100644 node-graph/libraries/core-types/src/transfer_curve.rs diff --git a/editor/src/messages/layout/layout_message_handler.rs b/editor/src/messages/layout/layout_message_handler.rs index b8f5623e44d..46d19c402ce 100644 --- a/editor/src/messages/layout/layout_message_handler.rs +++ b/editor/src/messages/layout/layout_message_handler.rs @@ -274,6 +274,20 @@ impl LayoutMessageHandler { responses.add(callback_message); } + Widget::TransferCurveInput(curve_input) => { + let callback_message = match action { + WidgetValueAction::Commit => (curve_input.on_commit.callback)(&()), + WidgetValueAction::Update => { + let Ok(update) = serde_json::from_value::(value) else { + warn!("TransferCurveInput update was not able to be parsed as TransferCurveInputUpdate"); + return; + }; + (curve_input.on_update.callback)(&update) + } + }; + + responses.add(callback_message); + } Widget::IconButton(icon_button) => { let callback_message = match action { WidgetValueAction::Commit => (icon_button.on_commit.callback)(&()), @@ -534,6 +548,23 @@ fn populate_computed_display_fields(layout: &mut Layout) { Widget::ColorInput(color_input) => { color_input.chosen_gradient = color_input.value.to_css_background_image(); } + Widget::TransferCurveInput(curve_input) => { + const SAMPLE_COUNT: usize = 128; + let curve = graphene_std::transfer_curve::TransferCurve::new(curve_input.points.iter().map(|&(x, y)| glam::DVec2::new(x, y)).collect()); + let evaluator = curve.evaluator(); + let [x_min, x_max] = curve_input.domain; + let [y_min, y_max] = curve_input.range; + let (x_span, y_span) = ((x_max - x_min).max(f64::EPSILON), (y_max - y_min).max(f64::EPSILON)); + // A spline overshooting the range rides its edge as a flat line, as the clamped adjustment it depicts does + let clamp_to_range = curve_input.clamp_to_range; + curve_input.samples = (0..=SAMPLE_COUNT) + .map(|i| { + let t = i as f64 / SAMPLE_COUNT as f64; + let y = (evaluator.evaluate(x_min + t * x_span) - y_min) / y_span; + (t, if clamp_to_range { y.clamp(0., 1.) } else { y }) + }) + .collect(); + } Widget::SpectrumInput(spectrum_input) => { // The track strip spans exactly 0 to 1, which no spread affects, so the widget carries no spread of its own let settings = graphene_std::vector::style::GradientSettings { diff --git a/editor/src/messages/layout/utility_types/layout_widget.rs b/editor/src/messages/layout/utility_types/layout_widget.rs index 59739951004..e53ab5ca880 100644 --- a/editor/src/messages/layout/utility_types/layout_widget.rs +++ b/editor/src/messages/layout/utility_types/layout_widget.rs @@ -471,6 +471,7 @@ impl LayoutGroup { | Widget::ColorComparisonInput(_) | Widget::ColorPresetsInput(_) | Widget::SpectrumInput(_) + | Widget::TransferCurveInput(_) | Widget::VisualColorPickersInput(_) => continue, }; if val.is_empty() { @@ -808,6 +809,7 @@ pub enum Widget { ColorComparisonInput(ColorComparisonInput), ColorInput(ColorInput), ColorPresetsInput(ColorPresetsInput), + TransferCurveInput(TransferCurveInput), DropdownInput(DropdownInput), IconButton(IconButton), IconLabel(IconLabel), @@ -887,6 +889,7 @@ impl DiffUpdate { | Widget::ColorComparisonInput(_) | Widget::ColorPresetsInput(_) | Widget::SpectrumInput(_) + | Widget::TransferCurveInput(_) | Widget::VisualColorPickersInput(_) => None, }; diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index cc49fbbaf99..6850cc4788b 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -578,6 +578,49 @@ pub enum ColorPresetsInputUpdate { EyedropperColorCode(String), } +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Clone, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)] +#[derivative(Debug, PartialEq, Default)] +pub struct TransferCurveInput { + // Content + /// The control points in the units of `domain` and `range`, in any x order, since sampling sorts them. + #[widget_builder(constructor)] + pub points: Vec<(f64, f64)>, + /// The x extent the box spans, left to right. + pub domain: [f64; 2], + /// The y extent the box spans, bottom to top. + pub range: [f64; 2], + /// Whether the drawn curve and a dragged point's y stay inside `range`. A point's x always stays inside `domain`. + #[serde(rename = "clampToRange")] + pub clamp_to_range: bool, + /// Polyline of the curve in box-normalized 0..1 coordinates with y upward. Auto-populated from `points` at layout-send time. + #[widget_builder(skip)] + pub samples: Vec<(f64, f64)>, + /// Whether clicking empty space inserts a point. + #[serde(rename = "allowInsert")] + pub allow_insert: bool, + /// Whether double-click or right-click removes a point. The handler still has the final say (e.g., enforcing a minimum count). + #[serde(rename = "allowDelete")] + pub allow_delete: bool, + pub disabled: bool, + + // Callbacks + #[serde(skip)] + #[derivative(Debug = "ignore", PartialEq = "ignore")] + pub on_update: WidgetCallback, + #[serde(skip)] + #[derivative(Debug = "ignore", PartialEq = "ignore")] + pub on_commit: WidgetCallback<()>, +} + +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum TransferCurveInputUpdate { + MovePoint { index: u32, x: f64, y: f64 }, + InsertPoint { x: f64, y: f64 }, + DeletePoint { index: u32 }, +} + #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Clone, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)] #[derivative(Debug, PartialEq, Default)] diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index f4775b033ad..7c4d6c1a6d1 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -16,11 +16,13 @@ use graphene_std::list::{Item, List, NodeIdPath}; use graphene_std::math::float_noise::round_away_float_noise; use graphene_std::memo::IORecord; use graphene_std::raster::{ - CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice, + AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, + SelectiveColorChoice, }; use graphene_std::raster_types::{CPU, GPU, Raster}; use graphene_std::text::TextAlign; use graphene_std::text_nodes::StringCapitalization; +use graphene_std::transfer_curve::TransferCurve; use graphene_std::transform::{ReferencePoint, ScaleType}; use graphene_std::vector::misc::{ ArcType, BezierHandles, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, @@ -228,6 +230,7 @@ fn generate_layout(introspected_data: &Arc, List, List, + List, List, List, List, @@ -240,6 +243,7 @@ fn generate_layout(introspected_data: &Arc, List, List, + List, List, List, List, @@ -283,6 +287,7 @@ fn generate_layout(introspected_data: &Arc, Item, Item, + Item, Item, Item, Item, @@ -295,6 +300,7 @@ fn generate_layout(introspected_data: &Arc, Item, Item, + Item, Item, Item, Item, @@ -562,6 +568,26 @@ impl TableItemLayout for Coverage { } } +impl TableItemLayout for TransferCurve { + fn type_name() -> &'static str { + "Transfer Curve" + } + fn identifier(&self) -> String { + let points = self.points().len(); + format!("Transfer Curve ({points} {})", if points == 1 { "point" } else { "points" }) + } + // The wrapping `Item` already contributes the breadcrumb; the inner list supplies the next level + fn layout_with_breadcrumb(&self, data: &mut LayoutData) -> Vec { + self.value_page(data) + } + fn value_widgets(&self, target: PathStep, data: &LayoutData) -> Vec { + self.0.value_widgets(target, data) + } + fn value_page(&self, data: &mut LayoutData) -> Vec { + self.0.layout_with_breadcrumb(data) + } +} + impl TableItemLayout for BoxCorners { fn type_name() -> &'static str { "BoxCorners" @@ -1044,6 +1070,7 @@ impl_table_item_layout_for_choice_enum!( RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice, + AdjustmentChannel, XY, ScaleType, CentroidType, @@ -1243,6 +1270,7 @@ macro_rules! known_item_types { Cover, DashPattern, BoxCorners, + TransferCurve, BlendMode, GradientForm, GradientSpread, @@ -1261,6 +1289,7 @@ macro_rules! known_item_types { RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice, + AdjustmentChannel, XY, ScaleType, ReferencePoint, diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index d394330bd5d..ef8bae14b68 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -914,6 +914,7 @@ fn static_node_properties() -> NodeProperties { map.insert("brightness_contrast_properties".to_string(), Box::new(node_properties::brightness_contrast_properties)); map.insert("channel_mixer_properties".to_string(), Box::new(node_properties::channel_mixer_properties)); map.insert("levels_properties".to_string(), Box::new(node_properties::levels_properties)); + map.insert("transfer_curves_properties".to_string(), Box::new(node_properties::transfer_curves_properties)); map.insert("hue_saturation_properties".to_string(), Box::new(node_properties::hue_saturation_properties)); map.insert("black_and_white_properties".to_string(), Box::new(node_properties::black_and_white_properties)); map.insert("threshold_properties".to_string(), Box::new(node_properties::threshold_properties)); diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index c8e38735e43..8719ff65785 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -20,12 +20,13 @@ use graphene_std::animation::RealTimeMode; use graphene_std::color::SRGBA8; use graphene_std::extract_xy::XY; use graphene_std::raster::{ - BlendMode, CellularDistanceFunction, CellularReturnType, Color, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, + AdjustmentChannel, BlendMode, CellularDistanceFunction, CellularReturnType, Color, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice, }; use graphene_std::raster_types::Image; use graphene_std::text::{Font, TextAlign}; use graphene_std::text_nodes::StringCapitalization; +use graphene_std::transfer_curve::TransferCurve; use graphene_std::transform::{Footprint, ReferencePoint, ScaleType, Transform}; use graphene_std::vector::misc::BooleanOperation; use graphene_std::vector::misc::{ @@ -289,6 +290,7 @@ pub(crate) fn property_from_type( // STRUCT TYPES // ============ Some(x) if id_is::(x) => font_widget(default_info), + Some(x) if id_is::(x) => transfer_curve_widget(default_info), Some(x) if id_is::(x) => footprint_widget(default_info, &mut extra_widgets), Some(x) if id_is::>(x) => vector_modification_widget(default_info).into(), Some(x) if id_is::>(x) => image_data_widget(default_info).into(), @@ -316,6 +318,7 @@ pub(crate) fn property_from_type( Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), + Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), @@ -1165,6 +1168,44 @@ pub fn color_widget(parameter_widgets_info: ParameterWidgetsInfo, color_button: LayoutGroup::row(widgets) } +/// A [`TransferCurve`] input's row: the label, then the curve editor spanning the unit square when the input is not exposed. +pub fn transfer_curve_widget(parameter_widgets_info: ParameterWidgetsInfo) -> LayoutGroup { + let mut widgets = start_widgets(¶meter_widgets_info); + + let Some(NodeInput::Value { tagged_value, exposed: false }) = parameter_widgets_info.input() else { + return LayoutGroup::row(widgets); + }; + let TaggedValue::TransferCurve(points) = &**tagged_value else { return LayoutGroup::row(widgets) }; + let curve = TransferCurve::from(points.clone()); + + widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + widgets.push( + TransferCurveInput::new(curve.points().iter().map(|point| (point.x, point.y)).collect()) + .domain([0., 1.]) + .range([0., 1.]) + .clamp_to_range(true) + .allow_insert(true) + .allow_delete(true) + .on_update(parameter_widgets_info.update_value(move |update: &TransferCurveInputUpdate| { + let mut curve = curve.clone(); + match *update { + TransferCurveInputUpdate::MovePoint { index, x, y } => curve.move_point(index as usize, DVec2::new(x, y)), + TransferCurveInputUpdate::InsertPoint { x, y } => { + curve.insert_point(DVec2::new(x, y)); + } + // A transfer curve keeps at least its two end points + TransferCurveInputUpdate::DeletePoint { index } if curve.points().len() > 2 => curve.remove_point(index as usize), + TransferCurveInputUpdate::DeletePoint { .. } => {} + } + TaggedValue::TransferCurve(curve.points().to_vec()) + })) + .on_commit(commit_value) + .widget_instance(), + ); + + LayoutGroup::row(widgets) +} + pub fn font_widget(parameter_widgets_info: ParameterWidgetsInfo) -> LayoutGroup { let (font_widgets, style_widgets) = font_inputs(parameter_widgets_info); font_widgets.into_iter().chain(style_widgets.unwrap_or_default()).collect::>().into() @@ -1290,6 +1331,29 @@ pub(crate) fn brightness_contrast_properties(node_id: NodeId, context: &mut Node layout } +pub(crate) fn transfer_curves_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { + use graphene_std::raster::curves::*; + + let mut channel_info = ParameterWidgetsInfo::new(node_id, ChannelInput, true, context); + channel_info.exposable = false; + let channel = enum_choice::().for_socket(channel_info).property_row(); + + let channel_value = match get_document_node(node_id, context).ok().and_then(|document_node| document_node.input_value(ChannelInput).cloned()) { + Some(TaggedValue::AdjustmentChannel(channel)) => channel, + _ => AdjustmentChannel::Rgb, + }; + let curve_parameter: ParameterRef = match channel_value { + AdjustmentChannel::Rgb => CurveInput.into(), + AdjustmentChannel::Red => RedCurveInput.into(), + AdjustmentChannel::Green => GreenCurveInput.into(), + AdjustmentChannel::Blue => BlueCurveInput.into(), + AdjustmentChannel::Alpha => AlphaCurveInput.into(), + }; + let transfer_curve = transfer_curve_widget(ParameterWidgetsInfo::new(node_id, curve_parameter, true, context)); + + vec![channel, transfer_curve] +} + pub(crate) fn levels_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { use graphene_std::raster::levels::*; diff --git a/frontend/src/components/widgets/WidgetSpan.svelte b/frontend/src/components/widgets/WidgetSpan.svelte index 1c7023730d4..4bdb35d9300 100644 --- a/frontend/src/components/widgets/WidgetSpan.svelte +++ b/frontend/src/components/widgets/WidgetSpan.svelte @@ -18,6 +18,7 @@ import SpectrumInput from "/src/components/widgets/inputs/SpectrumInput.svelte"; import TextAreaInput from "/src/components/widgets/inputs/TextAreaInput.svelte"; import TextInput from "/src/components/widgets/inputs/TextInput.svelte"; + import TransferCurveInput from "/src/components/widgets/inputs/TransferCurveInput.svelte"; import VisualColorPickersInput from "/src/components/widgets/inputs/VisualColorPickersInput.svelte"; import WorkingColorsInput from "/src/components/widgets/inputs/WorkingColorsInput.svelte"; import IconLabel from "/src/components/widgets/labels/IconLabel.svelte"; @@ -232,6 +233,16 @@ $$events: { value: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, true) }, }), }, + TransferCurveInput: { + component: TransferCurveInput, + getProps: (props, index) => ({ + ...props, + $$events: { + update: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false), + commit: () => widgetValueCommit(index, undefined), + }, + }), + }, SpectrumInput: { component: SpectrumInput, getProps: (props, index) => ({ diff --git a/frontend/src/components/widgets/inputs/TransferCurveInput.svelte b/frontend/src/components/widgets/inputs/TransferCurveInput.svelte new file mode 100644 index 00000000000..a4cf906165a --- /dev/null +++ b/frontend/src/components/widgets/inputs/TransferCurveInput.svelte @@ -0,0 +1,418 @@ + + + +

+
+ + + + + {#each points as point, index} +
+ {/each} + {#if insertPreview} +
+ {/if} +
+
+ + + diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index aec941840e5..bfd3c2607be 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -5,6 +5,7 @@ use crate::proto::{Any as DAny, FutureAny}; use brush_nodes::{BrushCache, Stroke}; use core_types::color::SRGBA8; use core_types::list::{Item, List, NodeIdPath}; +use core_types::transfer_curve::TransferCurve; use core_types::transform::Footprint; use core_types::{CacheHash, Color, ContextFeatures, MemoHash, Node, Type, TypeDescriptor}; use dyn_any::DynAny; @@ -89,6 +90,8 @@ macro_rules! tagged_value { DashPattern(Vec), /// Stored compactly as a `Vec` of corner values, materializes as an `Item` at runtime via `to_dynany`/`to_any`. BoxCorners(Vec), + /// Stored compactly as a `Vec` of control points, materializes as an `Item` at runtime via `to_dynany`/`to_any`. + TransferCurve(Vec), /// Stored as the `GradientRamp` exchange struct (nested `{ stops: { color, position?, midpoint? } }`), materializing as an `Item` at runtime. Aliases recover legacy on-disk shapes. /// (Old documents stored flat stops, a tuple list, or the ancient full `Gradient` struct under the legacy `"Gradient"` tag, all routed by `deserialize_tagged_value_with_legacy_migration`.) #[serde(alias = "Gradient", alias = "GradientTable", alias = "GradientPositions", alias = "GradientStops")] @@ -136,6 +139,7 @@ macro_rules! tagged_value { Self::F64Array(values) => values.cache_hash(state), Self::DashPattern(lengths) => lengths.cache_hash(state), Self::BoxCorners(values) => values.cache_hash(state), + Self::TransferCurve(points) => points.cache_hash(state), Self::GradientRamp(ramp) => ramp.cache_hash(state), Self::Strokes(strokes) => strokes.cache_hash(state), Self::BrushCache(cache) => cache.cache_hash(state), @@ -200,6 +204,7 @@ macro_rules! tagged_value { } Self::DashPattern(lengths) => Box::new(Item::new_from_element(DashPattern::from(lengths))), Self::BoxCorners(values) => Box::new(Item::new_from_element(BoxCorners::from(values))), + Self::TransferCurve(points) => Box::new(Item::new_from_element(TransferCurve::from(points))), Self::GradientRamp(ramp) => Box::new(Item::::from(ramp)), Self::Strokes(strokes) => { let list: List = strokes.into_iter().map(core_types::list::Item::new_from_element).collect(); @@ -267,6 +272,7 @@ macro_rules! tagged_value { } Self::DashPattern(lengths) => Arc::new(Item::new_from_element(DashPattern::from(lengths))), Self::BoxCorners(values) => Arc::new(Item::new_from_element(BoxCorners::from(values))), + Self::TransferCurve(points) => Arc::new(Item::new_from_element(TransferCurve::from(points))), Self::GradientRamp(ramp) => Arc::new(Item::::from(ramp)), Self::Strokes(strokes) => { let list: List = strokes.into_iter().map(core_types::list::Item::new_from_element).collect(); @@ -300,6 +306,7 @@ macro_rules! tagged_value { Self::F64Array(_) => list!(f64), Self::DashPattern(_) => item!(DashPattern), Self::BoxCorners(_) => item!(BoxCorners), + Self::TransferCurve(_) => item!(TransferCurve), Self::GradientRamp(_) => item!(Gradient), Self::Strokes(_) => list!(Stroke), Self::BrushCache(_) => item!(BrushCache), @@ -339,6 +346,8 @@ macro_rules! tagged_value { x if x == TypeId::of::>() => Ok(TaggedValue::DashPattern(downcast::>(input).unwrap().into_element().0.iter_element_values().copied().collect())), x if x == TypeId::of::() => Ok(TaggedValue::BoxCorners(downcast::(input).unwrap().0.iter_element_values().copied().collect())), x if x == TypeId::of::>() => Ok(TaggedValue::BoxCorners(downcast::>(input).unwrap().into_element().0.iter_element_values().copied().collect())), + x if x == TypeId::of::() => Ok(TaggedValue::TransferCurve(downcast::(input).unwrap().points().to_vec())), + x if x == TypeId::of::>() => Ok(TaggedValue::TransferCurve(downcast::>(input).unwrap().into_element().points().to_vec())), x if x == TypeId::of::() => Ok(TaggedValue::GradientRamp(GradientRamp::from(*downcast::(input).unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(&*downcast::>(input).unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::Strokes(downcast::>(input).unwrap().into_iter().map(Item::into_element).collect())), @@ -373,6 +382,8 @@ macro_rules! tagged_value { x if x == TypeId::of::>() => Ok(TaggedValue::DashPattern(input.downcast_ref::>().unwrap().element().0.iter_element_values().copied().collect())), x if x == TypeId::of::() => Ok(TaggedValue::BoxCorners(input.downcast_ref::().unwrap().0.iter_element_values().copied().collect())), x if x == TypeId::of::>() => Ok(TaggedValue::BoxCorners(input.downcast_ref::>().unwrap().element().0.iter_element_values().copied().collect())), + x if x == TypeId::of::() => Ok(TaggedValue::TransferCurve(input.downcast_ref::().unwrap().points().to_vec())), + x if x == TypeId::of::>() => Ok(TaggedValue::TransferCurve(input.downcast_ref::>().unwrap().element().points().to_vec())), x if x == TypeId::of::() => Ok(TaggedValue::GradientRamp(GradientRamp::from(input.downcast_ref::().unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(input.downcast_ref::>().unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::Strokes(input.downcast_ref::>().unwrap().iter_element_values().cloned().collect())), @@ -403,6 +414,7 @@ macro_rules! tagged_value { if name == std::any::type_name::() { return Some(TaggedValue::GradientRamp(GradientRamp::default())) } if name == std::any::type_name::() { return Some(TaggedValue::DashPattern(Vec::new())) } if name == std::any::type_name::() { return Some(TaggedValue::BoxCorners(Vec::new())) } + if name == std::any::type_name::() { return Some(TaggedValue::TransferCurve(TransferCurve::default().points().to_vec())) } $( if name == std::any::type_name::<$ty>() { return Some(TaggedValue::$identifier(Default::default())) } )* if name == std::any::type_name::>() { return Some(TaggedValue::Strokes(Vec::new())) } if name == std::any::type_name::() { return Some(TaggedValue::BrushCache(Default::default())) } @@ -460,6 +472,7 @@ macro_rules! tagged_value { Self::F64Array(values) => format!("F64Array({values:?})"), Self::DashPattern(lengths) => format!("DashPattern({lengths:?})"), Self::BoxCorners(values) => format!("BoxCorners({values:?})"), + Self::TransferCurve(points) => format!("TransferCurve({points:?})"), Self::GradientRamp(ramp) => format!("GradientRamp({ramp:?})"), Self::Strokes(strokes) => format!("Strokes({strokes:?})"), Self::BrushCache(cache) => format!("{cache:?}"), @@ -549,6 +562,7 @@ tagged_value! { DomainWarpType(raster_nodes::adjustments::DomainWarpType), RelativeAbsolute(raster_nodes::adjustments::RelativeAbsolute), SelectiveColorChoice(raster_nodes::adjustments::SelectiveColorChoice), + AdjustmentChannel(raster_nodes::adjustments::AdjustmentChannel), GridType(vector::misc::GridType), ArcType(vector::misc::ArcType), RowsOrColumns(vector::misc::RowsOrColumns), diff --git a/node-graph/graph-craft/src/proto.rs b/node-graph/graph-craft/src/proto.rs index db58d1b779d..ecfe71fea53 100644 --- a/node-graph/graph-craft/src/proto.rs +++ b/node-graph/graph-craft/src/proto.rs @@ -1059,7 +1059,7 @@ mod test { // If this assert fails: These NodeIds seem to be changing when you modify TaggedValue, just update them. assert_eq!( ids, - vec![NodeId(12331852515109999872), NodeId(5084548161767585362), NodeId(14635346976242256925), NodeId(16015195863711239715)] + vec![NodeId(9617677014563055585), NodeId(3306304180790283913), NodeId(4482673701109291121), NodeId(1535890178157254933)] ); } diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index f9df0dceb40..a298900c301 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -20,6 +20,7 @@ use graphene_std::raster::{CPU, Raster}; use graphene_std::render_node::RenderIntermediate; use graphene_std::text::{Font, TextAlign}; use graphene_std::text_nodes::StringCapitalization; +use graphene_std::transfer_curve::TransferCurve; use graphene_std::transform::{Footprint, ReferencePoint, ScaleType}; use graphene_std::vector::misc::{ ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, @@ -54,6 +55,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), @@ -126,6 +128,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), + async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), @@ -343,6 +346,7 @@ fn node_registry() -> HashMap HashMap); + +impl Default for TransferCurve { + /// The straight line from (0, 0) to (1, 1). + fn default() -> Self { + Self::new(vec![DVec2::ZERO, DVec2::ONE]) + } +} + +impl TransferCurve { + /// Builds a curve from points in any order. + pub fn new(mut points: Vec) -> Self { + points.sort_by(|a, b| a.x.total_cmp(&b.x)); + Self::from(points) + } + + /// The control points in the order they are stored, which a drag may carry out of x order. + pub fn points(&self) -> &[DVec2] { + self.0.iter_element_values().as_slice() + } + + /// Whether every control point sits on the y=x diagonal, so the curve leaves the values between them unchanged. + pub fn is_identity(&self) -> bool { + self.points().iter().all(|point| point.x == point.y) + } + + /// Adds a point ahead of the first one to its right, and returns its index. + pub fn insert_point(&mut self, point: DVec2) -> usize { + let index = self.points().iter().position(|existing| existing.x > point.x).unwrap_or(self.0.len()); + + // The list has no insert of its own, so the points are laid out fresh around the new one + let mut points = self.points().to_vec(); + points.insert(index, point); + self.0 = points.into_iter().map(Item::new_from_element).collect(); + + index + } + + pub fn remove_point(&mut self, index: usize) { + if index >= self.0.len() { + return; + } + + let mut points = self.points().to_vec(); + points.remove(index); + self.0 = points.into_iter().map(Item::new_from_element).collect(); + } + + /// Moves a point, which may carry it past others into a new place along the curve while it keeps its index. + pub fn move_point(&mut self, index: usize, point: DVec2) { + let Some(existing) = self.0.element_mut(index) else { return }; + *existing = point; + } + + /// Prepares the curve for repeated sampling: the spline through the points is solved once here rather than + /// on every [`TransferCurveEvaluator::evaluate`] call. + pub fn evaluator(&self) -> TransferCurveEvaluator { + TransferCurveEvaluator::new(self.points()) + } + + /// Samples the curve at `x`. Looping over many values should be done by holding a [`TransferCurve::evaluator`] instead. + pub fn evaluate(&self, x: f64) -> f64 { + self.evaluator().evaluate(x) + } +} + +impl From> for TransferCurve { + fn from(points: Vec) -> Self { + Self(points.into_iter().map(Item::new_from_element).collect()) + } +} + +impl From> for TransferCurve { + fn from(points: List) -> Self { + Self(points) + } +} + +/// A curve prepared for repeated sampling by [`TransferCurve::evaluator`]: +/// a natural cubic spline through the points, whose second derivative vanishes at both ends. +#[derive(Debug, Clone)] +pub struct TransferCurveEvaluator { + points: Vec, + second_derivatives: Vec, +} + +impl TransferCurveEvaluator { + fn new(points: &[DVec2]) -> Self { + let mut points = points.to_vec(); + points.sort_by(|a, b| a.x.total_cmp(&b.x)); + + // Points within epsilon of the same x would make the spline's system singular, so the later-stored one stands alone + points.reverse(); + points.dedup_by(|a, b| (a.x - b.x).abs() <= f64::EPSILON); + points.reverse(); + + let second_derivatives = natural_spline_second_derivatives(&points); + Self { points, second_derivatives } + } + + /// Samples the curve at `x`, holding the outermost points' values beyond them. + pub fn evaluate(&self, x: f64) -> f64 { + let points = &self.points; + match points.len() { + 0 => return x, + 1 => return points[0].y, + _ => {} + } + if x <= points[0].x { + return points[0].y; + } + if x >= points[points.len() - 1].x { + return points[points.len() - 1].y; + } + + // O(log n) search for the segment holding x + let upper = points.partition_point(|point| point.x <= x).min(points.len() - 1); + let lower = upper - 1; + let (a, b) = (points[lower], points[upper]); + let width = (b.x - a.x).max(f64::EPSILON); + + // The cubic segment from its two end second derivatives + let t_b = (x - a.x) / width; + let t_a = 1. - t_b; + let (m_a, m_b) = (self.second_derivatives[lower], self.second_derivatives[upper]); + t_a * a.y + t_b * b.y + ((t_a * t_a * t_a - t_a) * m_a + (t_b * t_b * t_b - t_b) * m_b) * width * width / 6. + } +} + +/// Second derivatives of the natural cubic spline through sorted `points`, solved by the tridiagonal (Thomas) algorithm in O(n). +fn natural_spline_second_derivatives(points: &[DVec2]) -> Vec { + let n = points.len(); + let mut second_derivatives = vec![0.; n]; + if n < 3 { + return second_derivatives; + } + + let width = |i: usize| (points[i + 1].x - points[i].x).max(f64::EPSILON); + let slope = |i: usize| (points[i + 1].y - points[i].y) / width(i); + + // Forward sweep over the interior rows, whose diagonal is 2(h[i-1] + h[i]) with off-diagonals h[i-1] and h[i] + let mut scratch = vec![0.; n]; + for i in 1..n - 1 { + let (h_previous, h_next) = (width(i - 1), width(i)); + let denominator = 2. * (h_previous + h_next) - h_previous * scratch[i - 1]; + scratch[i] = h_next / denominator; + second_derivatives[i] = (6. * (slope(i) - slope(i - 1)) - h_previous * second_derivatives[i - 1]) / denominator; + } + + // Back substitution, with the natural end conditions leaving both ends at zero + for i in (1..n - 1).rev() { + second_derivatives[i] -= scratch[i] * second_derivatives[i + 1]; + } + + second_derivatives +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_and_lines() { + let identity = TransferCurve::default(); + assert!(identity.is_identity()); + assert!((identity.evaluate(0.3) - 0.3).abs() < 1e-12); + + let line = TransferCurve::new(vec![DVec2::new(1., 0.), DVec2::new(0., 1.)]); + assert!((line.evaluate(0.25) - 0.75).abs() < 1e-12); + assert_eq!(line.evaluate(-1.), 1.); + assert_eq!(line.evaluate(2.), 0.); + } + + #[test] + fn spline_passes_through_points_and_stays_smooth() { + let curve = TransferCurve::new(vec![DVec2::ZERO, DVec2::new(0.25, 0.5), DVec2::new(0.75, 0.6), DVec2::ONE]); + let evaluator = curve.evaluator(); + for point in curve.points() { + assert!((evaluator.evaluate(point.x) - point.y).abs() < 1e-12); + } + + // The first derivative is continuous across the interior points + let step = 1e-6; + for point in &curve.points()[1..3] { + let before = (evaluator.evaluate(point.x) - evaluator.evaluate(point.x - step)) / step; + let after = (evaluator.evaluate(point.x + step) - evaluator.evaluate(point.x)) / step; + assert!((before - after).abs() < 1e-3, "kink at {}: {before} vs {after}", point.x); + } + } + + #[test] + fn points_sharing_an_x_leave_the_later_one_standing() { + let curve = TransferCurve::from(vec![DVec2::ZERO, DVec2::new(0.5, 0.2), DVec2::new(0.5, 0.8), DVec2::ONE]); + assert!((curve.evaluate(0.5) - 0.8).abs() < 1e-12); + + // A singular system would send the neighboring segments off to enormous values + for x in [0.1, 0.25, 0.4, 0.6, 0.75, 0.9] { + assert!(curve.evaluate(x).abs() < 2., "runaway value {} at {x}", curve.evaluate(x)); + } + } + + #[test] + fn a_moved_point_may_pass_another_while_keeping_its_index() { + let mut curve = TransferCurve::default(); + assert_eq!(curve.insert_point(DVec2::new(0.5, 0.7)), 1); + + // Carried past the point that was to its right, it stays at its own index and sampling sorts it into its new place + curve.move_point(1, DVec2::new(1.5, 0.2)); + assert_eq!(curve.points()[1], DVec2::new(1.5, 0.2)); + assert_eq!(curve.evaluate(2.), 0.2); + + curve.remove_point(1); + assert!(curve.is_identity()); + } +} diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index e86cf04fe9e..d7833c5ca4d 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -4,7 +4,11 @@ use crate::adjust::Adjust; use crate::cubic_spline::CubicSplines; use core::fmt::Debug; #[cfg(feature = "std")] -use core_types::list::Item; +use core_types::list::{Item, List}; +#[cfg(feature = "std")] +use core_types::transfer_curve::{TransferCurve, TransferCurveEvaluator}; +#[cfg(feature = "std")] +use glam::DVec2; use glam::Vec3; use no_std_types::color::{Color, linear_to_srgb, srgb_to_linear}; use no_std_types::context::Ctx; @@ -263,6 +267,23 @@ fn brightness_contrast>( input } +#[repr(u32)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "std", derive(dyn_any::DynAny))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, BufferStruct, FromPrimitive, IntoPrimitive)] +#[widget(Dropdown)] +/// The channel whose settings are shown, with RGB adjusting all three color channels together. +pub enum AdjustmentChannel { + #[default] + #[label("RGB")] + Rgb, + Red, + Green, + Blue, + Alpha, +} + // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=levl%27%20%3D%20Levels // @@ -349,6 +370,59 @@ fn levels>( image } +// Aims for interoperable compatibility with: +// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27curv%27%20%3D%20Curves +// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Curves%20file%20format +// +// Each curve is any number of (x, y) points on 0..1 joined by a natural cubic spline held flat beyond the outermost +// points, and the per-channel curves apply before the composite one, like Levels. The value between those two stages +// stays exact rather than rounding through an 8-bit table, which can leave results a level away from 8-bit pipelines. +// Needs the heap for its curves, so it stays off the shader build for now. +#[cfg(feature = "std")] +#[node_macro::node(category("Raster: Adjustment"), properties("transfer_curves_properties"))] +async fn curves + Send>( + _: impl Ctx, + #[implementations(Raster, Color, Gradient)] image: Item, + curve: Item, + #[name("(Red) Curve")] red_curve: Item, + #[name("(Green) Curve")] green_curve: Item, + #[name("(Blue) Curve")] blue_curve: Item, + #[name("(Alpha) Curve")] alpha_curve: Item, + _channel: Item, +) -> Item { + let mut image = image; + let composite = curve.into_element().evaluator(); + let red = red_curve.into_element().evaluator(); + let green = green_curve.into_element().evaluator(); + let blue = blue_curve.into_element().evaluator(); + let alpha = alpha_curve.into_element().evaluator(); + let map = |channel: &TransferCurveEvaluator, value: f32| composite.evaluate(channel.evaluate(value as f64).clamp(0., 1.)).clamp(0., 1.) as f32; + + image.element_mut().adjust(|color| { + // Curves math operates in gamma space + let [r, g, b, a] = color.to_gamma_srgb_channels(); + + // Alpha stands apart from the composite curve that the three color channels pass through + let a = alpha.evaluate(a as f64).clamp(0., 1.) as f32; + + Color::from_gamma_srgb_channels(map(&red, r), map(&green, g), map(&blue, b), a) + }); + + image +} + +/// Builds a transfer curve from a `Vec2[]` of control points, each mapping the input value at its x to the output value at its y. A smooth spline runs through them, holding the outermost points' values beyond them. +#[cfg(feature = "std")] +#[node_macro::node(category("Raster: Adjustment"), name("Points to Transfer Curve"))] +fn points_to_transfer_curve( + _: impl Ctx, + /// The control points, in any order, with both coordinates on the 0 to 1 range. + points: List, +) -> Item { + let points: Vec = points.iter_element_values().copied().collect(); + Item::new_from_element(TransferCurve::new(points)) +} + // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27blwh%27%20%3D%20Black%20and%20White // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Black%20White%20(Photoshop%20CS3) @@ -1124,7 +1198,10 @@ fn exposure>( #[cfg(feature = "std")] mod _graphene_hash_impls { - use super::{CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice}; + use super::{ + AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, + SelectiveColorChoice, + }; graphene_hash::impl_via_hash!( LuminanceCalculation, RedGreenBlue, @@ -1135,7 +1212,8 @@ mod _graphene_hash_impls { CellularReturnType, DomainWarpType, RelativeAbsolute, - SelectiveColorChoice + SelectiveColorChoice, + AdjustmentChannel ); } From e49e9a80a7a2b16a056bc346c2274794034ddf4e Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 12 Sep 2026 16:26:03 -0700 Subject: [PATCH 19/46] Ease the Smooth gradient interpolation into the ends of an open ramp (#4521) --- .../libraries/vector-types/src/gradient.rs | 48 ++++++++++++++++--- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index 9ba8099e342..5507aa5e2db 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -553,6 +553,14 @@ fn knot_channels(knots: &[GradientStop], gradient_hue_dir channels } +/// The tangent a [`MonotonicSpline`] takes at its two outermost knots: the end secant itself, or half of it as the mean of +/// that secant and the flat continuation beyond the end, so the curve settles into its ends instead of arriving at full slope. +#[derive(Clone, Copy)] +enum EndTangent { + Secant, + HalfSecant, +} + /// A Piecewise Cubic Hermite Interpolating Polynomial (PCHIP) spline, preserving its samples' monotonicity: /// it passes through every sample and joins the pieces with matching slopes, while the Fritsch-Carlson /// limiter keeps each piece bounded by its own two samples, so the curve rises and falls only where its @@ -566,7 +574,7 @@ struct MonotonicSpline { } impl MonotonicSpline { - fn new(position: Vec, value: Vec) -> Self { + fn new(position: Vec, value: Vec, end_tangent: EndTangent) -> Self { let count = position.len(); if count < 2 { let tangent = vec![0.; count]; @@ -580,15 +588,20 @@ impl MonotonicSpline { }) .collect(); + let end_scale = match end_tangent { + EndTangent::Secant => 1., + EndTangent::HalfSecant => 0.5, + }; + // A sign change or a flat run between neighboring secants pins that tangent to zero, // which is what stops the curve from bulging past a local extreme let mut tangent = Vec::with_capacity(count); - tangent.push(secant[0]); + tangent.push(secant[0] * end_scale); for index in 1..count - 1 { let (before, after) = (secant[index - 1], secant[index]); tangent.push(if before * after <= 0. { 0. } else { (before + after) / 2. }); } - tangent.push(secant[count - 2]); + tangent.push(secant[count - 2] * end_scale); // Fritsch-Carlson: pull any tangent pair back inside the radius-3 circle around their shared secant for index in 0..count - 1 { @@ -635,9 +648,10 @@ impl MonotonicSpline { } } -/// The Smooth path: a monoticity-preserving spline per color channel through every stop, traversed by a second such spline that +/// The Smooth path: a monotonicity-preserving spline per color channel through every stop, traversed by a second such spline that /// maps ramp position to spline parameter. Fitting the stop and midpoint constraints into one global warp is what keeps the /// traversal rate continuous across stops, where independent per-interval curves (what Linear uses) would kink at each one. +/// The color splines take [`EndTangent::HalfSecant`] at a ramp's real ends while the warp keeps the full secant, so the color eases but the traversal stays even. struct SmoothPath { space: GradientSpace, hue_index: Option, @@ -677,7 +691,9 @@ impl SmoothPath { let channels = with_space!(settings.space, knot_channels, &knots, settings.hue_direction); let parameter: Vec = (0..knots.len()).map(|index| index as f64).collect(); - let channel = std::array::from_fn(|component| MonotonicSpline::new(parameter.clone(), channels.iter().map(|values| values[component]).collect())); + // Wrapped copies give the end stops neighbors on both sides, so only a ramp with real ends eases into them + let end_tangent = if settings.cyclic && wrapped_interval { EndTangent::Secant } else { EndTangent::HalfSecant }; + let channel = std::array::from_fn(|component| MonotonicSpline::new(parameter.clone(), channels.iter().map(|values| values[component]).collect(), end_tangent)); // Each stop pins its own knot parameter and each midpoint the half-parameter between two, // so one monotonic curve satisfies every midpoint constraint at once @@ -700,7 +716,7 @@ impl SmoothPath { space: settings.space, hue_index: with_space!(settings.space, space_hue_index), channel, - warp: MonotonicSpline::new(warp_position, warp_value), + warp: MonotonicSpline::new(warp_position, warp_value, EndTangent::Secant), } } @@ -1783,7 +1799,7 @@ pub enum GradientInterpolation { /// Transitions straight from each stop to the next, turning a corner at every stop. #[default] Linear, - /// Transitions along a curve that flows through the stops without corners. + /// Transitions along a curve that flows through the stops without corners and settles gently into the two ends. /// /// The rate of color change carries smoothly through each stop (C1 continuity) and never overshoots beyond the stop colors, properties of its spline: a Piecewise Cubic Hermite Interpolating Polynomial (PCHIP) with Fritsch-Carlson tangent limiting. Smooth, @@ -2216,6 +2232,24 @@ mod tests { assert_eq!((last.0, last.1), (1., Color::WHITE)); } + #[test] + fn smooth_eases_into_the_ends_of_an_open_ramp() { + let smooth = GradientSettings { + space: GradientSpace::RgbLinear, + interpolation: GradientInterpolation::Smooth, + ..Default::default() + }; + + let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]); + + // Half the end secant as the end tangent makes a two-stop ramp the cubic 0.5 t + 1.5 t^2 - t^3, which leaves + // and arrives at half speed and crosses the middle at the halfway color + for (t, expected) in [(0.25, 0.203125), (0.5, 0.5), (0.75, 0.796875)] { + let red = gradient.evaluate(t, smooth).r() as f64; + assert!((red - expected).abs() < 1e-4, "expected {expected} at {t}, got {red}"); + } + } + #[test] fn smooth_cyclic_stops_on_both_boundaries_keep_a_hard_seam() { let open = GradientSettings { From 9e14e2d373a8a1613eccefdd1201e070908581b0 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 12 Sep 2026 16:40:48 -0700 Subject: [PATCH 20/46] New node: String to Vec2 (#4522) Add the String to Vec2 node --- node-graph/nodes/text/src/lib.rs | 45 ++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/node-graph/nodes/text/src/lib.rs b/node-graph/nodes/text/src/lib.rs index 9faa704064b..3a7154d9166 100644 --- a/node-graph/nodes/text/src/lib.rs +++ b/node-graph/nodes/text/src/lib.rs @@ -394,6 +394,32 @@ fn string_to_number( Item::from_parts(string.trim().parse::().unwrap_or(*fallback.element()), attributes) } +/// Parses a string like `"3, 4.5"` into a Vec2, using a comma and/or whitespace as separators. Falls back to the chosen value if the string is not a valid pair of numbers. +#[node_macro::node(category("Text"), name("String to Vec2"))] +fn string_to_vec2( + _: impl Ctx, + /// The string containing two numbers separated by a comma or whitespace, like `"3, 4.5"`, optionally wrapped in `(`parentheses`)` or `[`square brackets`]`. Each number follows the same rules as the "String to Number" node. + string: Item, + /// The value of the result if the string cannot be parsed as a valid Vec2. + fallback: Item, +) -> Item { + let (string, attributes) = string.into_parts(); + + let trimmed = string.trim(); + let unwrapped = (trimmed.strip_prefix('(').and_then(|inner| inner.strip_suffix(')'))) + .or_else(|| trimmed.strip_prefix('[').and_then(|inner| inner.strip_suffix(']'))) + .unwrap_or(trimmed); + + // Exactly two numbers, so a longer list is not quietly truncated into a pair + let mut numbers = unwrapped.split(|c: char| c == ',' || c.is_whitespace()).filter(|piece| !piece.is_empty()).map(str::parse::); + let parsed = match (numbers.next(), numbers.next(), numbers.next()) { + (Some(Ok(x)), Some(Ok(y)), None) => DVec2::new(x, y), + _ => *fallback.element(), + }; + + Item::from_parts(parsed, attributes) +} + /// Removes leading and/or trailing whitespace from a string. Common whitespace characters include spaces, tabs, and newlines. #[node_macro::node(category("Text"))] fn string_trim( @@ -891,3 +917,22 @@ fn serialize(_: impl Ctx, #[implementations(String, bool, f Item::from_parts(result, attributes) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn string_to_vec2_accepts_pairs_and_falls_back_otherwise() { + let fallback = DVec2::new(-1., -1.); + let parse = |text: &str| string_to_vec2((), Item::new_from_element(text.to_string()), Item::new_from_element(fallback)).into_element(); + + for text in ["3, 4.5", "3 4.5", " 3,4.5 ", "(3, 4.5)", "[3, 4.5]", "3e0,\t4.5"] { + assert_eq!(parse(text), DVec2::new(3., 4.5), "{text:?} should parse"); + } + + for text in ["", "3", "3, 4, 5", "3, four", "(3, 4.5]"] { + assert_eq!(parse(text), fallback, "{text:?} should fall back"); + } + } +} From 297ed2bd246f9e67239afde38b32c67c9916d3fd Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 12 Sep 2026 16:52:32 -0700 Subject: [PATCH 21/46] Rename the 'Hex to Color' node to 'String to Color' (#4523) --- editor/src/messages/portfolio/document_migration.rs | 4 ++++ node-graph/nodes/math/src/lib.rs | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 792aa807233..1fe2976c121 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -399,6 +399,10 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ node: graphene_std::math_nodes::sine_inverse::IDENTIFIER, aliases: &["graphene_math_nodes::SineInverseNode", "graphene_core::ops::SineInverseNode"], }, + NodeReplacement { + node: graphene_std::math_nodes::string_to_color::IDENTIFIER, + aliases: &["math_nodes::HexToColorNode"], + }, NodeReplacement { node: graphene_std::math_nodes::subtract::IDENTIFIER, aliases: &["graphene_math_nodes::SubtractNode", "graphene_core::ops::SubtractNode"], diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index c793a8f9612..4c3b65917e3 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -1365,9 +1365,9 @@ fn hsla_to_color( } /// Constructs a color value from a CSS color string. Accepts hex (`#RRGGBB`, `#RRGGBBAA`, plus bare and shorthand variants), CSS named colors (like `red`), and functional notations (`rgb(...)`, `hsl(...)`, etc.). Invalid inputs produce a transparent color. -#[node_macro::node(category("Color"), name("Hex to Color"))] -fn hex_to_color(_: impl Ctx, hex_code: Item) -> Item { - let color = core_types::misc::parse_css_color(hex_code.element()).unwrap_or_default(); +#[node_macro::node(category("Color"), name("String to Color"))] +fn string_to_color(_: impl Ctx, string: Item) -> Item { + let color = core_types::misc::parse_css_color(string.element()).unwrap_or_default(); Item::new_from_element(color) } From b2737ac7ec63db85805cba826d776b138983e559 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 12 Sep 2026 18:24:38 -0700 Subject: [PATCH 22/46] Make 'Gradient Map' use gamma-space luma and move it into adjustments.rs (#4524) Move Gradient Map into adjustments.rs and position it by the classic 0.3/0.59/0.11 luma --- .../messages/portfolio/document_migration.rs | 2 +- node-graph/nodes/raster/src/adjustments.rs | 39 +++++++++++++++++++ node-graph/nodes/raster/src/gradient_map.rs | 36 ----------------- node-graph/nodes/raster/src/lib.rs | 2 - 4 files changed, 40 insertions(+), 39 deletions(-) delete mode 100644 node-graph/nodes/raster/src/gradient_map.rs diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 1fe2976c121..dfb2ed6125b 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -539,7 +539,7 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ aliases: &["graphene_raster_nodes::adjustments::GammaCorrectionNode", "graphene_core::raster::adjustments::GammaCorrectionNode"], }, NodeReplacement { - node: graphene_std::raster_nodes::gradient_map::gradient_map::IDENTIFIER, + node: graphene_std::raster_nodes::adjustments::gradient_map::IDENTIFIER, aliases: &[ "graphene_raster_nodes::gradient_map::GradientMapNode", "graphene_raster_nodes::adjustments::GradientMapNode", diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index d7833c5ca4d..3c760a0d967 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -613,6 +613,45 @@ fn threshold>( image } +// Aims for interoperable compatibility with: +// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27grdm%27%20%3D%20Gradient%20Map +// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Gradient%20settings%20(Photoshop%206.0) +// +// TODO: Full PSD interop needs a compatibility variant of `GradientInterpolation` with its own midpoint semantics, position warp, +// TODO: and smoothing (a `gradient_smoothness` attribute), plus noise gradients, which we don't yet support. +// TODO: Its axes differ from ours: its midpoint is always a knee in the position warp and its smoothness blends the curve over +// TODO: that fixed warp, while each variant here picks warp and curve together, so neither end of the blend is Linear or Smooth. +// TODO: Per channel in the gradient space (measured on gamma RGB): +// TODO: - Position t maps to a parameter p by a piecewise-linear knee through (stop position, index) and (midpoint, index - 0.5). +// TODO: - Linear lerps the interval's stop colors by the fraction of p. Smooth is a cubic Hermite over the stop index with tangent +// TODO: `(c[i + 1] - c[i - 1]) / 2`, the end stops repeated past the ends, so two stops give `0.5 p + 1.5 p^2 - p^3`. +// TODO: - The ramp is `(1 - s) * linear + s * smooth` for smoothness s, clamped per interval to its two stop colors. +#[cfg(feature = "std")] +#[node_macro::node(category("Raster: Adjustment"))] +async fn gradient_map + Send>( + _: impl Ctx, + #[implementations(Raster, Color, Gradient)] image: Item, + #[default(Color::BLACK, Color::WHITE)] gradient: Item, + reverse: Item, +) -> Item { + let mut image = image; + let settings = vector_types::GradientSettings::from(&gradient); + let evaluator = gradient.into_element().evaluator(settings); + let reverse = reverse.into_element(); + + image.element_mut().adjust(|color| { + // The classic 0.3/0.59/0.11 luma of the gamma-encoded channels picks the position along the gradient + let [r, g, b, alpha] = color.to_gamma_srgb_channels(); + let intensity = 0.3 * r + 0.59 * g + 0.11 * b; + let intensity = if reverse { 1. - intensity } else { intensity }; + + // The source alpha is kept and the gradient's own alpha stops are ignored + evaluator.evaluate(intensity as f64).with_alpha(alpha) + }); + + image +} + // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27-,vibA%27%20%3D%20Vibrance,-%27hue%20%27%20%3D%20Old // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Vibrance%20(Photoshop%20CS3) diff --git a/node-graph/nodes/raster/src/gradient_map.rs b/node-graph/nodes/raster/src/gradient_map.rs deleted file mode 100644 index 51ed59ad822..00000000000 --- a/node-graph/nodes/raster/src/gradient_map.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Not immediately shader compatible due to needing [`Gradient`] as a param, which needs [`Vec`] - -use crate::adjust::Adjust; -use core_types::list::Item; -use core_types::{Color, Ctx}; -use raster_types::{CPU, Raster}; -use vector_types::Gradient; - -// Aims for interoperable compatibility with: -// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27grdm%27%20%3D%20Gradient%20Map -// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Gradient%20settings%20(Photoshop%206.0) -#[node_macro::node(category("Raster: Adjustment"))] -async fn gradient_map + Send>( - _: impl Ctx, - #[implementations( - Raster, - Color, - Gradient, - )] - image: Item, - #[default(Color::BLACK, Color::WHITE)] gradient: Item, - reverse: Item, -) -> Item { - let mut image = image; - let settings = vector_types::GradientSettings::from(&gradient); - let evaluator = gradient.into_element().evaluator(settings); - let reverse = reverse.into_element(); - - image.element_mut().adjust(|color| { - let intensity = color.luminance_rec_709(); - let intensity = if reverse { 1. - intensity } else { intensity }; - evaluator.evaluate(intensity as f64) - }); - - image -} diff --git a/node-graph/nodes/raster/src/lib.rs b/node-graph/nodes/raster/src/lib.rs index c3a7699c8cf..cd982d4890b 100644 --- a/node-graph/nodes/raster/src/lib.rs +++ b/node-graph/nodes/raster/src/lib.rs @@ -15,8 +15,6 @@ pub mod dehaze; #[cfg(feature = "std")] pub mod filter; #[cfg(feature = "std")] -pub mod gradient_map; -#[cfg(feature = "std")] pub mod image_color_palette; #[cfg(feature = "std")] pub mod std_nodes; From eb142401135e2bebe4624025c1d228eb400b3650 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 12 Sep 2026 19:32:51 -0700 Subject: [PATCH 23/46] Fix defaulted Item parameters dropping their type alias and rejecting hex string color defaults (#4525) --- node-graph/graph-craft/src/document/value.rs | 41 ++++++++++++-------- node-graph/node-macro/src/codegen.rs | 7 ++-- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index bfd3c2607be..fb5b184fb16 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -618,21 +618,13 @@ impl TaggedValue { } fn to_color(input: &str) -> Option { - // String syntax (e.g. "000000ff") - if input.starts_with('"') && input.ends_with('"') { - let hex = input.trim().trim_matches('"').trim().trim_start_matches('#'); - let color = SRGBA8::from_hex_str(hex).map(Color::from); - if color.is_none() { - log::error!("Invalid default value color string: {input}"); - } - return color; - } - // Color constant syntax (e.g. Color::BLACK) - let mut choices = input.split("::"); - let (first, second) = (choices.next()?.trim(), choices.next()?.trim()); - if first == "Color" { - return Some(match second { + if let Some((first, second)) = input.split_once("::") { + if first.trim() != "Color" { + log::error!("Invalid default value color: {input}"); + return None; + } + return Some(match second.trim() { "BLACK" => Color::BLACK, "WHITE" => Color::WHITE, "RED" => Color::RED, @@ -649,8 +641,13 @@ impl TaggedValue { }); } - log::error!("Invalid default value color: {input}"); - None + // Hex syntax (e.g. "000000ff"), which a string literal default reaches here without its quotes + let hex = input.trim().trim_matches('"').trim().trim_start_matches('#'); + let color = SRGBA8::from_hex_str(hex).map(Color::from); + if color.is_none() { + log::error!("Invalid default value color string: {input}"); + } + color } fn to_gradient(input: &str) -> Option { @@ -1039,6 +1036,18 @@ mod paint_default_parsing { ); } + /// A hex string default reaches the parser without the quotes its literal had in the node signature, and must still parse. + #[test] + fn hex_string_color_default_parses_without_quotes() { + let tint = Some(TaggedValue::Color(Color::from(SRGBA8::new(225, 211, 179, 255)))); + assert_eq!(TaggedValue::from_primitive_string("e1d3b3", &item!(Color)), tint, "a bare hex default should resolve"); + assert_eq!( + TaggedValue::from_primitive_string("\"#e1d3b3\"", &item!(Color)), + tint, + "a quoted, hash-prefixed hex default should resolve" + ); + } + /// Table-era documents stored the red-slash "no paint" fill as an empty color table, which must keep /// deserializing to [`TaggedValue::no_paint`] rather than collapsing to a transparent color. #[test] diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index aacec0d8006..6485d058c05 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -228,8 +228,9 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn quote!(Some(concrete!(#implementation_ty))) } } - // A concrete ranked `Item` param's scalar `#[default]` parses as a bare `T` literal (unranked, promoted at resolution); - // without one it keeps the structural `Type::Item` wire type with the element's alias on its descriptor (so the rank-0 Properties widget still dispatches, e.g. `Progression`), and `node_inputs` peels to `T` if no `Item` type default exists + // A concrete ranked `Item` param's scalar `#[default]` parses as a bare `T` literal (unranked, promoted at resolution); without one it keeps + // the structural `Type::Item` wire type, and `node_inputs` peels to `T` if no `Item` type default exists. Either way the element's alias stays + // on its descriptor so the rank-0 Properties widget still dispatches, e.g. `Progression`. None => match &field.ty { ParsedFieldType::Item { field: RegularParsedField { value_source, .. }, @@ -241,7 +242,7 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn // The fn's lifetimes are elided since the metadata registration fn declares none of them let element = substitute_lifetimes(element.clone(), "_"); match value_source { - ParsedValueSource::Default(_) => quote!(Some(concrete!(#element))), + ParsedValueSource::Default(_) => quote!(Some(concrete!(#element, #element))), _ => quote!(Some(#core_types::item!(#element, #element))), } } From bfe89098551fc68447a6bdbcb96e902606d8403a Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 12 Sep 2026 22:11:51 -0700 Subject: [PATCH 24/46] New node: Color Balance (#4527) --- .../node_graph/document_node_definitions.rs | 1 + .../document/node_graph/node_properties.rs | 48 +++- node-graph/graph-craft/src/document/value.rs | 1 + .../interpreted-executor/src/node_registry.rs | 1 + node-graph/nodes/raster/src/adjustments.rs | 224 +++++++++++++++++- 5 files changed, 268 insertions(+), 7 deletions(-) diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index ef8bae14b68..78e9019a621 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -919,6 +919,7 @@ fn static_node_properties() -> NodeProperties { map.insert("black_and_white_properties".to_string(), Box::new(node_properties::black_and_white_properties)); map.insert("threshold_properties".to_string(), Box::new(node_properties::threshold_properties)); map.insert("vibrance_properties".to_string(), Box::new(node_properties::vibrance_properties)); + map.insert("color_balance_properties".to_string(), Box::new(node_properties::color_balance_properties)); map.insert("fill_properties".to_string(), Box::new(node_properties::fill_properties)); map.insert("stroke_properties".to_string(), Box::new(node_properties::stroke_properties)); map.insert("offset_path_properties".to_string(), Box::new(node_properties::offset_path_properties)); diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 8719ff65785..4d8a7c451d6 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -21,7 +21,7 @@ use graphene_std::color::SRGBA8; use graphene_std::extract_xy::XY; use graphene_std::raster::{ AdjustmentChannel, BlendMode, CellularDistanceFunction, CellularReturnType, Color, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, - SelectiveColorChoice, + SelectiveColorChoice, TonalRange, }; use graphene_std::raster_types::Image; use graphene_std::text::{Font, TextAlign}; @@ -318,6 +318,7 @@ pub(crate) fn property_from_type( Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), + Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), @@ -1676,6 +1677,51 @@ pub(crate) fn vibrance_properties(node_id: NodeId, context: &mut NodePropertiesC )] } +pub(crate) fn color_balance_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { + use graphene_std::raster::color_balance::*; + + let mut tone_info = ParameterWidgetsInfo::new(node_id, ToneInput, true, context); + tone_info.exposable = false; + let tone = enum_choice::().for_socket(tone_info).property_row(); + let preserve_luminosity = bool_widget(ParameterWidgetsInfo::new(node_id, PreserveLuminosityInput, true, context), CheckboxInput::default()); + + let document_node = match get_document_node(node_id, context) { + Ok(document_node) => document_node, + Err(err) => { + log::error!("Could not get document node in color_balance_properties: {err}"); + return Vec::new(); + } + }; + let tone_choice = match document_node.input_value(ToneInput) { + Some(TaggedValue::TonalRange(choice)) => *choice, + _ => { + warn!("Color Balance node properties panel could not be displayed."); + return vec![]; + } + }; + + // Only the selected tone's three sliders are shown + let parameters: [ParameterRef; 3] = match tone_choice { + TonalRange::Shadows => [ShadowsCyanRedInput.into(), ShadowsMagentaGreenInput.into(), ShadowsYellowBlueInput.into()], + TonalRange::Midtones => [MidtonesCyanRedInput.into(), MidtonesMagentaGreenInput.into(), MidtonesYellowBlueInput.into()], + TonalRange::Highlights => [HighlightsCyanRedInput.into(), HighlightsMagentaGreenInput.into(), HighlightsYellowBlueInput.into()], + }; + let tracks = [ + Gradient::from(vec![Color::CYAN, Color::RED]), + Gradient::from(vec![Color::MAGENTA, Color::GREEN]), + Gradient::from(vec![Color::YELLOW, Color::BLUE]), + ]; + let number_input = NumberInput::default().mode_increment().unit("%").min(-100.).max(100.); + + let mut layout = vec![tone]; + for (parameter, track) in parameters.into_iter().zip(tracks) { + layout.push(spectrum_slider_row(node_id, context, parameter, track, Color::WHITE, -100., 100., 0., number_input.clone())); + } + layout.push(LayoutGroup::row(preserve_luminosity)); + + layout +} + pub(crate) fn black_and_white_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { use graphene_std::raster::black_and_white::*; diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index fb5b184fb16..198cc9affa7 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -562,6 +562,7 @@ tagged_value! { DomainWarpType(raster_nodes::adjustments::DomainWarpType), RelativeAbsolute(raster_nodes::adjustments::RelativeAbsolute), SelectiveColorChoice(raster_nodes::adjustments::SelectiveColorChoice), + TonalRange(raster_nodes::adjustments::TonalRange), AdjustmentChannel(raster_nodes::adjustments::AdjustmentChannel), GridType(vector::misc::GridType), ArcType(vector::misc::ArcType), diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index a298900c301..cc20a0832ca 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -356,6 +356,7 @@ fn node_registry() -> HashMap>( input } +#[repr(u32)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "std", derive(dyn_any::DynAny))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, BufferStruct, FromPrimitive, IntoPrimitive)] +#[widget(Dropdown)] +pub enum TonalRange { + Shadows, + #[default] + Midtones, + Highlights, +} + +/// A Levels-style tone curve: input black and white points on a 0..255 scale and a gamma exponent. For gamma above 1 the +/// power curve's slope is unbounded at black, so a cubic toe holds it to 2^gamma until past where that line meets the curve. +#[derive(Debug, Clone, Copy)] +struct LevelsCurve { + black: f32, + white: f32, + exponent: f32, + toe_end: f32, + toe_value: f32, + toe_slope_start: f32, + toe_slope_end: f32, +} + +impl LevelsCurve { + fn new(black: i32, white: i32, gamma: f32) -> Self { + Self::from_points(black as f32, white as f32, gamma) + } + + /// `gamma` is the Levels dialog value (pixel exponent 1/gamma). + fn from_points(black: f32, white: f32, gamma: f32) -> Self { + let gamma = gamma.max(0.01); + let black = black.min(white - 1.); + let exponent = 1. / gamma; + + let mut curve = Self { + black, + white, + exponent, + toe_end: 0., + toe_value: 0., + toe_slope_start: 0., + toe_slope_end: 0., + }; + if gamma > 1. { + let slope = 2_f32.powf(gamma); + let intersection = 255. * slope.powf(-1. / (1. - exponent)); + // The cubic toe joins the power curve at twice the intersection + if intersection > 1e-3 { + let toe_end = 2. * intersection; + curve.toe_end = toe_end; + curve.toe_value = 255. * (toe_end / 255.).powf(exponent); + curve.toe_slope_start = slope; + curve.toe_slope_end = exponent * (toe_end / 255.).powf(exponent - 1.); + } + } + curve + } + + /// Maps one gamma-space channel value in 0..1. + fn apply(&self, value: f32) -> f32 { + let t = ((value * 255. - self.black) * 255. / (self.white - self.black)).clamp(0., 255.); + let y = if t < self.toe_end { + let u = t / self.toe_end; + let hermite_start = u * u * u - 2. * u * u + u; + let hermite_end_value = 3. * u * u - 2. * u * u * u; + let hermite_end_slope = u * u * u - u * u; + hermite_start * self.toe_end * self.toe_slope_start + hermite_end_value * self.toe_value + hermite_end_slope * self.toe_end * self.toe_slope_end + } else { + 255. * (t / 255.).powf(self.exponent) + }; + y / 255. + } +} + +/// One channel's Levels parameters from its own slider values, and (with preserve_luminosity) the slider +/// extremes across all three channels. The halvings truncate toward zero, as PSD interop requires. +fn color_balance_curve(s: i32, m: i32, h: i32, s_max: i32, m_max: i32, m_min: i32, h_min: i32, preserve_luminosity: bool) -> LevelsCurve { + let (black, white, tone) = if preserve_luminosity { + (s_max - s, 255 - (h - h_min), m - (m_max + m_min) / 2) + } else { + (0.max(-s), 255 - 0.max(h), (s + h) / 2 + m) + }; + + // Rounding the derived gamma to hundredths, the precision of a PSD Levels record, is needed for compatible results + let gamma = (2_f32.powf(tone as f32 / 100.) * 100.).round() / 100.; + LevelsCurve::new(black, white, gamma) +} + +// Aims for interoperable compatibility with: +// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27blnc%27%20%3D%20Color%20Balance +// +// Every channel is a Levels curve whose black point, white point, and two-decimal gamma are derived from +// the nine sliders, see `color_balance_curve`. +#[node_macro::node(category("Raster: Adjustment"), properties("color_balance_properties"), shader_node(PerPixelAdjust))] +fn color_balance>( + _: impl Ctx, + #[implementations(Raster, Color, Gradient)] + #[gpu_image] + image: Item, + + #[name("(Shadows) Cyan-Red")] shadows_cyan_red: Item, + #[name("(Shadows) Magenta-Green")] shadows_magenta_green: Item, + #[name("(Shadows) Yellow-Blue")] shadows_yellow_blue: Item, + + #[name("(Midtones) Cyan-Red")] midtones_cyan_red: Item, + #[name("(Midtones) Magenta-Green")] midtones_magenta_green: Item, + #[name("(Midtones) Yellow-Blue")] midtones_yellow_blue: Item, + + #[name("(Highlights) Cyan-Red")] highlights_cyan_red: Item, + #[name("(Highlights) Magenta-Green")] highlights_magenta_green: Item, + #[name("(Highlights) Yellow-Blue")] highlights_yellow_blue: Item, + + #[default(true)] preserve_luminosity: Item, + + // Display-only property (not used within the node) + _tone: Item, +) -> Item { + let mut image = image; + let preserve_luminosity = preserve_luminosity.into_element(); + + // The derivation below is integer arithmetic, so the sliders round to whole percentages first + let slider = |value: Item| value.into_element().clamp(-100., 100.).round() as i32; + let (s_r, s_g, s_b) = (slider(shadows_cyan_red), slider(shadows_magenta_green), slider(shadows_yellow_blue)); + let (m_r, m_g, m_b) = (slider(midtones_cyan_red), slider(midtones_magenta_green), slider(midtones_yellow_blue)); + let (h_r, h_g, h_b) = (slider(highlights_cyan_red), slider(highlights_magenta_green), slider(highlights_yellow_blue)); + + let s_max = s_r.max(s_g).max(s_b); + let m_max = m_r.max(m_g).max(m_b); + let m_min = m_r.min(m_g).min(m_b); + let h_min = h_r.min(h_g).min(h_b); + let red = color_balance_curve(s_r, m_r, h_r, s_max, m_max, m_min, h_min, preserve_luminosity); + let green = color_balance_curve(s_g, m_g, h_g, s_max, m_max, m_min, h_min, preserve_luminosity); + let blue = color_balance_curve(s_b, m_b, h_b, s_max, m_max, m_min, h_min, preserve_luminosity); + + image.element_mut().adjust(|color| { + // The curves operate on gamma-space channel values + let [r, g, b, a] = color.to_gamma_srgb_channels(); + Color::from_gamma_srgb_channels(red.apply(r), green.apply(g), blue.apply(b), a) + }); + image +} + #[cfg(feature = "std")] mod _graphene_hash_impls { use super::{ AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, - SelectiveColorChoice, + SelectiveColorChoice, TonalRange, }; graphene_hash::impl_via_hash!( LuminanceCalculation, @@ -1252,7 +1393,8 @@ mod _graphene_hash_impls { DomainWarpType, RelativeAbsolute, SelectiveColorChoice, - AdjustmentChannel + AdjustmentChannel, + TonalRange, ); } @@ -1271,3 +1413,73 @@ mod test { assert!((a - 0.5).abs() < 1e-5, "alpha was {a}"); } } + +#[cfg(all(feature = "std", test))] +mod color_balance_tests { + use super::*; + + /// Runs Color Balance on one gamma-space RGB value (0..255) and returns the gamma-space result on the same scale. + fn run(input: [f32; 3], shadows: [f32; 3], midtones: [f32; 3], highlights: [f32; 3], preserve_luminosity: bool) -> [f32; 3] { + let color = Color::from_gamma_srgb_channels(input[0] / 255., input[1] / 255., input[2] / 255., 1.); + let result = color_balance( + (), + Item::new_from_element(color), + shadows[0].into(), + shadows[1].into(), + shadows[2].into(), + midtones[0].into(), + midtones[1].into(), + midtones[2].into(), + highlights[0].into(), + highlights[1].into(), + highlights[2].into(), + preserve_luminosity.into(), + TonalRange::Midtones.into(), + ); + let [r, g, b, _] = result.into_element().to_gamma_srgb_channels(); + [r * 255., g * 255., b * 255.] + } + + /// Matched to within one 8-bit level. + fn assert_close(actual: [f32; 3], expected: [f32; 3]) { + for (actual, expected) in actual.iter().zip(expected) { + assert!((actual - expected).abs() <= 1., "expected {expected}, got {actual}"); + } + } + + #[test] + fn midtones_are_a_gamma_with_a_toe() { + let none = [0., 0., 0.]; + assert_close(run([100., 100., 100.], none, [100., 0., 0.], none, false), [160., 100., 100.]); + assert_close(run([200., 200., 200.], none, [100., 0., 0.], none, false), [226., 200., 200.]); + assert_close(run([4., 4., 4.], none, [100., 0., 0.], none, false), [16., 4., 4.]); + assert_close(run([1., 1., 1.], none, [100., 0., 0.], none, false), [4., 1., 1.]); + assert_close(run([100., 100., 100.], none, [-100., 0., 0.], none, false), [39., 100., 100.]); + } + + #[test] + fn shadows_and_highlights_move_the_end_points() { + let none = [0., 0., 0.]; + assert_close(run([100., 100., 100.], [-100., 0., 0.], none, none, false), [0., 100., 100.]); + assert_close(run([150., 150., 150.], [-100., 0., 0.], none, none, false), [52., 150., 150.]); + assert_close(run([200., 200., 200.], [-100., 0., 0.], none, none, false), [138., 200., 200.]); + assert_close(run([100., 100., 100.], none, none, [100., 0., 0.], false), [187., 100., 100.]); + assert_close(run([155., 155., 155.], none, none, [100., 0., 0.], false), [255., 155., 155.]); + } + + #[test] + fn preserve_luminosity_makes_sliders_relative() { + let none = [0., 0., 0.]; + assert_close(run([90., 90., 90.], none, [-100., 0., 0.], none, true), [59., 122., 122.]); + assert_close(run([90., 90., 90.], [50., 0., 0.], none, none, true), [90., 50., 50.]); + assert_close(run([120., 120., 120.], none, none, [-100., 0., 0.], true), [120., 197., 197.]); + assert_close(run([90., 90., 90.], [100., 100., 100.], [100., 100., 100.], [100., 100., 100.], true), [90., 90., 90.]); + } + + #[test] + fn combined_tones_use_integer_arithmetic() { + // Red: black 39, white 235, gamma 1.09; green: gamma 0.91; blue: white 210, gamma 1.13 + let result = run([128., 128., 128.], [-39., 6., 42.], [21., 0., -25.], [20., -35., 45.], false); + assert_close(result, [124., 120., 164.]); + } +} From fbd782e54a745bbaf6e9f175519d9953bf4b2523 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 13 Sep 2026 03:02:27 -0700 Subject: [PATCH 25/46] Remove the 'Threshold' node's "Luminance Calculation" parameter (#4528) Remove the 'Threshold' node's luminance calculation dropdown so it always compares the Rec. 601 luma --- .../document/node_graph/node_properties.rs | 9 +-- .../messages/portfolio/document_migration.rs | 13 ++++ node-graph/nodes/raster/src/adjustments.rs | 62 +++++++++++++------ 3 files changed, 56 insertions(+), 28 deletions(-) diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 4d8a7c451d6..d1f120b1636 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -1647,16 +1647,9 @@ pub(crate) fn threshold_properties(node_id: NodeId, context: &mut NodeProperties let params: &[(ParameterRef, Color, f64)] = &[(MinLuminanceInput.into(), Color::BLACK, 50.), (MaxLuminanceInput.into(), Color::WHITE, 100.)]; - let mut layout = Vec::with_capacity(3); + let mut layout = Vec::with_capacity(2); build_shared_spectrum_section(node_id, context, params, &mut layout); - let luminance_calc = { - let mut info = ParameterWidgetsInfo::new(node_id, LuminanceCalcInput, true, context); - info.exposable = false; - enum_choice::().for_socket(info).property_row() - }; - layout.push(luminance_calc); - layout } diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index dfb2ed6125b..d3b1ebf01dd 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -2182,6 +2182,19 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], } } + // The Threshold node's luminance calculation input was retired + if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster_nodes::adjustments::threshold::IDENTIFIER) && inputs_count == 4 { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + document.network_interface.replace_implementation(node_id, network_path, &mut node_template); + + let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + + for (i, input) in old_inputs.iter().enumerate().take(3) { + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, i), input.clone(), network_path); + } + inputs_count = 3; + } + if reference == DefinitionIdentifier::ProtoNode(graphene_std::repeat::repeat_on_points::IDENTIFIER) && inputs_count == 2 { let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); document.network_interface.replace_implementation(node_id, network_path, &mut node_template); diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index bec5d01eb14..0460cc30622 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -576,39 +576,61 @@ fn invert>( #[node_macro::node(category("Raster: Adjustment"), properties("threshold_properties"), shader_node(PerPixelAdjust))] fn threshold>( _: impl Ctx, - #[implementations( - Raster, - Color, - Gradient, - )] + #[implementations(Raster, Color, Gradient)] #[gpu_image] image: Item, #[default(50.)] min_luminance: Item, #[default(100.)] max_luminance: Item, - luminance_calc: Item, ) -> Item { let mut image = image; - let min_luminance = min_luminance.into_element(); - let max_luminance = max_luminance.into_element(); - let luminance_calc = luminance_calc.into_element(); + let min_luminance = min_luminance.into_element() / 100.; + let max_luminance = max_luminance.into_element() / 100.; image.element_mut().adjust(|color| { - let min_luminance = srgb_to_linear(min_luminance / 100.); - let max_luminance = srgb_to_linear(max_luminance / 100.); - - let luminance = match luminance_calc { - LuminanceCalculation::SRGB => color.luminance_rec_709(), - LuminanceCalculation::Perceptual => color.luminance_perceptual(), - LuminanceCalculation::AverageChannels => color.average_rgb_channels(), - LuminanceCalculation::MinimumChannels => color.minimum_rgb_channels(), - LuminanceCalculation::MaximumChannels => color.maximum_rgb_channels(), - }; + // For PSD interop, we compare this 14-bit fixed-point Rec. 601 luma against the level unrounded + let [r, g, b, _] = color.to_gamma_srgb_channels(); + let luminance = (4915. * r + 9667. * g + 1802. * b) / 16384.; - if luminance >= min_luminance && luminance <= max_luminance { Color::WHITE } else { Color::BLACK } + let output = if luminance >= min_luminance && luminance <= max_luminance { Color::WHITE } else { Color::BLACK }; + output.with_alpha(color.a()) }); image } +#[cfg(all(feature = "std", test))] +mod threshold_tests { + use super::*; + + /// Whether one gamma-space RGB value (0..255) ends up white at the given threshold level (0..255). + fn is_white(input: [f32; 3], level: f32) -> bool { + let pixel = Color::from_gamma_srgb_channels(input[0] / 255., input[1] / 255., input[2] / 255., 1.); + let result = threshold((), Item::new_from_element(pixel), (level / 255. * 100.).into(), 100_f32.into()); + result.into_element().r() == 1. + } + + #[test] + fn rec_601_luma_is_compared_as_an_8_bit_level() { + assert!(!is_white([200., 100., 40.], 128.)); + assert!(!is_white([125., 130., 120.], 128.)); + assert!(is_white([0., 255., 0.], 128.)); + assert!(!is_white([255., 0., 0.], 128.)); + assert!(is_white([128., 128., 128.], 128.)); + assert!(!is_white([127., 127., 127.], 128.)); + assert!(is_white([200., 100., 40.], 123.)); + assert!(!is_white([200., 100., 40.], 124.)); + } + + #[test] + fn ties_follow_the_unrounded_fixed_point_luma() { + // Half-level lumas in 0.3/0.59/0.11 stay below the level either way, and the 14-bit weights pull a whole-level red or blue luma just under it + assert!(!is_white([189., 120., 0.], 128.)); + assert!(!is_white([248., 90., 0.], 128.)); + assert!(!is_white([135., 100., 0.], 100.)); + assert!(!is_white([255., 0., 50.], 82.)); + assert!(is_white([0., 200., 0.], 118.)); + } +} + // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27grdm%27%20%3D%20Gradient%20Map // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Gradient%20settings%20(Photoshop%206.0) From 3604120dcd0d4555b3c87498c5f7cd4c6226d2fc Mon Sep 17 00:00:00 2001 From: James Lindsay <78500760+0HyperCube@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:34:08 +0000 Subject: [PATCH 26/46] Fix cargo-about install command (#4512) Fix cargo about install --- tools/cargo-run/src/requirements.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/cargo-run/src/requirements.rs b/tools/cargo-run/src/requirements.rs index 3c2f8d547fd..46dc6eb1bcc 100644 --- a/tools/cargo-run/src/requirements.rs +++ b/tools/cargo-run/src/requirements.rs @@ -66,7 +66,7 @@ fn requirements(task: &Task) -> Vec { name: "Cargo About", // NOTICE: keep in sync with the `cargo-about` version pinned in `.github/workflows/build.yml` and `.devcontainer/devcontainer.json` version: Some(">=0.9.2"), - install: "cargo install -f cargo-about@0.9.2".into(), + install: "cargo install --features=cli -f cargo-about@0.9.2".into(), skip: Some(&|task| matches!(task.target, Target::Cli)), ..Default::default() }, From 972ecaa639fd0077b5489661ec0ad6fcd0ffbcd2 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 14 Sep 2026 14:34:55 -0700 Subject: [PATCH 27/46] Replace the 'Luminance' node with a 'Desaturate' node with a better selection of desaturation methods (#4529) * Remove the 'Threshold' node's luminance calculation dropdown so it always compares the Rec. 601 luma * Find the HSL lightness extremes before encoding and drop a stale luminance TODO --- .../data_panel/data_panel_message_handler.rs | 16 ++-- .../document/node_graph/node_properties.rs | 4 +- .../messages/portfolio/document_migration.rs | 3 +- node-graph/graph-craft/src/document/value.rs | 3 +- .../interpreted-executor/src/node_registry.rs | 2 +- .../no-std-types/src/color/color_types.rs | 33 +++++--- node-graph/nodes/raster/src/adjustments.rs | 84 +++++++++++++++---- 7 files changed, 103 insertions(+), 42 deletions(-) diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index 7c4d6c1a6d1..7c0da3eb64b 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -16,8 +16,8 @@ use graphene_std::list::{Item, List, NodeIdPath}; use graphene_std::math::float_noise::round_away_float_noise; use graphene_std::memo::IORecord; use graphene_std::raster::{ - AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, - SelectiveColorChoice, + AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DesaturateMethod, DomainWarpType, FractalType, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice, + TonalRange, }; use graphene_std::raster_types::{CPU, GPU, Raster}; use graphene_std::text::TextAlign; @@ -238,11 +238,12 @@ fn generate_layout(introspected_data: &Arc, List, List, - List, + List, List, List, List, List, + List, List, List, List, @@ -295,11 +296,12 @@ fn generate_layout(introspected_data: &Arc, Item, Item, - Item, + Item, Item, Item, Item, Item, + Item, Item, Item, Item, @@ -1065,11 +1067,12 @@ impl_table_item_layout_for_choice_enum!( ExtrudeJoiningAlgorithm, PointSpacingType, StringCapitalization, - LuminanceCalculation, + DesaturateMethod, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice, + TonalRange, AdjustmentChannel, XY, ScaleType, @@ -1284,11 +1287,12 @@ macro_rules! known_item_types { ExtrudeJoiningAlgorithm, PointSpacingType, StringCapitalization, - LuminanceCalculation, + DesaturateMethod, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice, + TonalRange, AdjustmentChannel, XY, ScaleType, diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index d1f120b1636..ab4f0cac6cf 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -20,7 +20,7 @@ use graphene_std::animation::RealTimeMode; use graphene_std::color::SRGBA8; use graphene_std::extract_xy::XY; use graphene_std::raster::{ - AdjustmentChannel, BlendMode, CellularDistanceFunction, CellularReturnType, Color, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, + AdjustmentChannel, BlendMode, CellularDistanceFunction, CellularReturnType, Color, DesaturateMethod, DomainWarpType, FractalType, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice, TonalRange, }; use graphene_std::raster_types::Image; @@ -332,7 +332,7 @@ pub(crate) fn property_from_type( Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), - Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), + Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index d3b1ebf01dd..00975c54d6e 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -591,8 +591,9 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ ], }, NodeReplacement { - node: graphene_std::raster_nodes::adjustments::luminance::IDENTIFIER, + node: graphene_std::raster_nodes::adjustments::desaturate::IDENTIFIER, aliases: &[ + "raster_nodes::adjustments::LuminanceNode", "graphene_raster_nodes::adjustments::LuminanceNode", "graphene_core::raster::adjustments::LuminanceNode", "graphene_core::raster::LuminanceNode", diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 198cc9affa7..7e5db6c200a 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -548,7 +548,8 @@ tagged_value! { // ENUM TYPES // ========== BlendMode(core_types::blending::BlendMode), - LuminanceCalculation(raster_nodes::adjustments::LuminanceCalculation), + #[serde(alias = "LuminanceCalculation")] + DesaturateMethod(raster_nodes::adjustments::DesaturateMethod), QRCodeErrorCorrectionLevel(vector_nodes::generator_nodes::QRCodeErrorCorrectionLevel), XY(graphene_core::extract_xy::XY), StringCapitalization(text_nodes::StringCapitalization), diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index cc20a0832ca..14ee46e2d57 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -351,7 +351,7 @@ fn node_registry() -> HashMap f32 { - // TODO: verify this is correct for sRGB 0.2126 * self.red() + 0.7152 * self.green() + 0.0722 * self.blue() } } @@ -541,37 +540,36 @@ impl Color { } /// Relative luminance using Rec.709 / sRGB-primary weights, computed on linear-light RGB. - // From https://stackoverflow.com/a/56678483/775283 #[inline(always)] pub fn luminance_rec_709(&self) -> f32 { + // From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients 0.2126 * self.red + 0.7152 * self.green + 0.0722 * self.blue } /// Luma using Rec.601 SDTV coefficients. - // From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients #[inline(always)] pub fn luminance_rec_601(&self) -> f32 { + // From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients 0.299 * self.red + 0.587 * self.green + 0.114 * self.blue } /// Luma using rounded Rec.601 coefficients (`0.3 / 0.59 / 0.11`), as used by some legacy image processing. - // From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients #[inline(always)] pub fn luminance_rec_601_rounded(&self) -> f32 { + // From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients 0.3 * self.red + 0.59 * self.green + 0.11 * self.blue } - /// Perceptual lightness (CIE L*) of the Rec.709 luminance, normalized to 0..1. - // From https://stackoverflow.com/a/56678483/775283 + /// Perceptual lightness (OkLab L) of the linear-light RGB, 0..1. #[inline(always)] - pub fn luminance_perceptual(&self) -> f32 { - let luminance = self.luminance_rec_709(); + pub fn lightness_oklab(&self) -> f32 { + // From https://bottosson.github.io/posts/oklab/#converting-from-linear-srgb-to-oklab - if luminance <= 0.008856 { - (luminance * 903.3) / 100. - } else { - (luminance.cbrt() * 116. - 16.) / 100. - } + let long = 0.41222147 * self.red + 0.53633254 * self.green + 0.05144599 * self.blue; + let medium = 0.2119035 * self.red + 0.6806995 * self.green + 0.10739696 * self.blue; + let short = 0.08830246 * self.red + 0.28171884 * self.green + 0.6299787 * self.blue; + + 0.21045426 * long.cbrt() + 0.7936178 * medium.cbrt() - 0.004072047 * short.cbrt() } /// Construct an opaque grayscale color where R = G = B = `luminance`. @@ -1062,6 +1060,15 @@ impl Color { #[cfg(test)] mod tests { use super::*; + + #[test] + fn oklab_lightness_spans_black_to_white() { + assert!(Color::BLACK.lightness_oklab().abs() < 1e-4); + assert!((Color::WHITE.lightness_oklab() - 1.).abs() < 1e-4); + // A gray keeps L at the cube root of its linear value, since the three cone responses sum to it + assert!((Color::from_luminance(0.18).lightness_oklab() - 0.18_f32.cbrt()).abs() < 1e-3); + } + #[test] fn hsl_roundtrip() { for (red, green, blue) in [ diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index 0460cc30622..ed2cc8e6379 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -35,24 +35,52 @@ use vector_types::Gradient; // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27clrL%27%20%3D%20Color%20Lookup // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Color%20Lookup%20(Photoshop%20CS6 +/// Conversion from a color to grayscale. #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "std", derive(dyn_any::DynAny))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash, node_macro::ChoiceType, bytemuck::NoUninit, BufferStruct, FromPrimitive, IntoPrimitive)] #[widget(Dropdown)] #[repr(u32)] -pub enum LuminanceCalculation { +pub enum DesaturateMethod { + /// Light level of the color, the Y (luminance) of Rec. 709, which weights the linear-light RGB channels by `0.2126, 0.7152, 0.0722`. + /// + /// Accessibility contrast ratios and SVG luminance masks use this. #[default] - #[label("sRGB")] - SRGB, - Perceptual, - AverageChannels, - MinimumChannels, - MaximumChannels, + #[label("Luminance (Rec. 709)")] + #[cfg_attr(feature = "serde", serde(alias = "SRGB"))] + LuminanceRec709, + /// Light level approximation for the color, the Y′ (luma) of Rec. 709, which weights the gamma-encoded RGB channels by `0.2126, 0.7152, 0.0722`. + /// + /// CSS filter functions such as `grayscale()` use this. + #[label("Luma (Rec. 709)")] + LumaRec709, + /// Light level approximation for the color, the Y′ (luma) of Rec. 601, which weights the gamma-encoded RGB channels by `0.299, 0.587, 0.114`. + #[label("Luma (Rec. 601)")] + LumaRec601, + /// Perceptually uniform scale from black to white, the L (lightness) of OkLab. + #[label("Lightness (OkLab)")] + #[cfg_attr(feature = "serde", serde(alias = "Perceptual"))] + LightnessOkLab, + /// Mean of the three linear-light RGB channels. + #[menu_separator] + #[cfg_attr(feature = "serde", serde(alias = "AverageChannels"))] + ChannelsAverage, + /// Smallest of the three linear-light RGB channels. + #[cfg_attr(feature = "serde", serde(alias = "MinimumChannels"))] + ChannelsMinimum, + /// Largest of the three linear-light RGB channels, the V (value) of HSV. + #[cfg_attr(feature = "serde", serde(alias = "MaximumChannels"))] + ChannelsMaximum, + /// Midpoint of the largest and smallest gamma-encoded RGB channels, the L (lightness) of HSL. + /// + /// The classic "Desaturate" command of many image editors uses this. + #[label("Lightness (HSL)")] + LightnessHsl, } #[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))] -fn luminance>( +fn desaturate>( _: impl Ctx, #[implementations( Raster, @@ -61,18 +89,38 @@ fn luminance>( )] #[gpu_image] input: Item, - luminance_calc: Item, + method: Item, ) -> Item { let mut input = input; - let luminance_calc = luminance_calc.into_element(); + let method = method.into_element(); input.element_mut().adjust(|color| { - let luminance = match luminance_calc { - LuminanceCalculation::SRGB => color.luminance_rec_709(), - LuminanceCalculation::Perceptual => color.luminance_perceptual(), - LuminanceCalculation::AverageChannels => color.average_rgb_channels(), - LuminanceCalculation::MinimumChannels => color.minimum_rgb_channels(), - LuminanceCalculation::MaximumChannels => color.maximum_rgb_channels(), + // Gamma-encoded formulas are decoded as if they were a gray + let gamma = || color.to_gamma_srgb_channels(); + let luminance = match method { + DesaturateMethod::LuminanceRec709 => color.luminance_rec_709(), + DesaturateMethod::LumaRec709 => { + let [r, g, b, _] = gamma(); + srgb_to_linear(0.2126 * r + 0.7152 * g + 0.0722 * b) + } + DesaturateMethod::LumaRec601 => { + let [r, g, b, _] = gamma(); + srgb_to_linear(0.299 * r + 0.587 * g + 0.114 * b) + } + DesaturateMethod::LightnessOkLab => { + // A gray's OkLab lightness is the cube root of its linear value, so cubing gives the gray of equal lightness + let lightness = color.lightness_oklab(); + lightness * lightness * lightness + } + DesaturateMethod::ChannelsAverage => color.average_rgb_channels(), + DesaturateMethod::ChannelsMinimum => color.minimum_rgb_channels(), + DesaturateMethod::ChannelsMaximum => color.maximum_rgb_channels(), + DesaturateMethod::LightnessHsl => { + // The transfer curve is monotonic, so the extremes are found first and only they are encoded + let max = linear_to_srgb(color.maximum_rgb_channels()); + let min = linear_to_srgb(color.minimum_rgb_channels()); + srgb_to_linear((max + min) / 2.) + } }; color.map_rgb(|_| luminance) }); @@ -1401,11 +1449,11 @@ fn color_balance>( #[cfg(feature = "std")] mod _graphene_hash_impls { use super::{ - AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, + AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DesaturateMethod, DomainWarpType, FractalType, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice, TonalRange, }; graphene_hash::impl_via_hash!( - LuminanceCalculation, + DesaturateMethod, RedGreenBlue, RedGreenBlueAlpha, NoiseType, From d57dd31d7281fe32d0c59ac5671cc69cd213ec12 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 14 Sep 2026 16:35:52 -0700 Subject: [PATCH 28/46] Improve accuracy of the 'Channel Mixer', 'Selective Color', 'Posterize', and 'Exposure' nodes (#4530) --- node-graph/nodes/raster/src/adjustments.rs | 300 ++++++++++++++------- 1 file changed, 206 insertions(+), 94 deletions(-) diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index ed2cc8e6379..6f7d2893987 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -645,40 +645,6 @@ fn threshold>( image } -#[cfg(all(feature = "std", test))] -mod threshold_tests { - use super::*; - - /// Whether one gamma-space RGB value (0..255) ends up white at the given threshold level (0..255). - fn is_white(input: [f32; 3], level: f32) -> bool { - let pixel = Color::from_gamma_srgb_channels(input[0] / 255., input[1] / 255., input[2] / 255., 1.); - let result = threshold((), Item::new_from_element(pixel), (level / 255. * 100.).into(), 100_f32.into()); - result.into_element().r() == 1. - } - - #[test] - fn rec_601_luma_is_compared_as_an_8_bit_level() { - assert!(!is_white([200., 100., 40.], 128.)); - assert!(!is_white([125., 130., 120.], 128.)); - assert!(is_white([0., 255., 0.], 128.)); - assert!(!is_white([255., 0., 0.], 128.)); - assert!(is_white([128., 128., 128.], 128.)); - assert!(!is_white([127., 127., 127.], 128.)); - assert!(is_white([200., 100., 40.], 123.)); - assert!(!is_white([200., 100., 40.], 124.)); - } - - #[test] - fn ties_follow_the_unrounded_fixed_point_luma() { - // Half-level lumas in 0.3/0.59/0.11 stay below the level either way, and the 14-bit weights pull a whole-level red or blue luma just under it - assert!(!is_white([189., 120., 0.], 128.)); - assert!(!is_white([248., 90., 0.], 128.)); - assert!(!is_white([135., 100., 0.], 100.)); - assert!(!is_white([255., 0., 50.], 82.)); - assert!(is_white([0., 200., 0.], 118.)); - } -} - // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27grdm%27%20%3D%20Gradient%20Map // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Gradient%20settings%20(Photoshop%206.0) @@ -1016,16 +982,19 @@ fn channel_mixer>( image.element_mut().adjust(|color| { let [r, g, b, a] = color.to_gamma_srgb_channels(); + // Weights and constants are 10-bit fixed point truncated toward zero, which PSD interop depends on + let weight = |percent: f32| (percent * 1024. / 100.).trunc() / 1024.; + let (out_r, out_g, out_b) = if monochrome { - let (monochrome_r, monochrome_g, monochrome_b, monochrome_c) = (monochrome_r / 100., monochrome_g / 100., monochrome_b / 100., monochrome_c / 100.); + let (monochrome_r, monochrome_g, monochrome_b, monochrome_c) = (weight(monochrome_r), weight(monochrome_g), weight(monochrome_b), weight(monochrome_c)); let gray = (r * monochrome_r + g * monochrome_g + b * monochrome_b + monochrome_c).clamp(0., 1.); (gray, gray, gray) } else { - let (red_r, red_g, red_b, red_c) = (red_r / 100., red_g / 100., red_b / 100., red_c / 100.); - let (green_r, green_g, green_b, green_c) = (green_r / 100., green_g / 100., green_b / 100., green_c / 100.); - let (blue_r, blue_g, blue_b, blue_c) = (blue_r / 100., blue_g / 100., blue_b / 100., blue_c / 100.); + let (red_r, red_g, red_b, red_c) = (weight(red_r), weight(red_g), weight(red_b), weight(red_c)); + let (green_r, green_g, green_b, green_c) = (weight(green_r), weight(green_g), weight(green_b), weight(green_c)); + let (blue_r, blue_g, blue_b, blue_c) = (weight(blue_r), weight(blue_g), weight(blue_b), weight(blue_c)); let red = (r * red_r + g * red_g + b * red_b + red_c).clamp(0., 1.); let green = (r * green_r + g * green_g + b * green_b + green_c).clamp(0., 1.); @@ -1167,7 +1136,8 @@ fn selective_color>( SelectiveColorChoice::Blues => max_channel == b, SelectiveColorChoice::Magentas => min_channel == g, SelectiveColorChoice::Whites => r > 0.5 && g > 0.5 && b > 0.5, - SelectiveColorChoice::Neutrals => r > 0. && g > 0. && b > 0. && r < 1. && g < 1. && b < 1., + // Every pixel, since the neutrals scale factor already vanishes at black, white, and fully saturated colors + SelectiveColorChoice::Neutrals => true, SelectiveColorChoice::Blacks => r < 0.5 && g < 0.5 && b < 0.5, }; @@ -1192,18 +1162,18 @@ fn selective_color>( (SelectiveColorChoice::Blacks, (k_c, k_m, k_y, k_k)), ]; let mut sum = Vec3::ZERO; + // Indexed because the shader compiler cannot lower array iterators + #[allow(clippy::needless_range_loop)] for i in 0..array.len() { let (color_parameter_group, (c, m, y, k)) = array[i]; // Skip this color parameter group... // ...if it's unchanged from the default of zero offset on all CMYK parameters, or... // ...if this pixel's color isn't in the range affected by this color parameter group - if (c < f32::EPSILON && m < f32::EPSILON && y < f32::EPSILON && k < f32::EPSILON) || (!pixel_color_range(color_parameter_group)) { + if (c == 0. && m == 0. && y == 0. && k == 0.) || !pixel_color_range(color_parameter_group) { continue; } - let (c, m, y, k) = (c / 100., m / 100., y / 100., k / 100.); - let color_parameter_group_scale_factor = match color_parameter_group { SelectiveColorChoice::Reds | SelectiveColorChoice::Greens | SelectiveColorChoice::Blues => color_parameter_group_scale_factor_rgb, SelectiveColorChoice::Cyans | SelectiveColorChoice::Magentas | SelectiveColorChoice::Yellows => color_parameter_group_scale_factor_cmy, @@ -1212,10 +1182,28 @@ fn selective_color>( SelectiveColorChoice::Blacks => 1. - max(r, g, b) * 2., }; - let offset_r = f32::clamp((c + k * (c + 1.)) * slope_r, -r, -r + 1.) * color_parameter_group_scale_factor; - let offset_g = f32::clamp((m + k * (m + 1.)) * slope_g, -g, -g + 1.) * color_parameter_group_scale_factor; - let offset_b = f32::clamp((y + k * (y + 1.)) * slope_b, -b, -b + 1.) * color_parameter_group_scale_factor; + // For PSD interop, the combined percent (c + k + c k / 100) rounds half up to an integer + let ink = |color: f32| { + let percent = ((2. * (100. * (color + k) + color * k) + 100.) / 200.).floor(); + match mode { + // The multiplier is stored as one byte, 255 / b above 1 and b / 255 below, so 99% and 100% both act as 127/128 + RelativeAbsolute::Relative => { + let multiplier = 1. + percent / 100.; + if multiplier >= 1. { + 255. / (255. / multiplier).round() - 1. + } else { + (255. * multiplier).round() / 255. - 1. + } + } + RelativeAbsolute::Absolute => percent / 100., + } + }; + + let offset_r = f32::clamp(ink(c) * slope_r, -r, -r + 1.) * color_parameter_group_scale_factor; + let offset_g = f32::clamp(ink(m) * slope_g, -g, -g + 1.) * color_parameter_group_scale_factor; + let offset_b = f32::clamp(ink(y) * slope_b, -b, -b + 1.) * color_parameter_group_scale_factor; + // An 8-bit PSD document sums the groups' 8-bit offsets, which this float node does not currently attempt to reproduce sum += Vec3::new(offset_r, offset_g, offset_b); } @@ -1229,10 +1217,6 @@ fn selective_color>( // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=nvrt%27%20%3D%20Invert-,%27post%27%20%3D%20Posterize,-%27thrs%27%20%3D%20Threshold -// -// Algorithm based on: -// https://www.axiomx.com/posterize.htm -// This algorithm produces fully accurate output in relation to the industry standard. #[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))] fn posterize>( _: impl Ctx, @@ -1251,9 +1235,12 @@ fn posterize>( let levels = levels.into_element() as f32; input.element_mut().adjust(|color| { - let number_of_areas = levels.recip(); - let size_of_areas = (levels - 1.).recip(); - color.map_gamma_rgb(|c| (c / number_of_areas).floor() * size_of_areas) + color.map_gamma_rgb(|c| { + // Bins as floor(c * levels) with the outputs spread evenly to white. + // The sliver of slack keeps an input exactly on an edge in the upper bin despite float ties. + let bin = ((c + 2e-7) * levels).floor().min(levels - 1.); + bin / (levels - 1.) + }) }); input } @@ -1262,7 +1249,7 @@ fn posterize>( // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=curv%27%20%3D%20Curves-,%27expA%27%20%3D%20Exposure,-%27vibA%27%20%3D%20Vibrance // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Flag%20(%20%3D%20128%20)-,Exposure,-Key%20is%20%27expA // -// Algorithm based on: +// The exposure, offset, and gamma operations follow: // https://geraldbakker.nl/psnumbers/exposure.html #[node_macro::node(category("Raster: Adjustment"), properties("exposure_properties"), shader_node(PerPixelAdjust))] fn exposure>( @@ -1287,16 +1274,19 @@ fn exposure>( let offset = offset.into_element(); let gamma_correction = gamma_correction.into_element(); + // Linearizes with a 2.2 power above a straight toe of slope 1/32, the two meeting at this constant + const TOE_END: f32 = 0.05568117; // 32^(-1. / 1.2) + + let decode = |value: f32| if value < TOE_END { value / 32. } else { value.powf(2.2) }; + let encode = |linear: f32| if linear < TOE_END / 32. { linear * 32. } else { linear.powf(1. / 2.2) }; + let adjust = |c: f32| { + let linear = decode(c) * 2_f32.powf(exposure) + offset; + encode(linear.max(0.).powf(1. / gamma_correction).min(1.)) + }; + input.element_mut().adjust(|color| { - let adjusted = color - // Exposure - .map_rgb(|c: f32| c * 2_f32.powf(exposure)) - // Offset - .map_rgb(|c: f32| c + offset) - // Gamma correction - .apply_gamma_exponent(gamma_correction); - - adjusted.map_rgb(|c: f32| c.clamp(0., 1.)) + let [r, g, b, a] = color.to_gamma_srgb_channels(); + Color::from_gamma_srgb_channels(adjust(r), adjust(g), adjust(b), a) }); input } @@ -1469,9 +1459,16 @@ mod _graphene_hash_impls { } #[cfg(all(feature = "std", test))] -mod test { +mod tests { use super::*; + /// Matched to within one 8-bit level. + fn assert_close(actual: [f32; 3], expected: [f32; 3]) { + for (actual, expected) in actual.iter().zip(expected) { + assert!((actual - expected).abs() <= 1., "expected {expected}, got {actual}"); + } + } + #[test] fn invert_flips_straight_channels_and_keeps_alpha() { let color = Color::from_gamma_srgb_channels(1., 0.25, 0., 0.5); @@ -1482,14 +1479,136 @@ mod test { assert!((r - 0.).abs() < 1e-5 && (g - 0.75).abs() < 1e-5 && (b - 1.).abs() < 1e-5, "inverted channels were {r} {g} {b}"); assert!((a - 0.5).abs() < 1e-5, "alpha was {a}"); } -} -#[cfg(all(feature = "std", test))] -mod color_balance_tests { - use super::*; + /// Whether one gamma-space RGB value (0..255) ends up white at the given threshold level (0..255). + fn threshold_is_white(input: [f32; 3], level: f32) -> bool { + let pixel = Color::from_gamma_srgb_channels(input[0] / 255., input[1] / 255., input[2] / 255., 1.); + let result = threshold((), Item::new_from_element(pixel), (level / 255. * 100.).into(), 100_f32.into()); + result.into_element().r() == 1. + } + + #[test] + fn threshold_compares_rec_601_luma_as_an_8_bit_level() { + assert!(!threshold_is_white([200., 100., 40.], 128.)); + assert!(!threshold_is_white([125., 130., 120.], 128.)); + assert!(threshold_is_white([0., 255., 0.], 128.)); + assert!(!threshold_is_white([255., 0., 0.], 128.)); + assert!(threshold_is_white([128., 128., 128.], 128.)); + assert!(!threshold_is_white([127., 127., 127.], 128.)); + assert!(threshold_is_white([200., 100., 40.], 123.)); + assert!(!threshold_is_white([200., 100., 40.], 124.)); + } + + #[test] + fn threshold_ties_follow_the_unrounded_fixed_point_luma() { + // Half-level lumas in 0.3/0.59/0.11 stay below the level either way, and the 14-bit weights pull a whole-level red or blue luma just under it + assert!(!threshold_is_white([189., 120., 0.], 128.)); + assert!(!threshold_is_white([248., 90., 0.], 128.)); + assert!(!threshold_is_white([135., 100., 0.], 100.)); + assert!(!threshold_is_white([255., 0., 50.], 82.)); + assert!(threshold_is_white([0., 200., 0.], 118.)); + } + + /// Runs Selective Color on one gamma-space RGB value (0..255) with the given group values + /// (Reds through Blacks, each cyan, magenta, yellow, black) and returns the gamma-space result on the same scale. + fn run_selective_color(input: [f32; 3], mode: RelativeAbsolute, groups: [[f32; 4]; 9]) -> [f32; 3] { + let pixel = Color::from_gamma_srgb_channels(input[0] / 255., input[1] / 255., input[2] / 255., 1.); + let g = |group: usize, component: usize| Item::new_from_element(groups[group][component]); + #[rustfmt::skip] + let result = selective_color( + (), Item::new_from_element(pixel), mode.into(), + g(0, 0), g(0, 1), g(0, 2), g(0, 3), g(1, 0), g(1, 1), g(1, 2), g(1, 3), g(2, 0), g(2, 1), g(2, 2), g(2, 3), + g(3, 0), g(3, 1), g(3, 2), g(3, 3), g(4, 0), g(4, 1), g(4, 2), g(4, 3), g(5, 0), g(5, 1), g(5, 2), g(5, 3), + g(6, 0), g(6, 1), g(6, 2), g(6, 3), g(7, 0), g(7, 1), g(7, 2), g(7, 3), g(8, 0), g(8, 1), g(8, 2), g(8, 3), + SelectiveColorChoice::Reds.into(), + ); + let [r, g, b, _] = result.into_element().to_gamma_srgb_channels(); + [r * 255., g * 255., b * 255.] + } + + #[test] + fn selective_color_applies_negative_values() { + let mut groups = [[0.; 4]; 9]; + groups[0] = [-100., 0., 0., 0.]; + assert_close(run_selective_color([125., 0., 0.], RelativeAbsolute::Relative, groups), [189., 0., 0.]); + assert_close(run_selective_color([120., 100., 5.], RelativeAbsolute::Relative, groups), [131., 100., 5.]); + + let mut groups = [[0.; 4]; 9]; + groups[0] = [0., 0., 0., -100.]; + assert_close(run_selective_color([110., 65., 25.], RelativeAbsolute::Absolute, groups), [136., 99., 66.]); + } + + #[test] + fn selective_color_neutrals_include_pixels_with_an_empty_channel() { + let mut groups = [[0.; 4]; 9]; + groups[7] = [0., -100., 0., 0.]; + assert_close(run_selective_color([100., 0., 130.], RelativeAbsolute::Relative, groups), [100., 125., 130.]); + assert_close(run_selective_color([100., 50., 130.], RelativeAbsolute::Relative, groups), [100., 191., 130.]); + } + + /// Runs Posterize on one gamma-space gray value (0..255) and returns the gamma-space result on the same scale. + fn run_posterize(value: f32, levels: u32) -> f32 { + let pixel = Color::from_gamma_srgb_channels(value / 255., value / 255., value / 255., 1.); + posterize((), Item::new_from_element(pixel), levels.into()).into_element().to_gamma_srgb_channels()[0] * 255. + } + + #[test] + fn posterize_bins_by_floor_with_levels_spread_to_white() { + for (value, levels, expected) in [ + (84., 3, 0.), + (85., 3, 127.5), + (169., 3, 127.5), + (170., 3, 255.), + (36., 7, 0.), + (37., 7, 42.5), + (110., 7, 127.5), + (255., 7, 255.), + ] { + let actual = run_posterize(value, levels); + assert!((actual - expected).abs() <= 0.01, "{value} at {levels} levels: expected {expected}, got {actual}"); + } + } + + /// Runs Exposure on one gamma-space gray value (0..255) and returns the gamma-space result on the same scale. + fn run_exposure(value: f32, exposure: f32, offset: f32, gamma_correction: f32) -> f32 { + let pixel = Color::from_gamma_srgb_channels(value / 255., value / 255., value / 255., 1.); + let result = super::exposure((), Item::new_from_element(pixel), exposure.into(), offset.into(), gamma_correction.into()); + result.into_element().to_gamma_srgb_channels()[0] * 255. + } + + #[test] + fn exposure_linearizes_through_the_toe_and_power_curve() { + for (value, exposure, offset, gamma_correction, expected) in [ + (1., 1., 0., 1., 2.), + (8., 1., 0., 1., 15.), + (16., 1., 0., 1., 22.), + (128., 1., 0., 1., 175.), + (200., 1., 0., 1., 255.), + (1., 0., 0., 2., 33.), + (16., 0., 0., 2., 64.), + (128., 0., 0., 2., 181.), + (200., 0., 0., 2., 226.), + (0., -2., 0.2, 1.5, 157.), + (100., -2., 0.2, 1.5, 164.), + (200., -2., 0.2, 1.5, 185.), + (100., 0., -0.25, 1., 0.), + (200., 0., -0.25, 1., 155.), + ] { + let actual = run_exposure(value, exposure, offset, gamma_correction); + assert!( + (actual - expected).abs() <= 1., + "{value} at exposure {exposure}, offset {offset}, gamma {gamma_correction}: expected {expected}, got {actual}" + ); + } + } + + #[test] + fn exposure_clamps_negative_offsets_before_the_gamma_power() { + assert_eq!(run_exposure(50., 0., -0.5, 2.), 0.); + } /// Runs Color Balance on one gamma-space RGB value (0..255) and returns the gamma-space result on the same scale. - fn run(input: [f32; 3], shadows: [f32; 3], midtones: [f32; 3], highlights: [f32; 3], preserve_luminosity: bool) -> [f32; 3] { + fn run_color_balance(input: [f32; 3], shadows: [f32; 3], midtones: [f32; 3], highlights: [f32; 3], preserve_luminosity: bool) -> [f32; 3] { let color = Color::from_gamma_srgb_channels(input[0] / 255., input[1] / 255., input[2] / 255., 1.); let result = color_balance( (), @@ -1510,46 +1629,39 @@ mod color_balance_tests { [r * 255., g * 255., b * 255.] } - /// Matched to within one 8-bit level. - fn assert_close(actual: [f32; 3], expected: [f32; 3]) { - for (actual, expected) in actual.iter().zip(expected) { - assert!((actual - expected).abs() <= 1., "expected {expected}, got {actual}"); - } - } - #[test] - fn midtones_are_a_gamma_with_a_toe() { + fn color_balance_midtones_are_a_gamma_with_a_toe() { let none = [0., 0., 0.]; - assert_close(run([100., 100., 100.], none, [100., 0., 0.], none, false), [160., 100., 100.]); - assert_close(run([200., 200., 200.], none, [100., 0., 0.], none, false), [226., 200., 200.]); - assert_close(run([4., 4., 4.], none, [100., 0., 0.], none, false), [16., 4., 4.]); - assert_close(run([1., 1., 1.], none, [100., 0., 0.], none, false), [4., 1., 1.]); - assert_close(run([100., 100., 100.], none, [-100., 0., 0.], none, false), [39., 100., 100.]); + assert_close(run_color_balance([100., 100., 100.], none, [100., 0., 0.], none, false), [160., 100., 100.]); + assert_close(run_color_balance([200., 200., 200.], none, [100., 0., 0.], none, false), [226., 200., 200.]); + assert_close(run_color_balance([4., 4., 4.], none, [100., 0., 0.], none, false), [16., 4., 4.]); + assert_close(run_color_balance([1., 1., 1.], none, [100., 0., 0.], none, false), [4., 1., 1.]); + assert_close(run_color_balance([100., 100., 100.], none, [-100., 0., 0.], none, false), [39., 100., 100.]); } #[test] - fn shadows_and_highlights_move_the_end_points() { + fn color_balance_shadows_and_highlights_move_the_end_points() { let none = [0., 0., 0.]; - assert_close(run([100., 100., 100.], [-100., 0., 0.], none, none, false), [0., 100., 100.]); - assert_close(run([150., 150., 150.], [-100., 0., 0.], none, none, false), [52., 150., 150.]); - assert_close(run([200., 200., 200.], [-100., 0., 0.], none, none, false), [138., 200., 200.]); - assert_close(run([100., 100., 100.], none, none, [100., 0., 0.], false), [187., 100., 100.]); - assert_close(run([155., 155., 155.], none, none, [100., 0., 0.], false), [255., 155., 155.]); + assert_close(run_color_balance([100., 100., 100.], [-100., 0., 0.], none, none, false), [0., 100., 100.]); + assert_close(run_color_balance([150., 150., 150.], [-100., 0., 0.], none, none, false), [52., 150., 150.]); + assert_close(run_color_balance([200., 200., 200.], [-100., 0., 0.], none, none, false), [138., 200., 200.]); + assert_close(run_color_balance([100., 100., 100.], none, none, [100., 0., 0.], false), [187., 100., 100.]); + assert_close(run_color_balance([155., 155., 155.], none, none, [100., 0., 0.], false), [255., 155., 155.]); } #[test] - fn preserve_luminosity_makes_sliders_relative() { + fn color_balance_preserve_luminosity_makes_sliders_relative() { let none = [0., 0., 0.]; - assert_close(run([90., 90., 90.], none, [-100., 0., 0.], none, true), [59., 122., 122.]); - assert_close(run([90., 90., 90.], [50., 0., 0.], none, none, true), [90., 50., 50.]); - assert_close(run([120., 120., 120.], none, none, [-100., 0., 0.], true), [120., 197., 197.]); - assert_close(run([90., 90., 90.], [100., 100., 100.], [100., 100., 100.], [100., 100., 100.], true), [90., 90., 90.]); + assert_close(run_color_balance([90., 90., 90.], none, [-100., 0., 0.], none, true), [59., 122., 122.]); + assert_close(run_color_balance([90., 90., 90.], [50., 0., 0.], none, none, true), [90., 50., 50.]); + assert_close(run_color_balance([120., 120., 120.], none, none, [-100., 0., 0.], true), [120., 197., 197.]); + assert_close(run_color_balance([90., 90., 90.], [100., 100., 100.], [100., 100., 100.], [100., 100., 100.], true), [90., 90., 90.]); } #[test] - fn combined_tones_use_integer_arithmetic() { + fn color_balance_combined_tones_use_integer_arithmetic() { // Red: black 39, white 235, gamma 1.09; green: gamma 0.91; blue: white 210, gamma 1.13 - let result = run([128., 128., 128.], [-39., 6., 42.], [21., 0., -25.], [20., -35., 45.], false); + let result = run_color_balance([128., 128., 128.], [-39., 6., 42.], [21., 0., -25.], [20., -35., 45.], false); assert_close(result, [124., 120., 164.]); } } From 670dcfbf0800995fb3b75bd827e901099a16c653 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 14 Sep 2026 18:03:31 -0700 Subject: [PATCH 29/46] Add a range slider mode to the spectrum widget and slider rows to node properties (#4531) * Add a range slider mode to the spectrum widget and slider rows to node properties * Reset the weighted strength slider to the node's default of zero and skip markers off the track when picking up the nearest one --- .../utility_types/widgets/input_widgets.rs | 5 +- .../node_graph/document_node_definitions.rs | 19 +- .../document/node_graph/node_properties.rs | 212 +++++++++++++----- .../widgets/inputs/SpectrumInput.svelte | 130 ++++++++--- 4 files changed, 271 insertions(+), 95 deletions(-) diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index 6850cc4788b..e0d1731f4c6 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -662,7 +662,7 @@ pub struct SpectrumInput { /// Whether to render midpoint diamonds between adjacent markers (only meaningful for gradient-like uses). #[serde(rename = "showMidpoints")] pub show_midpoints: bool, - /// Whether clicking the track inserts a new marker at the click position. + /// Whether clicking the track inserts a new marker at the click position. Otherwise the click picks up the nearest marker. #[serde(rename = "allowInsert")] pub allow_insert: bool, /// Whether right-click or pressing Delete removes a marker. The handler still has the final say on whether the deletion goes through (e.g., enforcing a minimum count). @@ -673,6 +673,9 @@ pub struct SpectrumInput { pub allow_reorder: bool, /// Compact mode: 8px track height with 8px top padding, for use in rows alongside other widgets. pub narrow: bool, + /// Plain range-slider mode, for a number beside its number input: a flat 4px track is drawn in place of the gradient, so `track` is never shown. + #[serde(rename = "rangeSlider")] + pub range_slider: bool, /// Whether the input is disabled (dimmed and read-only). pub disabled: bool, diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index 78e9019a621..ef403d33d90 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -1,7 +1,7 @@ mod document_node_derive; use super::node_properties::choice::enum_choice; -use super::node_properties::{self, ParameterWidgetsInfo}; +use super::node_properties::{self, ParameterWidgetsInfo, SliderRange}; use super::utility_types::{FrontendNodeType, InputTypeConstraint}; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::utility_types::network_interface::{ @@ -1073,19 +1073,26 @@ fn static_input_properties() -> InputProperties { ParameterWidgetsInfo::at_index(node_id, index, false, context), index - 1, number_input, + None, ))]) }), ); map.insert( - // Like `optional_f64`, but the number input is configured as a percentage with a 0-100 range. + // Like `optional_f64`, but with a 0-100% range slider beside the number input, double-click restoring the full 100%. // As with `optional_f64`, the bool input must be at the input index directly before the f64 input. "optional_percentage".to_string(), Box::new(|node_id, index, context| { - let number_input = NumberInput::default().percentage().min(0.).max(100.); + let number_input = NumberInput::default().mode_increment().unit("%").min(0.).max(100.); + let slider = SliderRange { + min: 0., + max: 100., + default: Some(100.), + }; Ok(vec![LayoutGroup::row(node_properties::optional_f64_widget( ParameterWidgetsInfo::at_index(node_id, index, false, context), index - 1, number_input, + Some(slider), ))]) }), ); @@ -1241,13 +1248,13 @@ fn static_input_properties() -> InputProperties { "noise_properties_fractal_weighted_strength".to_string(), Box::new(|node_id, index, context| { let (fractal_active, coherent_noise_active, _, _, _, domain_warp_only_fractal_type_wrongly_active) = node_properties::query_noise_pattern_state(node_id, context)?; - let fractal_weighted_strength = node_properties::number_widget( + let fractal_weighted_strength = node_properties::range_slider_widget( ParameterWidgetsInfo::at_index(node_id, index, true, context), NumberInput::default() - .mode_range() .min(0.) - .max(1.) // Defined for the 0-1 range + .max(1.) .disabled(!coherent_noise_active || !fractal_active || domain_warp_only_fractal_type_wrongly_active), + SliderRange { min: 0., max: 1., default: Some(0.) }, ); Ok(vec![fractal_weighted_strength.into()]) }), diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index ab4f0cac6cf..c9406ab3290 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -178,6 +178,24 @@ pub(crate) struct NumberOptions { pub slider: bool, } +/// The values a range slider's two ends map to linearly and the one its double-click restores, if known. +#[derive(Clone, Copy)] +pub struct SliderRange { + pub min: f64, + pub max: f64, + pub default: Option, +} + +impl SliderRange { + fn position(self, value: f64) -> f64 { + ((value - self.min) / (self.max - self.min)).clamp(0., 1.) + } + + fn value(self, position: f64) -> f64 { + (self.min + position * (self.max - self.min)).clamp(self.min, self.max) + } +} + pub(crate) fn property_from_type( node_id: NodeId, index: usize, @@ -991,43 +1009,37 @@ pub fn progression_widget(parameter_widgets_info: ParameterWidgetsInfo, number_p } /// `parameter_widgets_info` is for the f64 parameter. `bool_input_index` is the input index of the bool parameter for the checkbox. -pub fn optional_f64_widget(parameter_widgets_info: ParameterWidgetsInfo, bool_input_index: usize, number_props: NumberInput) -> Vec { - let ParameterWidgetsInfo { - document_node, - node_id, - index: number_input_index, - .. - } = parameter_widgets_info; - - let mut widgets = start_widgets(¶meter_widgets_info); - - let Some(document_node) = document_node else { return Vec::new() }; - let Some(number_input) = document_node.inputs.get(number_input_index) else { - log::warn!("A widget failed to be built because its node's input index is invalid."); - return vec![]; - }; - let Some(bool_input) = document_node.inputs.get(bool_input_index) else { - log::warn!("A widget failed to be built because its node's input index is invalid."); - return vec![]; +/// A number row gated by the bool input at `bool_input_index`, drawn as a checkbox in the assist slot after the label like the +/// Opacity node's toggles, so the caller passes `blank_assist = false`. Given a `slider`, a range slider spanning it sits between them. +pub fn optional_f64_widget(parameter_widgets_info: ParameterWidgetsInfo, bool_input_index: usize, number_props: NumberInput, slider: Option) -> Vec { + let node_id = parameter_widgets_info.node_id; + let enabled = parameter_widgets_info + .document_node + .and_then(|document_node| document_node.inputs.get(bool_input_index)) + .and_then(|input| input.as_non_exposed_value()) + .and_then(|value| if let TaggedValue::Bool(enabled) = value { Some(*enabled) } else { None }); + let label_count = start_widgets(¶meter_widgets_info).len(); + let exposed = parameter_widgets_info.is_exposed(); + + let number_props = number_props.disabled(enabled == Some(false)); + let mut widgets = match slider { + Some(slider) => range_slider_widget(parameter_widgets_info, number_props, slider), + None => number_widget(parameter_widgets_info, number_props), }; - if let (Some(&TaggedValue::Bool(enabled)), Some(&TaggedValue::F64(number))) = (bool_input.as_non_exposed_value(), number_input.as_non_exposed_value()) { - widgets.extend_from_slice(&[ + + if let Some(enabled) = enabled + && !exposed + { + let checkbox = [ Separator::new(SeparatorStyle::Unrelated).widget_instance(), Separator::new(SeparatorStyle::Related).widget_instance(), - // The checkbox toggles if the value is Some or None CheckboxInput::new(enabled) .on_update(update_value_at_index(|x: &CheckboxInput| TaggedValue::Bool(x.checked), node_id, bool_input_index)) .on_commit(commit_value) .widget_instance(), Separator::new(SeparatorStyle::Related).widget_instance(), - Separator::new(SeparatorStyle::Unrelated).widget_instance(), - number_props - .value(Some(number)) - .on_update(update_value_at_index(move |x: &NumberInput| TaggedValue::F64(x.value.unwrap_or_default()), node_id, number_input_index)) - .disabled(!enabled) - .on_commit(commit_value) - .widget_instance(), - ]); + ]; + widgets.splice(label_count..label_count, checkbox); } widgets @@ -1567,6 +1579,103 @@ pub(crate) fn hue_saturation_properties(node_id: NodeId, context: &mut NodePrope ] } +/// A single-marker `SpectrumInput` over `track` driving the number at `input_index`: the marker sits at `position`, double-click +/// returns it to `default_position`, and each move sets the input to `value_at` the new position. +fn value_slider( + node_id: NodeId, + input_index: usize, + track: GradientStops, + handle_color: Color, + position: f64, + default_position: Option, + value_at: impl Fn(f64) -> TaggedValue + 'static + Send + Sync, +) -> SpectrumInput { + SpectrumInput::new(track) + .track_space(GradientSpace::RgbGamma) + .markers(vec![SpectrumMarker::new(position, 0.5, handle_color)]) + .show_midpoints(false) + .allow_insert(false) + .allow_delete(false) + .allow_reorder(false) + .on_update(move |update: &SpectrumInputUpdate| { + let new_position = match update { + SpectrumInputUpdate::MoveMarker { index: 0, position } => Some(*position), + SpectrumInputUpdate::ResetMarker { index: 0 } => default_position, + _ => None, + }; + let Some(new_position) = new_position else { return Message::NoOp }; + + NodeGraphMessage::SetInputValue { + node_id, + input_index, + value: value_at(new_position).into(), + } + .into() + }) + .on_commit(commit_value) +} + +/// A row with a range slider and a 60px number input for the number at `parameter_widgets_info`. The slider's 0..1 position maps +/// to the number through `position_of` and `value_at`, and double-click restores `default`. +fn slider_row( + parameter_widgets_info: ParameterWidgetsInfo, + number_props: NumberInput, + default: Option, + position_of: impl Fn(f64) -> f64, + value_at: impl Fn(f64) -> f64 + 'static + Send + Sync, +) -> Vec { + let mut widgets = start_widgets(¶meter_widgets_info); + + let Some(input) = parameter_widgets_info.input() else { + log::warn!("A widget failed to be built because its node's input index is invalid."); + return vec![]; + }; + // An exposed input shows only its label and source + let (current, tagged_value): (f64, fn(f64) -> TaggedValue) = match input.as_non_exposed_value() { + Some(&TaggedValue::F64(value)) => (value, TaggedValue::F64), + Some(&TaggedValue::F32(value)) => (value as f64, |value| TaggedValue::F32(value as f32)), + _ => return widgets, + }; + let ParameterWidgetsInfo { node_id, index, .. } = parameter_widgets_info; + + widgets.extend_from_slice(&[ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + value_slider( + node_id, + index, + GradientStops::default(), + Color::WHITE, + position_of(current), + default.map(position_of), + move |position| tagged_value(value_at(position)), + ) + .range_slider(true) + .disabled(number_props.disabled) + .widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + number_props + .value(Some(current)) + .min_width(60) + .max_width(60) + .on_update(update_value_at_index(move |x: &NumberInput| tagged_value(x.value.unwrap_or_default()), node_id, index)) + .on_commit(commit_value) + .widget_instance(), + ]); + + widgets +} + +/// A slider row running linearly across `slider`'s bounds. +pub(crate) fn range_slider_widget(parameter_widgets_info: ParameterWidgetsInfo, number_props: NumberInput, slider: SliderRange) -> Vec { + slider_row( + parameter_widgets_info, + number_props, + slider.default, + move |value| slider.position(value), + move |position| slider.value(position), + ) +} + /// Build a row with a single-marker `SpectrumInput` and a 60px `NumberInput`. The marker maps `value_min..value_max` to position 0..1, and double-click resets to `default_value`. fn spectrum_slider_row( node_id: NodeId, @@ -1590,37 +1699,26 @@ fn spectrum_slider_row( // Only add the spectrum and number widgets when the input is not exposed if let Some(current) = current { - let value_range = value_max - value_min; - let position = ((current - value_min) / value_range).clamp(0., 1.); - let default_position = ((default_value - value_min) / value_range).clamp(0., 1.); + let slider = SliderRange { + min: value_min, + max: value_max, + default: Some(default_value), + }; + let value_at = move |position| TaggedValue::F32(slider.value(position) as f32); row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - - let position_to_value = move |position: f64| value_min + position * value_range; row.push( - SpectrumInput::new(GradientStops::from(&track)) - .track_space(GradientSpace::RgbGamma) - .markers(vec![SpectrumMarker::new(position, 0.5, handle_color)]) - .show_midpoints(false) - .allow_insert(false) - .allow_delete(false) - .allow_reorder(false) - .narrow(true) - .on_update(move |update: &SpectrumInputUpdate| { - let new_position = match update { - SpectrumInputUpdate::MoveMarker { index: 0, position } => *position, - SpectrumInputUpdate::ResetMarker { index: 0 } => default_position, - _ => return Message::NoOp, - }; - NodeGraphMessage::SetInputValue { - node_id, - input_index, - value: TaggedValue::F32(position_to_value(new_position).clamp(value_min, value_max) as f32).into(), - } - .into() - }) - .on_commit(commit_value) - .widget_instance(), + value_slider( + node_id, + input_index, + GradientStops::from(&track), + handle_color, + slider.position(current), + Some(slider.position(default_value)), + value_at, + ) + .narrow(true) + .widget_instance(), ); row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); row.push( diff --git a/frontend/src/components/widgets/inputs/SpectrumInput.svelte b/frontend/src/components/widgets/inputs/SpectrumInput.svelte index d3e60ec5810..8416e06e01f 100644 --- a/frontend/src/components/widgets/inputs/SpectrumInput.svelte +++ b/frontend/src/components/widgets/inputs/SpectrumInput.svelte @@ -26,6 +26,7 @@ export let allowDelete = true; export let allowReorder = true; export let narrow = false; + export let rangeSlider = false; export let disabled = false; // Reference to the marker track DOM element so we can convert pointer coordinates to a 0..1 position along the track. @@ -39,8 +40,8 @@ // Active marker selection at drag start, restored if the drag is cancelled. let activeMarkerIndexRestore: number | undefined = undefined; let activeMarkerIsMidpointRestore = false; - // Tracks whether a midpoint drag has actually moved by at least one frame, to distinguish click-to-select from drag. - let midpointDragged = false; + // Whether the current or last drag moved anything, so the double-click a drag's second press can produce resets nothing. + let dragMoved = false; // Mirrors whether Alt is currently held during the drag (the desired state). let duplicateRequested = false; // Mirrors whether a frozen copy currently exists in the gradient (the materialized state). @@ -72,19 +73,28 @@ return Math.max(lower, Math.min(upper, position)); } + // The nearest of the markers drawn on the track, skipping any outside 0..1 as the template does + function nearestMarkerIndex(position: number): number | undefined { + let nearest: number | undefined = undefined; + let nearestDistance = Number.POSITIVE_INFINITY; + + markers.forEach((marker, index) => { + if (marker.position < 0 || marker.position > 1) return; + const distance = Math.abs(marker.position - position); + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = index; + } + }); + + return nearest; + } + function markerPointerDown(e: PointerEvent, index: number) { if (disabled) return; if (e.button === BUTTON_LEFT) { - activeMarkerIndexRestore = activeMarkerIndex; - activeMarkerIsMidpointRestore = activeMarkerIsMidpoint; - dragRestorePosition = markers[index].position; - dragInsertedMarker = false; - // Only offer duplication where new stops are allowed. Don't materialize yet: wait for the first move so an Alt-click without a drag leaves no stray copy. - duplicateRequested = allowInsert && e.altKey; - duplicateActive = false; - setActive(index, false); - addEvents(); + beginMarkerDrag(e, index); return; } @@ -93,11 +103,30 @@ } } + function beginMarkerDrag(e: PointerEvent, index: number) { + activeMarkerIndexRestore = activeMarkerIndex; + activeMarkerIsMidpointRestore = activeMarkerIsMidpoint; + dragRestorePosition = markers[index].position; + dragInsertedMarker = false; + dragMoved = false; + // Only offer duplication where new stops are allowed. Don't materialize yet: wait for the first move so an Alt-click without a drag leaves no stray copy. + duplicateRequested = allowInsert && e.altKey; + duplicateActive = false; + setActive(index, false); + addEvents(); + } + + // Picks up the marker at `index` and carries it to the pointer + function pickUpMarker(e: PointerEvent, index: number) { + beginMarkerDrag(e, index); + moveActiveMarker(e); + } + function midpointPointerDown(e: PointerEvent, index: number) { if (disabled) return; if (e.button !== BUTTON_LEFT) return; - midpointDragged = false; + dragMoved = false; activeMarkerIndexRestore = activeMarkerIndex; activeMarkerIsMidpointRestore = activeMarkerIsMidpoint; dragRestorePosition = markers[index].midpoint; @@ -106,23 +135,29 @@ } function midpointDoubleClick(index: number) { - if (disabled || midpointDragged) return; + if (disabled || dragMoved) return; emit({ ResetMidpoint: { index } }); } function markerDoubleClick(index: number) { - if (disabled) return; + if (disabled || dragMoved) return; emit({ ResetMarker: { index } }); } function trackPointerDown(e: PointerEvent) { if (disabled) return; if (e.button !== BUTTON_LEFT) return; - if (!allowInsert) return; const position = pointerPosition(e); if (position === undefined) return; + // Where nothing can be inserted, the click picks up the nearest marker instead + if (!allowInsert) { + const index = nearestMarkerIndex(position); + if (index !== undefined) pickUpMarker(e, index); + return; + } + // Compute the index this marker will land at after Rust inserts it (matches Rust's `insert_stop` logic). let insertIndex = markers.findIndex((m) => m.position > position); if (insertIndex === -1) insertIndex = markers.length; @@ -133,6 +168,7 @@ activeMarkerIsMidpointRestore = activeMarkerIsMidpoint; dragRestorePosition = position; dragInsertedMarker = true; + dragMoved = false; // A stop being created by this drag can't be duplicated; duplication is only for dragging an existing stop. duplicateRequested = false; duplicateActive = false; @@ -142,6 +178,12 @@ addEvents(); } + // The lane the handles hang in picks up the nearest marker like the strip does, except where the click landed on a marker itself + function markerTrackPointerDown(e: PointerEvent) { + if (e.target !== e.currentTarget) return; + trackPointerDown(e); + } + function deleteShortcut(e: KeyboardEvent) { if (disabled) return; if (e.key !== "Delete" && e.key !== "Backspace") return; @@ -231,6 +273,7 @@ if (position === undefined) return; if (!allowReorder) position = clampToNeighbors(activeMarkerIndex, position); + dragMoved = true; if (!dragInsertedMarker) dispatch("dragging", true); emit({ MoveMarker: { index: activeMarkerIndex, position } }); } @@ -265,7 +308,7 @@ } const local = absolute < deadZoneSplit ? absolute + 1 - left : absolute - left; - midpointDragged = true; + dragMoved = true; dispatch("dragging", true); emit({ MoveMidpoint: { index: activeMarkerIndex, position: local / range } }); } @@ -303,7 +346,6 @@ dragInsertedMarker = false; activeMarkerIndexRestore = undefined; activeMarkerIsMidpointRestore = false; - midpointDragged = false; duplicateRequested = false; duplicateActive = false; skipNextMove = false; @@ -395,22 +437,24 @@ - - - - {#each trackSamples as sample} - - {/each} - - - + {#if !rangeSlider} + + + + {#each trackSamples as sample} + + {/each} + + + + {/if} {#each midpointPositions as midpoint, index} @@ -430,7 +474,7 @@ {/if} {/each} - + {#each markers as marker, index} {#if marker.position >= 0 && marker.position <= 1} Date: Mon, 14 Sep 2026 18:49:09 -0700 Subject: [PATCH 30/46] Show a range slider beside bounded range-mode numbers in the Properties panel (#4532) * Show a range slider beside bounded numbers in range mode * Fall back to a plain number widget when a range's bounds are reversed or equal --- .../document/node_graph/node_properties.rs | 39 ++++++++++++++++--- node-graph/nodes/gstd/src/text.rs | 1 + 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index c9406ab3290..caba27f2146 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -196,6 +196,20 @@ impl SliderRange { } } +/// The number a parameter's definition gives it by default, which a slider's double-click restores. +fn definition_default_number(parameter_widgets_info: &ParameterWidgetsInfo) -> Option { + let identifier = parameter_widgets_info + .network_interface + .reference(¶meter_widgets_info.node_id, parameter_widgets_info.selection_network_path)?; + let input = resolve_document_node_type(&identifier)?.node_template.inputs.get(parameter_widgets_info.index)?; + + match input.as_value()? { + TaggedValue::F64(value) => Some(*value), + TaggedValue::F32(value) => Some(*value as f64), + _ => None, + } +} + pub(crate) fn property_from_type( node_id: NodeId, index: usize, @@ -244,6 +258,21 @@ pub(crate) fn property_from_type( .range_max(Some(extent_max).filter(|bound| bound.is_finite())) }; + // A range-mode number clamped at both ends by its own hard bounds, or by a type whose extent is a true limit, becomes a range + // slider beside its number input, unless a soft bound lets typing pass the slider. An Angle's type default is no such limit. + let no_soft_bounds = soft_min.is_none() && soft_max.is_none(); + let hard_both_ends = hard_min.is_some() && hard_max.is_some(); + let number_or_slider = |default_info: ParameterWidgetsInfo, number_input: NumberInput, type_limits: bool| -> LayoutGroup { + let fixed_extent = number_input.mode == NumberInputMode::Range && no_soft_bounds && (hard_both_ends || type_limits); + match (number_input.min, number_input.max) { + (Some(min), Some(max)) if fixed_extent && min.is_finite() && max.is_finite() && min < max => { + let default = definition_default_number(&default_info); + range_slider_widget(default_info, number_input.mode_increment(), SliderRange { min, max, default }).into() + } + _ => number_widget(default_info, number_input).into(), + } + }; + let default_info = ParameterWidgetsInfo::at_index(node_id, index, true, context); // A type with no widget can only be supplied through the graph, labeled with a placeholder row @@ -269,13 +298,13 @@ pub(crate) fn property_from_type( Type::Concrete(concrete_type) => { match concrete_type.alias.as_ref().map(|x| x.as_ref()) { // Aliased types (ambiguous values) - Some("Percentage") | Some("PercentageF32") => number_widget(default_info, bounded(number_input.percentage(), 0., 100.)).into(), - Some("SignedPercentage") | Some("SignedPercentageF32") => number_widget(default_info, bounded(number_input.percentage(), -100., 100.)).into(), - Some("Angle") | Some("AngleF32") => number_widget(default_info, bounded(number_input.mode_range(), -180., 180.).unit(unit.unwrap_or("°"))).into(), + Some("Percentage") | Some("PercentageF32") => number_or_slider(default_info, bounded(number_input.percentage(), 0., 100.), true), + Some("SignedPercentage") | Some("SignedPercentageF32") => number_or_slider(default_info, bounded(number_input.percentage(), -100., 100.), true), + Some("Angle") | Some("AngleF32") => number_or_slider(default_info, bounded(number_input.mode_range(), -180., 180.).unit(unit.unwrap_or("°")), false), Some("Multiplier") => number_widget(default_info, bounded(number_input, f64::NEG_INFINITY, f64::INFINITY).unit(unit.unwrap_or("x"))).into(), Some("PixelLength") => number_widget(default_info, bounded(number_input, 0., f64::INFINITY).unit(unit.unwrap_or(" px"))).into(), Some("Length") => number_widget(default_info, bounded(number_input, 0., f64::INFINITY)).into(), - Some("Fraction") => number_widget(default_info, bounded(number_input.mode_range(), 0., 1.)).into(), + Some("Fraction") => number_or_slider(default_info, bounded(number_input.mode_range(), 0., 1.), true), Some("Progression") => progression_widget(default_info, bounded(number_input, 0., f64::INFINITY)).into(), Some("SignedInteger") => number_widget(default_info, bounded(number_input.int(), f64::NEG_INFINITY, f64::INFINITY)).into(), Some("SeedValue") => number_widget(default_info, bounded(number_input.int(), 0., f64::INFINITY)).into(), @@ -295,7 +324,7 @@ pub(crate) fn property_from_type( // =============== // PRIMITIVE TYPES // =============== - Some(x) if id_is::(x) || id_is::(x) => number_widget(default_info, bounded(number_input, f64::NEG_INFINITY, f64::INFINITY)).into(), + Some(x) if id_is::(x) || id_is::(x) => number_or_slider(default_info, bounded(number_input, f64::NEG_INFINITY, f64::INFINITY), false), Some(x) if id_is::(x) => number_widget(default_info, bounded(number_input.int(), 0., f64::from(u32::MAX))).into(), Some(x) if id_is::(x) => number_widget(default_info, bounded(number_input.int(), 0., f64::INFINITY)).into(), Some(x) if id_is::(x) => bool_widget(default_info, CheckboxInput::default()).into(), diff --git a/node-graph/nodes/gstd/src/text.rs b/node-graph/nodes/gstd/src/text.rs index e58f16f5904..f297800d068 100644 --- a/node-graph/nodes/gstd/src/text.rs +++ b/node-graph/nodes/gstd/src/text.rs @@ -38,6 +38,7 @@ fn text( letter_spacing: Item, /// The angle of faux italic slant applied to each glyph. #[unit("°")] + #[range] #[hard(-85..85)] letter_tilt: Item, /// Enables the maximum width constraint so lines can wrap. From ced8876ea0631b7b6ea7c85270e80b867c7bdd07 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 14 Sep 2026 19:55:38 -0700 Subject: [PATCH 31/46] Add a selection mode to the spectrum widget so only the gradient editor supports having a highlighted stop (#4533) * Add a selection mode to the spectrum widget so only the gradient editor keeps a highlighted stop * Record the drag mode for every drag and require selection for reordering so a non-selecting widget cannot drag the wrong stop --- .../color_picker_message_handler.rs | 1 + .../utility_types/widgets/input_widgets.rs | 6 +- .../widgets/inputs/SpectrumInput.svelte | 68 ++++++++++++++----- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/editor/src/messages/color_picker/color_picker_message_handler.rs b/editor/src/messages/color_picker/color_picker_message_handler.rs index 03d055a31b9..208fa10e9a2 100644 --- a/editor/src/messages/color_picker/color_picker_message_handler.rs +++ b/editor/src/messages/color_picker/color_picker_message_handler.rs @@ -529,6 +529,7 @@ impl ColorPickerMessageHandler { .allow_insert(!self.disabled) .allow_delete(!self.disabled) .allow_reorder(true) + .allow_select(true) .disabled(self.disabled) .on_update(|update: &SpectrumInputUpdate| ColorPickerMessage::GradientUpdate { update: update.clone() }.into()) .widget_instance(), diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index e0d1731f4c6..f0d20adbdf2 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -668,9 +668,13 @@ pub struct SpectrumInput { /// Whether right-click or pressing Delete removes a marker. The handler still has the final say on whether the deletion goes through (e.g., enforcing a minimum count). #[serde(rename = "allowDelete")] pub allow_delete: bool, - /// Whether dragging a marker past another reorders them. If false, the dragged marker is clamped between its neighbors. + /// Whether dragging a marker past another reorders them, which also needs `allow_select`. Otherwise the dragged marker is clamped between its neighbors. #[serde(rename = "allowReorder")] pub allow_reorder: bool, + /// Whether clicking a marker selects it, keeping it highlighted and reported as the active marker until another is chosen, + /// as a gradient editor needs for the stop being edited. Otherwise the highlight only follows the pointer and the drag. + #[serde(rename = "allowSelect")] + pub allow_select: bool, /// Compact mode: 8px track height with 8px top padding, for use in rows alongside other widgets. pub narrow: bool, /// Plain range-slider mode, for a number beside its number input: a flat 4px track is drawn in place of the gradient, so `track` is never shown. diff --git a/frontend/src/components/widgets/inputs/SpectrumInput.svelte b/frontend/src/components/widgets/inputs/SpectrumInput.svelte index 8416e06e01f..653fba1c735 100644 --- a/frontend/src/components/widgets/inputs/SpectrumInput.svelte +++ b/frontend/src/components/widgets/inputs/SpectrumInput.svelte @@ -25,6 +25,7 @@ export let allowInsert = true; export let allowDelete = true; export let allowReorder = true; + export let allowSelect = false; export let narrow = false; export let rangeSlider = false; export let disabled = false; @@ -49,17 +50,40 @@ // Set when a key-triggered reconcile inserts/removes the frozen copy, so the next pointer move skips emitting a `MoveMarker` // that would otherwise race the structural change before Rust has reported the dragged marker's new index. let skipNextMove = false; + // The hovered marker, highlighted ahead of the drag. + let hoverRun: [number, number] | undefined = undefined; + // The marker being dragged, or the left marker of the interval when a midpoint is dragged, and which of the two it is. + // Where selecting is allowed, these follow the selection, which Rust renumbers across structural changes. + let dragIndex: number | undefined = undefined; + let dragIsMidpoint = false; + $: if (allowSelect) { + dragIndex = activeMarkerIndex; + dragIsMidpoint = activeMarkerIsMidpoint; + } + // The marker highlighted: the one hovered, else the one being dragged (or selected, where selecting is allowed). + let highlightedRun: [number, number] | undefined; + $: highlightedRun = hoverRun !== undefined ? hoverRun : typeof dragIndex === "number" && !dragIsMidpoint ? [dragIndex, dragIndex] : undefined; function emit(intent: SpectrumInputUpdate) { dispatch("update", intent); } function setActive(index: number | undefined, isMidpoint: boolean) { + dragIndex = index; + dragIsMidpoint = isMidpoint; + if (!allowSelect) return; + activeMarkerIndex = index; activeMarkerIsMidpoint = isMidpoint; emit({ ActiveMarker: { activeMarkerIndex: index, activeMarkerIsMidpoint: isMidpoint } }); } + // Hovering highlights what a drag would carry, except where selecting is allowed and hover keeps its lighter tint + function markerPointerEnter(index: number) { + if (allowSelect) return; + hoverRun = [index, index]; + } + function pointerPosition(e: MouseEvent, clamp = true): number | undefined { const rect = markerTrackElement?.div()?.getBoundingClientRect(); if (!rect) return undefined; @@ -173,6 +197,8 @@ duplicateRequested = false; duplicateActive = false; // Don't dispatch an `ActiveMarker` here. The Rust handler already updates the active marker in response to `InsertMarker` and a duplicate `ActiveMarker` would race the layout update. + dragIndex = insertIndex; + dragIsMidpoint = false; activeMarkerIndex = insertIndex; activeMarkerIsMidpoint = false; addEvents(); @@ -208,7 +234,7 @@ let bestDistance = DUPLICATE_POSITION_EPSILON; markers.forEach((marker, index) => { - if (index === activeMarkerIndex) return; + if (index === dragIndex) return; const distance = Math.abs(marker.position - startPosition); if (distance < bestDistance) { @@ -223,14 +249,14 @@ // Bring the materialized duplicate state in line with whether Alt is currently held, inserting or removing the frozen copy. // Returns whether a structural change was emitted, so callers can skip the next move that would race it. function reconcileDuplicate(): boolean { - if (!allowInsert || activeMarkerIndex === undefined || activeMarkerIsMidpoint) return false; + if (!allowInsert || dragIndex === undefined || dragIsMidpoint) return false; if (duplicateRequested && !duplicateActive) { // Drop a frozen copy at the drag's start position. The dragged marker stays active and becomes the duplicate being moved. if (dragRestorePosition === undefined) return false; - emit({ InsertDuplicate: { index: activeMarkerIndex, position: dragRestorePosition } }); + emit({ InsertDuplicate: { index: dragIndex, position: dragRestorePosition } }); duplicateActive = true; return true; @@ -250,7 +276,7 @@ } function moveActiveMarker(e: PointerEvent) { - if (disabled || activeMarkerIndex === undefined) return; + if (disabled || dragIndex === undefined) return; if (e.buttons === 0) { endDrag(); return; @@ -271,15 +297,16 @@ let position = pointerPosition(e); if (position === undefined) return; - if (!allowReorder) position = clampToNeighbors(activeMarkerIndex, position); + // Without selection nothing reports the dragged marker's new index after a reorder, so it stays between its neighbors + if (!allowReorder || !allowSelect) position = clampToNeighbors(dragIndex, position); dragMoved = true; if (!dragInsertedMarker) dispatch("dragging", true); - emit({ MoveMarker: { index: activeMarkerIndex, position } }); + emit({ MoveMarker: { index: dragIndex, position } }); } function moveActiveMidpoint(e: PointerEvent) { - if (disabled || activeMarkerIndex === undefined) return; + if (disabled || dragIndex === undefined) return; if (e.buttons === 0) { endDrag(); return; @@ -287,12 +314,12 @@ // The wrapped interval's diamond (cyclic only) belongs to the last marker and spans through the 1|0 boundary to the first. // Its pointer ratio stays unclamped so overdragging past the strip's right or left edge keeps tracking after the 1|0 wrap. - const isWrappedInterval = trackCyclic && activeMarkerIndex === markers.length - 1; + const isWrappedInterval = trackCyclic && dragIndex === markers.length - 1; const absolute = pointerPosition(e, !isWrappedInterval); if (absolute === undefined) return; - const left = markers[activeMarkerIndex]?.position; - const right = isWrappedInterval ? markers[0].position + 1 : markers[activeMarkerIndex + 1]?.position; + const left = markers[dragIndex]?.position; + const right = isWrappedInterval ? markers[0].position + 1 : markers[dragIndex + 1]?.position; if (left === undefined || right === undefined) return; const range = right - left; if (range <= 0) return; @@ -310,13 +337,13 @@ dragMoved = true; dispatch("dragging", true); - emit({ MoveMidpoint: { index: activeMarkerIndex, position: local / range } }); + emit({ MoveMidpoint: { index: dragIndex, position: local / range } }); } function abortDrag() { - if (disabled || activeMarkerIndex === undefined) return; + if (disabled || dragIndex === undefined) return; - const dragged = activeMarkerIndex; + const dragged = dragIndex; const anchor = duplicateActive ? findDuplicateAnchorIndex() : undefined; if (dragInsertedMarker) { @@ -327,7 +354,7 @@ emit({ DeleteMarker: { index: dragged } }); } else if (dragRestorePosition !== undefined) { // Plain drag: return the marker (or midpoint) to where it began. - if (activeMarkerIsMidpoint) emit({ MoveMidpoint: { index: dragged, position: dragRestorePosition } }); + if (dragIsMidpoint) emit({ MoveMidpoint: { index: dragged, position: dragRestorePosition } }); else emit({ MoveMarker: { index: dragged, position: dragRestorePosition } }); } @@ -349,11 +376,16 @@ duplicateRequested = false; duplicateActive = false; skipNextMove = false; + // Without selection nothing stays active once the drag ends + if (!allowSelect) { + dragIndex = undefined; + dragIsMidpoint = false; + } dispatch("dragging", false); } function onPointerMove(e: PointerEvent) { - if (activeMarkerIsMidpoint) moveActiveMidpoint(e); + if (dragIsMidpoint) moveActiveMidpoint(e); else moveActiveMarker(e); } @@ -377,7 +409,7 @@ // Pressing Alt mid-drag duplicates the marker, leaving a frozen copy where the drag began. Reconcile immediately for instant // feedback, and arm a skip so the next pointer move doesn't race the just-emitted structural change. Only when dragging an // existing stop (not one being created by this drag). - if (e.key === "Alt" && allowInsert && !activeMarkerIsMidpoint && !dragInsertedMarker && !duplicateRequested) { + if (e.key === "Alt" && allowInsert && !dragIsMidpoint && !dragInsertedMarker && !duplicateRequested) { duplicateRequested = true; if (reconcileDuplicate()) skipNextMove = true; } @@ -479,9 +511,11 @@ {#if marker.position >= 0 && marker.position <= 1} = highlightedRun[0] && index <= highlightedRun[1]} style:--marker-position={marker.position} style:--marker-color={marker.handleColorCSS} + on:pointerenter={() => markerPointerEnter(index)} + on:pointerleave={() => (hoverRun = undefined)} on:pointerdown={(e) => markerPointerDown(e, index)} on:dblclick={() => markerDoubleClick(index)} data-gradient-marker From fdd72eb560f4f3d5dfbdd533b167474310466ced Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 14 Sep 2026 20:53:06 -0700 Subject: [PATCH 32/46] Add dashed links between spectrum markers and typed marker scales to the shared spectrum sections (#4534) --- .../utility_types/widgets/input_widgets.rs | 15 +- .../document/node_graph/node_properties.rs | 200 +++++++++++++----- .../widgets/inputs/SpectrumInput.svelte | 138 +++++++++++- 3 files changed, 290 insertions(+), 63 deletions(-) diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index f0d20adbdf2..db541dc4eab 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -705,12 +705,25 @@ pub struct SpectrumMarker { /// discarding any transparency so the handle always shows the RGB that steers the interpolation. #[serde(rename = "handleColorCSS")] handle_color_css: String, + /// Whether a dashed line runs from this marker to the next through the lane below the track. Dragging it carries both markers. + #[serde(rename = "dashedToNext")] + dashed_to_next: bool, } impl SpectrumMarker { pub fn new(position: f64, midpoint: f64, handle_color: Color) -> Self { let handle_color_css = format!("#{}", SRGBA8::from(handle_color).to_rgb_hex()); - Self { position, midpoint, handle_color_css } + Self { + position, + midpoint, + handle_color_css, + dashed_to_next: false, + } + } + + pub fn dash_to_next(mut self) -> Self { + self.dashed_to_next = true; + self } } diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index caba27f2146..dfb06abf792 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -1399,36 +1399,101 @@ pub(crate) fn transfer_curves_properties(node_id: NodeId, context: &mut NodeProp pub(crate) fn levels_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { use graphene_std::raster::levels::*; - // (parameter, marker handle color, default percentage for double-click reset) let input_range_params = [ - (ShadowsInput.into(), Color::BLACK, 0.), - (MidtonesInput.into(), Color::MIDDLE_GRAY, 50.), - (HighlightsInput.into(), Color::WHITE, 100.), + SpectrumSectionParam::new(ShadowsInput, Color::BLACK, 0., MarkerScale::Percent), + SpectrumSectionParam::new(MidtonesInput, Color::MIDDLE_GRAY, 50., MarkerScale::Percent), + SpectrumSectionParam::new(HighlightsInput, Color::WHITE, 100., MarkerScale::Percent), + ]; + let output_range_params = [ + SpectrumSectionParam::new(OutputMinimumsInput, Color::BLACK, 0., MarkerScale::Percent), + SpectrumSectionParam::new(OutputMaximumsInput, Color::WHITE, 100., MarkerScale::Percent), ]; - let output_range_params = [(OutputMinimumsInput.into(), Color::BLACK, 0.), (OutputMaximumsInput.into(), Color::WHITE, 100.)]; let mut layout = Vec::with_capacity(5); - build_shared_spectrum_section(node_id, context, &input_range_params, &mut layout); - build_shared_spectrum_section(node_id, context, &output_range_params, &mut layout); + build_shared_spectrum_section(node_id, context, &bw_track(), &input_range_params, &mut layout); + build_shared_spectrum_section(node_id, context, &bw_track(), &output_range_params, &mut layout); layout } -/// Append a section of related percentage parameters as rows: a shared black-to-white spectrum (with one marker per non-exposed parameter) sits on the first non-exposed row +/// How a shared spectrum marker's value maps onto its track. +#[derive(Clone, Copy)] +enum MarkerScale { + /// A 0..100 percentage, placed linearly. + Percent, + /// A gamma of 0.01..9.99 running from 9.99 at the left to 0.01 at the right, logarithmic on each side of the 1 at its center. + Gamma, +} + +impl MarkerScale { + fn position(self, value: f64) -> f64 { + match self { + Self::Percent => value / 100., + Self::Gamma if value >= 1. => 0.5 - 0.5 * value.log10() / 9.99_f64.log10(), + Self::Gamma => 0.5 + 0.5 * value.log10() / 0.01_f64.log10(), + } + .clamp(0., 1.) + } + + fn value(self, position: f64) -> f64 { + match self { + Self::Percent => (position * 100.).clamp(0., 100.), + Self::Gamma if position <= 0.5 => 9.99_f64.powf(1. - 2. * position).clamp(1., 9.99), + Self::Gamma => 0.01_f64.powf(2. * position - 1.).clamp(0.01, 1.), + } + } + + fn number_input(self) -> NumberInput { + match self { + Self::Percent => NumberInput::default().mode_increment().unit("%").min(0.).max(100.).display_decimal_places(0), + Self::Gamma => NumberInput::default().mode_increment().min(0.01).max(9.99).display_decimal_places(2), + } + } +} + +/// One parameter of a shared spectrum section and how its marker sits on the track. +struct SpectrumSectionParam { + parameter: ParameterRef, + handle_color: Color, + /// The value a double-click resets to. + default_value: f64, + scale: MarkerScale, + /// Whether a dashed line joins the marker to the next parameter's marker. + dash_to_next: bool, +} + +impl SpectrumSectionParam { + fn new(parameter: impl Into, handle_color: Color, default_value: f64, scale: MarkerScale) -> Self { + Self { + parameter: parameter.into(), + handle_color, + default_value, + scale, + dash_to_next: false, + } + } + + fn dash_to_next(mut self) -> Self { + self.dash_to_next = true; + self + } +} + +/// Append a section of related parameters as rows: a shared spectrum over `track` (with one marker per non-exposed parameter) sits on the first non-exposed row /// alongside its 60px number input, and the remaining non-exposed rows show only their 60px number input. Exposed parameters render as the standard exposed-row display. /// Marker positions are clamped to non-decreasing display order so they never visually cross even if the underlying values do. -fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesContext, params: &[(ParameterRef, Color, f64)], layout: &mut Vec) { +fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesContext, track: &Gradient, params: &[SpectrumSectionParam], layout: &mut Vec) { // Snapshot exposure and values before the mutable-borrow loop let exposure_and_value: Vec<(bool, f64)> = match get_document_node(node_id, context) { Ok(document_node) => params .iter() - .map(|(parameter, _, _)| { - let input = document_node.inputs.get(parameter.input_index); + .map(|param| { + let input = document_node.inputs.get(param.parameter.input_index); let exposed = input.is_some_and(|input| input.is_exposed()); - let percent = input + let value = input .and_then(|input| input.as_value()) .and_then(|tagged| if let TaggedValue::F32(value) = tagged { Some(*value as f64) } else { None }) .unwrap_or(0.); - (exposed, percent) + (exposed, value) }) .collect(), Err(err) => { @@ -1437,20 +1502,23 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo } }; - // Build markers for all non-exposed params + // Build markers for all non-exposed params, linking one to the next only when both have markers let mut marker_input_indices = Vec::new(); - let mut marker_default_percents = Vec::new(); + let mut marker_default_positions = Vec::new(); + let mut marker_scales = Vec::new(); let mut marker_positions = Vec::new(); - let mut handle_colors = Vec::new(); - for (i, &(ref parameter, handle_color, default_percent)) in params.iter().enumerate() { - let (exposed, percent) = exposure_and_value[i]; + let mut marker_colors_and_links = Vec::new(); + for (i, param) in params.iter().enumerate() { + let (exposed, value) = exposure_and_value[i]; if exposed { continue; } - marker_positions.push((percent / 100.).clamp(0., 1.)); - marker_input_indices.push(parameter.input_index); - marker_default_percents.push(default_percent); - handle_colors.push(handle_color); + let next_has_marker = exposure_and_value.get(i + 1).is_some_and(|&(next_exposed, _)| !next_exposed); + marker_positions.push(param.scale.position(value)); + marker_input_indices.push(param.parameter.input_index); + marker_default_positions.push(param.scale.position(param.default_value)); + marker_scales.push(param.scale); + marker_colors_and_links.push((param.handle_color, param.dash_to_next && next_has_marker)); } // Enforce non-decreasing order so markers never visually cross, matching the node's algorithm where shadows takes precedence @@ -1460,13 +1528,16 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo let spectrum_markers: Vec = marker_positions .iter() - .zip(&handle_colors) - .map(|(&position, &handle_color)| SpectrumMarker::new(position, 0.5, handle_color)) + .zip(&marker_colors_and_links) + .map(|(&position, &(handle_color, dashed))| { + let marker = SpectrumMarker::new(position, 0.5, handle_color); + if dashed { marker.dash_to_next() } else { marker } + }) .collect(); // Build the shared spectrum widget (placed on the first non-exposed row) let spectrum_widget = (!spectrum_markers.is_empty()).then(|| { - SpectrumInput::new(GradientStops::from(&bw_track())) + SpectrumInput::new(GradientStops::from(track)) .track_space(GradientSpace::RgbGamma) .markers(spectrum_markers) .show_midpoints(false) @@ -1476,31 +1547,31 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo .narrow(true) .on_update({ let marker_input_indices = marker_input_indices.clone(); - let marker_default_percents = marker_default_percents.clone(); + let marker_default_positions = marker_default_positions.clone(); + let marker_scales = marker_scales.clone(); let marker_positions = marker_positions.clone(); move |update: &SpectrumInputUpdate| { - let (input_index, percent) = match update { - SpectrumInputUpdate::MoveMarker { index, position } => match marker_input_indices.get(*index as usize) { - Some(&input_index) => (input_index, *position * 100.), - None => return Message::NoOp, - }, - SpectrumInputUpdate::ResetMarker { index } => { - let i = *index as usize; - let Some(&input_index) = marker_input_indices.get(i) else { return Message::NoOp }; - let Some(&default_percent) = marker_default_percents.get(i) else { return Message::NoOp }; - // Falls back to midpoint between neighbors if the default would cross one - let left = if i == 0 { 0. } else { marker_positions[i - 1] }; - let right = marker_positions.get(i + 1).copied().unwrap_or(1.); - let default_position = default_percent / 100.; - let new_position = if (left..=right).contains(&default_position) { default_position } else { (left + right) / 2. }; - (input_index, new_position * 100.) - } + let i = match update { + SpectrumInputUpdate::MoveMarker { index, .. } | SpectrumInputUpdate::ResetMarker { index } => *index as usize, + _ => return Message::NoOp, + }; + let (Some(&input_index), Some(&scale), Some(&default_position)) = (marker_input_indices.get(i), marker_scales.get(i), marker_default_positions.get(i)) else { + return Message::NoOp; + }; + let left = if i == 0 { 0. } else { marker_positions[i - 1] }; + let right = marker_positions.get(i + 1).copied().unwrap_or(1.); + + let scale_position = match update { + SpectrumInputUpdate::MoveMarker { position, .. } => *position, + // A default that would cross a neighbor falls back to the midpoint between them + SpectrumInputUpdate::ResetMarker { .. } if (left..=right).contains(&default_position) => default_position, + SpectrumInputUpdate::ResetMarker { .. } => (left + right) / 2., _ => return Message::NoOp, }; NodeGraphMessage::SetInputValue { node_id, input_index, - value: TaggedValue::F32(percent.clamp(0., 100.) as f32).into(), + value: TaggedValue::F32(scale.value(scale_position) as f32).into(), } .into() } @@ -1510,12 +1581,11 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo }); let spectrum_owner = marker_input_indices.first().copied(); - let number_input = NumberInput::default().mode_increment().unit("%").min(0.).max(100.); - // One row per parameter: first non-exposed carries the shared spectrum, others get just a number input - for (i, (parameter, _, _)) in params.iter().enumerate() { + for (i, param) in params.iter().enumerate() { let (exposed, current) = exposure_and_value[i]; - let input_index = parameter.input_index; + let input_index = param.parameter.input_index; + let number_input = param.scale.number_input(); if exposed { let row = number_widget(ParameterWidgetsInfo::at_index(node_id, input_index, true, context), number_input.clone()); @@ -1537,7 +1607,6 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo .value(Some(current)) .min_width(60) .max_width(60) - .display_decimal_places(0) .on_update(update_value_at_index( move |widget: &NumberInput| TaggedValue::F32(widget.value.unwrap_or(0.) as f32), node_id, @@ -1772,10 +1841,13 @@ fn spectrum_slider_row( pub(crate) fn threshold_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { use graphene_std::raster::threshold::*; - let params: &[(ParameterRef, Color, f64)] = &[(MinLuminanceInput.into(), Color::BLACK, 50.), (MaxLuminanceInput.into(), Color::WHITE, 100.)]; + let params = [ + SpectrumSectionParam::new(MinLuminanceInput, Color::WHITE, 50., MarkerScale::Percent).dash_to_next(), + SpectrumSectionParam::new(MaxLuminanceInput, Color::WHITE, 100., MarkerScale::Percent), + ]; let mut layout = Vec::with_capacity(2); - build_shared_spectrum_section(node_id, context, params, &mut layout); + build_shared_spectrum_section(node_id, context, &bw_track(), ¶ms, &mut layout); layout } @@ -2143,11 +2215,31 @@ pub(crate) fn sample_polyline_properties(node_id: NodeId, context: &mut NodeProp pub(crate) fn exposure_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { use graphene_std::raster::exposure::*; - let exposure = number_widget(ParameterWidgetsInfo::new(node_id, ExposureInput, true, context), NumberInput::default().min(-20.).max(20.)); - let offset = number_widget(ParameterWidgetsInfo::new(node_id, OffsetInput, true, context), NumberInput::default().min(-0.5).max(0.5)); - let gamma_correction = number_widget( + let exposure = range_slider_widget( + ParameterWidgetsInfo::new(node_id, ExposureInput, true, context), + NumberInput::default().min(-20.).max(20.), + SliderRange { + min: -20., + max: 20., + default: Some(0.), + }, + ); + let offset = range_slider_widget( + ParameterWidgetsInfo::new(node_id, OffsetInput, true, context), + NumberInput::default().min(-0.5).max(0.5), + SliderRange { + min: -0.5, + max: 0.5, + default: Some(0.), + }, + ); + + let gamma_correction = slider_row( ParameterWidgetsInfo::new(node_id, GammaCorrectionInput, true, context), - NumberInput::default().min(0.01).max(9.99).increment_step(0.1), + MarkerScale::Gamma.number_input().increment_step(0.1), + Some(1.), + |gamma| MarkerScale::Gamma.position(gamma), + |position| MarkerScale::Gamma.value(position), ); vec![LayoutGroup::row(exposure), LayoutGroup::row(offset), LayoutGroup::row(gamma_correction)] diff --git a/frontend/src/components/widgets/inputs/SpectrumInput.svelte b/frontend/src/components/widgets/inputs/SpectrumInput.svelte index 653fba1c735..43aa6007dcd 100644 --- a/frontend/src/components/widgets/inputs/SpectrumInput.svelte +++ b/frontend/src/components/widgets/inputs/SpectrumInput.svelte @@ -50,7 +50,9 @@ // Set when a key-triggered reconcile inserts/removes the frozen copy, so the next pointer move skips emitting a `MoveMarker` // that would otherwise race the structural change before Rust has reported the dragged marker's new index. let skipNextMove = false; - // The hovered marker, highlighted ahead of the drag. + // Set while a run of markers drags together: its bounds, the first's offset from the pointer, each member's gap from the first, and their start positions for cancelling. + let dragRun: { first: number; last: number; offset: number; spacings: number[]; restore: number[] } | undefined = undefined; + // The run a hovered marker or dashed link would carry, highlighted ahead of the drag. let hoverRun: [number, number] | undefined = undefined; // The marker being dragged, or the left marker of the interval when a midpoint is dragged, and which of the two it is. // Where selecting is allowed, these follow the selection, which Rust renumbers across structural changes. @@ -60,9 +62,10 @@ dragIndex = activeMarkerIndex; dragIsMidpoint = activeMarkerIsMidpoint; } - // The marker highlighted: the one hovered, else the one being dragged (or selected, where selecting is allowed). + // The markers highlighted: the run being dragged or hovered, else the marker dragged alone (or selected, where selecting is allowed). let highlightedRun: [number, number] | undefined; - $: highlightedRun = hoverRun !== undefined ? hoverRun : typeof dragIndex === "number" && !dragIsMidpoint ? [dragIndex, dragIndex] : undefined; + $: highlightedRun = + dragRun !== undefined ? [dragRun.first, dragRun.last] : hoverRun !== undefined ? hoverRun : typeof dragIndex === "number" && !dragIsMidpoint ? [dragIndex, dragIndex] : undefined; function emit(intent: SpectrumInputUpdate) { dispatch("update", intent); @@ -91,12 +94,30 @@ return clamp ? Math.max(0, Math.min(1, ratio)) : ratio; } - function clampToNeighbors(index: number, position: number): number { - const lower = markers[index - 1]?.position ?? 0; - const upper = markers[index + 1]?.position ?? 1; + // Holds markers `first..=last` (spanning `spacing`) between their neighbors as they move to `position` + function holdBetweenNeighbors(first: number, last: number, spacing: number, position: number): number { + // Without selection nothing reports the dragged marker's new index after a reorder, so it stays between its neighbors + if (allowReorder && allowSelect) return position; + const lower = markers[first - 1]?.position ?? 0; + const upper = (markers[last + 1]?.position ?? 1) - spacing; return Math.max(lower, Math.min(upper, position)); } + // The spans from each marker passing `linked` to its successor + function markerSpans(markers: SpectrumMarker[], linked: (marker: SpectrumMarker) => boolean): { index: number; left: number; width: number }[] { + const spans: { index: number; left: number; width: number }[] = []; + + markers.forEach((marker, index) => { + const next = markers[index + 1]; + if (!linked(marker) || next === undefined || next.position === marker.position) return; + + const [left, right] = next.position > marker.position ? [marker.position, next.position] : [next.position, marker.position]; + spans.push({ index, left, width: right - left }); + }); + + return spans; + } + // The nearest of the markers drawn on the track, skipping any outside 0..1 as the template does function nearestMarkerIndex(position: number): number | undefined { let nearest: number | undefined = undefined; @@ -140,6 +161,43 @@ addEvents(); } + // Drags markers `first..=last` as one, keeping the pointer's offset from the first when `grabbed` and otherwise carrying the run to the pointer + function beginRunDrag(e: PointerEvent, first: number, last: number, grabbed: boolean) { + const pointer = pointerPosition(e); + if (pointer === undefined) return; + const start = markers[first].position; + + const spacings: number[] = []; + const restore: number[] = []; + for (let index = first; index <= last; index += 1) { + const position = markers[index].position; + spacings.push(position - start); + restore.push(position); + } + + activeMarkerIndexRestore = activeMarkerIndex; + activeMarkerIsMidpointRestore = activeMarkerIsMidpoint; + dragRestorePosition = start; + dragInsertedMarker = false; + dragMoved = false; + duplicateRequested = false; + duplicateActive = false; + dragRun = { first, last, offset: grabbed ? start - pointer : 0, spacings, restore }; + setActive(first, false); + addEvents(); + } + + // The run a dashed link from `index` carries: the two markers it joins + function dashedRun(index: number): [number, number] { + return [index, index + 1]; + } + + function dashPointerDown(e: PointerEvent, index: number) { + if (disabled || e.button !== BUTTON_LEFT) return; + const [first, last] = dashedRun(index); + beginRunDrag(e, first, last, true); + } + // Picks up the marker at `index` and carries it to the pointer function pickUpMarker(e: PointerEvent, index: number) { beginMarkerDrag(e, index); @@ -168,6 +226,11 @@ emit({ ResetMarker: { index } }); } + function resetRun(first: number, last: number) { + if (disabled || dragMoved) return; + for (let index = first; index <= last; index += 1) emit({ ResetMarker: { index } }); + } + function trackPointerDown(e: PointerEvent) { if (disabled) return; if (e.button !== BUTTON_LEFT) return; @@ -297,14 +360,32 @@ let position = pointerPosition(e); if (position === undefined) return; - // Without selection nothing reports the dragged marker's new index after a reorder, so it stays between its neighbors - if (!allowReorder || !allowSelect) position = clampToNeighbors(dragIndex, position); + position = holdBetweenNeighbors(dragIndex, dragIndex, 0, position); dragMoved = true; if (!dragInsertedMarker) dispatch("dragging", true); emit({ MoveMarker: { index: dragIndex, position } }); } + function moveRun(e: PointerEvent) { + if (disabled || dragRun === undefined) return; + if (e.buttons === 0) { + endDrag(); + return; + } + + const { first, last, offset, spacings } = dragRun; + const pointer = pointerPosition(e); + if (pointer === undefined) return; + + const span = spacings[spacings.length - 1]; + const start = holdBetweenNeighbors(first, last, span, pointer + offset); + + dragMoved = true; + dispatch("dragging", true); + spacings.forEach((spacing, i) => emit({ MoveMarker: { index: first + i, position: start + spacing } })); + } + function moveActiveMidpoint(e: PointerEvent) { if (disabled || dragIndex === undefined) return; if (e.buttons === 0) { @@ -352,6 +433,10 @@ } else if (anchor !== undefined) { // A duplicated pre-existing marker: the frozen copy already sits at the start position, so deleting the dragged copy restores the original. emit({ DeleteMarker: { index: dragged } }); + } else if (dragRun !== undefined) { + // A run drag: return every member to where it began. + const { first, restore } = dragRun; + restore.forEach((position, i) => emit({ MoveMarker: { index: first + i, position } })); } else if (dragRestorePosition !== undefined) { // Plain drag: return the marker (or midpoint) to where it began. if (dragIsMidpoint) emit({ MoveMidpoint: { index: dragged, position: dragRestorePosition } }); @@ -376,6 +461,7 @@ duplicateRequested = false; duplicateActive = false; skipNextMove = false; + dragRun = undefined; // Without selection nothing stays active once the drag ends if (!allowSelect) { dragIndex = undefined; @@ -386,6 +472,7 @@ function onPointerMove(e: PointerEvent) { if (dragIsMidpoint) moveActiveMidpoint(e); + else if (dragRun !== undefined) moveRun(e); else moveActiveMarker(e); } @@ -457,6 +544,7 @@ return positions; } $: midpointPositions = diamondPositions(markers, showMidpoints, trackCyclic, trackInterpolation); + $: dashes = markerSpans(markers, (marker) => marker.dashedToNext); onMount(() => { document.addEventListener("keydown", deleteShortcut); @@ -507,6 +595,18 @@ {/each} + {#each dashes as dash} + + {/each} {#each markers as marker, index} {#if marker.position >= 0 && marker.position <= 1} Date: Mon, 14 Sep 2026 22:51:43 -0700 Subject: [PATCH 33/46] Add per-channel parameters to the 'Levels' node and make its midtones a gamma value (#4535) * Add per-channel records and a gamma midtones value to the 'Levels' node, with a channel selector in its Properties panel * Migrate the old 'Levels' midtones through its output range and bound a lone midtone marker by the track edges --- .../utility_types/widgets/input_widgets.rs | 15 +- .../document/node_graph/node_properties.rs | 122 ++++++- .../messages/portfolio/document_migration.rs | 25 ++ .../widgets/inputs/SpectrumInput.svelte | 12 +- node-graph/nodes/raster/src/adjustments.rs | 322 ++++++++++++++---- 5 files changed, 412 insertions(+), 84 deletions(-) diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index db541dc4eab..75483971a80 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -695,11 +695,9 @@ pub struct SpectrumInput { #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] pub struct SpectrumMarker { - /// Position of the marker along the spectrum track, normally from 0 to 1. A shifted or stretched non-cyclic ramp can - /// place it outside that range, where the track draws only the markers falling within its visible span. + /// Position along the track, normally 0..1. A shifted or stretched non-cyclic ramp can push it outside, where it is not drawn. position: f64, - /// Position (0..1) of the midpoint between this marker and the next, used only if `show_midpoints` is true. - /// The last marker's value controls the wrapped interval when `track_cyclic` is set, and is otherwise ignored. + /// Midpoint (0..1) of the interval to the next marker, used only with `show_midpoints`. The last marker's midpoint spans the wrap of a cyclic track, or is otherwise ignored. midpoint: f64, /// CSS color string for the marker handle's fill. Set via `SpectrumMarker::new` from a linear [`Color`], /// discarding any transparency so the handle always shows the RGB that steers the interpolation. @@ -708,6 +706,9 @@ pub struct SpectrumMarker { /// Whether a dashed line runs from this marker to the next through the lane below the track. Dragging it carries both markers. #[serde(rename = "dashedToNext")] dashed_to_next: bool, + /// Whether this marker follows its neighbors instead of bounding them, so they may drag past its drawn position. + #[serde(rename = "betweenNeighbors")] + between_neighbors: bool, } impl SpectrumMarker { @@ -718,9 +719,15 @@ impl SpectrumMarker { midpoint, handle_color_css, dashed_to_next: false, + between_neighbors: false, } } + pub fn between_neighbors(mut self) -> Self { + self.between_neighbors = true; + self + } + pub fn dash_to_next(mut self) -> Self { self.dashed_to_next = true; self diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index dfb06abf792..1bcaa67906d 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -1399,17 +1399,63 @@ pub(crate) fn transfer_curves_properties(node_id: NodeId, context: &mut NodeProp pub(crate) fn levels_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { use graphene_std::raster::levels::*; + let mut channel_info = ParameterWidgetsInfo::new(node_id, ChannelInput, true, context); + channel_info.exposable = false; + let channel = enum_choice::().for_socket(channel_info).property_row(); + + let channel_value = match get_document_node(node_id, context).ok().and_then(|document_node| document_node.input_value(ChannelInput).cloned()) { + Some(TaggedValue::AdjustmentChannel(channel)) => channel, + _ => AdjustmentChannel::Rgb, + }; + let [shadows, midtones, highlights, output_minimums, output_maximums]: [ParameterRef; 5] = match channel_value { + AdjustmentChannel::Rgb => [ + ShadowsInput.into(), + MidtonesInput.into(), + HighlightsInput.into(), + OutputMinimumsInput.into(), + OutputMaximumsInput.into(), + ], + AdjustmentChannel::Red => [ + RedShadowsInput.into(), + RedMidtonesInput.into(), + RedHighlightsInput.into(), + RedOutputMinimumsInput.into(), + RedOutputMaximumsInput.into(), + ], + AdjustmentChannel::Green => [ + GreenShadowsInput.into(), + GreenMidtonesInput.into(), + GreenHighlightsInput.into(), + GreenOutputMinimumsInput.into(), + GreenOutputMaximumsInput.into(), + ], + AdjustmentChannel::Blue => [ + BlueShadowsInput.into(), + BlueMidtonesInput.into(), + BlueHighlightsInput.into(), + BlueOutputMinimumsInput.into(), + BlueOutputMaximumsInput.into(), + ], + AdjustmentChannel::Alpha => [ + AlphaShadowsInput.into(), + AlphaMidtonesInput.into(), + AlphaHighlightsInput.into(), + AlphaOutputMinimumsInput.into(), + AlphaOutputMaximumsInput.into(), + ], + }; + let input_range_params = [ - SpectrumSectionParam::new(ShadowsInput, Color::BLACK, 0., MarkerScale::Percent), - SpectrumSectionParam::new(MidtonesInput, Color::MIDDLE_GRAY, 50., MarkerScale::Percent), - SpectrumSectionParam::new(HighlightsInput, Color::WHITE, 100., MarkerScale::Percent), + SpectrumSectionParam::new(shadows, Color::BLACK, 0., MarkerScale::Percent), + SpectrumSectionParam::new(midtones, Color::MIDDLE_GRAY, 1., MarkerScale::Gamma).between_neighbors(), + SpectrumSectionParam::new(highlights, Color::WHITE, 100., MarkerScale::Percent), ]; let output_range_params = [ - SpectrumSectionParam::new(OutputMinimumsInput, Color::BLACK, 0., MarkerScale::Percent), - SpectrumSectionParam::new(OutputMaximumsInput, Color::WHITE, 100., MarkerScale::Percent), + SpectrumSectionParam::new(output_minimums, Color::BLACK, 0., MarkerScale::Percent), + SpectrumSectionParam::new(output_maximums, Color::WHITE, 100., MarkerScale::Percent), ]; - let mut layout = Vec::with_capacity(5); + let mut layout = vec![channel]; build_shared_spectrum_section(node_id, context, &bw_track(), &input_range_params, &mut layout); build_shared_spectrum_section(node_id, context, &bw_track(), &output_range_params, &mut layout); layout @@ -1459,6 +1505,8 @@ struct SpectrumSectionParam { scale: MarkerScale, /// Whether a dashed line joins the marker to the next parameter's marker. dash_to_next: bool, + /// Whether the marker takes its scale position within the span between its neighbors rather than the whole track, following them as they move. + between_neighbors: bool, } impl SpectrumSectionParam { @@ -1469,9 +1517,15 @@ impl SpectrumSectionParam { default_value, scale, dash_to_next: false, + between_neighbors: false, } } + fn between_neighbors(mut self) -> Self { + self.between_neighbors = true; + self + } + fn dash_to_next(mut self) -> Self { self.dash_to_next = true; self @@ -1507,6 +1561,7 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo let mut marker_default_positions = Vec::new(); let mut marker_scales = Vec::new(); let mut marker_positions = Vec::new(); + let mut marker_between = Vec::new(); let mut marker_colors_and_links = Vec::new(); for (i, param) in params.iter().enumerate() { let (exposed, value) = exposure_and_value[i]; @@ -1518,20 +1573,41 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo marker_input_indices.push(param.parameter.input_index); marker_default_positions.push(param.scale.position(param.default_value)); marker_scales.push(param.scale); + marker_between.push(param.between_neighbors); marker_colors_and_links.push((param.handle_color, param.dash_to_next && next_has_marker)); } - // Enforce non-decreasing order so markers never visually cross, matching the node's algorithm where shadows takes precedence - for i in 1..marker_positions.len() { - marker_positions[i] = marker_positions[i].max(marker_positions[i - 1]); + // Enforce non-decreasing order so markers never visually cross, matching the node's algorithm where shadows takes precedence. + // A marker placed between its neighbors bounds nothing here and instead takes its scale position within their settled span. + let mut floor = 0.; + for (position, &between) in marker_positions.iter_mut().zip(&marker_between) { + if between { + continue; + } + *position = position.max(floor); + floor = *position; + } + for i in 0..marker_positions.len() { + if marker_between[i] { + let left = if i == 0 { 0. } else { marker_positions[i - 1] }; + let right = marker_positions.get(i + 1).copied().unwrap_or(1.); + marker_positions[i] = left + marker_positions[i] * (right - left); + } } let spectrum_markers: Vec = marker_positions .iter() .zip(&marker_colors_and_links) - .map(|(&position, &(handle_color, dashed))| { - let marker = SpectrumMarker::new(position, 0.5, handle_color); - if dashed { marker.dash_to_next() } else { marker } + .zip(&marker_between) + .map(|((&position, &(handle_color, dashed)), &between)| { + let mut marker = SpectrumMarker::new(position, 0.5, handle_color); + if dashed { + marker = marker.dash_to_next(); + } + if between { + marker = marker.between_neighbors(); + } + marker }) .collect(); @@ -1550,21 +1626,35 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo let marker_default_positions = marker_default_positions.clone(); let marker_scales = marker_scales.clone(); let marker_positions = marker_positions.clone(); + let marker_between = marker_between.clone(); move |update: &SpectrumInputUpdate| { let i = match update { SpectrumInputUpdate::MoveMarker { index, .. } | SpectrumInputUpdate::ResetMarker { index } => *index as usize, _ => return Message::NoOp, }; - let (Some(&input_index), Some(&scale), Some(&default_position)) = (marker_input_indices.get(i), marker_scales.get(i), marker_default_positions.get(i)) else { + let (Some(&input_index), Some(&scale), Some(&between), Some(&default_position)) = + (marker_input_indices.get(i), marker_scales.get(i), marker_between.get(i), marker_default_positions.get(i)) + else { return Message::NoOp; }; - let left = if i == 0 { 0. } else { marker_positions[i - 1] }; - let right = marker_positions.get(i + 1).copied().unwrap_or(1.); + + // The span the marker's scale maps onto: its neighbors' positions when placed between them, otherwise the track between the + // nearest markers that bound it, which a marker placed between its neighbors never does + let bounding = |j: usize| between || !marker_between[j]; + let left = (0..i).rev().find(|&j| bounding(j)).map_or(0., |j| marker_positions[j]); + let right = (i + 1..marker_positions.len()).find(|&j| bounding(j)).map_or(1., |j| marker_positions[j]); let scale_position = match update { + SpectrumInputUpdate::MoveMarker { position, .. } if between => { + let span = right - left; + if span <= f64::EPSILON { + return Message::NoOp; + } + ((position - left) / span).clamp(0., 1.) + } SpectrumInputUpdate::MoveMarker { position, .. } => *position, // A default that would cross a neighbor falls back to the midpoint between them - SpectrumInputUpdate::ResetMarker { .. } if (left..=right).contains(&default_position) => default_position, + SpectrumInputUpdate::ResetMarker { .. } if between || (left..=right).contains(&default_position) => default_position, SpectrumInputUpdate::ResetMarker { .. } => (left + right) / 2., _ => return Message::NoOp, }; diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 00975c54d6e..a1373975d48 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -2196,6 +2196,31 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], inputs_count = 3; } + // Levels' Midtones became the gamma value it encoded, and each channel gained its own record after the composite one + if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster::levels::IDENTIFIER) && inputs_count == 6 { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + document.network_interface.replace_implementation(node_id, network_path, &mut node_template); + let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + let output_level = |index: usize, default: f32| match old_inputs.get(index).and_then(|input| input.as_value()) { + Some(TaggedValue::F32(percent)) => percent / 100., + _ => default, + }; + let (output_minimums, output_maximums) = (output_level(4, 0.), output_level(5, 1.)); + for (index, input) in old_inputs.iter().take(6).enumerate() { + let input = match (index, input.as_value()) { + (2, Some(TaggedValue::F32(percent))) => { + // The old node's midtones-to-gamma mapping, from https://stackoverflow.com/questions/39510072/algorithm-for-adjustment-of-image-levels + let midtones = output_minimums + (output_maximums - output_minimums) * percent / 100.; + let gamma = if midtones < 0.5 { 1. + 9. * (1. - midtones * 2.) } else { ((1. - midtones) * 2.).max(0.01) }; + NodeInput::value(TaggedValue::F32(gamma.clamp(0.01, 9.99)), input.is_exposed()) + } + _ => input.clone(), + }; + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input, network_path); + } + inputs_count = 27; + } + if reference == DefinitionIdentifier::ProtoNode(graphene_std::repeat::repeat_on_points::IDENTIFIER) && inputs_count == 2 { let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); document.network_interface.replace_implementation(node_id, network_path, &mut node_template); diff --git a/frontend/src/components/widgets/inputs/SpectrumInput.svelte b/frontend/src/components/widgets/inputs/SpectrumInput.svelte index 43aa6007dcd..122d4ef9d8f 100644 --- a/frontend/src/components/widgets/inputs/SpectrumInput.svelte +++ b/frontend/src/components/widgets/inputs/SpectrumInput.svelte @@ -98,11 +98,19 @@ function holdBetweenNeighbors(first: number, last: number, spacing: number, position: number): number { // Without selection nothing reports the dragged marker's new index after a reorder, so it stays between its neighbors if (allowReorder && allowSelect) return position; - const lower = markers[first - 1]?.position ?? 0; - const upper = (markers[last + 1]?.position ?? 1) - spacing; + const lower = neighborBound(first, -1) ?? 0; + const upper = (neighborBound(last, 1) ?? 1) - spacing; return Math.max(lower, Math.min(upper, position)); } + // The position of the nearest marker past `index` in the direction of `step` that bounds others, skipping any placed between its neighbors since those follow them instead + function neighborBound(index: number, step: -1 | 1): number | undefined { + for (let i = index + step; i >= 0 && i < markers.length; i += step) { + if (!markers[i].betweenNeighbors) return markers[i].position; + } + return undefined; + } + // The spans from each marker passing `linked` to its successor function markerSpans(markers: SpectrumMarker[], linked: (marker: SpectrumMarker) => boolean): { index: number; left: number; width: number }[] { const spans: { index: number; left: number; width: number }[] = []; diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index 6f7d2893987..7fe8a69d752 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -328,88 +328,222 @@ pub enum AdjustmentChannel { Alpha, } +/// One Levels record in the node's units: percentage input and output points and the gamma value. +#[derive(Clone, Copy)] +struct LevelsRecord { + shadows: f32, + midtones: f32, + highlights: f32, + output_minimums: f32, + output_maximums: f32, +} + +/// A record's input curve followed by its output range. +#[derive(Clone, Copy)] +struct LevelsStage { + curve: LevelsCurve, + output_minimum: f32, + output_maximum: f32, +} + +impl LevelsRecord { + fn new(shadows: f32, midtones: f32, highlights: f32, output_minimums: f32, output_maximums: f32) -> Self { + Self { + shadows, + midtones, + highlights, + output_minimums, + output_maximums, + } + } + + fn stage(&self, gamma: f32) -> LevelsStage { + LevelsStage { + curve: LevelsCurve::from_points(self.shadows * 2.55, self.highlights * 2.55, gamma), + output_minimum: self.output_minimums / 100., + output_maximum: self.output_maximums / 100., + } + } +} + +impl LevelsStage { + fn apply(&self, value: f32) -> f32 { + self.curve.apply(value) * (self.output_maximum - self.output_minimum) + self.output_minimum + } +} + +/// A channel's record followed by the composite record. +#[derive(Clone, Copy)] +struct LevelsChain { + first: LevelsStage, + second: LevelsStage, + two_stages: bool, +} + +impl LevelsChain { + fn new(channel: LevelsRecord, composite: LevelsRecord) -> Self { + // For PSD interop, two power functions with nothing between them (the composite's input points and the + // channel's output range at their defaults) merge into one curve with the product of the gammas, toe included + let nothing_between = composite.shadows == 0. && composite.highlights == 100. && channel.output_minimums == 0. && channel.output_maximums == 100.; + if nothing_between { + let merged = LevelsRecord { + output_minimums: composite.output_minimums, + output_maximums: composite.output_maximums, + ..channel + }; + let stage = merged.stage(channel.midtones * composite.midtones); + Self { + first: stage, + second: stage, + two_stages: false, + } + } else { + Self { + first: channel.stage(channel.midtones), + second: composite.stage(composite.midtones), + two_stages: true, + } + } + } + + fn apply(&self, value: f32) -> f32 { + let value = self.first.apply(value); + if self.two_stages { self.second.apply(value) } else { value } + } +} + // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=levl%27%20%3D%20Levels // -// Algorithm from: -// https://stackoverflow.com/questions/39510072/algorithm-for-adjustment-of-image-levels -// // Some further analysis available at: // https://geraldbakker.nl/psnumbers/levels.html #[node_macro::node(category("Raster: Adjustment"), properties("levels_properties"), shader_node(PerPixelAdjust))] fn levels>( _: impl Ctx, - #[implementations( - Raster, - Color, - Gradient, - )] + #[implementations(Raster, Color, Gradient)] #[gpu_image] image: Item, #[default(0.)] shadows: Item, - #[default(50.)] midtones: Item, + #[default(1.)] midtones: Item, #[default(100.)] highlights: Item, #[default(0.)] output_minimums: Item, #[default(100.)] output_maximums: Item, + #[name("(Red) Shadows")] + #[default(0.)] + red_shadows: Item, + #[name("(Red) Midtones")] + #[default(1.)] + red_midtones: Item, + #[name("(Red) Highlights")] + #[default(100.)] + red_highlights: Item, + #[name("(Red) Output Minimums")] + #[default(0.)] + red_output_minimums: Item, + #[name("(Red) Output Maximums")] + #[default(100.)] + red_output_maximums: Item, + #[name("(Green) Shadows")] + #[default(0.)] + green_shadows: Item, + #[name("(Green) Midtones")] + #[default(1.)] + green_midtones: Item, + #[name("(Green) Highlights")] + #[default(100.)] + green_highlights: Item, + #[name("(Green) Output Minimums")] + #[default(0.)] + green_output_minimums: Item, + #[name("(Green) Output Maximums")] + #[default(100.)] + green_output_maximums: Item, + #[name("(Blue) Shadows")] + #[default(0.)] + blue_shadows: Item, + #[name("(Blue) Midtones")] + #[default(1.)] + blue_midtones: Item, + #[name("(Blue) Highlights")] + #[default(100.)] + blue_highlights: Item, + #[name("(Blue) Output Minimums")] + #[default(0.)] + blue_output_minimums: Item, + #[name("(Blue) Output Maximums")] + #[default(100.)] + blue_output_maximums: Item, + #[name("(Alpha) Shadows")] + #[default(0.)] + alpha_shadows: Item, + #[name("(Alpha) Midtones")] + #[default(1.)] + alpha_midtones: Item, + #[name("(Alpha) Highlights")] + #[default(100.)] + alpha_highlights: Item, + #[name("(Alpha) Output Minimums")] + #[default(0.)] + alpha_output_minimums: Item, + #[name("(Alpha) Output Maximums")] + #[default(100.)] + alpha_output_maximums: Item, + _channel: Item, ) -> Item { let mut image = image; - let shadows = shadows.into_element(); - let midtones = midtones.into_element(); - let highlights = highlights.into_element(); - let output_minimums = output_minimums.into_element(); - let output_maximums = output_maximums.into_element(); + let composite = LevelsRecord::new( + shadows.into_element(), + midtones.into_element(), + highlights.into_element(), + output_minimums.into_element(), + output_maximums.into_element(), + ); + let red = LevelsChain::new( + LevelsRecord::new( + red_shadows.into_element(), + red_midtones.into_element(), + red_highlights.into_element(), + red_output_minimums.into_element(), + red_output_maximums.into_element(), + ), + composite, + ); + let green = LevelsChain::new( + LevelsRecord::new( + green_shadows.into_element(), + green_midtones.into_element(), + green_highlights.into_element(), + green_output_minimums.into_element(), + green_output_maximums.into_element(), + ), + composite, + ); + let blue = LevelsChain::new( + LevelsRecord::new( + blue_shadows.into_element(), + blue_midtones.into_element(), + blue_highlights.into_element(), + blue_output_minimums.into_element(), + blue_output_maximums.into_element(), + ), + composite, + ); + + // Alpha stands apart from the composite record that the three color channels pass through + let alpha = LevelsRecord::new( + alpha_shadows.into_element(), + alpha_midtones.into_element(), + alpha_highlights.into_element(), + alpha_output_minimums.into_element(), + alpha_output_maximums.into_element(), + ); + let alpha = alpha.stage(alpha.midtones); image.element_mut().adjust(|color| { // Levels math operates in gamma space - let [mut r, mut g, mut b, a] = color.to_gamma_srgb_channels(); - - // Input Range (Range: 0-1) - let input_shadows = shadows / 100.; - let input_midtones = midtones / 100.; - let input_highlights = highlights / 100.; - - // Output Range (Range: 0-1) - let output_minimums = output_minimums / 100.; - let output_maximums = output_maximums / 100.; - - // Midtones interpolation factor between minimums and maximums (Range: 0-1) - let midtones = output_minimums + (output_maximums - output_minimums) * input_midtones; - - // Gamma correction (Range: 0.01-10) - let gamma = if midtones < 0.5 { - // Range: 0-1 - let x = 1. - midtones * 2.; - // Range: 1-10 - 1. + 9. * x - } else { - // Range: 0-0.5 - let x = 1. - midtones; - // Range: 0-1 - let x = x * 2.; - // Range: 0.01-1 - x.max(0.01) - }; + let [r, g, b, a] = color.to_gamma_srgb_channels(); - // Input levels (Range: 0-1) - let highlights_minus_shadows = (input_highlights - input_shadows).clamp(f32::EPSILON, 1.); - let input_map = |c: f32| ((c - input_shadows).max(0.) / highlights_minus_shadows).min(1.); - r = input_map(r); - g = input_map(g); - b = input_map(b); - - // Midtones gamma curve (Range: 0-1) - let inverse_gamma = 1. / gamma.max(0.0001); - r = r.powf(inverse_gamma); - g = g.powf(inverse_gamma); - b = b.powf(inverse_gamma); - - // Output levels (Range: 0-1) - let output_map = |c: f32| c * (output_maximums - output_minimums) + output_minimums; - r = output_map(r); - g = output_map(g); - b = output_map(b); - - Color::from_gamma_srgb_channels(r, g, b, a) + Color::from_gamma_srgb_channels(red.apply(r), green.apply(g), blue.apply(b), alpha.apply(a)) }); image } @@ -1469,6 +1603,70 @@ mod tests { } } + /// Runs Levels with composite and red records given as [black, white, gamma, output black, output white] with 0..255 points + /// on one gamma-space gray value (0..255), returning the red and green results on the same scale. + fn run_levels(value: f32, composite: [f32; 5], red: [f32; 5]) -> [f32; 2] { + let pixel = Color::from_gamma_srgb_channels(value / 255., value / 255., value / 255., 1.); + let percent = |level: f32| level / 2.55; + let result = levels( + (), + Item::new_from_element(pixel), + percent(composite[0]).into(), + composite[2].into(), + percent(composite[1]).into(), + percent(composite[3]).into(), + percent(composite[4]).into(), + percent(red[0]).into(), + red[2].into(), + percent(red[1]).into(), + percent(red[3]).into(), + percent(red[4]).into(), + 0_f32.into(), + 1_f32.into(), + 100_f32.into(), + 0_f32.into(), + 100_f32.into(), + 0_f32.into(), + 1_f32.into(), + 100_f32.into(), + 0_f32.into(), + 100_f32.into(), + 0_f32.into(), + 1_f32.into(), + 100_f32.into(), + 0_f32.into(), + 100_f32.into(), + AdjustmentChannel::Rgb.into(), + ); + let [r, g, _, _] = result.into_element().to_gamma_srgb_channels(); + [r * 255., g * 255.] + } + + #[test] + fn levels_records_merge_into_one_gamma_only_when_nothing_lies_between() { + const DEFAULT: [f32; 5] = [0., 255., 1., 0., 255.]; + for (value, composite, red, expected_red, expected_green) in [ + // Two gammas with nothing between them act as one gamma of 2.25, toe included + (5., [0., 255., 1.5, 0., 255.], [0., 255., 1.5, 0., 255.], 23., 14.), + (25., [0., 255., 1.5, 0., 255.], [0., 255., 1.5, 0., 255.], 89., 54.), + (100., [0., 255., 1.5, 0., 255.], [0., 255., 1.5, 0., 255.], 168., 137.), + // A black point in each record keeps them as two curves + (40., [30., 255., 1.5, 0., 255.], [20., 255., 1.5, 0., 255.], 49., 28.), + (100., [30., 255., 1.5, 0., 255.], [20., 255., 1.5, 0., 255.], 144., 117.), + // Input and output points only + (100., [30., 220., 1., 0., 255.], [50., 255., 1., 0., 200.], 26., 94.), + (150., [30., 220., 1., 0., 255.], [50., 255., 1., 0., 200.], 91., 161.), + // A pure channel gamma under a composite with points stays a separate stage + (5., [0., 200., 1.2, 10., 255.], [0., 255., 3., 0., 255.], 70., 21.), + (50., [0., 200., 1.2, 10., 255.], [0., 255., 3., 0., 255.], 201., 88.), + (128., DEFAULT, DEFAULT, 128., 128.), + ] { + let [red_actual, green_actual] = run_levels(value, composite, red); + assert!((red_actual - expected_red).abs() <= 1.5, "{value} red: expected {expected_red}, got {red_actual}"); + assert!((green_actual - expected_green).abs() <= 1.5, "{value} green: expected {expected_green}, got {green_actual}"); + } + } + #[test] fn invert_flips_straight_channels_and_keeps_alpha() { let color = Color::from_gamma_srgb_channels(1., 0.25, 0., 0.5); From 7ae335301e5fb3f1e992f1f66f7ad57d7287fcd7 Mon Sep 17 00:00:00 2001 From: Timon Date: Tue, 15 Sep 2026 10:36:17 +0000 Subject: [PATCH 34/46] Use cargo workspace to remove duplicated version, license, and author fields (#4226) Use cargo workspace to remove dublicated version, license and author fields --- Cargo.lock | 64 +++++++++---------- desktop/Cargo.toml | 12 ++-- desktop/bundle/Cargo.toml | 12 ++-- desktop/embedded-resources/Cargo.toml | 12 ++-- desktop/platform/linux/Cargo.toml | 12 ++-- desktop/platform/mac/Cargo.toml | 12 ++-- desktop/platform/win/Cargo.toml | 12 ++-- desktop/ui/Cargo.toml | 4 +- desktop/wrapper/Cargo.toml | 12 ++-- document/container/Cargo.toml | 4 +- document/format/Cargo.toml | 4 +- document/graph-storage/Cargo.toml | 4 +- editor/Cargo.toml | 15 ++--- frontend/wrapper/Cargo.toml | 15 ++--- libraries/dyn-any/Cargo.toml | 15 ++--- libraries/dyn-any/derive/Cargo.toml | 10 +-- libraries/math-parser/Cargo.toml | 11 ++-- libraries/wgpu-sync/Cargo.toml | 6 +- node-graph/graph-craft/Cargo.toml | 8 ++- node-graph/graphene-cli/Cargo.toml | 10 +-- node-graph/interpreted-executor/Cargo.toml | 8 ++- .../libraries/application-io/Cargo.toml | 10 +-- node-graph/libraries/brush-types/Cargo.toml | 10 +-- node-graph/libraries/canvas-utils/Cargo.toml | 10 +-- node-graph/libraries/core-types/Cargo.toml | 10 +-- node-graph/libraries/graphene-hash/Cargo.toml | 11 ++-- .../libraries/graphene-hash/derive/Cargo.toml | 11 ++-- node-graph/libraries/graphic-types/Cargo.toml | 10 +-- node-graph/libraries/no-std-types/Cargo.toml | 10 +-- node-graph/libraries/raster-types/Cargo.toml | 10 +-- node-graph/libraries/rendering/Cargo.toml | 10 +-- node-graph/libraries/resources/Cargo.toml | 10 +-- node-graph/libraries/vector-types/Cargo.toml | 10 +-- node-graph/libraries/wgpu-executor/Cargo.toml | 8 ++- node-graph/node-macro/Cargo.toml | 15 ++--- node-graph/nodes/blending/Cargo.toml | 10 +-- node-graph/nodes/brush/Cargo.toml | 10 +-- node-graph/nodes/gcore/Cargo.toml | 10 +-- node-graph/nodes/graphic/Cargo.toml | 8 ++- node-graph/nodes/gstd/Cargo.toml | 10 +-- node-graph/nodes/math/Cargo.toml | 10 +-- node-graph/nodes/path-bool/Cargo.toml | 10 +-- node-graph/nodes/raster/Cargo.toml | 10 +-- node-graph/nodes/raster/shaders/Cargo.toml | 10 +-- .../raster/shaders/entrypoint/Cargo.toml | 10 +-- node-graph/nodes/repeat/Cargo.toml | 10 +-- node-graph/nodes/text/Cargo.toml | 10 +-- node-graph/nodes/transform/Cargo.toml | 10 +-- node-graph/nodes/vector/Cargo.toml | 10 +-- node-graph/preprocessor/Cargo.toml | 8 ++- proc-macros/Cargo.toml | 15 ++--- tools/cargo-run/Cargo.toml | 5 +- tools/cargo-run/internal/download/Cargo.toml | 4 +- tools/cargo-run/internal/watch/Cargo.toml | 4 +- tools/crate-hierarchy-viz/Cargo.toml | 4 +- tools/editor-message-tree/Cargo.toml | 4 +- tools/node-docs/Cargo.toml | 4 +- tools/third-party-licenses/Cargo.toml | 4 +- 58 files changed, 334 insertions(+), 263 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e36ea02788d..230a0d9ecb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -320,7 +320,7 @@ dependencies = [ [[package]] name = "blending-nodes" -version = "0.1.0" +version = "0.0.0" dependencies = [ "core-types", "glam", @@ -368,7 +368,7 @@ dependencies = [ [[package]] name = "brush-nodes" -version = "0.1.0" +version = "0.0.0" dependencies = [ "brush-types", "bytemuck", @@ -388,7 +388,7 @@ dependencies = [ [[package]] name = "brush-types" -version = "0.1.0" +version = "0.0.0" dependencies = [ "core-types", "dyn-any", @@ -950,7 +950,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "core-types" -version = "0.1.0" +version = "0.0.0" dependencies = [ "base64", "bitflags 2.11.0", @@ -2043,7 +2043,7 @@ dependencies = [ [[package]] name = "graph-craft" -version = "0.1.0" +version = "0.0.0" dependencies = [ "brush-nodes", "core-types", @@ -2080,7 +2080,7 @@ dependencies = [ [[package]] name = "graphene-application-io" -version = "0.1.0" +version = "0.0.0" dependencies = [ "blake3", "core-types", @@ -2098,7 +2098,7 @@ dependencies = [ [[package]] name = "graphene-canvas-utils" -version = "0.1.0" +version = "0.0.0" dependencies = [ "core-types", "dyn-any", @@ -2115,7 +2115,7 @@ dependencies = [ [[package]] name = "graphene-cli" -version = "0.1.0" +version = "0.0.0" dependencies = [ "chrono", "clap", @@ -2136,7 +2136,7 @@ dependencies = [ [[package]] name = "graphene-core" -version = "0.1.0" +version = "0.0.0" dependencies = [ "core-types", "dyn-any", @@ -2171,7 +2171,7 @@ dependencies = [ [[package]] name = "graphene-resource" -version = "0.1.0" +version = "0.0.0" dependencies = [ "blake3", "core-types", @@ -2182,7 +2182,7 @@ dependencies = [ [[package]] name = "graphene-std" -version = "0.1.0" +version = "0.0.0" dependencies = [ "base64", "blending-nodes", @@ -2222,7 +2222,7 @@ dependencies = [ [[package]] name = "graphic-nodes" -version = "0.1.0" +version = "0.0.0" dependencies = [ "brush-types", "core-types", @@ -2238,7 +2238,7 @@ dependencies = [ [[package]] name = "graphic-types" -version = "0.1.0" +version = "0.0.0" dependencies = [ "brush-types", "core-types", @@ -2255,7 +2255,7 @@ dependencies = [ [[package]] name = "graphite-desktop" -version = "0.1.0" +version = "0.0.0" dependencies = [ "bytemuck", "clap", @@ -2300,7 +2300,7 @@ dependencies = [ [[package]] name = "graphite-desktop-embedded-resources" -version = "0.1.0" +version = "0.0.0" dependencies = [ "include_dir", ] @@ -2357,7 +2357,7 @@ dependencies = [ [[package]] name = "graphite-desktop-wrapper" -version = "0.1.0" +version = "0.0.0" dependencies = [ "base64", "dirs", @@ -2980,7 +2980,7 @@ dependencies = [ [[package]] name = "interpreted-executor" -version = "0.1.0" +version = "0.0.0" dependencies = [ "core-types", "criterion", @@ -3405,7 +3405,7 @@ dependencies = [ [[package]] name = "math-nodes" -version = "0.1.0" +version = "0.0.0" dependencies = [ "core-types", "glam", @@ -3614,7 +3614,7 @@ dependencies = [ [[package]] name = "no-std-types" -version = "0.1.0" +version = "0.0.0" dependencies = [ "bytemuck", "core-types", @@ -4151,7 +4151,7 @@ dependencies = [ [[package]] name = "path-bool-nodes" -version = "0.1.0" +version = "0.0.0" dependencies = [ "core-types", "glam", @@ -4462,7 +4462,7 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] name = "preprocessor" -version = "0.1.0" +version = "0.0.0" dependencies = [ "graph-craft", "graphene-std", @@ -4670,7 +4670,7 @@ checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" [[package]] name = "raster-nodes" -version = "0.1.0" +version = "0.0.0" dependencies = [ "bytemuck", "core-types", @@ -4702,7 +4702,7 @@ dependencies = [ [[package]] name = "raster-nodes-shaders" -version = "0.1.0" +version = "0.0.0" dependencies = [ "cargo-gpu-install", "env_logger", @@ -4711,14 +4711,14 @@ dependencies = [ [[package]] name = "raster-nodes-shaders-entrypoint" -version = "0.1.0" +version = "0.0.0" dependencies = [ "raster-nodes", ] [[package]] name = "raster-types" -version = "0.1.0" +version = "0.0.0" dependencies = [ "base64", "bytemuck", @@ -4887,7 +4887,7 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "rendering" -version = "0.1.0" +version = "0.0.0" dependencies = [ "base64", "brush-types", @@ -4912,7 +4912,7 @@ dependencies = [ [[package]] name = "repeat-nodes" -version = "0.1.0" +version = "0.0.0" dependencies = [ "core-types", "dyn-any", @@ -5962,7 +5962,7 @@ dependencies = [ [[package]] name = "text-nodes" -version = "0.1.0" +version = "0.0.0" dependencies = [ "convert_case", "core-types", @@ -6369,7 +6369,7 @@ dependencies = [ [[package]] name = "transform-nodes" -version = "0.1.0" +version = "0.0.0" dependencies = [ "core-types", "glam", @@ -6620,7 +6620,7 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "vector-nodes" -version = "0.1.0" +version = "0.0.0" dependencies = [ "core-types", "delaunator", @@ -6646,7 +6646,7 @@ dependencies = [ [[package]] name = "vector-types" -version = "0.1.0" +version = "0.0.0" dependencies = [ "bitflags 2.11.0", "bytemuck", @@ -7094,7 +7094,7 @@ dependencies = [ [[package]] name = "wgpu-executor" -version = "0.1.0" +version = "0.0.0" dependencies = [ "anyhow", "bytemuck", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 2acd03a872f..c3632a8cd88 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "graphite-desktop" -version = "0.1.0" description = "Graphite Desktop" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" -repository = "" -edition = "2024" -rust-version = "1.87" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [[bin]] name = "graphite" diff --git a/desktop/bundle/Cargo.toml b/desktop/bundle/Cargo.toml index 4521ab79ebf..330e5b5ba11 100644 --- a/desktop/bundle/Cargo.toml +++ b/desktop/bundle/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "graphite-desktop-bundle" -version = "0.0.0" description = "Graphite Desktop Bundle" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" -repository = "" -edition = "2024" -rust-version = "1.87" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [dependencies] cef-dll-sys = { workspace = true } diff --git a/desktop/embedded-resources/Cargo.toml b/desktop/embedded-resources/Cargo.toml index a8ea69c1fe0..81ae45577cd 100644 --- a/desktop/embedded-resources/Cargo.toml +++ b/desktop/embedded-resources/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "graphite-desktop-embedded-resources" -version = "0.1.0" description = "Graphite Desktop Embedded Resources" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" -repository = "" -edition = "2024" -rust-version = "1.87" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [dependencies] include_dir = { workspace = true } diff --git a/desktop/platform/linux/Cargo.toml b/desktop/platform/linux/Cargo.toml index 6f146156f4f..5f6e8ea2069 100644 --- a/desktop/platform/linux/Cargo.toml +++ b/desktop/platform/linux/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "graphite-desktop-platform-linux" -version = "0.0.0" description = "Graphite Desktop Platform Linux" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" -repository = "" -edition = "2024" -rust-version = "1.87" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [[bin]] name = "graphite" diff --git a/desktop/platform/mac/Cargo.toml b/desktop/platform/mac/Cargo.toml index e666770f3ed..6f19767d5e7 100644 --- a/desktop/platform/mac/Cargo.toml +++ b/desktop/platform/mac/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "graphite-desktop-platform-mac" -version = "0.0.0" description = "Graphite Desktop Platform Mac" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" -repository = "" -edition = "2024" -rust-version = "1.87" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] main = ["dep:graphite-desktop"] diff --git a/desktop/platform/win/Cargo.toml b/desktop/platform/win/Cargo.toml index d5cd2db1fae..cbe7cc3a41c 100644 --- a/desktop/platform/win/Cargo.toml +++ b/desktop/platform/win/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "graphite-desktop-platform-win" -version = "0.0.0" description = "Graphite Desktop Platform Windows" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" -repository = "" -edition = "2024" -rust-version = "1.87" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [[bin]] name = "graphite" diff --git a/desktop/ui/Cargo.toml b/desktop/ui/Cargo.toml index ceeb41e7a55..0aa95f4aa28 100644 --- a/desktop/ui/Cargo.toml +++ b/desktop/ui/Cargo.toml @@ -2,9 +2,11 @@ name = "graphite-desktop-ui" description = "Renders the Graphite editor frontend UI into wgpu textures" version.workspace = true -authors.workspace = true license.workspace = true +authors.workspace = true edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = [] diff --git a/desktop/wrapper/Cargo.toml b/desktop/wrapper/Cargo.toml index cae0417273a..b8c9d7dcc88 100644 --- a/desktop/wrapper/Cargo.toml +++ b/desktop/wrapper/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "graphite-desktop-wrapper" -version = "0.1.0" description = "Graphite Desktop Wrapper" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" -repository = "" -edition = "2024" -rust-version = "1.87" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] gpu = ["graphite-editor/gpu", "graphene-std/shader-nodes"] diff --git a/document/container/Cargo.toml b/document/container/Cargo.toml index 573a3e1f511..79080e4d3e4 100644 --- a/document/container/Cargo.toml +++ b/document/container/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "document-container" description = "Container abstraction for the on-disk side of the .gdd document format" -edition.workspace = true version.workspace = true license.workspace = true authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = [] diff --git a/document/format/Cargo.toml b/document/format/Cargo.toml index 196d6422dc7..94b049511b2 100644 --- a/document/format/Cargo.toml +++ b/document/format/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "document-format" description = "Typed handle for the .gdd document format, sitting over document-graph-storage and document-container" -edition.workspace = true version.workspace = true license.workspace = true authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] # Runtime bridge: the editor <-> storage conversion methods (stage/commit from a `NodeNetwork`, diff --git a/document/graph-storage/Cargo.toml b/document/graph-storage/Cargo.toml index a5edaf4bfdd..f7af97d59bd 100644 --- a/document/graph-storage/Cargo.toml +++ b/document/graph-storage/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "document-graph-storage" description = "Provides a delta based graph representation used in the Graphite file format" -edition.workspace = true version.workspace = true license.workspace = true authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] conversion = ["dep:graph-craft", "dep:core-types"] diff --git a/editor/Cargo.toml b/editor/Cargo.toml index 874a4487d3a..5c7256571b1 100644 --- a/editor/Cargo.toml +++ b/editor/Cargo.toml @@ -1,14 +1,11 @@ [package] name = "graphite-editor" -publish = false -version = "0.0.0" -rust-version = "1.88" -authors = ["Graphite Authors "] -edition = "2024" -readme = "../README.md" -homepage = "https://graphite.art" -repository = "https://github.com/GraphiteEditor/Graphite" -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["gpu"] diff --git a/frontend/wrapper/Cargo.toml b/frontend/wrapper/Cargo.toml index c7785ad88b1..dbdc8627141 100644 --- a/frontend/wrapper/Cargo.toml +++ b/frontend/wrapper/Cargo.toml @@ -1,14 +1,11 @@ [package] name = "graphite-wasm-wrapper" -publish = false -version = "0.0.0" -rust-version = "1.88" -authors = ["Graphite Authors "] -edition = "2024" -readme = "../../README.md" -homepage = "https://graphite.art" -repository = "https://github.com/GraphiteEditor/Graphite" -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["gpu", "shader-nodes", "web"] diff --git a/libraries/dyn-any/Cargo.toml b/libraries/dyn-any/Cargo.toml index f5deb6f10cd..807acd99488 100644 --- a/libraries/dyn-any/Cargo.toml +++ b/libraries/dyn-any/Cargo.toml @@ -1,15 +1,14 @@ [package] name = "dyn-any" -version = "0.3.1" -rust-version = "1.85" -edition = "2024" -authors = ["Graphite Authors "] description = "An Any trait that works for arbitrary lifetimes" -license = "MIT OR Apache-2.0" -readme = "./README.md" -homepage = "https://crates.io/crates/dyn-any" -repository = "https://github.com/GraphiteEditor/Graphite/tree/master/libraries/dyn-any" +version = "0.3.1" +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true documentation = "https://docs.rs/dyn-any" +repository = "https://github.com/GraphiteEditor/Graphite/tree/master/libraries/dyn-any" +readme = "./README.md" [features] default = ["std", "large-atomics"] diff --git a/libraries/dyn-any/derive/Cargo.toml b/libraries/dyn-any/derive/Cargo.toml index 753cd78ef7d..9c7fd61da4b 100644 --- a/libraries/dyn-any/derive/Cargo.toml +++ b/libraries/dyn-any/derive/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "dyn-any-derive" -version = "0.3.0" -edition = "2024" -authors = ["Graphite Authors "] - description = "#[derive(DynAny)]" +version = "0.3.0" +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true documentation = "https://docs.rs/dyn-any-derive" repository = "https://github.com/GraphiteEditor/Graphite/tree/master/libraries/dyn-any/derive" -license = "MIT OR Apache-2.0" readme = "../README.md" [lib] diff --git a/libraries/math-parser/Cargo.toml b/libraries/math-parser/Cargo.toml index acdfa7e3dcf..fda6166caa0 100644 --- a/libraries/math-parser/Cargo.toml +++ b/libraries/math-parser/Cargo.toml @@ -1,11 +1,12 @@ [package] name = "math-parser" -version = "0.0.0" -rust-version = "1.85" -edition = "2024" -authors = ["Graphite Authors "] description = "Parser for Graphite style mathematics expressions" -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [dependencies] thiserror = "2.0" diff --git a/libraries/wgpu-sync/Cargo.toml b/libraries/wgpu-sync/Cargo.toml index d8ecfd0352f..84fc9d35771 100644 --- a/libraries/wgpu-sync/Cargo.toml +++ b/libraries/wgpu-sync/Cargo.toml @@ -2,9 +2,11 @@ name = "wgpu-sync" description = "Helper for working with wgpu in a multi-threaded context" version.workspace = true -edition.workspace = true -authors.workspace = true license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [dependencies] wgpu = { workspace = true } diff --git a/node-graph/graph-craft/Cargo.toml b/node-graph/graph-craft/Cargo.toml index 688a0ea712e..739b5be2b7f 100644 --- a/node-graph/graph-craft/Cargo.toml +++ b/node-graph/graph-craft/Cargo.toml @@ -1,9 +1,11 @@ [package] name = "graph-craft" -version = "0.1.0" -edition = "2024" -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["dealloc_nodes", "wgpu", "loading"] diff --git a/node-graph/graphene-cli/Cargo.toml b/node-graph/graphene-cli/Cargo.toml index 5a7b82b9c3d..517d8ea1dff 100644 --- a/node-graph/graphene-cli/Cargo.toml +++ b/node-graph/graphene-cli/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "graphene-cli" -version = "0.1.0" -edition = "2024" description = "CLI interface for the graphene language" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["wgpu"] diff --git a/node-graph/interpreted-executor/Cargo.toml b/node-graph/interpreted-executor/Cargo.toml index 271408240c3..f08eec854b5 100644 --- a/node-graph/interpreted-executor/Cargo.toml +++ b/node-graph/interpreted-executor/Cargo.toml @@ -1,9 +1,11 @@ [package] name = "interpreted-executor" -version = "0.1.0" -edition = "2024" -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = [] diff --git a/node-graph/libraries/application-io/Cargo.toml b/node-graph/libraries/application-io/Cargo.toml index 564f3a3442b..541c22d68d2 100644 --- a/node-graph/libraries/application-io/Cargo.toml +++ b/node-graph/libraries/application-io/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "graphene-application-io" -version = "0.1.0" -edition = "2024" description = "graphene application io interface" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["serde"] diff --git a/node-graph/libraries/brush-types/Cargo.toml b/node-graph/libraries/brush-types/Cargo.toml index 82ec176975a..68cf67dc490 100644 --- a/node-graph/libraries/brush-types/Cargo.toml +++ b/node-graph/libraries/brush-types/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "brush-types" -version = "0.1.0" -edition = "2024" description = "The brush stroke data format for Graphene" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["serde"] diff --git a/node-graph/libraries/canvas-utils/Cargo.toml b/node-graph/libraries/canvas-utils/Cargo.toml index 0656c493e55..9c977283e46 100644 --- a/node-graph/libraries/canvas-utils/Cargo.toml +++ b/node-graph/libraries/canvas-utils/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "graphene-canvas-utils" -version = "0.1.0" -edition = "2024" description = "graphene canvas utilities" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] wgpu = ["dep:wgpu", "dep:wgpu-executor"] diff --git a/node-graph/libraries/core-types/Cargo.toml b/node-graph/libraries/core-types/Cargo.toml index f0162d6a2b9..1c611a10de5 100644 --- a/node-graph/libraries/core-types/Cargo.toml +++ b/node-graph/libraries/core-types/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "core-types" -version = "0.1.0" -edition = "2024" description = "Core types and traits for Graphene node system" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["serde"] diff --git a/node-graph/libraries/graphene-hash/Cargo.toml b/node-graph/libraries/graphene-hash/Cargo.toml index f1827e57f2c..88475a0e146 100644 --- a/node-graph/libraries/graphene-hash/Cargo.toml +++ b/node-graph/libraries/graphene-hash/Cargo.toml @@ -1,11 +1,12 @@ [package] name = "graphene-hash" -version = "0.0.0" -edition = "2024" -authors = ["Graphite Authors "] description = "CacheHash trait and derive macro for cache invalidation hashing in Graphite" -license = "MIT OR Apache-2.0" -publish = false +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["std"] diff --git a/node-graph/libraries/graphene-hash/derive/Cargo.toml b/node-graph/libraries/graphene-hash/derive/Cargo.toml index e96fd50016c..e0ffcae473f 100644 --- a/node-graph/libraries/graphene-hash/derive/Cargo.toml +++ b/node-graph/libraries/graphene-hash/derive/Cargo.toml @@ -1,11 +1,12 @@ [package] name = "graphene-hash-derive" -version = "0.0.0" -edition = "2024" -authors = ["Graphite Authors "] description = "#[derive(CacheHash)]" -license = "MIT OR Apache-2.0" -publish = false +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [lib] proc-macro = true diff --git a/node-graph/libraries/graphic-types/Cargo.toml b/node-graph/libraries/graphic-types/Cargo.toml index 0762710a893..1ee4cf088ce 100644 --- a/node-graph/libraries/graphic-types/Cargo.toml +++ b/node-graph/libraries/graphic-types/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "graphic-types" -version = "0.1.0" -edition = "2024" description = "Graphic types for Graphene - combines vector types with core infrastructure" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["serde"] diff --git a/node-graph/libraries/no-std-types/Cargo.toml b/node-graph/libraries/no-std-types/Cargo.toml index 3258cc03289..c59ed1421c7 100644 --- a/node-graph/libraries/no-std-types/Cargo.toml +++ b/node-graph/libraries/no-std-types/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "no-std-types" -version = "0.1.0" -edition = "2024" description = "no_std types for Graphene (shader-compatible)" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] # any feature that diff --git a/node-graph/libraries/raster-types/Cargo.toml b/node-graph/libraries/raster-types/Cargo.toml index 7421b3ad370..7534d0b6a72 100644 --- a/node-graph/libraries/raster-types/Cargo.toml +++ b/node-graph/libraries/raster-types/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "raster-types" -version = "0.1.0" -edition = "2024" description = "Raster data types for Graphene node system" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["serde"] diff --git a/node-graph/libraries/rendering/Cargo.toml b/node-graph/libraries/rendering/Cargo.toml index 7da33a4eb05..42c65808be2 100644 --- a/node-graph/libraries/rendering/Cargo.toml +++ b/node-graph/libraries/rendering/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "rendering" -version = "0.1.0" -edition = "2024" description = "SVG rendering for Graphene" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["serde"] diff --git a/node-graph/libraries/resources/Cargo.toml b/node-graph/libraries/resources/Cargo.toml index 0e96c66f614..65d632cae37 100644 --- a/node-graph/libraries/resources/Cargo.toml +++ b/node-graph/libraries/resources/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "graphene-resource" -version = "0.1.0" -edition = "2024" description = "graphene resource interface" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["serde"] diff --git a/node-graph/libraries/vector-types/Cargo.toml b/node-graph/libraries/vector-types/Cargo.toml index 0e8784b5849..f3debe9e775 100644 --- a/node-graph/libraries/vector-types/Cargo.toml +++ b/node-graph/libraries/vector-types/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "vector-types" -version = "0.1.0" -edition = "2024" description = "Vector graphics types and algorithms for Graphene" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["serde"] diff --git a/node-graph/libraries/wgpu-executor/Cargo.toml b/node-graph/libraries/wgpu-executor/Cargo.toml index 9c4236f043c..8bebe7ed61f 100644 --- a/node-graph/libraries/wgpu-executor/Cargo.toml +++ b/node-graph/libraries/wgpu-executor/Cargo.toml @@ -1,9 +1,11 @@ [package] name = "wgpu-executor" -version = "0.1.0" -edition = "2024" -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [dependencies] # Local dependencies diff --git a/node-graph/node-macro/Cargo.toml b/node-graph/node-macro/Cargo.toml index 89e88a1380d..c41468ae2b4 100644 --- a/node-graph/node-macro/Cargo.toml +++ b/node-graph/node-macro/Cargo.toml @@ -1,14 +1,11 @@ [package] name = "node-macro" -publish = false -version = "0.0.0" -rust-version = "1.88" -authors = ["Graphite Authors "] -edition = "2024" -readme = "../../README.md" -homepage = "https://graphite.art" -repository = "https://github.com/GraphiteEditor/Graphite" -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [lib] proc-macro = true diff --git a/node-graph/nodes/blending/Cargo.toml b/node-graph/nodes/blending/Cargo.toml index ed9229adc3a..fb6749647a9 100644 --- a/node-graph/nodes/blending/Cargo.toml +++ b/node-graph/nodes/blending/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "blending-nodes" -version = "0.1.0" -edition = "2024" description = "Blending operation nodes for Graphene" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["serde"] diff --git a/node-graph/nodes/brush/Cargo.toml b/node-graph/nodes/brush/Cargo.toml index dcc767238e4..12e8a5cd0d8 100644 --- a/node-graph/nodes/brush/Cargo.toml +++ b/node-graph/nodes/brush/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "brush-nodes" -version = "0.1.0" -edition = "2024" description = "Brush rendering nodes for Graphene" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["serde"] diff --git a/node-graph/nodes/gcore/Cargo.toml b/node-graph/nodes/gcore/Cargo.toml index 1e22058ff7d..a4e59007b41 100644 --- a/node-graph/nodes/gcore/Cargo.toml +++ b/node-graph/nodes/gcore/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "graphene-core" -version = "0.1.0" -edition = "2024" description = "Core utility nodes for Graphene" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["serde"] diff --git a/node-graph/nodes/graphic/Cargo.toml b/node-graph/nodes/graphic/Cargo.toml index 2fc815c2e31..5b88494ac4e 100644 --- a/node-graph/nodes/graphic/Cargo.toml +++ b/node-graph/nodes/graphic/Cargo.toml @@ -1,9 +1,11 @@ [package] name = "graphic-nodes" -version = "0.1.0" -edition = "2024" -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [dependencies] # Local dependencies diff --git a/node-graph/nodes/gstd/Cargo.toml b/node-graph/nodes/gstd/Cargo.toml index 2385c9a9b68..a2356f3eec1 100644 --- a/node-graph/nodes/gstd/Cargo.toml +++ b/node-graph/nodes/gstd/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "graphene-std" -version = "0.1.0" -edition = "2024" description = "Graphene standard library" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["wgpu"] diff --git a/node-graph/nodes/math/Cargo.toml b/node-graph/nodes/math/Cargo.toml index 6455301cd08..74989e63aa9 100644 --- a/node-graph/nodes/math/Cargo.toml +++ b/node-graph/nodes/math/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "math-nodes" -version = "0.1.0" -edition = "2024" description = "Math operation nodes for Graphene" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [dependencies] core-types = { workspace = true } diff --git a/node-graph/nodes/path-bool/Cargo.toml b/node-graph/nodes/path-bool/Cargo.toml index d6f49a35317..abab78ea330 100644 --- a/node-graph/nodes/path-bool/Cargo.toml +++ b/node-graph/nodes/path-bool/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "path-bool-nodes" -version = "0.1.0" -edition = "2024" description = "Path boolean operation nodes for Graphene" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [dependencies] # Local dependencies diff --git a/node-graph/nodes/raster/Cargo.toml b/node-graph/nodes/raster/Cargo.toml index 77f7a648cc1..c8e60d517a7 100644 --- a/node-graph/nodes/raster/Cargo.toml +++ b/node-graph/nodes/raster/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "raster-nodes" -version = "0.1.0" -edition = "2024" description = "Raster operation nodes for Graphene" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [lints] workspace = true diff --git a/node-graph/nodes/raster/shaders/Cargo.toml b/node-graph/nodes/raster/shaders/Cargo.toml index 175f25468bc..388b33bd40e 100644 --- a/node-graph/nodes/raster/shaders/Cargo.toml +++ b/node-graph/nodes/raster/shaders/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "raster-nodes-shaders" -version = "0.1.0" -edition = "2024" description = "graphene raster data format" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [dependencies] diff --git a/node-graph/nodes/raster/shaders/entrypoint/Cargo.toml b/node-graph/nodes/raster/shaders/entrypoint/Cargo.toml index 2102ba33b3a..affcab05ccc 100644 --- a/node-graph/nodes/raster/shaders/entrypoint/Cargo.toml +++ b/node-graph/nodes/raster/shaders/entrypoint/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "raster-nodes-shaders-entrypoint" -version = "0.1.0" -edition = "2024" description = "graphene raster nodes shaders entrypoint" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [dependencies] raster-nodes = { path = "../..", default-features = false } diff --git a/node-graph/nodes/repeat/Cargo.toml b/node-graph/nodes/repeat/Cargo.toml index ec3c38e495a..10c1ffe9253 100644 --- a/node-graph/nodes/repeat/Cargo.toml +++ b/node-graph/nodes/repeat/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "repeat-nodes" -version = "0.1.0" -edition = "2024" description = "Repeat operation nodes for Graphene" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["serde"] diff --git a/node-graph/nodes/text/Cargo.toml b/node-graph/nodes/text/Cargo.toml index ca9e12e4b04..dc1d0086ea3 100644 --- a/node-graph/nodes/text/Cargo.toml +++ b/node-graph/nodes/text/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "text-nodes" -version = "0.1.0" -edition = "2024" description = "Text operation nodes for Graphene" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["serde"] diff --git a/node-graph/nodes/transform/Cargo.toml b/node-graph/nodes/transform/Cargo.toml index 412994959e8..3831d02e800 100644 --- a/node-graph/nodes/transform/Cargo.toml +++ b/node-graph/nodes/transform/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "transform-nodes" -version = "0.1.0" -edition = "2024" description = "Transform operation nodes for Graphene" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["serde"] diff --git a/node-graph/nodes/vector/Cargo.toml b/node-graph/nodes/vector/Cargo.toml index dba094bd4e3..2f383cfe794 100644 --- a/node-graph/nodes/vector/Cargo.toml +++ b/node-graph/nodes/vector/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "vector-nodes" -version = "0.1.0" -edition = "2024" description = "Vector operation nodes for Graphene" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["serde"] diff --git a/node-graph/preprocessor/Cargo.toml b/node-graph/preprocessor/Cargo.toml index 6ec3c3fe996..a24d03f885c 100644 --- a/node-graph/preprocessor/Cargo.toml +++ b/node-graph/preprocessor/Cargo.toml @@ -1,9 +1,11 @@ [package] name = "preprocessor" -version = "0.1.0" -edition = "2024" -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] diff --git a/proc-macros/Cargo.toml b/proc-macros/Cargo.toml index de7fb22a6e8..8b48a559d6a 100644 --- a/proc-macros/Cargo.toml +++ b/proc-macros/Cargo.toml @@ -1,14 +1,11 @@ [package] name = "graphite-proc-macros" -publish = false -version = "0.0.0" -rust-version = "1.88" -authors = ["Graphite Authors "] -edition = "2024" -readme = "../README.md" -homepage = "https://graphite.art" -repository = "https://github.com/GraphiteEditor/Graphite" -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [lib] path = "src/lib.rs" diff --git a/tools/cargo-run/Cargo.toml b/tools/cargo-run/Cargo.toml index 5c7de9ea53c..a2f66d33357 100644 --- a/tools/cargo-run/Cargo.toml +++ b/tools/cargo-run/Cargo.toml @@ -1,10 +1,11 @@ [package] name = "cargo-run" -edition.workspace = true version.workspace = true license.workspace = true authors.workspace = true - +edition.workspace = true +rust-version.workspace = true +publish.workspace = true default-run = "cargo-run" [dependencies] diff --git a/tools/cargo-run/internal/download/Cargo.toml b/tools/cargo-run/internal/download/Cargo.toml index 8eccb16af29..96194bed11e 100644 --- a/tools/cargo-run/internal/download/Cargo.toml +++ b/tools/cargo-run/internal/download/Cargo.toml @@ -1,9 +1,11 @@ [package] name = "cargo-run-internal-download" -edition.workspace = true version.workspace = true license.workspace = true authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [dependencies] cargo-run = { path = "../.." } diff --git a/tools/cargo-run/internal/watch/Cargo.toml b/tools/cargo-run/internal/watch/Cargo.toml index 41c1edf936d..8839f359548 100644 --- a/tools/cargo-run/internal/watch/Cargo.toml +++ b/tools/cargo-run/internal/watch/Cargo.toml @@ -1,9 +1,11 @@ [package] name = "cargo-run-internal-watch" -edition.workspace = true version.workspace = true license.workspace = true authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [dependencies] cargo-run = { path = "../.." } diff --git a/tools/crate-hierarchy-viz/Cargo.toml b/tools/crate-hierarchy-viz/Cargo.toml index 910690db834..41ed045e04f 100644 --- a/tools/crate-hierarchy-viz/Cargo.toml +++ b/tools/crate-hierarchy-viz/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "crate-hierarchy-viz" description = "Tool to visualize the crate hierarchy in the Graphite workspace" -edition.workspace = true version.workspace = true license.workspace = true authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [dependencies] serde = { workspace = true } diff --git a/tools/editor-message-tree/Cargo.toml b/tools/editor-message-tree/Cargo.toml index 00b5bfcf774..bcf281e149a 100644 --- a/tools/editor-message-tree/Cargo.toml +++ b/tools/editor-message-tree/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "editor-message-tree" description = "Tool to generate developer documentation for the editor message system structure" -edition.workspace = true version.workspace = true license.workspace = true authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [dependencies] # Local dependencies diff --git a/tools/node-docs/Cargo.toml b/tools/node-docs/Cargo.toml index 385e815074e..a29fc5af8e9 100644 --- a/tools/node-docs/Cargo.toml +++ b/tools/node-docs/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "node-docs" description = "Tool to generate node documentation for the node catalog on the Graphite website" -edition.workspace = true version.workspace = true license.workspace = true authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [dependencies] # Local dependencies diff --git a/tools/third-party-licenses/Cargo.toml b/tools/third-party-licenses/Cargo.toml index fe348963c95..fd99e0a71d7 100644 --- a/tools/third-party-licenses/Cargo.toml +++ b/tools/third-party-licenses/Cargo.toml @@ -1,9 +1,11 @@ [package] name = "third-party-licenses" -edition.workspace = true version.workspace = true license.workspace = true authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] desktop = ["dep:cef-dll-sys", "dep:scraper"] From e487666ff91ea2e3295e68dd1c1b2e462278b673 Mon Sep 17 00:00:00 2001 From: Timon Date: Tue, 15 Sep 2026 16:17:51 +0200 Subject: [PATCH 35/46] Desktop: Switch to winit clipboard API (#4519) * Desktop: Upgrade winit and port DnD to the new data transfer API * Desktop: Read and write the clipboard via the winit data transfer API --- Cargo.lock | 529 ++++++++++++++---------------------------- Cargo.toml | 1 + desktop/Cargo.toml | 1 - desktop/src/app.rs | 102 ++++++-- desktop/src/event.rs | 4 + desktop/src/input.rs | 2 + desktop/src/window.rs | 33 --- 7 files changed, 266 insertions(+), 406 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 230a0d9ecb0..07408dfef65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,19 +33,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom 0.3.3", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.3" @@ -339,22 +326,13 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block2" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" -dependencies = [ - "objc2 0.5.2", -] - [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.3", + "objc2", ] [[package]] @@ -618,7 +596,7 @@ dependencies = [ "cef-dll-sys", "clap", "libloading 0.9.0", - "objc2 0.6.3", + "objc2", "plist", "semver", "serde", @@ -652,9 +630,9 @@ checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chrono" @@ -750,45 +728,6 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" -[[package]] -name = "clipboard-win" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" -dependencies = [ - "error-code", -] - -[[package]] -name = "clipboard_macos" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b7f4aaa047ba3c3630b080bb9860894732ff23e2aee290a418909aa6d5df38f" -dependencies = [ - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "clipboard_wayland" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "003f886bc4e2987729d10c1db3424e7f80809f3fc22dbc16c685738887cb37b8" -dependencies = [ - "smithay-clipboard", -] - -[[package]] -name = "clipboard_x11" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4274ea815e013e0f9f04a2633423e14194e408a0576c943ce3d14ca56c50031c" -dependencies = [ - "thiserror 1.0.69", - "x11rb", -] - [[package]] name = "cmake" version = "0.1.54" @@ -843,7 +782,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]] @@ -1262,7 +1201,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1272,9 +1211,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.11.0", - "block2 0.6.2", + "block2", "libc", - "objc2 0.6.3", + "objc2", ] [[package]] @@ -1290,9 +1229,9 @@ dependencies = [ [[package]] name = "dlib" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ "libloading 0.8.8", ] @@ -1393,6 +1332,11 @@ name = "dpi" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "git+https://github.com/timon-schelling/winit?branch=graphite#739828c05de222d0851899e4441343eb14aaf922" dependencies = [ "serde", ] @@ -1541,15 +1485,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] -[[package]] -name = "error-code" -version = "3.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" - [[package]] name = "euclid" version = "0.22.14" @@ -1593,7 +1531,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2270,9 +2208,9 @@ dependencies = [ "interprocess", "lzma-rust2", "muda", - "objc2 0.6.3", - "objc2-app-kit 0.3.2", - "objc2-foundation 0.3.2", + "objc2", + "objc2-app-kit", + "objc2-foundation", "open", "rand", "rfd", @@ -2284,7 +2222,6 @@ dependencies = [ "tracing-subscriber", "vello", "wgpu", - "window_clipboard", "windows 0.62.2", "winit", ] @@ -2339,11 +2276,11 @@ dependencies = [ "ipc-channel", "libc", "mach2", - "objc2 0.6.3", - "objc2-app-kit 0.3.2", - "objc2-foundation 0.3.2", + "objc2", + "objc2-app-kit", + "objc2-foundation", "objc2-io-surface", - "objc2-metal 0.3.2", + "objc2-metal", "rand", "serde", "serde_json", @@ -3239,9 +3176,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.175" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" @@ -3271,13 +3208,14 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" -version = "0.1.9" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" dependencies = [ "bitflags 2.11.0", "libc", - "redox_syscall", + "plain", + "redox_syscall 0.9.3", ] [[package]] @@ -3310,9 +3248,9 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -3510,12 +3448,12 @@ version = "0.17.1" source = "git+https://github.com/timon-schelling/muda.git?rev=e5bc28bbd6781b18afbfc237981f9ef47eddf863#e5bc28bbd6781b18afbfc237981f9ef47eddf863" dependencies = [ "crossbeam-channel", - "dpi", + "dpi 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "keyboard-types", - "objc2 0.6.3", - "objc2-app-kit 0.3.2", + "objc2", + "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", "once_cell", "png 0.17.16", "thiserror 2.0.18", @@ -3777,22 +3715,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "objc-sys" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" - -[[package]] -name = "objc2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" -dependencies = [ - "objc-sys", - "objc2-encode", -] - [[package]] name = "objc2" version = "0.6.3" @@ -3802,22 +3724,6 @@ dependencies = [ "objc2-encode", ] -[[package]] -name = "objc2-app-kit" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" -dependencies = [ - "bitflags 2.11.0", - "block2 0.5.1", - "libc", - "objc2 0.5.2", - "objc2-core-data", - "objc2-core-image", - "objc2-foundation 0.2.2", - "objc2-quartz-core 0.2.2", -] - [[package]] name = "objc2-app-kit" version = "0.3.2" @@ -3825,22 +3731,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.0", - "block2 0.6.2", - "objc2 0.6.3", + "block2", + "objc2", "objc2-core-foundation", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-core-data" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" -dependencies = [ - "bitflags 2.11.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", + "objc2-foundation", ] [[package]] @@ -3850,9 +3744,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.0", - "block2 0.6.2", + "block2", "dispatch2", - "objc2 0.6.3", + "objc2", ] [[package]] @@ -3866,18 +3760,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "objc2-core-image" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" -dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-metal 0.2.2", -] - [[package]] name = "objc2-core-video" version = "0.3.2" @@ -3895,18 +3777,6 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" -[[package]] -name = "objc2-foundation" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" -dependencies = [ - "bitflags 2.11.0", - "block2 0.5.1", - "libc", - "objc2 0.5.2", -] - [[package]] name = "objc2-foundation" version = "0.3.2" @@ -3914,8 +3784,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.0", - "block2 0.6.2", - "objc2 0.6.3", + "block2", + "objc2", "objc2-core-foundation", ] @@ -3927,21 +3797,9 @@ checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.11.0", "libc", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-metal" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" -dependencies = [ - "bitflags 2.11.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", + "objc2-foundation", ] [[package]] @@ -3951,27 +3809,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.11.0", - "block2 0.6.2", + "block2", "dispatch2", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", "objc2-io-surface", ] -[[package]] -name = "objc2-quartz-core" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" -dependencies = [ - "bitflags 2.11.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-metal 0.2.2", -] - [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -3979,10 +3824,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.11.0", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", - "objc2-foundation 0.3.2", - "objc2-metal 0.3.2", + "objc2-foundation", + "objc2-metal", ] [[package]] @@ -3992,9 +3837,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.11.0", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", ] [[package]] @@ -4049,9 +3894,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orbclient" -version = "0.3.48" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba0b26cec2e24f08ed8bb31519a9333140a6599b867dac464bb150bdb796fd43" +checksum = "bb08138df30517916447489a70848de31244c40c7b520bcf5ebdc9db6299da59" dependencies = [ "libredox", ] @@ -4081,7 +3926,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.52.0", ] [[package]] @@ -4111,7 +3956,7 @@ checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.17", "smallvec", "windows-targets 0.52.6", ] @@ -4306,6 +4151,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.8.0" @@ -4540,18 +4391,18 @@ checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" [[package]] name = "quick-xml" -version = "0.37.5" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" dependencies = [ "memchr", ] [[package]] name = "quick-xml" -version = "0.38.3" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] @@ -4609,7 +4460,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4753,10 +4604,10 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2 0.6.3", + "objc2", "objc2-core-foundation", - "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", + "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -4813,6 +4664,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" +[[package]] +name = "redox_event" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5018d583d6d2f5499352aea8d177e9067d1eb03ab17c78169d5ba7a30001b15" +dependencies = [ + "bitflags 2.11.0", + "libredox", +] + [[package]] name = "redox_syscall" version = "0.5.17" @@ -4822,6 +4683,15 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_syscall" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -4976,15 +4846,15 @@ name = "rfd" version = "0.17.2" source = "git+https://github.com/timon-schelling/rfd.git?branch=graphite#ebaf54232782629070e770bfe9cd90cb472ff137" dependencies = [ - "block2 0.6.2", + "block2", "dispatch2", "js-sys", "libc", "log", - "objc2 0.6.3", - "objc2-app-kit 0.3.2", + "objc2", + "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", "percent-encoding", "pollster", "raw-window-handle", @@ -5118,15 +4988,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.8" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5194,7 +5064,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5292,9 +5162,9 @@ dependencies = [ [[package]] name = "sctk-adwaita" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd3accc0f3f4bbaf2c9e1957a030dc582028130c67660d44c0a0345a22ca69b" +checksum = "1f8ea028b7cb32e2f577b0a55a7b54cb206376bdb1221bd5c81dcf20b68db1fb" dependencies = [ "ab_glyph", "log", @@ -5614,20 +5484,18 @@ dependencies = [ [[package]] name = "smithay-client-toolkit" -version = "0.20.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +checksum = "74dc9ee14b0fdcb535f9556141bacac070c994a977d2240ee455d7438a617f00" dependencies = [ "bitflags 2.11.0", "calloop", "calloop-wayland-source", "cursor-icon", - "libc", "log", "memmap2 0.9.10", "rustix", "thiserror 2.0.18", - "wayland-backend", "wayland-client", "wayland-csd-frame", "wayland-cursor", @@ -5639,17 +5507,6 @@ dependencies = [ "xkeysym", ] -[[package]] -name = "smithay-clipboard" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" -dependencies = [ - "libc", - "smithay-client-toolkit", - "wayland-backend", -] - [[package]] name = "smol_str" version = "0.3.2" @@ -5937,7 +5794,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6079,27 +5936,16 @@ dependencies = [ [[package]] name = "tiny-skia" -version = "0.11.4" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" dependencies = [ "arrayref", "arrayvec", "bytemuck", "cfg-if", "log", - "tiny-skia-path 0.11.4", -] - -[[package]] -name = "tiny-skia-path" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" -dependencies = [ - "arrayref", - "bytemuck", - "strict-num", + "tiny-skia-path", ] [[package]] @@ -6575,7 +6421,7 @@ dependencies = [ "siphasher", "strict-num", "svgtypes", - "tiny-skia-path 0.12.0", + "tiny-skia-path", "ttf-parser", "unicode-bidi", "unicode-script", @@ -6813,9 +6659,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.11" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", @@ -6827,9 +6673,9 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.11" +version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ "bitflags 2.11.0", "rustix", @@ -6850,9 +6696,9 @@ dependencies = [ [[package]] name = "wayland-cursor" -version = "0.31.11" +version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ "rustix", "wayland-client", @@ -6861,9 +6707,9 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.9" +version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ "bitflags 2.11.0", "wayland-backend", @@ -6873,9 +6719,9 @@ dependencies = [ [[package]] name = "wayland-protocols-experimental" -version = "20250721.0.1" +version = "20251230.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +checksum = "91c6c3e7178e553d093d46999032bc79aafea9dedd71dd702c8ace7bbc25cf13" dependencies = [ "bitflags 2.11.0", "wayland-backend", @@ -6925,20 +6771,20 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.7" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml 0.37.5", + "quick-xml 0.41.0", "quote", ] [[package]] name = "wayland-sys" -version = "0.31.7" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "dlib", "log", @@ -7123,7 +6969,7 @@ dependencies = [ "ash", "bit-set 0.9.1", "bitflags 2.11.0", - "block2 0.6.2", + "block2", "bytemuck", "cfg-if", "cfg_aliases", @@ -7139,11 +6985,11 @@ dependencies = [ "log", "naga", "ndk-sys", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", - "objc2-foundation 0.3.2", - "objc2-metal 0.3.2", - "objc2-quartz-core 0.3.2", + "objc2-foundation", + "objc2-metal", + "objc2-quartz-core", "once_cell", "ordered-float 4.6.0", "parking_lot", @@ -7225,7 +7071,7 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0978bf7171b3d90bac376700cb56d606feb40f251a475a5d6634613564460b22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -7234,20 +7080,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "window_clipboard" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5793d0b08c9e6a1240fe9ab2bd8db277487bf92436fd1a6321861a90a1b0cb7e" -dependencies = [ - "clipboard-win", - "clipboard_macos", - "clipboard_wayland", - "clipboard_x11", - "raw-window-handle", - "thiserror 1.0.69", -] - [[package]] name = "windows" version = "0.61.3" @@ -7691,14 +7523,13 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winit" -version = "0.31.0-beta.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2879d2854d1a43e48f67322d4bd097afcb6eb8f8f775c8de0260a71aea1df1aa" +version = "0.31.0-beta.3" +source = "git+https://github.com/timon-schelling/winit?branch=graphite#739828c05de222d0851899e4441343eb14aaf922" dependencies = [ "bitflags 2.11.0", "cfg_aliases", "cursor-icon", - "dpi", + "dpi 0.1.2 (git+https://github.com/timon-schelling/winit?branch=graphite)", "libc", "raw-window-handle", "rustix", @@ -7719,13 +7550,12 @@ dependencies = [ [[package]] name = "winit-android" -version = "0.31.0-beta.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d9c0d2cd93efec3a9f9ad819cfaf0834782403af7c0d248c784ec0c61761df" +version = "0.31.0-beta.3" +source = "git+https://github.com/timon-schelling/winit?branch=graphite#739828c05de222d0851899e4441343eb14aaf922" dependencies = [ "android-activity", "bitflags 2.11.0", - "dpi", + "dpi 0.1.2 (git+https://github.com/timon-schelling/winit?branch=graphite)", "ndk", "raw-window-handle", "smol_str", @@ -7735,20 +7565,19 @@ dependencies = [ [[package]] name = "winit-appkit" -version = "0.31.0-beta.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21310ca07851a49c348e0c2cc768e36b52ca65afda2c2354d78ed4b90074d8aa" +version = "0.31.0-beta.3" +source = "git+https://github.com/timon-schelling/winit?branch=graphite#739828c05de222d0851899e4441343eb14aaf922" dependencies = [ "bitflags 2.11.0", - "block2 0.6.2", + "block2", "dispatch2", - "dpi", - "objc2 0.6.3", - "objc2-app-kit 0.3.2", + "dpi 0.1.2 (git+https://github.com/timon-schelling/winit?branch=graphite)", + "objc2", + "objc2-app-kit", "objc2-core-foundation", "objc2-core-graphics", "objc2-core-video", - "objc2-foundation 0.3.2", + "objc2-foundation", "raw-window-handle", "smol_str", "tracing", @@ -7758,13 +7587,15 @@ dependencies = [ [[package]] name = "winit-common" -version = "0.31.0-beta.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45375fbac4cbb77260d83a30b1f9d8105880dbac99a9ae97f56656694680ff69" +version = "0.31.0-beta.3" +source = "git+https://github.com/timon-schelling/winit?branch=graphite#739828c05de222d0851899e4441343eb14aaf922" dependencies = [ + "block2", + "dpi 0.1.2 (git+https://github.com/timon-schelling/winit?branch=graphite)", "memmap2 0.9.10", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", + "objc2-foundation", "smol_str", "tracing", "winit-core", @@ -7774,31 +7605,31 @@ dependencies = [ [[package]] name = "winit-core" -version = "0.31.0-beta.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4f0ccd7abb43740e2c6124ac7cae7d865ecec74eec63783e8922577ac232583" +version = "0.31.0-beta.3" +source = "git+https://github.com/timon-schelling/winit?branch=graphite#739828c05de222d0851899e4441343eb14aaf922" dependencies = [ "bitflags 2.11.0", "cursor-icon", - "dpi", + "dpi 0.1.2 (git+https://github.com/timon-schelling/winit?branch=graphite)", "keyboard-types", "raw-window-handle", "serde", "smol_str", + "url", "web-time", ] [[package]] name = "winit-orbital" -version = "0.31.0-beta.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51ea1fb262e7209f265f12bd0cc792c399b14355675e65531e9c8a87db287d46" +version = "0.31.0-beta.3" +source = "git+https://github.com/timon-schelling/winit?branch=graphite#739828c05de222d0851899e4441343eb14aaf922" dependencies = [ "bitflags 2.11.0", - "dpi", + "dpi 0.1.2 (git+https://github.com/timon-schelling/winit?branch=graphite)", + "libredox", "orbclient", "raw-window-handle", - "redox_syscall", + "redox_event", "smol_str", "tracing", "winit-core", @@ -7806,17 +7637,16 @@ dependencies = [ [[package]] name = "winit-uikit" -version = "0.31.0-beta.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680a356e798837d8eb274d4556e83bceaf81698194e31aafc5cfb8a9f2fab643" +version = "0.31.0-beta.3" +source = "git+https://github.com/timon-schelling/winit?branch=graphite#739828c05de222d0851899e4441343eb14aaf922" dependencies = [ "bitflags 2.11.0", - "block2 0.6.2", + "block2", "dispatch2", - "dpi", - "objc2 0.6.3", + "dpi 0.1.2 (git+https://github.com/timon-schelling/winit?branch=graphite)", + "objc2", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", "objc2-ui-kit", "raw-window-handle", "serde", @@ -7828,17 +7658,17 @@ dependencies = [ [[package]] name = "winit-wayland" -version = "0.31.0-beta.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce5afb2ba07da603f84b722c95f9f9396d2cedae3944fb6c0cda4a6f88de545" +version = "0.31.0-beta.3" +source = "git+https://github.com/timon-schelling/winit?branch=graphite#739828c05de222d0851899e4441343eb14aaf922" dependencies = [ - "ahash", "bitflags 2.11.0", "calloop", "cursor-icon", - "dpi", + "dpi 0.1.2 (git+https://github.com/timon-schelling/winit?branch=graphite)", + "foldhash 0.2.0", "libc", "memmap2 0.9.10", + "percent-encoding", "raw-window-handle", "rustix", "sctk-adwaita", @@ -7855,15 +7685,14 @@ dependencies = [ [[package]] name = "winit-web" -version = "0.31.0-beta.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c2490a953fb776fbbd5e295d54f1c3847f4f15b6c3929ec53c09acda6487a92" +version = "0.31.0-beta.3" +source = "git+https://github.com/timon-schelling/winit?branch=graphite#739828c05de222d0851899e4441343eb14aaf922" dependencies = [ "atomic-waker", "bitflags 2.11.0", "concurrent-queue", "cursor-icon", - "dpi", + "dpi 0.1.2 (git+https://github.com/timon-schelling/winit?branch=graphite)", "js-sys", "pin-project", "raw-window-handle", @@ -7878,32 +7707,32 @@ dependencies = [ [[package]] name = "winit-win32" -version = "0.31.0-beta.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "644ea78af0e858aa3b092e5d1c67c41995a98220c81813f1353b28bc8bb91eaa" +version = "0.31.0-beta.3" +source = "git+https://github.com/timon-schelling/winit?branch=graphite#739828c05de222d0851899e4441343eb14aaf922" dependencies = [ "bitflags 2.11.0", "cursor-icon", - "dpi", + "dpi 0.1.2 (git+https://github.com/timon-schelling/winit?branch=graphite)", "raw-window-handle", "smol_str", "tracing", "unicode-segmentation", - "windows-sys 0.59.0", + "url", + "windows-sys 0.61.2", + "winit-common", "winit-core", ] [[package]] name = "winit-x11" -version = "0.31.0-beta.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa5b600756534c7041aa93cd0d244d44b09fca1b89e202bd1cd80dd9f3636c46" +version = "0.31.0-beta.3" +source = "git+https://github.com/timon-schelling/winit?branch=graphite#739828c05de222d0851899e4441343eb14aaf922" dependencies = [ "bitflags 2.11.0", "bytemuck", "calloop", "cursor-icon", - "dpi", + "dpi 0.1.2 (git+https://github.com/timon-schelling/winit?branch=graphite)", "libc", "percent-encoding", "raw-window-handle", diff --git a/Cargo.toml b/Cargo.toml index 194897cbdbc..f7d242e8178 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -260,6 +260,7 @@ lto = "thin" debug = true [patch.crates-io] +winit = { git = "https://github.com/timon-schelling/winit", branch = "graphite" } rfd = { git = "https://github.com/timon-schelling/rfd.git", branch = "graphite" } # TODO: Remove this once https://github.com/PolyMeilex/rfd/pull/317 is merged and released cef = { git = "https://github.com/timon-schelling/cef-rs.git", branch = "graphite-151" } cef-dll-sys = { git = "https://github.com/timon-schelling/cef-rs.git", branch = "graphite-151" } diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index c3632a8cd88..82afed4a6f3 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -49,7 +49,6 @@ clap = { workspace = true, features = ["derive"] } interprocess = "2.4.2" fd-lock = "4.0.4" ctrlc = "3.5.1" -window_clipboard = "0.5" # Windows-specific dependencies [target.'cfg(target_os = "windows")'.dependencies] diff --git a/desktop/src/app.rs b/desktop/src/app.rs index b89d1a5a964..1f48b438b00 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -9,10 +9,11 @@ use std::sync::mpsc::{Receiver, SyncSender}; use std::thread; use std::time::{Duration, Instant}; use winit::application::ApplicationHandler; +use winit::data_transfer::{DataTransferSendBuilder, TypeHint}; use winit::dpi::PhysicalSize; use winit::event::{ElementState, MouseButton, StartCause, WindowEvent}; use winit::event_loop::run_on_demand::EventLoopExtRunOnDemand; -use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use winit::event_loop::{ActiveEventLoop, AsyncRequestSerial, ControlFlow, DndAction, EventLoop}; use winit::window::WindowId; use crate::dirs; @@ -35,6 +36,8 @@ pub(crate) struct App { window_maximized: bool, window_fullscreen: bool, window_pending_drag: bool, + pending_dnd_fetch: Option, + pending_clipboard_fetch: Option, input_state: InputState, ui_scale: f64, app_event_receiver: Receiver, @@ -107,6 +110,8 @@ impl App { window_maximized: false, window_fullscreen: false, window_pending_drag: false, + pending_dnd_fetch: None, + pending_clipboard_fetch: None, input_state: InputState::new(), ui_scale: 1., app_event_receiver, @@ -335,16 +340,10 @@ impl App { } } DesktopFrontendMessage::ClipboardRead => { - if let Some(window) = &self.window { - let content = window.clipboard_read(); - let message = DesktopWrapperMessage::ClipboardReadResult { content }; - self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); - } + self.app_event_scheduler.schedule(AppEvent::ClipboardRead); } DesktopFrontendMessage::ClipboardWrite { content } => { - if let Some(window) = &mut self.window { - window.clipboard_write(content); - } + self.app_event_scheduler.schedule(AppEvent::ClipboardWrite { content }); } DesktopFrontendMessage::PointerLock => { self.input_state.lock_pointer(); @@ -467,6 +466,32 @@ impl App { self.ui_frame_received = true; } } + AppEvent::ClipboardRead => { + let result = event_loop.clipboard().and_then(|id| { + let Some(id) = id else { return Ok(None) }; + let data_transfer = event_loop.data_transfer(id)?; + if !data_transfer.has_type(&TypeHint::Plaintext) { + return Ok(None); + } + event_loop.fetch_data_transfer(id, &TypeHint::Plaintext).map(Some) + }); + match result { + Ok(Some(serial)) => self.pending_clipboard_fetch = Some(serial), + Ok(None) => self.dispatch_desktop_wrapper_message(DesktopWrapperMessage::ClipboardReadResult { content: None }), + Err(e) => { + tracing::error!("Failed to read from clipboard: {e}"); + self.dispatch_desktop_wrapper_message(DesktopWrapperMessage::ClipboardReadResult { content: None }); + } + } + } + AppEvent::ClipboardWrite { content } => { + let send_data = DataTransferSendBuilder::new(content) + .with_type(TypeHint::Plaintext, |content: &String, _| Some(content.clone())) + .build(); + if let Err(e) = event_loop.set_clipboard(send_data) { + tracing::error!("Failed to write to clipboard: {e}"); + } + } AppEvent::CursorChange(cursor) => { if let Some(window) = &mut self.window { window.set_cursor(event_loop, cursor); @@ -538,7 +563,7 @@ impl ApplicationHandler for App { } } - fn window_event(&mut self, _event_loop: &dyn ActiveEventLoop, _window_id: WindowId, event: WindowEvent) { + fn window_event(&mut self, event_loop: &dyn ActiveEventLoop, _window_id: WindowId, event: WindowEvent) { // Handle pointer lock release if let WindowEvent::PointerButton { state: ElementState::Released, @@ -601,20 +626,53 @@ impl ApplicationHandler for App { self.exit(Some(ExitReason::UiAccelerationFailure)); } } - WindowEvent::DragDropped { paths, .. } => { - for path in paths { - match fs::read(&path) { - Ok(content) => { - let message = DesktopWrapperMessage::ImportFile { path, content }; - self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); - } - Err(e) => { - tracing::error!("Failed to read dropped file {}: {}", path.display(), e); - return; - } - }; + WindowEvent::DragEntered { id, .. } => { + let accepts = event_loop.data_transfer(id).is_ok_and(|data_transfer| data_transfer.has_type(&TypeHint::UriList)); + let actions: &[DndAction] = if accepts { &[DndAction::Copy] } else { &[] }; + if let Err(e) = event_loop.set_valid_dnd_actions(id, actions) { + tracing::error!("Failed to set valid drag and drop actions: {e}"); } } + WindowEvent::DragDropped { id, .. } => match event_loop.fetch_data_transfer(id, &TypeHint::UriList) { + Ok(serial) => self.pending_dnd_fetch = Some(serial), + Err(e) => tracing::error!("Failed to fetch dropped data: {e}"), + }, + WindowEvent::DataTransferReceived { serial, ref value, .. } if self.pending_clipboard_fetch == Some(serial) => match value.try_as_string() { + Ok(content) => { + self.pending_clipboard_fetch = None; + let message = DesktopWrapperMessage::ClipboardReadResult { content: Some(content) }; + self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(e) => { + self.pending_clipboard_fetch = None; + tracing::error!("Failed to read from clipboard: {e}"); + let message = DesktopWrapperMessage::ClipboardReadResult { content: None }; + self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); + } + }, + WindowEvent::DataTransferReceived { serial, ref value, .. } if self.pending_dnd_fetch == Some(serial) => match value.try_as_file_paths() { + Ok(paths) => { + self.pending_dnd_fetch = None; + for path in paths { + match fs::read(&path) { + Ok(content) => { + let message = DesktopWrapperMessage::ImportFile { path, content }; + self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); + } + Err(e) => { + tracing::error!("Failed to read dropped file {}: {}", path.display(), e); + return; + } + }; + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(e) => { + self.pending_dnd_fetch = None; + tracing::error!("Failed to read dropped data: {e}"); + } + }, WindowEvent::PointerMoved { .. } | WindowEvent::PointerLeft { position: Some(_), .. } | WindowEvent::PointerEntered { .. } if !self.input_state.pointer_locked() && self.window_pending_drag => diff --git a/desktop/src/event.rs b/desktop/src/event.rs index 1d7a8283de7..cbe0f77f3b4 100644 --- a/desktop/src/event.rs +++ b/desktop/src/event.rs @@ -8,6 +8,10 @@ pub(crate) enum AppEvent { WebCommunicationInitialized, DesktopWrapperMessage(DesktopWrapperMessage), NodeGraphExecutionResult(NodeGraphExecutionResult), + ClipboardRead, + ClipboardWrite { + content: String, + }, Exit, UiCrashed, OpenFiles(Vec), diff --git a/desktop/src/input.rs b/desktop/src/input.rs index 731f43fdba1..dcc438f35b5 100644 --- a/desktop/src/input.rs +++ b/desktop/src/input.rs @@ -221,6 +221,7 @@ impl InputState { let input = match delta { MouseScrollDelta::LineDelta(x, y) => InputEvent::pointer().scrolled_lines(f64::from(*x), f64::from(*y)), MouseScrollDelta::PixelDelta(position) => InputEvent::pointer().scrolled_pixels(position.x, position.y), + _ => return, }; ui_callback(input.modifiers(self.modifiers).build()); return; @@ -229,6 +230,7 @@ impl InputState { let (x, y) = match delta { MouseScrollDelta::LineDelta(x, y) => (f64::from(*x) * SCROLL_LINE_WIDTH, f64::from(*y) * SCROLL_LINE_HEIGHT), MouseScrollDelta::PixelDelta(position) => (position.x, position.y), + _ => return, }; let scroll_delta = ScrollDelta::new(-x * SCROLL_SPEED_X, -y * SCROLL_SPEED_Y, 0.); diff --git a/desktop/src/window.rs b/desktop/src/window.rs index 7693719b033..6b984e0d2af 100644 --- a/desktop/src/window.rs +++ b/desktop/src/window.rs @@ -43,13 +43,6 @@ pub(crate) struct Window { #[allow(dead_code)] native_handle: native::NativeWindowImpl, custom_cursors: HashMap, - clipboard: Option, -} -impl Drop for Window { - fn drop(&mut self) { - // Clipboard must be dropped before `winit_window` - drop(self.clipboard.take()); - } } impl Window { @@ -70,12 +63,10 @@ impl Window { let winit_window = event_loop.create_window(attributes).unwrap(); let native_handle = native::NativeWindowImpl::new(winit_window.as_ref(), app_event_scheduler); - let clipboard = unsafe { window_clipboard::Clipboard::connect(&winit_window) }.ok(); Self { winit_window: winit_window.into(), native_handle, custom_cursors: HashMap::new(), - clipboard, } } @@ -208,28 +199,4 @@ impl Window { pub(crate) fn update_menu(&self, entries: Vec) { self.native_handle.update_menu(entries); } - - pub(crate) fn clipboard_read(&self) -> Option { - let Some(clipboard) = &self.clipboard else { - tracing::error!("Clipboard not available"); - return None; - }; - match clipboard.read() { - Ok(data) => Some(data), - Err(e) => { - tracing::error!("Failed to read from clipboard: {e}"); - None - } - } - } - - pub(crate) fn clipboard_write(&mut self, data: String) { - let Some(clipboard) = &mut self.clipboard else { - tracing::error!("Clipboard not available"); - return; - }; - if let Err(e) = clipboard.write(data) { - tracing::error!("Failed to write to clipboard: {e}") - } - } } From 76a9486212470f0e204d1a81843f85475c220346 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 15 Sep 2026 16:38:19 -0700 Subject: [PATCH 36/46] Add colorize and six hue ranges to the 'Hue/Saturation' node (#4536) * Add colorize and six hue ranges to the 'Hue/Saturation' node * Hide the 'Hue/Saturation' range rows while colorize is on, note the +100 saturation rule, and tidy two lints and a tooltip --- .../utility_types/widgets/input_widgets.rs | 14 +- .../data_panel/data_panel_message_handler.rs | 8 +- .../document/document_message_handler.rs | 2 +- .../document/graph_operation/utility_types.rs | 4 +- .../document/node_graph/node_properties.rs | 230 +++++-- .../messages/portfolio/document_migration.rs | 11 + .../widgets/inputs/SpectrumInput.svelte | 221 +++++-- node-graph/graph-craft/src/document/value.rs | 1 + .../interpreted-executor/src/node_registry.rs | 1 + node-graph/nodes/raster/src/adjustments.rs | 574 +++++++++++++++++- 10 files changed, 959 insertions(+), 107 deletions(-) diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index 75483971a80..28d81bb866b 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -671,6 +671,9 @@ pub struct SpectrumInput { /// Whether dragging a marker past another reorders them, which also needs `allow_select`. Otherwise the dragged marker is clamped between its neighbors. #[serde(rename = "allowReorder")] pub allow_reorder: bool, + /// Whether the track's ends meet, as on a hue wheel: a run dragged by its strip or dashed link wraps past them, a lone marker stops. + #[serde(rename = "allowWrap")] + pub allow_wrap: bool, /// Whether clicking a marker selects it, keeping it highlighted and reported as the active marker until another is chosen, /// as a gradient editor needs for the stop being edited. Otherwise the highlight only follows the pointer and the drag. #[serde(rename = "allowSelect")] @@ -703,7 +706,10 @@ pub struct SpectrumMarker { /// discarding any transparency so the handle always shows the RGB that steers the interpolation. #[serde(rename = "handleColorCSS")] handle_color_css: String, - /// Whether a dashed line runs from this marker to the next through the lane below the track. Dragging it carries both markers. + /// Whether this marker and the next form a split handle: one marker split down the middle while they coincide, two halves joined by a strip once apart. + #[serde(rename = "pairedWithNext")] + paired_with_next: bool, + /// Whether a dashed line runs from this marker to the next through the lane below the track. Dragging it carries both markers, along with any split-handle halves attached to them. #[serde(rename = "dashedToNext")] dashed_to_next: bool, /// Whether this marker follows its neighbors instead of bounding them, so they may drag past its drawn position. @@ -718,6 +724,7 @@ impl SpectrumMarker { position, midpoint, handle_color_css, + paired_with_next: false, dashed_to_next: false, between_neighbors: false, } @@ -728,6 +735,11 @@ impl SpectrumMarker { self } + pub fn pair_with_next(mut self) -> Self { + self.paired_with_next = true; + self + } + pub fn dash_to_next(mut self) -> Self { self.dashed_to_next = true; self diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index 7c0da3eb64b..04957c314e0 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -16,8 +16,8 @@ use graphene_std::list::{Item, List, NodeIdPath}; use graphene_std::math::float_noise::round_away_float_noise; use graphene_std::memo::IORecord; use graphene_std::raster::{ - AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DesaturateMethod, DomainWarpType, FractalType, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice, - TonalRange, + AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DesaturateMethod, DomainWarpType, FractalType, HueSaturationRange, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, + SelectiveColorChoice, TonalRange, }; use graphene_std::raster_types::{CPU, GPU, Raster}; use graphene_std::text::TextAlign; @@ -245,6 +245,7 @@ fn generate_layout(introspected_data: &Arc, List, List, + List, List, List, List, @@ -303,6 +304,7 @@ fn generate_layout(introspected_data: &Arc, Item, Item, + Item, Item, Item, Item, @@ -1074,6 +1076,7 @@ impl_table_item_layout_for_choice_enum!( SelectiveColorChoice, TonalRange, AdjustmentChannel, + HueSaturationRange, XY, ScaleType, CentroidType, @@ -1294,6 +1297,7 @@ macro_rules! known_item_types { SelectiveColorChoice, TonalRange, AdjustmentChannel, + HueSaturationRange, XY, ScaleType, ReferencePoint, diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 237bc66b38b..c814a4544fc 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -3537,7 +3537,7 @@ impl DocumentMessageHandler { }) .on_commit(|_| DocumentMessage::AddTransaction.into()) .max_width(100) - .tooltip_label("Fill") + .tooltip_label("Fill Opacity") .widget_instance(), ]; let layers_panel_control_bar_left = Layout(vec![LayoutGroup::row(widgets)]); diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index 3149b1d7170..e72ad3bcab3 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -664,7 +664,7 @@ impl<'a> ModifyInputsContext<'a> { .nodes .get(&node_id) .and_then(|node| node.input(graphene_std::math_nodes::gradient_positions::PositionsInput)); - if !current_input.is_some_and(|input| input.as_value().is_some()) { + if current_input.is_none_or(|input| input.as_value().is_none()) { return; } @@ -688,7 +688,7 @@ impl<'a> ModifyInputsContext<'a> { .nodes .get(&node_id) .and_then(|node| node.input(graphene_std::math_nodes::gradient_midpoints::MidpointsInput)); - if !current_input.is_some_and(|input| input.as_value().is_some()) { + if current_input.is_none_or(|input| input.as_value().is_none()) { return; } diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 1bcaa67906d..8291e0bfe80 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -20,8 +20,8 @@ use graphene_std::animation::RealTimeMode; use graphene_std::color::SRGBA8; use graphene_std::extract_xy::XY; use graphene_std::raster::{ - AdjustmentChannel, BlendMode, CellularDistanceFunction, CellularReturnType, Color, DesaturateMethod, DomainWarpType, FractalType, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, - SelectiveColorChoice, TonalRange, + AdjustmentChannel, BlendMode, CellularDistanceFunction, CellularReturnType, Color, DesaturateMethod, DomainWarpType, FractalType, HueSaturationRange, NoiseType, RedGreenBlue, RedGreenBlueAlpha, + RelativeAbsolute, SelectiveColorChoice, TonalRange, }; use graphene_std::raster_types::Image; use graphene_std::text::{Font, TextAlign}; @@ -367,6 +367,7 @@ pub(crate) fn property_from_type( Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), + Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), @@ -1468,6 +1469,8 @@ enum MarkerScale { Percent, /// A gamma of 0.01..9.99 running from 9.99 at the left to 0.01 at the right, logarithmic on each side of the 1 at its center. Gamma, + /// A hue of 0..360 degrees, placed linearly on a track that wraps around. + Degrees, } impl MarkerScale { @@ -1476,6 +1479,11 @@ impl MarkerScale { Self::Percent => value / 100., Self::Gamma if value >= 1. => 0.5 - 0.5 * value.log10() / 9.99_f64.log10(), Self::Gamma => 0.5 + 0.5 * value.log10() / 0.01_f64.log10(), + Self::Degrees => { + // A full turn stays at the far end, so only a value beyond one turn wraps + let turns = value / 360.; + if (0.0..=1.).contains(&turns) { turns } else { turns.rem_euclid(1.) } + } } .clamp(0., 1.) } @@ -1485,6 +1493,7 @@ impl MarkerScale { Self::Percent => (position * 100.).clamp(0., 100.), Self::Gamma if position <= 0.5 => 9.99_f64.powf(1. - 2. * position).clamp(1., 9.99), Self::Gamma => 0.01_f64.powf(2. * position - 1.).clamp(0.01, 1.), + Self::Degrees => (position * 360.).clamp(0., 360.), } } @@ -1492,8 +1501,14 @@ impl MarkerScale { match self { Self::Percent => NumberInput::default().mode_increment().unit("%").min(0.).max(100.).display_decimal_places(0), Self::Gamma => NumberInput::default().mode_increment().min(0.01).max(9.99).display_decimal_places(2), + Self::Degrees => NumberInput::default().mode_increment().unit("°").min(0.).max(360.).display_decimal_places(0), } } + + /// Whether the track wraps around, so its markers may sit in any order. + fn cyclic(self) -> bool { + matches!(self, Self::Degrees) + } } /// One parameter of a shared spectrum section and how its marker sits on the track. @@ -1503,6 +1518,8 @@ struct SpectrumSectionParam { /// The value a double-click resets to. default_value: f64, scale: MarkerScale, + /// Whether the marker and the next parameter's marker form one split handle. + pair_with_next: bool, /// Whether a dashed line joins the marker to the next parameter's marker. dash_to_next: bool, /// Whether the marker takes its scale position within the span between its neighbors rather than the whole track, following them as they move. @@ -1516,6 +1533,7 @@ impl SpectrumSectionParam { handle_color, default_value, scale, + pair_with_next: false, dash_to_next: false, between_neighbors: false, } @@ -1526,6 +1544,11 @@ impl SpectrumSectionParam { self } + fn pair_with_next(mut self) -> Self { + self.pair_with_next = true; + self + } + fn dash_to_next(mut self) -> Self { self.dash_to_next = true; self @@ -1574,18 +1597,21 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo marker_default_positions.push(param.scale.position(param.default_value)); marker_scales.push(param.scale); marker_between.push(param.between_neighbors); - marker_colors_and_links.push((param.handle_color, param.dash_to_next && next_has_marker)); + marker_colors_and_links.push((param.handle_color, param.pair_with_next && next_has_marker, param.dash_to_next && next_has_marker)); } // Enforce non-decreasing order so markers never visually cross, matching the node's algorithm where shadows takes precedence. // A marker placed between its neighbors bounds nothing here and instead takes its scale position within their settled span. - let mut floor = 0.; - for (position, &between) in marker_positions.iter_mut().zip(&marker_between) { - if between { - continue; + let cyclic = params.iter().any(|param| param.scale.cyclic()); + if !cyclic { + let mut floor = 0.; + for (position, &between) in marker_positions.iter_mut().zip(&marker_between) { + if between { + continue; + } + *position = position.max(floor); + floor = *position; } - *position = position.max(floor); - floor = *position; } for i in 0..marker_positions.len() { if marker_between[i] { @@ -1599,8 +1625,11 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo .iter() .zip(&marker_colors_and_links) .zip(&marker_between) - .map(|((&position, &(handle_color, dashed)), &between)| { + .map(|((&position, &(handle_color, paired, dashed)), &between)| { let mut marker = SpectrumMarker::new(position, 0.5, handle_color); + if paired { + marker = marker.pair_with_next(); + } if dashed { marker = marker.dash_to_next(); } @@ -1620,6 +1649,7 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo .allow_insert(false) .allow_delete(false) .allow_reorder(false) + .allow_wrap(cyclic) .narrow(true) .on_update({ let marker_input_indices = marker_input_indices.clone(); @@ -1654,7 +1684,7 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo } SpectrumInputUpdate::MoveMarker { position, .. } => *position, // A default that would cross a neighbor falls back to the midpoint between them - SpectrumInputUpdate::ResetMarker { .. } if between || (left..=right).contains(&default_position) => default_position, + SpectrumInputUpdate::ResetMarker { .. } if between || cyclic || (left..=right).contains(&default_position) => default_position, SpectrumInputUpdate::ResetMarker { .. } => (left + right) / 2., _ => return Message::NoOp, }; @@ -1713,58 +1743,178 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo pub(crate) fn hue_saturation_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { use graphene_std::raster::hue_saturation::*; - // Current hue position on the rainbow track, used for the saturation track's right-end color - let current_hue_shift = get_document_node(node_id, context) - .ok() - .and_then(|document_node| document_node.input(HueShiftInput).and_then(|input| input.as_value())) - .and_then(|tagged| if let TaggedValue::F32(value) = tagged { Some(*value) } else { None }) - .unwrap_or(0.); - // The rainbow has cyan at position 0.5 (hue_shift=0), so offset by +180 to align - let marker_hue = ((current_hue_shift + 180.) / 360.).rem_euclid(1.); - let saturated_current_hue = Color::from_hsva(marker_hue, 1., 1., 1.); - - // Hue: cyclic rainbow + let document_node = match get_document_node(node_id, context) { + Ok(document_node) => document_node, + Err(err) => { + log::error!("Could not get document node in hue_saturation_properties: {err}"); + return Vec::new(); + } + }; + let colorize_value = matches!(document_node.input_value(ColorizeInput), Some(TaggedValue::Bool(true))); + let range_value = match document_node.input_value(RangeInput) { + Some(TaggedValue::HueSaturationRange(range)) => *range, + _ => HueSaturationRange::Master, + }; + let f32_value = |parameter: &ParameterRef| match document_node.inputs.get(parameter.input_index).and_then(|input| input.as_value()) { + Some(TaggedValue::F32(value)) => *value, + _ => 0., + }; + + // The three sliders of the master, of the colorize mode, or of the selected range + let (hue, saturation, lightness): (ParameterRef, ParameterRef, ParameterRef) = if colorize_value { + (ColorizeHueInput.into(), ColorizeSaturationInput.into(), ColorizeLightnessInput.into()) + } else { + match range_value { + HueSaturationRange::Master => (HueInput.into(), SaturationInput.into(), LightnessInput.into()), + HueSaturationRange::Reds => (RedsHueInput.into(), RedsSaturationInput.into(), RedsLightnessInput.into()), + HueSaturationRange::Yellows => (YellowsHueInput.into(), YellowsSaturationInput.into(), YellowsLightnessInput.into()), + HueSaturationRange::Greens => (GreensHueInput.into(), GreensSaturationInput.into(), GreensLightnessInput.into()), + HueSaturationRange::Cyans => (CyansHueInput.into(), CyansSaturationInput.into(), CyansLightnessInput.into()), + HueSaturationRange::Blues => (BluesHueInput.into(), BluesSaturationInput.into(), BluesLightnessInput.into()), + HueSaturationRange::Magentas => (MagentasHueInput.into(), MagentasSaturationInput.into(), MagentasLightnessInput.into()), + } + }; + let range_values: Option<[ParameterRef; 4]> = match range_value { + HueSaturationRange::Reds => Some([RedsFalloffStartInput.into(), RedsRangeStartInput.into(), RedsRangeEndInput.into(), RedsFalloffEndInput.into()]), + HueSaturationRange::Yellows => Some([ + YellowsFalloffStartInput.into(), + YellowsRangeStartInput.into(), + YellowsRangeEndInput.into(), + YellowsFalloffEndInput.into(), + ]), + HueSaturationRange::Greens => Some([GreensFalloffStartInput.into(), GreensRangeStartInput.into(), GreensRangeEndInput.into(), GreensFalloffEndInput.into()]), + HueSaturationRange::Cyans => Some([CyansFalloffStartInput.into(), CyansRangeStartInput.into(), CyansRangeEndInput.into(), CyansFalloffEndInput.into()]), + HueSaturationRange::Blues => Some([BluesFalloffStartInput.into(), BluesRangeStartInput.into(), BluesRangeEndInput.into(), BluesFalloffEndInput.into()]), + HueSaturationRange::Magentas => Some([ + MagentasFalloffStartInput.into(), + MagentasRangeStartInput.into(), + MagentasRangeEndInput.into(), + MagentasFalloffEndInput.into(), + ]), + HueSaturationRange::Master => None, + }; + + let range_defaults: Option<[f64; 4]> = match range_value { + HueSaturationRange::Reds => Some([315., 345., 15., 45.]), + HueSaturationRange::Yellows => Some([15., 45., 75., 105.]), + HueSaturationRange::Greens => Some([75., 105., 135., 165.]), + HueSaturationRange::Cyans => Some([135., 165., 195., 225.]), + HueSaturationRange::Blues => Some([195., 225., 255., 285.]), + HueSaturationRange::Magentas => Some([255., 285., 315., 345.]), + HueSaturationRange::Master => None, + }; + + // Every saturation track fades from one middle gray. Colorize and a range head for the hue they act on. The master track favors none, + // sweeping in OkLCh at the gray's lightness the long way from azure (220°) to magenta (330°), skipping the dull blue and purple, as chroma climbs to the gamut. + use color::ColorSpace as _; + let gray_lightness = 0.7; + let oklch = |lightness: f32, chroma: f32, hue: f32| { + let [r, g, b] = color::Oklch::to_linear_srgb([lightness, chroma, hue]); + Color::from_rgbf32_unchecked(r.clamp(0., 1.), g.clamp(0., 1.), b.clamp(0., 1.)) + }; + // Fades from the gray to the pure hue at `turns` with the chroma rising evenly while the lightness eases to the hue's own + let toward_hue = |turns: f32| { + let pure = Color::from_hsva(turns.rem_euclid(1.), 1., 1., 1.); + let [pure_lightness, pure_chroma, pure_hue] = color::Oklch::from_linear_srgb([pure.r(), pure.g(), pure.b()]); + let stops = 24; + let stop = |i: i32| { + let t = i as f32 / stops as f32; + oklch(gray_lightness + (pure_lightness - gray_lightness) * t, pure_chroma * t, pure_hue) + }; + Gradient::from((0..=stops).map(stop).collect::>()) + }; + let saturation_track = if colorize_value { + toward_hue(f32_value(&hue) / 360.) + } else if let Some([_, range_start, range_end, _]) = &range_values { + let (start, end) = (f32_value(range_start), f32_value(range_end)); + let center = start + (end - start).rem_euclid(360.) / 2.; + toward_hue(center / 360.) + } else { + let in_gamut = |lightness: f32, chroma: f32, hue: f32| color::Oklch::to_linear_srgb([lightness, chroma, hue]).iter().all(|channel| (0.0..=1.).contains(channel)); + let gamut_chroma = |lightness: f32, hue: f32| { + let (mut inside, mut outside) = (0., 0.4); + for _ in 0..16 { + let chroma = (inside + outside) / 2.; + if in_gamut(lightness, chroma, hue) { + inside = chroma; + } else { + outside = chroma; + } + } + inside + }; + let stops = 80; + let stop = |i: i32| { + let t = i as f32 / stops as f32; + // A triangle wave gives every hue the same width, where a cosine would linger at its turnarounds + let bounce = (4. * t + 1.).rem_euclid(2.); + let along = if bounce <= 1. { bounce } else { 2. - bounce }; + let hue = 330. + 250. * along; + oklch(gray_lightness, gamut_chroma(gray_lightness, hue) * t, hue) + }; + Gradient::from((0..=stops).map(stop).collect::>()) + }; let hue_track = Gradient::from(vec![Color::RED, Color::YELLOW, Color::GREEN, Color::CYAN, Color::BLUE, Color::MAGENTA, Color::RED]); - // Saturation: gray to the fully saturated current hue - let saturation_track = Gradient::from(vec![Color::MIDDLE_GRAY, saturated_current_hue]); - // Lightness: black to white - let lightness_track = bw_track(); + let (hue_min, hue_max, hue_default) = if colorize_value { (0., 360., 24.) } else { (-180., 180., 0.) }; + let (saturation_min, saturation_default) = if colorize_value { (0., 25.) } else { (-100., 0.) }; - vec![ + // Colorize replaces the ranges, so while it is on the selector stays but grayed out and the selected range's edges hide + let mut range_info = ParameterWidgetsInfo::new(node_id, RangeInput, true, context); + range_info.exposable = false; + let mut layout = vec![enum_choice::().for_socket(range_info).disabled(colorize_value).property_row()]; + + layout.extend([ spectrum_slider_row( node_id, context, - HueShiftInput, - hue_track, + hue, + hue_track.clone(), Color::WHITE, - -180., - 180., - 0., - NumberInput::default().mode_increment().unit("°").min(-180.).max(180.), + hue_min, + hue_max, + hue_default, + NumberInput::default().mode_increment().unit("°").min(hue_min).max(hue_max), ), spectrum_slider_row( node_id, context, - SaturationShiftInput, + saturation, saturation_track, Color::WHITE, - -100., + saturation_min, 100., - 0., - NumberInput::default().mode_increment().unit("%").min(-100.).max(100.), + saturation_default, + NumberInput::default().mode_increment().unit("%").min(saturation_min).max(100.), ), spectrum_slider_row( node_id, context, - LightnessShiftInput, - lightness_track, + lightness, + bw_track(), Color::WHITE, -100., 100., 0., NumberInput::default().mode_increment().unit("%").min(-100.).max(100.), ), - ] + ]); + + // The selected range's edges share one rainbow as two split handles, a falloff half joined to a range half, with the range dashed between them + if !colorize_value && let (Some(values), Some(defaults)) = (range_values, range_defaults) { + let [falloff_start, range_start, range_end, falloff_end] = values; + let params = [ + SpectrumSectionParam::new(falloff_start, Color::WHITE, defaults[0], MarkerScale::Degrees).pair_with_next(), + SpectrumSectionParam::new(range_start, Color::WHITE, defaults[1], MarkerScale::Degrees).dash_to_next(), + SpectrumSectionParam::new(range_end, Color::WHITE, defaults[2], MarkerScale::Degrees).pair_with_next(), + SpectrumSectionParam::new(falloff_end, Color::WHITE, defaults[3], MarkerScale::Degrees), + ]; + build_shared_spectrum_section(node_id, context, &hue_track, ¶ms, &mut layout); + } + + let colorize = bool_widget(ParameterWidgetsInfo::new(node_id, ColorizeInput, true, context), CheckboxInput::default()); + layout.push(LayoutGroup::row(colorize)); + + layout } /// A single-marker `SpectrumInput` over `track` driving the number at `input_index`: the marker sits at `position`, double-click diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index a1373975d48..8a9bbb86c46 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -2221,6 +2221,17 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], inputs_count = 27; } + // Hue/Saturation gained colorize and the six hue ranges after its three master sliders + if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster::hue_saturation::IDENTIFIER) && inputs_count == 4 { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + document.network_interface.replace_implementation(node_id, network_path, &mut node_template); + let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + for (index, input) in old_inputs.iter().take(4).enumerate() { + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input.clone(), network_path); + } + inputs_count = 51; + } + if reference == DefinitionIdentifier::ProtoNode(graphene_std::repeat::repeat_on_points::IDENTIFIER) && inputs_count == 2 { let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); document.network_interface.replace_implementation(node_id, network_path, &mut node_template); diff --git a/frontend/src/components/widgets/inputs/SpectrumInput.svelte b/frontend/src/components/widgets/inputs/SpectrumInput.svelte index 122d4ef9d8f..e884405ea5a 100644 --- a/frontend/src/components/widgets/inputs/SpectrumInput.svelte +++ b/frontend/src/components/widgets/inputs/SpectrumInput.svelte @@ -25,6 +25,7 @@ export let allowInsert = true; export let allowDelete = true; export let allowReorder = true; + export let allowWrap = false; export let allowSelect = false; export let narrow = false; export let rangeSlider = false; @@ -50,9 +51,10 @@ // Set when a key-triggered reconcile inserts/removes the frozen copy, so the next pointer move skips emitting a `MoveMarker` // that would otherwise race the structural change before Rust has reported the dragged marker's new index. let skipNextMove = false; - // Set while a run of markers drags together: its bounds, the first's offset from the pointer, each member's gap from the first, and their start positions for cancelling. - let dragRun: { first: number; last: number; offset: number; spacings: number[]; restore: number[] } | undefined = undefined; - // The run a hovered marker or dashed link would carry, highlighted ahead of the drag. + // Set while a run of markers drags together: its bounds, the first's offset from the pointer, each member's gap from the first, + // their start positions for cancelling, and whether the run may cross the track's ends (only when dragged by a strip or dashed link). + let dragRun: { first: number; last: number; offset: number; spacings: number[]; restore: number[]; wrap: boolean } | undefined = undefined; + // The run a hovered marker, strip, or dashed link would carry, highlighted ahead of the drag. let hoverRun: [number, number] | undefined = undefined; // The marker being dragged, or the left marker of the interval when a midpoint is dragged, and which of the two it is. // Where selecting is allowed, these follow the selection, which Rust renumbers across structural changes. @@ -67,6 +69,29 @@ $: highlightedRun = dragRun !== undefined ? [dragRun.first, dragRun.last] : hoverRun !== undefined ? hoverRun : typeof dragIndex === "number" && !dragIsMidpoint ? [dragIndex, dragIndex] : undefined; + type MarkerShape = "Whole" | "Joined" | "Left" | "Right" | "Hidden"; + + // The whole marker and its left and right halves, each as an inner fill and a 1px border ring in a 12x12 box + const WHOLE_PATHS = { + fill: "M10,11.5H2c-0.8,0-1.5-0.7-1.5-1.5V6.8c0-0.4,0.2-0.8,0.4-1.1L6,0.7l5.1,5.1c0.3,0.3,0.4,0.7,0.4,1.1V10C11.5,10.8,10.8,11.5,10,11.5z", + border: + "M6,1.4L1.3,6.1C1.1,6.3,1,6.6,1,6.8V10c0,0.6,0.4,1,1,1h8c0.6,0,1-0.4,1-1V6.8c0-0.3-0.1-0.5-0.3-0.7L6,1.4" + + "M6,0l5.4,5.4C11.8,5.8,12,6.3,12,6.8V10c0,1.1-0.9,2-2,2H2c-1.1,0-2-0.9-2-2V6.8c0-0.5,0.2-1,0.6-1.4L6,0z", + }; + const LEFT_HALF_PATHS = { + fill: "M6,0.7V11.5H2c-0.8,0-1.5-0.7-1.5-1.5V6.8c0-0.4,0.2-0.8,0.4-1.1z", + border: "M6,0V12H2c-1.1,0-2-0.9-2-2V6.8c0-0.5,0.2-1,0.6-1.4L6,0zM5,2.4L1.3,6.1C1.1,6.3,1,6.6,1,6.8V10c0,0.6,0.4,1,1,1h3z", + }; + const RIGHT_HALF_PATHS = { + fill: "M6,0.7V11.5h4c0.8,0,1.5-0.7,1.5-1.5V6.8c0-0.4-0.2-0.8-0.4-1.1z", + border: "M6,0l5.4,5.4C11.8,5.8,12,6.3,12,6.8V10c0,1.1-0.9,2-2,2H6zM7,2.4V11h3c0.6,0,1-0.4,1-1V6.8c0-0.3-0.1-0.5-0.3-0.7z", + }; + function shapePaths(shape: MarkerShape): { fill: string; border: string } { + if (shape === "Left") return LEFT_HALF_PATHS; + if (shape === "Right") return RIGHT_HALF_PATHS; + return WHOLE_PATHS; + } + function emit(intent: SpectrumInputUpdate) { dispatch("update", intent); } @@ -84,7 +109,7 @@ // Hovering highlights what a drag would carry, except where selecting is allowed and hover keeps its lighter tint function markerPointerEnter(index: number) { if (allowSelect) return; - hoverRun = [index, index]; + hoverRun = markerShape(markers, index) === "Joined" ? [index, index + 1] : [index, index]; } function pointerPosition(e: MouseEvent, clamp = true): number | undefined { @@ -94,13 +119,27 @@ return clamp ? Math.max(0, Math.min(1, ratio)) : ratio; } - // Holds markers `first..=last` (spanning `spacing`) between their neighbors as they move to `position` - function holdBetweenNeighbors(first: number, last: number, spacing: number, position: number): number { + // Holds markers `first..=last` (spanning `spacing`, the first having begun its drag at `start`) between their neighbors as they move to `position`. + // On a wrapping track the hold works in the unwrapped frame around `start`, and the result then wraps back or, without `wrap`, stops at the ends. + function holdBetweenNeighbors(first: number, last: number, start: number, spacing: number, position: number, wrap: boolean): number { // Without selection nothing reports the dragged marker's new index after a reorder, so it stays between its neighbors - if (allowReorder && allowSelect) return position; - const lower = neighborBound(first, -1) ?? 0; - const upper = (neighborBound(last, 1) ?? 1) - spacing; - return Math.max(lower, Math.min(upper, position)); + const reorder = allowReorder && allowSelect; + let held = position; + + if (!reorder && !allowWrap) { + const lower = neighborBound(first, -1) ?? 0; + const upper = (neighborBound(last, 1) ?? 1) - spacing; + held = Math.max(lower, Math.min(upper, position)); + } else if (!reorder && last - first + 1 < markers.length) { + const lowerNeighbor = markers[(first + markers.length - 1) % markers.length].position; + const upperNeighbor = markers[(last + 1) % markers.length].position; + const lower = lowerNeighbor + Math.floor(start - lowerNeighbor); + const upper = upperNeighbor + Math.ceil(start + spacing - upperNeighbor) - spacing; + held = Math.max(lower, Math.min(upper, position)); + } + + if (!allowWrap) return held; + return wrap ? held - Math.floor(held) : Math.max(0, Math.min(1 - spacing, held)); } // The position of the nearest marker past `index` in the direction of `step` that bounds others, skipping any placed between its neighbors since those follow them instead @@ -111,16 +150,27 @@ return undefined; } - // The spans from each marker passing `linked` to its successor - function markerSpans(markers: SpectrumMarker[], linked: (marker: SpectrumMarker) => boolean): { index: number; left: number; width: number }[] { + // A marker paired with its successor draws as one marker split down the middle while the two coincide (the successor drawing nothing) and as a half once apart + function markerShape(markers: SpectrumMarker[], index: number): MarkerShape { + const marker = markers[index]; + const previous = markers[index - 1]; + const next = markers[index + 1]; + if (marker.pairedWithNext && next !== undefined) return next.position === marker.position ? "Joined" : "Left"; + if (previous?.pairedWithNext) return previous.position === marker.position ? "Hidden" : "Right"; + return "Whole"; + } + + // The spans from each marker passing `linked` to its successor, which on a wrapping track may cross the track's ends in two pieces + function markerSpans(markers: SpectrumMarker[], allowWrap: boolean, linked: (marker: SpectrumMarker) => boolean): { index: number; left: number; width: number }[] { const spans: { index: number; left: number; width: number }[] = []; markers.forEach((marker, index) => { const next = markers[index + 1]; if (!linked(marker) || next === undefined || next.position === marker.position) return; - const [left, right] = next.position > marker.position ? [marker.position, next.position] : [next.position, marker.position]; - spans.push({ index, left, width: right - left }); + if (next.position > marker.position) spans.push({ index, left: marker.position, width: next.position - marker.position }); + else if (allowWrap) spans.push({ index, left: marker.position, width: 1 - marker.position }, { index, left: 0, width: next.position }); + else spans.push({ index, left: next.position, width: marker.position - next.position }); }); return spans; @@ -147,6 +197,19 @@ if (disabled) return; if (e.button === BUTTON_LEFT) { + // A joined pair drags as a whole, unless Alt breaks off the half under the pointer + if (markerShape(markers, index) === "Joined") { + if (!e.altKey) { + beginRunDrag(e, index, index + 1, true, false); + return; + } + + const pointer = pointerPosition(e, false); + const half = pointer !== undefined && pointer >= markers[index].position ? index + 1 : index; + beginMarkerDrag(e, half); + return; + } + beginMarkerDrag(e, index); return; } @@ -170,16 +233,17 @@ } // Drags markers `first..=last` as one, keeping the pointer's offset from the first when `grabbed` and otherwise carrying the run to the pointer - function beginRunDrag(e: PointerEvent, first: number, last: number, grabbed: boolean) { - const pointer = pointerPosition(e); + function beginRunDrag(e: PointerEvent, first: number, last: number, grabbed: boolean, wrap: boolean) { + const pointer = pointerPosition(e, !wrap); if (pointer === undefined) return; const start = markers[first].position; + // Each member's forward gap from the first, so a run straddling the track's ends stays contiguous const spacings: number[] = []; const restore: number[] = []; for (let index = first; index <= last; index += 1) { const position = markers[index].position; - spacings.push(position - start); + spacings.push(allowWrap && position < start ? position + 1 - start : position - start); restore.push(position); } @@ -190,26 +254,38 @@ dragMoved = false; duplicateRequested = false; duplicateActive = false; - dragRun = { first, last, offset: grabbed ? start - pointer : 0, spacings, restore }; + dragRun = { first, last, offset: grabbed ? start - pointer : 0, spacings, restore, wrap }; setActive(first, false); addEvents(); } - // The run a dashed link from `index` carries: the two markers it joins + function stripPointerDown(e: PointerEvent, leftIndex: number) { + if (disabled || e.button !== BUTTON_LEFT) return; + beginRunDrag(e, leftIndex, leftIndex + 1, true, allowWrap); + } + + // The run a dashed link from `index` carries: the two markers it joins plus any split-handle halves attached to them function dashedRun(index: number): [number, number] { - return [index, index + 1]; + const first = markers[index - 1]?.pairedWithNext ? index - 1 : index; + const last = markers[index + 1]?.pairedWithNext && markers[index + 2] !== undefined ? index + 2 : index + 1; + return [first, last]; } function dashPointerDown(e: PointerEvent, index: number) { if (disabled || e.button !== BUTTON_LEFT) return; const [first, last] = dashedRun(index); - beginRunDrag(e, first, last, true); + beginRunDrag(e, first, last, true, allowWrap); } - // Picks up the marker at `index` and carries it to the pointer + // Picks up the marker at `index`, or the whole pair it is the joined half of, and carries it to the pointer function pickUpMarker(e: PointerEvent, index: number) { - beginMarkerDrag(e, index); - moveActiveMarker(e); + if (markerShape(markers, index) === "Joined") { + beginRunDrag(e, index, index + 1, false, false); + moveRun(e); + } else { + beginMarkerDrag(e, index); + moveActiveMarker(e); + } } function midpointPointerDown(e: PointerEvent, index: number) { @@ -231,7 +307,8 @@ function markerDoubleClick(index: number) { if (disabled || dragMoved) return; - emit({ ResetMarker: { index } }); + if (markerShape(markers, index) === "Joined") resetRun(index, index + 1); + else emit({ ResetMarker: { index } }); } function resetRun(first: number, last: number) { @@ -368,7 +445,7 @@ let position = pointerPosition(e); if (position === undefined) return; - position = holdBetweenNeighbors(dragIndex, dragIndex, 0, position); + position = holdBetweenNeighbors(dragIndex, dragIndex, dragRestorePosition ?? position, 0, position, false); dragMoved = true; if (!dragInsertedMarker) dispatch("dragging", true); @@ -376,22 +453,25 @@ } function moveRun(e: PointerEvent) { - if (disabled || dragRun === undefined) return; + if (disabled || dragRun === undefined || dragRestorePosition === undefined) return; if (e.buttons === 0) { endDrag(); return; } - const { first, last, offset, spacings } = dragRun; - const pointer = pointerPosition(e); + const { first, last, offset, spacings, wrap } = dragRun; + const pointer = pointerPosition(e, !wrap); if (pointer === undefined) return; const span = spacings[spacings.length - 1]; - const start = holdBetweenNeighbors(first, last, span, pointer + offset); + const start = holdBetweenNeighbors(first, last, dragRestorePosition, span, pointer + offset, wrap); dragMoved = true; dispatch("dragging", true); - spacings.forEach((spacing, i) => emit({ MoveMarker: { index: first + i, position: start + spacing } })); + spacings.forEach((spacing, i) => { + const position = start + spacing; + emit({ MoveMarker: { index: first + i, position: wrap ? position - Math.floor(position) : position } }); + }); } function moveActiveMidpoint(e: PointerEvent) { @@ -552,7 +632,8 @@ return positions; } $: midpointPositions = diamondPositions(markers, showMidpoints, trackCyclic, trackInterpolation); - $: dashes = markerSpans(markers, (marker) => marker.dashedToNext); + $: strips = markerSpans(markers, allowWrap, (marker) => marker.pairedWithNext); + $: dashes = markerSpans(markers, allowWrap, (marker) => marker.dashedToNext); onMount(() => { document.addEventListener("keydown", deleteShortcut); @@ -615,10 +696,27 @@ on:dblclick={() => resetRun(...dashedRun(dash.index))} > {/each} + {#each strips as strip} +
= highlightedRun[0] && strip.index < highlightedRun[1]} + style:--span-left={strip.left} + style:--span-width={strip.width} + style:--span-color={markers[strip.index].handleColorCSS} + on:pointerenter={() => (hoverRun = [strip.index, strip.index + 1])} + on:pointerleave={() => (hoverRun = undefined)} + on:pointerdown={(e) => stripPointerDown(e, strip.index)} + on:dblclick={() => resetRun(strip.index, strip.index + 1)} + >
+ {/each} {#each markers as marker, index} - {#if marker.position >= 0 && marker.position <= 1} + {@const shape = markerShape(markers, index)} + {@const paths = shapePaths(shape)} + {#if shape !== "Hidden" && marker.position >= 0 && marker.position <= 1} = highlightedRun[0] && index <= highlightedRun[1]} style:--marker-position={marker.position} style:--marker-color={marker.handleColorCSS} @@ -630,14 +728,14 @@ xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" > - + {#if disabled} - + + {/if} + + {#if shape === "Joined"} + {/if} - {/if} {/each} @@ -768,6 +866,19 @@ pointer-events: auto; } + .pair-strip { + position: absolute; + top: 4px; + left: calc(var(--span-left) * 100%); + width: calc(var(--span-width) * 100%); + height: 8px; + box-sizing: border-box; + border-top: 1px solid var(--color-5-dullgray); + border-bottom: 1px solid var(--color-5-dullgray); + background: rgb(from var(--span-color) r g b / 0.5); + pointer-events: auto; + } + .marker { position: absolute; transform: translateX(-50%); @@ -779,6 +890,15 @@ padding-top: 12px; margin-top: -12px; + // A half's empty side neither draws nor takes the pointer + &.left { + clip-path: inset(0 50% 0 0); + } + + &.right { + clip-path: inset(0 0 0 50%); + } + .inner-fill { fill: var(--marker-color); } @@ -807,9 +927,24 @@ --link-color: var(--color-e-nearwhite); } + &.disabled .marker-track .pair-strip { + border-color: var(--color-4-dimgray); + background-image: linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)); + } + + &:not(.disabled) .marker-track .pair-strip { + &:not(.active):hover { + border-color: var(--color-6-lowergray); + } + + &.active { + border-color: var(--color-e-nearwhite); + } + } + &:not(.disabled) .marker-track .marker { &:not(.active) { - .inner-fill:hover + .outer-border, + .inner-fill:hover ~ .outer-border, .outer-border:hover { fill: var(--color-6-lowergray); } @@ -818,7 +953,9 @@ &.active { z-index: 1; - .inner-fill { + // The split line shares the halo, or its near-white would vanish into a light fill + .inner-fill, + .split-line { filter: drop-shadow(0 0 1px var(--color-2-mildblack)) drop-shadow(0 0 1px var(--color-2-mildblack)); } @@ -827,7 +964,7 @@ fill: var(--color-e-nearwhite); } - .inner-fill:hover + .outer-border, + .inner-fill:hover ~ .outer-border, .outer-border:hover { fill: var(--color-f-white); } diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 7e5db6c200a..215849c8d3f 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -565,6 +565,7 @@ tagged_value! { SelectiveColorChoice(raster_nodes::adjustments::SelectiveColorChoice), TonalRange(raster_nodes::adjustments::TonalRange), AdjustmentChannel(raster_nodes::adjustments::AdjustmentChannel), + HueSaturationRange(raster_nodes::adjustments::HueSaturationRange), GridType(vector::misc::GridType), ArcType(vector::misc::ArcType), RowsOrColumns(vector::misc::RowsOrColumns), diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 14ee46e2d57..c639fe2a7d8 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -358,6 +358,7 @@ fn node_registry() -> HashMap>( image } +#[repr(u32)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "std", derive(dyn_any::DynAny))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, BufferStruct, FromPrimitive, IntoPrimitive)] +#[widget(Dropdown)] +pub enum HueSaturationRange { + #[default] + Master, + Reds, + Yellows, + Greens, + Cyans, + Blues, + Magentas, +} + +/// HSL of gamma-encoded channels: hue in degrees, saturation and lightness in 0..1. +fn gamma_rgb_to_hsl(r: f32, g: f32, b: f32) -> [f32; 3] { + let maximum = r.max(g).max(b); + let minimum = r.min(g).min(b); + let chroma = maximum - minimum; + let lightness = (maximum + minimum) / 2.; + if chroma <= 0. { + return [0., 0., lightness]; + } + + let saturation = chroma / (1. - (2. * lightness - 1.).abs()).max(1e-6); + [hexagon_hue_degrees(r, g, b), saturation.min(1.), lightness] +} + +/// Hexagon hue in degrees of three channels in any encoding, 0 for gray. +fn hexagon_hue_degrees(r: f32, g: f32, b: f32) -> f32 { + let maximum = r.max(g).max(b); + let chroma = maximum - r.min(g).min(b); + if chroma <= 0. { + return 0.; + } + + let sector = if maximum == r { + wrap_positive((g - b) / chroma, 6.) + } else if maximum == g { + (b - r) / chroma + 2. + } else { + (r - g) / chroma + 4. + }; + sector * 60. +} + +/// `value` wrapped into the range from 0 to `modulus`. +fn wrap_positive(value: f32, modulus: f32) -> f32 { + value - (value / modulus).floor() * modulus +} + +/// Gamma-encoded channels from a hue in degrees and saturation and lightness in 0..1. +fn hsl_to_gamma_rgb(hue: f32, saturation: f32, lightness: f32) -> [f32; 3] { + let chroma = (1. - (2. * lightness - 1.).abs()) * saturation; + let sector = wrap_positive(hue, 360.) / 60.; + let x = chroma * (1. - (wrap_positive(sector, 2.) - 1.).abs()); + let (r, g, b) = if sector < 1. { + (chroma, x, 0.) + } else if sector < 2. { + (x, chroma, 0.) + } else if sector < 3. { + (0., chroma, x) + } else if sector < 4. { + (0., x, chroma) + } else if sector < 5. { + (x, 0., chroma) + } else { + (chroma, 0., x) + }; + let m = lightness - chroma / 2.; + + [(r + m).clamp(0., 1.), (g + m).clamp(0., 1.), (b + m).clamp(0., 1.)] +} + +/// One set of Hue/Saturation sliders: a hue shift in degrees and saturation and lightness amounts in -1..1. +#[derive(Clone, Copy)] +struct HueSaturationSettings { + hue: f32, + saturation: f32, + lightness: f32, +} + +impl HueSaturationSettings { + fn new(hue: f32, saturation_percent: f32, lightness_percent: f32) -> Self { + Self { + hue, + saturation: (saturation_percent / 100.).clamp(-1., 1.), + lightness: (lightness_percent / 100.).clamp(-1., 1.), + } + } +} + +/// A hue range with its falloff: full weight from `range_start` to `range_end`, fading linearly to zero at the falloff ends. +#[derive(Clone, Copy)] +struct HueSaturationRangeSettings { + falloff_start: f32, + range_start: f32, + range_end: f32, + falloff_end: f32, + settings: HueSaturationSettings, +} + +impl HueSaturationRangeSettings { + fn new(falloff_start: f32, range_start: f32, range_end: f32, falloff_end: f32, settings: HueSaturationSettings) -> Self { + // For PSD interop, each edge rounds to 1536 hue units per turn over 359 rather than 360 degrees, landing up to a degree late + let edge = |degrees: f32| (degrees * 1536. / 359.).round() * 360. / 1536.; + + Self { + falloff_start: edge(falloff_start), + range_start: edge(range_start), + range_end: edge(range_end), + falloff_end: edge(falloff_end), + settings, + } + } + + fn weight(&self, hue: f32) -> f32 { + let distance = |from: f32, to: f32| wrap_positive(to - from, 360.); + if distance(self.range_start, hue) <= distance(self.range_start, self.range_end) { + return 1.; + } + let start_falloff = distance(self.falloff_start, self.range_start); + let end_falloff = distance(self.range_end, self.falloff_end); + if distance(self.falloff_start, hue) < start_falloff { + return distance(self.falloff_start, hue) / start_falloff; + } + if distance(self.range_end, hue) < end_falloff { + return 1. - distance(self.range_end, hue) / end_falloff; + } + 0. + } +} + +/// The six ranges' combined effect on one pixel, gathered before the master sliders apply. +struct HueSaturationRangeEffect { + hue_shift: f32, + saturation_factor: f32, + fully_saturate: bool, + rgb: [f32; 3], +} + +impl HueSaturationRangeEffect { + fn apply(&mut self, range: &HueSaturationRangeSettings, original_hue: f32, original_saturation: f32) { + // Range weights come from the original hue, and grays belong to no range + let weight = if original_saturation > 0. { range.weight(original_hue) } else { 0. }; + if weight <= 0. { + return; + } + + self.hue_shift += range.settings.hue * weight; + // For PSD interop, +100 saturates fully from the very edge of the falloff rather than scaling with the weight + if range.settings.saturation >= 1. { + self.fully_saturate = true; + } else { + self.saturation_factor *= 1. + (saturation_gain(range.settings.saturation) - 1.) * weight; + } + self.rgb = lightness_toward_max_or_min(self.rgb, range.settings.lightness * weight); + } +} + +/// The factor a saturation amount in -1..1 applies to HSL saturation, quantized for PSD interop: 1 - trunc(256 a) / 256 below zero and floor(65280 / (255 - trunc(254 a))) / 256 above. +fn saturation_gain(amount: f32) -> f32 { + if amount < 0. { + 1. - (-amount * 256.).trunc() / 256. + } else { + (65280. / (255. - (amount * 254.).trunc())).floor() / 256. + } +} + +/// Blends toward white for a positive amount and toward black for a negative one, as the master lightness slider does. +fn lightness_toward_white_or_black(value: f32, amount: f32) -> f32 { + if amount >= 0. { value + (1. - value) * amount } else { value * (1. + amount) } +} + +/// A range's lightness moves the channels toward the color's own maximum (positive) or minimum (negative) instead. +fn lightness_toward_max_or_min(rgb: [f32; 3], amount: f32) -> [f32; 3] { + let maximum = rgb[0].max(rgb[1]).max(rgb[2]); + let minimum = rgb[0].min(rgb[1]).min(rgb[2]); + let toward = if amount >= 0. { maximum } else { minimum }; + let blend = |value: f32| value + (toward - value) * amount.abs(); + [blend(rgb[0]), blend(rgb[1]), blend(rgb[2])] +} + // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27hue%20%27%20%3D%20Old,saturation%2C%20Photoshop%205.0 // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=0%20%3D%20Use%20other.-,Hue/Saturation,-Hue/Saturation%20settings +// +// TODO: Residuals in 8-bit PSD interop: the byte-hue colorize table, the colorize lightness slider (up to 2.5 levels), and the range edges (a few tenths of a degree) #[node_macro::node(name("Hue/Saturation"), category("Raster: Adjustment"), properties("hue_saturation_properties"), shader_node(PerPixelAdjust))] fn hue_saturation>( _: impl Ctx, @@ -710,27 +898,212 @@ fn hue_saturation>( )] #[gpu_image] input: Item, - hue_shift: Item, - saturation_shift: Item, - lightness_shift: Item, + hue: Item, + saturation: Item, + lightness: Item, + colorize: Item, + #[name("(Colorize) Hue")] + #[default(24.)] + colorize_hue: Item, + #[name("(Colorize) Saturation")] + #[default(25.)] + colorize_saturation: Item, + #[name("(Colorize) Lightness")] colorize_lightness: Item, + #[name("(Reds) Hue")] reds_hue: Item, + #[name("(Reds) Saturation")] reds_saturation: Item, + #[name("(Reds) Lightness")] reds_lightness: Item, + #[name("(Reds) Falloff Start")] + #[default(315.)] + reds_falloff_start: Item, + #[name("(Reds) Range Start")] + #[default(345.)] + reds_range_start: Item, + #[name("(Reds) Range End")] + #[default(15.)] + reds_range_end: Item, + #[name("(Reds) Falloff End")] + #[default(45.)] + reds_falloff_end: Item, + #[name("(Yellows) Hue")] yellows_hue: Item, + #[name("(Yellows) Saturation")] yellows_saturation: Item, + #[name("(Yellows) Lightness")] yellows_lightness: Item, + #[name("(Yellows) Falloff Start")] + #[default(15.)] + yellows_falloff_start: Item, + #[name("(Yellows) Range Start")] + #[default(45.)] + yellows_range_start: Item, + #[name("(Yellows) Range End")] + #[default(75.)] + yellows_range_end: Item, + #[name("(Yellows) Falloff End")] + #[default(105.)] + yellows_falloff_end: Item, + #[name("(Greens) Hue")] greens_hue: Item, + #[name("(Greens) Saturation")] greens_saturation: Item, + #[name("(Greens) Lightness")] greens_lightness: Item, + #[name("(Greens) Falloff Start")] + #[default(75.)] + greens_falloff_start: Item, + #[name("(Greens) Range Start")] + #[default(105.)] + greens_range_start: Item, + #[name("(Greens) Range End")] + #[default(135.)] + greens_range_end: Item, + #[name("(Greens) Falloff End")] + #[default(165.)] + greens_falloff_end: Item, + #[name("(Cyans) Hue")] cyans_hue: Item, + #[name("(Cyans) Saturation")] cyans_saturation: Item, + #[name("(Cyans) Lightness")] cyans_lightness: Item, + #[name("(Cyans) Falloff Start")] + #[default(135.)] + cyans_falloff_start: Item, + #[name("(Cyans) Range Start")] + #[default(165.)] + cyans_range_start: Item, + #[name("(Cyans) Range End")] + #[default(195.)] + cyans_range_end: Item, + #[name("(Cyans) Falloff End")] + #[default(225.)] + cyans_falloff_end: Item, + #[name("(Blues) Hue")] blues_hue: Item, + #[name("(Blues) Saturation")] blues_saturation: Item, + #[name("(Blues) Lightness")] blues_lightness: Item, + #[name("(Blues) Falloff Start")] + #[default(195.)] + blues_falloff_start: Item, + #[name("(Blues) Range Start")] + #[default(225.)] + blues_range_start: Item, + #[name("(Blues) Range End")] + #[default(255.)] + blues_range_end: Item, + #[name("(Blues) Falloff End")] + #[default(285.)] + blues_falloff_end: Item, + #[name("(Magentas) Hue")] magentas_hue: Item, + #[name("(Magentas) Saturation")] magentas_saturation: Item, + #[name("(Magentas) Lightness")] magentas_lightness: Item, + #[name("(Magentas) Falloff Start")] + #[default(255.)] + magentas_falloff_start: Item, + #[name("(Magentas) Range Start")] + #[default(285.)] + magentas_range_start: Item, + #[name("(Magentas) Range End")] + #[default(315.)] + magentas_range_end: Item, + #[name("(Magentas) Falloff End")] + #[default(345.)] + magentas_falloff_end: Item, + _range: Item, ) -> Item { let mut input = input; - let hue_shift = hue_shift.into_element(); - let saturation_shift = saturation_shift.into_element(); - let lightness_shift = lightness_shift.into_element(); + let master = HueSaturationSettings::new(hue.into_element(), saturation.into_element(), lightness.into_element()); + let colorize = colorize.into_element(); + let colorize_settings = HueSaturationSettings::new(colorize_hue.into_element(), colorize_saturation.into_element(), colorize_lightness.into_element()); + let (reds, yellows, greens, cyans, blues, magentas) = ( + HueSaturationRangeSettings::new( + reds_falloff_start.into_element(), + reds_range_start.into_element(), + reds_range_end.into_element(), + reds_falloff_end.into_element(), + HueSaturationSettings::new(reds_hue.into_element(), reds_saturation.into_element(), reds_lightness.into_element()), + ), + HueSaturationRangeSettings::new( + yellows_falloff_start.into_element(), + yellows_range_start.into_element(), + yellows_range_end.into_element(), + yellows_falloff_end.into_element(), + HueSaturationSettings::new(yellows_hue.into_element(), yellows_saturation.into_element(), yellows_lightness.into_element()), + ), + HueSaturationRangeSettings::new( + greens_falloff_start.into_element(), + greens_range_start.into_element(), + greens_range_end.into_element(), + greens_falloff_end.into_element(), + HueSaturationSettings::new(greens_hue.into_element(), greens_saturation.into_element(), greens_lightness.into_element()), + ), + HueSaturationRangeSettings::new( + cyans_falloff_start.into_element(), + cyans_range_start.into_element(), + cyans_range_end.into_element(), + cyans_falloff_end.into_element(), + HueSaturationSettings::new(cyans_hue.into_element(), cyans_saturation.into_element(), cyans_lightness.into_element()), + ), + HueSaturationRangeSettings::new( + blues_falloff_start.into_element(), + blues_range_start.into_element(), + blues_range_end.into_element(), + blues_falloff_end.into_element(), + HueSaturationSettings::new(blues_hue.into_element(), blues_saturation.into_element(), blues_lightness.into_element()), + ), + HueSaturationRangeSettings::new( + magentas_falloff_start.into_element(), + magentas_range_start.into_element(), + magentas_range_end.into_element(), + magentas_falloff_end.into_element(), + HueSaturationSettings::new(magentas_hue.into_element(), magentas_saturation.into_element(), magentas_lightness.into_element()), + ), + ); input.element_mut().adjust(|color| { - // HSL operates on gamma-space channels - let [hue, saturation, lightness, alpha] = color.to_hsla(); - - Color::from_hsla( - (hue + hue_shift / 360.) % 1., - // TODO: Improve the way saturation works (it's slightly off) - (saturation + saturation_shift / 100.).clamp(0., 1.), - // TODO: Fix the way lightness works (it's very off) - (lightness + lightness_shift / 100.).clamp(0., 1.), - alpha, - ) + let [r, g, b, alpha] = color.to_gamma_srgb_channels(); + + if colorize { + let [_, _, lightness] = gamma_rgb_to_hsl(r, g, b); + let lightness = lightness_toward_white_or_black(lightness, colorize_settings.lightness); + let saturation = colorize_settings.saturation.max(0.); + let [r, g, b] = hsl_to_gamma_rgb(colorize_settings.hue, saturation, lightness); + return Color::from_gamma_srgb_channels(r, g, b, alpha); + } + + // Each range weights its sliders by its falloff around the original hue: hue shifts add and saturation gains multiply + let [original_hue, original_saturation, _] = gamma_rgb_to_hsl(r, g, b); + let mut effect = HueSaturationRangeEffect { + hue_shift: master.hue, + saturation_factor: 1., + fully_saturate: false, + rgb: [r, g, b], + }; + effect.apply(&reds, original_hue, original_saturation); + effect.apply(&yellows, original_hue, original_saturation); + effect.apply(&greens, original_hue, original_saturation); + effect.apply(&cyans, original_hue, original_saturation); + effect.apply(&blues, original_hue, original_saturation); + effect.apply(&magentas, original_hue, original_saturation); + let HueSaturationRangeEffect { + hue_shift, + mut saturation_factor, + mut fully_saturate, + rgb, + } = effect; + if master.saturation >= 1. { + fully_saturate = true; + } else { + saturation_factor *= saturation_gain(master.saturation); + } + + // The master lightness blends toward white or black before the hue and saturation, which work in HSL of the gamma channels + let rgb = [ + lightness_toward_white_or_black(rgb[0], master.lightness), + lightness_toward_white_or_black(rgb[1], master.lightness), + lightness_toward_white_or_black(rgb[2], master.lightness), + ]; + let [hue, saturation, lightness] = gamma_rgb_to_hsl(rgb[0], rgb[1], rgb[2]); + let saturation = if saturation <= 0. { + 0. + } else if fully_saturate { + 1. + } else { + (saturation * saturation_factor).min(1.) + }; + let [r, g, b] = hsl_to_gamma_rgb(hue + hue_shift, saturation, lightness); + + Color::from_gamma_srgb_channels(r, g, b, alpha) }); input } @@ -1573,8 +1946,8 @@ fn color_balance>( #[cfg(feature = "std")] mod _graphene_hash_impls { use super::{ - AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DesaturateMethod, DomainWarpType, FractalType, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, - SelectiveColorChoice, TonalRange, + AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DesaturateMethod, DomainWarpType, FractalType, HueSaturationRange, NoiseType, RedGreenBlue, RedGreenBlueAlpha, + RelativeAbsolute, SelectiveColorChoice, TonalRange, }; graphene_hash::impl_via_hash!( DesaturateMethod, @@ -1589,6 +1962,7 @@ mod _graphene_hash_impls { SelectiveColorChoice, AdjustmentChannel, TonalRange, + HueSaturationRange ); } @@ -1603,6 +1977,12 @@ mod tests { } } + fn assert_close_with_label(actual: [f32; 3], expected: [f32; 3], label: &str) { + for channel in 0..3 { + assert!((actual[channel] - expected[channel]).abs() <= 1.5, "{label}: expected {expected:?}, got {actual:?}"); + } + } + /// Runs Levels with composite and red records given as [black, white, gamma, output black, output white] with 0..255 points /// on one gamma-space gray value (0..255), returning the red and green results on the same scale. fn run_levels(value: f32, composite: [f32; 5], red: [f32; 5]) -> [f32; 2] { @@ -1667,6 +2047,162 @@ mod tests { } } + /// Runs the node on one gamma-space RGB value (0..255) with the master sliders, colorize, and one range's sliders at + /// its default range values, returning the gamma-space result on the same scale. + fn run_hue_saturation(input: [f32; 3], master: [f32; 3], colorize: Option<[f32; 3]>, range: Option<(HueSaturationRange, [f32; 3])>) -> [f32; 3] { + let pixel = Color::from_gamma_srgb_channels(input[0] / 255., input[1] / 255., input[2] / 255., 1.); + let colorize_values = colorize.unwrap_or([24., 25., 0.]); + let range_values = |which: HueSaturationRange| match range { + Some((selected, values)) if selected == which => values, + _ => [0., 0., 0.], + }; + let [reds, yellows, greens, cyans, blues, magentas] = [ + range_values(HueSaturationRange::Reds), + range_values(HueSaturationRange::Yellows), + range_values(HueSaturationRange::Greens), + range_values(HueSaturationRange::Cyans), + range_values(HueSaturationRange::Blues), + range_values(HueSaturationRange::Magentas), + ]; + let result = hue_saturation( + (), + Item::new_from_element(pixel), + master[0].into(), + master[1].into(), + master[2].into(), + colorize.is_some().into(), + colorize_values[0].into(), + colorize_values[1].into(), + colorize_values[2].into(), + reds[0].into(), + reds[1].into(), + reds[2].into(), + 315_f32.into(), + 345_f32.into(), + 15_f32.into(), + 45_f32.into(), + yellows[0].into(), + yellows[1].into(), + yellows[2].into(), + 15_f32.into(), + 45_f32.into(), + 75_f32.into(), + 105_f32.into(), + greens[0].into(), + greens[1].into(), + greens[2].into(), + 75_f32.into(), + 105_f32.into(), + 135_f32.into(), + 165_f32.into(), + cyans[0].into(), + cyans[1].into(), + cyans[2].into(), + 135_f32.into(), + 165_f32.into(), + 195_f32.into(), + 225_f32.into(), + blues[0].into(), + blues[1].into(), + blues[2].into(), + 195_f32.into(), + 225_f32.into(), + 255_f32.into(), + 285_f32.into(), + magentas[0].into(), + magentas[1].into(), + magentas[2].into(), + 255_f32.into(), + 285_f32.into(), + 315_f32.into(), + 345_f32.into(), + HueSaturationRange::Master.into(), + ); + let [r, g, b, _] = result.into_element().to_gamma_srgb_channels(); + [r * 255., g * 255., b * 255.] + } + + #[test] + fn hue_saturation_master_sliders_rotate_scale_and_lighten() { + assert_close_with_label(run_hue_saturation([200., 50., 50.], [30., 0., 0.], None, None), [200., 125., 50.], "hue +30"); + assert_close_with_label(run_hue_saturation([60., 120., 200.], [30., 0., 0.], None, None), [70., 60., 200.], "hue +30 on blue"); + assert_close_with_label(run_hue_saturation([200., 50., 50.], [0., 50., 0.], None, None), [250., 0., 0.], "saturation +50"); + assert_close_with_label(run_hue_saturation([60., 120., 200.], [0., 50., 0.], None, None), [5., 111., 255.], "saturation +50 on blue"); + assert_close_with_label(run_hue_saturation([200., 50., 50.], [0., -50., 0.], None, None), [162., 87., 87.], "saturation -50"); + assert_close_with_label(run_hue_saturation([30., 200., 90.], [0., -50., 0.], None, None), [72., 157., 102.], "saturation -50 on green"); + assert_close_with_label(run_hue_saturation([200., 50., 50.], [0., 0., 50.], None, None), [227., 152., 152.], "lightness +50"); + assert_close_with_label(run_hue_saturation([250., 0., 130.], [0., 0., 50.], None, None), [252., 127., 192.], "lightness +50 on magenta"); + assert_close_with_label(run_hue_saturation([60., 120., 200.], [0., 0., -50.], None, None), [30., 60., 100.], "lightness -50"); + assert_close_with_label(run_hue_saturation([200., 50., 50.], [90., 60., -40.], None, None), [74., 150., 0.], "combined"); + assert_close_with_label(run_hue_saturation([30., 200., 90.], [90., 60., -40.], None, None), [0., 20., 138.], "combined on green"); + assert_close_with_label(run_hue_saturation([150., 150., 150.], [90., 60., -40.], None, None), [90., 90., 90.], "combined on gray"); + } + + #[test] + fn hue_saturation_colorize_rebuilds_the_exact_hsl_color() { + assert_close_with_label(run_hue_saturation([200., 50., 50.], [0., 0., 0.], Some([240., 100., 0.]), None), [0., 0., 250.], "colorize 240/100/0"); + assert_close_with_label(run_hue_saturation([150., 150., 150.], [0., 0., 0.], Some([240., 100., 0.]), None), [45., 45., 255.], "colorize on gray"); + assert_close_with_label(run_hue_saturation([30., 200., 90.], [0., 0., 0.], Some([60., 100., 0.]), None), [230., 230., 0.], "colorize 60/100/0"); + assert_close_with_label( + run_hue_saturation([150., 150., 150.], [0., 0., 0.], Some([30., 60., -30.]), None), + [168., 104., 42.], + "colorize 30/60/-30", + ); + assert_close_with_label(run_hue_saturation([120., 0., 30.], [0., 0., 0.], Some([30., 60., -30.]), None), [67., 42., 17.], "colorize dark"); + assert_close_with_label( + run_hue_saturation([255., 0., 0.], [0., 0., 0.], Some([20., 100., 0.]), None), + [255., 85., 0.], + "colorize 20 is the exact HSL color", + ); + assert_close_with_label(run_hue_saturation([255., 0., 0.], [0., 0., 0.], Some([160., 100., 0.]), None), [0., 255., 170.], "colorize 160"); + assert_close_with_label(run_hue_saturation([255., 0., 0.], [0., 0., 0.], Some([340., 100., 0.]), None), [255., 0., 85.], "colorize 340"); + assert_close_with_label( + run_hue_saturation([255., 0., 0.], [0., 0., 0.], Some([-20., 100., 0.]), None), + [255., 0., 85.], + "colorize -20 wraps to 340", + ); + } + + #[test] + fn hue_saturation_ranges_weight_their_sliders_by_falloff() { + let reds = HueSaturationRange::Reds; + assert_close_with_label( + run_hue_saturation([200., 50., 50.], [0., 0., 0.], None, Some((reds, [60., 0., 0.]))), + [199., 200., 50.], + "reds hue +60 inside", + ); + assert_close_with_label( + run_hue_saturation([60., 120., 200.], [0., 0., 0.], None, Some((reds, [60., 0., 0.]))), + [60., 120., 200.], + "reds hue +60 outside", + ); + assert_close_with_label( + run_hue_saturation([200., 50., 50.], [0., 0., 0.], None, Some((reds, [0., 100., 0.]))), + [250., 0., 0.], + "reds saturation +100", + ); + assert_close_with_label( + run_hue_saturation([200., 50., 50.], [0., 0., 0.], None, Some((reds, [0., 0., -50.]))), + [125., 50., 50.], + "reds lightness -50", + ); + assert_close_with_label( + run_hue_saturation([255., 65., 0.], [0., 0., 0.], None, Some((reds, [0., 0., -50.]))), + [129., 33., 0.], + "reds lightness -50 near the edge", + ); + assert_close_with_label( + run_hue_saturation([30., 200., 90.], [0., 0., 0.], None, Some((HueSaturationRange::Greens, [0., 50., -25.]))), + [0., 196., 69.], + "greens saturation and lightness", + ); + assert_close_with_label( + run_hue_saturation([200., 50., 50.], [30., 0., 0.], None, Some((reds, [0., 50., 0.]))), + [250., 125., 0.], + "master hue with a range saturation", + ); + } + #[test] fn invert_flips_straight_channels_and_keeps_alpha() { let color = Color::from_gamma_srgb_channels(1., 0.25, 0., 0.5); From 730d09d78a11877c12287d30178360fe06ebe4bd Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 15 Sep 2026 17:58:25 -0700 Subject: [PATCH 37/46] Add a saturation input to the 'Vibrance' node and rework both axes to work in linear light (#4537) * Add a saturation input to the 'Vibrance' node and rework both axes to work in linear light * Refine the sliders * Clarify some comments --- .../document/node_graph/node_properties.rs | 22 +- .../messages/portfolio/document_migration.rs | 11 + node-graph/nodes/raster/src/adjustments.rs | 188 ++++++++++-------- 3 files changed, 129 insertions(+), 92 deletions(-) diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 8291e0bfe80..8e38e614058 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -2095,18 +2095,16 @@ pub(crate) fn threshold_properties(node_id: NodeId, context: &mut NodeProperties pub(crate) fn vibrance_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { use graphene_std::raster::vibrance::*; - let track = Gradient::from(vec![Color::MIDDLE_GRAY, Color::RED]); - vec![spectrum_slider_row( - node_id, - context, - VibranceInput, - track, - Color::WHITE, - -100., - 100., - 0., - NumberInput::default().mode_increment().unit("%").min(-100.).max(100.), - )] + let number_input = NumberInput::default().mode_increment().unit("%").min(-100.).max(100.); + let slider = SliderRange { + min: -100., + max: 100., + default: Some(0.), + }; + let vibrance = range_slider_widget(ParameterWidgetsInfo::new(node_id, VibranceInput, true, context), number_input.clone(), slider); + let saturation = range_slider_widget(ParameterWidgetsInfo::new(node_id, SaturationInput, true, context), number_input, slider); + + vec![LayoutGroup::row(vibrance), LayoutGroup::row(saturation)] } pub(crate) fn color_balance_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 8a9bbb86c46..13847c6215b 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -2196,6 +2196,17 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], inputs_count = 3; } + // Vibrance gained a Saturation input after its Vibrance input, whose default of 0 leaves old documents unchanged + if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster::vibrance::IDENTIFIER) && inputs_count == 2 { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + document.network_interface.replace_implementation(node_id, network_path, &mut node_template); + let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + for (index, input) in old_inputs.iter().take(2).enumerate() { + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input.clone(), network_path); + } + inputs_count = 3; + } + // Levels' Midtones became the gamma value it encoded, and each channel gained its own record after the composite one if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster::levels::IDENTIFIER) && inputs_count == 6 { let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index fe2eb20c84c..9aca9f9a32e 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -1194,18 +1194,6 @@ async fn gradient_map + Send>( // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27-,vibA%27%20%3D%20Vibrance,-%27hue%20%27%20%3D%20Old // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Vibrance%20(Photoshop%20CS3) -// -// Algorithm based on: -// https://stackoverflow.com/questions/33966121/what-is-the-algorithm-for-vibrance-filters -// The results of this implementation are very close to correct, but not quite perfect. -// -// Some further analysis available at: -// https://www.photo-mark.com/notes/analyzing-photoshop-vibrance-and-saturation/ -// -// This algorithm is currently lacking a "Saturation" parameter which is needed for interoperability. -// It's not the same as the saturation component of Hue/Saturation/Value. Vibrance and Saturation are both separable. -// When both parameters are set, it is equivalent to running this adjustment twice, with only vibrance set and then only saturation set. -// (Except for some noise probably due to rounding error.) #[node_macro::node(category("Raster: Adjustment"), properties("vibrance_properties"), shader_node(PerPixelAdjust))] fn vibrance>( _: impl Ctx, @@ -1217,83 +1205,76 @@ fn vibrance>( #[gpu_image] image: Item, vibrance: Item, + saturation: Item, ) -> Item { let mut image = image; - let vibrance = vibrance.into_element(); + let vibrance = vibrance.into_element().clamp(-100., 100.) / 100.; + let saturation_scale = 1. + saturation.into_element().clamp(-100., 100.) / 100.; + // Vibrance then saturation, both in linear light, which equals applying each alone in turn image.element_mut().adjust(|color| { - let r_raw = color.r(); - let g_raw = color.g(); - let b_raw = color.b(); - let alpha_in = color.a(); - - let vibrance = vibrance / 100.; - // Slow the effect down by half when it's negative, since artifacts begin appearing past -50%. - // So this scales the 0% to -50% range to 0% to -100%. - let slowed_vibrance = if vibrance >= 0. { vibrance } else { vibrance * 0.5 }; - - let channel_max = r_raw.max(g_raw).max(b_raw); - let channel_min = r_raw.min(g_raw).min(b_raw); - let channel_difference = channel_max - channel_min; - - let scale_multiplier = if channel_max == r_raw { - let green_blue_difference = (g_raw - b_raw).abs(); - let t = (green_blue_difference / channel_difference).min(1.); - t * 0.5 + 0.5 - } else { - 1. - }; - let scale = slowed_vibrance * scale_multiplier * (2. - channel_difference); - let channel_reduction = channel_min * scale; - let scale = 1. + scale * (1. - channel_difference); - - let r_lin0 = srgb_to_linear(r_raw); - let g_lin0 = srgb_to_linear(g_raw); - let b_lin0 = srgb_to_linear(b_raw); - let luminance_initial = 0.2126 * r_lin0 + 0.7152 * g_lin0 + 0.0722 * b_lin0; - - let mut alt_r = srgb_to_linear(r_raw * scale - channel_reduction); - let mut alt_g = srgb_to_linear(g_raw * scale - channel_reduction); - let mut alt_b = srgb_to_linear(b_raw * scale - channel_reduction); - let luminance = 0.2126 * alt_r + 0.7152 * alt_g + 0.0722 * alt_b; - // Skip the luminance-preservation scaling when the result is black (e.g. black input pixel), avoiding division by zero. - if luminance > 0. { - alt_r *= luminance_initial / luminance; - alt_g *= luminance_initial / luminance; - alt_b *= luminance_initial / luminance; - } - - let channel_max = alt_r.max(alt_g).max(alt_b); - if linear_to_srgb(channel_max) > 1. { - let scale = (1. - luminance) / (channel_max - luminance); - alt_r = (alt_r - luminance) * scale + luminance; - alt_g = (alt_g - luminance) * scale + luminance; - alt_b = (alt_b - luminance) * scale + luminance; - } - - alt_r = linear_to_srgb(alt_r); - alt_g = linear_to_srgb(alt_g); - alt_b = linear_to_srgb(alt_b); + let (r, g, b) = (color.r(), color.g(), color.b()); + let maximum = r.max(g).max(b); + let [chroma_factor, brightness_factor] = vibrance_factors(r, g, b, vibrance); + let after_vibrance = Color::from_rgbaf32_unchecked( + scale_about_maximum(r, maximum, chroma_factor, brightness_factor), + scale_about_maximum(g, maximum, chroma_factor, brightness_factor), + scale_about_maximum(b, maximum, chroma_factor, brightness_factor), + color.a(), + ); - if vibrance >= 0. { - Color::from_rgbaf32_unchecked(alt_r, alt_g, alt_b, alpha_in) - } else { - // TODO: The result ends up a bit darker than it should be, further investigation is needed. - // Mix in gamma space (matching `alt_*`), so the luminance is computed from gamma channels too. - let [gr, gg, gb, _] = color.to_gamma_srgb_channels(); - let luminance = 0.299 * gr + 0.587 * gg + 0.114 * gb; - let factor = -slowed_vibrance; - Color::from_rgbaf32_unchecked( - alt_r * (1. - factor) + luminance * factor, - alt_g * (1. - factor) + luminance * factor, - alt_b * (1. - factor) + luminance * factor, - alpha_in, - ) - } + // For PSD interop, saturation scales each channel's distance from a gray weighted by ProPhoto's luminance coefficients + let gray = 0.288040 * after_vibrance.r() + 0.711874 * after_vibrance.g() + 0.000086 * after_vibrance.b(); + after_vibrance.map_rgb(|c| (gray + (c - gray) * saturation_scale).clamp(0., 1.)) }); image } +fn scale_about_maximum(channel: f32, maximum: f32, chroma_factor: f32, brightness_factor: f32) -> f32 { + (brightness_factor * (maximum + chroma_factor * (channel - maximum))).clamp(0., 1.) +} + +/// Share of the vibrance boost the skin-tone protection removes at full weight, a fitted constant. +const VIBRANCE_PROTECTION_LOSS: f32 = 0.4857; + +/// Vibrance on linear SDR channels as `[chroma factor about the max, brightness multiply]` for an amount in -1..1, both fading out toward black. +/// Negative desaturates and darkens low-chroma colors most. Positive boosts them, brightens a little, and spares reds. +fn vibrance_factors(r: f32, g: f32, b: f32, amount: f32) -> [f32; 2] { + let maximum = r.max(g).max(b); + let minimum = r.min(g).min(b); + if maximum <= 0. { + return [1., 1.]; + } + let ratio = minimum / maximum; + let saturation = 1. - ratio; + let q = ratio * saturation; + let toe = 1. - (16. * maximum).min(1.); + let rolloff = 1. - toe * toe; + let brightness = (2. * q - q * q) * (1. - maximum) * rolloff; + + if amount < 0. { + let amount = -amount; + let chroma_factor = (1. - amount / 4.) * (1. - amount * (1. - rolloff * saturation * (1. + saturation) / 2.)); + return [chroma_factor, 1. - amount * brightness]; + } + + let protection = skin_tone_window(hexagon_hue_degrees(r, g, b)) * (1. - saturation * saturation); + let amount = amount * (1. - protection * (1. - amount)); + let boost = (5. / 6.) * (1. - VIBRANCE_PROTECTION_LOSS * protection) * amount * ratio * (1. - minimum) * rolloff; + [1. / (1. - boost), 1. + amount * brightness / 4.] +} + +/// How fully a hue falls under the skin-tone protection: all of it from red to 30 degrees, fading out by 45, and back in from 300. +fn skin_tone_window(hue: f32) -> f32 { + if hue < 45. { + ((45. - hue) / 15.).min(1.) + } else if hue >= 300. { + (hue - 300.) / 60. + } else { + 0. + } +} + #[repr(u32)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "std", derive(dyn_any::DynAny))] @@ -2243,6 +2224,53 @@ mod tests { assert!(threshold_is_white([0., 200., 0.], 118.)); } + /// Runs the node on one gamma-space RGB value (0..255) and returns the gamma-space result on the same scale. + fn run_vibrance(input: [f32; 3], vibrance_amount: f32, saturation: f32) -> [f32; 3] { + let pixel = Color::from_gamma_srgb_channels(input[0] / 255., input[1] / 255., input[2] / 255., 1.); + let result = vibrance((), Item::new_from_element(pixel), vibrance_amount.into(), saturation.into()); + let [r, g, b, _] = result.into_element().to_gamma_srgb_channels(); + [r * 255., g * 255., b * 255.] + } + + #[test] + fn vibrance_saturation_scales_chroma_around_a_prophoto_weighted_gray() { + for (input, saturation, expected) in [ + ([255., 0., 0.], -100., [146., 146., 146.]), + ([0., 255., 0.], -100., [219., 219., 219.]), + ([0., 0., 255.], -100., [0., 0., 0.]), + ([200., 100., 50.], -100., [139., 139., 139.]), + ([0., 255., 0.], -50., [161., 238., 161.]), + ([200., 100., 50.], 50., [223., 71., 0.]), + ([100., 150., 200.], 100., [3., 161., 244.]), + ([200., 180., 170.], 100., [213., 174., 152.]), + ] { + let actual = run_vibrance(input, 0., saturation); + for (actual, expected) in actual.iter().zip(expected) { + assert!((actual - expected).abs() <= 1., "{input:?} at {saturation}: expected {expected}, got {actual}"); + } + } + } + + #[test] + fn vibrance_boosts_low_chroma_colors_most_and_spares_reds() { + for (input, amount, expected) in [ + ([200., 100., 100.], -100., [188., 148., 148.]), + ([200., 160., 160.], -50., [192., 172., 172.]), + ([50., 0., 0.], -100., [50., 31., 31.]), + ([100., 50., 0.], -100., [100., 67., 50.]), + ([200., 100., 100.], 100., [203., 71., 71.]), + ([255., 125., 125.], 100., [255., 91., 91.]), + ([125., 255., 125.], 100., [80., 255., 80.]), + ([255., 200., 205.], 100., [255., 190., 196.]), + ([60., 120., 200.], 75., [38., 115., 201.]), + ] { + let actual = run_vibrance(input, amount, 0.); + for (actual, expected) in actual.iter().zip(expected) { + assert!((actual - expected).abs() <= 1., "{input:?} at {amount}: expected {expected}, got {actual}"); + } + } + } + /// Runs Selective Color on one gamma-space RGB value (0..255) with the given group values /// (Reds through Blacks, each cyan, magenta, yellow, black) and returns the gamma-space result on the same scale. fn run_selective_color(input: [f32; 3], mode: RelativeAbsolute, groups: [[f32; 4]; 9]) -> [f32; 3] { From b363f7015039b3aa4a53531107aacd0cd3b6e372 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 15 Sep 2026 18:29:55 -0700 Subject: [PATCH 38/46] Retire the 'Brightness/Contrast Classic' node with the 'Brightness/Contrast' classic toggle now shader compatible (#4538) --- .../document/node_graph/node_properties.rs | 16 +- .../messages/portfolio/document_migration.rs | 31 +- node-graph/nodes/raster/src/adjustments.rs | 341 ++++++++++-------- node-graph/nodes/raster/src/cubic_spline.rs | 123 ------- node-graph/nodes/raster/src/lib.rs | 1 - 5 files changed, 230 insertions(+), 282 deletions(-) delete mode 100644 node-graph/nodes/raster/src/cubic_spline.rs diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 8e38e614058..1590a0ae5d1 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -1329,9 +1329,8 @@ pub(crate) fn brightness_contrast_properties(node_id: NodeId, context: &mut Node let use_classic_value = get_document_node(node_id, context) .ok() .and_then(|document_node| document_node.input(UseClassicInput).and_then(|input| input.as_value())) - .and_then(|tagged| if let TaggedValue::Bool(value) = tagged { Some(*value) } else { None }); - let includes_use_classic = use_classic_value.is_some(); - let use_classic_value = use_classic_value.unwrap_or(false); + .and_then(|tagged| if let TaggedValue::Bool(value) = tagged { Some(*value) } else { None }) + .unwrap_or(false); let brightness_min = if use_classic_value { -100. } else { -150. }; let brightness_max = if use_classic_value { 100. } else { 150. }; @@ -1364,11 +1363,12 @@ pub(crate) fn brightness_contrast_properties(node_id: NodeId, context: &mut Node NumberInput::default().mode_increment().unit("%").min(contrast_min).max(100.), ); - let mut layout = vec![brightness, contrast]; - if includes_use_classic { - // TODO: When we no longer use this function in the temporary "Brightness/Contrast Classic" node, remove this conditional pushing and just always include this - let use_classic = bool_widget(ParameterWidgetsInfo::new(node_id, UseClassicInput, true, context), CheckboxInput::default()); - layout.push(LayoutGroup::row(use_classic)); + let use_classic = bool_widget(ParameterWidgetsInfo::new(node_id, UseClassicInput, true, context), CheckboxInput::default()); + + let mut layout = vec![brightness, contrast, LayoutGroup::row(use_classic)]; + if use_classic_value { + let number_input = NumberInput::default().mode_increment().min(0.).max(255.); + layout.push(spectrum_slider_row(node_id, context, ClassicPivotInput, bw_track(), Color::WHITE, 0., 255., 127., number_input)); } layout diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 13847c6215b..2635368393f 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -477,12 +477,10 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ aliases: &[ "graphene_raster_nodes::adjustments::BrightnessContrastNode", "graphene_core::raster::adjustments::BrightnessContrastNode", + "graphene_raster_nodes::adjustments::brightness_contrast_classic", + "graphene_raster_nodes::adjustments::BrightnessContrastClassicNode", ], }, - NodeReplacement { - node: graphene_std::raster_nodes::adjustments::brightness_contrast_classic::IDENTIFIER, - aliases: &["graphene_raster_nodes::adjustments::BrightnessContrastClassicNode"], - }, NodeReplacement { node: graphene_std::raster_nodes::adjustments::channel_mixer::IDENTIFIER, aliases: &[ @@ -2207,6 +2205,31 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], inputs_count = 3; } + // The removed "Brightness/Contrast Classic" node had no Use Classic input, so its three inputs become the unified node with the toggle on + if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster::brightness_contrast::IDENTIFIER) && inputs_count == 3 { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + document.network_interface.replace_implementation(node_id, network_path, &mut node_template); + let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + for (index, input) in old_inputs.iter().take(3).enumerate() { + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input.clone(), network_path); + } + document + .network_interface + .set_input(&InputConnector::node_at_index(*node_id, 3), NodeInput::value(TaggedValue::Bool(true), false), network_path); + inputs_count = 4; + } + + // Brightness/Contrast gained the classic algorithm's pivot, whose default of 127 matches what PSD adjustment layers store + if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster::brightness_contrast::IDENTIFIER) && inputs_count == 4 { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + document.network_interface.replace_implementation(node_id, network_path, &mut node_template); + let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + for (index, input) in old_inputs.iter().take(4).enumerate() { + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input.clone(), network_path); + } + inputs_count = 5; + } + // Levels' Midtones became the gamma value it encoded, and each channel gained its own record after the composite one if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster::levels::IDENTIFIER) && inputs_count == 6 { let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index 9aca9f9a32e..3b77ad70b2d 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -1,7 +1,6 @@ #![allow(clippy::too_many_arguments)] use crate::adjust::Adjust; -use crate::cubic_spline::CubicSplines; use core::fmt::Debug; #[cfg(feature = "std")] use core_types::list::{Item, List}; @@ -82,11 +81,7 @@ pub enum DesaturateMethod { #[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))] fn desaturate>( _: impl Ctx, - #[implementations( - Raster, - Color, - Gradient, - )] + #[implementations(Raster, Color, Gradient)] #[gpu_image] input: Item, method: Item, @@ -130,11 +125,7 @@ fn desaturate>( #[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))] fn gamma_correction>( _: impl Ctx, - #[implementations( - Raster, - Color, - Gradient, - )] + #[implementations(Raster, Color, Gradient)] #[gpu_image] input: Item, #[default(2.2)] @@ -156,11 +147,7 @@ fn gamma_correction>( #[node_macro::node(category("Raster: Channels"), shader_node(PerPixelAdjust))] fn extract_channel>( _: impl Ctx, - #[implementations( - Raster, - Color, - Gradient, - )] + #[implementations(Raster, Color, Gradient)] #[gpu_image] input: Item, channel: Item, @@ -183,11 +170,7 @@ fn extract_channel>( #[node_macro::node(category("Raster: Channels"), shader_node(PerPixelAdjust))] fn make_opaque>( _: impl Ctx, - #[implementations( - Raster, - Color, - Gradient, - )] + #[implementations(Raster, Color, Gradient)] #[gpu_image] input: Item, ) -> Item { @@ -196,35 +179,98 @@ fn make_opaque>( input } -// TODO: Remove this once GPU shader nodes are able to support the non-classic algorithm -// TODO: Maybe re-add the "Raster: Adjustment" category to make this user-facing if we care to make this not just for testing -#[node_macro::node(name("Brightness/Contrast Classic"), category(""), properties("brightness_contrast_properties"), shader_node(PerPixelAdjust))] -fn brightness_contrast_classic>( - _: impl Ctx, - #[implementations( - Raster, - Color, - Gradient, - )] - #[gpu_image] - input: Item, - brightness: Item, - contrast: Item, -) -> Item { - let mut input = input; - let brightness = brightness.into_element(); - let contrast = contrast.into_element(); +/// Remaps a gamma-space channel through the stages of a Levels adjustment: the input range, the midtones gamma, and the output range. +fn apply_levels(value: f32, input_shadows: f32, input_highlights: f32, inverse_gamma: f32, output_minimum: f32, output_maximum: f32) -> f32 { + let highlights_minus_shadows = (input_highlights - input_shadows).clamp(f32::EPSILON, 1.); + let value = ((value - input_shadows).max(0.) / highlights_minus_shadows).min(1.); + let value = value.powf(inverse_gamma); - let brightness = brightness / 255.; + value * (output_maximum - output_minimum) + output_minimum +} - let contrast = contrast / 100.; - let contrast = if contrast > 0. { (contrast * core::f32::consts::FRAC_PI_2 - 0.01).tan() } else { contrast }; +/// The classic Brightness/Contrast algorithm: a Levels remap around the pivot, adding the brightness before a positive +/// contrast stretch and after a negative contrast squeeze. +fn brightness_contrast_classic(value: f32, brightness: f32, contrast: f32, pivot: f32) -> f32 { + // Full contrast is a hard step, sending values at the pivot or above to white (with half a 16-bit step of slack for float ties) + if contrast >= 1. { + return if value + brightness >= pivot - 1. / 65536. { 1. } else { 0. }; + } - let offset = brightness * contrast + brightness - contrast / 2.; + let result = if contrast > 0. { + let input_shadows = pivot * contrast - brightness; + apply_levels(value, input_shadows, input_shadows + 1. - contrast, 1., 0., 1.) + } else { + let output_minimum = brightness - pivot * contrast; + apply_levels(value, 0., 1., 1., output_minimum, output_minimum + 1. + contrast) + }; - input.element_mut().adjust(|color| color.map_gamma_rgb(|c| (c + c * contrast + offset).clamp(0., 1.))); + result.clamp(0., 1.) +} - input +/// One brightness curve of the current algorithm, for a magnitude in 0..100: a line of slope 2^(b/110) up to an output of 0.5, +/// continued by a cubic Hermite segment that eases into (1, 1). +struct BrightnessCurve { + slope: f32, + knee: f32, + end_slope: f32, +} + +impl BrightnessCurve { + fn new(brightness: f32) -> Self { + let slope = 2_f32.powf(brightness / 110.); + let knee = 0.5 / slope; + let end_slope = (1. / (1. + 12. * (slope - 1.))).max(0.1); + + Self { slope, knee, end_slope } + } + + /// Evaluates the Hermite segment at its parameter t in 0..1, returning the value and its derivative with respect to x. + fn hermite(&self, t: f32) -> (f32, f32) { + let length = 1. - self.knee; + let start_tangent = length * self.slope; + let end_tangent = length * self.end_slope; + let t2 = t * t; + let t3 = t2 * t; + + let value = (2. * t3 - 3. * t2 + 1.) * 0.5 + (t3 - 2. * t2 + t) * start_tangent + (-2. * t3 + 3. * t2) + (t3 - t2) * end_tangent; + let derivative = ((6. * t2 - 6. * t) * 0.5 + (3. * t2 - 4. * t + 1.) * start_tangent + (-6. * t2 + 6. * t) + (3. * t2 - 2. * t) * end_tangent) / length; + + (value, derivative) + } + + fn forward(&self, x: f32) -> f32 { + if x < self.knee { + return self.slope * x; + } + + let t = ((x - self.knee) / (1. - self.knee)).min(1.); + self.hermite(t).0.min(1.) + } + + /// Inverts the curve, solving the monotone Hermite segment with a bracketed Newton iteration. + fn inverse(&self, y: f32) -> f32 { + if y <= 0.5 { + return y / self.slope; + } + + let mut low = 0.; + let mut high = 1.; + let mut t = (y - 0.5) * 2.; + for _ in 0..8 { + let (value, derivative) = self.hermite(t); + let error = value - y; + if error > 0. { + high = t; + } else { + low = t; + } + + let step = t - error / (derivative * (1. - self.knee)); + t = if step >= low && step <= high { step } else { (low + high) * 0.5 }; + } + + self.knee + t * (1. - self.knee) + } } // Aims for interoperable compatibility with: @@ -233,80 +279,48 @@ fn brightness_contrast_classic>( // // Some further analysis available at: // https://geraldbakker.nl/psnumbers/brightness-contrast.html -#[node_macro::node(name("Brightness/Contrast"), category("Raster: Adjustment"), properties("brightness_contrast_properties"), cfg(feature = "std"))] +// +// TODO: A Lab-only mode once Graphite supports the CIE Lab color space. +#[node_macro::node(name("Brightness/Contrast"), category("Raster: Adjustment"), properties("brightness_contrast_properties"), shader_node(PerPixelAdjust))] fn brightness_contrast>( - _ctx: impl Ctx, - #[implementations( - Raster, - Color, - Gradient, - )] + _: impl Ctx, + #[implementations(Raster, Color, Gradient)] #[gpu_image] input: Item, brightness: Item, contrast: Item, use_classic: Item, + #[default(127.)] classic_pivot: Item, ) -> Item { - let use_classic = use_classic.into_element(); - if use_classic { - return brightness_contrast_classic(_ctx, input, brightness, contrast); - } - let mut input = input; let brightness = brightness.into_element(); - let contrast = contrast.into_element(); - - const WINDOW_SIZE: usize = 1024; - - // Brightness LUT - let brightness_is_negative = brightness < 0.; - // We clamp the brightness before the two curve X-axis points `130 - brightness * 26` and `233 - brightness * 48` intersect. - // Beyond the point of intersection, the cubic spline fitting becomes invalid and fails an assertion, which we need to avoid. - // See the intersection of the red lines at x = 103/22*100 = 468.18182 in the graph: https://www.desmos.com/calculator/ekvz4zyd9c - let brightness = (brightness.abs() / 100.).min(103. / 22. - 0.00001); - let brightness_curve_points = CubicSplines { - x: [0., 130. - brightness * 26., 233. - brightness * 48., 255.].map(|x| x / 255.), - y: [0., 130. + brightness * 51., 233. + brightness * 10., 255.].map(|x| x / 255.), - }; - let brightness_curve_solutions = brightness_curve_points.solve(); - let mut brightness_lut: [f32; WINDOW_SIZE] = core::array::from_fn(|i| { - let x = i as f32 / (WINDOW_SIZE as f32 - 1.); - brightness_curve_points.interpolate(x, &brightness_curve_solutions) - }); - // Special handling for when brightness is negative - if brightness_is_negative { - brightness_lut = core::array::from_fn(|i| { - let mut x = i; - while x > 1 && brightness_lut[x] > i as f32 / WINDOW_SIZE as f32 { - x -= 1; + let contrast = contrast.into_element() / 100.; + let use_classic = use_classic.into_element(); + let classic_pivot = classic_pivot.into_element() / 255.; + + // Beyond a magnitude of 100, the curve for 100 is applied first and the curve for the remainder after it + let magnitude = brightness.abs().min(150.); + let first_curve = BrightnessCurve::new(magnitude.min(100.)); + let second_curve = BrightnessCurve::new((magnitude - 100.).max(0.)); + + input.element_mut().adjust(|color| { + color.map_gamma_rgb(|c| { + if use_classic { + return brightness_contrast_classic(c, brightness / 255., contrast, classic_pivot); } - x as f32 / WINDOW_SIZE as f32 - }); - } - // Contrast LUT - // Unlike with brightness, the X-axis points `64` and `192` don't intersect at any contrast value, because they are constants. - // So we don't have to worry about clamping the contrast value to avoid invalid cubic spline fitting. - // See the graph: https://www.desmos.com/calculator/iql9vsca56 - let contrast = contrast / 100.; - let contrast_curve_points = CubicSplines { - x: [0., 64., 192., 255.].map(|x| x / 255.), - y: [0., 64. - contrast * 30., 192. + contrast * 30., 255.].map(|x| x / 255.), - }; - let contrast_curve_solutions = contrast_curve_points.solve(); - let contrast_lut: [f32; WINDOW_SIZE] = core::array::from_fn(|i| { - let x = i as f32 / (WINDOW_SIZE as f32 - 1.); - contrast_curve_points.interpolate(x, &contrast_curve_solutions) - }); + // Negative brightness runs the same curves in reverse + let brightened = if brightness >= 0. { + second_curve.forward(first_curve.forward(c)) + } else { + first_curve.inverse(second_curve.inverse(c)) + }; - // Composed brightness and contrast LUTs - let combined_lut = brightness_lut.map(|brightness| { - let index_in_contrast_lut = (brightness * (contrast_lut.len() - 1) as f32).round() as usize; - contrast_lut[index_in_contrast_lut] + // Contrast pushes away from (or pulls toward) the midpoint, most strongly at the quarter tones + let contrasted = brightened + 0.76 * contrast * (2. * brightened - 1.) * brightened.min(1. - brightened); + contrasted.clamp(0., 1.) + }) }); - let lut_max = (combined_lut.len() - 1) as f32; - - input.element_mut().adjust(|color| color.map_gamma_rgb(|c| combined_lut[(c * lut_max).round() as usize])); input } @@ -611,11 +625,7 @@ fn points_to_transfer_curve( #[node_macro::node(name("Black & White"), category("Raster: Adjustment"), properties("black_and_white_properties"), shader_node(PerPixelAdjust))] fn black_and_white>( _: impl Ctx, - #[implementations( - Raster, - Color, - Gradient, - )] + #[implementations(Raster, Color, Gradient)] #[gpu_image] image: Item, #[default(Color::BLACK)] tint: Item, @@ -891,11 +901,7 @@ fn lightness_toward_max_or_min(rgb: [f32; 3], amount: f32) -> [f32; 3] { #[node_macro::node(name("Hue/Saturation"), category("Raster: Adjustment"), properties("hue_saturation_properties"), shader_node(PerPixelAdjust))] fn hue_saturation>( _: impl Ctx, - #[implementations( - Raster, - Color, - Gradient, - )] + #[implementations(Raster, Color, Gradient)] #[gpu_image] input: Item, hue: Item, @@ -1113,11 +1119,7 @@ fn hue_saturation>( #[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))] fn invert>( _: impl Ctx, - #[implementations( - Raster, - Color, - Gradient, - )] + #[implementations(Raster, Color, Gradient)] #[gpu_image] input: Item, ) -> Item { @@ -1197,11 +1199,7 @@ async fn gradient_map + Send>( #[node_macro::node(category("Raster: Adjustment"), properties("vibrance_properties"), shader_node(PerPixelAdjust))] fn vibrance>( _: impl Ctx, - #[implementations( - Raster, - Color, - Gradient, - )] + #[implementations(Raster, Color, Gradient)] #[gpu_image] image: Item, vibrance: Item, @@ -1395,11 +1393,7 @@ pub enum DomainWarpType { #[node_macro::node(category("Raster: Adjustment"), properties("channel_mixer_properties"), shader_node(PerPixelAdjust))] fn channel_mixer>( _: impl Ctx, - #[implementations( - Raster, - Color, - Gradient, - )] + #[implementations(Raster, Color, Gradient)] #[gpu_image] image: Item, @@ -1537,11 +1531,7 @@ pub enum SelectiveColorChoice { #[node_macro::node(category("Raster: Adjustment"), properties("selective_color_properties"), shader_node(PerPixelAdjust))] fn selective_color>( _: impl Ctx, - #[implementations( - Raster, - Color, - Gradient, - )] + #[implementations(Raster, Color, Gradient)] #[gpu_image] image: Item, @@ -1708,11 +1698,7 @@ fn selective_color>( #[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))] fn posterize>( _: impl Ctx, - #[implementations( - Raster, - Color, - Gradient, - )] + #[implementations(Raster, Color, Gradient)] #[gpu_image] input: Item, #[default(4)] @@ -1742,11 +1728,7 @@ fn posterize>( #[node_macro::node(category("Raster: Adjustment"), properties("exposure_properties"), shader_node(PerPixelAdjust))] fn exposure>( _: impl Ctx, - #[implementations( - Raster, - Color, - Gradient, - )] + #[implementations(Raster, Color, Gradient)] #[gpu_image] input: Item, exposure: Item, @@ -1964,6 +1946,73 @@ mod tests { } } + /// Runs the node on one gamma-space gray value (0..255) and returns the gamma-space result on the same scale. + fn run_brightness_contrast(value: f32, brightness: f32, contrast: f32, use_classic: bool) -> f32 { + let pixel = Color::from_gamma_srgb_channels(value / 255., value / 255., value / 255., 1.); + let result = brightness_contrast((), Item::new_from_element(pixel), brightness.into(), contrast.into(), use_classic.into(), 127_f32.into()); + result.into_element().to_gamma_srgb_channels()[0] * 255. + } + + #[test] + fn brightness_contrast_curves_brightness_and_pivots_contrast_at_the_midpoint() { + for (value, brightness, contrast, expected) in [ + (16., 100., 0., 30.), + (64., 100., 0., 120.), + (128., 100., 0., 209.), + (192., 100., 0., 245.), + (128., 20., 0., 145.), + (64., -100., 0., 34.), + (128., -100., 0., 68.), + (192., -100., 0., 111.), + (240., -100., 0., 177.), + (64., 150., 0., 162.), + (128., 150., 0., 239.), + (128., -150., 0., 50.), + (240., -150., 0., 131.), + (32., 0., 100., 14.), + (64., 0., 100., 40.), + (192., 0., 100., 216.), + (64., 0., -50., 76.), + (64., 0., 25., 58.), + (64., 50., 30., 81.), + (128., 50., 30., 178.), + (192., 50., 30., 233.), + (64., -60., -20., 48.), + (128., -60., -20., 92.), + (192., -60., -20., 139.), + ] { + let actual = run_brightness_contrast(value, brightness, contrast, false); + assert!( + (actual - expected).abs() <= 1., + "{value} at brightness {brightness}, contrast {contrast}: expected {expected}, got {actual}" + ); + } + } + + #[test] + fn brightness_contrast_classic_remaps_levels_around_the_pivot() { + for (value, brightness, contrast, expected) in [ + (0., 0., -50., 64.), + (100., 0., -50., 114.), + (255., 0., -50., 191.), + (64., 0., 50., 1.), + (100., 0., 50., 73.), + (200., 0., 50., 255.), + (50., 40., 40., 65.), + (100., 40., 40., 148.), + (50., -40., -40., 41.), + (200., -40., -40., 131.), + (126., 0., 100., 0.), + (128., 0., 100., 255.), + ] { + let actual = run_brightness_contrast(value, brightness, contrast, true); + assert!( + (actual - expected).abs() <= 1., + "{value} at brightness {brightness}, contrast {contrast}: expected {expected}, got {actual}" + ); + } + } + /// Runs Levels with composite and red records given as [black, white, gamma, output black, output white] with 0..255 points /// on one gamma-space gray value (0..255), returning the red and green results on the same scale. fn run_levels(value: f32, composite: [f32; 5], red: [f32; 5]) -> [f32; 2] { diff --git a/node-graph/nodes/raster/src/cubic_spline.rs b/node-graph/nodes/raster/src/cubic_spline.rs deleted file mode 100644 index f57f699bebc..00000000000 --- a/node-graph/nodes/raster/src/cubic_spline.rs +++ /dev/null @@ -1,123 +0,0 @@ -#[derive(Debug)] -pub struct CubicSplines { - pub x: [f32; 4], - pub y: [f32; 4], -} - -impl CubicSplines { - pub fn solve(&self) -> [f32; 4] { - let (x, y) = (&self.x, &self.y); - - // Build an augmented matrix to solve the system of equations using Gaussian elimination - let mut augmented_matrix = [ - [ - 2. / (x[1] - x[0]), - 1. / (x[1] - x[0]), - 0., - 0., - // | - 3. * (y[1] - y[0]) / ((x[1] - x[0]) * (x[1] - x[0])), - ], - [ - 1. / (x[1] - x[0]), - 2. * (1. / (x[1] - x[0]) + 1. / (x[2] - x[1])), - 1. / (x[2] - x[1]), - 0., - // | - 3. * ((y[1] - y[0]) / ((x[1] - x[0]) * (x[1] - x[0])) + (y[2] - y[1]) / ((x[2] - x[1]) * (x[2] - x[1]))), - ], - [ - 0., - 1. / (x[2] - x[1]), - 2. * (1. / (x[2] - x[1]) + 1. / (x[3] - x[2])), - 1. / (x[3] - x[2]), - // | - 3. * ((y[2] - y[1]) / ((x[2] - x[1]) * (x[2] - x[1])) + (y[3] - y[2]) / ((x[3] - x[2]) * (x[3] - x[2]))), - ], - [ - 0., - 0., - 1. / (x[3] - x[2]), - 2. / (x[3] - x[2]), - // | - 3. * (y[3] - y[2]) / ((x[3] - x[2]) * (x[3] - x[2])), - ], - ]; - - // Gaussian elimination: forward elimination - for row in 0..4 { - let pivot_row_index = (row..4) - .max_by(|&a_row, &b_row| { - augmented_matrix[a_row][row] - .abs() - .partial_cmp(&augmented_matrix[b_row][row].abs()) - .unwrap_or(core::cmp::Ordering::Equal) - }) - .unwrap(); - - // Swap the current row with the row that has the largest pivot element - augmented_matrix.swap(row, pivot_row_index); - - // Eliminate the current column in all rows below the current one - for row_below_current in row + 1..4 { - assert!(augmented_matrix[row][row].abs() > f32::EPSILON); - - let scale_factor = augmented_matrix[row_below_current][row] / augmented_matrix[row][row]; - for col in row..5 { - augmented_matrix[row_below_current][col] -= augmented_matrix[row][col] * scale_factor - } - } - } - - // Gaussian elimination: back substitution - let mut solutions = [0.; 4]; - for col in (0..4).rev() { - assert!(augmented_matrix[col][col].abs() > f32::EPSILON); - - solutions[col] = augmented_matrix[col][4] / augmented_matrix[col][col]; - - for row in (0..col).rev() { - augmented_matrix[row][4] -= augmented_matrix[row][col] * solutions[col]; - augmented_matrix[row][col] = 0.; - } - } - - solutions - } - - pub fn interpolate(&self, input: f32, solutions: &[f32]) -> f32 { - if input <= self.x[0] { - return self.y[0]; - } - if input >= self.x[self.x.len() - 1] { - return self.y[self.x.len() - 1]; - } - - // Find the segment that the input falls between - let mut segment = 1; - while self.x[segment] < input { - segment += 1; - } - let segment_start = segment - 1; - let segment_end = segment; - - // Calculate the output value using quadratic interpolation - let input_value = self.x[segment_start]; - let input_value_prev = self.x[segment_end]; - let output_value = self.y[segment_start]; - let output_value_prev = self.y[segment_end]; - let solutions_value = solutions[segment_start]; - let solutions_value_prev = solutions[segment_end]; - - let output_delta = solutions_value_prev * (input_value - input_value_prev) - (output_value - output_value_prev); - let solution_delta = (output_value - output_value_prev) - solutions_value * (input_value - input_value_prev); - - let input_ratio = (input - input_value_prev) / (input_value - input_value_prev); - let prev_output_ratio = (1. - input_ratio) * output_value_prev; - let output_ratio = input_ratio * output_value; - let quadratic_ratio = input_ratio * (1. - input_ratio) * (output_delta * (1. - input_ratio) + solution_delta * input_ratio); - - let result = prev_output_ratio + output_ratio + quadratic_ratio; - result.clamp(0., 1.) - } -} diff --git a/node-graph/nodes/raster/src/lib.rs b/node-graph/nodes/raster/src/lib.rs index cd982d4890b..58efebdbd92 100644 --- a/node-graph/nodes/raster/src/lib.rs +++ b/node-graph/nodes/raster/src/lib.rs @@ -3,7 +3,6 @@ pub mod adjust; pub mod adjustments; pub mod blending_nodes; -pub mod cubic_spline; pub mod fullscreen_vertex; /// required by shader macro From dfe1319cfb97c2c859cd5d9f52fd996d6fc70326 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 15 Sep 2026 18:55:23 -0700 Subject: [PATCH 39/46] New node: 'Photo Filter' to warm or cool an image by multiplying it with a filter color in XYZ (#4539) --- node-graph/nodes/raster/src/adjustments.rs | 122 +++++++++++++++++++-- 1 file changed, 115 insertions(+), 7 deletions(-) diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index 3b77ad70b2d..36bad6da5a2 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -23,13 +23,7 @@ use raster_types::{CPU, Raster}; #[cfg(feature = "std")] use vector_types::Gradient; -// TODO: Implement the following: -// Photo Filter -// Aims for interoperable compatibility with: -// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27phfl%27%20%3D%20Photo%20Filter -// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=of%20the%20file.-,Photo%20Filter,-Key%20is%20%27phfl -// -// Color Lookup +// TODO: Implement 'Color Lookup': // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27clrL%27%20%3D%20Color%20Lookup // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Color%20Lookup%20(Photoshop%20CS6 @@ -1390,6 +1384,8 @@ pub enum DomainWarpType { // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27mixr%27%20%3D%20Channel%20Mixer // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Lab%20color%20only-,Channel%20Mixer,-Key%20is%20%27mixr +// +// TODO: CMYK source channels once Graphite supports the CMYK color space. #[node_macro::node(category("Raster: Adjustment"), properties("channel_mixer_properties"), shader_node(PerPixelAdjust))] fn channel_mixer>( _: impl Ctx, @@ -1906,6 +1902,93 @@ fn color_balance>( image } +// Aims for interoperable compatibility with: +// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27phfl%27%20%3D%20Photo%20Filter +// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=of%20the%20file.-,Photo%20Filter,-Key%20is%20%27phfl +#[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))] +fn photo_filter>( + _: impl Ctx, + #[implementations(Raster, Color, Gradient)] + #[gpu_image] + image: Item, + #[default("ec8a00")] color: Item, + #[default(25.)] density: Item, + #[default(true)] preserve_luminosity: Item, +) -> Item { + let mut image = image; + let color = color.into_element(); + let density = (density.into_element() / 100.).clamp(0., 1.); + let preserve_luminosity = preserve_luminosity.into_element(); + + // The image is multiplied in XYZ by the filter color normalized to the white point, with density easing that multiplier toward 1 + let filter_xyz = multiply_matrix(&SRGB_TO_XYZ_D50, [color.r(), color.g(), color.b()]); + let factor = [ + 1. + density * (filter_xyz[0] / WHITE_XYZ_D50[0] - 1.), + 1. + density * (filter_xyz[1] / WHITE_XYZ_D50[1] - 1.), + 1. + density * (filter_xyz[2] / WHITE_XYZ_D50[2] - 1.), + ]; + + image.element_mut().adjust(|pixel| { + let [r_in, g_in, b_in, alpha] = pixel.to_gamma_srgb_channels(); + let xyz = multiply_matrix(&SRGB_TO_XYZ_D50, [srgb_to_linear(r_in), srgb_to_linear(g_in), srgb_to_linear(b_in)]); + let filtered = multiply_matrix(&XYZ_D50_TO_SRGB, [xyz[0] * factor[0], xyz[1] * factor[1], xyz[2] * factor[2]]); + let mut r = linear_to_srgb(filtered[0].clamp(0., 1.)); + let mut g = linear_to_srgb(filtered[1].clamp(0., 1.)); + let mut b = linear_to_srgb(filtered[2].clamp(0., 1.)); + + if preserve_luminosity { + [r, g, b] = set_luminosity(r, g, b, luma_rec_601_fixed(r, g, b), luma_rec_601_fixed(r_in, g_in, b_in)); + } + + Color::from_gamma_srgb_channels(r, g, b, alpha) + }); + image +} + +// sRGB colorants adapted to D50 as in the sRGB IEC61966-2.1 ICC profile, row major, and their inverse +const SRGB_TO_XYZ_D50: [[f32; 3]; 3] = [[0.43607, 0.38515, 0.14307], [0.22249, 0.71687, 0.06061], [0.01392, 0.09708, 0.71410]]; +const XYZ_D50_TO_SRGB: [[f32; 3]; 3] = [[3.134096, -1.6174, -0.490638], [-0.978793, 1.916295, 0.033454], [0.071971, -0.228987, 1.40538]]; +const WHITE_XYZ_D50: [f32; 3] = [0.96420, 1., 0.82491]; + +fn multiply_matrix(matrix: &[[f32; 3]; 3], vector: [f32; 3]) -> [f32; 3] { + [ + matrix[0][0] * vector[0] + matrix[0][1] * vector[1] + matrix[0][2] * vector[2], + matrix[1][0] * vector[0] + matrix[1][1] * vector[1] + matrix[1][2] * vector[2], + matrix[2][0] * vector[0] + matrix[2][1] * vector[1] + matrix[2][2] * vector[2], + ] +} + +/// The Rec. 601 luma in the 14-bit fixed point that PSD interop depends on. +fn luma_rec_601_fixed(r: f32, g: f32, b: f32) -> f32 { + (4915. * r + 9667. * g + 1802. * b) / 16384. +} + +fn pull_toward_luminosity(channels: [f32; 3], luminosity: f32, scale: f32) -> [f32; 3] { + [ + luminosity + (channels[0] - luminosity) * scale, + luminosity + (channels[1] - luminosity) * scale, + luminosity + (channels[2] - luminosity) * scale, + ] +} + +/// The Luminosity blend mode's construction: shifts gamma-encoded channels from `luma` to `luminosity`, +/// then pulls them toward it just enough to bring every channel back into 0..1. +fn set_luminosity(r: f32, g: f32, b: f32, luma: f32, luminosity: f32) -> [f32; 3] { + let shift = luminosity - luma; + let mut channels = [r + shift, g + shift, b + shift]; + + let low = channels[0].min(channels[1]).min(channels[2]); + if low < 0. { + channels = pull_toward_luminosity(channels, luminosity, luminosity / (luminosity - low)); + } + let high = channels[0].max(channels[1]).max(channels[2]); + if high > 1. { + channels = pull_toward_luminosity(channels, luminosity, (1. - luminosity) / (high - luminosity)); + } + + [channels[0].clamp(0., 1.), channels[1].clamp(0., 1.), channels[2].clamp(0., 1.)] +} + #[cfg(feature = "std")] mod _graphene_hash_impls { use super::{ @@ -2475,4 +2558,29 @@ mod tests { let result = run_color_balance([128., 128., 128.], [-39., 6., 42.], [21., 0., -25.], [20., -35., 45.], false); assert_close(result, [124., 120., 164.]); } + + /// Runs Photo Filter on one gamma-space RGB value (0..255) and returns the gamma-space result on the same scale. + fn run_photo_filter(input: [f32; 3], filter: [f32; 3], density: f32, preserve_luminosity: bool) -> [f32; 3] { + let pixel = Color::from_gamma_srgb_channels(input[0] / 255., input[1] / 255., input[2] / 255., 1.); + let filter = Color::from_gamma_srgb_channels(filter[0] / 255., filter[1] / 255., filter[2] / 255., 1.); + let result = photo_filter((), Item::new_from_element(pixel), filter.into(), density.into(), preserve_luminosity.into()); + let [r, g, b, _] = result.into_element().to_gamma_srgb_channels(); + [r * 255., g * 255., b * 255.] + } + + #[test] + fn photo_filter_multiplies_in_xyz() { + assert_close(run_photo_filter([0., 255., 0.], [255., 0., 0.], 100., false), [146., 103., 0.]); + assert_close(run_photo_filter([0., 0., 255.], [255., 0., 0.], 100., false), [116., 0., 37.]); + assert_close(run_photo_filter([100., 100., 100.], [128., 128., 128.], 100., false), [46., 46., 46.]); + assert_close(run_photo_filter([100., 100., 100.], [236., 138., 0.], 25., false), [98., 91., 87.]); + assert_close(run_photo_filter([200., 200., 200.], [255., 255., 255.], 100., false), [200., 200., 200.]); + } + + #[test] + fn photo_filter_preserve_luminosity_shifts_then_clips_toward_luminosity() { + assert_close(run_photo_filter([20., 20., 20.], [255., 0., 0.], 100., true), [34., 14., 14.]); + assert_close(run_photo_filter([160., 160., 160.], [255., 0., 0.], 100., true), [255., 120., 120.]); + assert_close(run_photo_filter([90., 90., 90.], [236., 138., 0.], 25., true), [95., 88., 85.]); + } } From 62df61eb42f8e28c9749bb71b91123f50f1df2df Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 15 Sep 2026 19:51:51 -0700 Subject: [PATCH 40/46] Add a "Use Tint" toggle to the 'Black & White' node and give its tint the Luminosity blend's clipping (#4540) * Add a 'Use Tint' toggle to the 'Black & White' node and give its tint the Luminosity blend's clipping * Fix the Black & White migration for a wired tint and gray out the tint when unused --- .../document/node_graph/node_properties.rs | 37 +++++++- .../messages/portfolio/document_migration.rs | 16 ++++ node-graph/graph-craft/src/document/value.rs | 14 ++-- node-graph/nodes/raster/src/adjustments.rs | 84 +++++++++++++++---- 4 files changed, 129 insertions(+), 22 deletions(-) diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 1590a0ae5d1..8adc52eb2bd 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -1075,6 +1075,40 @@ pub fn optional_f64_widget(parameter_widgets_info: ParameterWidgetsInfo, bool_in widgets } +/// `parameter_widgets_info` is for the color parameter. `bool_input_index` is the input index of the bool parameter, drawn as a checkbox in front of the color. +/// A color row gated by the bool input at `bool_input_index`, whose checkbox takes the assist slot after the label like the +/// Opacity node's toggles, so the caller passes `blank_assist = false`. An exposed color shows neither, as in that node. +pub fn optional_color_widget(parameter_widgets_info: ParameterWidgetsInfo, bool_input_index: usize, color_button: ColorInput) -> LayoutGroup { + let node_id = parameter_widgets_info.node_id; + let enabled = parameter_widgets_info + .document_node + .and_then(|document_node| document_node.inputs.get(bool_input_index)) + .and_then(|input| input.as_non_exposed_value()) + .and_then(|value| if let TaggedValue::Bool(enabled) = value { Some(*enabled) } else { None }); + let label_count = start_widgets(¶meter_widgets_info).len(); + let exposed = parameter_widgets_info.is_exposed(); + + let LayoutGroup::Row(mut row) = color_widget(parameter_widgets_info, color_button.disabled(enabled == Some(false))) else { + return LayoutGroup::row(Vec::new()); + }; + if let Some(enabled) = enabled + && !exposed + { + let checkbox = [ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + Separator::new(SeparatorStyle::Related).widget_instance(), + CheckboxInput::new(enabled) + .on_update(update_value_at_index(|x: &CheckboxInput| TaggedValue::Bool(x.checked), node_id, bool_input_index)) + .on_commit(commit_value) + .widget_instance(), + Separator::new(SeparatorStyle::Related).widget_instance(), + ]; + row.widgets.splice(label_count..label_count, checkbox); + } + + LayoutGroup::Row(row) +} + pub fn number_widget(parameter_widgets_info: ParameterWidgetsInfo, number_props: NumberInput) -> Vec { let mut widgets = start_widgets(¶meter_widgets_info); @@ -2157,7 +2191,8 @@ pub(crate) fn black_and_white_properties(node_id: NodeId, context: &mut NodeProp let number_input = NumberInput::default().mode_increment().unit("%").min(-200.).max(300.); - let tint = color_widget(ParameterWidgetsInfo::new(node_id, TintInput, true, context), ColorInput::default()); + let use_tint: ParameterRef = UseTintInput.into(); + let tint = optional_color_widget(ParameterWidgetsInfo::new(node_id, TintInput, false, context), use_tint.input_index, ColorInput::default()); let mut layout = vec![tint]; let params: &[(ParameterRef, Color, f64)] = &[ diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 2635368393f..4a58e2743e5 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -2266,6 +2266,22 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], inputs_count = 51; } + // Black & White gained a Use Tint toggle ahead of its tint color; a non-black tint used to be the only way to tint + if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster::black_and_white::IDENTIFIER) && inputs_count == 8 { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + document.network_interface.replace_implementation(node_id, network_path, &mut node_template); + let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 0), old_inputs[0].clone(), network_path); + for (index, input) in old_inputs.iter().enumerate().skip(1).take(7) { + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index + 1), input.clone(), network_path); + } + let use_tint = !matches!(old_inputs[1].as_value(), Some(TaggedValue::Color(color)) if *color == Color::BLACK); + document + .network_interface + .set_input(&InputConnector::node_at_index(*node_id, 1), NodeInput::value(TaggedValue::Bool(use_tint), false), network_path); + inputs_count = 9; + } + if reference == DefinitionIdentifier::ProtoNode(graphene_std::repeat::repeat_on_points::IDENTIFIER) && inputs_count == 2 { let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); document.network_interface.replace_implementation(node_id, network_path, &mut node_template); diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 215849c8d3f..f7db9480732 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -644,9 +644,8 @@ impl TaggedValue { }); } - // Hex syntax (e.g. "000000ff"), which a string literal default reaches here without its quotes - let hex = input.trim().trim_matches('"').trim().trim_start_matches('#'); - let color = SRGBA8::from_hex_str(hex).map(Color::from); + // Hex syntax (e.g. "#1cd1ad", or "#1cd1ad70" with alpha), which a string literal default reaches here without its quotes + let color = input.trim().trim_matches('"').trim().strip_prefix('#').and_then(SRGBA8::from_hex_str).map(Color::from); if color.is_none() { log::error!("Invalid default value color string: {input}"); } @@ -654,7 +653,7 @@ impl TaggedValue { } fn to_gradient(input: &str) -> Option { - // String syntax: (e.g. "000000ff, ff0000ff") + // String syntax: (e.g. "#000000ff, #ff0000ff") let stops = input.split(',').filter_map(|s| to_color(s.trim())).collect::>(); match stops.len() { 0 => { @@ -1039,16 +1038,17 @@ mod paint_default_parsing { ); } - /// A hex string default reaches the parser without the quotes its literal had in the node signature, and must still parse. + /// A hex string default reaches the parser without the quotes its literal had in the node signature, and must carry its hash prefix. #[test] - fn hex_string_color_default_parses_without_quotes() { + fn hex_string_color_default_requires_its_hash_prefix() { let tint = Some(TaggedValue::Color(Color::from(SRGBA8::new(225, 211, 179, 255)))); - assert_eq!(TaggedValue::from_primitive_string("e1d3b3", &item!(Color)), tint, "a bare hex default should resolve"); assert_eq!( TaggedValue::from_primitive_string("\"#e1d3b3\"", &item!(Color)), tint, "a quoted, hash-prefixed hex default should resolve" ); + assert_eq!(TaggedValue::from_primitive_string("#e1d3b3ff", &item!(Color)), tint, "an alpha-suffixed hex default should resolve"); + assert_eq!(TaggedValue::from_primitive_string("e1d3b3", &item!(Color)), None, "a bare hex default should be rejected"); } /// Table-era documents stored the red-slash "no paint" fill as an empty color table, which must keep diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index 36bad6da5a2..8f1b57ffc4f 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -622,7 +622,8 @@ fn black_and_white>( #[implementations(Raster, Color, Gradient)] #[gpu_image] image: Item, - #[default(Color::BLACK)] tint: Item, + use_tint: Item, + #[default("#e1d3b3")] tint: Item, #[default(40.)] #[range] #[soft(-200..300)] @@ -650,6 +651,7 @@ fn black_and_white>( ) -> Item { let mut image = image; let tint = tint.into_element(); + let use_tint = use_tint.into_element(); let reds = reds.into_element(); let yellows = yellows.into_element(); let greens = greens.into_element(); @@ -685,18 +687,17 @@ fn black_and_white>( yellow_part * yellows + (red_part - yellow_part) * reds + (green_part - yellow_part) * greens }; - let luminance = gray_base + additional; + let luminance = (gray_base + additional).clamp(0., 1.); + if !use_tint { + return Color::from_gamma_srgb_channels(luminance, luminance, luminance, alpha_part); + } - // TODO: Fix "Color" blend mode implementation so it matches the expected behavior perfectly (it's currently close) - // Apply luminance substitution in gamma space - let [tr, tg, tb, _] = tint.to_gamma_srgb_channels(); - let tint_luma_rec_601 = 0.3 * tr + 0.59 * tg + 0.11 * tb; - let delta = luminance - tint_luma_rec_601; - let result_r = (tr + delta).clamp(0., 1.); - let result_g = (tg + delta).clamp(0., 1.); - let result_b = (tb + delta).clamp(0., 1.); + // The tint takes on the gray's luminosity the way the Luminosity blend mode would + let [tint_r, tint_g, tint_b, _] = tint.to_gamma_srgb_channels(); + let tint_luma = luma_rec_601_fixed_point(tint_r, tint_g, tint_b); + let [tinted_r, tinted_g, tinted_b] = set_luminosity(tint_r, tint_g, tint_b, tint_luma, luminance); - Color::from_gamma_srgb_channels(result_r, result_g, result_b, alpha_part) + Color::from_gamma_srgb_channels(tinted_r, tinted_g, tinted_b, alpha_part) }); image } @@ -1911,7 +1912,7 @@ fn photo_filter>( #[implementations(Raster, Color, Gradient)] #[gpu_image] image: Item, - #[default("ec8a00")] color: Item, + #[default("#ec8a00")] color: Item, #[default(25.)] density: Item, #[default(true)] preserve_luminosity: Item, ) -> Item { @@ -1937,7 +1938,7 @@ fn photo_filter>( let mut b = linear_to_srgb(filtered[2].clamp(0., 1.)); if preserve_luminosity { - [r, g, b] = set_luminosity(r, g, b, luma_rec_601_fixed(r, g, b), luma_rec_601_fixed(r_in, g_in, b_in)); + [r, g, b] = set_luminosity(r, g, b, luma_rec_601_fixed_point(r, g, b), luma_rec_601_fixed_point(r_in, g_in, b_in)); } Color::from_gamma_srgb_channels(r, g, b, alpha) @@ -1959,7 +1960,7 @@ fn multiply_matrix(matrix: &[[f32; 3]; 3], vector: [f32; 3]) -> [f32; 3] { } /// The Rec. 601 luma in the 14-bit fixed point that PSD interop depends on. -fn luma_rec_601_fixed(r: f32, g: f32, b: f32) -> f32 { +fn luma_rec_601_fixed_point(r: f32, g: f32, b: f32) -> f32 { (4915. * r + 9667. * g + 1802. * b) / 16384. } @@ -2160,6 +2161,61 @@ mod tests { } } + /// Runs Black & White with the default sliders on one gamma-space RGB value (0..255) and returns the gamma-space result on the same scale. + fn run_black_and_white(input: [f32; 3], tint: [f32; 3]) -> [f32; 3] { + let pixel = Color::from_gamma_srgb_channels(input[0] / 255., input[1] / 255., input[2] / 255., 1.); + let tint = Color::from_gamma_srgb_channels(tint[0] / 255., tint[1] / 255., tint[2] / 255., 1.); + let result = black_and_white( + (), + Item::new_from_element(pixel), + true.into(), + tint.into(), + 40_f32.into(), + 60_f32.into(), + 40_f32.into(), + 60_f32.into(), + 20_f32.into(), + 80_f32.into(), + ); + let [r, g, b, _] = result.into_element().to_gamma_srgb_channels(); + [r * 255., g * 255., b * 255.] + } + + #[test] + fn black_and_white_tint_takes_the_grays_luminosity() { + for (input, tint, expected) in [ + ([200., 200., 200.], [225., 211., 179.], [213., 199., 167.]), + ([50., 50., 50.], [225., 211., 179.], [63., 49., 17.]), + ([200., 100., 50.], [225., 211., 179.], [133., 119., 87.]), + ([200., 200., 200.], [30., 60., 120.], [176., 202., 255.]), + ([50., 50., 50.], [30., 60., 120.], [22., 52., 112.]), + ([200., 100., 50.], [30., 60., 120.], [92., 122., 182.]), + ] { + let actual = run_black_and_white(input, tint); + for (actual, expected) in actual.iter().zip(expected) { + assert!((actual - expected).abs() <= 1., "{input:?} tinted {tint:?}: expected {expected}, got {actual}"); + } + } + } + + #[test] + fn black_and_white_clipped_channels_are_pulled_toward_the_luminosity() { + // A pure red tint over grays, where the shifted channels run out of range + for (gray, expected) in [ + (1., [3.33, 0., 0.]), + (38., [126.67, 0., 0.]), + (75., [250.01, 0., 0.]), + (78., [255., 2.15, 2.15]), + (129., [255., 75., 75.]), + (200., [255., 176.43, 176.43]), + ] { + let actual = run_black_and_white([gray, gray, gray], [255., 0., 0.]); + for (actual, expected) in actual.iter().zip(expected) { + assert!((actual - expected).abs() <= 0.51, "gray {gray} tinted red: expected {expected}, got {actual}"); + } + } + } + /// Runs the node on one gamma-space RGB value (0..255) with the master sliders, colorize, and one range's sliders at /// its default range values, returning the gamma-space result on the same scale. fn run_hue_saturation(input: [f32; 3], master: [f32; 3], colorize: Option<[f32; 3]>, range: Option<(HueSaturationRange, [f32; 3])>) -> [f32; 3] { From 3be0d8c6707028aa6fb24c10dbb9f50c630fecd0 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 15 Sep 2026 20:12:31 -0700 Subject: [PATCH 41/46] Rename the SpectrumInput widget to SliderInput (#4541) Rename the 'SpectrumInput' widget to 'SliderInput' --- .../color_picker/color_picker_message.rs | 6 +- .../color_picker_message_handler.rs | 36 +++--- .../messages/layout/layout_message_handler.rs | 32 ++--- .../layout/utility_types/layout_widget.rs | 6 +- .../utility_types/widgets/input_widgets.rs | 22 ++-- .../document/node_graph/node_properties.rs | 122 +++++++++--------- .../floating-menus/ColorPicker.svelte | 2 +- .../src/components/widgets/WidgetSpan.svelte | 6 +- ...pectrumInput.svelte => SliderInput.svelte} | 22 ++-- 9 files changed, 127 insertions(+), 127 deletions(-) rename frontend/src/components/widgets/inputs/{SpectrumInput.svelte => SliderInput.svelte} (97%) diff --git a/editor/src/messages/color_picker/color_picker_message.rs b/editor/src/messages/color_picker/color_picker_message.rs index 0a969d581bf..072a2876fca 100644 --- a/editor/src/messages/color_picker/color_picker_message.rs +++ b/editor/src/messages/color_picker/color_picker_message.rs @@ -1,4 +1,4 @@ -use crate::messages::layout::utility_types::widgets::input_widgets::{SpectrumInputUpdate, VisualColorPickersInputUpdate}; +use crate::messages::layout::utility_types::widgets::input_widgets::{SliderInputUpdate, VisualColorPickersInputUpdate}; use crate::messages::prelude::*; use graphene_std::vector::style::{FillChoice, GradientHueDirection, GradientInterpolation, GradientSpace, GradientSpread}; @@ -45,8 +45,8 @@ pub enum ColorPickerMessage { /// Swap the current "new" color with the captured "old" color. SwapNewWithOld, - /// `SpectrumInput` change: marker move/insert/delete, midpoint move/reset, or active marker selection changed. - GradientUpdate { update: SpectrumInputUpdate }, + /// `SliderInput` change: marker move/insert/delete, midpoint move/reset, or active marker selection changed. + GradientUpdate { update: SliderInputUpdate }, /// Gradient spread choice from the gradient "Ends" selection. SetGradientSpread { gradient_spread: GradientSpread }, /// Gradient cyclic choice: whether the stops wrap as a cycle, from the "Cyclic" checkbox. diff --git a/editor/src/messages/color_picker/color_picker_message_handler.rs b/editor/src/messages/color_picker/color_picker_message_handler.rs index 208fa10e9a2..9ce33e5144a 100644 --- a/editor/src/messages/color_picker/color_picker_message_handler.rs +++ b/editor/src/messages/color_picker/color_picker_message_handler.rs @@ -1,6 +1,6 @@ use crate::messages::color_picker::color_picker_message::{HsvChannel, RgbChannel}; use crate::messages::layout::utility_types::widget_prelude::*; -use crate::messages::layout::utility_types::widgets::input_widgets::{ColorPresetsInputUpdate, SpectrumInputUpdate, SpectrumMarker, VisualColorPickersInputUpdate}; +use crate::messages::layout::utility_types::widgets::input_widgets::{ColorPresetsInputUpdate, SliderInputUpdate, SliderMarker, VisualColorPickersInputUpdate}; use crate::messages::prelude::*; use graphene_std::Color; use graphene_std::color::SRGBA8; @@ -377,10 +377,10 @@ impl ColorPickerMessageHandler { }); } - /// Apply an incoming `SpectrumInput` intent to the gradient state and broadcast the result. - fn apply_gradient_update(&mut self, update: SpectrumInputUpdate, responses: &mut VecDeque) { + /// Apply an incoming `SliderInput` intent to the gradient state and broadcast the result. + fn apply_gradient_update(&mut self, update: SliderInputUpdate, responses: &mut VecDeque) { // Active marker selection is the one update that doesn't mutate the gradient - if let SpectrumInputUpdate::ActiveMarker { + if let SliderInputUpdate::ActiveMarker { active_marker_index, active_marker_is_midpoint, } = update @@ -401,19 +401,19 @@ impl ColorPickerMessageHandler { let Some(mut gradient) = self.gradient.clone() else { return }; match update { - SpectrumInputUpdate::MoveMarker { index, position } => { + SliderInputUpdate::MoveMarker { index, position } => { let new_index = gradient.move_stop(index as usize, position, self.gradient_cyclic); if Some(index) == self.active_marker_index { self.active_marker_index = Some(new_index as u32); } } - SpectrumInputUpdate::MoveMidpoint { index, position } => { + SliderInputUpdate::MoveMidpoint { index, position } => { if (index as usize) >= gradient.len() { return; } gradient.set_midpoint(index as usize, position.clamp(MIN_MIDPOINT, MAX_MIDPOINT)); } - SpectrumInputUpdate::InsertMarker { position } => { + SliderInputUpdate::InsertMarker { position } => { let new_index = gradient.insert_stop(position, self.gradient_settings()); self.active_marker_index = Some(new_index as u32); self.active_marker_is_midpoint = false; @@ -422,7 +422,7 @@ impl ColorPickerMessageHandler { self.snapshot_old(); } } - SpectrumInputUpdate::InsertDuplicate { index, position } => { + SliderInputUpdate::InsertDuplicate { index, position } => { let source = index as usize; let Some(insert_index) = gradient.duplicate_stop(source, position, self.gradient_cyclic) else { return; @@ -432,7 +432,7 @@ impl ColorPickerMessageHandler { self.active_marker_index = Some(dragged_index as u32); self.active_marker_is_midpoint = false; } - SpectrumInputUpdate::RemoveDuplicate { index } => { + SliderInputUpdate::RemoveDuplicate { index } => { let anchor = index as usize; if anchor >= gradient.len() || gradient.len() <= 2 { return; @@ -449,7 +449,7 @@ impl ColorPickerMessageHandler { self.active_marker_index = Some(active - 1); } } - SpectrumInputUpdate::DeleteMarker { index } => { + SliderInputUpdate::DeleteMarker { index } => { // Enforce minimum stop count. The gradient editor needs at least 2 stops to remain meaningful. if gradient.len() <= 2 || (index as usize) >= gradient.len() { return; @@ -463,10 +463,10 @@ impl ColorPickerMessageHandler { self.snapshot_old(); } } - SpectrumInputUpdate::ResetMidpoint { index } => { + SliderInputUpdate::ResetMidpoint { index } => { gradient.reset_midpoint(index as usize); } - SpectrumInputUpdate::ResetMarker { index } => { + SliderInputUpdate::ResetMarker { index } => { let i = index as usize; let count = gradient.len(); if i >= count { @@ -483,7 +483,7 @@ impl ColorPickerMessageHandler { self.active_marker_index = Some(new_index as u32); } } - SpectrumInputUpdate::ActiveMarker { .. } => unreachable!("handled above"), + SliderInputUpdate::ActiveMarker { .. } => unreachable!("handled above"), } responses.add(FrontendMessage::ColorPickerColorChanged { @@ -514,10 +514,10 @@ impl ColorPickerMessageHandler { if let Some(gradient) = &self.gradient { // For gradient editing, the markers' handle colors mirror their gradient stop colors let markers = (0..gradient.len()) - .filter_map(|i| Some(SpectrumMarker::new(gradient.position(i, self.gradient_cyclic), gradient.midpoint(i), gradient.color(i)?))) + .filter_map(|i| Some(SliderMarker::new(gradient.position(i, self.gradient_cyclic), gradient.midpoint(i), gradient.color(i)?))) .collect(); let mut row_widgets = vec![ - SpectrumInput::new(GradientStops::from(gradient)) + SliderInput::new(GradientStops::from(gradient)) .track_space(self.gradient_space) .track_cyclic(self.gradient_cyclic) .track_hue_direction(self.gradient_hue_direction) @@ -531,7 +531,7 @@ impl ColorPickerMessageHandler { .allow_reorder(true) .allow_select(true) .disabled(self.disabled) - .on_update(|update: &SpectrumInputUpdate| ColorPickerMessage::GradientUpdate { update: update.clone() }.into()) + .on_update(|update: &SliderInputUpdate| ColorPickerMessage::GradientUpdate { update: update.clone() }.into()) .widget_instance(), ]; @@ -556,12 +556,12 @@ impl ColorPickerMessageHandler { return Message::NoOp; }; let update = if is_midpoint { - SpectrumInputUpdate::MoveMidpoint { + SliderInputUpdate::MoveMidpoint { index: captured_index, position: new_value / 100., } } else { - SpectrumInputUpdate::MoveMarker { + SliderInputUpdate::MoveMarker { index: captured_index, position: new_value / 100., } diff --git a/editor/src/messages/layout/layout_message_handler.rs b/editor/src/messages/layout/layout_message_handler.rs index 46d19c402ce..8e0746b6508 100644 --- a/editor/src/messages/layout/layout_message_handler.rs +++ b/editor/src/messages/layout/layout_message_handler.rs @@ -255,20 +255,20 @@ impl LayoutMessageHandler { responses.add(callback_message); } - Widget::SpectrumInput(spectrum_input) => { + Widget::SliderInput(slider_input) => { let callback_message = match action { - WidgetValueAction::Commit => (spectrum_input.on_commit.callback)(&()), + WidgetValueAction::Commit => (slider_input.on_commit.callback)(&()), WidgetValueAction::Update => { - let Ok(update) = serde_json::from_value::(value) else { - warn!("SpectrumInput update was not able to be parsed as SpectrumInputUpdate"); + let Ok(update) = serde_json::from_value::(value) else { + warn!("SliderInput update was not able to be parsed as SliderInputUpdate"); return; }; // Don't mutate the stored widget here: leaving its old values lets the layout diff detect a change // when the new layout is rebuilt with the updated state. Otherwise the frontend's stored layout // keeps stale values for `activeMarkerIndex`, etc., and any other widget's diff (e.g. the position - // NumberInput) will trigger Svelte to re-spread those stale props onto SpectrumInput, clobbering + // NumberInput) will trigger Svelte to re-spread those stale props onto SliderInput, clobbering // its local `activeMarkerIndex` and making subsequent drags target the wrong stop. - (spectrum_input.on_update.callback)(&update) + (slider_input.on_update.callback)(&update) } }; @@ -565,20 +565,20 @@ fn populate_computed_display_fields(layout: &mut Layout) { }) .collect(); } - Widget::SpectrumInput(spectrum_input) => { + Widget::SliderInput(slider_input) => { // The track strip spans exactly 0 to 1, which no spread affects, so the widget carries no spread of its own let settings = graphene_std::vector::style::GradientSettings { spread: Default::default(), - cyclic: spectrum_input.track_cyclic, - space: spectrum_input.track_space, - hue_direction: spectrum_input.track_hue_direction, - interpolation: spectrum_input.track_interpolation, + cyclic: slider_input.track_cyclic, + space: slider_input.track_space, + hue_direction: slider_input.track_hue_direction, + interpolation: slider_input.track_interpolation, }; - let track_gradient = graphene_std::vector::style::Gradient::from(&spectrum_input.track); - spectrum_input.track_samples = track_gradient + let track_gradient = graphene_std::vector::style::Gradient::from(&slider_input.track); + slider_input.track_samples = track_gradient .interpolated_samples_or_black(settings) .into_iter() - .map(|(position, color, _)| SpectrumSample::new(position, color)) + .map(|(position, color, _)| SliderSample::new(position, color)) .collect(); // The end caps sample the track's boundary colors, which a cyclic wrap makes the wrapped interval's boundary-crossing color rather than the outermost stops' let track_evaluator = track_gradient.evaluator(settings); @@ -586,8 +586,8 @@ fn populate_computed_display_fields(layout: &mut Layout) { let color = track_evaluator.evaluate(t); SRGBA8::from(color).to_css_hex() }; - spectrum_input.track_start_css = cap(0.); - spectrum_input.track_end_css = cap(1.); + slider_input.track_start_css = cap(0.); + slider_input.track_end_css = cap(1.); } Widget::ColorComparisonInput(comparison) => { let contrasting = |color: Option| color.map_or(SRGBA8::BLACK, |color| color.contrasting_text_color()).to_css_hex(); diff --git a/editor/src/messages/layout/utility_types/layout_widget.rs b/editor/src/messages/layout/utility_types/layout_widget.rs index e53ab5ca880..114b6a55f89 100644 --- a/editor/src/messages/layout/utility_types/layout_widget.rs +++ b/editor/src/messages/layout/utility_types/layout_widget.rs @@ -470,7 +470,7 @@ impl LayoutGroup { | Widget::ParameterExposeButton(_) | Widget::ColorComparisonInput(_) | Widget::ColorPresetsInput(_) - | Widget::SpectrumInput(_) + | Widget::SliderInput(_) | Widget::TransferCurveInput(_) | Widget::VisualColorPickersInput(_) => continue, }; @@ -823,7 +823,7 @@ pub enum Widget { PopoverButton(PopoverButton), RadioInput(RadioInput), Separator(Separator), - SpectrumInput(SpectrumInput), + SliderInput(SliderInput), TextAreaInput(TextAreaInput), TextButton(TextButton), TextInput(TextInput), @@ -888,7 +888,7 @@ impl DiffUpdate { | Widget::WorkingColorsInput(_) | Widget::ColorComparisonInput(_) | Widget::ColorPresetsInput(_) - | Widget::SpectrumInput(_) + | Widget::SliderInput(_) | Widget::TransferCurveInput(_) | Widget::VisualColorPickersInput(_) => None, }; diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index 28d81bb866b..55879b7fd6e 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -624,7 +624,7 @@ pub enum TransferCurveInputUpdate { #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Clone, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)] #[derivative(Debug, PartialEq, Default)] -pub struct SpectrumInput { +pub struct SliderInput { // Content /// The colored gradient drawn behind the markers (display-only, caller-owned). #[widget_builder(constructor)] @@ -644,7 +644,7 @@ pub struct SpectrumInput { /// Straight-alpha samples the frontend draws as the stops of an SVG gradient filling the track strip. Auto-populated from `track` at layout-send time. #[serde(rename = "trackSamples")] #[widget_builder(skip)] - pub track_samples: Vec, + pub track_samples: Vec, /// Hex string for the track strip's leftmost solid-color end-cap. Auto-populated by evaluating `track` at position 0. #[serde(rename = "trackStartCSS")] #[widget_builder(skip)] @@ -654,7 +654,7 @@ pub struct SpectrumInput { #[widget_builder(skip)] pub track_end_css: String, /// The handles the user can drag along the track. Their handle colors are caller-owned (e.g., for a gradient editor they follow the stop colors, for a "Shadows/Midpoints/Highlights" widget they're hardcoded). - pub markers: Vec, + pub markers: Vec, #[serde(rename = "activeMarkerIndex")] pub active_marker_index: Option, #[serde(rename = "activeMarkerIsMidpoint")] @@ -689,7 +689,7 @@ pub struct SpectrumInput { // Callbacks #[serde(skip)] #[derivative(Debug = "ignore", PartialEq = "ignore")] - pub on_update: WidgetCallback, + pub on_update: WidgetCallback, #[serde(skip)] #[derivative(Debug = "ignore", PartialEq = "ignore")] pub on_commit: WidgetCallback<()>, @@ -697,12 +697,12 @@ pub struct SpectrumInput { #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct SpectrumMarker { +pub struct SliderMarker { /// Position along the track, normally 0..1. A shifted or stretched non-cyclic ramp can push it outside, where it is not drawn. position: f64, /// Midpoint (0..1) of the interval to the next marker, used only with `show_midpoints`. The last marker's midpoint spans the wrap of a cyclic track, or is otherwise ignored. midpoint: f64, - /// CSS color string for the marker handle's fill. Set via `SpectrumMarker::new` from a linear [`Color`], + /// CSS color string for the marker handle's fill. Set via `SliderMarker::new` from a linear [`Color`], /// discarding any transparency so the handle always shows the RGB that steers the interpolation. #[serde(rename = "handleColorCSS")] handle_color_css: String, @@ -717,7 +717,7 @@ pub struct SpectrumMarker { between_neighbors: bool, } -impl SpectrumMarker { +impl SliderMarker { pub fn new(position: f64, midpoint: f64, handle_color: Color) -> Self { let handle_color_css = format!("#{}", SRGBA8::from(handle_color).to_rgb_hex()); Self { @@ -748,8 +748,8 @@ impl SpectrumMarker { #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct SpectrumSample { - /// Position (0..1) of the sample along the spectrum track, drawn as the SVG stop's `offset`. +pub struct SliderSample { + /// Position (0..1) of the sample along the slider track, drawn as the SVG stop's `offset`. position: f64, /// `#rrggbb` hex of the sample's color, drawn as the SVG stop's `stop-color`. color: String, @@ -757,7 +757,7 @@ pub struct SpectrumSample { alpha: f32, } -impl SpectrumSample { +impl SliderSample { pub fn new(position: f64, color: Color) -> Self { Self { position, @@ -769,7 +769,7 @@ impl SpectrumSample { #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum SpectrumInputUpdate { +pub enum SliderInputUpdate { MoveMarker { index: u32, position: f64, diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 8adc52eb2bd..0b8f4ce79cf 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -1346,12 +1346,12 @@ pub fn query_assign_colors_randomize(node_id: NodeId, context: &NodePropertiesCo }) } -/// 2-stop black-to-white gradient track for spectrum sliders that map a value to a grayscale axis. +/// 2-stop black-to-white gradient track for sliders that map a value to a grayscale axis. fn bw_track() -> Gradient { Gradient::from(vec![Color::BLACK, Color::WHITE]) } -/// 3-stop black-to-color-to-white gradient track for spectrum sliders that map a value to a hue's full luminance range. +/// 3-stop black-to-color-to-white gradient track for sliders that map a value to a hue's full luminance range. fn color_track(color: Color) -> Gradient { Gradient::from(vec![Color::BLACK, color, Color::WHITE]) } @@ -1369,7 +1369,7 @@ pub(crate) fn brightness_contrast_properties(node_id: NodeId, context: &mut Node let brightness_min = if use_classic_value { -100. } else { -150. }; let brightness_max = if use_classic_value { 100. } else { 150. }; - let brightness = spectrum_slider_row( + let brightness = gradient_slider_row( node_id, context, BrightnessInput, @@ -1385,7 +1385,7 @@ pub(crate) fn brightness_contrast_properties(node_id: NodeId, context: &mut Node let zero_position = -contrast_min / (100. - contrast_min); let mut contrast_track = Gradient::from(vec![Color::MIDDLE_GRAY, Color::BLACK, Color::MIDDLE_GRAY]); contrast_track.set_positions(&[0., zero_position, 1.]); - let contrast = spectrum_slider_row( + let contrast = gradient_slider_row( node_id, context, ContrastInput, @@ -1402,7 +1402,7 @@ pub(crate) fn brightness_contrast_properties(node_id: NodeId, context: &mut Node let mut layout = vec![brightness, contrast, LayoutGroup::row(use_classic)]; if use_classic_value { let number_input = NumberInput::default().mode_increment().min(0.).max(255.); - layout.push(spectrum_slider_row(node_id, context, ClassicPivotInput, bw_track(), Color::WHITE, 0., 255., 127., number_input)); + layout.push(gradient_slider_row(node_id, context, ClassicPivotInput, bw_track(), Color::WHITE, 0., 255., 127., number_input)); } layout @@ -1481,22 +1481,22 @@ pub(crate) fn levels_properties(node_id: NodeId, context: &mut NodePropertiesCon }; let input_range_params = [ - SpectrumSectionParam::new(shadows, Color::BLACK, 0., MarkerScale::Percent), - SpectrumSectionParam::new(midtones, Color::MIDDLE_GRAY, 1., MarkerScale::Gamma).between_neighbors(), - SpectrumSectionParam::new(highlights, Color::WHITE, 100., MarkerScale::Percent), + SliderSectionParam::new(shadows, Color::BLACK, 0., MarkerScale::Percent), + SliderSectionParam::new(midtones, Color::MIDDLE_GRAY, 1., MarkerScale::Gamma).between_neighbors(), + SliderSectionParam::new(highlights, Color::WHITE, 100., MarkerScale::Percent), ]; let output_range_params = [ - SpectrumSectionParam::new(output_minimums, Color::BLACK, 0., MarkerScale::Percent), - SpectrumSectionParam::new(output_maximums, Color::WHITE, 100., MarkerScale::Percent), + SliderSectionParam::new(output_minimums, Color::BLACK, 0., MarkerScale::Percent), + SliderSectionParam::new(output_maximums, Color::WHITE, 100., MarkerScale::Percent), ]; let mut layout = vec![channel]; - build_shared_spectrum_section(node_id, context, &bw_track(), &input_range_params, &mut layout); - build_shared_spectrum_section(node_id, context, &bw_track(), &output_range_params, &mut layout); + build_shared_slider_section(node_id, context, &bw_track(), &input_range_params, &mut layout); + build_shared_slider_section(node_id, context, &bw_track(), &output_range_params, &mut layout); layout } -/// How a shared spectrum marker's value maps onto its track. +/// How a shared slider marker's value maps onto its track. #[derive(Clone, Copy)] enum MarkerScale { /// A 0..100 percentage, placed linearly. @@ -1545,8 +1545,8 @@ impl MarkerScale { } } -/// One parameter of a shared spectrum section and how its marker sits on the track. -struct SpectrumSectionParam { +/// One parameter of a shared slider section and how its marker sits on the track. +struct SliderSectionParam { parameter: ParameterRef, handle_color: Color, /// The value a double-click resets to. @@ -1560,7 +1560,7 @@ struct SpectrumSectionParam { between_neighbors: bool, } -impl SpectrumSectionParam { +impl SliderSectionParam { fn new(parameter: impl Into, handle_color: Color, default_value: f64, scale: MarkerScale) -> Self { Self { parameter: parameter.into(), @@ -1589,10 +1589,10 @@ impl SpectrumSectionParam { } } -/// Append a section of related parameters as rows: a shared spectrum over `track` (with one marker per non-exposed parameter) sits on the first non-exposed row +/// Append a section of related parameters as rows: a shared slider over `track` (with one marker per non-exposed parameter) sits on the first non-exposed row /// alongside its 60px number input, and the remaining non-exposed rows show only their 60px number input. Exposed parameters render as the standard exposed-row display. /// Marker positions are clamped to non-decreasing display order so they never visually cross even if the underlying values do. -fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesContext, track: &Gradient, params: &[SpectrumSectionParam], layout: &mut Vec) { +fn build_shared_slider_section(node_id: NodeId, context: &mut NodePropertiesContext, track: &Gradient, params: &[SliderSectionParam], layout: &mut Vec) { // Snapshot exposure and values before the mutable-borrow loop let exposure_and_value: Vec<(bool, f64)> = match get_document_node(node_id, context) { Ok(document_node) => params @@ -1608,7 +1608,7 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo }) .collect(), Err(err) => { - log::error!("Could not get document node in build_shared_spectrum_section: {err}"); + log::error!("Could not get document node in build_shared_slider_section: {err}"); return; } }; @@ -1655,12 +1655,12 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo } } - let spectrum_markers: Vec = marker_positions + let slider_markers: Vec = marker_positions .iter() .zip(&marker_colors_and_links) .zip(&marker_between) .map(|((&position, &(handle_color, paired, dashed)), &between)| { - let mut marker = SpectrumMarker::new(position, 0.5, handle_color); + let mut marker = SliderMarker::new(position, 0.5, handle_color); if paired { marker = marker.pair_with_next(); } @@ -1674,11 +1674,11 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo }) .collect(); - // Build the shared spectrum widget (placed on the first non-exposed row) - let spectrum_widget = (!spectrum_markers.is_empty()).then(|| { - SpectrumInput::new(GradientStops::from(track)) + // Build the shared slider widget (placed on the first non-exposed row) + let slider_widget = (!slider_markers.is_empty()).then(|| { + SliderInput::new(GradientStops::from(track)) .track_space(GradientSpace::RgbGamma) - .markers(spectrum_markers) + .markers(slider_markers) .show_midpoints(false) .allow_insert(false) .allow_delete(false) @@ -1691,9 +1691,9 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo let marker_scales = marker_scales.clone(); let marker_positions = marker_positions.clone(); let marker_between = marker_between.clone(); - move |update: &SpectrumInputUpdate| { + move |update: &SliderInputUpdate| { let i = match update { - SpectrumInputUpdate::MoveMarker { index, .. } | SpectrumInputUpdate::ResetMarker { index } => *index as usize, + SliderInputUpdate::MoveMarker { index, .. } | SliderInputUpdate::ResetMarker { index } => *index as usize, _ => return Message::NoOp, }; let (Some(&input_index), Some(&scale), Some(&between), Some(&default_position)) = @@ -1709,17 +1709,17 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo let right = (i + 1..marker_positions.len()).find(|&j| bounding(j)).map_or(1., |j| marker_positions[j]); let scale_position = match update { - SpectrumInputUpdate::MoveMarker { position, .. } if between => { + SliderInputUpdate::MoveMarker { position, .. } if between => { let span = right - left; if span <= f64::EPSILON { return Message::NoOp; } ((position - left) / span).clamp(0., 1.) } - SpectrumInputUpdate::MoveMarker { position, .. } => *position, + SliderInputUpdate::MoveMarker { position, .. } => *position, // A default that would cross a neighbor falls back to the midpoint between them - SpectrumInputUpdate::ResetMarker { .. } if between || cyclic || (left..=right).contains(&default_position) => default_position, - SpectrumInputUpdate::ResetMarker { .. } => (left + right) / 2., + SliderInputUpdate::ResetMarker { .. } if between || cyclic || (left..=right).contains(&default_position) => default_position, + SliderInputUpdate::ResetMarker { .. } => (left + right) / 2., _ => return Message::NoOp, }; NodeGraphMessage::SetInputValue { @@ -1733,9 +1733,9 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo .on_commit(commit_value) .widget_instance() }); - let spectrum_owner = marker_input_indices.first().copied(); + let slider_owner = marker_input_indices.first().copied(); - // One row per parameter: first non-exposed carries the shared spectrum, others get just a number input + // One row per parameter: first non-exposed carries the shared slider, others get just a number input for (i, param) in params.iter().enumerate() { let (exposed, current) = exposure_and_value[i]; let input_index = param.parameter.input_index; @@ -1748,10 +1748,10 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo let mut row = start_widgets(&ParameterWidgetsInfo::at_index(node_id, input_index, true, context)); row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - if Some(input_index) == spectrum_owner - && let Some(spectrum) = &spectrum_widget + if Some(input_index) == slider_owner + && let Some(slider) = &slider_widget { - row.push(spectrum.clone()); + row.push(slider.clone()); row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); } @@ -1898,7 +1898,7 @@ pub(crate) fn hue_saturation_properties(node_id: NodeId, context: &mut NodePrope let mut layout = vec![enum_choice::().for_socket(range_info).disabled(colorize_value).property_row()]; layout.extend([ - spectrum_slider_row( + gradient_slider_row( node_id, context, hue, @@ -1909,7 +1909,7 @@ pub(crate) fn hue_saturation_properties(node_id: NodeId, context: &mut NodePrope hue_default, NumberInput::default().mode_increment().unit("°").min(hue_min).max(hue_max), ), - spectrum_slider_row( + gradient_slider_row( node_id, context, saturation, @@ -1920,7 +1920,7 @@ pub(crate) fn hue_saturation_properties(node_id: NodeId, context: &mut NodePrope saturation_default, NumberInput::default().mode_increment().unit("%").min(saturation_min).max(100.), ), - spectrum_slider_row( + gradient_slider_row( node_id, context, lightness, @@ -1937,12 +1937,12 @@ pub(crate) fn hue_saturation_properties(node_id: NodeId, context: &mut NodePrope if !colorize_value && let (Some(values), Some(defaults)) = (range_values, range_defaults) { let [falloff_start, range_start, range_end, falloff_end] = values; let params = [ - SpectrumSectionParam::new(falloff_start, Color::WHITE, defaults[0], MarkerScale::Degrees).pair_with_next(), - SpectrumSectionParam::new(range_start, Color::WHITE, defaults[1], MarkerScale::Degrees).dash_to_next(), - SpectrumSectionParam::new(range_end, Color::WHITE, defaults[2], MarkerScale::Degrees).pair_with_next(), - SpectrumSectionParam::new(falloff_end, Color::WHITE, defaults[3], MarkerScale::Degrees), + SliderSectionParam::new(falloff_start, Color::WHITE, defaults[0], MarkerScale::Degrees).pair_with_next(), + SliderSectionParam::new(range_start, Color::WHITE, defaults[1], MarkerScale::Degrees).dash_to_next(), + SliderSectionParam::new(range_end, Color::WHITE, defaults[2], MarkerScale::Degrees).pair_with_next(), + SliderSectionParam::new(falloff_end, Color::WHITE, defaults[3], MarkerScale::Degrees), ]; - build_shared_spectrum_section(node_id, context, &hue_track, ¶ms, &mut layout); + build_shared_slider_section(node_id, context, &hue_track, ¶ms, &mut layout); } let colorize = bool_widget(ParameterWidgetsInfo::new(node_id, ColorizeInput, true, context), CheckboxInput::default()); @@ -1951,7 +1951,7 @@ pub(crate) fn hue_saturation_properties(node_id: NodeId, context: &mut NodePrope layout } -/// A single-marker `SpectrumInput` over `track` driving the number at `input_index`: the marker sits at `position`, double-click +/// A single-marker `SliderInput` over `track` driving the number at `input_index`: the marker sits at `position`, double-click /// returns it to `default_position`, and each move sets the input to `value_at` the new position. fn value_slider( node_id: NodeId, @@ -1961,18 +1961,18 @@ fn value_slider( position: f64, default_position: Option, value_at: impl Fn(f64) -> TaggedValue + 'static + Send + Sync, -) -> SpectrumInput { - SpectrumInput::new(track) +) -> SliderInput { + SliderInput::new(track) .track_space(GradientSpace::RgbGamma) - .markers(vec![SpectrumMarker::new(position, 0.5, handle_color)]) + .markers(vec![SliderMarker::new(position, 0.5, handle_color)]) .show_midpoints(false) .allow_insert(false) .allow_delete(false) .allow_reorder(false) - .on_update(move |update: &SpectrumInputUpdate| { + .on_update(move |update: &SliderInputUpdate| { let new_position = match update { - SpectrumInputUpdate::MoveMarker { index: 0, position } => Some(*position), - SpectrumInputUpdate::ResetMarker { index: 0 } => default_position, + SliderInputUpdate::MoveMarker { index: 0, position } => Some(*position), + SliderInputUpdate::ResetMarker { index: 0 } => default_position, _ => None, }; let Some(new_position) = new_position else { return Message::NoOp }; @@ -2048,8 +2048,8 @@ pub(crate) fn range_slider_widget(parameter_widgets_info: ParameterWidgetsInfo, ) } -/// Build a row with a single-marker `SpectrumInput` and a 60px `NumberInput`. The marker maps `value_min..value_max` to position 0..1, and double-click resets to `default_value`. -fn spectrum_slider_row( +/// Build a row with a single-marker `SliderInput` over `track` and a 60px `NumberInput`. The marker maps `value_min..value_max` to position 0..1, and double-click resets to `default_value`. +fn gradient_slider_row( node_id: NodeId, context: &mut NodePropertiesContext, parameter: impl Into, @@ -2069,7 +2069,7 @@ fn spectrum_slider_row( .and_then(|input| input.as_non_exposed_value()) .and_then(|tagged| if let TaggedValue::F32(value) = tagged { Some(*value as f64) } else { None }); - // Only add the spectrum and number widgets when the input is not exposed + // Only add the slider and number widgets when the input is not exposed if let Some(current) = current { let slider = SliderRange { min: value_min, @@ -2116,12 +2116,12 @@ pub(crate) fn threshold_properties(node_id: NodeId, context: &mut NodeProperties use graphene_std::raster::threshold::*; let params = [ - SpectrumSectionParam::new(MinLuminanceInput, Color::WHITE, 50., MarkerScale::Percent).dash_to_next(), - SpectrumSectionParam::new(MaxLuminanceInput, Color::WHITE, 100., MarkerScale::Percent), + SliderSectionParam::new(MinLuminanceInput, Color::WHITE, 50., MarkerScale::Percent).dash_to_next(), + SliderSectionParam::new(MaxLuminanceInput, Color::WHITE, 100., MarkerScale::Percent), ]; let mut layout = Vec::with_capacity(2); - build_shared_spectrum_section(node_id, context, &bw_track(), ¶ms, &mut layout); + build_shared_slider_section(node_id, context, &bw_track(), ¶ms, &mut layout); layout } @@ -2179,7 +2179,7 @@ pub(crate) fn color_balance_properties(node_id: NodeId, context: &mut NodeProper let mut layout = vec![tone]; for (parameter, track) in parameters.into_iter().zip(tracks) { - layout.push(spectrum_slider_row(node_id, context, parameter, track, Color::WHITE, -100., 100., 0., number_input.clone())); + layout.push(gradient_slider_row(node_id, context, parameter, track, Color::WHITE, -100., 100., 0., number_input.clone())); } layout.push(LayoutGroup::row(preserve_luminosity)); @@ -2204,7 +2204,7 @@ pub(crate) fn black_and_white_properties(node_id: NodeId, context: &mut NodeProp (MagentasInput.into(), Color::MAGENTA, 80.), ]; for (parameter, color, default) in params { - layout.push(spectrum_slider_row( + layout.push(gradient_slider_row( node_id, context, parameter.clone(), @@ -2268,7 +2268,7 @@ pub(crate) fn channel_mixer_properties(node_id: NodeId, context: &mut NodeProper layout.push(output_channel); } for (i, (parameter, &default)) in parameters.into_iter().zip(defaults.iter()).enumerate() { - layout.push(spectrum_slider_row( + layout.push(gradient_slider_row( node_id, context, parameter, @@ -2329,7 +2329,7 @@ pub(crate) fn selective_color_properties(node_id: NodeId, context: &mut NodeProp let mut layout = vec![colors]; for (i, parameter) in parameters.into_iter().enumerate() { - layout.push(spectrum_slider_row(node_id, context, parameter, tracks[i].clone(), Color::WHITE, -100., 100., 0., number_input.clone())); + layout.push(gradient_slider_row(node_id, context, parameter, tracks[i].clone(), Color::WHITE, -100., 100., 0., number_input.clone())); } layout.push(mode); diff --git a/frontend/src/components/floating-menus/ColorPicker.svelte b/frontend/src/components/floating-menus/ColorPicker.svelte index aec680c23fa..0b9b7a37767 100644 --- a/frontend/src/components/floating-menus/ColorPicker.svelte +++ b/frontend/src/components/floating-menus/ColorPicker.svelte @@ -83,7 +83,7 @@ .pickers-and-gradient .widget-span { --row-height: 24px; - &:has(.spectrum-input) { + &:has(.slider-input) { margin-top: 16px; .number-input { diff --git a/frontend/src/components/widgets/WidgetSpan.svelte b/frontend/src/components/widgets/WidgetSpan.svelte index 4bdb35d9300..6e2385640fc 100644 --- a/frontend/src/components/widgets/WidgetSpan.svelte +++ b/frontend/src/components/widgets/WidgetSpan.svelte @@ -15,7 +15,7 @@ import NumberInput from "/src/components/widgets/inputs/NumberInput.svelte"; import RadioInput from "/src/components/widgets/inputs/RadioInput.svelte"; import ReferencePointInput from "/src/components/widgets/inputs/ReferencePointInput.svelte"; - import SpectrumInput from "/src/components/widgets/inputs/SpectrumInput.svelte"; + import SliderInput from "/src/components/widgets/inputs/SliderInput.svelte"; import TextAreaInput from "/src/components/widgets/inputs/TextAreaInput.svelte"; import TextInput from "/src/components/widgets/inputs/TextInput.svelte"; import TransferCurveInput from "/src/components/widgets/inputs/TransferCurveInput.svelte"; @@ -243,8 +243,8 @@ }, }), }, - SpectrumInput: { - component: SpectrumInput, + SliderInput: { + component: SliderInput, getProps: (props, index) => ({ ...props, $$events: { diff --git a/frontend/src/components/widgets/inputs/SpectrumInput.svelte b/frontend/src/components/widgets/inputs/SliderInput.svelte similarity index 97% rename from frontend/src/components/widgets/inputs/SpectrumInput.svelte rename to frontend/src/components/widgets/inputs/SliderInput.svelte index e884405ea5a..6bf052a0797 100644 --- a/frontend/src/components/widgets/inputs/SpectrumInput.svelte +++ b/frontend/src/components/widgets/inputs/SliderInput.svelte @@ -3,22 +3,22 @@ import { preventEscapeClosingParentFloatingMenu } from "/src/components/layout/FloatingMenu.svelte"; import LayoutCol from "/src/components/layout/LayoutCol.svelte"; import LayoutRow from "/src/components/layout/LayoutRow.svelte"; - import type { GradientInterpolation, SpectrumInputUpdate, SpectrumMarker, SpectrumSample } from "/wrapper/pkg/graphite_wasm_wrapper"; + import type { GradientInterpolation, SliderInputUpdate, SliderMarker, SliderSample } from "/wrapper/pkg/graphite_wasm_wrapper"; const BUTTON_LEFT = 0; const BUTTON_RIGHT = 2; - const dispatch = createEventDispatcher<{ update: SpectrumInputUpdate; dragging: boolean }>(); + const dispatch = createEventDispatcher<{ update: SliderInputUpdate; dragging: boolean }>(); // Document-unique `id` for this instance's SVG gradient, referenced by its `url(#...)` - const gradientId = `spectrum-input-gradient-${String(Math.random()).substring(2)}`; + const gradientId = `slider-input-gradient-${String(Math.random()).substring(2)}`; - export let trackSamples: SpectrumSample[]; + export let trackSamples: SliderSample[]; export let trackStartCSS: string; export let trackEndCSS: string; export let trackCyclic = false; export let trackInterpolation: GradientInterpolation = "Linear"; - export let markers: SpectrumMarker[]; + export let markers: SliderMarker[]; export let activeMarkerIndex: number | undefined = 0; export let activeMarkerIsMidpoint = false; export let showMidpoints = true; @@ -92,7 +92,7 @@ return WHOLE_PATHS; } - function emit(intent: SpectrumInputUpdate) { + function emit(intent: SliderInputUpdate) { dispatch("update", intent); } @@ -151,7 +151,7 @@ } // A marker paired with its successor draws as one marker split down the middle while the two coincide (the successor drawing nothing) and as a half once apart - function markerShape(markers: SpectrumMarker[], index: number): MarkerShape { + function markerShape(markers: SliderMarker[], index: number): MarkerShape { const marker = markers[index]; const previous = markers[index - 1]; const next = markers[index + 1]; @@ -161,7 +161,7 @@ } // The spans from each marker passing `linked` to its successor, which on a wrapping track may cross the track's ends in two pieces - function markerSpans(markers: SpectrumMarker[], allowWrap: boolean, linked: (marker: SpectrumMarker) => boolean): { index: number; left: number; width: number }[] { + function markerSpans(markers: SliderMarker[], allowWrap: boolean, linked: (marker: SliderMarker) => boolean): { index: number; left: number; width: number }[] { const spans: { index: number; left: number; width: number }[] = []; markers.forEach((marker, index) => { @@ -616,7 +616,7 @@ // Map midpoint pairs to absolute track positions for rendering the diamond markers. // A rendered diamond's index is the index of the interval's left marker, which for the cyclic wrapped interval's diamond is the last marker. - function diamondPositions(markers: SpectrumMarker[], showMidpoints: boolean, trackCyclic: boolean, trackInterpolation: GradientInterpolation): number[] { + function diamondPositions(markers: SliderMarker[], showMidpoints: boolean, trackCyclic: boolean, trackInterpolation: GradientInterpolation): number[] { // A stepped ramp jumps at its stops, so no midpoint has anything to bias if (!showMidpoints || trackInterpolation === "Stepped" || markers.length < 2) return []; const positions = markers.slice(0, -1).map((marker, i) => marker.position + marker.midpoint * (markers[i + 1].position - marker.position)); @@ -645,7 +645,7 @@