diff --git a/docs/editor/6-masking.md b/docs/editor/6-masking.md index 31cc8a3e..9e1b8fc9 100644 --- a/docs/editor/6-masking.md +++ b/docs/editor/6-masking.md @@ -2,4 +2,4 @@ ## Mask mode -At any time while in the viewport, Tab may be pressed to enter mask mode. The underlying canvas seen before entering this mode is still shown, but masks are drawn as marching ants (or other optional overlays) above the main document content. While in this mode, an island layer group is provided as the destination for drawing new mask layers using the regular set of tools. The Layer Panel also still shows the underlying main document, which lets the user select layers as contextual inputs for tools that are aware of input layers, like the Fill Tool. Rather than showing the full-color shapes over the main document canvas, they are overlaid in wireframe view mode and surrounded by a marching ants marquee outline. The mask group may be isolated (meaning it becomes the render output to the viewport, and a breadcrumb trail is shown leading from the document to the isolated layer/group) which makes the viewport output show the mask in grayscale and has the Layer Panel host the contents of the mask group. While in mask mode, the working colors are temporarily replaced with a grayscale pair. Certain tools, such as the Freehand Tool and Pen Tool, may default to a "closed" form in mask mode by turning off stroke and setting fill to white in order to provide functionality akin to the lasso or polygonal lasso selection tools. Tab may be hit again to exit mask mode, but the marching ants still show up. However now, all tools used and commands performed will take into account the working mask. CtrlD will discard the working mask. \ No newline at end of file +At any time while in the viewport, Tab may be pressed to enter mask mode. The underlying canvas seen before entering this mode is still shown, but masks are drawn as marching ants (or other optional overlays) above the main document content. While in this mode, an island layer group is provided as the destination for drawing new mask layers using the regular set of tools. The Layer Panel also still shows the underlying main document, which lets the user select layers as contextual inputs for tools that are aware of input layers, like the Fill Tool. Rather than showing the full-color shapes over the main document canvas, they are overlaid in outline view mode and surrounded by a marching ants marquee outline. The mask group may be isolated (meaning it becomes the render output to the viewport, and a breadcrumb trail is shown leading from the document to the isolated layer/group) which makes the viewport output show the mask in grayscale and has the Layer Panel host the contents of the mask group. While in mask mode, the working colors are temporarily replaced with a grayscale pair. Certain tools, such as the Freehand Tool and Pen Tool, may default to a "closed" form in mask mode by turning off stroke and setting fill to white in order to provide functionality akin to the lasso or polygonal lasso selection tools. Tab may be hit again to exit mask mode, but the marching ants still show up. However now, all tools used and commands performed will take into account the working mask. CtrlD will discard the working mask. diff --git a/editor/src/document/document_file.rs b/editor/src/document/document_file.rs index 55687254..3a52ab8b 100644 --- a/editor/src/document/document_file.rs +++ b/editor/src/document/document_file.rs @@ -16,8 +16,8 @@ use kurbo::PathSeg; use log::warn; use serde::{Deserialize, Serialize}; -use graphene::layers::BlendMode; -use graphene::{document::Document as GrapheneDocument, layers::LayerDataType, DocumentError, LayerId}; +use graphene::layers::{style::ViewMode, BlendMode, LayerDataType}; +use graphene::{document::Document as GrapheneDocument, DocumentError, LayerId}; use graphene::{DocumentResponse, Operation as DocumentOperation}; type DocumentSave = (GrapheneDocument, HashMap, LayerData>); @@ -68,6 +68,7 @@ pub struct DocumentMessageHandler { movement_handler: MovementMessageHandler, transform_layer_handler: TransformLayerMessageHandler, pub snapping_enabled: bool, + pub view_mode: ViewMode, } impl Default for DocumentMessageHandler { @@ -83,6 +84,7 @@ impl Default for DocumentMessageHandler { movement_handler: MovementMessageHandler::default(), transform_layer_handler: TransformLayerMessageHandler::default(), snapping_enabled: true, + view_mode: ViewMode::default(), } } } @@ -122,6 +124,9 @@ pub enum DocumentMessage { ExportDocument, SaveDocument, RenderDocument, + DirtyRenderDocument, + DirtyRenderDocumentInOutlineView, + SetViewMode(ViewMode), Undo, Redo, DocumentHistoryBackward, @@ -162,6 +167,7 @@ impl DocumentMessageHandler { movement_handler: MovementMessageHandler::default(), transform_layer_handler: TransformLayerMessageHandler::default(), snapping_enabled: true, + view_mode: ViewMode::default(), }; document.graphene_document.root.transform = document.layerdata(&[]).calculate_offset_transform(ipp.viewport_bounds.size() / 2.); document @@ -479,7 +485,7 @@ impl MessageHandler for DocumentMessageHand size.x, size.y, "\n", - self.graphene_document.render_root() + self.graphene_document.render_root(self.view_mode) ), name, } @@ -570,6 +576,10 @@ impl MessageHandler for DocumentMessageHand responses.push_front(DocumentOperation::DeleteLayer { path }.into()); } } + SetViewMode(mode) => { + self.view_mode = mode; + responses.push_front(DocumentMessage::DirtyRenderDocument.into()); + } DuplicateSelectedLayers => { self.backup(responses); for path in self.selected_layers_sorted() { @@ -659,7 +669,7 @@ impl MessageHandler for DocumentMessageHand responses.push_back(FolderChanged(vec![]).into()); } FolderChanged(path) => { - let _ = self.graphene_document.render_root(); + let _ = self.graphene_document.render_root(self.view_mode); responses.extend([LayerChanged(path).into(), DocumentStructureChanged.into()]); } DocumentStructureChanged => { @@ -672,7 +682,6 @@ impl MessageHandler for DocumentMessageHand (!overlay).then(|| FrontendMessage::UpdateLayer { data: entry }.into()) })); } - DispatchOperation(op) => match self.graphene_document.handle_operation(&op) { Ok(Some(document_responses)) => { for response in document_responses { @@ -702,7 +711,7 @@ impl MessageHandler for DocumentMessageHand RenderDocument => { responses.push_back( FrontendMessage::UpdateCanvas { - document: self.graphene_document.render_root(), + document: self.graphene_document.render_root(self.view_mode), } .into(), ); @@ -720,8 +729,8 @@ impl MessageHandler for DocumentMessageHand let scrollbar_size = viewport_size / bounds_length; let log = root_layerdata.scale.log2(); - let ruler_inverval = if log < 0. { 100. * 2_f64.powf(-log.ceil()) } else { 100. / 2_f64.powf(log.ceil()) }; - let ruler_spacing = ruler_inverval * root_layerdata.scale; + let ruler_interval = if log < 0. { 100. * 2_f64.powf(-log.ceil()) } else { 100. / 2_f64.powf(log.ceil()) }; + let ruler_spacing = ruler_interval * root_layerdata.scale; let ruler_origin = self.graphene_document.root.transform.transform_point2(DVec2::ZERO); @@ -738,12 +747,22 @@ impl MessageHandler for DocumentMessageHand FrontendMessage::UpdateRulers { origin: ruler_origin.into(), spacing: ruler_spacing, - interval: ruler_inverval, + interval: ruler_interval, } .into(), ); } + DirtyRenderDocument => { + // Mark all non-overlay caches as dirty + GrapheneDocument::visit_all_shapes(&mut self.graphene_document.root, &mut |_| {}); + responses.push_back(DocumentMessage::RenderDocument.into()); + } + DirtyRenderDocumentInOutlineView => { + if self.view_mode == ViewMode::Outline { + responses.push_front(DocumentMessage::DirtyRenderDocument.into()); + } + } NudgeSelectedLayers(x, y) => { self.backup(responses); for path in self.selected_layers().map(|path| path.to_vec()) { diff --git a/editor/src/document/layer_panel.rs b/editor/src/document/layer_panel.rs index b4300713..1d2cee8c 100644 --- a/editor/src/document/layer_panel.rs +++ b/editor/src/document/layer_panel.rs @@ -1,10 +1,7 @@ use crate::consts::VIEWPORT_ROTATE_SNAP_INTERVAL; use glam::{DAffine2, DVec2}; -use graphene::layers::{BlendMode, LayerDataType}; -use graphene::{ - layers::{Layer, LayerData as DocumentLayerData}, - LayerId, -}; +use graphene::layers::{style::ViewMode, BlendMode, Layer, LayerData as DocumentLayerData, LayerDataType}; +use graphene::LayerId; use serde::{ ser::{SerializeSeq, SerializeStruct}, Deserialize, Serialize, @@ -64,7 +61,7 @@ pub fn layer_panel_entry(layer_data: &LayerData, transform: DAffine2, layer: &La let arr = arr.iter().map(|x| (*x).into()).collect::>(); let mut thumbnail = String::new(); - layer.data.clone().render(&mut thumbnail, &mut vec![transform]); + layer.data.clone().render(&mut thumbnail, &mut vec![transform], ViewMode::Normal); let transform = transform.to_cols_array().iter().map(ToString::to_string).collect::>().join(","); let thumbnail = if let [(x_min, y_min), (x_max, y_max)] = arr.as_slice() { format!( diff --git a/editor/src/document/movement_handler.rs b/editor/src/document/movement_handler.rs index ca78cfb3..4dac0214 100644 --- a/editor/src/document/movement_handler.rs +++ b/editor/src/document/movement_handler.rs @@ -1,16 +1,15 @@ -pub use super::layer_panel::*; - -use super::LayerData; - +pub use crate::document::layer_panel::*; +use crate::document::{DocumentMessage, LayerData}; use crate::message_prelude::*; use crate::{ consts::{VIEWPORT_SCROLL_RATE, VIEWPORT_ZOOM_LEVELS, VIEWPORT_ZOOM_MOUSE_RATE, VIEWPORT_ZOOM_SCALE_MAX, VIEWPORT_ZOOM_SCALE_MIN, VIEWPORT_ZOOM_WHEEL_RATE}, input::{mouse::ViewportBounds, mouse::ViewportPosition, InputPreprocessor}, }; -use glam::DVec2; use graphene::document::Document; +use graphene::layers::style::ViewMode; use graphene::Operation as DocumentOperation; +use glam::DVec2; use serde::{Deserialize, Serialize}; use std::collections::VecDeque; @@ -132,21 +131,27 @@ impl MessageHandler { + // TODO: Eliminate redundant code by making this call SetCanvasZoom layerdata.scale = *VIEWPORT_ZOOM_LEVELS.iter().find(|scale| **scale > layerdata.scale).unwrap_or(&layerdata.scale); responses.push_back(FrontendMessage::SetCanvasZoom { new_zoom: layerdata.scale }.into()); responses.push_back(ToolMessage::SelectedLayersChanged.into()); + responses.push_back(DocumentMessage::DirtyRenderDocumentInOutlineView.into()); self.create_document_transform_from_layerdata(layerdata, &ipp.viewport_bounds, responses); } DecreaseCanvasZoom => { + // TODO: Eliminate redundant code by making this call SetCanvasZoom layerdata.scale = *VIEWPORT_ZOOM_LEVELS.iter().rev().find(|scale| **scale < layerdata.scale).unwrap_or(&layerdata.scale); responses.push_back(FrontendMessage::SetCanvasZoom { new_zoom: layerdata.scale }.into()); responses.push_back(ToolMessage::SelectedLayersChanged.into()); + responses.push_back(DocumentMessage::DirtyRenderDocumentInOutlineView.into()); self.create_document_transform_from_layerdata(layerdata, &ipp.viewport_bounds, responses); } WheelCanvasZoom => { + // TODO: Eliminate redundant code by making this call SetCanvasZoom let scroll = ipp.mouse.scroll_delta.scroll_delta(); let mouse = ipp.mouse.position; let viewport_bounds = ipp.viewport_bounds.size(); @@ -165,6 +170,7 @@ impl MessageHandler { @@ -199,6 +205,7 @@ impl MessageHandler this.dialog.comingSoon(319) }, + { value: "normal", icon: "ViewModeNormal", tooltip: "View Mode: Normal", action: () => this.setViewMode("Normal") }, + { value: "outline", icon: "ViewModeOutline", tooltip: "View Mode: Outline", action: () => this.setViewMode("Outline") }, { value: "pixels", icon: "ViewModePixels", tooltip: "View Mode: Pixels", action: () => this.dialog.comingSoon(320) }, ]; diff --git a/frontend/wasm/src/api.rs b/frontend/wasm/src/api.rs index aaaf3c37..f85c0688 100644 --- a/frontend/wasm/src/api.rs +++ b/frontend/wasm/src/api.rs @@ -5,16 +5,15 @@ use std::cell::Cell; use crate::helpers::Error; -use crate::type_translators::{translate_blend_mode, translate_key, translate_tool_type}; +use crate::type_translators::{translate_blend_mode, translate_key, translate_tool_type, translate_view_mode}; use crate::{EDITOR_HAS_CRASHED, EDITOR_INSTANCES}; use editor::consts::FILE_SAVE_SUFFIX; use editor::input::input_preprocessor::ModifierKeys; use editor::input::mouse::{EditorMouseState, ScrollDelta, ViewportBounds}; +use editor::message_prelude::*; use editor::misc::EditorError; use editor::tool::{tool_options::ToolOptions, tools, ToolType}; -use editor::Color; -use editor::LayerId; -use editor::{message_prelude::*, Editor}; +use editor::{Color, Editor, LayerId}; use wasm_bindgen::prelude::*; // To avoid wasm-bindgen from checking mutable reference issues using WasmRefCell @@ -379,6 +378,15 @@ impl JsEditorHandle { self.dispatch(message); } + /// Set the view mode to change the way layers are drawn in the viewport + pub fn set_view_mode(&self, new_mode: String) -> Result<(), JsValue> { + match translate_view_mode(new_mode.as_str()) { + Some(view_mode) => self.dispatch(DocumentMessage::SetViewMode(view_mode)), + None => return Err(Error::new("Invalid view mode").into()), + }; + Ok(()) + } + /// Sets the zoom to the value pub fn set_canvas_zoom(&self, new_zoom: f64) { let message = MovementMessage::SetCanvasZoom(new_zoom); diff --git a/frontend/wasm/src/type_translators.rs b/frontend/wasm/src/type_translators.rs index 656db9f3..f7aad285 100644 --- a/frontend/wasm/src/type_translators.rs +++ b/frontend/wasm/src/type_translators.rs @@ -1,7 +1,7 @@ use crate::helpers::match_string_to_enum; use editor::input::keyboard::Key; use editor::tool::ToolType; -use graphene::layers::BlendMode; +use graphene::layers::{style::ViewMode, BlendMode}; pub fn translate_tool_type(name: &str) -> Option { use ToolType::*; @@ -126,3 +126,12 @@ pub fn translate_key(name: &str) -> Key { _ => UnknownKey, } } + +pub fn translate_view_mode(name: &str) -> Option { + Some(match name { + "Normal" => ViewMode::Normal, + "Outline" => ViewMode::Outline, + "Pixels" => ViewMode::Pixels, + _ => return None, + }) +} diff --git a/graphene/src/consts.rs b/graphene/src/consts.rs new file mode 100644 index 00000000..e23a178f --- /dev/null +++ b/graphene/src/consts.rs @@ -0,0 +1,5 @@ +use crate::color::Color; + +// RENDERING +pub const LAYER_OUTLINE_STROKE_COLOR: Color = Color::BLACK; +pub const LAYER_OUTLINE_STROKE_WIDTH: f32 = 1.; diff --git a/graphene/src/document.rs b/graphene/src/document.rs index 472c996b..8cc1358e 100644 --- a/graphene/src/document.rs +++ b/graphene/src/document.rs @@ -8,7 +8,7 @@ use glam::{DAffine2, DVec2}; use serde::{Deserialize, Serialize}; use crate::{ - layers::{self, Folder, Layer, LayerData, LayerDataType, Shape}, + layers::{self, style::ViewMode, Folder, Layer, LayerData, LayerDataType, Shape}, DocumentError, DocumentResponse, LayerId, Operation, Quad, }; @@ -36,8 +36,8 @@ impl Document { } /// Wrapper around render, that returns the whole document as a Response. - pub fn render_root(&mut self) -> String { - self.root.render(&mut vec![]); + pub fn render_root(&mut self, mode: ViewMode) -> String { + self.root.render(&mut vec![], mode); self.root.cache.clone() } @@ -203,6 +203,28 @@ impl Document { Ok(()) } + /// Visit each layer recursively, applies modify_shape to each non-overlay Shape + pub fn visit_all_shapes(layer: &mut Layer, modify_shape: &mut F) -> bool { + match layer.data { + LayerDataType::Shape(ref mut shape) => { + if !layer.overlay { + modify_shape(shape); + + // This layer should be updated on next render pass + layer.cache_dirty = true; + } + } + LayerDataType::Folder(ref mut folder) => { + for sub_layer in folder.layers_mut() { + if Document::visit_all_shapes(sub_layer, modify_shape) { + layer.cache_dirty = true; + } + } + } + } + layer.cache_dirty + } + /// Adds a new layer to the folder specified by `path`. /// Passing a negative `insert_index` indexes relative to the end. /// -1 is equivalent to adding the layer to the top. diff --git a/graphene/src/layers/folder.rs b/graphene/src/layers/folder.rs index 4942fecd..8942abe2 100644 --- a/graphene/src/layers/folder.rs +++ b/graphene/src/layers/folder.rs @@ -1,6 +1,6 @@ use glam::DVec2; -use crate::{DocumentError, LayerId, Quad}; +use crate::{layers::style::ViewMode, DocumentError, LayerId, Quad}; use super::{Layer, LayerData, LayerDataType}; @@ -15,9 +15,9 @@ pub struct Folder { } impl LayerData for Folder { - fn render(&mut self, svg: &mut String, transforms: &mut Vec) { + fn render(&mut self, svg: &mut String, transforms: &mut Vec, view_mode: ViewMode) { for layer in &mut self.layers { - let _ = writeln!(svg, "{}", layer.render(transforms)); + let _ = writeln!(svg, "{}", layer.render(transforms, view_mode)); } } diff --git a/graphene/src/layers/mod.rs b/graphene/src/layers/mod.rs index 225d5533..83845d69 100644 --- a/graphene/src/layers/mod.rs +++ b/graphene/src/layers/mod.rs @@ -1,4 +1,5 @@ pub mod style; +use style::ViewMode; use glam::DAffine2; use glam::{DMat2, DVec2}; @@ -18,7 +19,7 @@ use serde::{Deserialize, Serialize}; use std::fmt::Write; pub trait LayerData { - fn render(&mut self, svg: &mut String, transforms: &mut Vec); + fn render(&mut self, svg: &mut String, transforms: &mut Vec, view_mode: ViewMode); fn intersects_quad(&self, quad: Quad, path: &mut Vec, intersections: &mut Vec>); fn bounding_box(&self, transform: glam::DAffine2) -> Option<[DVec2; 2]>; } @@ -46,12 +47,14 @@ impl LayerDataType { } impl LayerData for LayerDataType { - fn render(&mut self, svg: &mut String, transforms: &mut Vec) { - self.inner_mut().render(svg, transforms) + fn render(&mut self, svg: &mut String, transforms: &mut Vec, view_mode: ViewMode) { + self.inner_mut().render(svg, transforms, view_mode) } + fn intersects_quad(&self, quad: Quad, path: &mut Vec, intersections: &mut Vec>) { self.inner().intersects_quad(quad, path, intersections) } + fn bounding_box(&self, transform: glam::DAffine2) -> Option<[DVec2; 2]> { self.inner().bounding_box(transform) } @@ -102,14 +105,14 @@ impl Layer { } } - pub fn render(&mut self, transforms: &mut Vec) -> &str { + pub fn render(&mut self, transforms: &mut Vec, view_mode: ViewMode) -> &str { if !self.visible { return ""; } if self.cache_dirty { transforms.push(self.transform); self.thumbnail_cache.clear(); - self.data.render(&mut self.thumbnail_cache, transforms); + self.data.render(&mut self.thumbnail_cache, transforms, if self.overlay { ViewMode::Normal } else { view_mode }); self.cache.clear(); let _ = writeln!(self.cache, r#") { + fn render(&mut self, svg: &mut String, transforms: &mut Vec, view_mode: ViewMode) { let mut path = self.path.clone(); - let transform = self.transform(transforms); + let transform = self.transform(transforms, view_mode); let inverse = transform.inverse(); if !inverse.is_finite() { let _ = write!(svg, ""); @@ -45,7 +45,7 @@ impl LayerData for Shape { let _ = svg.write_str(&(entry.to_string() + if i != 5 { "," } else { "" })); }); let _ = svg.write_str(r#")">"#); - let _ = write!(svg, r#""#, path.to_svg(), self.style.render()); + let _ = write!(svg, r#""#, path.to_svg(), self.style.render(view_mode)); let _ = svg.write_str(""); } @@ -69,10 +69,11 @@ impl LayerData for Shape { } impl Shape { - pub fn transform(&self, transforms: &[DAffine2]) -> DAffine2 { - let start = match self.render_index { - -1 => 0, - x => (transforms.len() as i32 - x).max(0) as usize, + pub fn transform(&self, transforms: &[DAffine2], mode: ViewMode) -> DAffine2 { + let start = match (mode, self.render_index) { + (ViewMode::Outline, _) => 0, + (_, -1) => 0, + (_, x) => (transforms.len() as i32 - x).max(0) as usize, }; transforms.iter().skip(start).cloned().reduce(|a, b| a * b).unwrap_or(DAffine2::IDENTITY) } @@ -82,7 +83,7 @@ impl Shape { path: bez_path, style, render_index: 1, - solid: solid, + solid, } } diff --git a/graphene/src/layers/style/mod.rs b/graphene/src/layers/style/mod.rs index ea4376a8..e77f8160 100644 --- a/graphene/src/layers/style/mod.rs +++ b/graphene/src/layers/style/mod.rs @@ -1,15 +1,29 @@ use crate::color::Color; +use crate::consts::{LAYER_OUTLINE_STROKE_COLOR, LAYER_OUTLINE_STROKE_WIDTH}; use serde::{Deserialize, Serialize}; + const OPACITY_PRECISION: usize = 3; fn format_opacity(name: &str, opacity: f32) -> String { - if (opacity - 1.).abs() > 10f32.powi(-(OPACITY_PRECISION as i32)) { + if (opacity - 1.).abs() > 10_f32.powi(-(OPACITY_PRECISION as i32)) { format!(r#" {}-opacity="{:.precision$}""#, name, opacity, precision = OPACITY_PRECISION) } else { String::new() } } +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)] +pub enum ViewMode { + Normal, + Outline, + Pixels, +} +impl Default for ViewMode { + fn default() -> Self { + ViewMode::Normal + } +} + #[repr(C)] #[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)] pub struct Fill { @@ -22,7 +36,7 @@ impl Fill { pub fn color(&self) -> Option { self.color } - pub fn none() -> Self { + pub const fn none() -> Self { Self { color: None } } pub fn render(&self) -> String { @@ -41,7 +55,7 @@ pub struct Stroke { } impl Stroke { - pub fn new(color: Color, width: f32) -> Self { + pub const fn new(color: Color, width: f32) -> Self { Self { color, width } } pub fn color(&self) -> Color { @@ -83,17 +97,18 @@ impl PathStyle { pub fn clear_stroke(&mut self) { self.stroke = None; } - pub fn render(&self) -> String { - format!( - "{}{}", - match self.fill { - Some(fill) => fill.render(), - None => String::new(), - }, - match self.stroke { - Some(stroke) => stroke.render(), - None => String::new(), - }, - ) + + pub fn render(&self, view_mode: ViewMode) -> String { + let fill_attribute = match (view_mode, self.fill) { + (ViewMode::Outline, _) => Fill::none().render(), + (_, Some(fill)) => fill.render(), + (_, None) => String::new(), + }; + let stroke_attribute = match (view_mode, self.stroke) { + (ViewMode::Outline, _) => Stroke::new(LAYER_OUTLINE_STROKE_COLOR, LAYER_OUTLINE_STROKE_WIDTH).render(), + (_, Some(stroke)) => stroke.render(), + (_, None) => String::new(), + }; + format!("{}{}", fill_attribute, stroke_attribute) } } diff --git a/graphene/src/lib.rs b/graphene/src/lib.rs index b4739f63..d3023338 100644 --- a/graphene/src/lib.rs +++ b/graphene/src/lib.rs @@ -1,4 +1,5 @@ pub mod color; +pub mod consts; pub mod document; pub mod intersection; pub mod layers;