@lazlon-platform/html-editor 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/hooks/actions.ts +4 -3
- package/lib/hooks/node.ts +14 -6
- package/lib/hooks/pointer/moveable.ts +75 -54
- package/lib/hooks/pointer/pointer.ts +22 -11
- package/lib/hooks/pointer/resize.ts +176 -210
- package/lib/hooks/pointer/rotation.ts +41 -55
- package/lib/hooks/pointer/selectionFrame.ts +8 -11
- package/lib/hooks/pointer/selector.ts +48 -40
- package/lib/hooks/pointer/snap.ts +20 -20
- package/lib/model/geometry/math.ts +484 -0
- package/lib/model/geometry/svg.ts +55 -0
- package/lib/model/node/shape/arrow.ts +1 -1
- package/lib/model/node/shape/polygon.ts +23 -1
- package/lib/model/node/shape/star.ts +29 -1
- package/lib/model/node/text.ts +14 -10
- package/lib/model/node.ts +3 -7
- package/lib/model/page.ts +3 -1
- package/lib/ui/node/NodeView.tsx +1 -13
- package/lib/ui/selection.ts +9 -26
- package/package.json +34 -34
- package/lib/model/geometry.ts +0 -247
|
@@ -1,53 +1,64 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useRef } from "react"
|
|
2
|
+
import {
|
|
3
|
+
type Rect,
|
|
4
|
+
box,
|
|
5
|
+
boxContainsPoint,
|
|
6
|
+
pointSubtract,
|
|
7
|
+
rect,
|
|
8
|
+
boxIntersects,
|
|
9
|
+
} from "../../model/geometry/math"
|
|
2
10
|
import { useEditor } from "../editor"
|
|
3
|
-
import { usePointer } from "./pointer"
|
|
11
|
+
import { cursorPosition, usePointer } from "./pointer"
|
|
4
12
|
|
|
5
|
-
|
|
13
|
+
function clientPoint(event: { clientX: number; clientY: number }) {
|
|
14
|
+
return { x: event.clientX, y: event.clientY }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useSelector(view: (props: null | Rect) => void) {
|
|
6
18
|
const editor = useEditor()
|
|
7
19
|
|
|
20
|
+
const dragAnchor = useRef({
|
|
21
|
+
clientX: 0,
|
|
22
|
+
clientY: 0,
|
|
23
|
+
})
|
|
24
|
+
|
|
8
25
|
return usePointer({
|
|
9
26
|
onDown(event) {
|
|
10
|
-
const
|
|
27
|
+
for (const node of editor.nodes.values()) {
|
|
28
|
+
if (boxContainsPoint(box(node), cursorPosition(event, node))) {
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
}
|
|
11
32
|
|
|
12
|
-
if (!
|
|
33
|
+
if (!editor.action.action) {
|
|
13
34
|
editor.action = { action: "select" }
|
|
35
|
+
dragAnchor.current = event
|
|
14
36
|
}
|
|
15
|
-
|
|
16
|
-
return !isPointerInsideRect
|
|
17
37
|
},
|
|
18
|
-
onMove(
|
|
19
|
-
const div = props.ref.current
|
|
20
|
-
if (!div) throw Error("selector div ref is null")
|
|
38
|
+
onMove(event) {
|
|
21
39
|
if (editor.action.action !== "select") return
|
|
40
|
+
const stage = editor.ref!.getBoundingClientRect()
|
|
22
41
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const height = Math.floor(Math.abs(y))
|
|
29
|
-
const tx = (x > 0 ? start.clientX : start.clientX - width) - rect.x
|
|
30
|
-
const ty = (y > 0 ? start.clientY : start.clientY - height) - rect.y
|
|
31
|
-
|
|
32
|
-
const { left, right, top, bottom } = div.getBoundingClientRect()
|
|
42
|
+
/* world view */ {
|
|
43
|
+
const p1 = clientPoint(dragAnchor.current)
|
|
44
|
+
const p2 = clientPoint(event)
|
|
45
|
+
view(rect(pointSubtract(p1, stage), pointSubtract(p2, stage)))
|
|
46
|
+
}
|
|
33
47
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
const selection = editor.pages
|
|
49
|
+
.values()
|
|
50
|
+
.toArray()
|
|
51
|
+
.flatMap((page) => {
|
|
52
|
+
const p1 = cursorPosition(dragAnchor.current, page)
|
|
53
|
+
const p2 = cursorPosition(event, page)
|
|
54
|
+
const selection = box(rect(p1, p2))
|
|
55
|
+
return page.nodes
|
|
56
|
+
.values()
|
|
57
|
+
.toArray()
|
|
58
|
+
.filter((node) => boxIntersects(selection, box(node)))
|
|
59
|
+
})
|
|
46
60
|
|
|
47
|
-
|
|
48
|
-
div.style.height = `${height}px`
|
|
49
|
-
div.style.transform = `translate(${tx}px, ${ty}px)`
|
|
50
|
-
div.style.display = "block"
|
|
61
|
+
editor.selection = new Set(selection)
|
|
51
62
|
},
|
|
52
63
|
onCancel() {
|
|
53
64
|
editor.selection = new Set() // click away
|
|
@@ -55,10 +66,7 @@ export function useSelector(props: { ref: React.RefObject<HTMLDivElement | null>
|
|
|
55
66
|
},
|
|
56
67
|
onEnd() {
|
|
57
68
|
editor.action = {}
|
|
58
|
-
|
|
59
|
-
const div = props.ref.current
|
|
60
|
-
if (!div) throw Error("selector div ref is null")
|
|
61
|
-
div.style.display = "none"
|
|
69
|
+
view(null)
|
|
62
70
|
},
|
|
63
71
|
})
|
|
64
72
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Rect, box, boxBounds, rect } from "../../model/geometry/math"
|
|
2
2
|
import type { Node } from "../../model/node"
|
|
3
3
|
import type { Page } from "../../model/page"
|
|
4
4
|
import { useEditor, usePage } from "../editor"
|
|
@@ -17,13 +17,13 @@ export function useSnap() {
|
|
|
17
17
|
|
|
18
18
|
function ySnap(nodes: Node[], rect: Rect): [number, ...Lines] {
|
|
19
19
|
const nodelines = nodes.flatMap((n) => {
|
|
20
|
-
const { y, height } = n
|
|
20
|
+
const { y, height } = boxBounds(box(n))
|
|
21
21
|
return [y, y + height]
|
|
22
22
|
})
|
|
23
23
|
|
|
24
24
|
const hlines = [0, page.height, ...nodelines]
|
|
25
|
-
const top = hlines.find(shouldSnap(rect.
|
|
26
|
-
const bottom = hlines.find(shouldSnap(rect.
|
|
25
|
+
const top = hlines.find(shouldSnap(rect.top))
|
|
26
|
+
const bottom = hlines.find(shouldSnap(rect.bottom))
|
|
27
27
|
|
|
28
28
|
if (isN(top) && isN(bottom)) {
|
|
29
29
|
return [
|
|
@@ -46,15 +46,15 @@ export function useSnap() {
|
|
|
46
46
|
return [rect.y]
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function xSnap(nodes: Node[],
|
|
49
|
+
function xSnap(nodes: Node[], rect: Rect): [number, ...Lines] {
|
|
50
50
|
const nodelines = nodes.flatMap((n) => {
|
|
51
|
-
const { x, width } = n
|
|
51
|
+
const { x, width } = boxBounds(box(n))
|
|
52
52
|
return [x, x + width]
|
|
53
53
|
})
|
|
54
54
|
|
|
55
55
|
const hlines = [0, page.width, ...nodelines]
|
|
56
|
-
const left = hlines.find(shouldSnap(
|
|
57
|
-
const right = hlines.find(shouldSnap(
|
|
56
|
+
const left = hlines.find(shouldSnap(rect.left))
|
|
57
|
+
const right = hlines.find(shouldSnap(rect.right))
|
|
58
58
|
|
|
59
59
|
if (isN(left) && isN(right)) {
|
|
60
60
|
return [left, { x: left - 1 }, { x: right }]
|
|
@@ -65,16 +65,16 @@ export function useSnap() {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
if (isN(right)) {
|
|
68
|
-
return [right -
|
|
68
|
+
return [right - rect.width, { x: right }]
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
return [
|
|
71
|
+
return [rect.x]
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
return function snap(snap: boolean,
|
|
74
|
+
return function snap(snap: boolean, r: Rect): Rect {
|
|
75
75
|
if (!snap) {
|
|
76
76
|
page.snapLines = []
|
|
77
|
-
return
|
|
77
|
+
return r
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
const nodes = page.nodes
|
|
@@ -82,16 +82,16 @@ export function useSnap() {
|
|
|
82
82
|
.filter((node) => !editor.selection.has(node))
|
|
83
83
|
.toArray()
|
|
84
84
|
|
|
85
|
-
const [
|
|
86
|
-
const [
|
|
85
|
+
const [x, ...hlines] = xSnap(nodes, r)
|
|
86
|
+
const [y, ...vlines] = ySnap(nodes, r)
|
|
87
87
|
|
|
88
88
|
page.snapLines = [...vlines, ...hlines]
|
|
89
89
|
|
|
90
|
-
return {
|
|
91
|
-
x
|
|
92
|
-
y
|
|
93
|
-
width:
|
|
94
|
-
height:
|
|
95
|
-
}
|
|
90
|
+
return rect({
|
|
91
|
+
x,
|
|
92
|
+
y,
|
|
93
|
+
width: r.width,
|
|
94
|
+
height: r.height,
|
|
95
|
+
})
|
|
96
96
|
}
|
|
97
97
|
}
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
export type Deg = number & { readonly __unit: "degrees" }
|
|
2
|
+
export type Rad = number & { readonly __unit: "radians" }
|
|
3
|
+
export type Edge = "n" | "s" | "w" | "e"
|
|
4
|
+
export type Corner = "ne" | "nw" | "se" | "sw"
|
|
5
|
+
|
|
6
|
+
export interface Point {
|
|
7
|
+
readonly x: number
|
|
8
|
+
readonly y: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Size {
|
|
12
|
+
readonly width: number
|
|
13
|
+
readonly height: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Rect extends Point, Size {
|
|
17
|
+
readonly top: number
|
|
18
|
+
readonly bottom: number
|
|
19
|
+
readonly left: number
|
|
20
|
+
readonly right: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A rotated rectangle around a pivot point.
|
|
25
|
+
* Pivot point is relative to the center point.
|
|
26
|
+
*/
|
|
27
|
+
export interface Box extends Size {
|
|
28
|
+
readonly center: Point
|
|
29
|
+
readonly rotation: Deg
|
|
30
|
+
readonly pivot: Point
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Cast a number to a degree value.
|
|
35
|
+
*/
|
|
36
|
+
export function deg(deg: number): Deg {
|
|
37
|
+
return (deg % 360) as Deg
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Cast a number to a radian value.
|
|
42
|
+
*/
|
|
43
|
+
export function rad(rad: number): Rad {
|
|
44
|
+
return rad as Rad
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Create a point.
|
|
49
|
+
*/
|
|
50
|
+
export function point(p?: Partial<Point>): Point {
|
|
51
|
+
return { x: p?.x ?? 0, y: p?.y ?? 0 }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create a rectangle around the given points and rectangles.
|
|
56
|
+
*/
|
|
57
|
+
export function rect(...args: Array<(Point & Size) | Point>): Rect {
|
|
58
|
+
const points: Point[] = args.flatMap((arg) =>
|
|
59
|
+
"width" in arg && "height" in arg ? rectCorners(arg) : arg,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
let minX = Infinity
|
|
63
|
+
let minY = Infinity
|
|
64
|
+
let maxX = -Infinity
|
|
65
|
+
let maxY = -Infinity
|
|
66
|
+
|
|
67
|
+
for (const p of points) {
|
|
68
|
+
if (p.x < minX) minX = p.x
|
|
69
|
+
if (p.y < minY) minY = p.y
|
|
70
|
+
if (p.x > maxX) maxX = p.x
|
|
71
|
+
if (p.y > maxY) maxY = p.y
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const rect = {
|
|
75
|
+
x: minX,
|
|
76
|
+
y: minY,
|
|
77
|
+
width: maxX - minX,
|
|
78
|
+
height: maxY - minY,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
...rect,
|
|
83
|
+
top: rect.y,
|
|
84
|
+
bottom: rect.y + rect.height,
|
|
85
|
+
left: rect.x,
|
|
86
|
+
right: rect.x + rect.width,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create a rotatable box for a rectangle
|
|
92
|
+
*/
|
|
93
|
+
export function box(input?: Size & Point & { rotation?: Deg }): Box {
|
|
94
|
+
return {
|
|
95
|
+
center: rectCenter(input ?? rect()),
|
|
96
|
+
rotation: input?.rotation ?? deg(0),
|
|
97
|
+
pivot: rectCenter(input ?? rect()),
|
|
98
|
+
width: input?.width ?? 0,
|
|
99
|
+
height: input?.height ?? 0,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @returns Four corners of a rectangle.
|
|
105
|
+
* ●───●
|
|
106
|
+
* │ │
|
|
107
|
+
* ●───●
|
|
108
|
+
*/
|
|
109
|
+
export function rectCorners(rect: Size & Point): [Point, Point, Point, Point] {
|
|
110
|
+
const top = rect.y
|
|
111
|
+
const bottom = rect.y + rect.height
|
|
112
|
+
const left = rect.x
|
|
113
|
+
const right = rect.x + rect.width
|
|
114
|
+
return [
|
|
115
|
+
{ y: top, x: left },
|
|
116
|
+
{ y: top, x: right },
|
|
117
|
+
{ y: bottom, x: right },
|
|
118
|
+
{ y: bottom, x: left },
|
|
119
|
+
]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @returns Center point of a rectangle.
|
|
124
|
+
* ┌─────┐
|
|
125
|
+
* │ ●C │
|
|
126
|
+
* └─────┘
|
|
127
|
+
*/
|
|
128
|
+
export function rectCenter({ x, y, width, height }: Size & Point): Point {
|
|
129
|
+
return {
|
|
130
|
+
x: x + width / 2,
|
|
131
|
+
y: y + height / 2,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @returns Rotate a point relative to a pivot point.
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* ```
|
|
140
|
+
* P = (0,0)
|
|
141
|
+
* A = (2,0)
|
|
142
|
+
*
|
|
143
|
+
* rotate(A, P, 90)
|
|
144
|
+
* -> A' = (0,2)
|
|
145
|
+
* ```
|
|
146
|
+
*
|
|
147
|
+
* A
|
|
148
|
+
* ●
|
|
149
|
+
* │
|
|
150
|
+
* │ 90°
|
|
151
|
+
* ●─────● A'
|
|
152
|
+
* P
|
|
153
|
+
*/
|
|
154
|
+
export function rotatePoint(p: Point, pivot: Point, deg: Deg): Point {
|
|
155
|
+
const rad = (deg * Math.PI) / 180
|
|
156
|
+
const cos = Math.cos(rad)
|
|
157
|
+
const sin = Math.sin(rad)
|
|
158
|
+
const dx = p.x - pivot.x
|
|
159
|
+
const dy = p.y - pivot.y
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
x: pivot.x + dx * cos - dy * sin,
|
|
163
|
+
y: pivot.y + dx * sin + dy * cos,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @returns Axis-aligned bounding box (AABB) of the rotated rectangle.
|
|
169
|
+
* ⠀▁▁▁▁
|
|
170
|
+
* 🭵 ╱🭰
|
|
171
|
+
* 🭵╱ ╱🭰
|
|
172
|
+
* 🭵╱ 🭰
|
|
173
|
+
* ⠀▔▔▔▔
|
|
174
|
+
*/
|
|
175
|
+
export function boxBounds(...boxes: Box[]): Rect {
|
|
176
|
+
const points = boxes.flatMap((box) => {
|
|
177
|
+
const corners = rectCorners({
|
|
178
|
+
x: box.center.x - box.width / 2,
|
|
179
|
+
y: box.center.y - box.height / 2,
|
|
180
|
+
width: box.width,
|
|
181
|
+
height: box.height,
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
return corners.map((corner) => {
|
|
185
|
+
return rotatePoint(corner, box.pivot, box.rotation)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
return rect(...points)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @returns Distance between two points.
|
|
194
|
+
*
|
|
195
|
+
* ●
|
|
196
|
+
* ⠀╲
|
|
197
|
+
* ⠀⠀●
|
|
198
|
+
*/
|
|
199
|
+
export function pointDist(a: Point, b: Point): number {
|
|
200
|
+
const dx = a.x - b.x
|
|
201
|
+
const dy = a.y - b.y
|
|
202
|
+
return Math.hypot(dx, dy)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Subtract point `b` from point `a`.
|
|
207
|
+
* b● ────▶ ●a
|
|
208
|
+
*/
|
|
209
|
+
export function pointSubtract(a: Point, b: Point): Point {
|
|
210
|
+
return {
|
|
211
|
+
x: a.x - b.x,
|
|
212
|
+
y: a.y - b.y,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Add point `b` to point `a`
|
|
218
|
+
*
|
|
219
|
+
* a● ────▶ ●b
|
|
220
|
+
*/
|
|
221
|
+
export function pointAdd(a: Point, b: Point): Point {
|
|
222
|
+
return {
|
|
223
|
+
x: a.x + b.x,
|
|
224
|
+
y: a.y + b.y,
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Multiply a point by a scalar.
|
|
230
|
+
*
|
|
231
|
+
* ●──▶ p
|
|
232
|
+
* ●──────▶ p * n
|
|
233
|
+
*/
|
|
234
|
+
export function pointMultiply(p: Point, n: number): Point {
|
|
235
|
+
return {
|
|
236
|
+
x: p.x * n,
|
|
237
|
+
y: p.y * n,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Normalize a point as a vector from the origin.
|
|
243
|
+
*
|
|
244
|
+
* ●────────▶ p
|
|
245
|
+
* ●──▶ norm(p)
|
|
246
|
+
*/
|
|
247
|
+
export function pointNorm(p: Point): Point {
|
|
248
|
+
const d = Math.hypot(p.x, p.y)
|
|
249
|
+
return {
|
|
250
|
+
x: d === 0 ? 0 : p.x / d,
|
|
251
|
+
y: d === 0 ? 0 : p.y / d,
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Compute the dot product of two points as vectors.
|
|
257
|
+
*
|
|
258
|
+
* B●
|
|
259
|
+
* ╱
|
|
260
|
+
* ╱
|
|
261
|
+
* ▔▔▔▔▔●A
|
|
262
|
+
*/
|
|
263
|
+
export function vectorDotProd(a: Point, b: Point): number {
|
|
264
|
+
return a.x * b.x + a.y * b.y
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Clamp a number between inclusive lower and upper bounds.
|
|
269
|
+
*/
|
|
270
|
+
export function clamp(n: number, lo: number, hi: number) {
|
|
271
|
+
return Math.max(lo, Math.min(hi, n))
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Round tiny floating point noise to zero and limit precision.
|
|
276
|
+
* @example
|
|
277
|
+
* ```
|
|
278
|
+
* 0.00000000001 -> 0
|
|
279
|
+
* 1.23456 -> 1.235
|
|
280
|
+
* 10 -> 10
|
|
281
|
+
* ```
|
|
282
|
+
*/
|
|
283
|
+
export function floatNorm(v: number): number {
|
|
284
|
+
return Math.abs(v) < 1e-10 ? 0 : +v.toFixed(3)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Convert radians to degrees.
|
|
289
|
+
*
|
|
290
|
+
* ```text
|
|
291
|
+
* π rad = 180°
|
|
292
|
+
* ```
|
|
293
|
+
*/
|
|
294
|
+
export function radToDeg(rad: Rad): Deg {
|
|
295
|
+
return deg((rad * 180) / Math.PI)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* @example
|
|
300
|
+
* ```ts
|
|
301
|
+
* C = (0,0)
|
|
302
|
+
* A = (0,2)
|
|
303
|
+
* B = (2,0)
|
|
304
|
+
* angle(C, A, B) // 90
|
|
305
|
+
* ```
|
|
306
|
+
* A
|
|
307
|
+
* ●
|
|
308
|
+
* │
|
|
309
|
+
* │ 90°
|
|
310
|
+
* ●─────● B
|
|
311
|
+
* C
|
|
312
|
+
*/
|
|
313
|
+
export function angle(center: Point, a: Point, b: Point): Deg {
|
|
314
|
+
const angA = radToDeg(rad(Math.atan2(a.y - center.y, a.x - center.x)))
|
|
315
|
+
const angB = radToDeg(rad(Math.atan2(b.y - center.y, b.x - center.x)))
|
|
316
|
+
return deg(Math.round(((((angA + angB + 180) % 360) + 360) % 360) - 180))
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* @returns Unrotated rectangle for box
|
|
321
|
+
*/
|
|
322
|
+
export function boxRect(box: Box): Rect {
|
|
323
|
+
return rect({
|
|
324
|
+
x: box.center.x - box.width / 2,
|
|
325
|
+
y: box.center.y - box.height / 2,
|
|
326
|
+
width: box.width,
|
|
327
|
+
height: box.height,
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* @returns Whether point is contained in box.
|
|
333
|
+
* @example
|
|
334
|
+
* ┌──┐
|
|
335
|
+
* │ ●│
|
|
336
|
+
* └──┘
|
|
337
|
+
*/
|
|
338
|
+
export function boxContainsPoint(target: Box, point: Point): boolean {
|
|
339
|
+
const p = rotatePoint(point, target.pivot, deg(-target.rotation))
|
|
340
|
+
const r = boxRect(target)
|
|
341
|
+
const x = floatNorm(p.x)
|
|
342
|
+
const y = floatNorm(p.y)
|
|
343
|
+
|
|
344
|
+
return (
|
|
345
|
+
x >= floatNorm(r.left) &&
|
|
346
|
+
x <= floatNorm(r.right) &&
|
|
347
|
+
y >= floatNorm(r.top) &&
|
|
348
|
+
y <= floatNorm(r.bottom)
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* @returns Four corners of a box.
|
|
354
|
+
* ⠀⠀ ●
|
|
355
|
+
* ⠀
|
|
356
|
+
* ● ●
|
|
357
|
+
* ⠀
|
|
358
|
+
* ⠀⠀⠀●
|
|
359
|
+
*/
|
|
360
|
+
function boxCorners(box: Box): [Point, Point, Point, Point] {
|
|
361
|
+
const [a, b, c, d] = rectCorners(boxRect(box)).map((corner) =>
|
|
362
|
+
rotatePoint(corner, box.pivot, box.rotation),
|
|
363
|
+
)
|
|
364
|
+
return [a, b, c, d]
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* @returns Perpendicular normalized axes for each box edge.
|
|
369
|
+
*/
|
|
370
|
+
function boxAxes(corners: Point[]): Point[] {
|
|
371
|
+
return corners
|
|
372
|
+
.map((corner, i) => pointSubtract(corners[(i + 1) % corners.length], corner))
|
|
373
|
+
.map((edge) => pointNorm({ x: -edge.y, y: edge.x }))
|
|
374
|
+
.filter((axis) => axis.x !== 0 || axis.y !== 0)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* @returns Smallest and largest scalar projections of points onto an axis.
|
|
379
|
+
*/
|
|
380
|
+
function projectPoints(points: Point[], axis: Point) {
|
|
381
|
+
let min = Infinity
|
|
382
|
+
let max = -Infinity
|
|
383
|
+
|
|
384
|
+
for (const point of points) {
|
|
385
|
+
const projection = floatNorm(vectorDotProd(point, axis))
|
|
386
|
+
if (projection < min) min = projection
|
|
387
|
+
if (projection > max) max = projection
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return { min, max }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* @returns Whether `a` covers any point from `b`.
|
|
395
|
+
*/
|
|
396
|
+
export function boxIntersects(a: Box, b: Box): boolean {
|
|
397
|
+
const ac = boxCorners(a)
|
|
398
|
+
const bc = boxCorners(b)
|
|
399
|
+
|
|
400
|
+
return [...boxAxes(ac), ...boxAxes(bc)].every((axis) => {
|
|
401
|
+
const ap = projectPoints(ac, axis)
|
|
402
|
+
const bp = projectPoints(bc, axis)
|
|
403
|
+
|
|
404
|
+
return ap.max >= bp.min && bp.max >= ap.min
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Perpendicular (signed) distance from point b to the infinite line
|
|
410
|
+
* that passes through point a with direction `deg` (degrees).
|
|
411
|
+
*/
|
|
412
|
+
export function perpDistance(a: Point, b: Point, deg: Deg): number {
|
|
413
|
+
const normal = rotatePoint(point({ y: 1 }), point(), deg)
|
|
414
|
+
const vector = pointSubtract(b, a)
|
|
415
|
+
return vectorDotProd(vector, normal)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Resize box by one of its edges.
|
|
420
|
+
* The center and pivot is shifted accordingly so that visually only the edge moves.
|
|
421
|
+
*/
|
|
422
|
+
export function resizeBox(box: Box, edge: Edge, by: number): Box {
|
|
423
|
+
const delta = by / 2
|
|
424
|
+
const direction =
|
|
425
|
+
edge === "n"
|
|
426
|
+
? { x: 0, y: -1 }
|
|
427
|
+
: edge === "s"
|
|
428
|
+
? { x: 0, y: 1 }
|
|
429
|
+
: edge === "w"
|
|
430
|
+
? { x: -1, y: 0 }
|
|
431
|
+
: { x: 1, y: 0 }
|
|
432
|
+
|
|
433
|
+
const shift = pointMultiply(rotatePoint(direction, point(), box.rotation), delta)
|
|
434
|
+
const center = pointAdd(box.center, shift)
|
|
435
|
+
const pivot = pointAdd(box.pivot, shift)
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
...box,
|
|
439
|
+
center,
|
|
440
|
+
pivot,
|
|
441
|
+
width: edge === "w" || edge === "e" ? box.width + by : box.width,
|
|
442
|
+
height: edge === "n" || edge === "s" ? box.height + by : box.height,
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Scale box by one of its edges keeping its aspect ratio.
|
|
448
|
+
* The center and pivot is shifted accordingly so that visually only the edge moves.
|
|
449
|
+
*/
|
|
450
|
+
export function scaleBox(box: Box, direction: Edge | Corner, by: number) {
|
|
451
|
+
const vertical = direction.includes("n") || direction.includes("s")
|
|
452
|
+
const scale =
|
|
453
|
+
vertical && box.height !== 0
|
|
454
|
+
? (box.height + by) / box.height
|
|
455
|
+
: box.width !== 0
|
|
456
|
+
? (box.width + by) / box.width
|
|
457
|
+
: 1
|
|
458
|
+
|
|
459
|
+
const width = box.width * scale
|
|
460
|
+
const height = box.height * scale
|
|
461
|
+
const widthDelta = width - box.width
|
|
462
|
+
const heightDelta = height - box.height
|
|
463
|
+
|
|
464
|
+
const isCorner = direction.length === 2
|
|
465
|
+
const north = direction.includes("n") ? heightDelta : 0
|
|
466
|
+
const south = direction.includes("s") ? heightDelta : 0
|
|
467
|
+
const west = direction.includes("w") ? widthDelta : 0
|
|
468
|
+
const east = direction.includes("e") ? widthDelta : 0
|
|
469
|
+
const x = (east - west) / 2
|
|
470
|
+
const y = (south - north) / 2
|
|
471
|
+
const edgeShift = isCorner ? { x, y } : vertical ? { x: 0, y } : { x, y: 0 }
|
|
472
|
+
|
|
473
|
+
const shift = rotatePoint(edgeShift, point(), box.rotation)
|
|
474
|
+
const center = pointAdd(box.center, shift)
|
|
475
|
+
const pivot = pointAdd(box.pivot, shift)
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
rotation: box.rotation,
|
|
479
|
+
center,
|
|
480
|
+
pivot,
|
|
481
|
+
width,
|
|
482
|
+
height,
|
|
483
|
+
}
|
|
484
|
+
}
|