diff --git a/__e2e_tests__/peer1.e2e.js b/__e2e_tests__/peer1.e2e.js
index 5288777a9b0217bbbe147bfd41959662c995d45d..fd51fb34a8b0ba7d8fb7bc6671ae9a0b53c0090b 100644
--- a/__e2e_tests__/peer1.e2e.js
+++ b/__e2e_tests__/peer1.e2e.js
@@ -29,5 +29,6 @@ test("Clicking and dragging on canvas creates a single child element", async (t)
     .drag(canvas, 10, 10)
     .wait(syncTimeout)
     .expect(canvas.childElementCount)
-    .eql(1)
+    // first draw also creates last recognised path placeholder
+    .eql(2)
 })
diff --git a/__tests__/shape.test.js b/__tests__/shape.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..1f2b315555301288735e6e1d3ffab6c3d2e472a8
--- /dev/null
+++ b/__tests__/shape.test.js
@@ -0,0 +1,626 @@
+import recognizeFromPoints, { Shapes } from "../src/shapes"
+
+describe("shape recognition", () => {
+  describe("general", () => {
+    test("should return a shape description object", () => {
+      const points = [[0, 0]]
+      const result = recognizeFromPoints(points)
+      expect(result).not.toBeNull()
+    })
+  })
+
+  describe("lines", () => {
+    test("should recognize a simple horizontal line", () => {
+      const points = [
+        [0, 0],
+        [100, 0],
+      ]
+      const result = recognizeFromPoints(points)
+      expect(result.shape).toBe(Shapes.line)
+      expect(result.firstPoint).toStrictEqual([0, 0])
+      expect(result.lastPoint).toStrictEqual([100, 0])
+    })
+
+    test("should recognize a simple vertical line", () => {
+      const points = [
+        [0, 50],
+        [0, -100],
+      ]
+      const result = recognizeFromPoints(points)
+      expect(result.shape).toBe(Shapes.line)
+      expect(result.firstPoint).toStrictEqual([0, 50])
+      expect(result.lastPoint).toStrictEqual([0, -100])
+    })
+
+    test("should recognize a slightly curve horizontal line", () => {
+      const points = [
+        [0, 0],
+        [30, 5],
+        [100, 0],
+      ]
+      const result = recognizeFromPoints(points)
+      expect(result.shape).toBe(Shapes.line)
+    })
+
+    test("should not recognize a heavily curved horizontal line", () => {
+      const points = [
+        [0, 0],
+        [30, 30],
+        [100, -4],
+      ]
+      const result = recognizeFromPoints(points)
+      expect(result.shape).not.toBe(Shapes.line)
+    })
+
+    test("should recognize a long horizontal line", () => {
+      const points = [
+        [48.675496688741724, 197.8062913907285],
+        [50.66225165562914, 197.8062913907285],
+        [53.64238410596026, 197.8062913907285],
+        [57.6158940397351, 197.8062913907285],
+        [61.58940397350993, 197.8062913907285],
+        [66.55629139072848, 197.8062913907285],
+        [71.52317880794702, 197.8062913907285],
+        [76.49006622516558, 197.8062913907285],
+        [77.48344370860927, 197.8062913907285],
+        [82.45033112582782, 197.8062913907285],
+        [84.43708609271523, 197.8062913907285],
+        [87.41721854304636, 197.8062913907285],
+        [88.41059602649007, 197.8062913907285],
+        [88.41059602649007, 197.8062913907285],
+        [89.40397350993378, 197.8062913907285],
+      ]
+      const result = recognizeFromPoints(points)
+      expect(result.shape).toBe(Shapes.line)
+    })
+
+    test("should recognize a long vertical line", () => {
+      const points = [
+        [218.54304635761588, 84.56125827814569],
+        [218.54304635761588, 86.54801324503312],
+        [218.54304635761588, 87.54139072847681],
+        [218.54304635761588, 88.53476821192052],
+        [218.54304635761588, 90.52152317880794],
+        [218.54304635761588, 91.51490066225165],
+        [218.54304635761588, 92.50827814569537],
+        [218.54304635761588, 93.50165562913908],
+        [218.54304635761588, 94.49503311258277],
+        [218.54304635761588, 94.49503311258277],
+        [218.54304635761588, 95.4884105960265],
+        [218.54304635761588, 96.4817880794702],
+        [218.54304635761588, 97.4751655629139],
+        [218.54304635761588, 98.46854304635761],
+        [218.54304635761588, 100.45529801324503],
+        [218.54304635761588, 101.44867549668874],
+        [218.54304635761588, 103.43543046357617],
+        [218.54304635761588, 105.42218543046357],
+        [218.54304635761588, 108.4023178807947],
+        [218.54304635761588, 110.38907284768212],
+        [218.54304635761588, 112.37582781456953],
+        [218.54304635761588, 114.36258278145695],
+        [218.54304635761588, 116.34933774834438],
+        [218.54304635761588, 118.33609271523179],
+        [218.54304635761588, 119.32947019867551],
+        [218.54304635761588, 120.3228476821192],
+        [218.54304635761588, 122.30960264900662],
+        [218.54304635761588, 123.30298013245033],
+        [218.54304635761588, 124.29635761589404],
+        [218.54304635761588, 125.28973509933775],
+        [218.54304635761588, 125.28973509933775],
+        [218.54304635761588, 125.28973509933775],
+        [218.54304635761588, 126.28311258278147],
+        [218.54304635761588, 126.28311258278147],
+        [218.54304635761588, 127.27649006622516],
+      ]
+      const result = recognizeFromPoints(points)
+      expect(result.shape).toBe(Shapes.line)
+    })
+
+    test("should recognize a line at 20 degrees", () => {
+      const points = [
+        [34.7682119205298, 268.3360927152318],
+        [34.7682119205298, 268.3360927152318],
+        [36.75496688741722, 268.3360927152318],
+        [37.74834437086093, 268.3360927152318],
+        [38.741721854304636, 268.3360927152318],
+        [39.735099337748345, 268.3360927152318],
+        [40.728476821192054, 268.3360927152318],
+        [41.721854304635755, 268.3360927152318],
+        [42.71523178807947, 268.3360927152318],
+        [43.70860927152318, 268.3360927152318],
+        [43.70860927152318, 268.3360927152318],
+        [44.70198675496689, 268.3360927152318],
+        [44.70198675496689, 268.3360927152318],
+        [45.6953642384106, 267.3427152317881],
+        [45.6953642384106, 267.3427152317881],
+        [46.6887417218543, 267.3427152317881],
+        [47.682119205298015, 267.3427152317881],
+        [48.675496688741724, 266.34933774834434],
+        [48.675496688741724, 266.34933774834434],
+        [49.66887417218543, 266.34933774834434],
+        [50.66225165562914, 266.34933774834434],
+        [51.65562913907284, 265.35596026490066],
+        [52.64900662251656, 265.35596026490066],
+        [52.64900662251656, 265.35596026490066],
+        [53.64238410596026, 265.35596026490066],
+        [54.63576158940397, 264.362582781457],
+        [54.63576158940397, 264.362582781457],
+        [55.629139072847686, 264.362582781457],
+        [55.629139072847686, 264.362582781457],
+        [57.6158940397351, 263.36920529801324],
+        [57.6158940397351, 263.36920529801324],
+        [58.609271523178805, 263.36920529801324],
+        [58.609271523178805, 263.36920529801324],
+        [59.602649006622514, 262.37582781456956],
+        [60.59602649006623, 262.37582781456956],
+        [61.58940397350993, 262.37582781456956],
+        [62.58278145695365, 261.3824503311258],
+        [63.57615894039735, 261.3824503311258],
+        [64.56953642384106, 260.3890728476821],
+        [66.55629139072848, 259.3956953642384],
+        [67.54966887417218, 259.3956953642384],
+        [68.54304635761589, 259.3956953642384],
+        [69.5364238410596, 258.4023178807947],
+        [69.5364238410596, 258.4023178807947],
+        [70.52980132450331, 258.4023178807947],
+        [71.52317880794702, 258.4023178807947],
+        [71.52317880794702, 257.408940397351],
+        [71.52317880794702, 257.408940397351],
+        [72.51655629139073, 257.408940397351],
+        [72.51655629139073, 257.408940397351],
+        [72.51655629139073, 257.408940397351],
+        [73.50993377483444, 257.408940397351],
+        [73.50993377483444, 257.408940397351],
+        [73.50993377483444, 257.408940397351],
+        [74.50331125827815, 257.408940397351],
+        [76.49006622516558, 257.408940397351],
+        [77.48344370860927, 257.408940397351],
+        [79.47019867549669, 256.4155629139073],
+        [80.4635761589404, 256.4155629139073],
+        [82.45033112582782, 255.42218543046357],
+        [83.44370860927151, 255.42218543046357],
+        [84.43708609271523, 255.42218543046357],
+        [85.43046357615894, 255.42218543046357],
+        [85.43046357615894, 254.42880794701986],
+        [86.42384105960264, 254.42880794701986],
+        [86.42384105960264, 254.42880794701986],
+        [87.41721854304636, 254.42880794701986],
+        [87.41721854304636, 254.42880794701986],
+      ]
+
+      const result = recognizeFromPoints(points)
+      expect(result.shape).toBe(Shapes.line)
+    })
+
+    test("should recognize line 1", () => {
+      const points = [
+        [380, 355],
+        [380, 356],
+        [381, 355],
+        [383, 354],
+        [386, 352],
+        [391, 349],
+        [395, 346],
+        [400, 343],
+        [405, 339],
+        [411, 335],
+        [416, 332],
+        [425, 325],
+        [433, 318],
+        [440, 312],
+        [447, 306],
+        [454, 300],
+        [462, 293],
+        [471, 283],
+        [479, 274],
+        [487, 266],
+        [498, 255],
+        [509, 244],
+        [520, 235],
+        [531, 226],
+        [539, 218],
+        [543, 215],
+        [550, 208],
+        [558, 203],
+        [563, 199],
+        [567, 196],
+        [571, 193],
+        [575, 190],
+        [577, 189],
+        [578, 188],
+        [579, 187],
+        [580, 187],
+        [580, 187],
+        [581, 187],
+        [581, 186],
+      ]
+      const result = recognizeFromPoints(points)
+      expect(result.shape).toBe(Shapes.line)
+    })
+
+    test("should recognize line 2", () => {
+      const points = [
+        [648, 509],
+        [648, 509],
+        [648, 509],
+        [646, 509],
+        [641, 509],
+        [634, 509],
+        [628, 509],
+        [608, 509],
+        [591, 509],
+        [571, 509],
+        [554, 509],
+        [534, 509],
+        [513, 505],
+        [486, 499],
+        [462, 495],
+        [454, 494],
+        [433, 490],
+        [413, 488],
+        [396, 485],
+        [382, 485],
+        [368, 482],
+        [360, 482],
+        [356, 482],
+        [348, 479],
+        [341, 479],
+        [336, 478],
+        [331, 478],
+        [329, 478],
+        [327, 477],
+        [326, 476],
+        [326, 476],
+        [325, 476],
+        [325, 476],
+        [325, 476],
+        [325, 476],
+        [323, 476],
+        [322, 476],
+        [320, 476],
+        [319, 476],
+        [318, 476],
+        [316, 476],
+        [315, 475],
+        [315, 475],
+        [314, 475],
+        [314, 475],
+        [313, 475],
+      ]
+      const result = recognizeFromPoints(points)
+      expect(result.shape).toBe(Shapes.line)
+    })
+
+    test("should recognize line 3", () => {
+      const points = [
+        [204, 590],
+        [205, 590],
+        [208, 584],
+        [219, 574],
+        [254, 534],
+        [276, 500],
+        [305, 456],
+        [334, 410],
+        [346, 388],
+        [376, 336],
+        [404, 284],
+        [430, 238],
+        [454, 197],
+        [458, 190],
+        [474, 160],
+        [483, 142],
+        [485, 138],
+        [492, 126],
+        [495, 121],
+        [498, 117],
+        [499, 115],
+        [500, 114],
+        [500, 114],
+        [500, 113],
+      ]
+      const result = recognizeFromPoints(points)
+      expect(result.shape).toBe(Shapes.line)
+    })
+  })
+
+  describe("rectangles", () => {
+    test("should recognize simple rectangle", () => {
+      const points = [
+        [0, 0],
+        [5, 0],
+        [10, 0],
+        [10, 5],
+        [10, 10],
+        [5, 10],
+        [0, 10],
+      ]
+      const result = recognizeFromPoints(points)
+      expect(result.shape).toBe(Shapes.rectangle)
+    })
+
+    test("should recognize rectangle with varying points", () => {
+      const points = [
+        [0, 0],
+        [5, 1],
+        [10, 0],
+        [10, 6],
+        [10, 10],
+        [6, 10],
+        [0, 10],
+      ]
+      const result = recognizeFromPoints(points)
+      expect(result.shape).toBe(Shapes.rectangle)
+    })
+
+    test("should not recognize rectangle with non-rectangular points", () => {
+      const points = [
+        [5, 1],
+        [23, 0],
+        [10, 54],
+        [0, 10],
+      ]
+      const result = recognizeFromPoints(points)
+      expect(result.shape).not.toBe(Shapes.rectangle)
+    })
+
+    test("should not recognize unclosed rectangle", () => {
+      const points = [
+        [-10, -10],
+        [10, -10],
+        [10, 10],
+      ]
+      const result = recognizeFromPoints(points)
+      expect(result.shape).not.toBe(Shapes.rectangle)
+    })
+
+    test("should recognize almost-closed rectangle", () => {
+      const points = [
+        [0, 0],
+        [5, 0],
+        [10, 0],
+        [10, 5],
+        [10, 10],
+        [5, 10],
+        [0, 8],
+      ]
+      const result = recognizeFromPoints(points)
+      expect(result.shape).toBe(Shapes.rectangle)
+    })
+
+    test("should recognize half-closed rectangle", () => {
+      const points = [
+        [380, 503],
+        [379, 503],
+        [379, 503],
+        [379, 498],
+        [379, 491],
+        [379, 471],
+        [379, 468],
+        [379, 457],
+        [379, 440],
+        [379, 428],
+        [379, 412],
+        [379, 398],
+        [379, 384],
+        [379, 373],
+        [379, 369],
+        [379, 361],
+        [379, 354],
+        [379, 349],
+        [379, 346],
+        [379, 344],
+        [379, 343],
+        [379, 342],
+        [379, 341],
+        [381, 340],
+        [385, 340],
+        [389, 340],
+        [398, 340],
+        [407, 340],
+        [418, 340],
+        [429, 340],
+        [440, 340],
+        [451, 340],
+        [463, 340],
+        [476, 342],
+        [488, 343],
+        [502, 345],
+        [515, 348],
+        [520, 349],
+        [531, 350],
+        [540, 351],
+        [549, 352],
+        [552, 353],
+        [557, 354],
+        [558, 355],
+        [560, 355],
+        [560, 355],
+        [560, 355],
+        [561, 355],
+        [561, 356],
+        [561, 356],
+        [561, 356],
+        [561, 357],
+        [561, 360],
+        [561, 363],
+        [561, 368],
+        [561, 377],
+        [561, 388],
+        [561, 399],
+        [561, 406],
+        [561, 414],
+        [561, 423],
+        [561, 432],
+        [561, 441],
+        [561, 447],
+        [561, 452],
+        [561, 459],
+        [561, 463],
+        [561, 468],
+        [561, 471],
+        [561, 475],
+        [561, 478],
+        [561, 479],
+        [561, 480],
+        [561, 480],
+        [561, 481],
+        [561, 482],
+        [561, 482],
+        [561, 483],
+        [560, 483],
+        [560, 483],
+        [558, 484],
+        [556, 485],
+        [551, 487],
+        [546, 488],
+        [540, 490],
+        [533, 492],
+        [522, 493],
+        [513, 494],
+        [502, 496],
+        [491, 497],
+        [480, 497],
+        [466, 499],
+        [452, 500],
+        [447, 500],
+        [436, 500],
+        [427, 501],
+        [418, 501],
+        [412, 503],
+        [407, 503],
+        [402, 504],
+        [400, 504],
+        [398, 504],
+        [398, 504],
+        [397, 504],
+        [396, 504],
+      ]
+      const result = recognizeFromPoints(points)
+      expect(result.shape).toBe(Shapes.rectangle)
+    })
+
+    test("should recognize half-closed rectangle 2", () => {
+      const points = [
+        [294, 477],
+        [293, 477],
+        [293, 477],
+        [293, 473],
+        [293, 469],
+        [293, 465],
+        [293, 459],
+        [293, 455],
+        [293, 451],
+        [293, 444],
+        [293, 437],
+        [293, 431],
+        [293, 423],
+        [293, 414],
+        [293, 403],
+        [293, 394],
+        [293, 391],
+        [293, 384],
+        [293, 379],
+        [293, 376],
+        [293, 373],
+        [293, 372],
+        [293, 371],
+        [293, 370],
+        [293, 370],
+        [293, 369],
+        [294, 369],
+        [295, 368],
+        [298, 368],
+        [303, 367],
+        [308, 366],
+        [317, 364],
+        [328, 362],
+        [341, 360],
+        [358, 357],
+        [375, 355],
+        [392, 352],
+        [412, 350],
+        [436, 348],
+        [457, 346],
+        [480, 344],
+        [501, 342],
+        [518, 342],
+        [535, 340],
+        [539, 339],
+        [551, 339],
+        [559, 338],
+        [568, 337],
+        [573, 337],
+        [576, 337],
+        [578, 337],
+        [579, 337],
+        [579, 337],
+        [580, 337],
+        [580, 337],
+        [581, 337],
+        [581, 338],
+        [581, 341],
+        [581, 346],
+        [581, 349],
+        [581, 358],
+        [579, 366],
+        [578, 373],
+        [576, 382],
+        [575, 386],
+        [571, 403],
+        [571, 407],
+        [568, 420],
+        [567, 424],
+        [565, 430],
+        [562, 437],
+        [561, 442],
+        [559, 447],
+        [558, 450],
+        [557, 452],
+        [556, 454],
+        [556, 456],
+        [555, 458],
+        [555, 459],
+        [554, 460],
+        [554, 461],
+        [553, 462],
+        [553, 462],
+        [553, 463],
+        [552, 463],
+        [552, 463],
+        [552, 464],
+        [552, 464],
+        [551, 464],
+        [550, 465],
+        [549, 465],
+        [544, 466],
+        [541, 467],
+        [534, 468],
+        [527, 469],
+        [521, 470],
+        [512, 472],
+        [509, 472],
+        [500, 474],
+        [493, 475],
+        [485, 476],
+        [478, 477],
+        [471, 478],
+        [465, 479],
+        [460, 480],
+        [454, 481],
+        [450, 482],
+        [446, 483],
+        [442, 483],
+        [439, 483],
+        [437, 484],
+        [436, 484],
+        [435, 484],
+        [434, 484],
+        [433, 484],
+        [433, 484],
+        [433, 484],
+      ]
+      const result = recognizeFromPoints(points)
+      expect(result.shape).toBe(Shapes.rectangle)
+    })
+  })
+})
diff --git a/package-lock.json b/package-lock.json
index 2417731f8da044be4423a51efbbefab9d595adff..b01e3a09bd57ddc784fde9f0da62101d5663545c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1088,6 +1088,11 @@
       "integrity": "sha1-bI4obRHtdoMn+OYuzuhzU8o+eLg=",
       "dev": true
     },
+    "array-flat-polyfill": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/array-flat-polyfill/-/array-flat-polyfill-1.0.1.tgz",
+      "integrity": "sha512-hfJmKupmQN0lwi0xG6FQ5U8Rd97RnIERplymOv/qpq8AoNKPPAnxJadjFA23FNWm88wykh9HmpLJUUwUtNU/iw=="
+    },
     "array-flatten": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -2358,9 +2363,9 @@
       "integrity": "sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig=="
     },
     "bluebird": {
-      "version": "3.7.1",
-      "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.1.tgz",
-      "integrity": "sha512-DdmyoGCleJnkbp3nkbxTLJ18rjDsE4yCggEwKNXkeV123sPNfOCYeDoeuOY+F2FrSjO1YXcTU+dsy96KMy+gcg==",
+      "version": "3.7.2",
+      "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
+      "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
       "dev": true
     },
     "bn.js": {
@@ -3624,9 +3629,9 @@
       "dev": true
     },
     "electron-to-chromium": {
-      "version": "1.3.314",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.314.tgz",
-      "integrity": "sha512-IKDR/xCxKFhPts7h+VaSXS02Z1mznP3fli1BbXWXeN89i2gCzKraU8qLpEid8YzKcmZdZD3Mly3cn5/lY9xsBQ==",
+      "version": "1.3.320",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.320.tgz",
+      "integrity": "sha512-GVyRfGaKs/Vsf915WDaK5NG9vfud8nJFyapyQcrVS+sp8IeMpfml/YMvhthXsSOLlc0rzwdtnkNJE/+q4EPbTA==",
       "dev": true
     },
     "elegant-spinner": {
@@ -3907,9 +3912,9 @@
       }
     },
     "eslint": {
-      "version": "6.7.1",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-6.7.1.tgz",
-      "integrity": "sha512-UWzBS79pNcsDSxgxbdjkmzn/B6BhsXMfUaOHnNwyE8nD+Q6pyT96ow2MccVayUTV4yMid4qLhMiQaywctRkBLA==",
+      "version": "6.7.2",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-6.7.2.tgz",
+      "integrity": "sha512-qMlSWJaCSxDFr8fBPvJM9kJwbazrhNcBU3+DszDW1OlEwKBBRWsJc7NJFelvwQpanHCR14cOLD41x8Eqvo3Nng==",
       "dev": true,
       "requires": {
         "@babel/code-frame": "^7.0.0",
@@ -8313,9 +8318,9 @@
       "dev": true
     },
     "psl": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/psl/-/psl-1.4.0.tgz",
-      "integrity": "sha512-HZzqCGPecFLyoRj5HLfuDSKYTJkAfB5thKBIkRHtGjWwY7p1dAyveIbXIq4tO0KYfDF2tHqPUgY9SDnGm00uFw==",
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/psl/-/psl-1.5.0.tgz",
+      "integrity": "sha512-4vqUjKi2huMu1OJiLhi3jN6jeeKvMZdI1tYgi/njW5zV52jNLgSAZSdN16m9bJFe61/cT8ulmw4qFitV9QRsEA==",
       "dev": true
     },
     "public-encrypt": {
@@ -9714,9 +9719,9 @@
       "dev": true
     },
     "terser": {
-      "version": "4.4.0",
-      "resolved": "https://registry.npmjs.org/terser/-/terser-4.4.0.tgz",
-      "integrity": "sha512-oDG16n2WKm27JO8h4y/w3iqBGAOSCtq7k8dRmrn4Wf9NouL0b2WpMHGChFGZq4nFAQy1FsNJrVQHfurXOSTmOA==",
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/terser/-/terser-4.4.1.tgz",
+      "integrity": "sha512-e05giplw+8sIYh50qXYHZmr0b76O5dOSm9JwSDebGFLri4ItYzxsnumiAK+yuI56R+H7uIjT9KbVEKNkrprzHw==",
       "dev": true,
       "requires": {
         "commander": "^2.20.0",
@@ -10060,9 +10065,9 @@
           }
         },
         "fast-glob": {
-          "version": "3.1.0",
-          "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.1.0.tgz",
-          "integrity": "sha512-TrUz3THiq2Vy3bjfQUB2wNyPdGBeGmdjbzzBLhfHN4YFurYptCKwGq/TfiRavbGywFRzY6U2CdmQ1zmsY5yYaw==",
+          "version": "3.1.1",
+          "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.1.1.tgz",
+          "integrity": "sha512-nTCREpBY8w8r+boyFYAx21iL6faSsQynliPHM4Uf56SbkyohCNxpVPEH9xrF5TXKy+IsjkPUHDKiUkzBVRXn9g==",
           "dev": true,
           "requires": {
             "@nodelib/fs.stat": "^2.0.2",
@@ -10710,9 +10715,9 @@
       "dev": true
     },
     "uglify-js": {
-      "version": "3.7.0",
-      "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.7.0.tgz",
-      "integrity": "sha512-PC/ee458NEMITe1OufAjal65i6lB58R1HWMRcxwvdz1UopW0DYqlRL3xdu3IcTvTXsB02CRHykidkTRL+A3hQA==",
+      "version": "3.7.1",
+      "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.7.1.tgz",
+      "integrity": "sha512-pnOF7jY82wdIhATVn87uUY/FHU+MDUdPLkmGFvGoclQmeu229eTkbG5gjGGBi3R7UuYYSEeYXY/TTY5j2aym2g==",
       "dev": true,
       "optional": true,
       "requires": {
diff --git a/package.json b/package.json
index 0d23c135e1320c1d063a58623d00563ae51c7884..049c501821dd458885def91a91755e717150d9fe 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
   },
   "dependencies": {
     "@ungap/event-target": "^0.1.0",
+    "array-flat-polyfill": "^1.0.1",
     "d3-shape": "^1.3.5",
     "dotenv": "^8.2.0",
     "express": "^4.17.1",
diff --git a/public/index.html b/public/index.html
index ca2ae6044aece77c536d264245695f3d3464af17..400687d87de0a059a312490f8a285c5f42e8a26f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -60,7 +60,7 @@
         <button id="room-connect">Connect <i class="fa fa-link"></i></button>
       </div>
       <div id="tools-panel">
-        <button id="pen-tool" class="selected">
+        <button id="pen-tool" class="selectable-tool selected">
           <i class="fa fa-paint-brush"></i>
         </button>
         <div id="pen-properties" class="properties">
@@ -86,8 +86,7 @@
                 <input
                   type="range"
                   min="1"
-                  max="100"
-                  value="10"
+                  max="10"
                   class="slider"
                   id="range"
                 />
@@ -192,18 +191,31 @@
                 <b>Other colours</b>
               </div>
               <label id="colours">
-                <input id="other-colours" type="color" value="#0000ff" />
+                <input id="other-colours" type="color" />
               </label>
             </div>
           </div>
         </div>
-        <button id="eraser-tool"><i class="fa fa-eraser"></i></button>
+        <button id="eraser-tool" class="selectable-tool">
+          <i class="fa fa-eraser"></i>
+        </button>
+        <button id="recognition-mode" class="selectable-tool">
+          <i class="fa fa-square"></i>
+        </button>
+        <div class="spacer"></div>
+
         <div id="status-info">
-          <div id="user-avatar"></div>
+          <button id="fast-undo-tool" class="disabled selectable-tool">
+            <i class="fa fa-fast-backward"></i>
+          </button>
+          <button id="undo-tool" class="disabled selectable-tool">
+            <i class="fa fa-backward"></i>
+          </button>
           <div id="connected-room-info">
-            Room <i class="fa fa-globe">: </i> &nbsp;
+            Room:&nbsp;
             <span id="connected-room-id"></span>
           </div>
+          <div id="user-avatar"></div>
         </div>
       </div>
     </div>
diff --git a/public/styles.css b/public/styles.css
index d1f4a1381ed965be737d4eb3082e5c50a941ad2f..b4ba7fb7579943df3066b77fc788672ac35ef29f 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -27,20 +27,18 @@ body {
   align-items: center;
 }
 
+.spacer {
+  flex: 1;
+}
+
 #connected-room-info {
   color: white;
   display: flex;
   align-items: center;
   background-color: #4f4f4fb7;
   border-radius: 4px;
-  margin-left: 8px;
   justify-content: center;
-  height: 44px;
-}
-
-#connected-room-info:hover {
-  background-color: #4f4f4f !important;
-  transition-duration: 0.4s;
+  padding: 0.75em;
 }
 
 button.selected {
@@ -269,7 +267,7 @@ button.selected {
   border-radius: 4px;
 }
 
-#pen-tool {
+.selectable-tool {
   background-color: #2f2f2f;
   color: white;
   padding: 10px;
@@ -277,27 +275,47 @@ button.selected {
   border: none;
   cursor: pointer;
   border-radius: 50%;
-  margin-right: 8px;
 }
 
-#pen-tool:hover {
+.selectable-tool:hover {
   background-color: #4f4f4f !important;
   transition-duration: 0.4s;
 }
 
+#pen-tool > i {
+  padding: 0 1px;
+}
+
 #eraser-tool {
-  background-color: #2f2f2f;
-  color: white;
-  padding: 10px;
-  font-size: 16px;
-  border: none;
-  cursor: pointer;
-  border-radius: 50%;
+  margin-right: 8px;
 }
 
-#eraser-tool:hover {
-  background-color: #4f4f4f !important;
-  transition-duration: 0.4s;
+#eraser-tool > i {
+  padding: 0 0.5px;
+}
+
+#undo-tool {
+  margin-right: 8px;
+}
+
+#undo-tool.disabled {
+  display: none;
+}
+
+#undo-tool > i {
+  padding: 0 3px 0 0;
+}
+
+#fast-undo-tool {
+  margin-right: 8px;
+}
+
+#fast-undo-tool.disabled {
+  display: none;
+}
+
+#fast-undo-tool > i {
+  padding: 0 1px;
 }
 
 .properties {
@@ -547,16 +565,12 @@ button.selected {
   align-items: center;
   background-color: #4f4f4fb7;
   border-radius: 4px;
-}
-
-#user-avatar:hover {
-  background-color: #4f4f4f !important;
-  transition-duration: 0.4s;
+  margin-left: 0.75em;
+  padding: 0 0.75em 0 0;
 }
 
 #status-info {
   display: flex;
   align-items: center;
   justify-content: right;
-  width: 100%;
 }
diff --git a/src/app.js b/src/app.js
index 57f9c36c3389c68657532106b4f80928a9a8be7b..4a2f2f811f9a03b69df58b4248f2f7c56978d5a0 100644
--- a/src/app.js
+++ b/src/app.js
@@ -9,6 +9,7 @@ import { computeErasureIntervals } from "./erasure.js"
 import { connect } from "./room.js"
 import WebRTCConnection from "./connection/WebRTC.js"
 import * as toolSelection from "./tool-selection.js"
+import recognizeFromPoints, { Shapes } from "./shapes.js"
 import * as humanhash from "humanhash"
 import jdenticon from "jdenticon"
 
@@ -17,6 +18,27 @@ const DEFAULT_ROOM = "imperial"
 const MIN_PRESSURE_FACTOR = 0.1
 const MAX_PRESSURE_FACTOR = 1.5
 
+const UNDO_RATE = 24
+let undoInterval = null
+
+let room = null
+
+const humanHasher = new humanhash()
+
+const PREDICTED_POINT_COLOR = "#00000044"
+const LAST_RECOGNIZED_PATH_ID = "LSP"
+
+const pathIDsByPointerID = new Map()
+
+// Safari reports mouse touches as events with 0 pressure, but the standard requires
+// it to be 0.5
+function getNormalizedPressure(e) {
+  if (e.pressure === 0 && e.buttons) {
+    return 0.5
+  }
+  return e.pressure
+}
+
 // This is a quadratic such that:
 // - getPressureFactor(0.0) = MIN_PRESSURE_FACTOR
 // - getPressureFactor(0.5) = 1.0
@@ -33,9 +55,22 @@ const getPressureFactor = (pressure) => {
   return a * pressure ** 2 + b * pressure + c
 }
 
-let room = null
+function selectedRadiusPoint(x, y, color, pressure = 0) {
+  return [
+    x,
+    y,
+    toolSelection.getStrokeRadius() * getPressureFactor(pressure),
+    color,
+  ]
+}
 
-const humanHasher = new humanhash()
+function faintPredictionPoint(x, y, pressure = 0.5) {
+  return selectedRadiusPoint(x, y, PREDICTED_POINT_COLOR, pressure)
+}
+
+function selectedColorAndRadiusPoint(x, y, pressure = 0.5) {
+  return selectedRadiusPoint(x, y, toolSelection.getStrokeColour(), pressure)
+}
 
 function eraseEverythingAtPosition(x, y, radius, room) {
   const mousePos = [x, y]
@@ -139,6 +174,67 @@ const onRoomConnect = (room_) => {
       canvas.renderPath(id, room.getPathPoints(id), intervals)
     },
   )
+
+  room.addEventListener("undoEnabled", () => {
+    HTML.fastUndoButton.classList.remove("disabled")
+    HTML.undoButton.classList.remove("disabled")
+  })
+}
+
+function getRecognizedShapePoints(points) {
+  const recognizedShape = recognizeFromPoints(points)
+  if (!recognizedShape.shape) return undefined
+
+  switch (recognizedShape.shape) {
+    case Shapes.line: {
+      const p1 = recognizedShape.firstPoint
+      const p2 = recognizedShape.lastPoint
+      return [p1, p2]
+    }
+    case Shapes.rectangle: {
+      return recognizedShape.boundingPoints
+    }
+  }
+
+  return undefined
+}
+
+function drawIfRecognized(points, callback, notRecCallback) {
+  const recognizedPoints = getRecognizedShapePoints(points)
+  if (recognizedPoints) {
+    callback && callback(recognizedPoints)
+  } else {
+    notRecCallback && notRecCallback()
+  }
+}
+
+function clearRecognizedUpcoming() {
+  canvas.renderPath(LAST_RECOGNIZED_PATH_ID, [], [])
+}
+
+function drawRecognizedUpcoming(points) {
+  drawIfRecognized(
+    points,
+    (recognizedPoints) =>
+      canvas.renderPath(
+        LAST_RECOGNIZED_PATH_ID,
+        recognizedPoints.map((point) =>
+          faintPredictionPoint(point[0], point[1]),
+        ),
+        [],
+      ),
+    clearRecognizedUpcoming,
+  )
+}
+
+function drawRecognized(pathID, points) {
+  drawIfRecognized(points, (newPoints) =>
+    room.replacePath(
+      pathID,
+      newPoints.map((point) => selectedColorAndRadiusPoint(point[0], point[1])),
+    ),
+  )
+  clearRecognizedUpcoming()
 }
 
 const tryRoomConnect = async (roomID) => {
@@ -147,8 +243,6 @@ const tryRoomConnect = async (roomID) => {
     .catch((err) => alert(`Error connecting to a room:\n${err}`))
 }
 
-const pathIDsByPointerID = new Map()
-
 HTML.peerButton.addEventListener("click", () => {
   const peerID = HTML.peerIDElem.value
   if (room == null || peerID == "") {
@@ -171,12 +265,46 @@ const onRoomJoinEnter = () => {
 
   canvas.clear()
   HTML.connectedPeers.innerHTML = "No peers are connected"
+  HTML.fastUndoButton.classList.add("disabled")
+  HTML.undoButton.classList.add("disabled")
 
   tryRoomConnect(selectedRoomID)
 }
 
 HTML.roomConnectButton.addEventListener("click", onRoomJoinEnter)
 
+HTML.fastUndoButton.addEventListener("click", () => {
+  if (room == null) return
+
+  room.fastUndo()
+
+  if (!room.canUndo()) {
+    HTML.fastUndoButton.classList.add("disabled")
+    HTML.undoButton.classList.add("disabled")
+  }
+})
+
+HTML.undoButton.addEventListener("mouseup", () => {
+  clearInterval(undoInterval)
+})
+
+HTML.undoButton.addEventListener("mouseleave", () => {
+  clearInterval(undoInterval)
+})
+
+HTML.undoButton.addEventListener("mousedown", () => {
+  undoInterval = setInterval(function() {
+    if (room == null) return
+
+    room.undo()
+
+    if (!room.canUndo()) {
+      HTML.fastUndoButton.classList.add("disabled")
+      HTML.undoButton.classList.add("disabled")
+    }
+  }, 1000 / UNDO_RATE)
+})
+
 HTML.roomIDElem.addEventListener("keydown", (event) => {
   if (event.key == "Enter") {
     event.target.blur()
@@ -244,6 +372,10 @@ const updateOverallStatusIcon = () => {
 }
 
 canvas.input.addEventListener("strokestart", ({ detail: e }) => {
+  e.preventDefault()
+  const pressure = getNormalizedPressure(e)
+
+  clearRecognizedUpcoming()
   if (room == null) {
     return
   }
@@ -252,45 +384,39 @@ canvas.input.addEventListener("strokestart", ({ detail: e }) => {
   if (currentTool == toolSelection.Tools.PEN) {
     pathIDsByPointerID.set(
       e.pointerId,
-      room.addPath([
-        ...mousePos,
-        toolSelection.getStrokeRadius() * getPressureFactor(e.pressure),
-        toolSelection.getStrokeColour(),
-      ]),
+      room.addPath(selectedColorAndRadiusPoint(...mousePos, pressure)),
     )
   } else if (currentTool == toolSelection.Tools.ERASER) {
-    eraseEverythingAtPosition(
-      mousePos[0],
-      mousePos[1],
-      toolSelection.getEraseRadius(),
-      room,
-    )
+    eraseEverythingAtPosition(...mousePos, toolSelection.getEraseRadius(), room)
   }
 })
 
 canvas.input.addEventListener("strokeend", ({ detail: e }) => {
-  pathIDsByPointerID.delete(e.pointerId)
+  const { pointerId } = e
+  const pressure = getNormalizedPressure(e)
+  const pathID = pathIDsByPointerID.get(pointerId)
+  if (toolSelection.isRecognitionModeSet()) {
+    drawRecognized(pathID, room.getPathPoints(pathID), pressure)
+  }
+  pathIDsByPointerID.delete(pointerId)
+  clearRecognizedUpcoming()
 })
 
 canvas.input.addEventListener("strokemove", ({ detail: e }) => {
   if (room == null) {
     return
   }
+  const pressure = getNormalizedPressure(e)
   const currentTool = toolSelection.getTool()
   const mousePos = [e.offsetX, e.offsetY]
   if (currentTool == toolSelection.Tools.PEN) {
-    room.extendPath(pathIDsByPointerID.get(e.pointerId), [
-      ...mousePos,
-      toolSelection.getStrokeRadius() * getPressureFactor(e.pressure),
-      toolSelection.getStrokeColour(),
-    ])
+    const pathID = pathIDsByPointerID.get(e.pointerId)
+    room.extendPath(pathID, selectedColorAndRadiusPoint(...mousePos, pressure))
+    if (toolSelection.isRecognitionModeSet()) {
+      drawRecognizedUpcoming(room.getPathPoints(pathID), pressure)
+    }
   } else if (currentTool == toolSelection.Tools.ERASER) {
-    eraseEverythingAtPosition(
-      mousePos[0],
-      mousePos[1],
-      toolSelection.getEraseRadius(),
-      room,
-    )
+    eraseEverythingAtPosition(...mousePos, toolSelection.getEraseRadius(), room)
   }
 })
 
diff --git a/src/canvas.js b/src/canvas.js
index 26c9c758c5892be1a128bc2832f4bb8f081610c4..7e4774c03f251dde9d5109b9c305d4f40604f10b 100644
--- a/src/canvas.js
+++ b/src/canvas.js
@@ -1,3 +1,5 @@
+import "array-flat-polyfill"
+
 // Local canvas rendering.
 // Emit input events and receive draw calls seperately - these must be piped
 // together externally if desired.
@@ -237,12 +239,13 @@ export const clear = () => {
 }
 
 // Necessary since buttons property is non standard on iOS versions < 13.2
-const isValidPointerEvent = (e) => {
+const isValidPointerEvent = (e, name) => {
+  if (name === "strokeend") return true
   return e.buttons & 1 || e.pointerType === "touch"
 }
 
 const dispatchPointerEvent = (name) => (e) => {
-  if (isValidPointerEvent(e)) {
+  if (isValidPointerEvent(e, name)) {
     input.dispatchEvent(new CustomEvent(name, { detail: e }))
   }
 }
@@ -251,6 +254,8 @@ const dispatchPointerEvent = (name) => (e) => {
 canvas.addEventListener("pointerdown", dispatchPointerEvent("strokestart"))
 canvas.addEventListener("pointerenter", dispatchPointerEvent("strokestart"))
 canvas.addEventListener("pointerup", dispatchPointerEvent("strokeend"))
+canvas.addEventListener("onmouseup", dispatchPointerEvent("strokeend"))
+canvas.addEventListener("mouseup", dispatchPointerEvent("strokeend"))
 canvas.addEventListener("pointerleave", dispatchPointerEvent("strokeend"))
 canvas.addEventListener("pointermove", dispatchPointerEvent("strokemove"))
 canvas.addEventListener("touchmove", (e) => e.preventDefault())
diff --git a/src/elements.js b/src/elements.js
index b1902a21ff6bd350fede2d44e7300c243ab7efa3..a80d58d41cd812e8115f68f9ea6c91bc3073865a 100644
--- a/src/elements.js
+++ b/src/elements.js
@@ -12,6 +12,10 @@ export const overallStatusIconImage = document.getElementById(
 export const canvas = document.getElementById("canvas")
 export const penButton = document.getElementById("pen-tool")
 export const eraserButton = document.getElementById("eraser-tool")
+export const recognitionModeButton = document.getElementById("recognition-mode")
+
+export const fastUndoButton = document.getElementById("fast-undo-tool")
+export const undoButton = document.getElementById("undo-tool")
 
 export const roomIDElem = document.getElementById("room-id")
 export const roomConnectButton = document.getElementById("room-connect")
@@ -25,8 +29,8 @@ export const closeButton = document.querySelectorAll(".close")
 export const palette = document.getElementById("palette")
 export const rectangle = document.getElementById("rectangle")
 export const wheel = document.getElementById("wheel")
-export const picker = document.getElementById("other-colours")
-export const slider = document.getElementById("range")
+export const strokeColorPicker = document.getElementById("other-colours")
+export const strokeRadiusSlider = document.getElementById("range")
 export const output = document.getElementById("value")
 export const labelColours = document.getElementById("colours")
 export const userInfo = document.getElementById("user-avatar")
diff --git a/src/room.js b/src/room.js
index 69d11a2697c9b8916efbd2abf9d246cf6b615a56..1f898d753b62d2e497c1cf28f6fb72aa44889d52 100644
--- a/src/room.js
+++ b/src/room.js
@@ -27,6 +27,7 @@ class Room extends EventTarget {
     this.name = name
     this._y = null
     this.ownID = null
+    this.undoStack = []
   }
 
   disconnect() {
@@ -39,11 +40,25 @@ class Room extends EventTarget {
     this.shared.strokePoints.set(id, Y.Array).push([[x, y, w, colour]])
     this.shared.eraseIntervals.set(id, Y.Union)
 
+    this.undoStack.push([id, 0, 0])
+
+    this.dispatchEvent(new CustomEvent("undoEnabled"))
+
     return id
   }
 
   extendPath(id, [x, y, w, colour]) {
-    this.shared.strokePoints.get(id).push([[x, y, w, colour]])
+    const path = this.shared.strokePoints.get(id)
+
+    path.push([[x, y, w, colour]])
+
+    if (path.length == 2) {
+      this.undoStack[this.undoStack.length - 1] = [id, 0, 1]
+    } else {
+      this.undoStack.push([id, path.length - 2, path.length - 1])
+    }
+
+    this.dispatchEvent(new CustomEvent("undoEnabled"))
   }
 
   extendErasureIntervals(pathID, pointID, newIntervals) {
@@ -52,6 +67,46 @@ class Room extends EventTarget {
       .merge(flattenErasureIntervals({ [pointID]: newIntervals }))
   }
 
+  replacePath(pathID, newPoints) {
+    this.fastUndo(true)
+    newPoints.forEach((point) => this.extendPath(pathID, point))
+    this.undoStack.splice(this.undoStack.length - newPoints.length, 1)
+  }
+
+  undo() {
+    const operation = this.undoStack.pop()
+
+    if (!operation) return
+
+    const [id, ...interval] = operation
+
+    this.shared.eraseIntervals.get(id).merge([interval])
+  }
+
+  fastUndo(forReplacing = false) {
+    let from = this.undoStack.length - 1
+
+    if (from < 0) return
+
+    // eslint-disable-next-line no-unused-vars
+    const [id, _, end] = this.undoStack[from]
+    const endErasing = forReplacing ? end + 1 : end
+
+    for (; from >= 0; from--) {
+      if (this.undoStack[from][0] != id) {
+        from++
+        break
+      }
+    }
+
+    this.undoStack = this.undoStack.slice(0, Math.max(0, from))
+    this.shared.eraseIntervals.get(id).merge([[0, endErasing]])
+  }
+
+  canUndo() {
+    return this.undoStack.length > 0
+  }
+
   getPaths() {
     const paths = new Map()
 
diff --git a/src/shapes.js b/src/shapes.js
new file mode 100644
index 0000000000000000000000000000000000000000..213b02be1da496e96afbaed22131e2f802236130
--- /dev/null
+++ b/src/shapes.js
@@ -0,0 +1,191 @@
+const LINE_ANGLE_THRESHOLD = Math.PI / 6
+const VECTOR_LEN_THRESHOLD_FRACTION = 0.3
+
+const RECT_MATRIX_SIZE = 3
+const RECT_MATRIX_CENTER_RATIO = 0.65
+
+const RECT_THRESHOLD_CENTER = 0
+const RECT_THRESHOLD_SIDE_VARIANCE = 0.25
+
+const MIN_RECT_POINTS = 4
+const MIN_LINE_POINTS = 2
+
+function getDistance(p1, p2) {
+  if (!(p1 && p2)) return 0
+  const [[x0, y0], [x1, y1]] = [p1, p2]
+  return Math.hypot(x1 - x0, y1 - y0)
+}
+
+function vectorLength([x, y]) {
+  return Math.hypot(x, y)
+}
+
+function diffVector([x0, y0], [x1, y1]) {
+  return [x0 - x1, y0 - y1]
+}
+
+function angleBetweenVectors(p1, p2) {
+  const [[x0, y0], [x1, y1]] = [p1, p2]
+  return Math.acos((x0 * x1 + y0 * y1) / (vectorLength(p1) * vectorLength(p2)))
+}
+
+function boundingCoords(points) {
+  const xs = points.map((p) => p[0])
+  const ys = points.map((p) => p[1])
+  return {
+    maxX: Math.max(...xs),
+    minX: Math.min(...xs),
+    maxY: Math.max(...ys),
+    minY: Math.min(...ys),
+  }
+}
+
+function matrixBoundsArray(min, max) {
+  const d = max - min
+  const centerSegmentSize = d * RECT_MATRIX_CENTER_RATIO
+  const smallStep = (d - centerSegmentSize) / 2
+  const p = [min + smallStep, min + smallStep + centerSegmentSize, max]
+  return p
+}
+
+function getCluster([x, y], xBounds, yBounds) {
+  return {
+    x: xBounds.findIndex((bound) => x <= bound),
+    y: yBounds.findIndex((bound) => y <= bound),
+  }
+}
+
+function computeClusters(points, xBounds, yBounds) {
+  const clusters = Array(RECT_MATRIX_SIZE)
+    .fill(0)
+    .map(() =>
+      Array(RECT_MATRIX_SIZE)
+        .fill()
+        .map(() => ({ arr: [], sum: 0 })),
+    )
+  const intervals = points.map((point, i) => ({
+    point,
+    dist: getDistance(point, points[i + 1]),
+  }))
+
+  let totalSum = 0
+  intervals.forEach((interval) => {
+    const { x, y } = getCluster(interval.point, xBounds, yBounds)
+    clusters[x][y].arr.push(interval)
+    clusters[x][y].sum += interval.dist
+    totalSum += interval.dist
+  })
+
+  return { arr: clusters, totalSum }
+}
+
+function clusterCoefficients(clusters) {
+  return clusters.arr.map((rowCluster) =>
+    rowCluster.map((cluster) => cluster.sum / clusters.totalSum),
+  )
+}
+
+export function computeMatrixCoefficients(points, boundingRect) {
+  const { maxX, minX, maxY, minY } = boundingRect
+  const xBounds = matrixBoundsArray(minX, maxX)
+  const yBounds = matrixBoundsArray(minY, maxY)
+  const clusters = computeClusters(points, xBounds, yBounds)
+  const coefficients = clusterCoefficients(clusters, points)
+  return coefficients
+}
+
+function couldBeRect(points) {
+  if (points.length < MIN_RECT_POINTS) return false
+
+  const boundingRect = boundingCoords(points)
+  const matrixCoefficients = computeMatrixCoefficients(points, boundingRect)
+
+  let [maxC, minC] = [0, 1]
+  for (let i = 0; i < RECT_MATRIX_SIZE; i++) {
+    for (let j = 0; j < RECT_MATRIX_SIZE; j++) {
+      if (!(i === j && j === 1)) {
+        maxC = Math.max(maxC, matrixCoefficients[i][j])
+        minC = Math.min(minC, matrixCoefficients[i][j])
+      }
+    }
+  }
+
+  if (
+    matrixCoefficients[1][1] <= RECT_THRESHOLD_CENTER &&
+    maxC - minC < RECT_THRESHOLD_SIDE_VARIANCE
+  ) {
+    return { coefficients: matrixCoefficients, boundingRect }
+  }
+  return undefined
+}
+
+function couldBeLine(points) {
+  if (points.length < MIN_LINE_POINTS) return false
+
+  const vectorThreshold = Math.floor(
+    points.length * VECTOR_LEN_THRESHOLD_FRACTION,
+  )
+  const pivot = points[0]
+  let cumulativeThreshold = 0
+
+  for (let i = 2; i < points.length; i++) {
+    const prev = points[i - 1]
+    const curr = points[i]
+    const d1 = diffVector(pivot, prev)
+    const d2 = diffVector(prev, curr)
+    const angle = angleBetweenVectors(d1, d2)
+
+    if (Math.abs(angle) > LINE_ANGLE_THRESHOLD) {
+      const d2Len = vectorLength(d2)
+      if (cumulativeThreshold < vectorThreshold && d2Len < vectorThreshold) {
+        cumulativeThreshold += d2Len
+        continue
+      }
+      return false
+    }
+  }
+  return true
+}
+
+function recognizedRect(_, rectDetectionData) {
+  const { minX, minY, maxX, maxY } = rectDetectionData.boundingRect
+  return {
+    boundingPoints: [
+      [minX, minY],
+      [minX, maxY],
+      [maxX, maxY],
+      [maxX, minY],
+      [minX, minY],
+    ],
+    shape: Shapes.rectangle,
+  }
+}
+
+function recognizedLine(points) {
+  const [p1, p2] = [points[0], points[points.length - 1]]
+  return {
+    shape: Shapes.line,
+
+    // Take only [x, y] from the whole point tuple
+    lastPoint: p2.slice(0, 2),
+    firstPoint: p1.slice(0, 2),
+  }
+}
+
+function recognizeFromPoints(points) {
+  const rectDetectData = couldBeRect(points)
+  if (rectDetectData) {
+    return recognizedRect(points, rectDetectData)
+  } else if (couldBeLine(points)) {
+    return recognizedLine(points)
+  }
+
+  return {}
+}
+
+export const Shapes = {
+  rectangle: "rect",
+  line: "line",
+}
+
+export default recognizeFromPoints
diff --git a/src/tool-selection.js b/src/tool-selection.js
index 82bef482629aede3da532676bb0d5da91d9a607b..e8b0e8c1f27b23ac983191b3670b3009a24bb67c 100644
--- a/src/tool-selection.js
+++ b/src/tool-selection.js
@@ -5,16 +5,19 @@ export const Tools = Object.freeze({
   ERASER: Symbol("eraser"),
 })
 
-let tool = Tools.PEN
-let strokeColour = "#0000ff"
-let strokeRadius = 5
+let selectedTool = Tools.PEN
+let strokeColour = "#000000"
+let strokeRadius = 2
+let recognitionEnabled = false
 // TODO: The erase radius should also be selectable.
 const ERASE_RADIUS = 20
 
-export const getTool = () => tool
+export const getTool = () => selectedTool
 export const getStrokeColour = () => strokeColour
 export const getStrokeRadius = () => strokeRadius
+
 export const getEraseRadius = () => ERASE_RADIUS
+export const isRecognitionModeSet = () => recognitionEnabled
 
 const showElement = (element) => {
   element.style.display = "block"
@@ -24,40 +27,52 @@ const hideElement = (element) => {
   element.style.display = "none"
 }
 
+HTML.strokeColorPicker.setAttribute("value", strokeColour)
+HTML.strokeRadiusSlider.setAttribute("value", strokeRadius)
+
+HTML.recognitionModeButton.addEventListener("click", () => {
+  recognitionEnabled = !recognitionEnabled
+  if (recognitionEnabled) {
+    HTML.recognitionModeButton.classList.add("selected")
+  } else {
+    HTML.recognitionModeButton.classList.remove("selected")
+  }
+})
+
 HTML.penButton.addEventListener("click", () => {
-  if (tool == Tools.PEN) {
+  if (selectedTool == Tools.PEN) {
     showElement(HTML.penProperties)
   } else {
-    tool = Tools.PEN
+    selectedTool = Tools.PEN
     HTML.penButton.classList.add("selected")
     HTML.eraserButton.classList.remove("selected")
   }
 })
 
 HTML.eraserButton.addEventListener("click", () => {
-  tool = Tools.ERASER
+  selectedTool = Tools.ERASER
   HTML.penButton.classList.remove("selected")
   HTML.eraserButton.classList.add("selected")
 })
 
-HTML.picker.addEventListener("change", () => {
+HTML.strokeColorPicker.addEventListener("change", () => {
   const paletteColour = event.target.value
   HTML.rectangle.style.backgroundColor = paletteColour
   HTML.labelColours.style.backgroundColor = paletteColour
   strokeColour = paletteColour
 })
 
-HTML.slider.oninput = function() {
+HTML.strokeRadiusSlider.oninput = function() {
   HTML.output.innerHTML = this.value
   strokeRadius = this.value / 2
 }
 
-HTML.output.innerHTML = HTML.slider.value
+HTML.output.innerHTML = HTML.strokeRadiusSlider.value
 
 // If the page has been refreshed
 if (performance.navigation.type == 1) {
   const sliderValue = parseInt(HTML.output.innerHTML)
-  HTML.slider.setAttribute("value", sliderValue)
+  HTML.strokeRadiusSlider.setAttribute("value", sliderValue)
   strokeRadius = sliderValue / 2
 }
 
@@ -97,7 +112,7 @@ for (let i = 1; i < svg.length; i++) {
   svg[i].addEventListener("click", (event) => {
     const paletteColour = event.target.getAttribute("fill")
     HTML.rectangle.style.backgroundColor = paletteColour
-    HTML.picker.value = paletteColour
+    HTML.strokeColorPicker.value = paletteColour
     HTML.labelColours.style.backgroundColor = paletteColour
     strokeColour = paletteColour
     hideElement(HTML.palette)