From e96c2c0c9d5723f281a5b11a40a6f2ff4ffd488d Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Fri, 25 Sep 2026 11:13:02 +0200
Subject: [PATCH 1/2] feat: add live shape pen with grid snapping (#391)
---
Cargo.lock | 4 +-
Cargo.toml | 2 +-
README.md | 1 +
config.example.toml | 2 +
configurator/Cargo.toml | 2 +-
configurator/src/models/fields/tool.rs | 11 ++
.../src/models/keybindings/field/list.rs | 1 +
docs/CONFIG.md | 3 +
packaging/.SRCINFO | 4 +-
packaging/PKGBUILD | 2 +-
.../wayland/state/render/tool_preview.rs | 1 +
src/config/action_meta/entries/tools.rs | 10 +
src/config/action_meta/tests.rs | 3 +
src/config/keybindings/config/map/edit.rs | 1 +
src/config/keybindings/config/map/tools.rs | 4 +
.../config/types/bindings/tools.rs | 4 +
src/config/keybindings/defaults/tools.rs | 4 +
src/config/keybindings/tests.rs | 1 +
src/config/types/toolbar/ids.rs | 1 +
src/config/types/toolbar/items.rs | 1 +
src/config/types/toolbar/items/definitions.rs | 1 +
src/configurator_destination.rs | 1 +
src/domain/action.rs | 1 +
src/domain/tests.rs | 1 +
src/domain/tool.rs | 8 +
src/input/state/core/dirty.rs | 3 +
src/input/state/interaction/actions.rs | 1 +
src/input/state/mouse/release/drawing.rs | 1 +
src/input/state/render.rs | 1 +
src/input/state/tests/drawing.rs | 147 ++++++++++++++
src/input/tool/catalog.rs | 64 ++++--
src/input/tool/drawing.rs | 55 ++++++
src/input/tool/live_shape.rs | 187 ++++++++++++++++++
src/input/tool/mod.rs | 1 +
src/input/tool/tests.rs | 4 +-
src/toolbar_icons/mod.rs | 1 +
src/toolbar_icons/tools.rs | 5 +
.../help_overlay/sections/builder/sections.rs | 1 +
src/ui/toolbar/model/tools.rs | 19 +-
39 files changed, 531 insertions(+), 33 deletions(-)
create mode 100644 src/input/tool/live_shape.rs
diff --git a/Cargo.lock b/Cargo.lock
index d7c1c12d2..5136bdacf 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1842,7 +1842,7 @@ dependencies = [
[[package]]
name = "wayscriber"
-version = "0.9.25"
+version = "0.9.26"
dependencies = [
"anyhow",
"cairo-rs",
@@ -1879,7 +1879,7 @@ dependencies = [
[[package]]
name = "wayscriber-configurator"
-version = "0.9.25"
+version = "0.9.26"
dependencies = [
"gtk4",
"libadwaita",
diff --git a/Cargo.toml b/Cargo.toml
index 015052750..35f5580b7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "wayscriber"
-version = "0.9.25"
+version = "0.9.26"
edition = "2024"
rust-version = "1.98.1"
description = "Screen annotation tool for Wayland compositors"
diff --git a/README.md b/README.md
index 9063e1d8b..d3e8714cb 100644
--- a/README.md
+++ b/README.md
@@ -117,6 +117,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See
### Drawing and editing
- Freehand pen, highlighter, eraser (circle/rect)
+- Shape pen: draw naturally and confident strokes preview and become editable straight lines or circles; other strokes remain freehand. Choose **Live Shaping Tool** in the command palette or the toolbar's Shapes picker.
- Pen smoothing: finished pen and marker strokes are cleaned up on release, so the live line never lags the cursor (`[drawing] pen_smoothing`, 0-6, or the toolbar's **Smoothing** stepper); tablet pressure values are preserved, and the level is remembered with the session
- Shapes: lines, rectangles, ellipses, polygons (with fill toggle)
- Arrows in four styles - standard, pointy, curved (drag its handle to route around what is in the way), and double-ended - with optional auto-numbered labels; step markers for walkthroughs
diff --git a/config.example.toml b/config.example.toml
index b8782dce5..a3a0fdc2c 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -115,6 +115,7 @@ decrease_marker_opacity = ["Ctrl+Alt+ArrowDown"]
# Tool selection shortcuts (optional; keep empty to rely on modifiers)
select_selection_tool = ["V"]
select_pen_tool = ["F"]
+select_live_shape_tool = []
select_marker_tool = ["H"]
select_step_marker_tool = []
select_eraser_tool = ["D"]
@@ -696,6 +697,7 @@ shown = []
top_tools = [
"top.tool.select",
"top.tool.pen",
+ "top.tool.live-shape",
"top.tool.marker",
"top.tool.step-marker",
"top.tool.eraser",
diff --git a/configurator/Cargo.toml b/configurator/Cargo.toml
index d5009d88d..90543ede9 100644
--- a/configurator/Cargo.toml
+++ b/configurator/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "wayscriber-configurator"
-version = "0.9.25"
+version = "0.9.26"
edition = "2024"
rust-version = "1.98.1"
diff --git a/configurator/src/models/fields/tool.rs b/configurator/src/models/fields/tool.rs
index c1e736626..55a2c77f8 100644
--- a/configurator/src/models/fields/tool.rs
+++ b/configurator/src/models/fields/tool.rs
@@ -43,6 +43,7 @@ impl DragToolField {
pub enum ToolOption {
Select,
Pen,
+ LiveShape,
Line,
Rect,
Ellipse,
@@ -65,6 +66,7 @@ impl ToolOption {
vec![
Self::Select,
Self::Pen,
+ Self::LiveShape,
Self::Line,
Self::Rect,
Self::Ellipse,
@@ -87,6 +89,7 @@ impl ToolOption {
match self {
Self::Select => "Select",
Self::Pen => "Pen",
+ Self::LiveShape => "Shape pen",
Self::Line => "Line",
Self::Rect => "Rectangle",
Self::Ellipse => "Ellipse",
@@ -109,6 +112,7 @@ impl ToolOption {
match self {
Self::Select => Tool::Select,
Self::Pen => Tool::Pen,
+ Self::LiveShape => Tool::LiveShape,
Self::Line => Tool::Line,
Self::Rect => Tool::Rect,
Self::Ellipse => Tool::Ellipse,
@@ -131,6 +135,7 @@ impl ToolOption {
match tool {
Tool::Select => Self::Select,
Tool::Pen => Self::Pen,
+ Tool::LiveShape => Self::LiveShape,
Tool::Line => Self::Line,
Tool::Rect => Self::Rect,
Tool::Ellipse => Self::Ellipse,
@@ -165,6 +170,7 @@ pub enum DragToolOption {
Default,
Select,
Pen,
+ LiveShape,
Line,
Rect,
Ellipse,
@@ -186,6 +192,7 @@ impl DragToolOption {
Self::Default,
Self::Select,
Self::Pen,
+ Self::LiveShape,
Self::Line,
Self::Rect,
Self::Ellipse,
@@ -215,6 +222,7 @@ impl DragToolOption {
Self::Default => "Button default",
Self::Select => "Select",
Self::Pen => "Pen",
+ Self::LiveShape => "Shape pen",
Self::Line => "Line",
Self::Rect => "Rectangle",
Self::Ellipse => "Ellipse",
@@ -236,6 +244,7 @@ impl DragToolOption {
Self::Default => DragTool::Default,
Self::Select => DragTool::Select,
Self::Pen => DragTool::Pen,
+ Self::LiveShape => DragTool::LiveShape,
Self::Line => DragTool::Line,
Self::Rect => DragTool::Rect,
Self::Ellipse => DragTool::Ellipse,
@@ -257,6 +266,7 @@ impl DragToolOption {
DragTool::Default => Self::Default,
DragTool::Select => Self::Select,
DragTool::Pen => Self::Pen,
+ DragTool::LiveShape => Self::LiveShape,
DragTool::Line => Self::Line,
DragTool::Rect => Self::Rect,
DragTool::Ellipse => Self::Ellipse,
@@ -278,6 +288,7 @@ impl DragToolOption {
Self::Default => None,
Self::Select => Some(ToolOption::Select),
Self::Pen => Some(ToolOption::Pen),
+ Self::LiveShape => Some(ToolOption::LiveShape),
Self::Line => Some(ToolOption::Line),
Self::Rect => Some(ToolOption::Rect),
Self::Ellipse => Some(ToolOption::Ellipse),
diff --git a/configurator/src/models/keybindings/field/list.rs b/configurator/src/models/keybindings/field/list.rs
index 5a18491d7..779d386f2 100644
--- a/configurator/src/models/keybindings/field/list.rs
+++ b/configurator/src/models/keybindings/field/list.rs
@@ -37,6 +37,7 @@ const PREFERRED_ORDER: &[KeybindingField] = &[
KeybindingField::DecreaseMarkerOpacity,
KeybindingField::SelectSelectionTool,
KeybindingField::SelectPenTool,
+ KeybindingField::SelectLiveShapeTool,
KeybindingField::SelectEraserTool,
KeybindingField::ToggleEraserMode,
KeybindingField::CycleFontFamily,
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index e121fe12d..51aa6ee08 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -378,6 +378,7 @@ drag_tool = "default"
- **Arrow style**: Run **Cycle Arrow Style** from the command palette to step through standard → pointy → curved → double (unbound by default; bind `cycle_arrow_style`). With arrows selected it restyles those in one undo step; with nothing selected it sets the style for the next arrow
- **Marker opacity**: Use Ctrl+Alt + ↑/↓
- **Pen smoothing**: Run **Increase / Decrease Pen Smoothing** from the command palette, or bind `increase_pen_smoothing` / `decrease_pen_smoothing` (see [Pen smoothing](#pen-smoothing))
+- **Live shaping**: Choose **Shape pen** from the Shapes picker or **Live Shaping Tool** from the command palette; bind `select_live_shape_tool` for a shortcut. Confident lines and circles preview as shapes and commit as editable shapes. Lines close to Cartesian or isometric board-paper lines snap to them; without a nearby grid line, nearly horizontal or vertical strokes align to that axis and skew lines keep their angle. Other ink stays freehand.
- **Text font**: Shift+T steps through `font_cycle`; **Font Picker** in the command palette opens the full list (see [Font cycle](#font-cycle) and [Font picker](#font-picker))
- **Regular polygon sides**: Use the Shapes popover Sides control (range: 3-12)
- **Font size**: Use Ctrl+Shift++/Ctrl+Shift+- or Shift + scroll (range: 8-72px)
@@ -1364,6 +1365,7 @@ shown = []
top_tools = [
"top.tool.select",
"top.tool.pen",
+ "top.tool.live-shape",
"top.tool.marker",
"top.tool.step-marker",
"top.tool.eraser",
@@ -2163,6 +2165,7 @@ decrease_marker_opacity = ["Ctrl+Alt+ArrowDown"]
# Tool selection shortcuts (optional; keep empty to rely on modifiers)
select_selection_tool = ["V"]
select_pen_tool = ["F"]
+select_live_shape_tool = [] # draw ink that snaps to lines or circles
select_marker_tool = ["H"]
select_step_marker_tool = []
select_eraser_tool = ["D"]
diff --git a/packaging/.SRCINFO b/packaging/.SRCINFO
index d9cab6ef4..44bd8d57a 100644
--- a/packaging/.SRCINFO
+++ b/packaging/.SRCINFO
@@ -1,6 +1,6 @@
pkgbase = wayscriber
pkgdesc = Screen annotation tool for Wayland compositors
- pkgver = 0.9.25
+ pkgver = 0.9.26
pkgrel = 1
url = https://wayscriber.com
arch = x86_64
@@ -21,7 +21,7 @@ pkgbase = wayscriber
depends = slurp
optdepends = tesseract: copy text from screen (OCR)
optdepends = tesseract-data-eng: English language data for OCR
- source = wayscriber-0.9.25.tar.gz::https://github.com/devmobasa/wayscriber/archive/refs/tags/v0.9.25.tar.gz
+ source = wayscriber-0.9.26.tar.gz::https://github.com/devmobasa/wayscriber/archive/refs/tags/v0.9.26.tar.gz
sha256sums = SKIP
pkgname = wayscriber
diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD
index 811308931..5672b7357 100644
--- a/packaging/PKGBUILD
+++ b/packaging/PKGBUILD
@@ -1,6 +1,6 @@
# Maintainer: wayscriber maintainers
pkgname=wayscriber
-pkgver=0.9.25
+pkgver=0.9.26
pkgrel=1
pkgdesc='Screen annotation tool for Wayland compositors'
arch=('x86_64' 'aarch64')
diff --git a/src/backend/wayland/state/render/tool_preview.rs b/src/backend/wayland/state/render/tool_preview.rs
index 4c4484900..5411906d3 100644
--- a/src/backend/wayland/state/render/tool_preview.rs
+++ b/src/backend/wayland/state/render/tool_preview.rs
@@ -208,6 +208,7 @@ fn draw_semantic_tool_icon(
match icon {
SemanticToolIcon::Select => toolbar_icons::draw_icon_select(ctx, x, y, size),
SemanticToolIcon::Pen => toolbar_icons::draw_icon_pen(ctx, x, y, size),
+ SemanticToolIcon::LiveShape => toolbar_icons::draw_icon_live_shape(ctx, x, y, size),
SemanticToolIcon::Line => toolbar_icons::draw_icon_line(ctx, x, y, size),
SemanticToolIcon::Rect => toolbar_icons::draw_icon_rect(ctx, x, y, size),
SemanticToolIcon::Circle => toolbar_icons::draw_icon_circle(ctx, x, y, size),
diff --git a/src/config/action_meta/entries/tools.rs b/src/config/action_meta/entries/tools.rs
index 21c03d514..b0d1df5ad 100644
--- a/src/config/action_meta/entries/tools.rs
+++ b/src/config/action_meta/entries/tools.rs
@@ -45,6 +45,16 @@ pub const ENTRIES: &[ActionMeta] = &[
true,
icon: crate::toolbar_icons::draw_icon_pen
),
+ meta!(
+ SelectLiveShapeTool,
+ "Live Shaping Tool",
+ Some("Shape Pen"),
+ "Turn confident ink lines and circles into shapes",
+ Tools,
+ true,
+ true,
+ true
+ ),
meta!(
SelectLineTool,
"Line Tool",
diff --git a/src/config/action_meta/tests.rs b/src/config/action_meta/tests.rs
index 3c2751c18..8d7fafb4d 100644
--- a/src/config/action_meta/tests.rs
+++ b/src/config/action_meta/tests.rs
@@ -41,6 +41,7 @@ const HELP_ACTIONS: &[Action] = &[
Action::SetColorBlack,
Action::SelectSelectionTool,
Action::SelectPenTool,
+ Action::SelectLiveShapeTool,
Action::SelectLineTool,
Action::SelectRectTool,
Action::SelectEllipseTool,
@@ -100,6 +101,7 @@ const HELP_ACTIONS: &[Action] = &[
const TOOLBAR_ACTIONS: &[Action] = &[
Action::SelectPenTool,
+ Action::SelectLiveShapeTool,
Action::SelectLineTool,
Action::SelectRectTool,
Action::SelectEllipseTool,
@@ -161,6 +163,7 @@ const EXPECTED_COMMAND_PALETTE_ACTIONS: &[Action] = &[
Action::Redo,
Action::SelectSelectionTool,
Action::SelectPenTool,
+ Action::SelectLiveShapeTool,
Action::SelectLineTool,
Action::SelectRectTool,
Action::SelectEllipseTool,
diff --git a/src/config/keybindings/config/map/edit.rs b/src/config/keybindings/config/map/edit.rs
index e1b436607..ae130bf4f 100644
--- a/src/config/keybindings/config/map/edit.rs
+++ b/src/config/keybindings/config/map/edit.rs
@@ -168,6 +168,7 @@ define_action_binding_accessors! {
CycleBlurStyle => tools.cycle_blur_style,
CycleArrowStyle => tools.cycle_arrow_style,
SelectPenTool => tools.select_pen_tool,
+ SelectLiveShapeTool => tools.select_live_shape_tool,
SelectLineTool => tools.select_line_tool,
SelectRectTool => tools.select_rect_tool,
SelectEllipseTool => tools.select_ellipse_tool,
diff --git a/src/config/keybindings/config/map/tools.rs b/src/config/keybindings/config/map/tools.rs
index b41182f7a..c89d36d14 100644
--- a/src/config/keybindings/config/map/tools.rs
+++ b/src/config/keybindings/config/map/tools.rs
@@ -41,6 +41,10 @@ impl KeybindingsConfig {
inserter.insert_all(&self.tools.cycle_blur_style, Action::CycleBlurStyle)?;
inserter.insert_all(&self.tools.cycle_arrow_style, Action::CycleArrowStyle)?;
inserter.insert_all(&self.tools.select_pen_tool, Action::SelectPenTool)?;
+ inserter.insert_all(
+ &self.tools.select_live_shape_tool,
+ Action::SelectLiveShapeTool,
+ )?;
inserter.insert_all(&self.tools.select_line_tool, Action::SelectLineTool)?;
inserter.insert_all(&self.tools.select_rect_tool, Action::SelectRectTool)?;
inserter.insert_all(&self.tools.select_ellipse_tool, Action::SelectEllipseTool)?;
diff --git a/src/config/keybindings/config/types/bindings/tools.rs b/src/config/keybindings/config/types/bindings/tools.rs
index 097bb12f2..54ad586d2 100644
--- a/src/config/keybindings/config/types/bindings/tools.rs
+++ b/src/config/keybindings/config/types/bindings/tools.rs
@@ -57,6 +57,9 @@ pub struct ToolKeybindingsConfig {
#[serde(default = "default_select_pen_tool")]
pub select_pen_tool: Vec,
+ #[serde(default = "default_select_live_shape_tool")]
+ pub select_live_shape_tool: Vec,
+
#[serde(default = "default_select_line_tool")]
pub select_line_tool: Vec,
@@ -128,6 +131,7 @@ impl Default for ToolKeybindingsConfig {
cycle_blur_style: default_cycle_blur_style(),
cycle_arrow_style: default_cycle_arrow_style(),
select_pen_tool: default_select_pen_tool(),
+ select_live_shape_tool: default_select_live_shape_tool(),
select_line_tool: default_select_line_tool(),
select_rect_tool: default_select_rect_tool(),
select_ellipse_tool: default_select_ellipse_tool(),
diff --git a/src/config/keybindings/defaults/tools.rs b/src/config/keybindings/defaults/tools.rs
index 921af3ec5..69b07dd0d 100644
--- a/src/config/keybindings/defaults/tools.rs
+++ b/src/config/keybindings/defaults/tools.rs
@@ -72,6 +72,10 @@ pub(crate) fn default_select_pen_tool() -> Vec {
vec!["F".to_string()]
}
+pub(crate) fn default_select_live_shape_tool() -> Vec {
+ Vec::new()
+}
+
pub(crate) fn default_select_line_tool() -> Vec {
Vec::new()
}
diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs
index 939f00c51..2e9f040c7 100644
--- a/src/config/keybindings/tests.rs
+++ b/src/config/keybindings/tests.rs
@@ -748,6 +748,7 @@ const DEFAULT_BINDING_SNAPSHOT: &[(&str, &[&str])] = &[
("cycle_blur_style", &[]),
("cycle_arrow_style", &[]),
("select_pen_tool", &["F"]),
+ ("select_live_shape_tool", &[]),
("select_line_tool", &[]),
("select_rect_tool", &[]),
("select_ellipse_tool", &[]),
diff --git a/src/config/types/toolbar/ids.rs b/src/config/types/toolbar/ids.rs
index a330e0d55..97a12a1e2 100644
--- a/src/config/types/toolbar/ids.rs
+++ b/src/config/types/toolbar/ids.rs
@@ -8,6 +8,7 @@ pub const TOP_CHROME_CLOSE: ToolbarItemId = ToolbarItemId::from_known("top.chrom
pub const TOP_TOOL_SELECT: ToolbarItemId = ToolbarItemId::from_known("top.tool.select");
pub const TOP_TOOL_PEN: ToolbarItemId = ToolbarItemId::from_known("top.tool.pen");
+pub const TOP_TOOL_LIVE_SHAPE: ToolbarItemId = ToolbarItemId::from_known("top.tool.live-shape");
pub const TOP_TOOL_MARKER: ToolbarItemId = ToolbarItemId::from_known("top.tool.marker");
pub const TOP_TOOL_STEP_MARKER: ToolbarItemId = ToolbarItemId::from_known("top.tool.step-marker");
pub const TOP_TOOL_ERASER: ToolbarItemId = ToolbarItemId::from_known("top.tool.eraser");
diff --git a/src/config/types/toolbar/items.rs b/src/config/types/toolbar/items.rs
index 73afe9236..8a177d287 100644
--- a/src/config/types/toolbar/items.rs
+++ b/src/config/types/toolbar/items.rs
@@ -72,6 +72,7 @@ pub(crate) fn toolbar_item_hidden_by_baseline(id: ToolbarItemId) -> bool {
const DEFAULT_TOP_TOOLS_ORDER: &[ToolbarItemId] = &[
ids::TOP_TOOL_SELECT,
ids::TOP_TOOL_PEN,
+ ids::TOP_TOOL_LIVE_SHAPE,
ids::TOP_TOOL_MARKER,
ids::TOP_TOOL_STEP_MARKER,
ids::TOP_TOOL_ERASER,
diff --git a/src/config/types/toolbar/items/definitions.rs b/src/config/types/toolbar/items/definitions.rs
index 3191b8d05..672103c62 100644
--- a/src/config/types/toolbar/items/definitions.rs
+++ b/src/config/types/toolbar/items/definitions.rs
@@ -18,6 +18,7 @@ const TOOLBAR_ITEM_DEFINITIONS: &[ToolbarItemDefinition] = &[
),
item(ids::TOP_TOOL_SELECT, "Select", Top, Tool, None),
item(ids::TOP_TOOL_PEN, "Pen", Top, Tool, None),
+ item(ids::TOP_TOOL_LIVE_SHAPE, "Shape pen", Top, Tool, None),
item(ids::TOP_TOOL_MARKER, "Marker", Top, Tool, None),
item(ids::TOP_TOOL_STEP_MARKER, "Step marker", Top, Tool, None),
item(ids::TOP_TOOL_ERASER, "Eraser", Top, Tool, None),
diff --git a/src/configurator_destination.rs b/src/configurator_destination.rs
index 3c0f26651..e4ded6343 100644
--- a/src/configurator_destination.rs
+++ b/src/configurator_destination.rs
@@ -108,6 +108,7 @@ pub fn keybindings_section_for_action(action: Action) -> Option KeybindingsSection::Drawing,
Action::SelectSelectionTool
| Action::SelectPenTool
+ | Action::SelectLiveShapeTool
| Action::SelectEraserTool
| Action::ToggleEraserMode
| Action::IncreasePenSmoothing
diff --git a/src/domain/action.rs b/src/domain/action.rs
index dcd139b43..0f4049c28 100644
--- a/src/domain/action.rs
+++ b/src/domain/action.rs
@@ -53,6 +53,7 @@ pub enum Action {
CycleBlurStyle,
CycleArrowStyle,
SelectPenTool,
+ SelectLiveShapeTool,
SelectLineTool,
SelectRectTool,
SelectEllipseTool,
diff --git a/src/domain/tests.rs b/src/domain/tests.rs
index c09f8050b..b7d3167d4 100644
--- a/src/domain/tests.rs
+++ b/src/domain/tests.rs
@@ -92,6 +92,7 @@ fn action_serialization_matches_established_contract() {
(Action::CycleBlurStyle, "cycle_blur_style"),
(Action::CycleArrowStyle, "cycle_arrow_style"),
(Action::SelectPenTool, "select_pen_tool"),
+ (Action::SelectLiveShapeTool, "select_live_shape_tool"),
(Action::SelectLineTool, "select_line_tool"),
(Action::SelectRectTool, "select_rect_tool"),
(Action::SelectEllipseTool, "select_ellipse_tool"),
diff --git a/src/domain/tool.rs b/src/domain/tool.rs
index 2dd963906..7fc2a977c 100644
--- a/src/domain/tool.rs
+++ b/src/domain/tool.rs
@@ -12,6 +12,8 @@ pub enum Tool {
Select,
/// Freehand drawing - follows mouse path (default, no modifiers)
Pen,
+ /// Freehand ink that resolves confident lines and circles into shapes.
+ LiveShape,
/// Straight line - between start and end points (Shift)
Line,
/// Rectangle outline - from corner to corner (Ctrl)
@@ -59,6 +61,7 @@ pub enum DragTool {
Select,
/// Freehand drawing.
Pen,
+ LiveShape,
/// Straight line.
Line,
/// Rectangle outline.
@@ -106,6 +109,7 @@ impl DragTool {
pub enum DragBindableTool {
Select,
Pen,
+ LiveShape,
Line,
Rect,
Ellipse,
@@ -126,6 +130,7 @@ impl DragBindableTool {
match self {
Self::Select => DragTool::Select,
Self::Pen => DragTool::Pen,
+ Self::LiveShape => DragTool::LiveShape,
Self::Line => DragTool::Line,
Self::Rect => DragTool::Rect,
Self::Ellipse => DragTool::Ellipse,
@@ -146,6 +151,7 @@ impl DragBindableTool {
match self {
Self::Select => Tool::Select,
Self::Pen => Tool::Pen,
+ Self::LiveShape => Tool::LiveShape,
Self::Line => Tool::Line,
Self::Rect => Tool::Rect,
Self::Ellipse => Tool::Ellipse,
@@ -166,6 +172,7 @@ impl DragBindableTool {
match tool {
Tool::Select => Some(Self::Select),
Tool::Pen => Some(Self::Pen),
+ Tool::LiveShape => Some(Self::LiveShape),
Tool::Line => Some(Self::Line),
Tool::Rect => Some(Self::Rect),
Tool::Ellipse => Some(Self::Ellipse),
@@ -190,6 +197,7 @@ impl DragBindableTool {
DragTool::Default => None,
DragTool::Select => Some(Self::Select),
DragTool::Pen => Some(Self::Pen),
+ DragTool::LiveShape => Some(Self::LiveShape),
DragTool::Line => Some(Self::Line),
DragTool::Rect => Some(Self::Rect),
DragTool::Ellipse => Some(Self::Ellipse),
diff --git a/src/input/state/core/dirty.rs b/src/input/state/core/dirty.rs
index c837a7ffd..879c0a53e 100644
--- a/src/input/state/core/dirty.rs
+++ b/src/input/state/core/dirty.rs
@@ -119,6 +119,9 @@ impl InputState {
else {
return None;
};
+ if *tool == crate::input::Tool::LiveShape {
+ return None;
+ }
let stroke_width = match tool.motion_behavior() {
ToolMotionBehavior::NoPathAccumulation => return None,
diff --git a/src/input/state/interaction/actions.rs b/src/input/state/interaction/actions.rs
index 45feb5740..868fb8e54 100644
--- a/src/input/state/interaction/actions.rs
+++ b/src/input/state/interaction/actions.rs
@@ -48,6 +48,7 @@ pub(crate) fn classify_action(action: Action) -> ActionRoute {
| Action::CycleBlurStyle
| Action::CycleArrowStyle
| Action::SelectPenTool
+ | Action::SelectLiveShapeTool
| Action::SelectLineTool
| Action::SelectRectTool
| Action::SelectEllipseTool
diff --git a/src/input/state/mouse/release/drawing.rs b/src/input/state/mouse/release/drawing.rs
index 0b19e3308..ebbf1cc62 100644
--- a/src/input/state/mouse/release/drawing.rs
+++ b/src/input/state/mouse/release/drawing.rs
@@ -67,6 +67,7 @@ pub(super) fn finish_drawing(
point_thicknesses: release.point_thicknesses,
color: drawing_color,
size: drawing_thickness,
+ grid: state.boards.active_board().spec.grid,
marker_opacity: state.style.marker_opacity,
fill_enabled: state.style.fill_enabled,
blur_style: state.style.blur_style,
diff --git a/src/input/state/render.rs b/src/input/state/render.rs
index cae55789c..e1187c2d2 100644
--- a/src/input/state/render.rs
+++ b/src/input/state/render.rs
@@ -78,6 +78,7 @@ impl InputState {
point_thicknesses,
color: self.active_drag_color_or_current(),
size: self.thickness_for_tool(*tool),
+ grid: self.boards.active_board().spec.grid,
eraser_size: self.style.eraser_size,
marker_opacity: self.style.marker_opacity,
fill_enabled: self.style.fill_enabled,
diff --git a/src/input/state/tests/drawing.rs b/src/input/state/tests/drawing.rs
index 15edc546d..79b172c1a 100644
--- a/src/input/state/tests/drawing.rs
+++ b/src/input/state/tests/drawing.rs
@@ -66,6 +66,153 @@ fn mouse_drag_creates_shapes_for_each_tool() {
assert_eq!(state.boards.active_frame().shapes.len(), 5);
}
+#[test]
+fn live_shape_strokes_preview_and_commit_lines_and_circles() {
+ use crate::input::tool::ProvisionalToolStroke;
+
+ let mut state = create_test_input_state();
+ assert!(state.set_tool_override(Some(Tool::LiveShape)));
+
+ state.on_mouse_press(MouseButton::Left, 0, 0);
+ for point in [(20, 1), (40, -1), (60, 1), (80, 3)] {
+ state.on_mouse_motion(point.0, point.1);
+ }
+ assert!(matches!(
+ state.provisional_tool_stroke(80, 3),
+ ProvisionalToolStroke::Shape(Shape::Line { y2: 0, .. })
+ ));
+ state.on_mouse_release(MouseButton::Left, 80, 3);
+ assert!(matches!(
+ state.boards.active_frame().shapes[0].shape,
+ Shape::Line {
+ x1: 0,
+ y1: 0,
+ x2: 80,
+ y2: 0,
+ ..
+ }
+ ));
+
+ state.on_mouse_press(MouseButton::Left, 120, 80);
+ for index in 1..=32 {
+ let angle = std::f64::consts::TAU * f64::from(index) / 32.0;
+ state.on_mouse_motion(
+ 80 + (40.0 * angle.cos()).round() as i32,
+ 80 + (40.0 * angle.sin()).round() as i32,
+ );
+ }
+ assert!(matches!(
+ state.provisional_tool_stroke(120, 80),
+ ProvisionalToolStroke::Shape(Shape::Ellipse { .. })
+ ));
+ state.on_mouse_release(MouseButton::Left, 120, 80);
+ assert!(matches!(
+ state.boards.active_frame().shapes[1].shape,
+ Shape::Ellipse {
+ cx: 80,
+ cy: 80,
+ rx: 40,
+ ry: 40,
+ fill: false,
+ ..
+ }
+ ));
+
+ state.on_mouse_press(MouseButton::Left, 0, 120);
+ for point in [(20, 140), (40, 160), (60, 180)] {
+ state.on_mouse_motion(point.0, point.1);
+ }
+ state.on_mouse_release(MouseButton::Left, 60, 180);
+ assert!(matches!(
+ state.boards.active_frame().shapes[2].shape,
+ Shape::Line {
+ x1: 0,
+ y1: 120,
+ x2: 60,
+ y2: 180,
+ ..
+ }
+ ));
+ assert_eq!(state.boards.active_frame().shapes.len(), 3);
+ assert_eq!(state.boards.active_frame().undo_stack_len(), 3);
+}
+
+#[test]
+fn live_shape_keeps_ambiguous_strokes_as_freehand() {
+ let mut state = create_test_input_state();
+ assert!(state.set_tool_override(Some(Tool::LiveShape)));
+
+ state.on_mouse_press(MouseButton::Left, 0, 0);
+ for point in [(30, 30), (0, 30), (30, 0)] {
+ state.on_mouse_motion(point.0, point.1);
+ }
+ state.on_mouse_release(MouseButton::Left, 30, 0);
+
+ assert!(matches!(
+ state.boards.active_frame().shapes[0].shape,
+ Shape::Freehand { .. }
+ ));
+
+ state.on_mouse_press(MouseButton::Left, 40, 0);
+ for point in [(80, 40), (40, 80), (0, 40), (40, 0)] {
+ state.on_mouse_motion(point.0, point.1);
+ }
+ state.on_mouse_release(MouseButton::Left, 40, 0);
+ assert!(matches!(
+ state.boards.active_frame().shapes[1].shape,
+ Shape::Freehand { .. }
+ ));
+}
+
+#[test]
+fn live_shape_snaps_lines_to_visible_board_grid() {
+ use crate::domain::{BoardGrid, BoardGridKind};
+ use crate::input::BOARD_ID_WHITEBOARD;
+ use crate::input::tool::ProvisionalToolStroke;
+
+ let mut state = create_test_input_state();
+ state.switch_board(BOARD_ID_WHITEBOARD);
+ state.boards.active_board_mut().spec.grid = BoardGrid::new(BoardGridKind::Cartesian, 40);
+ assert!(state.set_tool_override(Some(Tool::LiveShape)));
+
+ state.on_mouse_press(MouseButton::Left, 0, 44);
+ for point in [(30, 43), (60, 42), (100, 44)] {
+ state.on_mouse_motion(point.0, point.1);
+ }
+ assert!(matches!(
+ state.provisional_tool_stroke(100, 44),
+ ProvisionalToolStroke::Shape(Shape::Line { y1: 40, y2: 40, .. })
+ ));
+ state.on_mouse_release(MouseButton::Left, 100, 44);
+ assert!(matches!(
+ state.boards.active_frame().shapes[0].shape,
+ Shape::Line {
+ x1: 0,
+ y1: 40,
+ x2: 100,
+ y2: 40,
+ ..
+ }
+ ));
+
+ state.boards.active_board_mut().spec.grid = BoardGrid::new(BoardGridKind::Isometric, 40);
+ state.on_mouse_press(MouseButton::Left, 0, 1);
+ for point in [(40, 23), (80, 47), (120, 69)] {
+ state.on_mouse_motion(point.0, point.1);
+ }
+ state.on_mouse_release(MouseButton::Left, 120, 69);
+ assert!(matches!(
+ state.boards.active_frame().shapes[1].shape,
+ Shape::Line {
+ x1: 0,
+ y1: 0,
+ x2: 120,
+ y2: 69,
+ ..
+ }
+ ));
+}
+
#[test]
fn regular_polygon_drag_stores_concrete_points_and_side_metadata() {
let mut state = create_test_input_state();
diff --git a/src/input/tool/catalog.rs b/src/input/tool/catalog.rs
index f913f2139..e3a7443b2 100644
--- a/src/input/tool/catalog.rs
+++ b/src/input/tool/catalog.rs
@@ -43,6 +43,7 @@ pub(crate) enum ToolDrawingBehavior {
kind: ToolPathKind,
pressure: ToolPressureBehavior,
},
+ LiveShape,
Line,
Rect,
Ellipse,
@@ -82,7 +83,7 @@ const fn profile(
}
}
-const DESCRIPTORS: [ToolDescriptor; 17] = [
+const DESCRIPTORS: [ToolDescriptor; 18] = [
ToolDescriptor {
tool: Tool::Select,
short_label: "Select",
@@ -122,6 +123,26 @@ const DESCRIPTORS: [ToolDescriptor; 17] = [
pressure: ToolPressureBehavior::OptionalPressureStroke,
},
},
+ ToolDescriptor {
+ tool: Tool::LiveShape,
+ short_label: "Shape Pen",
+ display_label: "Live Shaping Tool",
+ action: Some(Action::SelectLiveShapeTool),
+ profile: profile(
+ ToolSettingsSlot::Pen,
+ ToolSizeSource::DrawingThickness,
+ ToolControlGroup::Stroke,
+ true,
+ "Thickness",
+ ),
+ press: ToolPressBehavior::StartDrawing {
+ request_blur_capture: false,
+ },
+ motion: ToolMotionBehavior::AccumulatePath {
+ size_source: ToolMotionSizeSource::ToolSize,
+ },
+ drawing: ToolDrawingBehavior::LiveShape,
+ },
ToolDescriptor {
tool: Tool::Line,
short_label: "Line",
@@ -402,9 +423,10 @@ const DESCRIPTORS: [ToolDescriptor; 17] = [
];
impl Tool {
- pub(crate) const ALL: [Self; 17] = [
+ pub(crate) const ALL: [Self; 18] = [
Self::Select,
Self::Pen,
+ Self::LiveShape,
Self::Line,
Self::Rect,
Self::Ellipse,
@@ -426,21 +448,22 @@ impl Tool {
match self {
Self::Select => &DESCRIPTORS[0],
Self::Pen => &DESCRIPTORS[1],
- Self::Line => &DESCRIPTORS[2],
- Self::Rect => &DESCRIPTORS[3],
- Self::Ellipse => &DESCRIPTORS[4],
- Self::Triangle => &DESCRIPTORS[5],
- Self::Parallelogram => &DESCRIPTORS[6],
- Self::Rhombus => &DESCRIPTORS[7],
- Self::RegularPolygon => &DESCRIPTORS[8],
- Self::FreeformPolygon => &DESCRIPTORS[9],
- Self::Arrow => &DESCRIPTORS[10],
- Self::Blur => &DESCRIPTORS[11],
- Self::Spotlight => &DESCRIPTORS[12],
- Self::Marker => &DESCRIPTORS[13],
- Self::Highlight => &DESCRIPTORS[14],
- Self::StepMarker => &DESCRIPTORS[15],
- Self::Eraser => &DESCRIPTORS[16],
+ Self::LiveShape => &DESCRIPTORS[2],
+ Self::Line => &DESCRIPTORS[3],
+ Self::Rect => &DESCRIPTORS[4],
+ Self::Ellipse => &DESCRIPTORS[5],
+ Self::Triangle => &DESCRIPTORS[6],
+ Self::Parallelogram => &DESCRIPTORS[7],
+ Self::Rhombus => &DESCRIPTORS[8],
+ Self::RegularPolygon => &DESCRIPTORS[9],
+ Self::FreeformPolygon => &DESCRIPTORS[10],
+ Self::Arrow => &DESCRIPTORS[11],
+ Self::Blur => &DESCRIPTORS[12],
+ Self::Spotlight => &DESCRIPTORS[13],
+ Self::Marker => &DESCRIPTORS[14],
+ Self::Highlight => &DESCRIPTORS[15],
+ Self::StepMarker => &DESCRIPTORS[16],
+ Self::Eraser => &DESCRIPTORS[17],
}
}
@@ -487,7 +510,10 @@ impl Tool {
/// Accumulated paths — freehand and marker — are the ones smoothed on
/// release.
pub(crate) fn smooths_strokes(self) -> bool {
- matches!(self.drawing_behavior(), ToolDrawingBehavior::Path { .. })
+ matches!(
+ self.drawing_behavior(),
+ ToolDrawingBehavior::Path { .. } | ToolDrawingBehavior::LiveShape
+ )
}
pub(crate) fn settings_slot(self) -> ToolSettingsSlot {
@@ -517,7 +543,7 @@ impl Tool {
ToolDrawingBehavior::Path {
pressure: ToolPressureBehavior::OptionalPressureStroke,
..
- }
+ } | ToolDrawingBehavior::LiveShape
)
}
}
diff --git a/src/input/tool/drawing.rs b/src/input/tool/drawing.rs
index e44b4907c..5e6eef5bd 100644
--- a/src/input/tool/drawing.rs
+++ b/src/input/tool/drawing.rs
@@ -39,6 +39,7 @@ pub(crate) struct ToolStrokeSnapshot {
pub(crate) point_thicknesses: Vec,
pub(crate) color: Color,
pub(crate) size: f64,
+ pub(crate) grid: crate::domain::BoardGrid,
pub(crate) marker_opacity: f64,
pub(crate) fill_enabled: bool,
pub(crate) blur_style: BlurStyle,
@@ -89,6 +90,7 @@ pub(crate) struct ProvisionalToolSnapshot<'a> {
pub(crate) point_thicknesses: &'a [f32],
pub(crate) color: Color,
pub(crate) size: f64,
+ pub(crate) grid: crate::domain::BoardGrid,
pub(crate) eraser_size: f64,
pub(crate) marker_opacity: f64,
pub(crate) fill_enabled: bool,
@@ -147,6 +149,35 @@ impl Tool {
ToolDrawingBehavior::Path { kind, pressure } => {
finish_path_stroke(snapshot, kind, pressure, usage)
}
+ ToolDrawingBehavior::LiveShape => {
+ let recognized = if snapshot.points.last().copied() == Some(snapshot.end) {
+ super::live_shape::recognize(
+ &snapshot.points,
+ snapshot.color,
+ snapshot.size,
+ snapshot.grid,
+ )
+ } else {
+ let mut path = snapshot.points.clone();
+ path.push(snapshot.end);
+ super::live_shape::recognize(
+ &path,
+ snapshot.color,
+ snapshot.size,
+ snapshot.grid,
+ )
+ };
+ if let Some(shape) = recognized {
+ FinishedToolStroke::Shape { shape, usage }
+ } else {
+ finish_path_stroke(
+ snapshot,
+ ToolPathKind::Freehand,
+ ToolPressureBehavior::OptionalPressureStroke,
+ usage,
+ )
+ }
+ }
ToolDrawingBehavior::Line => finish_shape(snapshot, usage, |snapshot| Shape::Line {
x1: snapshot.start.0,
y1: snapshot.start.1,
@@ -265,6 +296,30 @@ impl Tool {
debug_assert_eq!(self, snapshot.tool);
match self.drawing_behavior() {
ToolDrawingBehavior::None => ProvisionalToolStroke::None,
+ ToolDrawingBehavior::LiveShape => {
+ if let Some(shape) = super::live_shape::recognize(
+ snapshot.points,
+ snapshot.color,
+ snapshot.size,
+ snapshot.grid,
+ ) {
+ ProvisionalToolStroke::Shape(shape)
+ } else if !snapshot.point_thicknesses.is_empty()
+ && snapshot.point_thicknesses.len() == snapshot.points.len()
+ {
+ ProvisionalToolStroke::BorrowedPressureFreehand {
+ points: snapshot.points,
+ point_thicknesses: snapshot.point_thicknesses,
+ color: snapshot.color,
+ }
+ } else {
+ ProvisionalToolStroke::BorrowedFreehand {
+ points: snapshot.points,
+ color: snapshot.color,
+ size: snapshot.size,
+ }
+ }
+ }
ToolDrawingBehavior::Path {
kind: ToolPathKind::Freehand,
pressure: ToolPressureBehavior::OptionalPressureStroke,
diff --git a/src/input/tool/live_shape.rs b/src/input/tool/live_shape.rs
new file mode 100644
index 000000000..1282acec2
--- /dev/null
+++ b/src/input/tool/live_shape.rs
@@ -0,0 +1,187 @@
+//! Recognize a confident line or circle from a pen path. Ambiguous ink stays ink.
+
+use crate::domain::{BoardGrid, BoardGridKind};
+use crate::draw::{Color, Shape};
+
+pub(super) fn recognize(
+ points: &[(i32, i32)],
+ color: Color,
+ thick: f64,
+ grid: BoardGrid,
+) -> Option {
+ let first = *points.first()?;
+ let last = *points.last()?;
+ let (min_x, max_x, min_y, max_y) = points.iter().fold(
+ (i32::MAX, i32::MIN, i32::MAX, i32::MIN),
+ |(min_x, max_x, min_y, max_y), &(x, y)| {
+ (min_x.min(x), max_x.max(x), min_y.min(y), max_y.max(y))
+ },
+ );
+ let width = f64::from(max_x) - f64::from(min_x);
+ let height = f64::from(max_y) - f64::from(min_y);
+ let diameter = width.max(height);
+ let chord = distance(first, last);
+ let length: f64 = points
+ .windows(2)
+ .map(|pair| distance(pair[0], pair[1]))
+ .sum();
+
+ if diameter >= 24.0
+ && (width - height).abs() <= diameter * 0.25
+ && chord <= diameter * 0.2
+ && let Some(circle) = recognize_circle(points, min_x, max_x, min_y, max_y, length)
+ {
+ let (cx, cy, radius) = circle;
+ return Some(Shape::Ellipse {
+ cx,
+ cy,
+ rx: radius,
+ ry: radius,
+ fill: false,
+ color,
+ thick,
+ });
+ }
+
+ if chord < 16.0 || length > chord * 1.16 {
+ return None;
+ }
+
+ let deviation = points
+ .iter()
+ .map(|&(x, y)| {
+ (((f64::from(x) - f64::from(first.0)) * (f64::from(last.1) - f64::from(first.1))
+ - (f64::from(y) - f64::from(first.1)) * (f64::from(last.0) - f64::from(first.0)))
+ / chord)
+ .abs()
+ })
+ .fold(0.0_f64, f64::max);
+ if deviation > (chord * 0.08).max(4.0) {
+ return None;
+ }
+
+ let (start, end) = align_line(first, last, chord, grid);
+
+ Some(Shape::Line {
+ x1: start.0,
+ y1: start.1,
+ x2: end.0,
+ y2: end.1,
+ color,
+ thick,
+ })
+}
+
+fn align_line(
+ first: (i32, i32),
+ last: (i32, i32),
+ chord: f64,
+ grid: BoardGrid,
+) -> ((i32, i32), (i32, i32)) {
+ let dx = f64::from(last.0) - f64::from(first.0);
+ let dy = f64::from(last.1) - f64::from(first.1);
+ let spacing = f64::from(grid.spacing());
+
+ if dy.abs() <= chord * 0.12 {
+ let y = if grid.kind == BoardGridKind::Cartesian {
+ snap_to_grid(f64::from(first.1), f64::from(last.1), spacing).unwrap_or(first.1)
+ } else {
+ first.1
+ };
+ return ((first.0, y), (last.0, y));
+ }
+
+ if dx.abs() <= chord * 0.12 {
+ let vertical_spacing = match grid.kind {
+ BoardGridKind::Cartesian => Some(spacing),
+ BoardGridKind::Isometric => Some(3.0_f64.sqrt() * spacing / 2.0),
+ _ => None,
+ };
+ let x = vertical_spacing
+ .and_then(|s| snap_to_grid(f64::from(first.0), f64::from(last.0), s))
+ .unwrap_or(first.0);
+ return ((x, first.1), (x, last.1));
+ }
+
+ if grid.kind == BoardGridKind::Isometric {
+ for slope in [-1.0 / 3.0_f64.sqrt(), 1.0 / 3.0_f64.sqrt()] {
+ if (dy - slope * dx).abs() / (1.0 + slope * slope).sqrt() > chord * 0.12 {
+ continue;
+ }
+ let first_intercept = f64::from(first.1) - slope * f64::from(first.0);
+ let last_intercept = f64::from(last.1) - slope * f64::from(last.0);
+ if let Some(intercept) = snap_to_grid(first_intercept, last_intercept, spacing) {
+ let y1 = (slope * f64::from(first.0) + f64::from(intercept)).round() as i32;
+ let y2 = (slope * f64::from(last.0) + f64::from(intercept)).round() as i32;
+ return ((first.0, y1), (last.0, y2));
+ }
+ }
+ }
+
+ (first, last)
+}
+
+fn snap_to_grid(first: f64, last: f64, spacing: f64) -> Option {
+ let coordinate = ((first + last) / (2.0 * spacing)).round() * spacing;
+ let margin = (spacing * 0.15).clamp(4.0, 8.0);
+ ((first - coordinate).abs() <= margin && (last - coordinate).abs() <= margin)
+ .then_some(coordinate.round() as i32)
+}
+
+fn recognize_circle(
+ points: &[(i32, i32)],
+ min_x: i32,
+ max_x: i32,
+ min_y: i32,
+ max_y: i32,
+ length: f64,
+) -> Option<(i32, i32, i32)> {
+ if points.len() < 8 {
+ return None;
+ }
+
+ let cx = (f64::from(min_x) + f64::from(max_x)) / 2.0;
+ let cy = (f64::from(min_y) + f64::from(max_y)) / 2.0;
+ let radius =
+ ((f64::from(max_x) - f64::from(min_x)) + (f64::from(max_y) - f64::from(min_y))) / 4.0;
+ let circumference = std::f64::consts::TAU * radius;
+ if !(circumference * 0.75..=circumference * 1.35).contains(&length) {
+ return None;
+ }
+
+ let mut winding = 0.0_f64;
+ let mut reverse = 0.0_f64;
+ let mut previous_angle: Option = None;
+ for &(x, y) in points {
+ let dx = f64::from(x) - cx;
+ let dy = f64::from(y) - cy;
+ let sample_radius = dx.hypot(dy);
+ if (sample_radius - radius).abs() > (radius * 0.25).max(5.0) {
+ return None;
+ }
+
+ let angle = dy.atan2(dx);
+ if let Some(previous) = previous_angle {
+ let delta = (angle - previous + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU)
+ - std::f64::consts::PI;
+ if delta.abs() > std::f64::consts::FRAC_PI_3 {
+ return None;
+ }
+ winding += delta;
+ reverse += delta.abs();
+ }
+ previous_angle = Some(angle);
+ }
+ if winding.abs() < std::f64::consts::TAU * 0.8
+ || winding.abs() > std::f64::consts::TAU * 1.2
+ || reverse > winding.abs() * 1.35
+ {
+ return None;
+ }
+
+ Some((cx.round() as i32, cy.round() as i32, radius.round() as i32))
+}
+
+fn distance(a: (i32, i32), b: (i32, i32)) -> f64 {
+ (f64::from(a.0) - f64::from(b.0)).hypot(f64::from(a.1) - f64::from(b.1))
+}
diff --git a/src/input/tool/mod.rs b/src/input/tool/mod.rs
index 67e508d88..0c46139b3 100644
--- a/src/input/tool/mod.rs
+++ b/src/input/tool/mod.rs
@@ -2,6 +2,7 @@
mod catalog;
mod drawing;
+mod live_shape;
mod profile;
mod settings;
diff --git a/src/input/tool/tests.rs b/src/input/tool/tests.rs
index aaf1a5e80..592eff23e 100644
--- a/src/input/tool/tests.rs
+++ b/src/input/tool/tests.rs
@@ -163,11 +163,11 @@ fn descriptor_exposes_press_motion_and_drawing_behavior() {
}
#[test]
-fn only_freehand_pen_supports_pressure_thickness() {
+fn freehand_pens_support_pressure_thickness() {
for tool in Tool::ALL {
assert_eq!(
tool.supports_pressure_thickness(),
- tool == Tool::Pen,
+ matches!(tool, Tool::Pen | Tool::LiveShape),
"unexpected pressure-thickness behavior for {tool:?}"
);
}
diff --git a/src/toolbar_icons/mod.rs b/src/toolbar_icons/mod.rs
index 9582ca0a0..2400657ec 100644
--- a/src/toolbar_icons/mod.rs
+++ b/src/toolbar_icons/mod.rs
@@ -122,6 +122,7 @@ pub(crate) fn top_toolbar_icon_painter(
I::LayoutAdvanced => draw_icon_layout_advanced,
I::Tool(T::Select) => draw_icon_select,
I::Tool(T::Pen) => draw_icon_pen,
+ I::Tool(T::LiveShape) => draw_icon_live_shape,
I::Tool(T::Line) => draw_icon_line,
I::Tool(T::Rect) => draw_icon_rect,
I::Tool(T::Circle) => draw_icon_circle,
diff --git a/src/toolbar_icons/tools.rs b/src/toolbar_icons/tools.rs
index e2ef200ac..8c3f8ff75 100644
--- a/src/toolbar_icons/tools.rs
+++ b/src/toolbar_icons/tools.rs
@@ -11,6 +11,11 @@ pub fn draw_icon_pen(ctx: &Context, x: f64, y: f64, size: f64) {
super::svg::render_pen(ctx, x, y, size);
}
+pub fn draw_icon_live_shape(ctx: &Context, x: f64, y: f64, size: f64) {
+ super::svg::render_pen(ctx, x, y, size);
+ super::svg::render_circle(ctx, x + size * 0.46, y + size * 0.46, size * 0.52);
+}
+
pub fn draw_icon_line(ctx: &Context, x: f64, y: f64, size: f64) {
super::svg::render_line(ctx, x, y, size);
}
diff --git a/src/ui/help_overlay/sections/builder/sections.rs b/src/ui/help_overlay/sections/builder/sections.rs
index ddc7b66da..eb23a360e 100644
--- a/src/ui/help_overlay/sections/builder/sections.rs
+++ b/src/ui/help_overlay/sections/builder/sections.rs
@@ -104,6 +104,7 @@ pub(super) fn build_main_sections(
title: "Drawing",
rows: vec![
action_row(bindings, Action::SelectPenTool, NOT_BOUND_LABEL),
+ action_row(bindings, Action::SelectLiveShapeTool, NOT_BOUND_LABEL),
action_row(bindings, Action::SelectLineTool, "Shift+Drag"),
action_row(bindings, Action::SelectRectTool, "Ctrl+Drag"),
action_row(bindings, Action::SelectEllipseTool, "Tab+Drag"),
diff --git a/src/ui/toolbar/model/tools.rs b/src/ui/toolbar/model/tools.rs
index 96cd76c49..a2b98acf0 100644
--- a/src/ui/toolbar/model/tools.rs
+++ b/src/ui/toolbar/model/tools.rs
@@ -13,6 +13,7 @@ use crate::ui::toolbar::ToolbarSnapshot;
pub(crate) enum SemanticToolIcon {
Select,
Pen,
+ LiveShape,
Line,
Rect,
Circle,
@@ -49,7 +50,8 @@ const FULL_TOOL_BUTTONS: [Tool; 7] = [
];
/// Full-mode shape picker: everything the strip no longer shows inline.
-const FULL_SHAPE_PICKER_TOOLS: [Tool; 9] = [
+const FULL_SHAPE_PICKER_TOOLS: [Tool; 10] = [
+ Tool::LiveShape,
Tool::Rect,
Tool::Ellipse,
Tool::Blur,
@@ -61,7 +63,8 @@ const FULL_SHAPE_PICKER_TOOLS: [Tool; 9] = [
Tool::FreeformPolygon,
];
-const SHAPE_TOOLS: [Tool; 11] = [
+const SHAPE_TOOLS: [Tool; 12] = [
+ Tool::LiveShape,
Tool::Line,
Tool::Rect,
Tool::Ellipse,
@@ -161,6 +164,7 @@ pub(crate) fn toolbar_item_id_for_tool(tool: Tool) -> ToolbarItemId {
match tool {
Tool::Select => ids::TOP_TOOL_SELECT,
Tool::Pen => ids::TOP_TOOL_PEN,
+ Tool::LiveShape => ids::TOP_TOOL_LIVE_SHAPE,
Tool::Line => ids::TOP_TOOL_LINE,
Tool::Rect => ids::TOP_TOOL_RECT,
Tool::Ellipse => ids::TOP_TOOL_ELLIPSE,
@@ -183,6 +187,7 @@ fn tool_for_toolbar_item_id(id: ToolbarItemId) -> Option {
[
(ids::TOP_TOOL_SELECT, Tool::Select),
(ids::TOP_TOOL_PEN, Tool::Pen),
+ (ids::TOP_TOOL_LIVE_SHAPE, Tool::LiveShape),
(ids::TOP_TOOL_LINE, Tool::Line),
(ids::TOP_TOOL_RECT, Tool::Rect),
(ids::TOP_TOOL_ELLIPSE, Tool::Ellipse),
@@ -332,9 +337,12 @@ pub(crate) enum TopToolGroup {
pub(crate) fn top_tool_group(tool: Tool) -> TopToolGroup {
match tool {
- Tool::Select | Tool::Pen | Tool::Marker | Tool::StepMarker | Tool::Eraser => {
- TopToolGroup::Pens
- }
+ Tool::Select
+ | Tool::Pen
+ | Tool::LiveShape
+ | Tool::Marker
+ | Tool::StepMarker
+ | Tool::Eraser => TopToolGroup::Pens,
_ => TopToolGroup::Shapes,
}
}
@@ -385,6 +393,7 @@ pub(crate) fn semantic_icon_for_tool(tool: Tool) -> SemanticToolIcon {
match tool {
Tool::Select => SemanticToolIcon::Select,
Tool::Pen => SemanticToolIcon::Pen,
+ Tool::LiveShape => SemanticToolIcon::LiveShape,
Tool::Line => SemanticToolIcon::Line,
Tool::Rect => SemanticToolIcon::Rect,
Tool::Ellipse => SemanticToolIcon::Circle,
From f28b3fc2ee854c7b2010e5880bd82d46db5daea3 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Fri, 25 Sep 2026 13:29:06 +0200
Subject: [PATCH 2/2] feat: add adjustable Shape Pen recognition and rectangles
(#391)
---
README.md | 2 +-
config.example.toml | 5 +
.../src/app/pages/drawing/defaults.rs | 12 +
configurator/src/app/search/terms.rs | 3 +
configurator/src/app/search/tests.rs | 2 +
.../src/models/config/draft/from_config.rs | 4 +
configurator/src/models/config/draft/mod.rs | 1 +
configurator/src/models/config/setters.rs | 3 +
configurator/src/models/config/tests.rs | 22 ++
.../src/models/config/to_config/drawing.rs | 9 +
configurator/src/models/fields/toggles.rs | 1 +
docs/CONFIG.md | 5 +-
src/config/mod.rs | 7 +-
src/config/tests/validate.rs | 16 +
src/config/types/drawing.rs | 15 +-
src/config/types/mod.rs | 1 +
src/config/validate/drawing.rs | 10 +-
src/input/state/core/style.rs | 2 +
src/input/state/mouse/release/drawing.rs | 1 +
src/input/state/render.rs | 1 +
src/input/state/tests/drawing.rs | 175 ++++++++++-
src/input/tool/drawing.rs | 5 +
src/input/tool/live_shape.rs | 293 ++++++++++++++----
23 files changed, 526 insertions(+), 69 deletions(-)
diff --git a/README.md b/README.md
index d3e8714cb..9fd5916c1 100644
--- a/README.md
+++ b/README.md
@@ -117,7 +117,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See
### Drawing and editing
- Freehand pen, highlighter, eraser (circle/rect)
-- Shape pen: draw naturally and confident strokes preview and become editable straight lines or circles; other strokes remain freehand. Choose **Live Shaping Tool** in the command palette or the toolbar's Shapes picker.
+- Shape pen: draw naturally and confident strokes preview and become editable lines, circles, ovals, or rectangles; other strokes remain freehand. Choose **Live Shaping Tool** in the command palette or the toolbar's Shapes picker. Adjust recognition with `[drawing] shape_recognition_sensitivity` (0-4) or the configurator's Drawing page.
- Pen smoothing: finished pen and marker strokes are cleaned up on release, so the live line never lags the cursor (`[drawing] pen_smoothing`, 0-6, or the toolbar's **Smoothing** stepper); tablet pressure values are preserved, and the level is remembered with the session
- Shapes: lines, rectangles, ellipses, polygons (with fill toggle)
- Arrows in four styles - standard, pointy, curved (drag its handle to route around what is in the way), and double-ended - with optional auto-numbered labels; step markers for walkthroughs
diff --git a/config.example.toml b/config.example.toml
index a3a0fdc2c..4d7b587ee 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -1118,6 +1118,11 @@ font_cycle = ["Sans", "Monospace", "Serif"]
# A session remembers the level it was saved at; this is the starting value.
pen_smoothing = 3
+# Shape Pen recognition sensitivity (0 - 4): 0 is precise, 2 is balanced,
+# and 4 accepts rougher lines and closed shapes. Applies to lines, ellipses,
+# and rectangles; it does not change the dedicated shape tools or grid spacing.
+shape_recognition_sensitivity = 2
+
# Default fill state for fill-capable shapes
default_fill_enabled = false
diff --git a/configurator/src/app/pages/drawing/defaults.rs b/configurator/src/app/pages/drawing/defaults.rs
index 654b612ae..60c379226 100644
--- a/configurator/src/app/pages/drawing/defaults.rs
+++ b/configurator/src/app/pages/drawing/defaults.rs
@@ -54,6 +54,18 @@ pub(super) fn build(page: &mut PageBuilder) {
|value| Message::TextChanged(TextField::DrawingPenSmoothing, value),
|app| validate_usize_range(&app.draft.drawing_pen_smoothing, 0, 6),
)
+ .entry_row_validated(
+ "Shape Pen sensitivity (0-4)",
+ |app| app.draft.drawing_shape_recognition_sensitivity.clone(),
+ |value| Message::TextChanged(TextField::DrawingShapeRecognitionSensitivity, value),
+ |app| {
+ validate_usize_range(
+ &app.draft.drawing_shape_recognition_sensitivity,
+ 0,
+ usize::from(wayscriber::config::MAX_SHAPE_RECOGNITION_SENSITIVITY),
+ )
+ },
+ )
.entry_row_validated(
"Undo stack limit",
|app| app.draft.drawing_undo_stack_limit.clone(),
diff --git a/configurator/src/app/search/terms.rs b/configurator/src/app/search/terms.rs
index 38bcfaaeb..de1fd7cfc 100644
--- a/configurator/src/app/search/terms.rs
+++ b/configurator/src/app/search/terms.rs
@@ -99,6 +99,9 @@ pub(super) const DRAWING_DEFAULT_TERMS: &[&str] = &[
"polygon",
"polygon sides",
"shape",
+ "shape pen sensitivity",
+ "shape recognition",
+ "recognition sensitivity",
"marker",
"marker opacity",
"fill",
diff --git a/configurator/src/app/search/tests.rs b/configurator/src/app/search/tests.rs
index 8bd7887f2..1e4869573 100644
--- a/configurator/src/app/search/tests.rs
+++ b/configurator/src/app/search/tests.rs
@@ -115,6 +115,8 @@ fn exact_drawing_default_labels_match_defaults_section() {
for query in [
"font size pt",
"eraser size px",
+ "shape pen sensitivity",
+ "shape recognition",
"enable text background",
"enable text halo",
] {
diff --git a/configurator/src/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs
index 1f28920d6..9315f4ae6 100644
--- a/configurator/src/models/config/draft/from_config.rs
+++ b/configurator/src/models/config/draft/from_config.rs
@@ -74,6 +74,10 @@ impl ConfigDraft {
drawing_polygon_sides: config.drawing.polygon_sides.to_string(),
drawing_marker_opacity: format_float(config.drawing.marker_opacity),
drawing_pen_smoothing: config.drawing.pen_smoothing.to_string(),
+ drawing_shape_recognition_sensitivity: config
+ .drawing
+ .shape_recognition_sensitivity
+ .to_string(),
drawing_font_cycle: FontCycleDraft::from_entries(config.drawing.font_cycle.clone()),
drawing_hit_test_tolerance: format_float(config.drawing.hit_test_tolerance),
drawing_hit_test_linear_threshold: config.drawing.hit_test_linear_threshold.to_string(),
diff --git a/configurator/src/models/config/draft/mod.rs b/configurator/src/models/config/draft/mod.rs
index 2d8109cd2..b054ae53b 100644
--- a/configurator/src/models/config/draft/mod.rs
+++ b/configurator/src/models/config/draft/mod.rs
@@ -42,6 +42,7 @@ pub struct ConfigDraft {
pub drawing_polygon_sides: String,
pub drawing_marker_opacity: String,
pub drawing_pen_smoothing: String,
+ pub drawing_shape_recognition_sensitivity: String,
/// Families `Shift+T` steps through, in order. A list rather than a
/// comma-separated line: a family name can contain a comma, and the row
/// editor picks from what is installed instead of asking for exact text.
diff --git a/configurator/src/models/config/setters.rs b/configurator/src/models/config/setters.rs
index 12e3cad65..947fd252e 100644
--- a/configurator/src/models/config/setters.rs
+++ b/configurator/src/models/config/setters.rs
@@ -331,6 +331,9 @@ impl ConfigDraft {
TextField::DrawingPolygonSides => self.drawing_polygon_sides = value,
TextField::DrawingMarkerOpacity => self.drawing_marker_opacity = value,
TextField::DrawingPenSmoothing => self.drawing_pen_smoothing = value,
+ TextField::DrawingShapeRecognitionSensitivity => {
+ self.drawing_shape_recognition_sensitivity = value
+ }
TextField::DrawingFontFamily => self.drawing_font_family = value,
TextField::DrawingFontWeight => {
self.drawing_font_weight = value;
diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs
index dc2108ac6..65a17dfde 100644
--- a/configurator/src/models/config/tests.rs
+++ b/configurator/src/models/config/tests.rs
@@ -1,5 +1,27 @@
use super::super::color::ColorInput;
+#[test]
+fn shape_pen_sensitivity_round_trips_and_rejects_invalid_drafts() {
+ let mut config = Config::default();
+ config.drawing.shape_recognition_sensitivity = 3;
+ let mut draft = ConfigDraft::from_config(&config);
+ assert_eq!(draft.drawing_shape_recognition_sensitivity, "3");
+
+ draft.drawing_shape_recognition_sensitivity = "4".to_string();
+ assert_eq!(
+ draft
+ .to_config(&config)
+ .expect("valid sensitivity")
+ .drawing
+ .shape_recognition_sensitivity,
+ 4
+ );
+
+ draft.drawing_shape_recognition_sensitivity = "5".to_string();
+ assert!(draft.to_config(&config).is_err());
+ assert_eq!(draft.drawing_shape_recognition_sensitivity, "5");
+}
+
#[test]
fn board_grid_draft_preserves_patterns_and_rejects_invalid_spacing() {
let mut config = Config::default();
diff --git a/configurator/src/models/config/to_config/drawing.rs b/configurator/src/models/config/to_config/drawing.rs
index de1645e7a..8267bedb3 100644
--- a/configurator/src/models/config/to_config/drawing.rs
+++ b/configurator/src/models/config/to_config/drawing.rs
@@ -4,6 +4,7 @@ use super::super::parse::{
};
use crate::models::error::FormError;
use wayscriber::config::Config;
+use wayscriber::config::MAX_SHAPE_RECOGNITION_SENSITIVITY;
use wayscriber::domain::{DragBindableTool, DragTool};
use wayscriber::domain::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS};
use wayscriber::draw::{MAX_PEN_SMOOTHING, REGULAR_POLYGON_MAX_SIDES, REGULAR_POLYGON_MIN_SIDES};
@@ -59,6 +60,14 @@ impl ConfigDraft {
errors,
|value| config.drawing.pen_smoothing = value,
);
+ parse_u8_in_range(
+ &self.drawing_shape_recognition_sensitivity,
+ "drawing.shape_recognition_sensitivity",
+ 0,
+ MAX_SHAPE_RECOGNITION_SENSITIVITY,
+ errors,
+ |value| config.drawing.shape_recognition_sensitivity = value,
+ );
parse_field_in_range(
&self.drawing_marker_opacity,
"drawing.marker_opacity",
diff --git a/configurator/src/models/fields/toggles.rs b/configurator/src/models/fields/toggles.rs
index e61b0f9cb..085e5654f 100644
--- a/configurator/src/models/fields/toggles.rs
+++ b/configurator/src/models/fields/toggles.rs
@@ -109,6 +109,7 @@ pub enum TextField {
DrawingPolygonSides,
DrawingMarkerOpacity,
DrawingPenSmoothing,
+ DrawingShapeRecognitionSensitivity,
DrawingFontFamily,
DrawingFontWeight,
DrawingFontStyle,
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index 51aa6ee08..6fba24550 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -244,6 +244,9 @@ font_cycle = ["Sans", "Monospace", "Serif"]
# Smoothing applied to a finished freehand or marker stroke (0 - 6)
pen_smoothing = 3
+# Shape Pen: 0 precise, 2 balanced (default), 4 more forgiving
+shape_recognition_sensitivity = 2
+
# Default fill state for fill-capable shape tools
default_fill_enabled = false
@@ -378,7 +381,7 @@ drag_tool = "default"
- **Arrow style**: Run **Cycle Arrow Style** from the command palette to step through standard → pointy → curved → double (unbound by default; bind `cycle_arrow_style`). With arrows selected it restyles those in one undo step; with nothing selected it sets the style for the next arrow
- **Marker opacity**: Use Ctrl+Alt + ↑/↓
- **Pen smoothing**: Run **Increase / Decrease Pen Smoothing** from the command palette, or bind `increase_pen_smoothing` / `decrease_pen_smoothing` (see [Pen smoothing](#pen-smoothing))
-- **Live shaping**: Choose **Shape pen** from the Shapes picker or **Live Shaping Tool** from the command palette; bind `select_live_shape_tool` for a shortcut. Confident lines and circles preview as shapes and commit as editable shapes. Lines close to Cartesian or isometric board-paper lines snap to them; without a nearby grid line, nearly horizontal or vertical strokes align to that axis and skew lines keep their angle. Other ink stays freehand.
+- **Live shaping**: Choose **Shape pen** from the Shapes picker or **Live Shaping Tool** from the command palette; bind `select_live_shape_tool` for a shortcut. Confident lines, circles, ovals, and axis-aligned rectangles preview as shapes and commit as editable shapes. Set `[drawing] shape_recognition_sensitivity` from 0 (precise) to 4 (forgiving), with 2 as the default, or edit **Shape Pen sensitivity** on the configurator's Drawing page. Reopen the overlay after changing it. The setting affects recognition by Shape Pen, not the dedicated shape tools. Lines close to Cartesian or isometric board-paper lines snap to them; without a nearby grid line, nearly horizontal or vertical strokes align to that axis and skew lines keep their angle. Other ink stays freehand.
- **Text font**: Shift+T steps through `font_cycle`; **Font Picker** in the command palette opens the full list (see [Font cycle](#font-cycle) and [Font picker](#font-picker))
- **Regular polygon sides**: Use the Shapes popover Sides control (range: 3-12)
- **Font size**: Use Ctrl+Shift++/Ctrl+Shift+- or Shift + scroll (range: 8-72px)
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 1e623f5cf..e3d1b2d7b 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -63,9 +63,10 @@ pub use types::{
ARROW_ANGLE_MAX, ARROW_ANGLE_MIN, ARROW_LENGTH_MAX, ARROW_LENGTH_MIN, ArrowConfig,
BoardBackgroundConfig, BoardColorConfig, BoardConfig, BoardGridConfig, BoardGridKindConfig,
BoardItemConfig, BoardsConfig, CaptureConfig, ClickHighlightConfig, DEFAULT_OCR_LANGUAGES,
- DEFAULT_PEN_SMOOTHING, DragButtonConfig, DrawingConfig, ExportConfig, HelpOverlayStyle,
- HistoryConfig, InputHudConfig, InputHudMode, InputHudPosition, MouseDragToolsConfig,
- PDF_LABEL_APP_BOARD, PDF_LABEL_APP_BOARDS, PDF_LABEL_BOARD_NAME, PDF_LABEL_DEFAULT_TEMPLATE,
+ DEFAULT_PEN_SMOOTHING, DEFAULT_SHAPE_RECOGNITION_SENSITIVITY, DragButtonConfig, DrawingConfig,
+ ExportConfig, HelpOverlayStyle, HistoryConfig, InputHudConfig, InputHudMode, InputHudPosition,
+ MAX_SHAPE_RECOGNITION_SENSITIVITY, MouseDragToolsConfig, PDF_LABEL_APP_BOARD,
+ PDF_LABEL_APP_BOARDS, PDF_LABEL_BOARD_NAME, PDF_LABEL_DEFAULT_TEMPLATE,
PDF_LABEL_DOCUMENT_PAGE, PDF_LABEL_DOCUMENT_PAGES, PDF_LABEL_EXPORT_BOARD,
PDF_LABEL_EXPORT_BOARDS, PDF_LABEL_PAGE, PDF_LABEL_PAGE_NAME, PDF_LABEL_PAGES,
PDF_LABEL_PLACEHOLDERS, PRESET_SLOTS_MAX, PRESET_SLOTS_MIN, PdfExportConfig, PdfFitMode,
diff --git a/src/config/tests/validate.rs b/src/config/tests/validate.rs
index 768c835c5..566d97e07 100644
--- a/src/config/tests/validate.rs
+++ b/src/config/tests/validate.rs
@@ -85,6 +85,22 @@ fn drawing_polygon_sides_validation_keeps_supported_bounds() {
assert_eq!(config.drawing.polygon_sides, 12);
}
+#[test]
+fn shape_recognition_sensitivity_defaults_and_clamps() {
+ let mut config = config_from_toml("[drawing]\n");
+ assert_eq!(
+ config.drawing.shape_recognition_sensitivity,
+ crate::config::DEFAULT_SHAPE_RECOGNITION_SENSITIVITY
+ );
+
+ config.drawing.shape_recognition_sensitivity = u8::MAX;
+ config.validate_and_clamp();
+ assert_eq!(
+ config.drawing.shape_recognition_sensitivity,
+ crate::config::MAX_SHAPE_RECOGNITION_SENSITIVITY
+ );
+}
+
#[test]
fn default_overlay_item_is_the_transparent_board() {
let overlay = BoardsConfig::default_overlay_item();
diff --git a/src/config/types/drawing.rs b/src/config/types/drawing.rs
index 4a33433f8..07c289316 100644
--- a/src/config/types/drawing.rs
+++ b/src/config/types/drawing.rs
@@ -12,10 +12,14 @@ pub(crate) const DEFAULT_HIT_TEST_TOLERANCE: f64 = 6.0;
/// Default release-time smoothing level for freehand and marker strokes.
pub const DEFAULT_PEN_SMOOTHING: u8 = 3;
+/// Shape Pen recognition levels, from precise to forgiving.
+pub const DEFAULT_SHAPE_RECOGNITION_SENSITIVITY: u8 = 2;
+pub const MAX_SHAPE_RECOGNITION_SENSITIVITY: u8 = 4;
+
/// Drawing-related settings.
///
/// Controls the default appearance of drawing tools when the overlay first opens.
-/// Users can change these values at runtime using keybindings.
+/// Some values can also be changed at runtime using keybindings.
#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DrawingConfig {
@@ -68,6 +72,10 @@ pub struct DrawingConfig {
#[serde(default = "default_pen_smoothing")]
pub pen_smoothing: u8,
+ /// How readily Shape Pen turns ink into lines, ellipses, or rectangles (0 - 4).
+ #[serde(default = "default_shape_recognition_sensitivity")]
+ pub shape_recognition_sensitivity: u8,
+
/// Whether shapes start filled when applicable
#[serde(default = "default_fill_enabled")]
pub default_fill_enabled: bool,
@@ -155,6 +163,7 @@ impl Default for DrawingConfig {
marker_opacity: default_marker_opacity(),
font_cycle: default_font_cycle(),
pen_smoothing: default_pen_smoothing(),
+ shape_recognition_sensitivity: default_shape_recognition_sensitivity(),
default_fill_enabled: default_fill_enabled(),
polygon_sides: default_polygon_sides(),
default_font_size: default_font_size(),
@@ -914,6 +923,10 @@ fn default_pen_smoothing() -> u8 {
DEFAULT_PEN_SMOOTHING
}
+fn default_shape_recognition_sensitivity() -> u8 {
+ DEFAULT_SHAPE_RECOGNITION_SENSITIVITY
+}
+
fn default_fill_enabled() -> bool {
false
}
diff --git a/src/config/types/mod.rs b/src/config/types/mod.rs
index a3c3406d5..2eb4e607a 100644
--- a/src/config/types/mod.rs
+++ b/src/config/types/mod.rs
@@ -40,6 +40,7 @@ pub use click_highlight::ClickHighlightConfig;
pub use context_menu::ContextMenuUiConfig;
pub(crate) use drawing::DEFAULT_HIT_TEST_TOLERANCE;
pub use drawing::DEFAULT_PEN_SMOOTHING;
+pub use drawing::{DEFAULT_SHAPE_RECOGNITION_SENSITIVITY, MAX_SHAPE_RECOGNITION_SENSITIVITY};
pub use drawing::{
DragButtonConfig, DrawingConfig, MouseDragToolsConfig, QUICK_COLOR_RENDER_LIMIT,
QuickColorConfig, QuickColorPalette, QuickColorPaletteEntry, QuickColorSlot, QuickColorWrite,
diff --git a/src/config/validate/drawing.rs b/src/config/validate/drawing.rs
index c31044ab9..b02271403 100644
--- a/src/config/validate/drawing.rs
+++ b/src/config/validate/drawing.rs
@@ -1,5 +1,5 @@
use super::Config;
-use crate::config::types::DEFAULT_HIT_TEST_TOLERANCE;
+use crate::config::types::{DEFAULT_HIT_TEST_TOLERANCE, MAX_SHAPE_RECOGNITION_SENSITIVITY};
use crate::domain::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS};
use crate::draw::shape::{MAX_PEN_SMOOTHING, REGULAR_POLYGON_MAX_SIDES, REGULAR_POLYGON_MIN_SIDES};
@@ -41,6 +41,14 @@ impl Config {
self.drawing.pen_smoothing = MAX_PEN_SMOOTHING;
}
+ if self.drawing.shape_recognition_sensitivity > MAX_SHAPE_RECOGNITION_SENSITIVITY {
+ log::warn!(
+ "Invalid shape_recognition_sensitivity {}, clamping to 0-{MAX_SHAPE_RECOGNITION_SENSITIVITY} range",
+ self.drawing.shape_recognition_sensitivity
+ );
+ self.drawing.shape_recognition_sensitivity = MAX_SHAPE_RECOGNITION_SENSITIVITY;
+ }
+
if !(0.05..=0.9).contains(&self.drawing.marker_opacity) {
log::warn!(
"Invalid marker_opacity {:.2}, clamping to 0.05-0.90 range",
diff --git a/src/input/state/core/style.rs b/src/input/state/core/style.rs
index ccf4868c5..a1dad7bea 100644
--- a/src/input/state/core/style.rs
+++ b/src/input/state/core/style.rs
@@ -31,6 +31,7 @@ pub(crate) struct DrawingStyle {
pub(crate) eraser_mode: EraserMode,
pub(crate) marker_opacity: f64,
pub(crate) pen_smoothing: u8,
+ pub(crate) shape_recognition_sensitivity: u8,
pub(crate) blur_style: BlurStyle,
pub(crate) spotlight_dim_opacity: f64,
pub(crate) spotlight_feather: f64,
@@ -78,6 +79,7 @@ impl From<(&DrawingConfig, &ArrowConfig, &SpotlightConfig)> for DrawingStyle {
eraser_mode: drawing.default_eraser_mode,
marker_opacity: drawing.marker_opacity,
pen_smoothing: crate::draw::shape::clamp_pen_smoothing(drawing.pen_smoothing),
+ shape_recognition_sensitivity: drawing.shape_recognition_sensitivity,
blur_style: drawing.default_blur_style,
spotlight_dim_opacity: spotlight.dim_opacity,
spotlight_feather: spotlight.feather,
diff --git a/src/input/state/mouse/release/drawing.rs b/src/input/state/mouse/release/drawing.rs
index ebbf1cc62..e11983f26 100644
--- a/src/input/state/mouse/release/drawing.rs
+++ b/src/input/state/mouse/release/drawing.rs
@@ -68,6 +68,7 @@ pub(super) fn finish_drawing(
color: drawing_color,
size: drawing_thickness,
grid: state.boards.active_board().spec.grid,
+ shape_recognition_sensitivity: state.style.shape_recognition_sensitivity,
marker_opacity: state.style.marker_opacity,
fill_enabled: state.style.fill_enabled,
blur_style: state.style.blur_style,
diff --git a/src/input/state/render.rs b/src/input/state/render.rs
index e1187c2d2..c9a9b5c08 100644
--- a/src/input/state/render.rs
+++ b/src/input/state/render.rs
@@ -79,6 +79,7 @@ impl InputState {
color: self.active_drag_color_or_current(),
size: self.thickness_for_tool(*tool),
grid: self.boards.active_board().spec.grid,
+ shape_recognition_sensitivity: self.style.shape_recognition_sensitivity,
eraser_size: self.style.eraser_size,
marker_opacity: self.style.marker_opacity,
fill_enabled: self.style.fill_enabled,
diff --git a/src/input/state/tests/drawing.rs b/src/input/state/tests/drawing.rs
index 79b172c1a..0169be444 100644
--- a/src/input/state/tests/drawing.rs
+++ b/src/input/state/tests/drawing.rs
@@ -137,6 +137,161 @@ fn live_shape_strokes_preview_and_commit_lines_and_circles() {
assert_eq!(state.boards.active_frame().undo_stack_len(), 3);
}
+#[test]
+fn live_shape_previews_and_commits_hand_drawn_rectangles_and_ovals() {
+ use crate::input::tool::ProvisionalToolStroke;
+
+ let mut state = create_test_input_state();
+ assert!(state.set_tool_override(Some(Tool::LiveShape)));
+
+ state.on_mouse_press(MouseButton::Left, 10, 10);
+ for (x, y) in [
+ (30, 8),
+ (55, 11),
+ (80, 9),
+ (110, 10),
+ (112, 30),
+ (109, 55),
+ (110, 90),
+ (85, 92),
+ (60, 89),
+ (35, 91),
+ (10, 90),
+ (8, 70),
+ (11, 45),
+ (9, 25),
+ (10, 10),
+ ] {
+ state.on_mouse_motion(x, y);
+ }
+ assert!(matches!(
+ state.provisional_tool_stroke(10, 10),
+ ProvisionalToolStroke::Shape(Shape::Rect { .. })
+ ));
+ state.on_mouse_release(MouseButton::Left, 10, 10);
+ assert!(matches!(
+ state.boards.active_frame().shapes[0].shape,
+ Shape::Rect {
+ x: 8,
+ y: 8,
+ w: 104,
+ h: 84,
+ fill: false,
+ ..
+ }
+ ));
+
+ state.style.shape_recognition_sensitivity = 0;
+ state.on_mouse_press(MouseButton::Left, 260, 80);
+ for index in 1..=32 {
+ let angle = std::f64::consts::TAU * f64::from(index) / 32.0;
+ state.on_mouse_motion(
+ 200 + (60.0 * angle.cos()).round() as i32,
+ 80 + (35.0 * angle.sin()).round() as i32,
+ );
+ }
+ assert!(matches!(
+ state.provisional_tool_stroke(260, 80),
+ ProvisionalToolStroke::Shape(Shape::Ellipse { .. })
+ ));
+ state.on_mouse_release(MouseButton::Left, 260, 80);
+ assert!(matches!(
+ state.boards.active_frame().shapes[1].shape,
+ Shape::Ellipse {
+ cx: 200,
+ cy: 80,
+ rx: 60,
+ ry: 35,
+ fill: false,
+ ..
+ }
+ ));
+}
+
+#[test]
+fn live_shape_sensitivity_recognizes_rough_lines_without_changing_precise_ink() {
+ for (sensitivity, becomes_line) in [(0, false), (4, true)] {
+ let mut state = create_test_input_state();
+ state.style.shape_recognition_sensitivity = sensitivity;
+ assert!(state.set_tool_override(Some(Tool::LiveShape)));
+
+ state.on_mouse_press(MouseButton::Left, 0, 0);
+ for (x, y) in [(20, 5), (40, -4), (60, 6), (80, 1)] {
+ state.on_mouse_motion(x, y);
+ }
+ state.on_mouse_release(MouseButton::Left, 80, 1);
+
+ assert_eq!(
+ matches!(
+ state.boards.active_frame().shapes[0].shape,
+ Shape::Line { .. }
+ ),
+ becomes_line,
+ "sensitivity {sensitivity}"
+ );
+ }
+}
+
+#[test]
+fn live_shape_sensitivity_controls_rough_closed_shapes() {
+ let rectangle = vec![
+ (10, 10),
+ (30, 16),
+ (50, 18),
+ (70, 16),
+ (90, 10),
+ (84, 30),
+ (82, 50),
+ (84, 70),
+ (90, 90),
+ (70, 84),
+ (50, 82),
+ (30, 84),
+ (10, 90),
+ (16, 70),
+ (18, 50),
+ (16, 30),
+ (10, 10),
+ ];
+ let oval: Vec<_> = (0..=32)
+ .map(|index| {
+ let angle = std::f64::consts::TAU * f64::from(index) / 32.0;
+ let wobble = 1.0 + 0.13 * (3.0 * angle).sin();
+ (
+ 200 + (50.0 * wobble * angle.cos()).round() as i32,
+ 80 + (40.0 * wobble * angle.sin()).round() as i32,
+ )
+ })
+ .collect();
+
+ for (path, expected_kind) in [(rectangle, "Rectangle"), (oval, "Ellipse")] {
+ for (sensitivity, recognized) in [(0, false), (4, true)] {
+ let mut state = create_test_input_state();
+ state.style.shape_recognition_sensitivity = sensitivity;
+ assert!(state.set_tool_override(Some(Tool::LiveShape)));
+
+ let first = path[0];
+ state.on_mouse_press(MouseButton::Left, first.0, first.1);
+ for &(x, y) in &path[1..] {
+ state.on_mouse_motion(x, y);
+ }
+ let last = *path.last().unwrap();
+ state.on_mouse_release(MouseButton::Left, last.0, last.1);
+
+ let kind = state.boards.active_frame().shapes[0].shape.kind_name();
+ assert_eq!(
+ kind,
+ if recognized {
+ expected_kind
+ } else {
+ "Freehand"
+ },
+ "{expected_kind} at sensitivity {sensitivity}"
+ );
+ }
+ }
+}
+
#[test]
fn live_shape_keeps_ambiguous_strokes_as_freehand() {
let mut state = create_test_input_state();
@@ -153,8 +308,26 @@ fn live_shape_keeps_ambiguous_strokes_as_freehand() {
Shape::Freehand { .. }
));
+ state.style.shape_recognition_sensitivity = 4;
state.on_mouse_press(MouseButton::Left, 40, 0);
- for point in [(80, 40), (40, 80), (0, 40), (40, 0)] {
+ for point in [
+ (50, 10),
+ (60, 20),
+ (70, 30),
+ (80, 40),
+ (70, 50),
+ (60, 60),
+ (50, 70),
+ (40, 80),
+ (30, 70),
+ (20, 60),
+ (10, 50),
+ (0, 40),
+ (10, 30),
+ (20, 20),
+ (30, 10),
+ (40, 0),
+ ] {
state.on_mouse_motion(point.0, point.1);
}
state.on_mouse_release(MouseButton::Left, 40, 0);
diff --git a/src/input/tool/drawing.rs b/src/input/tool/drawing.rs
index 5e6eef5bd..dc416c4a6 100644
--- a/src/input/tool/drawing.rs
+++ b/src/input/tool/drawing.rs
@@ -40,6 +40,7 @@ pub(crate) struct ToolStrokeSnapshot {
pub(crate) color: Color,
pub(crate) size: f64,
pub(crate) grid: crate::domain::BoardGrid,
+ pub(crate) shape_recognition_sensitivity: u8,
pub(crate) marker_opacity: f64,
pub(crate) fill_enabled: bool,
pub(crate) blur_style: BlurStyle,
@@ -91,6 +92,7 @@ pub(crate) struct ProvisionalToolSnapshot<'a> {
pub(crate) color: Color,
pub(crate) size: f64,
pub(crate) grid: crate::domain::BoardGrid,
+ pub(crate) shape_recognition_sensitivity: u8,
pub(crate) eraser_size: f64,
pub(crate) marker_opacity: f64,
pub(crate) fill_enabled: bool,
@@ -156,6 +158,7 @@ impl Tool {
snapshot.color,
snapshot.size,
snapshot.grid,
+ snapshot.shape_recognition_sensitivity,
)
} else {
let mut path = snapshot.points.clone();
@@ -165,6 +168,7 @@ impl Tool {
snapshot.color,
snapshot.size,
snapshot.grid,
+ snapshot.shape_recognition_sensitivity,
)
};
if let Some(shape) = recognized {
@@ -302,6 +306,7 @@ impl Tool {
snapshot.color,
snapshot.size,
snapshot.grid,
+ snapshot.shape_recognition_sensitivity,
) {
ProvisionalToolStroke::Shape(shape)
} else if !snapshot.point_thicknesses.is_empty()
diff --git a/src/input/tool/live_shape.rs b/src/input/tool/live_shape.rs
index 1282acec2..b012658e5 100644
--- a/src/input/tool/live_shape.rs
+++ b/src/input/tool/live_shape.rs
@@ -1,4 +1,4 @@
-//! Recognize a confident line or circle from a pen path. Ambiguous ink stays ink.
+//! Recognize lines and closed shapes from a pen path. Ambiguous ink stays ink.
use crate::domain::{BoardGrid, BoardGridKind};
use crate::draw::{Color, Shape};
@@ -8,42 +8,28 @@ pub(super) fn recognize(
color: Color,
thick: f64,
grid: BoardGrid,
+ sensitivity: u8,
) -> Option {
let first = *points.first()?;
let last = *points.last()?;
- let (min_x, max_x, min_y, max_y) = points.iter().fold(
- (i32::MAX, i32::MIN, i32::MAX, i32::MIN),
- |(min_x, max_x, min_y, max_y), &(x, y)| {
- (min_x.min(x), max_x.max(x), min_y.min(y), max_y.max(y))
- },
- );
- let width = f64::from(max_x) - f64::from(min_x);
- let height = f64::from(max_y) - f64::from(min_y);
- let diameter = width.max(height);
+ let bounds = Bounds::for_points(points);
let chord = distance(first, last);
let length: f64 = points
.windows(2)
.map(|pair| distance(pair[0], pair[1]))
.sum();
+ let sensitivity = sensitivity.min(crate::config::MAX_SHAPE_RECOGNITION_SENSITIVITY);
- if diameter >= 24.0
- && (width - height).abs() <= diameter * 0.25
- && chord <= diameter * 0.2
- && let Some(circle) = recognize_circle(points, min_x, max_x, min_y, max_y, length)
+ if points.len() >= 8
+ && bounds.width >= 24.0
+ && bounds.height >= 24.0
+ && chord <= bounds.diameter() * (0.12 + 0.05 * f64::from(sensitivity))
+ && let Some(shape) = recognize_closed(points, bounds, length, color, thick, sensitivity)
{
- let (cx, cy, radius) = circle;
- return Some(Shape::Ellipse {
- cx,
- cy,
- rx: radius,
- ry: radius,
- fill: false,
- color,
- thick,
- });
+ return Some(shape);
}
- if chord < 16.0 || length > chord * 1.16 {
+ if chord < 16.0 || length > chord * (1.08 + 0.04 * f64::from(sensitivity)) {
return None;
}
@@ -56,7 +42,7 @@ pub(super) fn recognize(
.abs()
})
.fold(0.0_f64, f64::max);
- if deviation > (chord * 0.08).max(4.0) {
+ if deviation > (chord * (0.04 + 0.02 * f64::from(sensitivity))).max(4.0) {
return None;
}
@@ -72,6 +58,75 @@ pub(super) fn recognize(
})
}
+#[derive(Clone, Copy)]
+struct Bounds {
+ min_x: i32,
+ max_x: i32,
+ min_y: i32,
+ max_y: i32,
+ width: f64,
+ height: f64,
+}
+
+impl Bounds {
+ fn for_points(points: &[(i32, i32)]) -> Self {
+ let (min_x, max_x, min_y, max_y) = points.iter().fold(
+ (i32::MAX, i32::MIN, i32::MAX, i32::MIN),
+ |(min_x, max_x, min_y, max_y), &(x, y)| {
+ (min_x.min(x), max_x.max(x), min_y.min(y), max_y.max(y))
+ },
+ );
+ Self {
+ min_x,
+ max_x,
+ min_y,
+ max_y,
+ width: f64::from(max_x) - f64::from(min_x),
+ height: f64::from(max_y) - f64::from(min_y),
+ }
+ }
+
+ fn diameter(self) -> f64 {
+ self.width.max(self.height)
+ }
+
+ fn center(self) -> (f64, f64) {
+ (
+ (f64::from(self.min_x) + f64::from(self.max_x)) / 2.0,
+ (f64::from(self.min_y) + f64::from(self.max_y)) / 2.0,
+ )
+ }
+}
+
+struct ClosedFit {
+ shape: Shape,
+ error: f64,
+}
+
+fn recognize_closed(
+ points: &[(i32, i32)],
+ bounds: Bounds,
+ length: f64,
+ color: Color,
+ thick: f64,
+ sensitivity: u8,
+) -> Option {
+ if !winds_once(points, bounds) {
+ return None;
+ }
+
+ let ellipse = fit_ellipse(points, bounds, length, color, thick, sensitivity);
+ let rectangle = fit_rectangle(points, bounds, length, color, thick, sensitivity);
+ match (ellipse, rectangle) {
+ (Some(ellipse), Some(rectangle)) if rectangle.error < ellipse.error => {
+ Some(rectangle.shape)
+ }
+ (Some(ellipse), _) => Some(ellipse.shape),
+ (_, Some(rectangle)) => Some(rectangle.shape),
+ _ => None,
+ }
+}
+
fn align_line(
first: (i32, i32),
last: (i32, i32),
@@ -128,58 +183,174 @@ fn snap_to_grid(first: f64, last: f64, spacing: f64) -> Option {
.then_some(coordinate.round() as i32)
}
-fn recognize_circle(
+fn winds_once(points: &[(i32, i32)], bounds: Bounds) -> bool {
+ let (cx, cy) = bounds.center();
+ let rx = bounds.width / 2.0;
+ let ry = bounds.height / 2.0;
+ let mut winding = 0.0_f64;
+ let mut travel = 0.0_f64;
+ let mut previous_angle: Option = None;
+
+ for &(x, y) in points {
+ let angle = ((f64::from(y) - cy) / ry).atan2((f64::from(x) - cx) / rx);
+ if let Some(previous) = previous_angle {
+ let delta = (angle - previous + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU)
+ - std::f64::consts::PI;
+ if delta.abs() > std::f64::consts::FRAC_PI_2 + 0.1 {
+ return false;
+ }
+ winding += delta;
+ travel += delta.abs();
+ }
+ previous_angle = Some(angle);
+ }
+
+ (std::f64::consts::TAU * 0.8..=std::f64::consts::TAU * 1.2).contains(&winding.abs())
+ && travel <= winding.abs() * 1.5
+}
+
+fn fit_ellipse(
points: &[(i32, i32)],
- min_x: i32,
- max_x: i32,
- min_y: i32,
- max_y: i32,
+ bounds: Bounds,
length: f64,
-) -> Option<(i32, i32, i32)> {
- if points.len() < 8 {
+ color: Color,
+ thick: f64,
+ sensitivity: u8,
+) -> Option {
+ let level = f64::from(sensitivity);
+ let rx = bounds.width / 2.0;
+ let ry = bounds.height / 2.0;
+ if rx.max(ry) / rx.min(ry) > 2.5 || has_polygon_corners(points, bounds) {
return None;
}
- let cx = (f64::from(min_x) + f64::from(max_x)) / 2.0;
- let cy = (f64::from(min_y) + f64::from(max_y)) / 2.0;
- let radius =
- ((f64::from(max_x) - f64::from(min_x)) + (f64::from(max_y) - f64::from(min_y))) / 4.0;
- let circumference = std::f64::consts::TAU * radius;
- if !(circumference * 0.75..=circumference * 1.35).contains(&length) {
+ let circumference =
+ std::f64::consts::PI * (3.0 * (rx + ry) - ((3.0 * rx + ry) * (rx + 3.0 * ry)).sqrt());
+ if !(circumference * (0.74 - 0.03 * level)..=circumference * (1.3 + 0.05 * level))
+ .contains(&length)
+ {
return None;
}
- let mut winding = 0.0_f64;
- let mut reverse = 0.0_f64;
- let mut previous_angle: Option = None;
+ let (cx, cy) = bounds.center();
+ let mut error = 0.0_f64;
+ let mut worst = 0.0_f64;
for &(x, y) in points {
- let dx = f64::from(x) - cx;
- let dy = f64::from(y) - cy;
- let sample_radius = dx.hypot(dy);
- if (sample_radius - radius).abs() > (radius * 0.25).max(5.0) {
- return None;
- }
+ let radius = ((f64::from(x) - cx) / rx).hypot((f64::from(y) - cy) / ry);
+ let deviation = (radius - 1.0).abs();
+ error += deviation;
+ worst = worst.max(deviation);
+ }
+ let error = error / points.len() as f64;
+ if error > 0.07 + 0.025 * level || worst > 0.2 + 0.05 * level {
+ return None;
+ }
- let angle = dy.atan2(dx);
- if let Some(previous) = previous_angle {
- let delta = (angle - previous + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU)
- - std::f64::consts::PI;
- if delta.abs() > std::f64::consts::FRAC_PI_3 {
- return None;
+ Some(ClosedFit {
+ shape: Shape::Ellipse {
+ cx: cx.round() as i32,
+ cy: cy.round() as i32,
+ rx: rx.round() as i32,
+ ry: ry.round() as i32,
+ fill: false,
+ color,
+ thick,
+ },
+ error,
+ })
+}
+
+fn has_polygon_corners(points: &[(i32, i32)], bounds: Bounds) -> bool {
+ // One kink can be hand jitter; three clear turns describe a polygon.
+ let min_segment = (bounds.width.min(bounds.height) * 0.08).max(3.0);
+ let mut corners = 0;
+ for triplet in points.windows(3) {
+ let first = (
+ f64::from(triplet[1].0) - f64::from(triplet[0].0),
+ f64::from(triplet[1].1) - f64::from(triplet[0].1),
+ );
+ let second = (
+ f64::from(triplet[2].0) - f64::from(triplet[1].0),
+ f64::from(triplet[2].1) - f64::from(triplet[1].1),
+ );
+ let first_length = first.0.hypot(first.1);
+ let second_length = second.0.hypot(second.1);
+ if first_length.min(second_length) < min_segment {
+ continue;
+ }
+ if first.0 * second.0 + first.1 * second.1 < 0.25 * first_length * second_length {
+ corners += 1;
+ if corners >= 3 {
+ return true;
}
- winding += delta;
- reverse += delta.abs();
}
- previous_angle = Some(angle);
}
- if winding.abs() < std::f64::consts::TAU * 0.8
- || winding.abs() > std::f64::consts::TAU * 1.2
- || reverse > winding.abs() * 1.35
+ false
+}
+
+fn fit_rectangle(
+ points: &[(i32, i32)],
+ bounds: Bounds,
+ length: f64,
+ color: Color,
+ thick: f64,
+ sensitivity: u8,
+) -> Option {
+ let level = f64::from(sensitivity);
+ let short_side = bounds.width.min(bounds.height);
+ let perimeter = 2.0 * (bounds.width + bounds.height);
+ if bounds.diameter() / short_side > 5.0
+ || !(perimeter * (0.8 - 0.04 * level)..=perimeter * (1.25 + 0.08 * level)).contains(&length)
{
return None;
}
- Some((cx.round() as i32, cy.round() as i32, radius.round() as i32))
+ let mut error = 0.0_f64;
+ let mut worst = 0.0_f64;
+ for &(x, y) in points {
+ let x = f64::from(x);
+ let y = f64::from(y);
+ let distance = (x - f64::from(bounds.min_x))
+ .abs()
+ .min((x - f64::from(bounds.max_x)).abs())
+ .min((y - f64::from(bounds.min_y)).abs())
+ .min((y - f64::from(bounds.max_y)).abs())
+ / short_side;
+ error += distance;
+ worst = worst.max(distance);
+ }
+ let error = error / points.len() as f64;
+ if error > 0.035 + 0.02 * level || worst > 0.13 + 0.035 * level {
+ return None;
+ }
+
+ for corner in [
+ (bounds.min_x, bounds.min_y),
+ (bounds.max_x, bounds.min_y),
+ (bounds.max_x, bounds.max_y),
+ (bounds.min_x, bounds.max_y),
+ ] {
+ let nearest = points
+ .iter()
+ .map(|&point| distance(point, corner))
+ .fold(f64::INFINITY, f64::min);
+ if nearest > short_side * (0.12 + 0.04 * level) {
+ return None;
+ }
+ }
+
+ Some(ClosedFit {
+ shape: Shape::Rect {
+ x: bounds.min_x,
+ y: bounds.min_y,
+ w: bounds.width.round() as i32,
+ h: bounds.height.round() as i32,
+ fill: false,
+ color,
+ thick,
+ },
+ error,
+ })
}
fn distance(a: (i32, i32), b: (i32, i32)) -> f64 {