diff --git a/desktop/src/render/state.rs b/desktop/src/render/state.rs index 7481cdd916..3cae549e54 100644 --- a/desktop/src/render/state.rs +++ b/desktop/src/render/state.rs @@ -1,8 +1,7 @@ -use std::borrow::Cow; use wgpu::PresentMode; use crate::window::Window; -use crate::wrapper::{TargetTexture, WgpuContext, WgpuExecutor}; +use crate::wrapper::{WgpuContext, WgpuExecutor}; #[derive(derivative::Derivative)] #[derivative(Debug)] @@ -19,7 +18,7 @@ pub(crate) struct RenderState { viewport_scale: [f32; 2], viewport_offset: [f32; 2], viewport_texture: Option>, - overlays_texture: Option, + overlays_texture: Option>, ui_texture: Option, bind_group: Option, #[derivative(Debug = "ignore")] @@ -233,11 +232,17 @@ impl RenderState { return; }; let size = glam::UVec2::new(viewport_texture.width(), viewport_texture.height()); - let result = futures::executor::block_on(self.executor.render_vello_scene_to_target_texture(&scene, size, &Default::default(), &mut self.overlays_texture)); - if let Err(e) = result { - tracing::error!("Error rendering overlays: {:?}", e); - return; + let result = futures::executor::block_on(self.executor.render_vello_scene(&scene, size, &Default::default(), None)); + match result { + Ok(texture) => { + self.overlays_texture = Some(texture); + } + Err(e) => { + self.overlays_texture = None; + tracing::error!("Error rendering overlays: {:?}", e); + } } + self.update_bindgroup(); } @@ -314,11 +319,7 @@ impl RenderState { fn update_bindgroup(&mut self) { self.surface_outdated = true; let viewport_texture_view = self.viewport_texture.as_ref().unwrap_or(&self.transparent_texture).create_view(&wgpu::TextureViewDescriptor::default()); - let overlays_texture_view = self - .overlays_texture - .as_ref() - .map(|target| Cow::Borrowed(target.view())) - .unwrap_or_else(|| Cow::Owned(self.transparent_texture.create_view(&wgpu::TextureViewDescriptor::default()))); + let overlays_texture_view = self.overlays_texture.as_ref().unwrap_or(&self.transparent_texture).create_view(&wgpu::TextureViewDescriptor::default()); let ui_texture_view = self.ui_texture.as_ref().unwrap_or(&self.transparent_texture).create_view(&wgpu::TextureViewDescriptor::default()); let bind_group = self.context.device.create_bind_group(&wgpu::BindGroupDescriptor { @@ -330,7 +331,7 @@ impl RenderState { }, wgpu::BindGroupEntry { binding: 1, - resource: wgpu::BindingResource::TextureView(overlays_texture_view.as_ref()), + resource: wgpu::BindingResource::TextureView(&overlays_texture_view), }, wgpu::BindGroupEntry { binding: 2, diff --git a/desktop/wrapper/src/lib.rs b/desktop/wrapper/src/lib.rs index 2f8c4c49f4..ad83ca4154 100644 --- a/desktop/wrapper/src/lib.rs +++ b/desktop/wrapper/src/lib.rs @@ -5,7 +5,6 @@ use message_dispatcher::DesktopWrapperMessageDispatcher; use messages::{DesktopFrontendMessage, DesktopWrapperMessage}; pub use graphite_editor::consts::{DOUBLE_CLICK_MILLISECONDS, FILE_EXTENSION}; -pub use wgpu_executor::TargetTexture; pub use wgpu_executor::WgpuContext; pub use wgpu_executor::WgpuContextBuilder; pub use wgpu_executor::WgpuExecutor; diff --git a/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs b/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs index ea321efd4c..cbfacb1715 100644 --- a/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs +++ b/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs @@ -58,12 +58,10 @@ impl MessageHandler for NewDocumentDialogMessageHa responses.add(NodeGraphMessage::RunDocumentGraph); responses.add(ViewportMessage::RepropagateUpdate); + responses.add(DocumentMessage::DeselectAllLayers); + responses.add(DeferMessage::AfterNavigationReady { - messages: vec![ - DocumentMessage::ZoomCanvasToFitAll.into(), - DocumentMessage::DeselectAllLayers.into(), - PortfolioMessage::AutoSaveActiveDocument.into(), - ], + messages: vec![DocumentMessage::ZoomCanvasToFitAll.into(), PortfolioMessage::AutoSaveActiveDocument.into()], }); responses.add(DocumentMessage::MarkAsSaved); diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index f8039baede..7150f13b9a 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -244,6 +244,7 @@ pub enum FrontendMessage { spacing: f64, interval: f64, visible: bool, + tilt: f64, }, UpdateDocumentScrollbars { position: (f64, f64), diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 229fbf1d4e..422f21880a 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -843,6 +843,7 @@ impl MessageHandler> for DocumentMes spacing: ruler_spacing, interval: ruler_interval, visible: self.rulers_visible, + tilt: if self.graph_view_overlay_open { 0. } else { current_ptz.tilt() }, }); } DocumentMessage::RenderScrollbars => { diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index aa1628eae4..3ce54d16b1 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -4,8 +4,8 @@ use crate::messages::portfolio::document::utility_types::network_interface::Node use crate::messages::prelude::*; use glam::{DAffine2, DVec2}; use graph_craft::document::NodeId; +use graphene_std::Color; use graphene_std::brush::brush_stroke::BrushStroke; -use graphene_std::color::Color; use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; use graphene_std::subpath::Subpath; diff --git a/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs b/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs index 3bf436251c..c7acb50c59 100644 --- a/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs +++ b/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs @@ -29,6 +29,12 @@ impl MessageHandler> for OverlaysMes use crate::messages::viewport::{Position, ToPhysical}; use wasm_bindgen::JsCast; + // Discard detached canvas after a panel reorganization remounts the DOM + if self.canvas.as_ref().is_some_and(|canvas| !canvas.is_connected()) { + self.canvas = None; + self.context = None; + } + let canvas = match &self.canvas { Some(canvas) => canvas, None => { diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index 979fae2291..e188278989 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -195,10 +195,6 @@ pub enum PortfolioMessage { UpdateOpenDocumentsList, UpdateWorkspacePanelLayout, ResetWorkspaceLayout, - ResetPanelGroupSizes { - /// Path of child indices from the root to the split node whose children's sizes should be reset to defaults. - split_path: Vec, - }, SetPanelGroupSizes { /// Path of child indices from the root to the split node whose children's sizes are being set. split_path: Vec, diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index b8e3c847a1..9530536279 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -453,6 +453,13 @@ impl MessageHandler> for Portfolio if let Some(layout) = state.workspace_layout { self.workspace_panel_layout = layout; responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); + + // Refill panels whose content was lost when the layout load remounted their frontend components + for group_id in self.workspace_panel_layout.root.all_group_ids() { + if let Some(panel_type) = self.workspace_panel_layout.panel_group(group_id).and_then(|g| g.active_panel_type()) { + self.refresh_panel_content(panel_type, responses); + } + } } let PersistedState { @@ -1330,13 +1337,16 @@ impl MessageHandler> for Portfolio Self::destroy_panel_layouts(target_active, responses); } + // Preserve the source panel's visual weight at its new location + let source_slot_size = self.workspace_panel_layout.find_source_slot_size(&tabs); + // Remove the dragged tabs from their current panel groups (without pruning, so the target group survives) for &panel_type in &tabs { self.remove_panel_from_layout(panel_type); } // Create the new panel group adjacent to the target, then prune empty groups - let Some(new_id) = self.workspace_panel_layout.split_panel_group(target_group, direction, tabs.clone(), active_tab_index) else { + let Some(new_id) = self.workspace_panel_layout.split_panel_group(target_group, direction, tabs.clone(), active_tab_index, source_slot_size) else { log::error!("Failed to insert split adjacent to panel group {target_group:?}"); return; }; @@ -1611,20 +1621,6 @@ impl MessageHandler> for Portfolio responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); responses.add(MenuBarMessage::SendLayout); } - PortfolioMessage::ResetPanelGroupSizes { split_path } => { - // Walk the tree to the target split node using the path - let mut node = &mut self.workspace_panel_layout.root; - for &index in &split_path { - let PanelLayoutSubdivision::Split { children } = node else { return }; - let Some(child) = children.get_mut(index) else { return }; - node = &mut child.subdivision; - } - - // Recalculate default sizes for this split node - node.recalculate_default_sizes(); - - responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); - } PortfolioMessage::SetPanelGroupSizes { split_path, sizes } => { // Walk the tree to the target split node using the path let mut node = &mut self.workspace_panel_layout.root; @@ -1977,7 +1973,12 @@ impl PortfolioMessageHandler { PanelType::Data => { // The Data panel's content is populated automatically as a side effect of the graph run completing, so there's nothing to do here } - PanelType::Document | PanelType::Welcome => {} + PanelType::Document | PanelType::Welcome => { + // Re-send the welcome screen buttons layout to repopulate after a remount + if self.document_ids.is_empty() { + responses.add(PortfolioMessage::RequestWelcomeScreenButtonsLayout); + } + } } } } diff --git a/editor/src/messages/portfolio/utility_types.rs b/editor/src/messages/portfolio/utility_types.rs index f7e0ebb386..905ed4d4d0 100644 --- a/editor/src/messages/portfolio/utility_types.rs +++ b/editor/src/messages/portfolio/utility_types.rs @@ -2,6 +2,11 @@ use graphene_std::Color; use graphene_std::raster::Image; use graphene_std::text::{Font, FontCache}; +/// Proportional share (0-1) for the document panel's side when splitting adjacent to non-document panels. +const DOCUMENT_PANEL_SHARE: f64 = 0.8; +/// Proportional share for each side when neither (or both) contain the document panel. +const EQUAL_PANEL_SHARE: f64 = 0.5; + #[derive(Debug, Default)] pub struct CachedData { pub font_cache: FontCache, @@ -244,25 +249,54 @@ impl WorkspacePanelLayout { /// The direction determines where the new group goes relative to the target. /// Left/Right creates a horizontal (row) split, Top/Bottom creates a vertical (column) split. /// Returns the ID of the newly created panel group, or `None` if insertion failed. - pub fn split_panel_group(&mut self, target_group_id: PanelGroupId, direction: DockingSplitDirection, tabs: Vec, active_tab_index: usize) -> Option { + /// + /// `source_slot_size` overrides the new panel's size (for moves where the old slot will be pruned). + /// If `None`, target's slot is split in the default ratio. + pub fn split_panel_group( + &mut self, + target_group_id: PanelGroupId, + direction: DockingSplitDirection, + tabs: Vec, + active_tab_index: usize, + source_slot_size: Option, + ) -> Option { let new_id = self.next_id(); let new_group = SplitChild { subdivision: PanelLayoutSubdivision::PanelGroup { id: new_id, state: PanelGroupState { tabs, active_tab_index }, }, - size: 50., + size: source_slot_size.unwrap_or(EQUAL_PANEL_SHARE), }; let insert_before = matches!(direction, DockingSplitDirection::Left | DockingSplitDirection::Top); let needs_horizontal = matches!(direction, DockingSplitDirection::Left | DockingSplitDirection::Right); - self.root.insert_split_adjacent(target_group_id, new_group, insert_before, needs_horizontal, 0).then_some(new_id) + self.root + .insert_split_adjacent(target_group_id, new_group, insert_before, needs_horizontal, 0, source_slot_size) + .then_some(new_id) + } + + /// Find the slot size of the panel group whose entire content is exactly the given tabs. + /// Returns `None` if the tabs span multiple groups or don't fill their group exactly. + pub fn find_source_slot_size(&self, tabs: &[PanelType]) -> Option { + if tabs.is_empty() { + return None; + } + let group_id = self.find_panel(tabs[0])?; + if !tabs.iter().all(|&t| self.find_panel(t) == Some(group_id)) { + return None; + } + let group = self.panel_group(group_id)?; + if group.tabs.len() != tabs.len() { + return None; + } + self.root.find_slot_size_by_group_id(group_id) } /// Recalculate the default sizes for all splits in the tree based on document panel proximity. pub fn recalculate_default_sizes(&mut self) { - self.root.recalculate_default_sizes(); + self.root.recalculate_default_sizes_recursive(); } /// Remember which panel group and tab index a panel was in before removal, so it can be restored there later. @@ -311,10 +345,10 @@ impl WorkspacePanelLayout { }, }, size: match panel_type { - PanelType::Data => 30., - PanelType::Properties => 45., - PanelType::Layers => 55., - _ => 50., + PanelType::Data => 0.3, + PanelType::Properties => 0.45, + PanelType::Layers => 0.55, + _ => EQUAL_PANEL_SHARE, }, }; @@ -330,7 +364,10 @@ impl WorkspacePanelLayout { if !matches!(&self.root, PanelLayoutSubdivision::Split { .. }) { let old_root = std::mem::replace(&mut self.root, PanelLayoutSubdivision::Split { children: vec![] }); if let PanelLayoutSubdivision::Split { children } = &mut self.root { - children.push(SplitChild { subdivision: old_root, size: 80. }); + children.push(SplitChild { + subdivision: old_root, + size: DOCUMENT_PANEL_SHARE, + }); } } @@ -340,7 +377,7 @@ impl WorkspacePanelLayout { while root_children.len() <= root_child_index { root_children.push(SplitChild { subdivision: PanelLayoutSubdivision::Split { children: vec![] }, - size: 20., + size: (1. - DOCUMENT_PANEL_SHARE), }); } @@ -351,7 +388,7 @@ impl WorkspacePanelLayout { if let PanelLayoutSubdivision::Split { children } = target { children.push(SplitChild { subdivision: old_subdivision, - size: 50., + size: EQUAL_PANEL_SHARE, }); } } @@ -386,10 +423,10 @@ impl Default for WorkspacePanelLayout { active_tab_index: 0, }, }, - size: 100., + size: 1., }], }, - size: 80., + size: DOCUMENT_PANEL_SHARE, }, SplitChild { subdivision: PanelLayoutSubdivision::Split { @@ -402,7 +439,7 @@ impl Default for WorkspacePanelLayout { active_tab_index: 0, }, }, - size: 50., + size: EQUAL_PANEL_SHARE, }, SplitChild { subdivision: PanelLayoutSubdivision::PanelGroup { @@ -412,11 +449,11 @@ impl Default for WorkspacePanelLayout { active_tab_index: 0, }, }, - size: 50., + size: EQUAL_PANEL_SHARE, }, ], }, - size: 20., + size: (1. - DOCUMENT_PANEL_SHARE), }, ], }, @@ -427,6 +464,15 @@ impl Default for WorkspacePanelLayout { } } +/// The share of the slot that should go to the old side when splitting it with a new side. +fn document_split_share(old_side: &PanelLayoutSubdivision, new_side: &PanelLayoutSubdivision) -> f64 { + match (old_side.contains_document(), new_side.contains_document()) { + (true, false) => DOCUMENT_PANEL_SHARE, + (false, true) => 1. - DOCUMENT_PANEL_SHARE, + _ => EQUAL_PANEL_SHARE, + } +} + impl PanelLayoutSubdivision { /// Find the panel group state for a given ID. pub fn find_group(&self, target_id: PanelGroupId) -> Option<&PanelGroupState> { @@ -463,9 +509,10 @@ impl PanelLayoutSubdivision { } } - /// Remove empty panel groups and collapse unnecessary nesting. - /// Does NOT collapse single-child splits into their child, as that would change subdivision depths - /// and break the direction-by-depth alternation system. + /// Remove empty groups/splits and flatten single-child `Split`-in-`Split` nesting (which docking sequences can create). + /// + /// Flattening preserves depth parity (and therefore direction). `PanelGroup`-only single-child splits are left + /// alone since collapsing would change the panel's depth and alter future wrap orientation. pub fn prune(&mut self) { let PanelLayoutSubdivision::Split { children } = self else { return }; @@ -477,6 +524,54 @@ impl PanelLayoutSubdivision { // Remove empty splits (splits that lost all their children after pruning) children.retain(|child| !matches!(&child.subdivision, PanelLayoutSubdivision::Split { children } if children.is_empty())); + + // Flatten single-child `Split`-in-`Split` nesting, rescaling sizes to preserve visual proportions + let mut i = 0; + while i < children.len() { + // Must be a `Split`... + let PanelLayoutSubdivision::Split { children: outer } = &children[i].subdivision else { + i += 1; + continue; + }; + // ...with exactly one child... + let [only_child] = outer.as_slice() else { + i += 1; + continue; + }; + // ...that is itself a `Split` + let PanelLayoutSubdivision::Split { .. } = &only_child.subdivision else { + i += 1; + continue; + }; + + // Remove the redundant wrapper + let removed = children.remove(i); + let outer_size = removed.size; + + // Extract the inner grandchildren + let PanelLayoutSubdivision::Split { children: mut outer_children } = removed.subdivision else { + continue; + }; + let Some(inner_split) = outer_children.pop() else { continue }; + let PanelLayoutSubdivision::Split { children: inner_children } = inner_split.subdivision else { + continue; + }; + + // Splice grandchildren in at the same position, scaling their sizes to fill the removed slot + let inner_total: f64 = inner_children.iter().map(|c| c.size).sum(); + for (offset, mut grandchild) in inner_children.into_iter().enumerate() { + grandchild.size = if inner_total > 0. { grandchild.size / inner_total * outer_size } else { outer_size }; + children.insert(i + offset, grandchild); + } + } + + // Renormalize to sum=1 since dock/prune cycles can compound shrinkage + let total: f64 = children.iter().map(|c| c.size).sum(); + if total > 0. && (total - 1.).abs() > 0.001 { + for child in children.iter_mut() { + child.size /= total; + } + } } /// Remove all non-document/non-welcome tabs from panel groups, leaving only document-related panels. @@ -503,7 +598,9 @@ impl PanelLayoutSubdivision { /// Inserts a new split child adjacent to a target panel group and returns whether the insertion was successful. /// Recurses to the deepest split closest to the target that matches the requested split direction. /// If the target is a direct child of a mismatched-direction split, this wraps it in a new sub-split. - pub fn insert_split_adjacent(&mut self, target_id: PanelGroupId, new_child: SplitChild, insert_before: bool, needs_horizontal: bool, depth: usize) -> bool { + /// + /// `source_slot_size` preserves the moved panel's visual weight. If `None`, uses the default split ratio. + pub fn insert_split_adjacent(&mut self, target_id: PanelGroupId, new_child: SplitChild, insert_before: bool, needs_horizontal: bool, depth: usize, source_slot_size: Option) -> bool { let PanelLayoutSubdivision::Split { children } = self else { return false }; let is_horizontal = depth.is_multiple_of(2); @@ -517,18 +614,35 @@ impl PanelLayoutSubdivision { // If the target is a direct child: we can certainly insert the new split, either as a sibling (if direction matches) or wrapping the target in a new split (if direction is mismatched) let target_is_direct_child = matches!(&children[containing_index].subdivision, PanelLayoutSubdivision::PanelGroup { id, .. } if *id == target_id); if target_is_direct_child { - // Direction matches and target is right here: insert as a sibling + // Direction matches: insert as sibling, sizing based on whether target will be pruned, source size hint, or default ratio if direction_matches { + let mut new_child = new_child; + let target_will_be_pruned = matches!(&children[containing_index].subdivision, PanelLayoutSubdivision::PanelGroup { state, .. } if state.tabs.is_empty()); + if target_will_be_pruned { + new_child.size = children[containing_index].size; + } else if let Some(hint) = source_slot_size { + new_child.size = hint; + } else { + let target_share = document_split_share(&children[containing_index].subdivision, &new_child.subdivision); + let total = children[containing_index].size; + children[containing_index].size = total * target_share; + new_child.size = total * (1. - target_share); + } + let insert_index = if insert_before { containing_index } else { containing_index + 1 }; children.insert(insert_index, new_child); } - // Direction mismatch: wrap the target in a new sub-split (at depth+1, which has the opposite direction of this and thus is the requested direction) + // Direction mismatch: wrap target in a sub-split at depth+1, sharing the slot in the default ratio else { let old_child_subdivision = std::mem::replace(&mut children[containing_index].subdivision, PanelLayoutSubdivision::Split { children: vec![] }); + + let old_share = document_split_share(&old_child_subdivision, &new_child.subdivision); let old_child = SplitChild { subdivision: old_child_subdivision, - size: 50., + size: old_share, }; + let mut new_child = new_child; + new_child.size = 1. - old_share; if let PanelLayoutSubdivision::Split { children: sub_children } = &mut children[containing_index].subdivision { if insert_before { @@ -547,7 +661,25 @@ impl PanelLayoutSubdivision { // The target is deeper, so recurse into the containing child's subtree and return its insertion outcome children[containing_index] .subdivision - .insert_split_adjacent(target_id, new_child.clone(), insert_before, needs_horizontal, depth + 1) + .insert_split_adjacent(target_id, new_child.clone(), insert_before, needs_horizontal, depth + 1, source_slot_size) + } + + /// Find the size of the `SplitChild` slot whose subdivision is the panel group with the given ID, if it exists. + pub fn find_slot_size_by_group_id(&self, group_id: PanelGroupId) -> Option { + let PanelLayoutSubdivision::Split { children } = self else { return None }; + for child in children { + if let PanelLayoutSubdivision::PanelGroup { id, .. } = &child.subdivision + && *id == group_id + { + return Some(child.size); + } + } + for child in children { + if let Some(size) = child.subdivision.find_slot_size_by_group_id(group_id) { + return Some(size); + } + } + None } /// Check if this subtree contains the document panel. @@ -558,39 +690,46 @@ impl PanelLayoutSubdivision { } } - /// Recalculate the default sizes for this subdivision's children based on proximity to the document panel. + /// Recalculate the default sizes for this subdivision's direct children based on proximity to the document panel. /// Splits directly surrounding the document panel use 80-20 weighting. /// All other splits use equal division. + /// Does not recurse into descendants: use [`Self::recalculate_default_sizes_recursive`] for that. pub fn recalculate_default_sizes(&mut self) { - if let PanelLayoutSubdivision::Split { children } = self { - let child_count = children.len(); - if child_count == 0 { - return; - } + let PanelLayoutSubdivision::Split { children } = self else { return }; - // Check if any child directly contains (or is) the document panel - let document_child_index = children.iter().position(|child| child.subdivision.contains_document()); + let child_count = children.len(); + if child_count == 0 { + return; + } - if let Some(document_index) = document_child_index { - // This split directly surrounds the document panel, so use 80-20 weighting - let non_document_count = child_count - 1; - let document_share = if non_document_count > 0 { 80. } else { 100. }; - let other_share = if non_document_count > 0 { 20. / non_document_count as f64 } else { 0. }; + // Check if any child directly contains (or is) the document panel + let document_child_index = children.iter().position(|child| child.subdivision.contains_document()); - for (i, child) in children.iter_mut().enumerate() { - child.size = if i == document_index { document_share } else { other_share }; - } - } else { - // This split doesn't directly contain the document, use equal division - let equal_share = 100. / child_count as f64; - for child in children.iter_mut() { - child.size = equal_share; - } + if let Some(document_index) = document_child_index { + // This split directly surrounds the document panel + let non_document_count = child_count - 1; + let document_share = if non_document_count > 0 { DOCUMENT_PANEL_SHARE } else { 1. }; + let other_share = if non_document_count > 0 { (1. - DOCUMENT_PANEL_SHARE) / non_document_count as f64 } else { 0. }; + + for (i, child) in children.iter_mut().enumerate() { + child.size = if i == document_index { document_share } else { other_share }; + } + } else { + // This split doesn't directly contain the document, use equal division + let equal_share = 1. / child_count as f64; + for child in children.iter_mut() { + child.size = equal_share; } + } + } - // Recurse into children + /// Recalculate the default sizes for this subdivision and all its descendant splits. + pub fn recalculate_default_sizes_recursive(&mut self) { + self.recalculate_default_sizes(); + + if let PanelLayoutSubdivision::Split { children } = self { for child in children.iter_mut() { - child.subdivision.recalculate_default_sizes(); + child.subdivision.recalculate_default_sizes_recursive(); } } } diff --git a/editor/src/messages/tool/tool_messages/select_tool.rs b/editor/src/messages/tool/tool_messages/select_tool.rs index 0eb1518f46..4202e374b0 100644 --- a/editor/src/messages/tool/tool_messages/select_tool.rs +++ b/editor/src/messages/tool/tool_messages/select_tool.rs @@ -1430,9 +1430,15 @@ impl Fsm for SelectToolFsmState { NestedSelectionBehavior::Deepest if remove => drag_deepest_manipulation(responses, selected, tool_data, document, true), NestedSelectionBehavior::Shallowest if !deepest => drag_shallowest_manipulation(responses, selected, tool_data, document, false, true), _ => { - responses.add(DocumentMessage::DeselectAllLayers); - tool_data.layers_dragging.clear(); - drag_deepest_manipulation(responses, selected, tool_data, document, false) + // Narrow multi-selection to just the clicked layer (no-op if it's already the sole selection) + let currently_selected = document.network_interface.selected_nodes().selected_layers(document.metadata()).collect::>(); + let already_only_selection = currently_selected.as_slice() == [intersection]; + + if !already_only_selection { + responses.add(DocumentMessage::DeselectAllLayers); + tool_data.layers_dragging.clear(); + drag_deepest_manipulation(responses, selected, tool_data, document, false) + } } } diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index 4252bd38ac..0fe3984254 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -417,6 +417,7 @@ impl NodeGraphExecutor { click_targets, clip_targets, vector_data, + backgrounds: _, } = render_output.metadata; // Run these update state messages immediately diff --git a/frontend/src/components/panels/Data.svelte b/frontend/src/components/panels/Data.svelte index b524ecefa8..400e556f5b 100644 --- a/frontend/src/components/panels/Data.svelte +++ b/frontend/src/components/panels/Data.svelte @@ -1,30 +1,15 @@ - + diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index aad6865bd1..51f771f25e 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -44,6 +44,7 @@ let rulerSpacing = 100; let rulerInterval = 100; let rulersVisible = true; + let rulerTilt = 0; // Rendered SVG viewport data let artworkSvg = ""; @@ -287,11 +288,12 @@ scrollbarMultiplier = { x: multiplier[0], y: multiplier[1] }; } - export function updateDocumentRulers(origin: [number, number], spacing: number, interval: number, visible: boolean) { + export function updateDocumentRulers(origin: [number, number], spacing: number, interval: number, visible: boolean, tilt: number) { rulerOrigin = { x: origin[0], y: origin[1] }; rulerSpacing = spacing; rulerInterval = interval; rulersVisible = visible; + rulerTilt = tilt; } // Update mouse cursor icon @@ -487,8 +489,8 @@ subscriptions.subscribeFrontendMessage("UpdateDocumentRulers", async (data) => { await tick(); - const { origin, spacing, interval, visible } = data; - updateDocumentRulers(origin, spacing, interval, visible); + const { origin, spacing, interval, visible, tilt } = data; + updateDocumentRulers(origin, spacing, interval, visible, tilt); }); // Update mouse cursor icon @@ -595,13 +597,29 @@ {#if rulersVisible} - + {/if} {#if rulersVisible} - + {/if} diff --git a/frontend/src/components/panels/Layers.svelte b/frontend/src/components/panels/Layers.svelte index c2ac6f93fa..e5d91fc88b 100644 --- a/frontend/src/components/panels/Layers.svelte +++ b/frontend/src/components/panels/Layers.svelte @@ -1,6 +1,5 @@ (dragInPanel = false)}> - - {#if layersPanelControlBarLeftLayout?.length > 0 && layersPanelControlBarRightLayout?.length > 0} + + {#if $portfolio.layersPanelControlBarLeftLayout?.length > 0 && $portfolio.layersPanelControlBarRightLayout?.length > 0} {/if} - + - + diff --git a/frontend/src/components/panels/Properties.svelte b/frontend/src/components/panels/Properties.svelte index 31e96629c9..fee12bd76a 100644 --- a/frontend/src/components/panels/Properties.svelte +++ b/frontend/src/components/panels/Properties.svelte @@ -1,30 +1,15 @@ - + diff --git a/frontend/src/components/panels/Welcome.svelte b/frontend/src/components/panels/Welcome.svelte index c21b57c389..a38d724ee5 100644 --- a/frontend/src/components/panels/Welcome.svelte +++ b/frontend/src/components/panels/Welcome.svelte @@ -1,30 +1,16 @@ -
+
+
{#if $nodeGraph.contextMenuInformation} +
{$nodeGraph.error.error} @@ -295,7 +298,7 @@ {#if $nodeGraph.clickTargets} -
+
{#each $nodeGraph.clickTargets.nodeClickTargets as pathString} @@ -318,9 +321,9 @@ {/if} -
+
- {#each $nodeGraph.wires.values() as map} + {#each $nodeGraphWires.values() as map} {#each map.values() as { pathString, dataType, thick, dashed }} {#if thick} -
- {#if $nodeGraph.updateImportsExports} - {#each $nodeGraph.updateImportsExports.imports as frontendOutput, index} +
+ {#if $nodeGraphImportsExports} + {#each $nodeGraphImportsExports.imports as frontendOutput, index} {#if frontendOutput} {#if frontendOutput.connectedTo.length > 0} @@ -365,10 +368,10 @@ on:pointerenter={() => (hoveringImportIndex = index)} on:pointerleave={() => (hoveringImportIndex = undefined)} class="edit-import-export import" - class:separator-bottom={index === 0 && $nodeGraph.updateImportsExports.addImportExport} - class:separator-top={index === 1 && $nodeGraph.updateImportsExports.addImportExport} - style:--offset-left={($nodeGraph.updateImportsExports.importPosition[0] - 8) / 24} - style:--offset-top={($nodeGraph.updateImportsExports.importPosition[1] - 8) / 24 + index} + class:separator-bottom={index === 0 && $nodeGraphImportsExports.addImportExport} + class:separator-top={index === 1 && $nodeGraphImportsExports.addImportExport} + style:--offset-left={($nodeGraphImportsExports.importPosition[0] - 8) / 24} + style:--offset-top={($nodeGraphImportsExports.importPosition[1] - 8) / 24 + index} > {#if editingNameImportIndex === index}
+
editor.addPrimaryImport()} />
{/if} {/each} - {#each $nodeGraph.updateImportsExports.exports as frontendInput, index} + {#each $nodeGraphImportsExports.exports as frontendInput, index} {#if frontendInput} {#if frontendInput.connectedTo !== "Connected to nothing."} @@ -437,12 +436,12 @@ on:pointerenter={() => (hoveringExportIndex = index)} on:pointerleave={() => (hoveringExportIndex = undefined)} class="edit-import-export export" - class:separator-bottom={index === 0 && $nodeGraph.updateImportsExports.addImportExport} - class:separator-top={index === 1 && $nodeGraph.updateImportsExports.addImportExport} - style:--offset-left={($nodeGraph.updateImportsExports.exportPosition[0] - 8) / 24} - style:--offset-top={($nodeGraph.updateImportsExports.exportPosition[1] - 8) / 24 + index} + class:separator-bottom={index === 0 && $nodeGraphImportsExports.addImportExport} + class:separator-top={index === 1 && $nodeGraphImportsExports.addImportExport} + style:--offset-left={($nodeGraphImportsExports.exportPosition[0] - 8) / 24} + style:--offset-top={($nodeGraphImportsExports.exportPosition[1] - 8) / 24 + index} > - {#if (hoveringExportIndex === index || editingNameExportIndex === index) && $nodeGraph.updateImportsExports.addImportExport} + {#if (hoveringExportIndex === index || editingNameExportIndex === index) && $nodeGraphImportsExports.addImportExport} {#if index > 0}
{/if} @@ -473,28 +472,24 @@ {/if}
{:else} -
+
editor.addPrimaryExport()} />
{/if} {/each} - {#if $nodeGraph.updateImportsExports.addImportExport} + {#if $nodeGraphImportsExports.addImportExport}
editor.addSecondaryImport()} />
editor.addSecondaryExport()} />
@@ -502,16 +497,16 @@ {#if $nodeGraph.reorderImportIndex !== undefined} {@const position = { - x: Number($nodeGraph.updateImportsExports.importPosition[0]), - y: Number($nodeGraph.updateImportsExports.importPosition[1]) + Number($nodeGraph.reorderImportIndex) * 24, + x: Number($nodeGraphImportsExports.importPosition[0]), + y: Number($nodeGraphImportsExports.importPosition[1]) + Number($nodeGraph.reorderImportIndex) * 24, }}
{/if} {#if $nodeGraph.reorderExportIndex !== undefined} {@const position = { - x: Number($nodeGraph.updateImportsExports.exportPosition[0]), - y: Number($nodeGraph.updateImportsExports.exportPosition[1]) + Number($nodeGraph.reorderExportIndex) * 24, + x: Number($nodeGraphImportsExports.exportPosition[0]), + y: Number($nodeGraphImportsExports.exportPosition[1]) + Number($nodeGraph.reorderExportIndex) * 24, }}
{/if} @@ -519,11 +514,11 @@
-
+
{#each Array.from($nodeGraph.nodes) - .filter(([nodeId, node]) => node.isLayer && $nodeGraph.visibleNodes.has(nodeId)) - .map(([_, node], nodeIndex) => ({ node, nodeIndex })) as { node, nodeIndex } (nodeIndex)} + .filter(([nodeId, node]) => node.isLayer && $visibleNodes.has(nodeId)) + .map(([_, node]) => node) as node (node.id)} {@const clipPathId = String(Math.random()).substring(2)} {@const stackDataInput = node.exposedInputs[0]} {@const layerAreaWidth = $nodeGraph.layerWidths.get(node.id) || 8} @@ -687,7 +682,7 @@
- {#each $nodeGraph.wires.values() as map} + {#each $nodeGraphWires.values() as map} {#each map.values() as { pathString, dataType, thick, dashed }} {#if !thick} {#each Array.from($nodeGraph.nodes) - .filter(([nodeId, node]) => !node.isLayer && $nodeGraph.visibleNodes.has(nodeId)) - .map(([_, node], nodeIndex) => ({ node, nodeIndex })) as { node, nodeIndex } (nodeIndex)} + .filter(([nodeId, node]) => !node.isLayer && $visibleNodes.has(nodeId)) + .map(([_, node]) => node) as node (node.id)} {@const exposedInputsOutputs = zipWithUndefined(node.exposedInputs, node.exposedOutputs)} {@const clipPathId = String(Math.random()).substring(2)} {@const description = node.reference ? $nodeGraph.nodeDescriptions.get(node.reference) : undefined} @@ -870,18 +865,25 @@ flex-direction: row; flex-grow: 1; - // We're displaying the dotted grid in a pseudo-element because `image-rendering` is an inherited property and we don't want it to apply to child elements - &::before { - content: ""; + .grid-background { position: absolute; width: 100%; height: 100%; - background-size: var(--grid-spacing) var(--grid-spacing); - background-position: calc(var(--grid-offset-x) - var(--grid-dot-radius)) calc(var(--grid-offset-y) - var(--grid-dot-radius)); - background-image: radial-gradient(circle at var(--grid-dot-radius) var(--grid-dot-radius), var(--color-3-darkgray) var(--grid-dot-radius), transparent 0); - background-repeat: repeat; - image-rendering: pixelated; - mix-blend-mode: screen; + pointer-events: none; + + // We're displaying the dotted grid in a pseudo-element because `image-rendering` is an inherited property and we don't want it to apply to child elements + &::before { + content: ""; + position: absolute; + width: 100%; + height: 100%; + background-size: var(--grid-spacing) var(--grid-spacing); + background-position: calc(var(--grid-offset-x) - var(--grid-dot-radius)) calc(var(--grid-offset-y) - var(--grid-dot-radius)); + background-image: radial-gradient(circle at var(--grid-dot-radius) var(--grid-dot-radius), var(--color-3-darkgray) var(--grid-dot-radius), transparent 0); + background-repeat: repeat; + image-rendering: pixelated; + mix-blend-mode: screen; + } } > img { diff --git a/frontend/src/components/widgets/inputs/RulerInput.svelte b/frontend/src/components/widgets/inputs/RulerInput.svelte index 1c8511ace7..8a90c5c2c7 100644 --- a/frontend/src/components/widgets/inputs/RulerInput.svelte +++ b/frontend/src/components/widgets/inputs/RulerInput.svelte @@ -5,11 +5,14 @@ const MAJOR_MARK_THICKNESS = 16; const MINOR_MARK_THICKNESS = 6; const MICRO_MARK_THICKNESS = 3; + const TAU = 2 * Math.PI; type RulerDirection = "Horizontal" | "Vertical"; export let direction: RulerDirection = "Vertical"; - export let origin: number; + export let originX: number; + export let originY: number; + export let tilt: number; export let numberInterval: number; export let majorMarkSpacing: number; export let minorDivisions = 5; @@ -19,62 +22,121 @@ let rulerLength = 0; let svgBounds = { width: "0px", height: "0px" }; - $: svgPath = computeSvgPath(direction, origin, majorMarkSpacing, minorDivisions, microDivisions, rulerLength); - $: svgTexts = computeSvgTexts(direction, origin, majorMarkSpacing, numberInterval, rulerLength); - - function computeSvgPath(direction: RulerDirection, origin: number, majorMarkSpacing: number, minorDivisions: number, microDivisions: number, rulerLength: number): string { - const isVertical = direction === "Vertical"; - const lineDirection = isVertical ? "H" : "V"; - - const offsetStart = mod(origin, majorMarkSpacing); - const shiftedOffsetStart = offsetStart - majorMarkSpacing; + type Axis = { sign: number; vec: [number, number] }; + + $: axes = computeAxes(tilt); + $: isHorizontal = direction === "Horizontal"; + $: trackedAxis = isHorizontal ? axes.horiz : axes.vert; + $: otherAxis = isHorizontal ? axes.vert : axes.horiz; + $: stretchFactor = 1 / Math.max(Math.abs(isHorizontal ? trackedAxis.vec[0] : trackedAxis.vec[1]), 1e-10); + $: stretchedSpacing = majorMarkSpacing * stretchFactor; + $: effectiveOrigin = computeEffectiveOrigin(direction, originX, originY, otherAxis); + $: svgPath = computeSvgPath(direction, effectiveOrigin, stretchedSpacing, stretchFactor, minorDivisions, microDivisions, rulerLength, otherAxis); + $: svgTexts = computeSvgTexts(direction, effectiveOrigin, stretchedSpacing, numberInterval, rulerLength, trackedAxis, otherAxis, tilt); + + function computeAxes(tilt: number): { horiz: Axis; vert: Axis } { + const normTilt = ((tilt % TAU) + TAU) % TAU; + const octant = Math.floor((normTilt + Math.PI / 4) / (Math.PI / 2)) % 4; + + const [c, s] = [Math.cos(tilt), Math.sin(tilt)]; + const posX: Axis = { sign: 1, vec: [c, s] }; + const posY: Axis = { sign: 1, vec: [-s, c] }; + const negX: Axis = { sign: -1, vec: [-c, -s] }; + const negY: Axis = { sign: -1, vec: [s, -c] }; + + if (octant === 0) return { horiz: posX, vert: posY }; + if (octant === 1) return { horiz: negY, vert: posX }; + if (octant === 2) return { horiz: negX, vert: negY }; + return { horiz: posY, vert: negX }; + } - const divisions = majorMarkSpacing / minorDivisions / microDivisions; - const majorMarksFrequency = minorDivisions * microDivisions; + function computeEffectiveOrigin(direction: RulerDirection, ox: number, oy: number, otherAxis: Axis): number { + const [vx, vy] = otherAxis.vec; + if (direction === "Horizontal") { + return Math.abs(vy) < 1e-10 ? ox : ox - oy * (vx / vy); + } else { + return Math.abs(vx) < 1e-10 ? oy : oy - ox * (vy / vx); + } + } - let dPathAttribute = ""; + function computeSvgPath( + direction: RulerDirection, + effectiveOrigin: number, + stretchedSpacing: number, + stretchFactor: number, + minorDivisions: number, + microDivisions: number, + rulerLength: number, + otherAxis: Axis, + ): string { + const adaptive = stretchFactor > 1.3 ? { minor: minorDivisions, micro: 1 } : { minor: minorDivisions, micro: microDivisions }; + const divisions = stretchedSpacing / adaptive.minor / adaptive.micro; + const majorMarksFrequency = adaptive.minor * adaptive.micro; + const shiftedOffsetStart = mod(effectiveOrigin, stretchedSpacing) - stretchedSpacing; + + const [vx, vy] = otherAxis.vec; + const flip = direction === "Horizontal" ? (vy > 0 ? -1 : 1) : vx > 0 ? -1 : 1; + const [dx, dy] = [vx * flip, vy * flip]; + const [sxBase, syBase] = direction === "Horizontal" ? [0, RULER_THICKNESS] : [RULER_THICKNESS, 0]; + + let path = ""; let i = 0; - for (let location = shiftedOffsetStart; location < rulerLength; location += divisions) { + for (let location = shiftedOffsetStart; location < rulerLength + RULER_THICKNESS; location += divisions) { let length; if (i % majorMarksFrequency === 0) length = MAJOR_MARK_THICKNESS; - else if (i % microDivisions === 0) length = MINOR_MARK_THICKNESS; + else if (i % adaptive.micro === 0) length = MINOR_MARK_THICKNESS; else length = MICRO_MARK_THICKNESS; i += 1; const destination = Math.round(location) + 0.5; - const startPoint = isVertical ? `${RULER_THICKNESS - length},${destination}` : `${destination},${RULER_THICKNESS - length}`; - dPathAttribute += `M${startPoint}${lineDirection}${RULER_THICKNESS} `; + const [sx, sy] = direction === "Horizontal" ? [destination, syBase] : [sxBase, destination]; + path += `M${sx},${sy}l${dx * length},${dy * length} `; } - return dPathAttribute; + return path; } - function computeSvgTexts(direction: RulerDirection, origin: number, majorMarkSpacing: number, numberInterval: number, rulerLength: number): { transform: string; text: string }[] { + function computeSvgTexts( + direction: RulerDirection, + effectiveOrigin: number, + stretchedSpacing: number, + numberInterval: number, + rulerLength: number, + trackedAxis: Axis, + otherAxis: Axis, + tilt: number, + ): { transform: string; text: string }[] { const isVertical = direction === "Vertical"; - const offsetStart = mod(origin, majorMarkSpacing); - const shiftedOffsetStart = offsetStart - majorMarkSpacing; + const [vx, vy] = otherAxis.vec; + const flip = isVertical ? (vx > 0 ? -1 : 1) : vy > 0 ? -1 : 1; + const tiltScale = tilt >= 0 ? 1 : 0.5; + const tipOffsetX = vx * flip * MAJOR_MARK_THICKNESS * tiltScale; + const tipOffsetY = vy * flip * MAJOR_MARK_THICKNESS * tiltScale; - const svgTextCoordinates = []; + const shiftedOffsetStart = mod(effectiveOrigin, stretchedSpacing) - stretchedSpacing; + const increments = Math.round((shiftedOffsetStart - effectiveOrigin) / stretchedSpacing); + let labelNumber = increments * numberInterval * trackedAxis.sign; - let labelNumber = (Math.ceil(-origin / majorMarkSpacing) - 1) * numberInterval; + const results: { transform: string; text: string }[] = []; - for (let location = shiftedOffsetStart; location < rulerLength; location += majorMarkSpacing) { + for (let location = shiftedOffsetStart; location < rulerLength; location += stretchedSpacing) { const destination = Math.round(location); - const x = isVertical ? 9 : destination + 2; - const y = isVertical ? destination + 1 : 9; + const x = isVertical ? 9 : destination + 2 + tipOffsetX; + const y = isVertical ? destination + 1 + tipOffsetY : 9; let transform = `translate(${x} ${y})`; if (isVertical) transform += " rotate(270)"; - const text = numberInterval >= 1 ? `${labelNumber}` : labelNumber.toFixed(Math.abs(Math.log10(numberInterval))).replace(/\.0+$/, ""); + const num = Math.abs(labelNumber) < 1e-9 ? 0 : labelNumber; + const text = numberInterval >= 1 ? `${num}` : num.toFixed(Math.abs(Math.log10(numberInterval))).replace(/\.0+$/, ""); - svgTextCoordinates.push({ transform, text }); + results.push({ transform, text }); - labelNumber += numberInterval; + labelNumber += numberInterval * trackedAxis.sign; } - return svgTextCoordinates; + return results; } export function resize() { @@ -83,7 +145,7 @@ const isVertical = direction === "Vertical"; const newLength = isVertical ? rulerInput.clientHeight : rulerInput.clientWidth; - const roundedUp = (Math.floor(newLength / majorMarkSpacing) + 1) * majorMarkSpacing; + const roundedUp = (Math.floor(newLength / stretchedSpacing) + 2) * stretchedSpacing; if (roundedUp !== rulerLength) { rulerLength = roundedUp; diff --git a/frontend/src/components/window/Panel.svelte b/frontend/src/components/window/Panel.svelte index 8b93f644a9..e7a54fb06a 100644 --- a/frontend/src/components/window/Panel.svelte +++ b/frontend/src/components/window/Panel.svelte @@ -94,7 +94,7 @@ // Only start a group drag from the tab bar background (not from a tab or button) if (e.button !== BUTTON_LEFT) return; if (e.target !== e.currentTarget) return; - if (!crossPanelDropAction) return; + if (!crossPanelDropAction && !splitDropAction) return; dragStartState = { tabIndex: tabActiveIndex, pointerX: e.clientX, pointerY: e.clientY, isGroupDrag: true }; dragging = false; @@ -142,13 +142,12 @@ dragging = true; - if (crossPanelDropAction) { - if (dragStartState.isGroupDrag) { - startCrossPanelDrag(panelId, [...panelTypes], tabActiveIndex, true); - } else { - const draggedTab = panelTypes[dragStartState.tabIndex]; - startCrossPanelDrag(panelId, [draggedTab], dragStartState.tabIndex, false); - } + // Group drags enter cross-panel state for edge docking even without crossPanelDropAction + if (dragStartState.isGroupDrag && (crossPanelDropAction || splitDropAction)) { + startCrossPanelDrag(panelId, [...panelTypes], tabActiveIndex, true); + } else if (!dragStartState.isGroupDrag && crossPanelDropAction) { + const draggedTab = panelTypes[dragStartState.tabIndex]; + startCrossPanelDrag(panelId, [draggedTab], dragStartState.tabIndex, false); } } @@ -165,7 +164,9 @@ insertionIndex = undefined; insertionMarkerLeft = undefined; - // Check if the pointer is over any other dockable panel's tab bar + // Skip cross-panel hover detection for sources that can't dock anywhere + if (!crossPanelDropAction && !splitDropAction) return; + if (crossPanelDropAction) { const tabBarTarget = Array.from(document.querySelectorAll("[data-panel-tab-bar]")).find((element) => { const targetPanelId = element.getAttribute("data-panel-tab-bar"); @@ -180,35 +181,35 @@ calculateForeignInsertionIndex(e.clientX, tabBarTargetId, tabBarTarget); return; } + } - // Check if the pointer is over any panel body's edge zone for split docking - const panelBody = Array.from(document.querySelectorAll("[data-panel-body]")).find((element) => { - const rect = element.getBoundingClientRect(); - return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom; - }); - - const bodyPanelId = panelBody && panelBody.getAttribute("data-panel-body"); - if (bodyPanelId) { - const rect = panelBody.getBoundingClientRect(); - let edge: DockingEdge | undefined = detectDockingEdge(e.clientX, e.clientY, rect); - - // Block center drops between document and non-document panels - if (edge === "Center") { - const targetIsDockable = panelBody.hasAttribute("data-panel-dockable"); - const sourceIsDockable = crossPanelDropAction !== undefined; - if (targetIsDockable !== sourceIsDockable) edge = undefined; - } - - if (edge) { - updateDockingHover(bodyPanelId, edge); - return; - } + // Check for edge-zone split docking + const panelBody = Array.from(document.querySelectorAll("[data-panel-body]")).find((element) => { + const rect = element.getBoundingClientRect(); + return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom; + }); + + const bodyPanelId = panelBody && panelBody.getAttribute("data-panel-body"); + if (bodyPanelId) { + const rect = panelBody.getBoundingClientRect(); + let edge: DockingEdge | undefined = detectDockingEdge(e.clientX, e.clientY, rect); + + // Center drops between different panels require both to be cross-panel-dockable (self-drops are always allowed as a no-op) + if (edge === "Center" && bodyPanelId !== panelId) { + const targetIsDockable = panelBody.hasAttribute("data-panel-dockable"); + const sourceIsDockable = crossPanelDropAction !== undefined; + if (!sourceIsDockable || !targetIsDockable) edge = undefined; } - // Not hovering any drop target - updateCrossPanelHover(undefined, undefined, undefined); - updateDockingHover(undefined, undefined); + if (edge) { + updateDockingHover(bodyPanelId, edge); + return; + } } + + // Not hovering any drop target + updateCrossPanelHover(undefined, undefined, undefined); + updateDockingHover(undefined, undefined); } function dragPointerUp() { @@ -273,7 +274,7 @@ dragging = false; insertionIndex = undefined; insertionMarkerLeft = undefined; - if (crossPanelDropAction) endCrossPanelDrag(); + endCrossPanelDrag(); removeDragListeners(); } diff --git a/frontend/src/components/window/PanelSubdivision.svelte b/frontend/src/components/window/PanelSubdivision.svelte index 1c76470426..8a7beeb2cb 100644 --- a/frontend/src/components/window/PanelSubdivision.svelte +++ b/frontend/src/components/window/PanelSubdivision.svelte @@ -8,6 +8,9 @@ const MIN_PANEL_SIZE = 100; const DOUBLE_CLICK_MILLISECONDS = 500; + // Must match DOCUMENT_PANEL_SHARE / NON_DOCUMENT_PANEL_SHARE in utility_types.rs + const DOCUMENT_PANEL_SHARE = 0.8; + const EQUAL_PANEL_SHARE = 0.5; const editor = getContext("editor"); const portfolio = getContext("portfolio"); @@ -24,11 +27,15 @@ let activeResizeCleanup: (() => void) | undefined = undefined; let lastGutterClickTarget: EventTarget | undefined = undefined; let lastGutterClickTime = 0; + let lastSubdivisionRef: PanelLayoutSubdivision | undefined = undefined; // At even depths (0, 2, 4...) children are in a row, at odd depths (1, 3, 5...) in a column $: horizontal = depth % 2 === 0; - // Reset overrides when the subdivision changes (e.g., backend sends a new layout) - $: if (subdivision) sizeOverrides = {}; + // Compare by reference because `safe_not_equal` treats any store update as changed, which would wipe drag overrides + $: if (subdivision !== lastSubdivisionRef) { + sizeOverrides = {}; + lastSubdivisionRef = subdivision; + } // Reactive array of resolved sizes (merging backend defaults with local overrides) $: resolvedSizes = subdivision && "Split" in subdivision ? subdivision.Split.children.map((child, index) => sizeOverrides[index] ?? child.size) : []; $: documentTabLabels = $portfolio.documents.map((doc: DocumentInfo) => { @@ -55,26 +62,41 @@ const parentElement = gutter.parentElement; if (!(nextSibling instanceof HTMLDivElement) || !(prevSibling instanceof HTMLDivElement) || !(parentElement instanceof HTMLDivElement)) return; - // Double-click resets both adjacent panels to their default sizes + // Double-click resets the two adjacent panels to the default ratio (80:20 near document, otherwise 50:50) const now = Date.now(); const isDoubleClick = now - lastGutterClickTime < DOUBLE_CLICK_MILLISECONDS && lastGutterClickTarget === gutter; + lastGutterClickTime = now; lastGutterClickTarget = gutter; + if (isDoubleClick) { - sizeOverrides = {}; - editor.resetPanelGroupSizes(splitPath); + const children = subdivision.Split.children; + const adjacentSum = resolvedSizes[prevIndex] + resolvedSizes[nextIndex]; + + const prevHasDocument = subtreeContainsDocument(children[prevIndex].subdivision); + const nextHasDocument = subtreeContainsDocument(children[nextIndex].subdivision); + + let prevShare = EQUAL_PANEL_SHARE; + if (prevHasDocument && !nextHasDocument) prevShare = DOCUMENT_PANEL_SHARE; + else if (!prevHasDocument && nextHasDocument) prevShare = 1 - DOCUMENT_PANEL_SHARE; + + sizeOverrides[prevIndex] = adjacentSum * prevShare; + sizeOverrides[nextIndex] = adjacentSum * (1 - prevShare); + sizeOverrides = sizeOverrides; + + const allSizes = children.map((child, i) => sizeOverrides[i] ?? child.size); + editor.setPanelGroupSizes(splitPath, allSizes); return; } const isHorizontal = horizontal; - const gutterSize = isHorizontal ? gutter.getBoundingClientRect().width : gutter.getBoundingClientRect().height; const nextSiblingSize = isHorizontal ? nextSibling.getBoundingClientRect().width : nextSibling.getBoundingClientRect().height; const prevSiblingSize = isHorizontal ? prevSibling.getBoundingClientRect().width : prevSibling.getBoundingClientRect().height; - const parentElementSize = isHorizontal ? parentElement.getBoundingClientRect().width : parentElement.getBoundingClientRect().height; - const totalResizingSpaceOccupied = gutterSize + nextSiblingSize + prevSiblingSize; - const proportionBeingResized = totalResizingSpaceOccupied / parentElementSize; + // Only redistribute within the two adjacent panels' combined flex-grow total + const adjacentFlexGrowTotal = resolvedSizes[prevIndex] + resolvedSizes[nextIndex]; + const adjacentPixelTotal = prevSiblingSize + nextSiblingSize; pointerCaptureId = e.pointerId; gutter.setPointerCapture(pointerCaptureId); @@ -88,7 +110,9 @@ activeResizeCleanup = undefined; if (gutterResizeRestore !== undefined) { - sizeOverrides = { ...sizeOverrides, [nextIndex]: gutterResizeRestore[0], [prevIndex]: gutterResizeRestore[1] }; + sizeOverrides[nextIndex] = gutterResizeRestore[0]; + sizeOverrides[prevIndex] = gutterResizeRestore[1]; + sizeOverrides = sizeOverrides; gutterResizeRestore = undefined; } }; @@ -102,11 +126,9 @@ if (gutterResizeRestore === undefined) gutterResizeRestore = [resolvedSizes[nextIndex], resolvedSizes[prevIndex]]; - sizeOverrides = { - ...sizeOverrides, - [nextIndex]: ((nextSiblingSize + mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100, - [prevIndex]: ((prevSiblingSize - mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100, - }; + sizeOverrides[nextIndex] = (adjacentFlexGrowTotal * (nextSiblingSize + mouseDelta)) / adjacentPixelTotal; + sizeOverrides[prevIndex] = (adjacentFlexGrowTotal * (prevSiblingSize - mouseDelta)) / adjacentPixelTotal; + sizeOverrides = sizeOverrides; }; const onPointerUp = () => { @@ -164,6 +186,12 @@ function isDocumentGroup(state: PanelGroupState): boolean { return state.tabs.some((t) => t === "Document" || t === "Welcome"); } + + function subtreeContainsDocument(node: PanelLayoutSubdivision): boolean { + if ("PanelGroup" in node) return isDocumentGroup(node.PanelGroup.state); + if ("Split" in node) return node.Split.children.some((child) => subtreeContainsDocument(child.subdivision)); + return false; + } {#if subdivision && "PanelGroup" in subdivision} diff --git a/frontend/src/stores/node-graph.ts b/frontend/src/stores/node-graph.ts index 4a91484717..24d7c7e796 100644 --- a/frontend/src/stores/node-graph.ts +++ b/frontend/src/stores/node-graph.ts @@ -6,6 +6,8 @@ import type { NodeGraphErrorDiagnostic, BoxSelection, FrontendClickTargets, Cont export type NodeGraphStore = ReturnType; +export type NodeGraphTransform = { scale: number; x: number; y: number }; + type NodeGraphStoreState = { box: BoxSelection | undefined; clickTargets: FrontendClickTargets | undefined; @@ -14,17 +16,12 @@ type NodeGraphStoreState = { layerWidths: Map; chainWidths: Map; hasLeftInputWire: Map; - updateImportsExports: MessageBody<"UpdateImportsExports"> | undefined; nodes: Map; - visibleNodes: Set; - /// The index is the exposed input index. The exports have a first key value of u32::MAX. - wires: Map>; wirePathInProgress: WirePath | undefined; nodeDescriptions: Map; nodeTypes: FrontendNodeType[]; thumbnails: Map; selected: bigint[]; - transform: { scale: number; x: number; y: number }; inSelectedNetwork: boolean; reorderImportIndex: number | undefined; reorderExportIndex: number | undefined; @@ -37,16 +34,12 @@ const initialState: NodeGraphStoreState = { layerWidths: new Map(), chainWidths: new Map(), hasLeftInputWire: new Map(), - updateImportsExports: undefined, nodes: new Map(), - visibleNodes: new Set(), - wires: new Map(), wirePathInProgress: undefined, nodeDescriptions: new Map(), nodeTypes: [], thumbnails: new Map(), selected: [], - transform: { scale: 1, x: 0, y: 0 }, inSelectedNetwork: true, reorderImportIndex: undefined, reorderExportIndex: undefined, @@ -59,6 +52,22 @@ const store: Writable = import.meta.hot?.data?.store || wri if (import.meta.hot) import.meta.hot.data.store = store; const { subscribe, update } = store; +// Separate transform store so pan/zoom updates don't trigger re-rendering the entire node graph +const transformStore: Writable = import.meta.hot?.data?.transformStore || writable({ scale: 1, x: 0, y: 0 }); +if (import.meta.hot) import.meta.hot.data.transformStore = transformStore; + +// Separate imports/exports store so viewport-anchored position updates don't trigger node re-renders +const importsExportsStore: Writable | undefined> = import.meta.hot?.data?.importsExportsStore || writable(undefined); +if (import.meta.hot) import.meta.hot.data.importsExportsStore = importsExportsStore; + +// Separate visible nodes store so viewport culling changes don't trigger full node re-renders +const visibleNodesStore: Writable> = import.meta.hot?.data?.visibleNodesStore || writable(new Set()); +if (import.meta.hot) import.meta.hot.data.visibleNodesStore = visibleNodesStore; + +// Separate wires store so wire path updates (e.g. export connector movement during pan) don't trigger node re-renders +const wiresStore: Writable>> = import.meta.hot?.data?.wiresStore || writable(new Map()); +if (import.meta.hot) import.meta.hot.data.wiresStore = wiresStore; + export function createNodeGraphStore(subscriptions: SubscriptionsRouter) { destroyNodeGraphStore(); @@ -108,10 +117,7 @@ export function createNodeGraphStore(subscriptions: SubscriptionsRouter) { }); subscriptions.subscribeFrontendMessage("UpdateImportsExports", (data) => { - update((state) => { - state.updateImportsExports = data; - return state; - }); + importsExportsStore.set(data); }); subscriptions.subscribeFrontendMessage("UpdateInSelectedNetwork", (data) => { @@ -148,20 +154,35 @@ export function createNodeGraphStore(subscriptions: SubscriptionsRouter) { }); subscriptions.subscribeFrontendMessage("UpdateVisibleNodes", (data) => { - update((state) => { - state.visibleNodes = new Set(data.nodes); - return state; + const newNodes = new Set(data.nodes); + + // Short-circuit when the visible set hasn't changed to avoid unnecessary re-renders + let changed = false; + const unsubscribe = visibleNodesStore.subscribe((current) => { + if (current.size !== newNodes.size) { + changed = true; + } else { + newNodes.forEach((node) => { + if (!current.has(node)) changed = true; + }); + } }); + unsubscribe(); + + if (!changed) return; + + visibleNodesStore.set(newNodes); }); subscriptions.subscribeFrontendMessage("UpdateNodeGraphWires", (data) => { - update((state) => { + if (data.wires.length === 0) return; + + wiresStore.update((wires) => { data.wires.forEach((wireUpdate) => { - let inputMap = state.wires.get(wireUpdate.id); - // If it doesn't exist, create it and set it in the outer map + let inputMap = wires.get(wireUpdate.id); if (!inputMap) { inputMap = new Map(); - state.wires.set(wireUpdate.id, inputMap); + wires.set(wireUpdate.id, inputMap); } if (wireUpdate.wirePathUpdate !== undefined) { inputMap.set(wireUpdate.inputIndex, wireUpdate.wirePathUpdate); @@ -169,15 +190,12 @@ export function createNodeGraphStore(subscriptions: SubscriptionsRouter) { inputMap.delete(wireUpdate.inputIndex); } }); - return state; + return wires; }); }); subscriptions.subscribeFrontendMessage("ClearAllNodeGraphWires", () => { - update((state) => { - state.wires.clear(); - return state; - }); + wiresStore.set(new Map()); }); subscriptions.subscribeFrontendMessage("UpdateNodeGraphSelection", (data) => { @@ -188,10 +206,7 @@ export function createNodeGraphStore(subscriptions: SubscriptionsRouter) { }); subscriptions.subscribeFrontendMessage("UpdateNodeGraphTransform", (data) => { - update((state) => { - state.transform = { scale: data.scale, x: data.translation[0], y: data.translation[1] }; - return state; - }); + transformStore.set({ scale: data.scale, x: data.translation[0], y: data.translation[1] }); }); subscriptions.subscribeFrontendMessage("UpdateNodeThumbnail", (data) => { @@ -208,7 +223,7 @@ export function createNodeGraphStore(subscriptions: SubscriptionsRouter) { }); }); - return { subscribe }; + return { subscribe, transformStore, importsExportsStore, visibleNodesStore, wiresStore }; } export function destroyNodeGraphStore() { diff --git a/frontend/src/stores/portfolio.ts b/frontend/src/stores/portfolio.ts index 8ce87951ac..4944d74c86 100644 --- a/frontend/src/stores/portfolio.ts +++ b/frontend/src/stores/portfolio.ts @@ -1,9 +1,12 @@ +import { tick } from "svelte"; +import { SvelteMap } from "svelte/reactivity"; import { writable } from "svelte/store"; import type { Writable } from "svelte/store"; import type { SubscriptionsRouter } from "/src/subscriptions-router"; import { downloadFile, downloadFileBlob, upload } from "/src/utility-functions/files"; import { rasterizeSVG } from "/src/utility-functions/rasterization"; -import type { EditorWrapper, DocumentInfo, WorkspacePanelLayout } from "/wrapper/pkg/graphite_wasm_wrapper"; +import { patchLayout } from "/src/utility-functions/widgets"; +import type { EditorWrapper, DocumentInfo, LayerPanelEntry, LayerStructureEntry, Layout, WorkspacePanelLayout } from "/wrapper/pkg/graphite_wasm_wrapper"; export type PortfolioStore = ReturnType; @@ -12,12 +15,28 @@ type PortfolioStoreState = { documents: DocumentInfo[]; activeDocumentIndex: number; panelLayout: WorkspacePanelLayout; + welcomeScreenButtonsLayout: Layout; + propertiesPanelLayout: Layout; + dataPanelLayout: Layout; + layersPanelControlBarLeftLayout: Layout; + layersPanelControlBarRightLayout: Layout; + layersPanelBottomBarLayout: Layout; + layerCache: SvelteMap; + layerStructure: LayerStructureEntry[]; }; const initialState: PortfolioStoreState = { unsaved: false, documents: [], activeDocumentIndex: 0, panelLayout: {}, + welcomeScreenButtonsLayout: [], + propertiesPanelLayout: [], + dataPanelLayout: [], + layersPanelControlBarLeftLayout: [], + layersPanelControlBarRightLayout: [], + layersPanelBottomBarLayout: [], + layerCache: new SvelteMap(), + layerStructure: [], }; let subscriptionsRouter: SubscriptionsRouter | undefined = undefined; @@ -104,6 +123,69 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: }); }); + // All panel layouts below live in this store so panels that remount during a panel-tree change keep their contents + subscriptions.subscribeLayoutUpdate("WelcomeScreenButtons", async (data) => { + await tick(); + update((state) => { + patchLayout(state.welcomeScreenButtonsLayout, data); + return state; + }); + }); + + subscriptions.subscribeLayoutUpdate("PropertiesPanel", async (data) => { + await tick(); + update((state) => { + patchLayout(state.propertiesPanelLayout, data); + return state; + }); + }); + + subscriptions.subscribeLayoutUpdate("DataPanel", async (data) => { + await tick(); + update((state) => { + patchLayout(state.dataPanelLayout, data); + return state; + }); + }); + + subscriptions.subscribeLayoutUpdate("LayersPanelControlLeftBar", async (data) => { + await tick(); + update((state) => { + patchLayout(state.layersPanelControlBarLeftLayout, data); + return state; + }); + }); + + subscriptions.subscribeLayoutUpdate("LayersPanelControlRightBar", async (data) => { + await tick(); + update((state) => { + patchLayout(state.layersPanelControlBarRightLayout, data); + return state; + }); + }); + + subscriptions.subscribeLayoutUpdate("LayersPanelBottomBar", async (data) => { + await tick(); + update((state) => { + patchLayout(state.layersPanelBottomBarLayout, data); + return state; + }); + }); + + subscriptions.subscribeFrontendMessage("UpdateDocumentLayerStructure", (data) => { + update((state) => { + state.layerStructure = data.layerStructure; + return state; + }); + }); + + subscriptions.subscribeFrontendMessage("UpdateDocumentLayerDetails", (data) => { + update((state) => { + state.layerCache.set(String(data.data.id), data.data); + return state; + }); + }); + return { subscribe }; } @@ -120,4 +202,12 @@ export function destroyPortfolioStore() { subscriptions.unsubscribeFrontendMessage("TriggerSaveFile"); subscriptions.unsubscribeFrontendMessage("TriggerExportImage"); subscriptions.unsubscribeFrontendMessage("UpdateWorkspacePanelLayout"); + subscriptions.unsubscribeLayoutUpdate("WelcomeScreenButtons"); + subscriptions.unsubscribeLayoutUpdate("PropertiesPanel"); + subscriptions.unsubscribeLayoutUpdate("DataPanel"); + subscriptions.unsubscribeLayoutUpdate("LayersPanelControlLeftBar"); + subscriptions.unsubscribeLayoutUpdate("LayersPanelControlRightBar"); + subscriptions.unsubscribeLayoutUpdate("LayersPanelBottomBar"); + subscriptions.unsubscribeFrontendMessage("UpdateDocumentLayerStructure"); + subscriptions.unsubscribeFrontendMessage("UpdateDocumentLayerDetails"); } diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 6eb1a89511..6335113daf 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -498,13 +498,6 @@ impl EditorWrapper { self.dispatch(message); } - #[wasm_bindgen(js_name = resetPanelGroupSizes)] - pub fn reset_panel_group_sizes(&self, split_path: JsValue) { - let split_path: Vec = serde_wasm_bindgen::from_value(split_path).unwrap(); - let message = PortfolioMessage::ResetPanelGroupSizes { split_path }; - self.dispatch(message); - } - #[wasm_bindgen(js_name = setPanelGroupSizes)] pub fn set_panel_group_sizes(&self, split_path: JsValue, sizes: JsValue) { let split_path: Vec = serde_wasm_bindgen::from_value(split_path).unwrap(); diff --git a/node-graph/interpreted-executor/src/util.rs b/node-graph/interpreted-executor/src/util.rs index ab3b8b4a58..e9877085fd 100644 --- a/node-graph/interpreted-executor/src/util.rs +++ b/node-graph/interpreted-executor/src/util.rs @@ -28,7 +28,7 @@ pub fn wrap_network_in_scope(mut network: NodeNetwork, editor_api: Arc>> = LazyLock::new(|| { - const SIZE: u32 = 16; - const HALF: u32 = 8; - - let mut data = vec![0_u8; (SIZE * SIZE * 4) as usize]; - for y in 0..SIZE { - for x in 0..SIZE { - let is_light = ((x / HALF) + (y / HALF)).is_multiple_of(2); - let value = if is_light { 0xff } else { 0xcc }; - let index = ((y * SIZE + x) * 4) as usize; - data[index] = value; - data[index + 1] = value; - data[index + 2] = value; - data[index + 3] = 0xff; - } - } - - Arc::new(data) -}); - -/// Creates a 16x16 tiling transparency checkerboard brush for Vello. -pub fn checkerboard_brush() -> peniko::Brush { - peniko::Brush::Image(peniko::ImageBrush { - image: peniko::ImageData { - data: peniko::Blob::new(CHECKERBOARD_IMAGE_DATA.clone()), - format: peniko::ImageFormat::Rgba8, - width: 16, - height: 16, - alpha_type: peniko::ImageAlphaType::Alpha, - }, - sampler: peniko::ImageSampler { - x_extend: peniko::Extend::Repeat, - y_extend: peniko::Extend::Repeat, - quality: peniko::ImageQuality::Low, // Nearest-neighbor sampling for crisp edges - alpha: 1., - }, - }) -} - #[derive(Clone, Copy, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] enum MaskType { @@ -125,15 +86,17 @@ impl SvgRender { pub fn format_svg(&mut self, bounds_min: DVec2, bounds_max: DVec2) { let (x, y) = bounds_min.into(); let (size_x, size_y) = (bounds_max - bounds_min).into(); - let defs = &self.svg_defs; - let svg_header = format!(r#"{defs}"#,); + let svg_header = format!( + r#"{defs}"#, + defs = &self.svg_defs + ); + self.svg_defs = String::new(); self.svg.insert(0, svg_header.into()); self.svg.push("".into()); } /// Wraps the SVG with `...`, which allows for rotation pub fn wrap_with_transform(&mut self, transform: DAffine2, size: Option) { - let defs = &self.svg_defs; let view_box = size .map(|size| format!("viewBox=\"0 0 {} {}\" width=\"{}\" height=\"{}\"", size.x, size.y, size.x, size.y)) .unwrap_or_default(); @@ -141,7 +104,11 @@ impl SvgRender { let matrix = format_transform_matrix(transform); let transform = if matrix.is_empty() { String::new() } else { format!(r#" transform="{matrix}""#) }; - let svg_header = format!(r#"{defs}"#); + let svg_header = format!( + r#"{defs}"#, + defs = &self.svg_defs + ); + self.svg_defs = String::new(); self.svg.insert(0, svg_header.into()); self.svg.push("".into()); } @@ -186,6 +153,34 @@ impl SvgRender { } } +pub struct SvgRenderOutput { + pub svg: String, + pub svg_defs: String, + pub image_data: HashMap>, u64>, +} + +impl From<&SvgRenderOutput> for SvgRender { + fn from(value: &SvgRenderOutput) -> Self { + Self { + svg: vec![value.svg.clone().into()], + svg_defs: value.svg_defs.clone(), + transform: DAffine2::IDENTITY, + image_data: value.image_data.clone(), + indent: 0, + } + } +} + +impl From for SvgRenderOutput { + fn from(val: SvgRender) -> Self { + Self { + svg: val.svg.to_svg_string(), + svg_defs: val.svg_defs, + image_data: val.image_data, + } + } +} + impl Default for SvgRender { fn default() -> Self { Self::new() @@ -215,8 +210,6 @@ pub struct RenderParams { pub scale: f64, pub render_output_type: RenderOutputType, pub thumbnail: bool, - /// Don't render the rectangle for an artboard to allow exporting with a transparent background. - pub hide_artboards: bool, /// Are we exporting pub for_export: bool, /// Are we generating a mask in this render pass? Used to see if fill should be multiplied with alpha. @@ -334,6 +327,7 @@ pub struct RenderMetadata { pub click_targets: HashMap>>, pub clip_targets: HashSet, pub vector_data: HashMap>, + pub backgrounds: Vec, } impl RenderMetadata { @@ -354,6 +348,7 @@ impl RenderMetadata { click_targets, clip_targets, vector_data, + backgrounds, } = self; upstream_footprints.extend(other.upstream_footprints.iter()); local_transforms.extend(other.local_transforms.iter()); @@ -361,9 +356,22 @@ impl RenderMetadata { click_targets.extend(other.click_targets.iter().map(|(k, v)| (*k, v.clone()))); clip_targets.extend(other.clip_targets.iter()); vector_data.extend(other.vector_data.iter().map(|(id, data)| (*id, data.clone()))); + + // TODO: Find a better non O(n^2) way to merge backgrounds + for background in &other.backgrounds { + if !backgrounds.contains(background) { + backgrounds.push(background.clone()); + } + } } } +#[derive(Debug, Default, Clone, PartialEq, DynAny, serde::Serialize, serde::Deserialize)] +pub struct Background { + pub location: DVec2, + pub dimensions: DVec2, +} + // TODO: Rename to "Graphical" pub trait Render: BoundingBox + RenderComplexity { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams); @@ -526,42 +534,17 @@ impl Render for Table> { let width = dimensions.x.abs(); let height = dimensions.y.abs(); - // Rectangle for the artboard - if !render_params.hide_artboards { - // Transparency checkerboard behind the artboard background (viewport only) - let show_checkerboard = background.alpha() < 1. && render_params.to_canvas(); - if show_checkerboard && render_params.viewport_zoom > 0. { - let checker_id = format!("checkered-artboard-{}", generate_uuid()); - let cell_size = 8. / render_params.viewport_zoom; - let pattern_size = cell_size * 2.; - - // Anchor pattern at this artboard's top-left corner (x, y), not the document origin - let _ = write!( - &mut render.svg_defs, - r##""## - ); - - render.leaf_tag("rect", |attributes| { - attributes.push("x", x.to_string()); - attributes.push("y", y.to_string()); - attributes.push("width", width.to_string()); - attributes.push("height", height.to_string()); - attributes.push("fill", format!("url(#{checker_id})")); - }); + // Background + render.leaf_tag("rect", |attributes| { + attributes.push("fill", format!("#{}", background.to_rgb_hex_srgb_from_gamma())); + if background.a() < 1. { + attributes.push("fill-opacity", ((background.a() * 1000.).round() / 1000.).to_string()); } - - // Background - render.leaf_tag("rect", |attributes| { - attributes.push("fill", format!("#{}", background.to_rgb_hex_srgb_from_gamma())); - if background.a() < 1. { - attributes.push("fill-opacity", ((background.a() * 1000.).round() / 1000.).to_string()); - } - attributes.push("x", x.to_string()); - attributes.push("y", y.to_string()); - attributes.push("width", width.to_string()); - attributes.push("height", height.to_string()); - }); - } + attributes.push("x", x.to_string()); + attributes.push("y", y.to_string()); + attributes.push("width", width.to_string()); + attributes.push("height", height.to_string()); + }); // Artwork render.parent_tag( @@ -607,26 +590,12 @@ impl Render for Table> { let [a, b] = [location, location + dimensions]; let rect = kurbo::Rect::new(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)); - // Render background - if !render_params.hide_artboards { - let artboard_transform = kurbo::Affine::new(transform.to_cols_array()); - - // Transparency checkerboard behind the artboard background (viewport only) - let show_checkerboard = background.alpha() < 1. && render_params.to_canvas(); - if show_checkerboard && render_params.viewport_zoom > 0. { - // Anchor pattern at THIS artboard's top-left corner - // brush_transform is an image placement transform: it maps brush pixel coords → shape coords - // scale(1/zoom) sets each brush pixel to 1/zoom document units (constant CSS size after viewport transform) - // then_translate places the brush origin at the artboard corner - let brush_transform = kurbo::Affine::scale(1. / render_params.viewport_zoom).then_translate(kurbo::Vec2::new(rect.x0, rect.y0)); - scene.fill(peniko::Fill::NonZero, artboard_transform, &checkerboard_brush(), Some(brush_transform), &rect); - } + let artboard_transform = kurbo::Affine::new(transform.to_cols_array()); - let color = peniko::Color::new([background.r(), background.g(), background.b(), background.a()]); - scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect); - scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect); - scene.pop_layer(); - } + let color = peniko::Color::new([background.r(), background.g(), background.b(), background.a()]); + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect); + scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect); + scene.pop_layer(); if clip { scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(transform.to_cols_array()), &rect); @@ -661,6 +630,8 @@ impl Render for Table> { } } + metadata.backgrounds.push(Background { location, dimensions }); + let mut child_footprint = footprint; child_footprint.transform *= DAffine2::from_translation(location); content.collect_metadata(metadata, child_footprint, None); diff --git a/node-graph/libraries/wgpu-executor/src/background/checker_rect.wgsl b/node-graph/libraries/wgpu-executor/src/background/checker_rect.wgsl new file mode 100644 index 0000000000..79f7163c22 --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/background/checker_rect.wgsl @@ -0,0 +1,49 @@ +struct CompositeUniforms { + transform_x: vec2, + transform_y: vec2, + transform_translation: vec2, + rect_min: vec2, + rect_max: vec2, + viewport_size: vec2, + pattern_origin: vec2, + checker_size: f32, + _pad: f32, +}; + +@group(0) @binding(0) +var uniforms: CompositeUniforms; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) document_position: vec2, +}; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + let document_corners = array, 6>( + uniforms.rect_min, + vec2(uniforms.rect_max.x, uniforms.rect_min.y), + vec2(uniforms.rect_min.x, uniforms.rect_max.y), + vec2(uniforms.rect_min.x, uniforms.rect_max.y), + vec2(uniforms.rect_max.x, uniforms.rect_min.y), + uniforms.rect_max, + ); + let document_position = document_corners[vertex_index]; + + let transformed = uniforms.transform_x * document_position.x + uniforms.transform_y * document_position.y + uniforms.transform_translation; + let normalized = transformed / uniforms.viewport_size; + let clip = vec2(normalized.x * 2.0 - 1.0, 1.0 - normalized.y * 2.0); + + var out: VertexOutput; + out.position = vec4(clip, 0.0, 1.0); + out.document_position = document_position; + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let tile = floor((in.document_position - uniforms.pattern_origin) / uniforms.checker_size); + let parity = i32(tile.x + tile.y) & 1; + let luminance = vec3(select(1.0, 0.8, parity == 1)); + return vec4(luminance, 1.0); +} diff --git a/node-graph/libraries/wgpu-executor/src/background/checker_viewport.wgsl b/node-graph/libraries/wgpu-executor/src/background/checker_viewport.wgsl new file mode 100644 index 0000000000..c583efcc38 --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/background/checker_viewport.wgsl @@ -0,0 +1,45 @@ +struct CompositeUniforms { + transform_x: vec2, + transform_y: vec2, + transform_translation: vec2, + rect_min: vec2, + rect_max: vec2, + viewport_size: vec2, + pattern_origin: vec2, + checker_size: f32, + _pad: f32, +}; + +@group(0) @binding(0) +var uniforms: CompositeUniforms; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) document_position: vec2, +}; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + let positions = array, 3>( + vec2(-1.0, -1.0), + vec2(-1.0, 3.0), + vec2( 3.0, -1.0), + ); + let position = positions[vertex_index]; + + let screen_position = vec2((position.x + 1.0) * 0.5 * uniforms.viewport_size.x, (1.0 - position.y) * 0.5 * uniforms.viewport_size.y); + let document_position = uniforms.transform_x * screen_position.x + uniforms.transform_y * screen_position.y + uniforms.transform_translation; + + var out: VertexOutput; + out.position = vec4(position, 0.0, 1.0); + out.document_position = document_position; + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let tile = floor((in.document_position - uniforms.pattern_origin) / uniforms.checker_size); + let parity = i32(tile.x + tile.y) & 1; + let luminance = vec3(select(1.0, 0.8, parity == 1)); + return vec4(luminance, 1.0); +} diff --git a/node-graph/libraries/wgpu-executor/src/background/fullscreen.wgsl b/node-graph/libraries/wgpu-executor/src/background/fullscreen.wgsl new file mode 100644 index 0000000000..fc760c927b --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/background/fullscreen.wgsl @@ -0,0 +1,35 @@ +@group(0) @binding(0) +var foreground_sampler: sampler; + +@group(0) @binding(1) +var foreground_texture: texture_2d; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) tex_coord: vec2, +}; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + let positions = array, 3>( + vec2(-1.0, -1.0), + vec2(-1.0, 3.0), + vec2( 3.0, -1.0), + ); + + let tex_coords = array, 3>( + vec2(0.0, 1.0), + vec2(0.0, -1.0), + vec2(2.0, 1.0), + ); + + var vertex_out: VertexOutput; + vertex_out.position = vec4(positions[vertex_index], 0.0, 1.0); + vertex_out.tex_coord = tex_coords[vertex_index]; + return vertex_out; +} + +@fragment +fn fs_main(fragment_in: VertexOutput) -> @location(0) vec4 { + return textureSample(foreground_texture, foreground_sampler, fragment_in.tex_coord); +} diff --git a/node-graph/libraries/wgpu-executor/src/background/mod.rs b/node-graph/libraries/wgpu-executor/src/background/mod.rs new file mode 100644 index 0000000000..dbc8fcf7ec --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/background/mod.rs @@ -0,0 +1,344 @@ +use glam::{Affine2, Vec2}; +use wgpu::util::DeviceExt; + +pub struct BackgroundCompositor { + checker_rect_pipeline: wgpu::RenderPipeline, + checker_viewport_pipeline: wgpu::RenderPipeline, + fullscreen_pipeline: wgpu::RenderPipeline, + checker_bind_group_layout: wgpu::BindGroupLayout, + fullscreen_bind_group_layout: wgpu::BindGroupLayout, + sampler: wgpu::Sampler, +} + +impl BackgroundCompositor { + pub fn new(device: &wgpu::Device) -> Self { + let format = wgpu::TextureFormat::Rgba8Unorm; + let checker_rect_shader = device.create_shader_module(wgpu::include_wgsl!("checker_rect.wgsl")); + let checker_viewport_shader = device.create_shader_module(wgpu::include_wgsl!("checker_viewport.wgsl")); + let fullscreen_shader = device.create_shader_module(wgpu::include_wgsl!("fullscreen.wgsl")); + + let checker_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("background_checker_bind_group_layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + + let checker_rect_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("background_checker_rect_pipeline_layout"), + bind_group_layouts: &[&checker_bind_group_layout], + immediate_size: 0, + }); + + let checker_viewport_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("background_checker_viewport_pipeline_layout"), + bind_group_layouts: &[&checker_bind_group_layout], + immediate_size: 0, + }); + + let fullscreen_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("background_fullscreen_bind_group_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + ], + }); + + let fullscreen_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("background_fullscreen_pipeline_layout"), + bind_group_layouts: &[&fullscreen_bind_group_layout], + immediate_size: 0, + }); + + let checker_rect_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("background_checker_rect_pipeline"), + layout: Some(&checker_rect_pipeline_layout), + vertex: wgpu::VertexState { + module: &checker_rect_shader, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &checker_rect_shader, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + + let checker_viewport_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("background_checker_viewport_pipeline"), + layout: Some(&checker_viewport_pipeline_layout), + vertex: wgpu::VertexState { + module: &checker_viewport_shader, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &checker_viewport_shader, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + + let fullscreen_blend = wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::SrcAlpha, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + }; + + let fullscreen_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("background_fullscreen_pipeline"), + layout: Some(&fullscreen_pipeline_layout), + vertex: wgpu::VertexState { + module: &fullscreen_shader, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &fullscreen_shader, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(fullscreen_blend), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("background_fullscreen_sampler"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::MipmapFilterMode::Nearest, + ..Default::default() + }); + + Self { + checker_rect_pipeline, + checker_viewport_pipeline, + fullscreen_pipeline, + checker_bind_group_layout, + fullscreen_bind_group_layout, + sampler, + } + } + + pub fn composite(&self, context: &crate::WgpuContext, foreground: &wgpu::Texture, output: &wgpu::Texture, backgrounds: &[rendering::Background], document_to_screen: Affine2, zoom: f32) { + if zoom <= 0.0 { + return; + } + + let device = &context.device; + let queue = &context.queue; + + let checker_size_doc = 8.0 / zoom; + let screen_to_document = document_to_screen.inverse(); + let viewport_size = output.size(); + let viewport_size = Vec2::new(viewport_size.width as f32, viewport_size.height as f32); + + let output_view = output.create_view(&wgpu::TextureViewDescriptor::default()); + let foreground_view = foreground.create_view(&wgpu::TextureViewDescriptor::default()); + + let checker_draws = if backgrounds.is_empty() { + vec![( + 3, + self.create_checker_bind_group(device, CompositeUniforms::fullscreen(viewport_size, screen_to_document, checker_size_doc)), + )] + } else { + backgrounds + .iter() + .filter_map(|background| { + let a = background.location.as_vec2(); + let b = (background.location + background.dimensions).as_vec2(); + + let min = a.min(b); + let max = a.max(b); + + if max.x <= min.x || max.y <= min.y { + return None; + } + + let uniforms = CompositeUniforms::rect(min, max, document_to_screen, viewport_size, checker_size_doc); + Some((6, self.create_checker_bind_group(device, uniforms))) + }) + .collect() + }; + + let fullscreen_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("background_fullscreen_bind_group"), + layout: &self.fullscreen_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&foreground_view), + }, + ], + }); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("background_encoder") }); + + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("background_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &output_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + + if backgrounds.is_empty() { + pass.set_pipeline(&self.checker_viewport_pipeline); + for (vertex_count, bind_group) in &checker_draws { + pass.set_bind_group(0, bind_group, &[]); + pass.draw(0..*vertex_count, 0..1); + } + } else { + pass.set_pipeline(&self.checker_rect_pipeline); + for (vertex_count, bind_group) in &checker_draws { + pass.set_bind_group(0, bind_group, &[]); + pass.draw(0..*vertex_count, 0..1); + } + } + + pass.set_pipeline(&self.fullscreen_pipeline); + pass.set_bind_group(0, &fullscreen_bind_group, &[]); + pass.draw(0..3, 0..1); + } + + queue.submit(std::iter::once(encoder.finish())); + } + + fn create_checker_bind_group(&self, device: &wgpu::Device, uniforms: CompositeUniforms) -> wgpu::BindGroup { + let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("background_checker_uniforms"), + contents: bytemuck::bytes_of(&uniforms), + usage: wgpu::BufferUsages::UNIFORM, + }); + + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("background_checker_bind_group"), + layout: &self.checker_bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: buffer.as_entire_binding(), + }], + }) + } +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)] +struct CompositeUniforms { + transform_x: [f32; 2], + transform_y: [f32; 2], + transform_translation: [f32; 2], + rect_min: [f32; 2], + rect_max: [f32; 2], + viewport_size: [f32; 2], + pattern_origin: [f32; 2], + checker_size: f32, + _pad: f32, +} + +impl CompositeUniforms { + fn fullscreen(viewport_size: Vec2, screen_to_document: Affine2, checker_size_doc: f32) -> Self { + Self::new(screen_to_document, Vec2::ZERO, Vec2::ZERO, viewport_size, Vec2::ZERO, checker_size_doc) + } + + fn rect(rect_min: Vec2, rect_max: Vec2, document_to_screen: Affine2, viewport_size: Vec2, checker_size_doc: f32) -> Self { + Self::new(document_to_screen, rect_min, rect_max, viewport_size, rect_min, checker_size_doc) + } + + fn new(transform: Affine2, rect_min: Vec2, rect_max: Vec2, viewport_size: Vec2, pattern_origin: Vec2, checker_size: f32) -> Self { + Self { + transform_x: transform.matrix2.x_axis.to_array(), + transform_y: transform.matrix2.y_axis.to_array(), + transform_translation: transform.translation.to_array(), + rect_min: rect_min.to_array(), + rect_max: rect_max.to_array(), + viewport_size: viewport_size.to_array(), + pattern_origin: pattern_origin.to_array(), + checker_size, + _pad: 0., + } + } +} diff --git a/node-graph/libraries/wgpu-executor/src/lib.rs b/node-graph/libraries/wgpu-executor/src/lib.rs index 7cd413c1cb..9395ec1bf1 100644 --- a/node-graph/libraries/wgpu-executor/src/lib.rs +++ b/node-graph/libraries/wgpu-executor/src/lib.rs @@ -1,13 +1,20 @@ +mod background; // TODO: Think about where to place this. Likely inlined in the node. Requires refactor of wgpu pipline usage. mod context; mod resample; pub mod shader_runtime; +mod texture_cache; pub mod texture_conversion; +use std::sync::Arc; + +use crate::background::BackgroundCompositor; use crate::resample::Resampler; use crate::shader_runtime::ShaderRuntime; +use crate::texture_cache::TextureCache; use anyhow::Result; +use core_types::Color; use futures::lock::Mutex; -use glam::UVec2; +use glam::{Affine2, UVec2}; use graphene_application_io::{ApplicationIo, EditorApi}; use vello::{AaConfig, AaSupport, RenderParams, Renderer, RendererOptions, Scene}; use wgpu::{Origin3d, TextureAspect}; @@ -18,11 +25,15 @@ pub use rendering::RenderContext; pub use wgpu::Backends as WgpuBackends; pub use wgpu::Features as WgpuFeatures; +const TEXTURE_CACHE_SIZE: u64 = 256 * 1024 * 1024; // 256 MiB + #[derive(dyn_any::DynAny)] pub struct WgpuExecutor { pub context: WgpuContext, + texture_cache: Mutex, vello_renderer: Mutex, resampler: Resampler, + background_compositor: BackgroundCompositor, pub shader_runtime: ShaderRuntime, } @@ -38,105 +49,55 @@ impl<'a, T: ApplicationIo> From<&'a EditorApi> for & } } -#[derive(Clone, Debug)] -pub struct TargetTexture { - texture: wgpu::Texture, - view: wgpu::TextureView, - size: UVec2, -} - -impl TargetTexture { - /// Creates a new TargetTexture with the specified size. - pub fn new(device: &wgpu::Device, size: UVec2) -> Self { - let size = size.max(UVec2::ONE); - let texture = device.create_texture(&wgpu::TextureDescriptor { - label: None, - size: wgpu::Extent3d { - width: size.x, - height: size.y, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC, - format: VELLO_SURFACE_FORMAT, - view_formats: &[], - }); - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - - Self { texture, view, size } - } - - /// Ensures the texture has the specified size, creating a new one if needed. - /// This allows reusing the same texture across frames when the size hasn't changed. - pub fn ensure_size(&mut self, device: &wgpu::Device, size: UVec2) { - let size = size.max(UVec2::ONE); - if self.size == size { - return; +impl WgpuExecutor { + pub async fn render_vello_scene(&self, scene: &Scene, size: UVec2, context: &RenderContext, background: Option) -> Result> { + let texture = self.request_texture(size).await; + + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let [r, g, b, a] = background.unwrap_or(Color::TRANSPARENT).to_rgba8(); + let render_params = RenderParams { + base_color: vello::peniko::Color::from_rgba8(r, g, b, a), + width: size.x, + height: size.y, + antialiasing_method: AaConfig::Msaa16, + }; + + { + let mut renderer = self.vello_renderer.lock().await; + for (image_brush, texture) in context.resource_overrides.iter() { + let texture_view = wgpu::TexelCopyTextureInfoBase { + texture: texture.clone(), + mip_level: 0, + origin: Origin3d::ZERO, + aspect: TextureAspect::All, + }; + renderer.override_image(&image_brush.image, Some(texture_view)); + } + renderer.render_to_texture(&self.context.device, &self.context.queue, scene, &texture_view, &render_params)?; + for (image_brush, _) in context.resource_overrides.iter() { + renderer.override_image(&image_brush.image, None); + } } - *self = Self::new(device, size); - } - - /// Returns a reference to the texture view for rendering. - pub fn view(&self) -> &wgpu::TextureView { - &self.view + Ok(texture) } - /// Returns a reference to the underlying texture. - pub fn texture(&self) -> &wgpu::Texture { - &self.texture + pub async fn resample_texture(&self, source: &wgpu::Texture, size: UVec2, transform: &glam::DAffine2) -> Arc { + let out = self.request_texture(size).await; + self.resampler.resample(&self.context, source, transform, &out); + out } -} - -const VELLO_SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; - -impl WgpuExecutor { - pub async fn render_vello_scene_to_texture(&self, scene: &Scene, size: UVec2, context: &RenderContext) -> Result { - let mut output = None; - self.render_vello_scene_to_target_texture(scene, size, context, &mut output).await?; - Ok(output.unwrap().texture) - } - - pub async fn render_vello_scene_to_target_texture(&self, scene: &Scene, size: UVec2, context: &RenderContext, output: &mut Option) -> Result<()> { - // Initialize (lazily) if this is the first call - if output.is_none() { - *output = Some(TargetTexture::new(&self.context.device, size)); - } - if let Some(target_texture) = output.as_mut() { - target_texture.ensure_size(&self.context.device, size); - - let render_params = RenderParams { - base_color: vello::peniko::Color::from_rgba8(0, 0, 0, 0), - width: size.x, - height: size.y, - antialiasing_method: AaConfig::Msaa16, - }; - - { - let mut renderer = self.vello_renderer.lock().await; - for (image_brush, texture) in context.resource_overrides.iter() { - let texture_view = wgpu::TexelCopyTextureInfoBase { - texture: texture.clone(), - mip_level: 0, - origin: Origin3d::ZERO, - aspect: TextureAspect::All, - }; - renderer.override_image(&image_brush.image, Some(texture_view)); - } - renderer.render_to_texture(&self.context.device, &self.context.queue, scene, target_texture.view(), &render_params)?; - for (image_brush, _) in context.resource_overrides.iter() { - renderer.override_image(&image_brush.image, None); - } - } - } - Ok(()) + pub async fn composite_background(&self, foreground: &wgpu::Texture, backgrounds: &[rendering::Background], document_to_screen: Affine2, zoom: f32) -> Arc { + let size = foreground.size(); + let output = self.request_texture(UVec2::new(size.width, size.height)).await; + self.background_compositor.composite(&self.context, foreground, &output, backgrounds, document_to_screen, zoom); + output } - pub fn resample_texture(&self, source: &wgpu::Texture, target_size: UVec2, transform: &glam::DAffine2) -> wgpu::Texture { - self.resampler.resample(&self.context, source, target_size, transform) + pub async fn request_texture(&self, size: UVec2) -> Arc { + self.texture_cache.lock().await.request_texture(&self.context.device, size) } } @@ -158,13 +119,19 @@ impl WgpuExecutor { .map_err(|e| anyhow::anyhow!("Failed to create Vello renderer: {:?}", e)) .ok()?; + let texture_cache = TextureCache::new(TEXTURE_CACHE_SIZE); + let resampler = Resampler::new(&context.device); + let background_compositor = BackgroundCompositor::new(&context.device); + let shader_runtime = ShaderRuntime::new(&context); Some(Self { - shader_runtime: ShaderRuntime::new(&context), context, - resampler, + texture_cache: texture_cache.into(), vello_renderer: vello_renderer.into(), + resampler, + background_compositor, + shader_runtime, }) } } diff --git a/node-graph/libraries/wgpu-executor/src/resample.rs b/node-graph/libraries/wgpu-executor/src/resample.rs index e91cf49e5a..fda60d8d5e 100644 --- a/node-graph/libraries/wgpu-executor/src/resample.rs +++ b/node-graph/libraries/wgpu-executor/src/resample.rs @@ -1,5 +1,5 @@ use crate::WgpuContext; -use glam::{DAffine2, UVec2, Vec2}; +use glam::{DAffine2, Vec2}; pub struct Resampler { pipeline: wgpu::RenderPipeline, @@ -74,29 +74,11 @@ impl Resampler { Resampler { pipeline, bind_group_layout } } - pub fn resample(&self, context: &WgpuContext, source: &wgpu::Texture, target_size: UVec2, transform: &DAffine2) -> wgpu::Texture { - let device = &context.device; - let queue = &context.queue; - - let output_texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("resample_output"), - size: wgpu::Extent3d { - width: target_size.x.max(1), - height: target_size.y.max(1), - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }); - + pub fn resample(&self, context: &WgpuContext, source: &wgpu::Texture, transform: &DAffine2, output: &wgpu::Texture) { let source_view = source.create_view(&wgpu::TextureViewDescriptor::default()); - let output_view = output_texture.create_view(&wgpu::TextureViewDescriptor::default()); + let output_view = output.create_view(&wgpu::TextureViewDescriptor::default()); - let params_buffer = device.create_buffer(&wgpu::BufferDescriptor { + let params_buffer = context.device.create_buffer(&wgpu::BufferDescriptor { label: Some("resample_params"), size: 32, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, @@ -104,9 +86,9 @@ impl Resampler { }); let params_data = [transform.matrix2.x_axis.as_vec2(), transform.matrix2.y_axis.as_vec2(), transform.translation.as_vec2(), Vec2::ZERO]; - queue.write_buffer(¶ms_buffer, 0, bytemuck::cast_slice(¶ms_data)); + context.queue.write_buffer(¶ms_buffer, 0, bytemuck::cast_slice(¶ms_data)); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + let bind_group = context.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("resample_bind_group"), layout: &self.bind_group_layout, entries: &[ @@ -121,7 +103,7 @@ impl Resampler { ], }); - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("resample_encoder") }); + let mut encoder = context.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("resample_encoder") }); { let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { @@ -143,8 +125,6 @@ impl Resampler { render_pass.draw(0..3, 0..1); } - queue.submit([encoder.finish()]); - - output_texture + context.queue.submit([encoder.finish()]); } } diff --git a/node-graph/libraries/wgpu-executor/src/texture_cache.rs b/node-graph/libraries/wgpu-executor/src/texture_cache.rs new file mode 100644 index 0000000000..4e3fad2178 --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/texture_cache.rs @@ -0,0 +1,95 @@ +use glam::UVec2; +use std::collections::VecDeque; +use std::sync::Arc; + +pub(crate) struct TextureCache { + /// Always sorted oldest-first by insertion/last-use order. + textures: VecDeque>, + max_free_bytes: u64, +} + +impl TextureCache { + pub fn new(max_free_bytes: u64) -> Self { + Self { + textures: VecDeque::new(), + max_free_bytes, + } + } + + pub fn request_texture(&mut self, device: &wgpu::Device, size: UVec2) -> Arc { + let size = size.max(UVec2::ONE); + + if let Some(pos) = self + .textures + .iter() + .position(|texture| UVec2::new(texture.width(), texture.height()) == size && Arc::strong_count(texture) == 1) + { + let entry = self.textures.remove(pos).unwrap(); + let texture = entry.clone(); + self.textures.push_back(entry); + return texture; + } + + let incoming_bytes = size.x as u64 * size.y as u64 * 4; + self.evict_until_fits(incoming_bytes); + + let texture = Arc::new(device.create_texture(&wgpu::TextureDescriptor { + label: Some(&format!("cached_texture_{}x{}", size.x, size.y)), + size: wgpu::Extent3d { + width: size.x, + height: size.y, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + })); + + self.textures.push_back(texture.clone()); + + texture + } + + fn total_free_bytes(&self) -> u64 { + self.textures + .iter() + .filter(|texture| Arc::strong_count(texture) == 1) + .map(|texture| texture.memory_size_estimate()) + .sum() + } + + fn evict_until_fits(&mut self, incoming_bytes: u64) { + let mut free_bytes = self.total_free_bytes(); + let max_free_bytes = self.max_free_bytes; + + if free_bytes + incoming_bytes <= max_free_bytes { + return; + } + + self.textures.retain(|texture| { + if free_bytes + incoming_bytes <= max_free_bytes { + return true; + } + if Arc::strong_count(texture) == 1 { + free_bytes -= texture.memory_size_estimate(); + texture.destroy(); + false + } else { + true + } + }); + } +} + +trait TextureMemoryCostEstimateExt { + fn memory_size_estimate(&self) -> u64; +} + +impl TextureMemoryCostEstimateExt for wgpu::Texture { + fn memory_size_estimate(&self) -> u64 { + self.width() as u64 * self.height() as u64 * 4 + } +} diff --git a/node-graph/nodes/gstd/src/pixel_preview.rs b/node-graph/nodes/gstd/src/pixel_preview.rs index 266ff7de93..d27b0cb8a7 100644 --- a/node-graph/nodes/gstd/src/pixel_preview.rs +++ b/node-graph/nodes/gstd/src/pixel_preview.rs @@ -59,7 +59,7 @@ pub async fn pixel_preview<'a: 'n>( let transform = DAffine2::from_translation(-upstream_min) * footprint.transform.inverse() * DAffine2::from_scale(logical_resolution); let exec = editor_api.application_io.as_ref().unwrap().gpu_executor().unwrap(); - let resampled = exec.resample_texture(source_texture.as_ref(), physical_resolution, &transform); + let resampled = exec.resample_texture(source_texture.as_ref(), physical_resolution, &transform).await; result.data = RenderOutputType::Texture(resampled.into()); diff --git a/node-graph/nodes/gstd/src/render_cache.rs b/node-graph/nodes/gstd/src/render_cache.rs index 434c2c8ebb..dd06fbab2f 100644 --- a/node-graph/nodes/gstd/src/render_cache.rs +++ b/node-graph/nodes/gstd/src/render_cache.rs @@ -6,7 +6,7 @@ use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, ExtractAnimationTime, E use glam::{DAffine2, DVec2, IVec2, UVec2}; use graph_craft::application_io::PlatformEditorApi; use graph_craft::document::value::RenderOutput; -use graphene_application_io::ApplicationIo; +use graphene_application_io::{ApplicationIo, ImageTexture}; use rendering::{RenderOutputType as RenderOutputTypeRequest, RenderParams}; use std::collections::HashSet; use std::hash::Hash; @@ -26,7 +26,7 @@ pub struct TileCoord { #[derive(Debug, Clone)] pub struct CachedRegion { - pub texture: wgpu::Texture, + pub texture: ImageTexture, pub texture_size: UVec2, pub tiles: Vec, pub metadata: rendering::RenderMetadata, @@ -41,7 +41,6 @@ pub struct CacheKey { pub device_scale: u64, pub zoom: u64, pub rotation: u64, - pub hide_artboards: bool, pub for_export: bool, pub for_mask: bool, pub thumbnail: bool, @@ -60,7 +59,6 @@ impl CacheKey { device_scale: f64, zoom: f64, rotation: f64, - hide_artboards: bool, for_export: bool, for_mask: bool, thumbnail: bool, @@ -87,7 +85,6 @@ impl CacheKey { device_scale: device_scale.to_bits(), zoom: zoom.to_bits(), rotation: quantized_rotation.to_bits(), - hide_artboards, for_export, for_mask, thumbnail, @@ -100,23 +97,27 @@ impl CacheKey { } } +#[derive(Clone, Default, dyn_any::DynAny, Debug)] +pub struct TileCache(Arc>); + +impl TileCache { + pub fn query(&self, viewport_bounds: &AxisAlignedBbox, cache_key: &CacheKey, max_region_area: u32) -> CacheQuery { + self.0.lock().unwrap().query(viewport_bounds, cache_key, max_region_area) + } + + pub fn store_regions(&self, regions: Vec) { + self.0.lock().unwrap().store_regions(regions); + } +} + #[derive(Default, Debug)] struct TileCacheImpl { regions: Vec, timestamp: u64, total_memory: usize, cache_key: CacheKey, - texture_cache_resolution: UVec2, - /// Pool of textures of the same size: `texture_cache_resolution`. - /// Reusing textures reduces the wgpu allocation pressure, - /// which is a problem on web since we have to wait for - /// the browser to garbage collect unused textures, eating up memory. - texture_cache: Vec>, } -#[derive(Clone, Default, dyn_any::DynAny, Debug)] -pub struct TileCache(Arc>); - #[derive(Debug, Clone)] pub struct RenderRegion { pub tiles: Vec, @@ -205,7 +206,6 @@ impl TileCacheImpl { while self.total_memory > MAX_CACHE_MEMORY_BYTES && !self.regions.is_empty() { if let Some((oldest_idx, _)) = self.regions.iter().enumerate().min_by_key(|(_, r)| r.last_access) { let removed = self.regions.remove(oldest_idx); - removed.texture.destroy(); self.total_memory = self.total_memory.saturating_sub(removed.memory_size); } else { break; @@ -214,56 +214,9 @@ impl TileCacheImpl { } fn invalidate_all(&mut self) { - for region in &self.regions { - region.texture.destroy(); - } self.regions.clear(); self.total_memory = 0; } - - pub fn request_texture(&mut self, size: UVec2, device: &wgpu::Device) -> Arc { - if self.texture_cache_resolution != size { - self.texture_cache_resolution = size; - self.texture_cache.clear(); - } - self.texture_cache.truncate(5); - for texture in &self.texture_cache { - if Arc::strong_count(texture) == 1 { - return Arc::clone(texture); - } - } - let texture = Arc::new(device.create_texture(&wgpu::TextureDescriptor { - label: Some("viewport_output"), - size: wgpu::Extent3d { - width: size.x, - height: size.y, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - })); - self.texture_cache.push(texture.clone()); - - texture - } -} - -impl TileCache { - pub fn query(&self, viewport_bounds: &AxisAlignedBbox, cache_key: &CacheKey, max_region_area: u32) -> CacheQuery { - self.0.lock().unwrap().query(viewport_bounds, cache_key, max_region_area) - } - - pub fn store_regions(&self, regions: Vec) { - self.0.lock().unwrap().store_regions(regions); - } - - pub fn request_texture(&self, size: UVec2, device: &wgpu::Device) -> Arc { - self.0.lock().unwrap().request_texture(size, device) - } } fn group_into_regions(tiles: &[TileCoord], max_region_area: u32) -> Vec { @@ -411,7 +364,6 @@ pub async fn render_output_cache<'a: 'n>( device_scale, zoom, rotation, - render_params.hide_artboards, render_params.for_export, render_params.for_mask, render_params.thumbnail, @@ -454,10 +406,9 @@ pub async fn render_output_cache<'a: 'n>( let exec = editor_api.application_io.as_ref().unwrap().gpu_executor().unwrap(); - let device = &exec.context.device; - let output_texture = tile_cache.request_texture(physical_resolution, device); + let output_texture = exec.request_texture(physical_resolution).await; - let combined_metadata = composite_cached_regions(&all_regions, output_texture.as_ref(), &device_origin_offset, &footprint.transform, exec); + let combined_metadata = composite_cached_regions(&all_regions, &output_texture, &device_origin_offset, &footprint.transform, exec); RenderOutput { data: RenderOutputType::Texture(output_texture.into()), @@ -496,7 +447,7 @@ where let region_ctx = OwnedContextImpl::from(ctx).with_footprint(region_footprint).with_vararg(Box::new(region_params)).into_context(); let mut result = render_fn(region_ctx).await; - let RenderOutputType::Texture(rendered_texture) = result.data else { + let RenderOutputType::Texture(texture) = result.data else { unreachable!("render_missing_region: expected texture output from Vello render"); }; @@ -506,7 +457,7 @@ where let memory_size = (region_pixel_size.x * region_pixel_size.y) as usize * BYTES_PER_PIXEL; CachedRegion { - texture: rendered_texture.as_ref().clone(), + texture, texture_size: region_pixel_size, tiles: region.tiles.clone(), metadata: result.metadata, @@ -552,7 +503,7 @@ fn composite_cached_regions( if width > 0 && height > 0 { encoder.copy_texture_to_texture( wgpu::TexelCopyTextureInfo { - texture: ®ion.texture, + texture: region.texture.as_ref(), mip_level: 0, origin: wgpu::Origin3d { x: src_x, y: src_y, z: 0 }, aspect: wgpu::TextureAspect::All, diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index cd7454bd59..7d3a291f6c 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -7,12 +7,10 @@ pub use graph_craft::application_io::*; use graph_craft::document::value::RenderOutput; pub use graph_craft::document::value::RenderOutputType; use graphene_application_io::{ApplicationIo, ExportFormat, RenderConfig}; -use graphic_types::raster_types::Image; use graphic_types::raster_types::{CPU, Raster}; use graphic_types::{Graphic, Vector}; -use rendering::{Render, RenderOutputType as RenderOutputTypeRequest, RenderParams, RenderSvgSegmentList, SvgRender, checkerboard_brush}; -use rendering::{RenderMetadata, SvgSegment}; -use std::collections::HashMap; +use rendering::{Render, RenderMetadata, RenderOutputType as RenderOutputTypeRequest, RenderParams, SvgRender, SvgRenderOutput}; +use std::fmt::Write; use std::sync::Arc; use vector_types::GradientStops; use wgpu_executor::RenderContext; @@ -20,19 +18,15 @@ use wgpu_executor::RenderContext; // Re-export render_output_cache from render_cache module pub use crate::render_cache::render_output_cache; -/// List of (canvas id, image data) pairs for embedding images as canvases in the final SVG string. -type ImageData = HashMap>, u64>; - #[derive(Clone, dyn_any::DynAny)] pub enum RenderIntermediateType { Vello(Arc<(vello::Scene, RenderContext)>), - Svg(Arc<(String, ImageData, String)>), + Svg(Arc), } #[derive(Clone, dyn_any::DynAny)] pub struct RenderIntermediate { pub(crate) ty: RenderIntermediateType, pub(crate) metadata: RenderMetadata, - pub(crate) contains_artboard: bool, } #[node_macro::node(category(""))] @@ -60,8 +54,6 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send + let footprint = Footprint::default(); let mut metadata = RenderMetadata::default(); data.collect_metadata(&mut metadata, footprint, None); - let contains_artboard = data.contains_artboard(); - match &render_params.render_output_type { RenderOutputTypeRequest::Vello => { let mut scene = vello::Scene::new(); @@ -72,7 +64,6 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send + RenderIntermediate { ty: RenderIntermediateType::Vello(Arc::new((scene, context))), metadata, - contains_artboard, } } RenderOutputTypeRequest::Svg => { @@ -81,49 +72,13 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send + data.render_svg(&mut render, render_params); RenderIntermediate { - ty: RenderIntermediateType::Svg(Arc::new((render.svg.to_svg_string(), render.image_data, render.svg_defs.clone()))), + ty: RenderIntermediateType::Svg(Arc::new(render.into())), metadata, - contains_artboard, } } } } -#[node_macro::node(category(""))] -async fn create_context<'a: 'n>( - // Context injections are defined in the wrap_network_in_scope function - render_config: RenderConfig, - data: impl Node, Output = RenderOutput>, -) -> RenderOutput { - let footprint = render_config.viewport; - - let render_output_type = match render_config.export_format { - ExportFormat::Svg => RenderOutputTypeRequest::Svg, - ExportFormat::Raster => RenderOutputTypeRequest::Vello, - }; - - let render_params = RenderParams { - render_mode: render_config.render_mode, - hide_artboards: false, - for_export: render_config.for_export, - render_output_type, - footprint: Footprint::default(), - scale: render_config.scale, - viewport_zoom: footprint.scale_magnitudes().x, - ..Default::default() - }; - - let ctx = OwnedContextImpl::default() - .with_footprint(footprint) - .with_real_time(render_config.time.time) - .with_animation_time(render_config.time.animation_time.as_secs_f64()) - .with_pointer_position(render_config.pointer) - .with_vararg(Box::new(render_params)) - .into_context(); - - data.eval(ctx).await -} - #[node_macro::node(category(""))] async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, editor_api: &'a PlatformEditorApi, data: RenderIntermediate) -> RenderOutput { let footprint = ctx.footprint(); @@ -134,101 +89,39 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito .expect("Downcasting render params yielded invalid type"); let mut render_params = render_params.clone(); render_params.footprint = *footprint; - let render_params = &render_params; - - let scale = render_params.scale; - let physical_resolution = render_params.footprint.resolution; - let logical_resolution = render_params.footprint.resolution.as_dvec2() / scale; - let RenderIntermediate { ty, mut metadata, contains_artboard } = data; + let RenderIntermediate { ty, mut metadata } = data; metadata.apply_transform(footprint.transform); - let data = match (render_params.render_output_type, &ty) { - (RenderOutputTypeRequest::Svg, RenderIntermediateType::Svg(svg_data)) => { - let mut rendering = SvgRender::new(); + let data = match (render_params.render_output_type, ty) { + (RenderOutputTypeRequest::Svg, RenderIntermediateType::Svg(data)) => { + let logical_resolution = render_params.footprint.resolution.as_dvec2() / render_params.scale; - // Infinite canvas background (no artboards) - if !contains_artboard && !render_params.hide_artboards { - let show_checkerboard = render_params.to_canvas(); - if show_checkerboard && render_params.viewport_zoom > 0. { - // Checkerboard pattern anchored at the document origin, tiling at 8x8 viewport pixels - let checker_id = format!("checkered-canvas-{}", generate_uuid()); - let cell_size = 8. / render_params.viewport_zoom; - let pattern_size = cell_size * 2.; + let mut render = SvgRender::from(data.as_ref()); + render.wrap_with_transform(render_params.footprint.transform, Some(logical_resolution)); - // Compute the axis-aligned bounding box of all four viewport corners in document space, - // which is necessary when the view is rotated so the rect fully covers the visible area - let inverse_transform = footprint.transform.inverse(); - let corners = [ - inverse_transform.transform_point2(glam::DVec2::ZERO), - inverse_transform.transform_point2(glam::DVec2::new(logical_resolution.x, 0.)), - inverse_transform.transform_point2(glam::DVec2::new(0., logical_resolution.y)), - inverse_transform.transform_point2(logical_resolution), - ]; - let bb_min = corners.iter().fold(glam::DVec2::MAX, |acc, &c| acc.min(c)); - let bb_max = corners.iter().fold(glam::DVec2::MIN, |acc, &c| acc.max(c)); - - rendering.leaf_tag("rect", |attributes| { - attributes.push("x", bb_min.x.to_string()); - attributes.push("y", bb_min.y.to_string()); - attributes.push("width", (bb_max.x - bb_min.x).to_string()); - attributes.push("height", (bb_max.y - bb_min.y).to_string()); - attributes.push("fill", format!("url(#{checker_id})")); - }); + let output = SvgRenderOutput::from(render); + assert!(output.svg_defs.is_empty()); - // Pattern defs will be appended after the intermediate defs are copied below - rendering.svg_defs = format!( - r##""##, - ); - } - } - - let existing_defs = rendering.svg_defs.clone(); - rendering.svg.push(SvgSegment::from(svg_data.0.clone())); - rendering.image_data = svg_data.1.clone(); - rendering.svg_defs = format!("{existing_defs}{}", svg_data.2); - - rendering.wrap_with_transform(footprint.transform, Some(logical_resolution)); RenderOutputType::Svg { - svg: rendering.svg.to_svg_string(), - image_data: rendering.image_data.into_iter().map(|(image, id)| (id, image.0)).collect(), + svg: output.svg, + image_data: output.image_data.into_iter().map(|(image, id)| (id, image.0)).collect(), } } - (RenderOutputTypeRequest::Vello, RenderIntermediateType::Vello(vello_data)) => { + (RenderOutputTypeRequest::Vello, RenderIntermediateType::Vello(data)) => { let Some(exec) = editor_api.application_io.as_ref().unwrap().gpu_executor() else { unreachable!("Attempted to render with Vello when no GPU executor is available"); }; - let (child, context) = Arc::as_ref(vello_data); + let (scene, context) = data.as_ref(); + let scale = render_params.scale; + let physical_resolution = render_params.footprint.resolution; let scale_transform = glam::DAffine2::from_scale(glam::DVec2::splat(scale)); - let footprint_transform = scale_transform * footprint.transform; + let footprint_transform = scale_transform * render_params.footprint.transform; let footprint_transform_vello = vello::kurbo::Affine::new(footprint_transform.to_cols_array()); - let mut scene = vello::Scene::new(); - - // Infinite canvas checkerboard (when no artboards are present) - let show_checkerboard = !render_params.for_export && !contains_artboard && !render_params.hide_artboards; - if show_checkerboard && scale > 0. && render_params.viewport_zoom > 0. { - // Compute the axis-aligned bounding box of all four viewport corners in document space, - // which is necessary so the rect fully covers the visible area when the canvas is tilted - let inverse_footprint = footprint_transform.inverse(); - let corners = [ - inverse_footprint.transform_point2(glam::DVec2::ZERO), - inverse_footprint.transform_point2(glam::DVec2::new(physical_resolution.x as f64, 0.)), - inverse_footprint.transform_point2(glam::DVec2::new(0., physical_resolution.y as f64)), - inverse_footprint.transform_point2(physical_resolution.as_dvec2()), - ]; - let bb_min = corners.iter().fold(glam::DVec2::MAX, |acc, &c| acc.min(c)); - let bb_max = corners.iter().fold(glam::DVec2::MIN, |acc, &c| acc.max(c)); - let doc_rect = vello::kurbo::Rect::new(bb_min.x, bb_min.y, bb_max.x, bb_max.y); - - // Draw in document space, transformed to screen by footprint_transform (includes rotation) - // Brush maps each pixel to 1/viewport_zoom document units, giving constant 8px cells - let brush_transform = vello::kurbo::Affine::scale(1. / render_params.viewport_zoom); - scene.fill(vello::peniko::Fill::NonZero, footprint_transform_vello, &checkerboard_brush(), Some(brush_transform), &doc_rect); - } - - scene.append(child, Some(footprint_transform_vello)); + let mut transformed_scene = vello::Scene::new(); + transformed_scene.append(scene, Some(footprint_transform_vello)); // We now replace all transforms which are supposed to be infinite with a transform which covers the entire viewport. // See for more detail. @@ -239,17 +132,154 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito // vertices, dropping the gradient and tanking performance. `!is_finite()` also covers NaN as a guard against future // code paths where `matrix[0]` could land on `0 * INFINITY`. let scaled_infinite_transform = vello::kurbo::Affine::scale_non_uniform(physical_resolution.x as f64, physical_resolution.y as f64); - for transform in scene.encoding_mut().transforms.iter_mut() { + for transform in transformed_scene.encoding_mut().transforms.iter_mut() { if !transform.matrix[0].is_finite() { *transform = vello_encoding::Transform::from_kurbo(&scaled_infinite_transform); } } - let texture = Arc::new(exec.render_vello_scene_to_texture(&scene, physical_resolution, context).await.expect("Failed to render Vello scene")); - + let texture = exec + .render_vello_scene(&transformed_scene, physical_resolution, context, None) + .await + .expect("Failed to render Vello scene"); RenderOutputType::Texture(texture.into()) } _ => unreachable!("Render node did not receive its requested data type"), }; + RenderOutput { data, metadata } } + +#[node_macro::node(category(""))] +async fn render_background<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, editor_api: &'a PlatformEditorApi, data: RenderOutput) -> RenderOutput { + let footprint = ctx.footprint(); + let render_params = ctx + .vararg(0) + .expect("Did not find var args") + .downcast_ref::() + .expect("Downcasting render params yielded invalid type"); + + if !render_params.to_canvas() { + return data; + } + + let RenderOutput { data: foreground_data, metadata } = data; + let mut render_params = render_params.clone(); + render_params.footprint = *footprint; + + let data = match foreground_data { + RenderOutputType::Texture(foreground_texture) => { + if let Some(exec) = editor_api.application_io.as_ref().unwrap().gpu_executor() { + let doc_to_screen = (glam::DAffine2::from_scale(glam::DVec2::splat(render_params.scale)) * render_params.footprint.transform).as_affine2(); + let blended = exec + .composite_background(foreground_texture.as_ref(), &metadata.backgrounds, doc_to_screen, render_params.viewport_zoom as f32) + .await; + + RenderOutputType::Texture(blended.into()) + } else { + RenderOutputType::Texture(foreground_texture) + } + } + RenderOutputType::Svg { + svg: foreground_svg, + image_data: foreground_images, + } => { + let mut render = SvgRender::new(); + + if render_params.viewport_zoom > 0. { + let draw_checkerboard = |render: &mut SvgRender, rect: vello::kurbo::Rect, pattern_origin: glam::DVec2, checker_id_prefix: &str| { + let checker_id = format!("{checker_id_prefix}-{}", generate_uuid()); + let cell_size = 8. / render_params.viewport_zoom; + let pattern_size = cell_size * 2.; + + write!( + &mut render.svg_defs, + r##""##, + pattern_origin.x, + pattern_origin.y, + ) + .unwrap(); + + render.leaf_tag("rect", |attributes| { + attributes.push("x", rect.x0.to_string()); + attributes.push("y", rect.y0.to_string()); + attributes.push("width", rect.width().to_string()); + attributes.push("height", rect.height().to_string()); + attributes.push("fill", format!("url(#{checker_id})")); + }); + }; + + if metadata.backgrounds.is_empty() { + if render_params.scale > 0. { + let logical_resolution = render_params.footprint.resolution.as_dvec2() / render_params.scale; + let logical_footprint = Footprint { + resolution: logical_resolution.round().as_uvec2().max(glam::UVec2::ONE), + ..render_params.footprint + }; + let bounds = logical_footprint.viewport_bounds_in_local_space(); + let min = bounds.start.floor(); + let max = bounds.end.ceil(); + + if min.is_finite() && max.is_finite() { + let rect = vello::kurbo::Rect::new(min.x, min.y, max.x, max.y); + draw_checkerboard(&mut render, rect, glam::DVec2::ZERO, "checkered-viewport"); + } + } + } else { + for background in &metadata.backgrounds { + let [a, b] = [background.location, background.location + background.dimensions]; + let rect = vello::kurbo::Rect::new(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)); + draw_checkerboard(&mut render, rect, glam::DVec2::new(rect.x0, rect.y0), "checkered-artboard"); + } + } + } + + let logical_resolution = render_params.footprint.resolution.as_dvec2() / render_params.scale; + render.wrap_with_transform(render_params.footprint.transform, Some(logical_resolution)); + + let background = SvgRenderOutput::from(render); + assert!(background.svg_defs.is_empty()); + + let svg = format!("{}{}", background.svg, foreground_svg); + let image_data = foreground_images; + + RenderOutputType::Svg { svg, image_data } + } + _ => unreachable!("Render background node received unsupported render output type"), + }; + + RenderOutput { data, metadata } +} + +#[node_macro::node(category(""))] +async fn create_context<'a: 'n>( + // Context injections are defined in the wrap_network_in_scope function + render_config: RenderConfig, + data: impl Node, Output = RenderOutput>, +) -> RenderOutput { + let footprint = render_config.viewport; + + let render_output_type = match render_config.export_format { + ExportFormat::Svg => RenderOutputTypeRequest::Svg, + ExportFormat::Raster => RenderOutputTypeRequest::Vello, + }; + + let render_params = RenderParams { + render_mode: render_config.render_mode, + for_export: render_config.for_export, + render_output_type, + scale: render_config.scale, + viewport_zoom: footprint.scale_magnitudes().x, + ..Default::default() + }; + + let ctx = OwnedContextImpl::default() + .with_footprint(footprint) + .with_real_time(render_config.time.time) + .with_animation_time(render_config.time.animation_time.as_secs_f64()) + .with_pointer_position(render_config.pointer) + .with_vararg(Box::new(render_params)) + .into_context(); + + data.eval(ctx).await +} diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000000..2f6db3fc71 --- /dev/null +++ b/website/README.md @@ -0,0 +1,10 @@ +# Graphite Website + +Graphite's website uses the [Zola](https://www.getzola.org/) static site generator and [NPM](https://github.com/npm/cli) for dependency resolution. You will need to install both to build the site. + +## Building for Development + +1. Switch to this directory from the repo root: `cd website` +2. Install the fonts: `npm run install-fonts` +3. Run `zola serve` +4. Open in your browser and start coding! diff --git a/website/content/blog/2022-02-12-announcing-graphite-alpha.md b/website/content/blog/2022-02-12-announcing-graphite-alpha.md index c675da48ee..0a9439d31e 100644 --- a/website/content/blog/2022-02-12-announcing-graphite-alpha.md +++ b/website/content/blog/2022-02-12-announcing-graphite-alpha.md @@ -1,6 +1,8 @@ +++ title = "Announcing Graphite alpha" date = 2022-02-12 +authors = ["Keavon Chambers"] +description = "The Graphite open source team announces the alpha release of their next-generation graphics editor, a web-based SVG editor with vector-based tools. Future plans include a node-based procedural workflow, a raster graphics compositing engine, and a native desktop client." [extra] banner = "https://static.graphite.art/content/blog/2022-02-12-announcing-graphite-alpha.avif" diff --git a/website/content/blog/2022-03-12-graphite-a-vision-for-the-future-of-2d-content-creation.md b/website/content/blog/2022-03-12-graphite-a-vision-for-the-future-of-2d-content-creation.md index 67ac1e472a..ca19dd5d75 100644 --- a/website/content/blog/2022-03-12-graphite-a-vision-for-the-future-of-2d-content-creation.md +++ b/website/content/blog/2022-03-12-graphite-a-vision-for-the-future-of-2d-content-creation.md @@ -1,12 +1,12 @@ +++ title = "Graphite: a vision for the future of 2D content creation" date = 2022-03-12 +authors = ["Keavon Chambers"] +description = "Graphite is an open-source application for 2D graphics editing and digital content creation, offering a nondestructive, node-based workflow. It combines intuitive UI with powerful procedural image generators to revolutionize 2D content creation." [extra] banner = "https://static.graphite.art/content/blog/2022-03-12-graphite-a-vision-for-the-future-of-2d-content-creation.avif" banner_png = "https://static.graphite.art/content/blog/2022-03-12-graphite-a-vision-for-the-future-of-2d-content-creation.png" -author = "Keavon Chambers" -summary = "Graphite is an open-source application for 2D graphics editing and digital content creation, offering a nondestructive, node-based workflow. It combines intuitive UI with powerful procedural image generators to revolutionize 2D content creation." reddit = "https://www.reddit.com/r/graphite/comments/unw3va/blog_post_graphite_a_vision_for_the_future_of_2d/" twitter = "https://twitter.com/GraphiteEditor/status/1524664010091556864" +++ diff --git a/website/content/blog/2022-05-12-distributed-computing-in-the-graphene-runtime.md b/website/content/blog/2022-05-12-distributed-computing-in-the-graphene-runtime.md index 32197a3ce7..cc6f603445 100644 --- a/website/content/blog/2022-05-12-distributed-computing-in-the-graphene-runtime.md +++ b/website/content/blog/2022-05-12-distributed-computing-in-the-graphene-runtime.md @@ -1,12 +1,12 @@ +++ title = "Distributed computing in the Graphene runtime" date = 2022-05-12 +authors = ["Keavon Chambers"] +description = "Graphite's 2D editor is built upon Graphene, a node-based editing system for nondestructive design across various data types designed to render artwork faster using multiple machines. The system optimizes execution paths, minimizes latency, and uses a distributed runtime for quick data processing." [extra] banner = "https://static.graphite.art/content/blog/2022-05-12-distributed-computing-in-the-graphene-runtime__2.avif" banner_png = "https://static.graphite.art/content/blog/2022-05-12-distributed-computing-in-the-graphene-runtime__2.png" -author = "Keavon Chambers" -summary = "Graphite's 2D editor is built upon Graphene, a node-based editing system for nondestructive design across various data types designed to render artwork faster using multiple machines. The system optimizes execution paths, minimizes latency, and uses a distributed runtime for quick data processing." reddit = "https://www.reddit.com/r/graphite/comments/unw45k/blog_post_distributed_computing_in_the_graphene/" twitter = "https://twitter.com/GraphiteEditor/status/1524664083554791424" +++ diff --git a/website/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.md b/website/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.md index ccc171a1af..7d95df1548 100644 --- a/website/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.md +++ b/website/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.md @@ -1,12 +1,12 @@ +++ title = "Looking back on 2023 and what's next" date = 2024-01-01 +authors = ["Keavon Chambers"] +description = "Looking back on 2023, we reflect on our significant achievements and milestones. As we move forward, we're excited to share what's next, promising a year filled with innovation and progress." [extra] banner = "https://static.graphite.art/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.avif" banner_png = "https://static.graphite.art/content/blog/2024-01-01-looking-back-on-2023-and-what's-next.png" -author = "Keavon Chambers" -summary = "Looking back on 2023, we reflect on our significant achievements and milestones. As we move forward, we're excited to share what's next, promising a year filled with innovation and progress." reddit = "https://www.reddit.com/r/graphite/comments/18xmoti/blog_post_looking_back_on_2023_and_whats_next/" twitter = "https://twitter.com/GraphiteEditor/status/1742576805532577937" @@ -21,10 +21,10 @@ The new year is here, and with so many accomplishments to share from the past tw I am grateful to everyone who has placed their faith in my vision for Graphite since I laid forth the design and wrote its first line of code nearly three years ago. Meeting some of the amazing people this summer who helped to make it possible, and inspired the project in the first place, was a pleasure and an honor. From California to Europe and back again, my combined family vacation and Graphite outreach tour was an opportunity to make connections with those helping us reach our goals. This blog post is both a project update and a public thank-you to those who generously lent their time and attention to our small-but-growing project. And for readers eager for an update on the software itself, stick around (or skip ahead) for a development progress report and a look at what's coming down the pipeline in the new year.
- +
Happy Holidays from the Graphite team!
These procedural light strands are powered by the newly completed node graph features.
Click here to explore this demo — drag the wire layer's points with the Path tool.
@@ -94,7 +94,7 @@ The next big news of August was my formation of [Graphite Labs, LLC](https://www I allocated my time at several points throughout the year into growing and evolving this website with a refreshed and more visually-appealing home page, dedicated pages for information [about](/about) the project and its [features](/features), an area providing resources and help for [volunteers](/volunteer) and [code contributors](/volunteer/guide), and just this month— a [user manual](/learn) complete with an introductory tutorial series. The first video went up yesterday:
- Vector Art Quickstart - Graphite, the Open Source 2D Graphics Suite + Vector Art Quickstart - Graphite, the Open Source 2D Graphics Suite
The user manual and tutorial series will continue expanding throughout the coming weeks. Additional website features including user accounts, forums, and other community features are being planned. diff --git a/website/content/blog/2024-02-22-graphite-internships-announcing-participation-in-gsoc-2024.md b/website/content/blog/2024-02-22-graphite-internships-announcing-participation-in-gsoc-2024.md index 647080e08c..dd1eaf64ee 100644 --- a/website/content/blog/2024-02-22-graphite-internships-announcing-participation-in-gsoc-2024.md +++ b/website/content/blog/2024-02-22-graphite-internships-announcing-participation-in-gsoc-2024.md @@ -1,11 +1,12 @@ +++ title = "Graphite internships: announcing participation in GSoC 2024" date = 2024-02-22 +authors = ["Keavon Chambers"] +description = "Join Graphite in Google Summer of Code 2024 for a unique opportunity to contribute to open-source software development in Rust and computer graphics. Get paid while learning, working on self-contained projects under experienced mentors, and help Graphite grow." + [extra] banner = "https://static.graphite.art/content/blog/2024-02-22-graphite-internships-announcing-participation-in-gsoc-2024.avif" banner_png = "https://static.graphite.art/content/blog/2024-02-22-graphite-internships-announcing-participation-in-gsoc-2024.png" -author = "Keavon Chambers" -summary = "Join Graphite in Google Summer of Code 2024 for a unique opportunity to contribute to open-source software development in Rust and computer graphics. Get paid while learning, working on self-contained projects under experienced mentors, and help Graphite grow." reddit = "https://www.reddit.com/r/graphite/comments/1ax3l8z/blog_post_graphite_internships_announcing/" twitter = "https://twitter.com/GraphiteEditor/status/1760619083396165703" +++ diff --git a/website/content/blog/2024-05-09-graphite-progress-report-q1-2024.md b/website/content/blog/2024-05-09-graphite-progress-report-q1-2024.md index 589e2a4366..ca84e14bb1 100644 --- a/website/content/blog/2024-05-09-graphite-progress-report-q1-2024.md +++ b/website/content/blog/2024-05-09-graphite-progress-report-q1-2024.md @@ -1,11 +1,13 @@ +++ title = "Graphite progress report (Q1 2024)" date = 2024-05-09 +authors = ["Keavon Chambers", "Hypercube"] +description = "Graphite's Q1 2024 update introduces a precise snapping system and a customizable grid for enhanced design control. The update also includes improved procedural scattering with the 'Copy to Points' node, demonstrated in new demo artwork." + + [extra] banner = "https://static.graphite.art/content/blog/2024-05-09-graphite-progress-report-q1-2024__2.avif" banner_png = "https://static.graphite.art/content/blog/2024-05-09-graphite-progress-report-q1-2024__2.png" -author = "Keavon Chambers & Hypercube" -summary = "Graphite's Q1 2024 update introduces a precise snapping system and a customizable grid for enhanced design control. The update also includes improved procedural scattering with the 'Copy to Points' node, demonstrated in new demo artwork." reddit = "https://www.reddit.com/r/graphite/comments/1coa0if/blog_post_graphite_progress_report_q1_2024/" twitter = "https://twitter.com/GraphiteEditor/status/1788698448348266946" css = ["/component/demo-artwork.css"] @@ -22,18 +24,18 @@ Over the first three months of the year, we are delighted to have seen many cont All Q1 2024 commits may be [viewed in this list](https://github.com/GraphiteEditor/Graphite/commits/master/?since=2024-01-01&until=2024-03-31) and all noteworthy changes are detailed below. As two of the major new features are the grid and snapping systems, the *Isometric Fountain* artwork shown on this blog post demonstrates what those features can achieve.
- - Vector art of Isometric Fountain - -

- - Isometric Fountain - -
- - Open this artwork to
explore it yourself. -
-

+ + Vector art of Isometric Fountain + +

+ + Isometric Fountain + +
+ + Open this artwork to
explore it yourself. +
+

ARTICLE HAS NO SUMMARY! After the first paragraph (or two short ones), a `` comment must be inserted in the markdown. Otherwise the blog page would be missing its preview text." | safe) }} {%- endif -%} {%- endblock content -%} diff --git a/website/templates/blog.html b/website/templates/blog.html index 4581be356e..a1637cbdf9 100644 --- a/website/templates/blog.html +++ b/website/templates/blog.html @@ -19,9 +19,14 @@ - By {{ page.extra.author }}. {{ page.date | date(format = "%B %d, %Y", timezone = "America/Los_Angeles") }}. + + {% if page.authors %} + By {{ page.authors | join(sep=", ") }}. + {% endif %} + . +
-

{{ page.summary | striptags | safe }}

+

{{ page.description | striptags | safe }}

Keep Reading diff --git a/website/templates/book.html b/website/templates/book.html index 22e2c47957..598692b723 100644 --- a/website/templates/book.html +++ b/website/templates/book.html @@ -4,7 +4,7 @@ {%- block head -%}{%- set page = page | default(value = section) -%} {%- set title = page.title -%} {%- set meta_article_type = true -%} -{%- set meta_description = page.extra.summary | default(value = page.content | striptags | safe | linebreaksbr | replace(from = "
", to = " ") | replace(from = " ", to = " ") | trim | truncate(length = 200)) -%} +{%- set meta_description = page.description | default(value = page.content | striptags | safe | linebreaksbr | replace(from = "
", to = " ") | replace(from = " ", to = " ") | trim | truncate(length = 200)) -%} {%- set css = ["/template/book.css", "/layout/reading-material.css", "/component/code-snippet.css"] -%} {%- set js = ["/js/template/book.js"] -%} {%- endblock head -%} diff --git a/website/templates/macros/replacements.html b/website/templates/macros/replacements.html index bace183903..1e0a7d6b33 100644 --- a/website/templates/macros/replacements.html +++ b/website/templates/macros/replacements.html @@ -6,7 +6,7 @@

{{ article.title }}

-

{{ article.summary | striptags | safe }}

+

{{ article.description | striptags | safe }}

Keep reading
diff --git a/website/templates/rss.xml b/website/templates/rss.xml new file mode 100644 index 0000000000..2d77259f6b --- /dev/null +++ b/website/templates/rss.xml @@ -0,0 +1,37 @@ + + + {{ config.title }} Blog + + {{ now() | date(format="%Y-%m-%dT%H:%M:%S%:z") }} + {{ config.base_url | safe }} + Latest news and articles from the Graphite team. + Zola + /favicon-32x32.png + © 2025 Graphite Labs, LLC + + {% for page in section.pages %} + + {{ page.title }} + + {{ page.permalink | safe }} + {{ page.updated | default(value=page.date) | date(format="%Y-%m-%dT%H:%M:%S%:z") }} + {{ page.date | date(format="%Y-%m-%dT%H:%M:%S%:z") }} + + {% if page.authors %} + {% for author in page.authors %} + + {{ author }} + + {% endfor %} + {% endif %} + + {% if page.description %} + {{ page.description }} + {% endif %} + + + + {% endfor %} +