From 90df412aab52f715e8cfb132556ad6cabff6ee2f Mon Sep 17 00:00:00 2001 From: 0HyperCube <78500760+0HyperCube@users.noreply.github.com> Date: Sat, 17 Apr 2021 19:08:44 +0100 Subject: [PATCH] Add Shape Tool for drawing polygons (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ⬠ Add polygon drawing tool * 🔤 Minor fix of variable and function names * ❌ Remove stroke * ⌨️ Use N key as polygo tool shortcut. * ⌨️ Now using key Y for polygons. * ⌨️ The tooltip for the shortcut is fixed --- client/web/package-lock.json | 180 +++++++++--------- client/web/src/components/panels/Document.vue | 2 +- client/web/wasm/src/wrappers.rs | 1 + core/document/src/lib.rs | 21 +- core/document/src/operation.rs | 9 + core/document/src/shape_points.rs | 126 ++++++++++++ core/editor/src/dispatcher/events.rs | 1 + core/editor/src/dispatcher/mod.rs | 6 + core/editor/src/tools/shape.rs | 73 ++++++- 9 files changed, 324 insertions(+), 95 deletions(-) create mode 100644 core/document/src/shape_points.rs diff --git a/client/web/package-lock.json b/client/web/package-lock.json index d6a672132..cacdfbad7 100644 --- a/client/web/package-lock.json +++ b/client/web/package-lock.json @@ -605,95 +605,6 @@ "tslint": "^5.20.1", "webpack": "^4.0.0", "yorkie": "^2.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "optional": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", - "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", - "dev": true, - "optional": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "optional": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "optional": true - }, - "fork-ts-checker-webpack-plugin-v5": { - "version": "npm:fork-ts-checker-webpack-plugin@5.2.1", - "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-5.2.1.tgz", - "integrity": "sha512-SVi+ZAQOGbtAsUWrZvGzz38ga2YqjWvca1pXQFUArIVXqli0lLoDQ8uS0wg0kSpcwpZmaW5jVCZXQebkyUQSsw==", - "dev": true, - "optional": true, - "requires": { - "@babel/code-frame": "^7.8.3", - "@types/json-schema": "^7.0.5", - "chalk": "^4.1.0", - "cosmiconfig": "^6.0.0", - "deepmerge": "^4.2.2", - "fs-extra": "^9.0.0", - "memfs": "^3.1.2", - "minimatch": "^3.0.4", - "schema-utils": "2.7.0", - "semver": "^7.3.2", - "tapable": "^1.0.0" - } - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "optional": true - }, - "schema-utils": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", - "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", - "dev": true, - "optional": true, - "requires": { - "@types/json-schema": "^7.0.4", - "ajv": "^6.12.2", - "ajv-keywords": "^3.4.1" - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "optional": true, - "requires": { - "has-flag": "^4.0.0" - } - } } }, "@vue/cli-plugin-vuex": { @@ -1416,7 +1327,7 @@ "dev": true }, "@wasm-tool/wasm-pack-plugin": { - "version": "github:wasm-tool/wasm-pack-plugin#2984f4b570756e05b5d3fcd5b9d00878a4b63695", + "version": "github:wasm-tool/wasm-pack-plugin#f0cbb6dda359440374f54b5173077fd582162ad2", "from": "github:wasm-tool/wasm-pack-plugin", "dev": true, "requires": { @@ -5718,6 +5629,95 @@ } } }, + "fork-ts-checker-webpack-plugin-v5": { + "version": "npm:fork-ts-checker-webpack-plugin@5.2.1", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-5.2.1.tgz", + "integrity": "sha512-SVi+ZAQOGbtAsUWrZvGzz38ga2YqjWvca1pXQFUArIVXqli0lLoDQ8uS0wg0kSpcwpZmaW5jVCZXQebkyUQSsw==", + "dev": true, + "optional": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@types/json-schema": "^7.0.5", + "chalk": "^4.1.0", + "cosmiconfig": "^6.0.0", + "deepmerge": "^4.2.2", + "fs-extra": "^9.0.0", + "memfs": "^3.1.2", + "minimatch": "^3.0.4", + "schema-utils": "2.7.0", + "semver": "^7.3.2", + "tapable": "^1.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "optional": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "optional": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "optional": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "optional": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "optional": true + }, + "schema-utils": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", + "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", + "dev": true, + "optional": true, + "requires": { + "@types/json-schema": "^7.0.4", + "ajv": "^6.12.2", + "ajv-keywords": "^3.4.1" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "optional": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "form-data": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", diff --git a/client/web/src/components/panels/Document.vue b/client/web/src/components/panels/Document.vue index dae149341..75f8f0ac7 100644 --- a/client/web/src/components/panels/Document.vue +++ b/client/web/src/components/panels/Document.vue @@ -78,7 +78,7 @@ - +
diff --git a/client/web/wasm/src/wrappers.rs b/client/web/wasm/src/wrappers.rs index 83901665c..3fc4008a0 100644 --- a/client/web/wasm/src/wrappers.rs +++ b/client/web/wasm/src/wrappers.rs @@ -84,6 +84,7 @@ pub fn translate_key(name: &str) -> events::Key { "m" => K::KeyM, "x" => K::KeyX, "z" => K::KeyZ, + "y" => K::KeyY, "0" => K::Key0, "1" => K::Key1, "2" => K::Key2, diff --git a/core/document/src/lib.rs b/core/document/src/lib.rs index 7b60bed09..8f0bc2f2c 100644 --- a/core/document/src/lib.rs +++ b/core/document/src/lib.rs @@ -1,6 +1,7 @@ pub mod operation; -pub use kurbo::{Circle, Line, Point, Rect}; +mod shape_points; +pub use kurbo::{Circle, Line, Point, Rect, Vec2}; pub use operation::Operation; #[derive(Debug, Clone, PartialEq)] @@ -9,6 +10,7 @@ pub enum LayerType { Circle(Circle), Rect(Rect), Line(Line), + Shape(shape_points::ShapePoints), } impl LayerType { @@ -24,6 +26,9 @@ impl LayerType { Self::Line(l) => { format!(r#""#, l.p0.x, l.p0.y, l.p1.x, l.p1.y) } + Self::Shape(s) => { + format!(r#""#, s) + } } } } @@ -220,6 +225,20 @@ impl Document { update_frontend(self.render()); } + Operation::AddShape { + path, + insert_index, + x0, + y0, + x1, + y1, + sides, + } => { + let s = shape_points::ShapePoints::new(Point::new(x0, y0), Vec2 { x: x0 - x1, y: y0 - y1 }, sides); + self.add_layer(&path, Layer::new(LayerType::Shape(s)), insert_index)?; + + update_frontend(self.render()); + } Operation::DeleteLayer { path } => { self.delete(&path)?; diff --git a/core/document/src/operation.rs b/core/document/src/operation.rs index 3c35bb178..80bd25927 100644 --- a/core/document/src/operation.rs +++ b/core/document/src/operation.rs @@ -24,6 +24,15 @@ pub enum Operation { x1: f64, y1: f64, }, + AddShape { + path: Vec, + insert_index: isize, + x0: f64, + y0: f64, + x1: f64, + y1: f64, + sides: u8, + }, DeleteLayer { path: Vec, }, diff --git a/core/document/src/shape_points.rs b/core/document/src/shape_points.rs new file mode 100644 index 000000000..125c49a4d --- /dev/null +++ b/core/document/src/shape_points.rs @@ -0,0 +1,126 @@ +use std::{fmt, ops::Add}; + +use kurbo::{PathEl, Point, Vec2}; +use log::info; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ShapePoints { + center: kurbo::Point, + extent: kurbo::Vec2, + sides: u8, +} + +impl ShapePoints { + /// A new shape from center, a point and the number of points. + #[inline] + pub fn new(center: impl Into, extent: impl Into, sides: u8) -> ShapePoints { + ShapePoints { + center: center.into(), + extent: extent.into(), + sides: sides, + } + } + + // Gets the angle in radians between the longest line from the center and the apothem. + #[inline] + pub fn apothem_offset_angle(&self) -> f64 { + std::f64::consts::PI / (self.sides as f64) + } + + // Gets the apothem (the shortest distance from the center to the edge) + #[inline] + pub fn apothem(&self) -> f64 { + self.apothem_offset_angle().cos() * (self.sides as f64) + } + + // Gets the length of one side + #[inline] + pub fn side_length(&self) -> f64 { + self.apothem_offset_angle().sin() * (self.sides as f64) * (2 as f64) + } +} + +impl std::fmt::Display for ShapePoints { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fn rotate(v: &Vec2, theta: f64) -> Vec2 { + let cosine = theta.cos(); + let sine = theta.sin(); + return Vec2::new(v.x * cosine - v.y * sine, v.x * sine + v.y * cosine); + } + info!("sides{}", self.sides); + for i in 0..self.sides { + let radians = self.apothem_offset_angle() * ((i * 2 + (self.sides % 2)) as f64); + let offset = rotate(&self.extent, radians); + let point = self.center + offset; + write!(f, "{},{} ", point.x, point.y)?; + } + + Ok(()) + } +} + +#[doc(hidden)] +pub struct ShapePathIter { + shape: ShapePoints, + ix: usize, +} + +impl Iterator for ShapePathIter { + type Item = PathEl; + + fn next(&mut self) -> Option { + fn rotate(v: &Vec2, theta: f64) -> Vec2 { + let cosine = theta.cos(); + let sine = theta.sin(); + return Vec2::new(v.x * cosine - v.y * sine, v.x * sine + v.y * cosine); + } + self.ix += 1; + match self.ix { + 1 => Some(PathEl::MoveTo(self.shape.center + self.shape.extent)), + _ => { + let radians = self.shape.apothem_offset_angle() * ((self.ix * 2 + (self.shape.sides % 2) as usize) as f64); + let offset = rotate(&self.shape.extent, radians); + let point = self.shape.center + offset; + Some(PathEl::LineTo(point)) + } + } + } +} + +impl Add for ShapePoints { + type Output = ShapePoints; + + #[inline] + fn add(self, movement: Vec2) -> ShapePoints { + ShapePoints { + center: self.center + movement, + extent: self.extent, + sides: self.sides, + } + } +} + +impl kurbo::Shape for ShapePoints { + type PathElementsIter = ShapePathIter; + #[inline] + fn perimeter(&self, _accuracy: f64) -> f64 { + self.side_length() * (self.sides as f64) + } + + #[inline] + fn area(&self) -> f64 { + self.apothem() * self.perimeter(2.1) + } + + fn path_elements(&self, _tolerance: f64) -> Self::PathElementsIter { + todo!() + } + + fn winding(&self, _pt: Point) -> i32 { + todo!() + } + + fn bounding_box(&self) -> kurbo::Rect { + todo!() + } +} diff --git a/core/editor/src/dispatcher/events.rs b/core/editor/src/dispatcher/events.rs index 1f75781a2..4b4d3c5fb 100644 --- a/core/editor/src/dispatcher/events.rs +++ b/core/editor/src/dispatcher/events.rs @@ -115,6 +115,7 @@ pub enum Key { KeyV, KeyX, KeyZ, + KeyY, Key0, Key1, Key2, diff --git a/core/editor/src/dispatcher/mod.rs b/core/editor/src/dispatcher/mod.rs index ec089e542..a1e37892f 100644 --- a/core/editor/src/dispatcher/mod.rs +++ b/core/editor/src/dispatcher/mod.rs @@ -75,6 +75,12 @@ impl Dispatcher { tool_name: ToolType::Rectangle.to_string(), }); } + Key::KeyY => { + editor_state.tool_state.active_tool_type = ToolType::Shape; + self.dispatch_response(Response::SetActiveTool { + tool_name: ToolType::Shape.to_string(), + }); + } Key::KeyE => { editor_state.tool_state.active_tool_type = ToolType::Ellipse; self.dispatch_response(Response::SetActiveTool { diff --git a/core/editor/src/tools/shape.rs b/core/editor/src/tools/shape.rs index 7e4e6c75a..8f5a68fb2 100644 --- a/core/editor/src/tools/shape.rs +++ b/core/editor/src/tools/shape.rs @@ -1,13 +1,80 @@ use crate::events::{Event, Response}; -use crate::tools::Tool; +use crate::events::{Key, MouseKeys, ViewportPosition}; +use crate::tools::{Fsm, Tool}; use crate::Document; use document_core::Operation; #[derive(Default)] -pub struct Shape; +pub struct Shape { + fsm_state: ShapeToolFsmState, + data: ShapeToolData, +} impl Tool for Shape { fn handle_input(&mut self, event: &Event, document: &Document) -> (Vec, Vec) { - todo!(); + let mut responses = Vec::new(); + let mut operations = Vec::new(); + self.fsm_state = self.fsm_state.transition(event, document, &mut self.data, &mut responses, &mut operations); + + (responses, operations) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ShapeToolFsmState { + Ready, + LmbDown, +} + +impl Default for ShapeToolFsmState { + fn default() -> Self { + ShapeToolFsmState::Ready + } +} +#[derive(Clone, Debug, Default)] +struct ShapeToolData { + drag_start: ViewportPosition, + sides: u8, +} + +impl Fsm for ShapeToolFsmState { + type ToolData = ShapeToolData; + + fn transition(self, event: &Event, document: &Document, data: &mut Self::ToolData, responses: &mut Vec, operations: &mut Vec) -> Self { + match (self, event) { + (ShapeToolFsmState::Ready, Event::MouseDown(mouse_state)) if mouse_state.mouse_keys.contains(MouseKeys::LEFT) => { + data.drag_start = mouse_state.position; + ShapeToolFsmState::LmbDown + } + (ShapeToolFsmState::Ready, Event::KeyDown(Key::KeyZ)) => { + if let Some(id) = document.root.list_layers().last() { + operations.push(Operation::DeleteLayer { path: vec![*id] }) + } + ShapeToolFsmState::Ready + } + + // TODO - Check for left mouse button + (ShapeToolFsmState::LmbDown, Event::MouseUp(mouse_state)) => { + let r = data.drag_start.distance(&mouse_state.position); + log::info!("Draw Shape with radius: {:.2}", r); + + let start = data.drag_start; + let end = mouse_state.position; + let sides = data.sides; + operations.push(Operation::AddShape { + path: vec![], + insert_index: -1, + x0: start.x as f64, + y0: start.y as f64, + x1: end.x as f64, + y1: end.y as f64, + sides: 6, + }); + + ShapeToolFsmState::Ready + } + + _ => self, + } } }