Просмотр исходного кода

Adds transforming, ellipse

main
Steve Ruiz 4 лет назад
Родитель
Сommit
b50045c9b7

+ 38
- 45
components/canvas/bounds.tsx Просмотреть файл

@@ -1,8 +1,8 @@
1 1
 import state, { useSelector } from "state"
2
-import { motion } from "framer-motion"
3 2
 import styled from "styles"
4 3
 import inputs from "state/inputs"
5 4
 import { useRef } from "react"
5
+import { TransformCorner, TransformEdge } from "types"
6 6
 
7 7
 export default function Bounds() {
8 8
   const zoom = useSelector((state) => state.data.camera.zoom)
@@ -16,6 +16,8 @@ export default function Bounds() {
16 16
   const p = 4 / zoom
17 17
   const cp = p * 2
18 18
 
19
+  if (width < p || height < p) return null
20
+
19 21
   return (
20 22
     <g pointerEvents={isBrushing ? "none" : "all"}>
21 23
       <StyledBounds
@@ -27,61 +29,61 @@ export default function Bounds() {
27 29
       />
28 30
       {width * zoom > 8 && (
29 31
         <>
30
-          <Corner
31
-            x={minX}
32
-            y={minY}
33
-            width={cp}
34
-            height={cp}
35
-            corner="top_left_corner"
36
-          />
37
-          <Corner
38
-            x={maxX}
39
-            y={minY}
40
-            width={cp}
41
-            height={cp}
42
-            corner="top_right_corner"
43
-          />
44
-          <Corner
45
-            x={maxX}
46
-            y={maxY}
47
-            width={cp}
48
-            height={cp}
49
-            corner="bottom_right_corner"
50
-          />
51
-          <Corner
52
-            x={minX}
53
-            y={maxY}
54
-            width={cp}
55
-            height={cp}
56
-            corner="bottom_left_corner"
57
-          />
58 32
           <EdgeHorizontal
59 33
             x={minX + p}
60 34
             y={minY}
61 35
             width={Math.max(0, width - p * 2)}
62 36
             height={p}
63
-            edge="top_edge"
37
+            edge={TransformEdge.Top}
64 38
           />
65 39
           <EdgeVertical
66 40
             x={maxX}
67 41
             y={minY + p}
68 42
             width={p}
69 43
             height={Math.max(0, height - p * 2)}
70
-            edge="right_edge"
44
+            edge={TransformEdge.Right}
71 45
           />
72 46
           <EdgeHorizontal
73 47
             x={minX + p}
74 48
             y={maxY}
75 49
             width={Math.max(0, width - p * 2)}
76 50
             height={p}
77
-            edge="bottom_edge"
51
+            edge={TransformEdge.Bottom}
78 52
           />
79 53
           <EdgeVertical
80 54
             x={minX}
81 55
             y={minY + p}
82 56
             width={p}
83 57
             height={Math.max(0, height - p * 2)}
84
-            edge="left_edge"
58
+            edge={TransformEdge.Left}
59
+          />
60
+          <Corner
61
+            x={minX}
62
+            y={minY}
63
+            width={cp}
64
+            height={cp}
65
+            corner={TransformCorner.TopLeft}
66
+          />
67
+          <Corner
68
+            x={maxX}
69
+            y={minY}
70
+            width={cp}
71
+            height={cp}
72
+            corner={TransformCorner.TopRight}
73
+          />
74
+          <Corner
75
+            x={maxX}
76
+            y={maxY}
77
+            width={cp}
78
+            height={cp}
79
+            corner={TransformCorner.BottomRight}
80
+          />
81
+          <Corner
82
+            x={minX}
83
+            y={maxY}
84
+            width={cp}
85
+            height={cp}
86
+            corner={TransformCorner.BottomLeft}
85 87
           />
86 88
         </>
87 89
       )}
@@ -100,11 +102,7 @@ function Corner({
100 102
   y: number
101 103
   width: number
102 104
   height: number
103
-  corner:
104
-    | "top_left_corner"
105
-    | "top_right_corner"
106
-    | "bottom_right_corner"
107
-    | "bottom_left_corner"
105
+  corner: TransformCorner
108 106
 }) {
109 107
   const rRotateCorner = useRef<SVGRectElement>(null)
110 108
   const rCorner = useRef<SVGRectElement>(null)
@@ -166,7 +164,7 @@ function EdgeHorizontal({
166 164
   y: number
167 165
   width: number
168 166
   height: number
169
-  edge: "top_edge" | "bottom_edge"
167
+  edge: TransformEdge.Top | TransformEdge.Bottom
170 168
 }) {
171 169
   const rEdge = useRef<SVGRectElement>(null)
172 170
 
@@ -205,7 +203,7 @@ function EdgeVertical({
205 203
   y: number
206 204
   width: number
207 205
   height: number
208
-  edge: "right_edge" | "left_edge"
206
+  edge: TransformEdge.Right | TransformEdge.Left
209 207
 }) {
210 208
   const rEdge = useRef<SVGRectElement>(null)
211 209
 
@@ -232,11 +230,6 @@ function EdgeVertical({
232 230
   )
233 231
 }
234 232
 
235
-function restoreCursor(e: PointerEvent) {
236
-  state.send("STOPPED_POINTING", { id: "bounds", ...inputs.pointerUp(e) })
237
-  document.body.style.cursor = "default"
238
-}
239
-
240 233
 const StyledEdge = styled("rect", {
241 234
   stroke: "none",
242 235
   fill: "none",

+ 3
- 6
components/canvas/shape.tsx Просмотреть файл

@@ -1,7 +1,7 @@
1 1
 import React, { useCallback, useRef, memo } from "react"
2 2
 import state, { useSelector } from "state"
3 3
 import inputs from "state/inputs"
4
-import shapes from "lib/shapes"
4
+import { getShapeUtils } from "lib/shapes"
5 5
 import styled from "styles"
6 6
 
7 7
 function Shape({ id }: { id: string }) {
@@ -41,7 +41,6 @@ function Shape({ id }: { id: string }) {
41 41
     (e: React.PointerEvent) => state.send("UNHOVERED_SHAPE", { id }),
42 42
     [id]
43 43
   )
44
-
45 44
   return (
46 45
     <StyledGroup
47 46
       ref={rGroup}
@@ -52,9 +51,7 @@ function Shape({ id }: { id: string }) {
52 51
       onPointerEnter={handlePointerEnter}
53 52
       onPointerLeave={handlePointerLeave}
54 53
     >
55
-      <defs>
56
-        {shapes[shape.type] ? shapes[shape.type].render(shape) : null}
57
-      </defs>
54
+      <defs>{getShapeUtils(shape).render(shape)}</defs>
58 55
       <HoverIndicator as="use" xlinkHref={"#" + id} />
59 56
       <use xlinkHref={"#" + id} {...shape.style} />
60 57
       <Indicator as="use" xlinkHref={"#" + id} />
@@ -65,7 +62,7 @@ function Shape({ id }: { id: string }) {
65 62
 const Indicator = styled("path", {
66 63
   fill: "none",
67 64
   stroke: "transparent",
68
-  zStrokeWidth: 1,
65
+  zStrokeWidth: [1, 1],
69 66
   pointerEvents: "none",
70 67
   strokeLineCap: "round",
71 68
   strokeLinejoin: "round",

+ 0
- 19
lib/shapes/base-shape.tsx Просмотреть файл

@@ -1,19 +0,0 @@
1
-import { Bounds, Shape } from "types"
2
-
3
-export default interface BaseLibShape<K extends Shape> {
4
-  create(props: Partial<K>): K
5
-  getBounds(this: BaseLibShape<K>, shape: K): Bounds
6
-  hitTest(this: BaseLibShape<K>, shape: K, test: number[]): boolean
7
-  hitTestBounds(this: BaseLibShape<K>, shape: K, bounds: Bounds): boolean
8
-  rotate(this: BaseLibShape<K>, shape: K): K
9
-  translate(this: BaseLibShape<K>, shape: K, delta: number[]): K
10
-  scale(this: BaseLibShape<K>, shape: K, scale: number): K
11
-  stretch(this: BaseLibShape<K>, shape: K, scaleX: number, scaleY: number): K
12
-  render(this: BaseLibShape<K>, shape: K): JSX.Element
13
-}
14
-
15
-export function createShape<T extends Shape>(
16
-  shape: BaseLibShape<T>
17
-): BaseLibShape<T> {
18
-  return shape
19
-}

+ 14
- 5
lib/shapes/circle.tsx Просмотреть файл

@@ -1,12 +1,13 @@
1 1
 import { v4 as uuid } from "uuid"
2 2
 import * as vec from "utils/vec"
3 3
 import { CircleShape, ShapeType } from "types"
4
-import { boundsCache } from "./index"
4
+import { createShape } from "./index"
5 5
 import { boundsContained } from "utils/bounds"
6 6
 import { intersectCircleBounds } from "utils/intersections"
7
-import { createShape } from "./base-shape"
8 7
 
9 8
 const circle = createShape<CircleShape>({
9
+  boundsCache: new WeakMap([]),
10
+
10 11
   create(props) {
11 12
     return {
12 13
       id: uuid(),
@@ -27,8 +28,8 @@ const circle = createShape<CircleShape>({
27 28
   },
28 29
 
29 30
   getBounds(shape) {
30
-    if (boundsCache.has(shape)) {
31
-      return boundsCache.get(shape)
31
+    if (this.boundsCache.has(shape)) {
32
+      return this.boundsCache.get(shape)
32 33
     }
33 34
 
34 35
     const {
@@ -45,7 +46,8 @@ const circle = createShape<CircleShape>({
45 46
       height: radius * 2,
46 47
     }
47 48
 
48
-    boundsCache.set(shape, bounds)
49
+    this.boundsCache.set(shape, bounds)
50
+
49 51
     return bounds
50 52
   },
51 53
 
@@ -84,6 +86,13 @@ const circle = createShape<CircleShape>({
84 86
   stretch(shape, scaleX, scaleY) {
85 87
     return shape
86 88
   },
89
+
90
+  transform(shape, bounds) {
91
+    shape.point = [bounds.minX, bounds.minY]
92
+    shape.radius = Math.min(bounds.width, bounds.height) / 2
93
+
94
+    return shape
95
+  },
87 96
 })
88 97
 
89 98
 export default circle

+ 18
- 10
lib/shapes/dot.tsx Просмотреть файл

@@ -1,12 +1,13 @@
1 1
 import { v4 as uuid } from "uuid"
2 2
 import * as vec from "utils/vec"
3 3
 import { DotShape, ShapeType } from "types"
4
-import { boundsCache } from "./index"
4
+import { createShape } from "./index"
5 5
 import { boundsContained } from "utils/bounds"
6 6
 import { intersectCircleBounds } from "utils/intersections"
7
-import { createShape } from "./base-shape"
8 7
 
9 8
 const dot = createShape<DotShape>({
9
+  boundsCache: new WeakMap([]),
10
+
10 11
   create(props) {
11 12
     return {
12 13
       id: uuid(),
@@ -22,12 +23,12 @@ const dot = createShape<DotShape>({
22 23
   },
23 24
 
24 25
   render({ id }) {
25
-    return <circle id={id} cx={4} cy={4} r={4} />
26
+    return <circle id={id} cx={0} cy={0} r={4} />
26 27
   },
27 28
 
28 29
   getBounds(shape) {
29
-    if (boundsCache.has(shape)) {
30
-      return boundsCache.get(shape)
30
+    if (this.boundsCache.has(shape)) {
31
+      return this.boundsCache.get(shape)
31 32
     }
32 33
 
33 34
     const {
@@ -36,14 +37,15 @@ const dot = createShape<DotShape>({
36 37
 
37 38
     const bounds = {
38 39
       minX: x,
39
-      maxX: x + 8,
40
+      maxX: x + 1,
40 41
       minY: y,
41
-      maxY: y + 8,
42
-      width: 8,
43
-      height: 8,
42
+      maxY: y + 1,
43
+      width: 1,
44
+      height: 1,
44 45
     }
45 46
 
46
-    boundsCache.set(shape, bounds)
47
+    this.boundsCache.set(shape, bounds)
48
+
47 49
     return bounds
48 50
   },
49 51
 
@@ -75,6 +77,12 @@ const dot = createShape<DotShape>({
75 77
   stretch(shape, scaleX: number, scaleY: number) {
76 78
     return shape
77 79
   },
80
+
81
+  transform(shape, bounds) {
82
+    shape.point = [bounds.minX, bounds.minY]
83
+
84
+    return shape
85
+  },
78 86
 })
79 87
 
80 88
 export default dot

+ 103
- 0
lib/shapes/ellipse.tsx Просмотреть файл

@@ -0,0 +1,103 @@
1
+import { v4 as uuid } from "uuid"
2
+import * as vec from "utils/vec"
3
+import { EllipseShape, ShapeType } from "types"
4
+import { createShape } from "./index"
5
+import { boundsContained } from "utils/bounds"
6
+import { intersectEllipseBounds } from "utils/intersections"
7
+import { pointInEllipse } from "utils/hitTests"
8
+
9
+const ellipse = createShape<EllipseShape>({
10
+  boundsCache: new WeakMap([]),
11
+
12
+  create(props) {
13
+    return {
14
+      id: uuid(),
15
+      type: ShapeType.Ellipse,
16
+      name: "Ellipse",
17
+      parentId: "page0",
18
+      childIndex: 0,
19
+      point: [0, 0],
20
+      radiusX: 20,
21
+      radiusY: 20,
22
+      rotation: 0,
23
+      style: {},
24
+      ...props,
25
+    }
26
+  },
27
+
28
+  render({ id, radiusX, radiusY }) {
29
+    return (
30
+      <ellipse id={id} cx={radiusX} cy={radiusY} rx={radiusX} ry={radiusY} />
31
+    )
32
+  },
33
+
34
+  getBounds(shape) {
35
+    if (this.boundsCache.has(shape)) {
36
+      return this.boundsCache.get(shape)
37
+    }
38
+
39
+    const {
40
+      point: [x, y],
41
+      radiusX,
42
+      radiusY,
43
+    } = shape
44
+
45
+    const bounds = {
46
+      minX: x,
47
+      maxX: x + radiusX * 2,
48
+      minY: y,
49
+      maxY: y + radiusY * 2,
50
+      width: radiusX * 2,
51
+      height: radiusY * 2,
52
+    }
53
+
54
+    this.boundsCache.set(shape, bounds)
55
+
56
+    return bounds
57
+  },
58
+
59
+  hitTest(shape, point) {
60
+    return pointInEllipse(point, shape.point, shape.radiusX, shape.radiusY)
61
+  },
62
+
63
+  hitTestBounds(this, shape, brushBounds) {
64
+    const shapeBounds = this.getBounds(shape)
65
+
66
+    return (
67
+      boundsContained(shapeBounds, brushBounds) ||
68
+      intersectEllipseBounds(
69
+        vec.add(shape.point, [shape.radiusX, shape.radiusY]),
70
+        shape.radiusX,
71
+        shape.radiusY,
72
+        brushBounds
73
+      ).length > 0
74
+    )
75
+  },
76
+
77
+  rotate(shape) {
78
+    return shape
79
+  },
80
+
81
+  translate(shape, delta) {
82
+    shape.point = vec.add(shape.point, delta)
83
+    return shape
84
+  },
85
+
86
+  scale(shape, scale: number) {
87
+    return shape
88
+  },
89
+
90
+  stretch(shape, scaleX: number, scaleY: number) {
91
+    return shape
92
+  },
93
+
94
+  transform(shape, bounds) {
95
+    shape.point = [bounds.minX, bounds.minY]
96
+    shape.radiusX = bounds.width / 2
97
+    shape.radiusY = bounds.height / 2
98
+
99
+    return shape
100
+  },
101
+})
102
+
103
+export default ellipse

+ 85
- 18
lib/shapes/index.tsx Просмотреть файл

@@ -1,20 +1,87 @@
1
-import Circle from "./circle"
2
-import Dot from "./dot"
3
-import Polyline from "./polyline"
4
-import Rectangle from "./rectangle"
5
-
6
-import { Bounds, Shape, ShapeType } from "types"
7
-
8
-export const boundsCache = new WeakMap<Shape, Bounds>([])
9
-
10
-const shapes = {
11
-  [ShapeType.Circle]: Circle,
12
-  [ShapeType.Dot]: Dot,
13
-  [ShapeType.Polyline]: Polyline,
14
-  [ShapeType.Rectangle]: Rectangle,
15
-  [ShapeType.Ellipse]: Rectangle,
16
-  [ShapeType.Line]: Rectangle,
17
-  [ShapeType.Ray]: Rectangle,
1
+import { Bounds, BoundsSnapshot, Shape, Shapes, ShapeType } from "types"
2
+import circle from "./circle"
3
+import dot from "./dot"
4
+import polyline from "./polyline"
5
+import rectangle from "./rectangle"
6
+import ellipse from "./ellipse"
7
+import line from "./line"
8
+import ray from "./ray"
9
+
10
+/*
11
+Shape Utiliies
12
+
13
+A shape utility is an object containing utility methods for each type of shape
14
+in the application. While shapes may be very different, each shape must support
15
+a common set of utility methods, such as hit tests or translations, that 
16
+
17
+Operations throughout the app will call these utility methods
18
+when performing tests (such as hit tests) or mutations, such as translations.
19
+*/
20
+
21
+export interface ShapeUtility<K extends Shape> {
22
+  // A cache for the computed bounds of this kind of shape.
23
+  boundsCache: WeakMap<K, Bounds>
24
+
25
+  // Create a new shape.
26
+  create(props: Partial<K>): K
27
+
28
+  // Get the bounds of the a shape.
29
+  getBounds(this: ShapeUtility<K>, shape: K): Bounds
30
+
31
+  // Test whether a point lies within a shape.
32
+  hitTest(this: ShapeUtility<K>, shape: K, test: number[]): boolean
33
+
34
+  // Test whether bounds collide with or contain a shape.
35
+  hitTestBounds(this: ShapeUtility<K>, shape: K, bounds: Bounds): boolean
36
+
37
+  // Apply a rotation to a shape.
38
+  rotate(this: ShapeUtility<K>, shape: K): K
39
+
40
+  // Apply a translation to a shape.
41
+  translate(this: ShapeUtility<K>, shape: K, delta: number[]): K
42
+
43
+  // Transform to fit a new bounding box.
44
+  transform(this: ShapeUtility<K>, shape: K, bounds: Bounds): K
45
+
46
+  // Apply a scale to a shape.
47
+  scale(this: ShapeUtility<K>, shape: K, scale: number): K
48
+
49
+  // Apply a stretch to a shape.
50
+  stretch(this: ShapeUtility<K>, shape: K, scaleX: number, scaleY: number): K
51
+
52
+  // Render a shape to JSX.
53
+  render(this: ShapeUtility<K>, shape: K): JSX.Element
54
+}
55
+
56
+// A mapping of shape types to shape utilities.
57
+const shapeUtilityMap: { [key in ShapeType]: ShapeUtility<Shapes[key]> } = {
58
+  [ShapeType.Circle]: circle,
59
+  [ShapeType.Dot]: dot,
60
+  [ShapeType.Polyline]: polyline,
61
+  [ShapeType.Rectangle]: rectangle,
62
+  [ShapeType.Ellipse]: ellipse,
63
+  [ShapeType.Line]: line,
64
+  [ShapeType.Ray]: ray,
65
+}
66
+
67
+/**
68
+ * A helper to retrieve a shape utility based on a shape object.
69
+ * @param shape
70
+ * @returns
71
+ */
72
+export function getShapeUtils(shape: Shape): ShapeUtility<typeof shape> {
73
+  return shapeUtilityMap[shape.type]
74
+}
75
+
76
+/**
77
+ *  A factory of shape utilities, with typing enforced.
78
+ * @param shape
79
+ * @returns
80
+ */
81
+export function createShape<T extends Shape>(
82
+  shape: ShapeUtility<T>
83
+): ShapeUtility<T> {
84
+  return Object.freeze(shape)
18 85
 }
19 86
 
20
-export default shapes
87
+export default shapeUtilityMap

+ 87
- 0
lib/shapes/line.tsx Просмотреть файл

@@ -0,0 +1,87 @@
1
+import { v4 as uuid } from "uuid"
2
+import * as vec from "utils/vec"
3
+import { LineShape, ShapeType } from "types"
4
+import { createShape } from "./index"
5
+import { boundsContained } from "utils/bounds"
6
+import { intersectCircleBounds } from "utils/intersections"
7
+
8
+const line = createShape<LineShape>({
9
+  boundsCache: new WeakMap([]),
10
+
11
+  create(props) {
12
+    return {
13
+      id: uuid(),
14
+      type: ShapeType.Line,
15
+      name: "Line",
16
+      parentId: "page0",
17
+      childIndex: 0,
18
+      point: [0, 0],
19
+      vector: [0, 0],
20
+      rotation: 0,
21
+      style: {},
22
+      ...props,
23
+    }
24
+  },
25
+
26
+  render({ id }) {
27
+    return <circle id={id} cx={4} cy={4} r={4} />
28
+  },
29
+
30
+  getBounds(shape) {
31
+    if (this.boundsCache.has(shape)) {
32
+      return this.boundsCache.get(shape)
33
+    }
34
+
35
+    const {
36
+      point: [x, y],
37
+    } = shape
38
+
39
+    const bounds = {
40
+      minX: x,
41
+      maxX: x + 8,
42
+      minY: y,
43
+      maxY: y + 8,
44
+      width: 8,
45
+      height: 8,
46
+    }
47
+
48
+    this.boundsCache.set(shape, bounds)
49
+
50
+    return bounds
51
+  },
52
+
53
+  hitTest(shape, test) {
54
+    return vec.dist(shape.point, test) < 4
55
+  },
56
+
57
+  hitTestBounds(this, shape, brushBounds) {
58
+    const shapeBounds = this.getBounds(shape)
59
+    return (
60
+      boundsContained(shapeBounds, brushBounds) ||
61
+      intersectCircleBounds(shape.point, 4, brushBounds).length > 0
62
+    )
63
+  },
64
+
65
+  rotate(shape) {
66
+    return shape
67
+  },
68
+
69
+  translate(shape, delta) {
70
+    shape.point = vec.add(shape.point, delta)
71
+    return shape
72
+  },
73
+
74
+  scale(shape, scale: number) {
75
+    return shape
76
+  },
77
+
78
+  stretch(shape, scaleX: number, scaleY: number) {
79
+    return shape
80
+  },
81
+
82
+  transform(shape, bounds) {
83
+    return shape
84
+  },
85
+})
86
+
87
+export default line

+ 21
- 5
lib/shapes/polyline.tsx Просмотреть файл

@@ -1,12 +1,13 @@
1 1
 import { v4 as uuid } from "uuid"
2 2
 import * as vec from "utils/vec"
3 3
 import { PolylineShape, ShapeType } from "types"
4
-import { boundsCache } from "./index"
4
+import { createShape } from "./index"
5 5
 import { intersectPolylineBounds } from "utils/intersections"
6 6
 import { boundsCollide, boundsContained } from "utils/bounds"
7
-import { createShape } from "./base-shape"
8 7
 
9 8
 const polyline = createShape<PolylineShape>({
9
+  boundsCache: new WeakMap([]),
10
+
10 11
   create(props) {
11 12
     return {
12 13
       id: uuid(),
@@ -27,8 +28,8 @@ const polyline = createShape<PolylineShape>({
27 28
   },
28 29
 
29 30
   getBounds(shape) {
30
-    if (boundsCache.has(shape)) {
31
-      return boundsCache.get(shape)
31
+    if (this.boundsCache.has(shape)) {
32
+      return this.boundsCache.get(shape)
32 33
     }
33 34
 
34 35
     let minX = 0
@@ -52,7 +53,7 @@ const polyline = createShape<PolylineShape>({
52 53
       height: maxY - minY,
53 54
     }
54 55
 
55
-    boundsCache.set(shape, bounds)
56
+    this.boundsCache.set(shape, bounds)
56 57
     return bounds
57 58
   },
58 59
 
@@ -88,6 +89,21 @@ const polyline = createShape<PolylineShape>({
88 89
   stretch(shape, scaleX: number, scaleY: number) {
89 90
     return shape
90 91
   },
92
+
93
+  transform(shape, bounds) {
94
+    const currentBounds = this.getBounds(shape)
95
+
96
+    const scaleX = bounds.width / currentBounds.width
97
+    const scaleY = bounds.height / currentBounds.height
98
+
99
+    shape.points = shape.points.map((point) => {
100
+      let pt = vec.mulV(point, [scaleX, scaleY])
101
+      return pt
102
+    })
103
+
104
+    shape.point = [bounds.minX, bounds.minY]
105
+    return shape
106
+  },
91 107
 })
92 108
 
93 109
 export default polyline

+ 87
- 0
lib/shapes/ray.tsx Просмотреть файл

@@ -0,0 +1,87 @@
1
+import { v4 as uuid } from "uuid"
2
+import * as vec from "utils/vec"
3
+import { RayShape, ShapeType } from "types"
4
+import { createShape } from "./index"
5
+import { boundsContained } from "utils/bounds"
6
+import { intersectCircleBounds } from "utils/intersections"
7
+
8
+const ray = createShape<RayShape>({
9
+  boundsCache: new WeakMap([]),
10
+
11
+  create(props) {
12
+    return {
13
+      id: uuid(),
14
+      type: ShapeType.Ray,
15
+      name: "Ray",
16
+      parentId: "page0",
17
+      childIndex: 0,
18
+      point: [0, 0],
19
+      vector: [0, 0],
20
+      rotation: 0,
21
+      style: {},
22
+      ...props,
23
+    }
24
+  },
25
+
26
+  render({ id }) {
27
+    return <circle id={id} cx={4} cy={4} r={4} />
28
+  },
29
+
30
+  getBounds(shape) {
31
+    if (this.boundsCache.has(shape)) {
32
+      return this.boundsCache.get(shape)
33
+    }
34
+
35
+    const {
36
+      point: [x, y],
37
+    } = shape
38
+
39
+    const bounds = {
40
+      minX: x,
41
+      maxX: x + 8,
42
+      minY: y,
43
+      maxY: y + 8,
44
+      width: 8,
45
+      height: 8,
46
+    }
47
+
48
+    this.boundsCache.set(shape, bounds)
49
+
50
+    return bounds
51
+  },
52
+
53
+  hitTest(shape, test) {
54
+    return vec.dist(shape.point, test) < 4
55
+  },
56
+
57
+  hitTestBounds(this, shape, brushBounds) {
58
+    const shapeBounds = this.getBounds(shape)
59
+    return (
60
+      boundsContained(shapeBounds, brushBounds) ||
61
+      intersectCircleBounds(shape.point, 4, brushBounds).length > 0
62
+    )
63
+  },
64
+
65
+  rotate(shape) {
66
+    return shape
67
+  },
68
+
69
+  translate(shape, delta) {
70
+    shape.point = vec.add(shape.point, delta)
71
+    return shape
72
+  },
73
+
74
+  scale(shape, scale: number) {
75
+    return shape
76
+  },
77
+
78
+  stretch(shape, scaleX: number, scaleY: number) {
79
+    return shape
80
+  },
81
+
82
+  transform(shape, bounds) {
83
+    return shape
84
+  },
85
+})
86
+
87
+export default ray

+ 15
- 5
lib/shapes/rectangle.tsx Просмотреть файл

@@ -1,11 +1,12 @@
1 1
 import { v4 as uuid } from "uuid"
2 2
 import * as vec from "utils/vec"
3 3
 import { RectangleShape, ShapeType } from "types"
4
-import { boundsCache } from "./index"
4
+import { createShape } from "./index"
5 5
 import { boundsContained, boundsCollide } from "utils/bounds"
6
-import { createShape } from "./base-shape"
7 6
 
8 7
 const rectangle = createShape<RectangleShape>({
8
+  boundsCache: new WeakMap([]),
9
+
9 10
   create(props) {
10 11
     return {
11 12
       id: uuid(),
@@ -26,8 +27,8 @@ const rectangle = createShape<RectangleShape>({
26 27
   },
27 28
 
28 29
   getBounds(shape) {
29
-    if (boundsCache.has(shape)) {
30
-      return boundsCache.get(shape)
30
+    if (this.boundsCache.has(shape)) {
31
+      return this.boundsCache.get(shape)
31 32
     }
32 33
 
33 34
     const {
@@ -44,7 +45,8 @@ const rectangle = createShape<RectangleShape>({
44 45
       height,
45 46
     }
46 47
 
47
-    boundsCache.set(shape, bounds)
48
+    this.boundsCache.set(shape, bounds)
49
+
48 50
     return bounds
49 51
   },
50 52
 
@@ -74,6 +76,14 @@ const rectangle = createShape<RectangleShape>({
74 76
   },
75 77
 
76 78
   stretch(shape, scaleX, scaleY) {
79
+    shape.size = vec.mulV(shape.size, [scaleX, scaleY])
80
+    return shape
81
+  },
82
+
83
+  transform(shape, bounds) {
84
+    shape.point = [bounds.minX, bounds.minY]
85
+    shape.size = [bounds.width, bounds.height]
86
+
77 87
     return shape
78 88
   },
79 89
 })

+ 18
- 5
state/data.ts Просмотреть файл

@@ -1,5 +1,5 @@
1 1
 import { Data, ShapeType } from "types"
2
-import Shapes from "lib/shapes"
2
+import shapeUtils from "lib/shapes"
3 3
 
4 4
 export const defaultDocument: Data["document"] = {
5 5
   pages: {
@@ -9,7 +9,7 @@ export const defaultDocument: Data["document"] = {
9 9
       name: "Page 0",
10 10
       childIndex: 0,
11 11
       shapes: {
12
-        shape3: Shapes[ShapeType.Dot].create({
12
+        shape3: shapeUtils[ShapeType.Dot].create({
13 13
           id: "shape3",
14 14
           name: "Shape 3",
15 15
           childIndex: 3,
@@ -20,7 +20,7 @@ export const defaultDocument: Data["document"] = {
20 20
             strokeWidth: 1,
21 21
           },
22 22
         }),
23
-        shape0: Shapes[ShapeType.Circle].create({
23
+        shape0: shapeUtils[ShapeType.Circle].create({
24 24
           id: "shape0",
25 25
           name: "Shape 0",
26 26
           childIndex: 1,
@@ -32,7 +32,20 @@ export const defaultDocument: Data["document"] = {
32 32
             strokeWidth: 1,
33 33
           },
34 34
         }),
35
-        shape2: Shapes[ShapeType.Polyline].create({
35
+        shape5: shapeUtils[ShapeType.Ellipse].create({
36
+          id: "shape5",
37
+          name: "Shape 5",
38
+          childIndex: 5,
39
+          point: [250, 100],
40
+          radiusX: 50,
41
+          radiusY: 30,
42
+          style: {
43
+            fill: "#aaa",
44
+            stroke: "#777",
45
+            strokeWidth: 1,
46
+          },
47
+        }),
48
+        shape2: shapeUtils[ShapeType.Polyline].create({
36 49
           id: "shape2",
37 50
           name: "Shape 2",
38 51
           childIndex: 2,
@@ -50,7 +63,7 @@ export const defaultDocument: Data["document"] = {
50 63
             strokeLinejoin: "round",
51 64
           },
52 65
         }),
53
-        shape1: Shapes[ShapeType.Rectangle].create({
66
+        shape1: shapeUtils[ShapeType.Rectangle].create({
54 67
           id: "shape1",
55 68
           name: "Shape 1",
56 69
           childIndex: 1,

+ 2
- 2
state/sessions/brush-session.ts Просмотреть файл

@@ -1,5 +1,5 @@
1 1
 import { current } from "immer"
2
-import { BaseLibShape, Bounds, Data, Shapes } from "types"
2
+import { ShapeUtil, Bounds, Data, Shapes } from "types"
3 3
 import BaseSession from "./base-session"
4 4
 import shapes from "lib/shapes"
5 5
 import { getBoundsFromPoints } from "utils/utils"
@@ -68,7 +68,7 @@ export default class BrushSession extends BaseSession {
68 68
         .map((shape) => ({
69 69
           id: shape.id,
70 70
           test: (brushBounds: Bounds): boolean =>
71
-            (shapes[shape.type] as BaseLibShape<
71
+            (shapes[shape.type] as ShapeUtil<
72 72
               Shapes[typeof shape.type]
73 73
             >).hitTestBounds(shape, brushBounds),
74 74
         })),

+ 2
- 1
state/sessions/index.ts Просмотреть файл

@@ -1,5 +1,6 @@
1 1
 import BaseSession from "./base-session"
2 2
 import BrushSession from "./brush-session"
3 3
 import TranslateSession from "./translate-session"
4
+import TransformSession from "./transform-session"
4 5
 
5
-export { BrushSession, BaseSession, TranslateSession }
6
+export { BrushSession, BaseSession, TranslateSession, TransformSession }

+ 217
- 0
state/sessions/transform-session.ts Просмотреть файл

@@ -0,0 +1,217 @@
1
+import { Data, TransformEdge, TransformCorner, Bounds } from "types"
2
+import * as vec from "utils/vec"
3
+import BaseSession from "./base-session"
4
+import commands from "state/commands"
5
+import { current } from "immer"
6
+import { getShapeUtils } from "lib/shapes"
7
+import { getCommonBounds } from "utils/utils"
8
+
9
+export default class TransformSession extends BaseSession {
10
+  delta = [0, 0]
11
+  transformType: TransformEdge | TransformCorner
12
+  origin: number[]
13
+  snapshot: TransformSnapshot
14
+  currentBounds: Bounds
15
+  corners: {
16
+    a: number[]
17
+    b: number[]
18
+  }
19
+
20
+  constructor(
21
+    data: Data,
22
+    type: TransformCorner | TransformEdge,
23
+    point: number[]
24
+  ) {
25
+    super(data)
26
+    this.origin = point
27
+    this.transformType = type
28
+    this.snapshot = getTransformSnapshot(data)
29
+
30
+    const { minX, minY, maxX, maxY } = this.snapshot.initialBounds
31
+
32
+    this.currentBounds = { ...this.snapshot.initialBounds }
33
+
34
+    this.corners = {
35
+      a: [minX, minY],
36
+      b: [maxX, maxY],
37
+    }
38
+  }
39
+
40
+  update(data: Data, point: number[]) {
41
+    const { shapeBounds, currentPageId, selectedIds } = this.snapshot
42
+    const {
43
+      document: { pages },
44
+    } = data
45
+
46
+    let [x, y] = point
47
+    const { corners, transformType } = this
48
+
49
+    // Edge Transform
50
+
51
+    switch (transformType) {
52
+      case TransformEdge.Top: {
53
+        corners.a[1] = y
54
+        break
55
+      }
56
+      case TransformEdge.Right: {
57
+        corners.b[0] = x
58
+        break
59
+      }
60
+      case TransformEdge.Bottom: {
61
+        corners.b[1] = y
62
+        break
63
+      }
64
+      case TransformEdge.Left: {
65
+        corners.a[0] = x
66
+        break
67
+      }
68
+      case TransformCorner.TopLeft: {
69
+        corners.a[1] = y
70
+        corners.a[0] = x
71
+        break
72
+      }
73
+      case TransformCorner.TopRight: {
74
+        corners.b[0] = x
75
+        corners.a[1] = y
76
+        break
77
+      }
78
+      case TransformCorner.BottomRight: {
79
+        corners.b[1] = y
80
+        corners.b[0] = x
81
+        break
82
+      }
83
+      case TransformCorner.BottomLeft: {
84
+        corners.a[0] = x
85
+        corners.b[1] = y
86
+        break
87
+      }
88
+    }
89
+
90
+    const newBounds = {
91
+      minX: Math.min(corners.a[0], corners.b[0]),
92
+      minY: Math.min(corners.a[1], corners.b[1]),
93
+      maxX: Math.max(corners.a[0], corners.b[0]),
94
+      maxY: Math.max(corners.a[1], corners.b[1]),
95
+      width: Math.abs(corners.b[0] - corners.a[0]),
96
+      height: Math.abs(corners.b[1] - corners.a[1]),
97
+    }
98
+
99
+    const isFlippedX = corners.b[0] - corners.a[0] < 0
100
+    const isFlippedY = corners.b[1] - corners.a[1] < 0
101
+
102
+    // const dx = newBounds.minX - currentBounds.minX
103
+    // const dy = newBounds.minY - currentBounds.minY
104
+    // const scaleX = newBounds.width / currentBounds.width
105
+    // const scaleY = newBounds.height / currentBounds.height
106
+
107
+    this.currentBounds = newBounds
108
+
109
+    selectedIds.forEach((id) => {
110
+      const { nx, nmx, nw, ny, nmy, nh } = shapeBounds[id]
111
+
112
+      const minX = newBounds.minX + (isFlippedX ? nmx : nx) * newBounds.width
113
+      const minY = newBounds.minY + (isFlippedY ? nmy : ny) * newBounds.height
114
+      const width = nw * newBounds.width
115
+      const height = nh * newBounds.height
116
+
117
+      const shape = pages[currentPageId].shapes[id]
118
+
119
+      getShapeUtils(shape).transform(shape, {
120
+        minX,
121
+        minY,
122
+        maxX: minX + width,
123
+        maxY: minY + height,
124
+        width,
125
+        height,
126
+      })
127
+      // utils.stretch(shape, scaleX, scaleY)
128
+    })
129
+
130
+    // switch (this.transformHandle) {
131
+    //   case TransformEdge.Top:
132
+    //   case TransformEdge.Left:
133
+    //   case TransformEdge.Right:
134
+    //   case TransformEdge.Bottom: {
135
+    //     for (let id in shapeBounds) {
136
+    //       const { ny, nmy, nh } = shapeBounds[id]
137
+    //       const minY = v.my + (v.y1 < v.y0 ? nmy : ny) * v.mh
138
+    //       const height = nh * v.mh
139
+
140
+    //       const shape = pages[currentPageId].shapes[id]
141
+
142
+    //       getShapeUtils(shape).transform(shape)
143
+    //     }
144
+    //   }
145
+    //   case TransformCorner.TopLeft:
146
+    //   case TransformCorner.TopRight:
147
+    //   case TransformCorner.BottomLeft:
148
+    //   case TransformCorner.BottomRight: {
149
+    //   }
150
+    // }
151
+  }
152
+
153
+  cancel(data: Data) {
154
+    const { currentPageId } = this.snapshot
155
+    const { document } = data
156
+
157
+    // for (let id in shapes) {
158
+    // Restore shape using original bounds
159
+    // document.pages[currentPageId].shapes[id]
160
+    // }
161
+  }
162
+
163
+  complete(data: Data) {
164
+    // commands.translate(data, this.snapshot, getTransformSnapshot(data))
165
+  }
166
+}
167
+
168
+export function getTransformSnapshot(data: Data) {
169
+  const {
170
+    document: { pages },
171
+    selectedIds,
172
+    currentPageId,
173
+  } = current(data)
174
+
175
+  // A mapping of selected shapes and their bounds
176
+  const shapesBounds = Object.fromEntries(
177
+    Array.from(selectedIds.values()).map((id) => {
178
+      const shape = pages[currentPageId].shapes[id]
179
+      return [shape.id, getShapeUtils(shape).getBounds(shape)]
180
+    })
181
+  )
182
+
183
+  // The common (exterior) bounds of the selected shapes
184
+  const bounds = getCommonBounds(
185
+    ...Array.from(selectedIds.values()).map((id) => {
186
+      const shape = pages[currentPageId].shapes[id]
187
+      return getShapeUtils(shape).getBounds(shape)
188
+    })
189
+  )
190
+
191
+  // Return a mapping of shapes to bounds together with the relative
192
+  // positions of the shape's bounds within the common bounds shape.
193
+  return {
194
+    currentPageId,
195
+    initialBounds: bounds,
196
+    selectedIds: new Set(selectedIds),
197
+    shapeBounds: Object.fromEntries(
198
+      Array.from(selectedIds.values()).map((id) => {
199
+        const { minX, minY, width, height } = shapesBounds[id]
200
+        return [
201
+          id,
202
+          {
203
+            ...bounds,
204
+            nx: (minX - bounds.minX) / bounds.width,
205
+            ny: (minY - bounds.minY) / bounds.height,
206
+            nmx: 1 - (minX + width - bounds.minX) / bounds.width,
207
+            nmy: 1 - (minY + height - bounds.minY) / bounds.height,
208
+            nw: width / bounds.width,
209
+            nh: height / bounds.height,
210
+          },
211
+        ]
212
+      })
213
+    ),
214
+  }
215
+}
216
+
217
+export type TransformSnapshot = ReturnType<typeof getTransformSnapshot>

+ 32
- 23
state/state.ts Просмотреть файл

@@ -1,9 +1,9 @@
1 1
 import { createSelectorHook, createState } from "@state-designer/react"
2 2
 import { clamp, getCommonBounds, screenToWorld } from "utils/utils"
3 3
 import * as vec from "utils/vec"
4
-import { Bounds, Data, PointerInfo, Shape, ShapeType } from "types"
4
+import { Data, PointerInfo, TransformCorner, TransformEdge } from "types"
5 5
 import { defaultDocument } from "./data"
6
-import Shapes from "lib/shapes"
6
+import { getShapeUtils } from "lib/shapes"
7 7
 import history from "state/history"
8 8
 import * as Sessions from "./sessions"
9 9
 
@@ -43,6 +43,8 @@ const state = createState({
43 43
           on: {
44 44
             POINTED_CANVAS: { to: "brushSelecting" },
45 45
             POINTED_BOUNDS: { to: "pointingBounds" },
46
+            POINTED_BOUNDS_EDGE: { to: "transformingSelection" },
47
+            POINTED_BOUNDS_CORNER: { to: "transformingSelection" },
46 48
             POINTED_SHAPE: [
47 49
               "setPointedId",
48 50
               {
@@ -84,6 +86,15 @@ const state = createState({
84 86
             },
85 87
           },
86 88
         },
89
+        transformingSelection: {
90
+          onEnter: "startTransformSession",
91
+          on: {
92
+            MOVED_POINTER: "updateTransformSession",
93
+            PANNED_CAMERA: "updateTransformSession",
94
+            STOPPED_POINTING: { do: "completeSession", to: "selecting" },
95
+            CANCELLED: { do: "cancelSession", to: "selecting" },
96
+          },
97
+        },
87 98
         draggingSelection: {
88 99
           onEnter: "startTranslateSession",
89 100
           on: {
@@ -160,6 +171,7 @@ const state = createState({
160 171
     updateBrushSession(data, payload: PointerInfo) {
161 172
       session.update(data, screenToWorld(payload.point, data))
162 173
     },
174
+
163 175
     // Dragging / Translating
164 176
     startTranslateSession(data, payload: PointerInfo) {
165 177
       session = new Sessions.TranslateSession(
@@ -171,6 +183,21 @@ const state = createState({
171 183
       session.update(data, screenToWorld(payload.point, data))
172 184
     },
173 185
 
186
+    // Dragging / Translating
187
+    startTransformSession(
188
+      data,
189
+      payload: PointerInfo & { target: TransformCorner | TransformEdge }
190
+    ) {
191
+      session = new Sessions.TransformSession(
192
+        data,
193
+        payload.target,
194
+        screenToWorld(payload.point, data)
195
+      )
196
+    },
197
+    updateTransformSession(data, payload: PointerInfo) {
198
+      session.update(data, screenToWorld(payload.point, data))
199
+    },
200
+
174 201
     // Selection
175 202
     setPointedId(data, payload: PointerInfo) {
176 203
       data.pointedId = payload.target
@@ -224,31 +251,13 @@ const state = createState({
224 251
         document: { pages },
225 252
       } = data
226 253
 
254
+      if (selectedIds.size === 0) return null
255
+
227 256
       return getCommonBounds(
228 257
         ...Array.from(selectedIds.values())
229 258
           .map((id) => {
230 259
             const shape = pages[currentPageId].shapes[id]
231
-
232
-            switch (shape.type) {
233
-              case ShapeType.Dot: {
234
-                return Shapes[shape.type].getBounds(shape)
235
-              }
236
-              case ShapeType.Circle: {
237
-                return Shapes[shape.type].getBounds(shape)
238
-              }
239
-              case ShapeType.Line: {
240
-                return Shapes[shape.type].getBounds(shape)
241
-              }
242
-              case ShapeType.Polyline: {
243
-                return Shapes[shape.type].getBounds(shape)
244
-              }
245
-              case ShapeType.Rectangle: {
246
-                return Shapes[shape.type].getBounds(shape)
247
-              }
248
-              default: {
249
-                return null
250
-              }
251
-            }
260
+            return getShapeUtils(shape).getBounds(shape)
252 261
           })
253 262
           .filter(Boolean)
254 263
       )

+ 31
- 1
types.ts Просмотреть файл

@@ -101,6 +101,22 @@ export interface Bounds {
101 101
   height: number
102 102
 }
103 103
 
104
+export interface ShapeBounds extends Bounds {
105
+  id: string
106
+}
107
+
108
+export interface PointSnapshot extends Bounds {
109
+  nx: number
110
+  nmx: number
111
+  ny: number
112
+  nmy: number
113
+}
114
+
115
+export interface BoundsSnapshot extends PointSnapshot {
116
+  nw: number
117
+  nh: number
118
+}
119
+
104 120
 export interface Shapes extends Record<ShapeType, Shape> {
105 121
   [ShapeType.Dot]: DotShape
106 122
   [ShapeType.Circle]: CircleShape
@@ -120,7 +136,7 @@ export type ShapeSpecificProps<T extends Shape> = Pick<
120 136
 
121 137
 export type ShapeIndicatorProps<T extends Shape> = ShapeSpecificProps<T>
122 138
 
123
-export type BaseLibShape<K extends Shape> = {
139
+export type ShapeUtil<K extends Shape> = {
124 140
   create(props: Partial<K>): K
125 141
   getBounds(shape: K): Bounds
126 142
   hitTest(shape: K, test: number[]): boolean
@@ -142,3 +158,17 @@ export interface PointerInfo {
142 158
   metaKey: boolean
143 159
   altKey: boolean
144 160
 }
161
+
162
+export enum TransformEdge {
163
+  Top = "top_edge",
164
+  Right = "right_edge",
165
+  Bottom = "bottom_edge",
166
+  Left = "left_edge",
167
+}
168
+
169
+export enum TransformCorner {
170
+  TopLeft = "top_left_corner",
171
+  TopRight = "top_right_corner",
172
+  BottomRight = "bottom_right_corner",
173
+  BottomLeft = "bottom_left_corner",
174
+}

+ 48
- 0
utils/hitTests.ts Просмотреть файл

@@ -0,0 +1,48 @@
1
+import { Bounds } from "types"
2
+import * as vec from "./vec"
3
+
4
+/**
5
+ * Get whether a point is inside of a bounds.
6
+ * @param A
7
+ * @param b
8
+ * @returns
9
+ */
10
+export function pointInBounds(A: number[], b: Bounds) {
11
+  return !(A[0] < b.minX || A[0] > b.maxX || A[1] < b.minY || A[1] > b.maxY)
12
+}
13
+
14
+/**
15
+ * Get whether a point is inside of a circle.
16
+ * @param A
17
+ * @param b
18
+ * @returns
19
+ */
20
+export function pointInCircle(A: number[], C: number[], r: number) {
21
+  return vec.dist(A, C) <= r
22
+}
23
+
24
+/**
25
+ * Get whether a point is inside of an ellipse.
26
+ * @param point
27
+ * @param center
28
+ * @param rx
29
+ * @param ry
30
+ * @param rotation
31
+ * @returns
32
+ */
33
+export function pointInEllipse(
34
+  A: number[],
35
+  C: number[],
36
+  rx: number,
37
+  ry: number,
38
+  rotation = 0
39
+) {
40
+  rotation = rotation || 0
41
+  const cos = Math.cos(rotation)
42
+  const sin = Math.sin(rotation)
43
+  const delta = vec.sub(A, C)
44
+  const tdx = cos * delta[0] + sin * delta[1]
45
+  const tdy = sin * delta[0] - cos * delta[1]
46
+
47
+  return (tdx * tdx) / (rx * rx) + (tdy * tdy) / (ry * ry) <= 1
48
+}

+ 152
- 16
utils/intersections.ts Просмотреть файл

@@ -7,10 +7,7 @@ interface Intersection {
7 7
   points: number[][]
8 8
 }
9 9
 
10
-function getIntersection(
11
-  points: number[][],
12
-  message = points.length ? "Intersection" : "No intersection"
13
-) {
10
+function getIntersection(message: string, ...points: number[][]) {
14 11
   return { didIntersect: points.length > 0, message, points }
15 12
 }
16 13
 
@@ -29,22 +26,22 @@ export function intersectLineSegments(
29 26
   const u_b = BV[1] * AV[0] - BV[0] * AV[1]
30 27
 
31 28
   if (ua_t === 0 || ub_t === 0) {
32
-    return getIntersection([], "Coincident")
29
+    return getIntersection("coincident")
33 30
   }
34 31
 
35 32
   if (u_b === 0) {
36
-    return getIntersection([], "Parallel")
33
+    return getIntersection("parallel")
37 34
   }
38 35
 
39 36
   if (u_b != 0) {
40 37
     const ua = ua_t / u_b
41 38
     const ub = ub_t / u_b
42 39
     if (0 <= ua && ua <= 1 && 0 <= ub && ub <= 1) {
43
-      return getIntersection([vec.add(a1, vec.mul(AV, ua))])
40
+      return getIntersection("intersection", vec.add(a1, vec.mul(AV, ua)))
44 41
     }
45 42
   }
46 43
 
47
-  return getIntersection([])
44
+  return getIntersection("no intersection")
48 45
 }
49 46
 
50 47
 export function intersectCircleLineSegment(
@@ -68,11 +65,11 @@ export function intersectCircleLineSegment(
68 65
   const deter = b * b - 4 * a * cc
69 66
 
70 67
   if (deter < 0) {
71
-    return { didIntersect: false, message: "outside", points: [] }
68
+    return getIntersection("outside")
72 69
   }
73 70
 
74 71
   if (deter === 0) {
75
-    return { didIntersect: false, message: "tangent", points: [] }
72
+    return getIntersection("tangent")
76 73
   }
77 74
 
78 75
   var e = Math.sqrt(deter)
@@ -80,17 +77,71 @@ export function intersectCircleLineSegment(
80 77
   var u2 = (-b - e) / (2 * a)
81 78
   if ((u1 < 0 || u1 > 1) && (u2 < 0 || u2 > 1)) {
82 79
     if ((u1 < 0 && u2 < 0) || (u1 > 1 && u2 > 1)) {
83
-      return { didIntersect: false, message: "outside", points: [] }
80
+      return getIntersection("outside")
84 81
     } else {
85
-      return { didIntersect: false, message: "inside", points: [] }
82
+      return getIntersection("inside")
86 83
     }
87 84
   }
88 85
 
89
-  const result = { didIntersect: true, message: "intersection", points: [] }
90
-  if (0 <= u1 && u1 <= 1) result.points.push(vec.lrp(a1, a2, u1))
91
-  if (0 <= u2 && u2 <= 1) result.points.push(vec.lrp(a1, a2, u2))
86
+  const results: number[][] = []
87
+  if (0 <= u1 && u1 <= 1) results.push(vec.lrp(a1, a2, u1))
88
+  if (0 <= u2 && u2 <= 1) results.push(vec.lrp(a1, a2, u2))
92 89
 
93
-  return result
90
+  return getIntersection("intersection", ...results)
91
+}
92
+
93
+export function intersectEllipseLineSegment(
94
+  center: number[],
95
+  rx: number,
96
+  ry: number,
97
+  a1: number[],
98
+  a2: number[],
99
+  rotation = 0
100
+) {
101
+  // If the ellipse or line segment are empty, return no tValues.
102
+  if (rx === 0 || ry === 0 || vec.isEqual(a1, a2)) {
103
+    return getIntersection("No intersection")
104
+  }
105
+
106
+  // Get the semimajor and semiminor axes.
107
+  rx = rx < 0 ? rx : -rx
108
+  ry = ry < 0 ? ry : -ry
109
+
110
+  // Rotate points and translate so the ellipse is centered at the origin.
111
+  a1 = vec.sub(vec.rotWith(a1, center, -rotation), center)
112
+  a2 = vec.sub(vec.rotWith(a2, center, -rotation), center)
113
+
114
+  // Calculate the quadratic parameters.
115
+  const diff = vec.sub(a2, a1)
116
+
117
+  var A = (diff[0] * diff[0]) / rx / rx + (diff[1] * diff[1]) / ry / ry
118
+  var B = (2 * a1[0] * diff[0]) / rx / rx + (2 * a1[1] * diff[1]) / ry / ry
119
+  var C = (a1[0] * a1[0]) / rx / rx + (a1[1] * a1[1]) / ry / ry - 1
120
+
121
+  // Make a list of t values (normalized points on the line where intersections occur).
122
+  var tValues: number[] = []
123
+
124
+  // Calculate the discriminant.
125
+  var discriminant = B * B - 4 * A * C
126
+
127
+  if (discriminant === 0) {
128
+    // One real solution.
129
+    tValues.push(-B / 2 / A)
130
+  } else if (discriminant > 0) {
131
+    const root = Math.sqrt(discriminant)
132
+    // Two real solutions.
133
+    tValues.push((-B + root) / 2 / A)
134
+    tValues.push((-B - root) / 2 / A)
135
+  }
136
+
137
+  // Filter to only points that are on the segment.
138
+  // Solve for points, then counter-rotate points.
139
+  const points = tValues
140
+    .filter((t) => t >= 0 && t <= 1)
141
+    .map((t) => vec.add(center, vec.add(a1, vec.mul(vec.sub(a2, a1), t))))
142
+    .map((p) => vec.rotWith(p, center, rotation))
143
+
144
+  return getIntersection("intersection", ...points)
94 145
 }
95 146
 
96 147
 export function intersectCircleRectangle(
@@ -130,6 +181,73 @@ export function intersectCircleRectangle(
130 181
   return intersections
131 182
 }
132 183
 
184
+export function intersectEllipseRectangle(
185
+  c: number[],
186
+  rx: number,
187
+  ry: number,
188
+  point: number[],
189
+  size: number[],
190
+  rotation = 0
191
+): Intersection[] {
192
+  const tl = point
193
+  const tr = vec.add(point, [size[0], 0])
194
+  const br = vec.add(point, size)
195
+  const bl = vec.add(point, [0, size[1]])
196
+
197
+  const intersections: Intersection[] = []
198
+
199
+  const topIntersection = intersectEllipseLineSegment(
200
+    c,
201
+    rx,
202
+    ry,
203
+    tl,
204
+    tr,
205
+    rotation
206
+  )
207
+  const rightIntersection = intersectEllipseLineSegment(
208
+    c,
209
+    rx,
210
+    ry,
211
+    tr,
212
+    br,
213
+    rotation
214
+  )
215
+  const bottomIntersection = intersectEllipseLineSegment(
216
+    c,
217
+    rx,
218
+    ry,
219
+    bl,
220
+    br,
221
+    rotation
222
+  )
223
+  const leftIntersection = intersectEllipseLineSegment(
224
+    c,
225
+    rx,
226
+    ry,
227
+    tl,
228
+    bl,
229
+    rotation
230
+  )
231
+
232
+  if (topIntersection.didIntersect) {
233
+    intersections.push({ ...topIntersection, message: "top" })
234
+  }
235
+
236
+  if (rightIntersection.didIntersect) {
237
+    intersections.push({ ...rightIntersection, message: "right" })
238
+  }
239
+
240
+  if (bottomIntersection.didIntersect) {
241
+    intersections.push({ ...bottomIntersection, message: "bottom" })
242
+  }
243
+
244
+  if (leftIntersection.didIntersect) {
245
+    intersections.push({ ...leftIntersection, message: "left" })
246
+  }
247
+
248
+  return intersections
249
+}
250
+
133 251
 export function intersectRectangleLineSegment(
134 252
   point: number[],
135 253
   size: number[],
@@ -180,6 +298,24 @@ export function intersectCircleBounds(
180 298
   return intersectCircleRectangle(c, r, [minX, minY], [width, height])
181 299
 }
182 300
 
301
+export function intersectEllipseBounds(
302
+  c: number[],
303
+  rx: number,
304
+  ry: number,
305
+  bounds: Bounds,
306
+  rotation = 0
307
+): Intersection[] {
308
+  const { minX, minY, width, height } = bounds
309
+  return intersectEllipseRectangle(
310
+    c,
311
+    rx,
312
+    ry,
313
+    [minX, minY],
314
+    [width, height],
315
+    rotation
316
+  )
317
+}
318
+
183 319
 export function intersectLineSegmentBounds(
184 320
   a1: number[],
185 321
   a2: number[],

+ 251
- 0
utils/transforms.ts Просмотреть файл

@@ -0,0 +1,251 @@
1
+import { Bounds, BoundsSnapshot, ShapeBounds } from "types"
2
+
3
+export function stretchshapesX(shapes: ShapeBounds[]) {
4
+  const [first, ...rest] = shapes
5
+  let min = first.minX
6
+  let max = first.minX + first.width
7
+  for (let box of rest) {
8
+    min = Math.min(min, box.minX)
9
+    max = Math.max(max, box.minX + box.width)
10
+  }
11
+  return shapes.map((box) => ({ ...box, x: min, width: max - min }))
12
+}
13
+
14
+export function stretchshapesY(shapes: ShapeBounds[]) {
15
+  const [first, ...rest] = shapes
16
+  let min = first.minY
17
+  let max = first.minY + first.height
18
+  for (let box of rest) {
19
+    min = Math.min(min, box.minY)
20
+    max = Math.max(max, box.minY + box.height)
21
+  }
22
+  return shapes.map((box) => ({ ...box, y: min, height: max - min }))
23
+}
24
+
25
+export function distributeshapesX(shapes: ShapeBounds[]) {
26
+  const len = shapes.length
27
+  const sorted = [...shapes].sort((a, b) => a.minX - b.minX)
28
+  let min = sorted[0].minX
29
+
30
+  sorted.sort((a, b) => a.minX + a.width - b.minX - b.width)
31
+  let last = sorted[len - 1]
32
+  let max = last.minX + last.width
33
+
34
+  let range = max - min
35
+  let step = range / len
36
+  return sorted.map((box, i) => ({ ...box, x: min + step * i }))
37
+}
38
+
39
+export function distributeshapesY(shapes: ShapeBounds[]) {
40
+  const len = shapes.length
41
+  const sorted = [...shapes].sort((a, b) => a.minY - b.minY)
42
+  let min = sorted[0].minY
43
+
44
+  sorted.sort((a, b) => a.minY + a.height - b.minY - b.height)
45
+  let last = sorted[len - 1]
46
+  let max = last.minY + last.height
47
+
48
+  let range = max - min
49
+  let step = range / len
50
+  return sorted.map((box, i) => ({ ...box, y: min + step * i }))
51
+}
52
+
53
+export function alignshapesCenterX(shapes: ShapeBounds[]) {
54
+  let midX = 0
55
+  for (let box of shapes) midX += box.minX + box.width / 2
56
+  midX /= shapes.length
57
+  return shapes.map((box) => ({ ...box, x: midX - box.width / 2 }))
58
+}
59
+
60
+export function alignshapesCenterY(shapes: ShapeBounds[]) {
61
+  let midY = 0
62
+  for (let box of shapes) midY += box.minY + box.height / 2
63
+  midY /= shapes.length
64
+  return shapes.map((box) => ({ ...box, y: midY - box.height / 2 }))
65
+}
66
+
67
+export function alignshapesTop(shapes: ShapeBounds[]) {
68
+  const [first, ...rest] = shapes
69
+  let y = first.minY
70
+  for (let box of rest) if (box.minY < y) y = box.minY
71
+  return shapes.map((box) => ({ ...box, y }))
72
+}
73
+
74
+export function alignshapesBottom(shapes: ShapeBounds[]) {
75
+  const [first, ...rest] = shapes
76
+  let maxY = first.minY + first.height
77
+  for (let box of rest)
78
+    if (box.minY + box.height > maxY) maxY = box.minY + box.height
79
+  return shapes.map((box) => ({ ...box, y: maxY - box.height }))
80
+}
81
+
82
+export function alignshapesLeft(shapes: ShapeBounds[]) {
83
+  const [first, ...rest] = shapes
84
+  let x = first.minX
85
+  for (let box of rest) if (box.minX < x) x = box.minX
86
+  return shapes.map((box) => ({ ...box, x }))
87
+}
88
+
89
+export function alignshapesRight(shapes: ShapeBounds[]) {
90
+  const [first, ...rest] = shapes
91
+  let maxX = first.minX + first.width
92
+  for (let box of rest)
93
+    if (box.minX + box.width > maxX) maxX = box.minX + box.width
94
+  return shapes.map((box) => ({ ...box, x: maxX - box.width }))
95
+}
96
+
97
+// Resizers
98
+
99
+export function getBoundingBox(shapes: ShapeBounds[]): Bounds {
100
+  if (shapes.length === 0) {
101
+    return {
102
+      minX: 0,
103
+      minY: 0,
104
+      maxX: 0,
105
+      maxY: 0,
106
+      width: 0,
107
+      height: 0,
108
+    }
109
+  }
110
+
111
+  const first = shapes[0]
112
+
113
+  let minX = first.minX
114
+  let minY = first.minY
115
+  let maxX = first.minX + first.width
116
+  let maxY = first.minY + first.height
117
+
118
+  for (let box of shapes) {
119
+    minX = Math.min(minX, box.minX)
120
+    minY = Math.min(minY, box.minY)
121
+    maxX = Math.max(maxX, box.minX + box.width)
122
+    maxY = Math.max(maxY, box.minY + box.height)
123
+  }
124
+
125
+  return {
126
+    minX,
127
+    minY,
128
+    maxX,
129
+    maxY,
130
+    width: maxX - minX,
131
+    height: maxY - minY,
132
+  }
133
+}
134
+
135
+export function getSnapshots(
136
+  shapes: ShapeBounds[],
137
+  bounds: Bounds
138
+): Record<string, BoundsSnapshot> {
139
+  const acc = {} as Record<string, BoundsSnapshot>
140
+
141
+  const w = bounds.maxX - bounds.minX
142
+  const h = bounds.maxY - bounds.minY
143
+
144
+  for (let box of shapes) {
145
+    acc[box.id] = {
146
+      ...box,
147
+      nx: (box.minX - bounds.minX) / w,
148
+      ny: (box.minY - bounds.minY) / h,
149
+      nmx: 1 - (box.minX + box.width - bounds.minX) / w,
150
+      nmy: 1 - (box.minY + box.height - bounds.minY) / h,
151
+      nw: box.width / w,
152
+      nh: box.height / h,
153
+    }
154
+  }
155
+
156
+  return acc
157
+}
158
+
159
+export function getEdgeResizer(shapes: ShapeBounds[], edge: number) {
160
+  const initial = getBoundingBox(shapes)
161
+  const snapshots = getSnapshots(shapes, initial)
162
+  const mshapes = [...shapes]
163
+
164
+  let { minX: x0, minY: y0, maxX: x1, maxY: y1 } = initial
165
+  let { minX: mx, minY: my } = initial
166
+  let mw = x1 - x0
167
+  let mh = y1 - y0
168
+
169
+  return function edgeResize({ x, y }) {
170
+    if (edge === 0 || edge === 2) {
171
+      edge === 0 ? (y0 = y) : (y1 = y)
172
+      my = y0 < y1 ? y0 : y1
173
+      mh = Math.abs(y1 - y0)
174
+      for (let box of mshapes) {
175
+        const { ny, nmy, nh } = snapshots[box.id]
176
+        box.minY = my + (y1 < y0 ? nmy : ny) * mh
177
+        box.height = nh * mh
178
+      }
179
+    } else {
180
+      edge === 1 ? (x1 = x) : (x0 = x)
181
+      mx = x0 < x1 ? x0 : x1
182
+      mw = Math.abs(x1 - x0)
183
+      for (let box of mshapes) {
184
+        const { nx, nmx, nw } = snapshots[box.id]
185
+        box.minX = mx + (x1 < x0 ? nmx : nx) * mw
186
+        box.width = nw * mw
187
+      }
188
+    }
189
+
190
+    return [
191
+      mshapes,
192
+      {
193
+        x: mx,
194
+        y: my,
195
+        width: mw,
196
+        height: mh,
197
+        maxX: mx + mw,
198
+        maxY: my + mh,
199
+      },
200
+    ]
201
+  }
202
+}
203
+
204
+/**
205
+ * Returns a function that can be used to calculate corner resize transforms.
206
+ * @param shapes An array of the shapes being resized.
207
+ * @param corner A number representing the corner being dragged. Top Left: 0, Top Right: 1, Bottom Right: 2, Bottom Left: 3.
208
+ * @example
209
+ * const resizer = getCornerResizer(selectedshapes, 3)
210
+ * resizer(selectedshapes, )
211
+ */
212
+export function getCornerResizer(shapes: ShapeBounds[], corner: number) {
213
+  const initial = getBoundingBox(shapes)
214
+  const snapshots = getSnapshots(shapes, initial)
215
+  const mshapes = [...shapes]
216
+
217
+  let { minX: x0, minY: y0, maxX: x1, maxY: y1 } = initial
218
+  let { minX: mx, minY: my } = initial
219
+  let mw = x1 - x0
220
+  let mh = y1 - y0
221
+
222
+  return function cornerResizer({ x, y }) {
223
+    corner < 2 ? (y0 = y) : (y1 = y)
224
+    my = y0 < y1 ? y0 : y1
225
+    mh = Math.abs(y1 - y0)
226
+
227
+    corner === 1 || corner === 2 ? (x1 = x) : (x0 = x)
228
+    mx = x0 < x1 ? x0 : x1
229
+    mw = Math.abs(x1 - x0)
230
+
231
+    for (let box of mshapes) {
232
+      const { nx, nmx, nw, ny, nmy, nh } = snapshots[box.id]
233
+      box.minX = mx + (x1 < x0 ? nmx : nx) * mw
234
+      box.minY = my + (y1 < y0 ? nmy : ny) * mh
235
+      box.width = nw * mw
236
+      box.height = nh * mh
237
+    }
238
+
239
+    return [
240
+      mshapes,
241
+      {
242
+        x: mx,
243
+        y: my,
244
+        width: mw,
245
+        height: mh,
246
+        maxX: mx + mw,
247
+        maxY: my + mh,
248
+      },
249
+    ]
250
+  }
251
+}

+ 2
- 0
utils/vec.ts Просмотреть файл

@@ -249,6 +249,8 @@ export function rot(A: number[], r: number) {
249 249
  * @param r rotation in radians
250 250
  */
251 251
 export function rotWith(A: number[], C: number[], r: number) {
252
+  if (r === 0) return A
253
+
252 254
   const s = Math.sin(r)
253 255
   const c = Math.cos(r)
254 256
 

Загрузка…
Отмена
Сохранить