Adds transforming, ellipse
This commit is contained in:
parent
d99507de5b
commit
b50045c9b7
21 changed files with 1223 additions and 158 deletions
|
@ -1,8 +1,8 @@
|
||||||
import state, { useSelector } from "state"
|
import state, { useSelector } from "state"
|
||||||
import { motion } from "framer-motion"
|
|
||||||
import styled from "styles"
|
import styled from "styles"
|
||||||
import inputs from "state/inputs"
|
import inputs from "state/inputs"
|
||||||
import { useRef } from "react"
|
import { useRef } from "react"
|
||||||
|
import { TransformCorner, TransformEdge } from "types"
|
||||||
|
|
||||||
export default function Bounds() {
|
export default function Bounds() {
|
||||||
const zoom = useSelector((state) => state.data.camera.zoom)
|
const zoom = useSelector((state) => state.data.camera.zoom)
|
||||||
|
@ -16,6 +16,8 @@ export default function Bounds() {
|
||||||
const p = 4 / zoom
|
const p = 4 / zoom
|
||||||
const cp = p * 2
|
const cp = p * 2
|
||||||
|
|
||||||
|
if (width < p || height < p) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g pointerEvents={isBrushing ? "none" : "all"}>
|
<g pointerEvents={isBrushing ? "none" : "all"}>
|
||||||
<StyledBounds
|
<StyledBounds
|
||||||
|
@ -27,61 +29,61 @@ export default function Bounds() {
|
||||||
/>
|
/>
|
||||||
{width * zoom > 8 && (
|
{width * zoom > 8 && (
|
||||||
<>
|
<>
|
||||||
<Corner
|
|
||||||
x={minX}
|
|
||||||
y={minY}
|
|
||||||
width={cp}
|
|
||||||
height={cp}
|
|
||||||
corner="top_left_corner"
|
|
||||||
/>
|
|
||||||
<Corner
|
|
||||||
x={maxX}
|
|
||||||
y={minY}
|
|
||||||
width={cp}
|
|
||||||
height={cp}
|
|
||||||
corner="top_right_corner"
|
|
||||||
/>
|
|
||||||
<Corner
|
|
||||||
x={maxX}
|
|
||||||
y={maxY}
|
|
||||||
width={cp}
|
|
||||||
height={cp}
|
|
||||||
corner="bottom_right_corner"
|
|
||||||
/>
|
|
||||||
<Corner
|
|
||||||
x={minX}
|
|
||||||
y={maxY}
|
|
||||||
width={cp}
|
|
||||||
height={cp}
|
|
||||||
corner="bottom_left_corner"
|
|
||||||
/>
|
|
||||||
<EdgeHorizontal
|
<EdgeHorizontal
|
||||||
x={minX + p}
|
x={minX + p}
|
||||||
y={minY}
|
y={minY}
|
||||||
width={Math.max(0, width - p * 2)}
|
width={Math.max(0, width - p * 2)}
|
||||||
height={p}
|
height={p}
|
||||||
edge="top_edge"
|
edge={TransformEdge.Top}
|
||||||
/>
|
/>
|
||||||
<EdgeVertical
|
<EdgeVertical
|
||||||
x={maxX}
|
x={maxX}
|
||||||
y={minY + p}
|
y={minY + p}
|
||||||
width={p}
|
width={p}
|
||||||
height={Math.max(0, height - p * 2)}
|
height={Math.max(0, height - p * 2)}
|
||||||
edge="right_edge"
|
edge={TransformEdge.Right}
|
||||||
/>
|
/>
|
||||||
<EdgeHorizontal
|
<EdgeHorizontal
|
||||||
x={minX + p}
|
x={minX + p}
|
||||||
y={maxY}
|
y={maxY}
|
||||||
width={Math.max(0, width - p * 2)}
|
width={Math.max(0, width - p * 2)}
|
||||||
height={p}
|
height={p}
|
||||||
edge="bottom_edge"
|
edge={TransformEdge.Bottom}
|
||||||
/>
|
/>
|
||||||
<EdgeVertical
|
<EdgeVertical
|
||||||
x={minX}
|
x={minX}
|
||||||
y={minY + p}
|
y={minY + p}
|
||||||
width={p}
|
width={p}
|
||||||
height={Math.max(0, height - p * 2)}
|
height={Math.max(0, height - p * 2)}
|
||||||
edge="left_edge"
|
edge={TransformEdge.Left}
|
||||||
|
/>
|
||||||
|
<Corner
|
||||||
|
x={minX}
|
||||||
|
y={minY}
|
||||||
|
width={cp}
|
||||||
|
height={cp}
|
||||||
|
corner={TransformCorner.TopLeft}
|
||||||
|
/>
|
||||||
|
<Corner
|
||||||
|
x={maxX}
|
||||||
|
y={minY}
|
||||||
|
width={cp}
|
||||||
|
height={cp}
|
||||||
|
corner={TransformCorner.TopRight}
|
||||||
|
/>
|
||||||
|
<Corner
|
||||||
|
x={maxX}
|
||||||
|
y={maxY}
|
||||||
|
width={cp}
|
||||||
|
height={cp}
|
||||||
|
corner={TransformCorner.BottomRight}
|
||||||
|
/>
|
||||||
|
<Corner
|
||||||
|
x={minX}
|
||||||
|
y={maxY}
|
||||||
|
width={cp}
|
||||||
|
height={cp}
|
||||||
|
corner={TransformCorner.BottomLeft}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -100,11 +102,7 @@ function Corner({
|
||||||
y: number
|
y: number
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
corner:
|
corner: TransformCorner
|
||||||
| "top_left_corner"
|
|
||||||
| "top_right_corner"
|
|
||||||
| "bottom_right_corner"
|
|
||||||
| "bottom_left_corner"
|
|
||||||
}) {
|
}) {
|
||||||
const rRotateCorner = useRef<SVGRectElement>(null)
|
const rRotateCorner = useRef<SVGRectElement>(null)
|
||||||
const rCorner = useRef<SVGRectElement>(null)
|
const rCorner = useRef<SVGRectElement>(null)
|
||||||
|
@ -166,7 +164,7 @@ function EdgeHorizontal({
|
||||||
y: number
|
y: number
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
edge: "top_edge" | "bottom_edge"
|
edge: TransformEdge.Top | TransformEdge.Bottom
|
||||||
}) {
|
}) {
|
||||||
const rEdge = useRef<SVGRectElement>(null)
|
const rEdge = useRef<SVGRectElement>(null)
|
||||||
|
|
||||||
|
@ -205,7 +203,7 @@ function EdgeVertical({
|
||||||
y: number
|
y: number
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
edge: "right_edge" | "left_edge"
|
edge: TransformEdge.Right | TransformEdge.Left
|
||||||
}) {
|
}) {
|
||||||
const rEdge = useRef<SVGRectElement>(null)
|
const rEdge = useRef<SVGRectElement>(null)
|
||||||
|
|
||||||
|
@ -232,11 +230,6 @@ function EdgeVertical({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreCursor(e: PointerEvent) {
|
|
||||||
state.send("STOPPED_POINTING", { id: "bounds", ...inputs.pointerUp(e) })
|
|
||||||
document.body.style.cursor = "default"
|
|
||||||
}
|
|
||||||
|
|
||||||
const StyledEdge = styled("rect", {
|
const StyledEdge = styled("rect", {
|
||||||
stroke: "none",
|
stroke: "none",
|
||||||
fill: "none",
|
fill: "none",
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useCallback, useRef, memo } from "react"
|
import React, { useCallback, useRef, memo } from "react"
|
||||||
import state, { useSelector } from "state"
|
import state, { useSelector } from "state"
|
||||||
import inputs from "state/inputs"
|
import inputs from "state/inputs"
|
||||||
import shapes from "lib/shapes"
|
import { getShapeUtils } from "lib/shapes"
|
||||||
import styled from "styles"
|
import styled from "styles"
|
||||||
|
|
||||||
function Shape({ id }: { id: string }) {
|
function Shape({ id }: { id: string }) {
|
||||||
|
@ -41,7 +41,6 @@ function Shape({ id }: { id: string }) {
|
||||||
(e: React.PointerEvent) => state.send("UNHOVERED_SHAPE", { id }),
|
(e: React.PointerEvent) => state.send("UNHOVERED_SHAPE", { id }),
|
||||||
[id]
|
[id]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledGroup
|
<StyledGroup
|
||||||
ref={rGroup}
|
ref={rGroup}
|
||||||
|
@ -52,9 +51,7 @@ function Shape({ id }: { id: string }) {
|
||||||
onPointerEnter={handlePointerEnter}
|
onPointerEnter={handlePointerEnter}
|
||||||
onPointerLeave={handlePointerLeave}
|
onPointerLeave={handlePointerLeave}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>{getShapeUtils(shape).render(shape)}</defs>
|
||||||
{shapes[shape.type] ? shapes[shape.type].render(shape) : null}
|
|
||||||
</defs>
|
|
||||||
<HoverIndicator as="use" xlinkHref={"#" + id} />
|
<HoverIndicator as="use" xlinkHref={"#" + id} />
|
||||||
<use xlinkHref={"#" + id} {...shape.style} />
|
<use xlinkHref={"#" + id} {...shape.style} />
|
||||||
<Indicator as="use" xlinkHref={"#" + id} />
|
<Indicator as="use" xlinkHref={"#" + id} />
|
||||||
|
@ -65,7 +62,7 @@ function Shape({ id }: { id: string }) {
|
||||||
const Indicator = styled("path", {
|
const Indicator = styled("path", {
|
||||||
fill: "none",
|
fill: "none",
|
||||||
stroke: "transparent",
|
stroke: "transparent",
|
||||||
zStrokeWidth: 1,
|
zStrokeWidth: [1, 1],
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
strokeLineCap: "round",
|
strokeLineCap: "round",
|
||||||
strokeLinejoin: "round",
|
strokeLinejoin: "round",
|
||||||
|
|
|
@ -1,19 +0,0 @@
|
||||||
import { Bounds, Shape } from "types"
|
|
||||||
|
|
||||||
export default interface BaseLibShape<K extends Shape> {
|
|
||||||
create(props: Partial<K>): K
|
|
||||||
getBounds(this: BaseLibShape<K>, shape: K): Bounds
|
|
||||||
hitTest(this: BaseLibShape<K>, shape: K, test: number[]): boolean
|
|
||||||
hitTestBounds(this: BaseLibShape<K>, shape: K, bounds: Bounds): boolean
|
|
||||||
rotate(this: BaseLibShape<K>, shape: K): K
|
|
||||||
translate(this: BaseLibShape<K>, shape: K, delta: number[]): K
|
|
||||||
scale(this: BaseLibShape<K>, shape: K, scale: number): K
|
|
||||||
stretch(this: BaseLibShape<K>, shape: K, scaleX: number, scaleY: number): K
|
|
||||||
render(this: BaseLibShape<K>, shape: K): JSX.Element
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createShape<T extends Shape>(
|
|
||||||
shape: BaseLibShape<T>
|
|
||||||
): BaseLibShape<T> {
|
|
||||||
return shape
|
|
||||||
}
|
|
|
@ -1,12 +1,13 @@
|
||||||
import { v4 as uuid } from "uuid"
|
import { v4 as uuid } from "uuid"
|
||||||
import * as vec from "utils/vec"
|
import * as vec from "utils/vec"
|
||||||
import { CircleShape, ShapeType } from "types"
|
import { CircleShape, ShapeType } from "types"
|
||||||
import { boundsCache } from "./index"
|
import { createShape } from "./index"
|
||||||
import { boundsContained } from "utils/bounds"
|
import { boundsContained } from "utils/bounds"
|
||||||
import { intersectCircleBounds } from "utils/intersections"
|
import { intersectCircleBounds } from "utils/intersections"
|
||||||
import { createShape } from "./base-shape"
|
|
||||||
|
|
||||||
const circle = createShape<CircleShape>({
|
const circle = createShape<CircleShape>({
|
||||||
|
boundsCache: new WeakMap([]),
|
||||||
|
|
||||||
create(props) {
|
create(props) {
|
||||||
return {
|
return {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
|
@ -27,8 +28,8 @@ const circle = createShape<CircleShape>({
|
||||||
},
|
},
|
||||||
|
|
||||||
getBounds(shape) {
|
getBounds(shape) {
|
||||||
if (boundsCache.has(shape)) {
|
if (this.boundsCache.has(shape)) {
|
||||||
return boundsCache.get(shape)
|
return this.boundsCache.get(shape)
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
@ -45,7 +46,8 @@ const circle = createShape<CircleShape>({
|
||||||
height: radius * 2,
|
height: radius * 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
boundsCache.set(shape, bounds)
|
this.boundsCache.set(shape, bounds)
|
||||||
|
|
||||||
return bounds
|
return bounds
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -84,6 +86,13 @@ const circle = createShape<CircleShape>({
|
||||||
stretch(shape, scaleX, scaleY) {
|
stretch(shape, scaleX, scaleY) {
|
||||||
return shape
|
return shape
|
||||||
},
|
},
|
||||||
|
|
||||||
|
transform(shape, bounds) {
|
||||||
|
shape.point = [bounds.minX, bounds.minY]
|
||||||
|
shape.radius = Math.min(bounds.width, bounds.height) / 2
|
||||||
|
|
||||||
|
return shape
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export default circle
|
export default circle
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
import { v4 as uuid } from "uuid"
|
import { v4 as uuid } from "uuid"
|
||||||
import * as vec from "utils/vec"
|
import * as vec from "utils/vec"
|
||||||
import { DotShape, ShapeType } from "types"
|
import { DotShape, ShapeType } from "types"
|
||||||
import { boundsCache } from "./index"
|
import { createShape } from "./index"
|
||||||
import { boundsContained } from "utils/bounds"
|
import { boundsContained } from "utils/bounds"
|
||||||
import { intersectCircleBounds } from "utils/intersections"
|
import { intersectCircleBounds } from "utils/intersections"
|
||||||
import { createShape } from "./base-shape"
|
|
||||||
|
|
||||||
const dot = createShape<DotShape>({
|
const dot = createShape<DotShape>({
|
||||||
|
boundsCache: new WeakMap([]),
|
||||||
|
|
||||||
create(props) {
|
create(props) {
|
||||||
return {
|
return {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
|
@ -22,12 +23,12 @@ const dot = createShape<DotShape>({
|
||||||
},
|
},
|
||||||
|
|
||||||
render({ id }) {
|
render({ id }) {
|
||||||
return <circle id={id} cx={4} cy={4} r={4} />
|
return <circle id={id} cx={0} cy={0} r={4} />
|
||||||
},
|
},
|
||||||
|
|
||||||
getBounds(shape) {
|
getBounds(shape) {
|
||||||
if (boundsCache.has(shape)) {
|
if (this.boundsCache.has(shape)) {
|
||||||
return boundsCache.get(shape)
|
return this.boundsCache.get(shape)
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
@ -36,14 +37,15 @@ const dot = createShape<DotShape>({
|
||||||
|
|
||||||
const bounds = {
|
const bounds = {
|
||||||
minX: x,
|
minX: x,
|
||||||
maxX: x + 8,
|
maxX: x + 1,
|
||||||
minY: y,
|
minY: y,
|
||||||
maxY: y + 8,
|
maxY: y + 1,
|
||||||
width: 8,
|
width: 1,
|
||||||
height: 8,
|
height: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
boundsCache.set(shape, bounds)
|
this.boundsCache.set(shape, bounds)
|
||||||
|
|
||||||
return bounds
|
return bounds
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -75,6 +77,12 @@ const dot = createShape<DotShape>({
|
||||||
stretch(shape, scaleX: number, scaleY: number) {
|
stretch(shape, scaleX: number, scaleY: number) {
|
||||||
return shape
|
return shape
|
||||||
},
|
},
|
||||||
|
|
||||||
|
transform(shape, bounds) {
|
||||||
|
shape.point = [bounds.minX, bounds.minY]
|
||||||
|
|
||||||
|
return shape
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export default dot
|
export default dot
|
||||||
|
|
103
lib/shapes/ellipse.tsx
Normal file
103
lib/shapes/ellipse.tsx
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
import { v4 as uuid } from "uuid"
|
||||||
|
import * as vec from "utils/vec"
|
||||||
|
import { EllipseShape, ShapeType } from "types"
|
||||||
|
import { createShape } from "./index"
|
||||||
|
import { boundsContained } from "utils/bounds"
|
||||||
|
import { intersectEllipseBounds } from "utils/intersections"
|
||||||
|
import { pointInEllipse } from "utils/hitTests"
|
||||||
|
|
||||||
|
const ellipse = createShape<EllipseShape>({
|
||||||
|
boundsCache: new WeakMap([]),
|
||||||
|
|
||||||
|
create(props) {
|
||||||
|
return {
|
||||||
|
id: uuid(),
|
||||||
|
type: ShapeType.Ellipse,
|
||||||
|
name: "Ellipse",
|
||||||
|
parentId: "page0",
|
||||||
|
childIndex: 0,
|
||||||
|
point: [0, 0],
|
||||||
|
radiusX: 20,
|
||||||
|
radiusY: 20,
|
||||||
|
rotation: 0,
|
||||||
|
style: {},
|
||||||
|
...props,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render({ id, radiusX, radiusY }) {
|
||||||
|
return (
|
||||||
|
<ellipse id={id} cx={radiusX} cy={radiusY} rx={radiusX} ry={radiusY} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
getBounds(shape) {
|
||||||
|
if (this.boundsCache.has(shape)) {
|
||||||
|
return this.boundsCache.get(shape)
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
point: [x, y],
|
||||||
|
radiusX,
|
||||||
|
radiusY,
|
||||||
|
} = shape
|
||||||
|
|
||||||
|
const bounds = {
|
||||||
|
minX: x,
|
||||||
|
maxX: x + radiusX * 2,
|
||||||
|
minY: y,
|
||||||
|
maxY: y + radiusY * 2,
|
||||||
|
width: radiusX * 2,
|
||||||
|
height: radiusY * 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.boundsCache.set(shape, bounds)
|
||||||
|
|
||||||
|
return bounds
|
||||||
|
},
|
||||||
|
|
||||||
|
hitTest(shape, point) {
|
||||||
|
return pointInEllipse(point, shape.point, shape.radiusX, shape.radiusY)
|
||||||
|
},
|
||||||
|
|
||||||
|
hitTestBounds(this, shape, brushBounds) {
|
||||||
|
const shapeBounds = this.getBounds(shape)
|
||||||
|
|
||||||
|
return (
|
||||||
|
boundsContained(shapeBounds, brushBounds) ||
|
||||||
|
intersectEllipseBounds(
|
||||||
|
vec.add(shape.point, [shape.radiusX, shape.radiusY]),
|
||||||
|
shape.radiusX,
|
||||||
|
shape.radiusY,
|
||||||
|
brushBounds
|
||||||
|
).length > 0
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
rotate(shape) {
|
||||||
|
return shape
|
||||||
|
},
|
||||||
|
|
||||||
|
translate(shape, delta) {
|
||||||
|
shape.point = vec.add(shape.point, delta)
|
||||||
|
return shape
|
||||||
|
},
|
||||||
|
|
||||||
|
scale(shape, scale: number) {
|
||||||
|
return shape
|
||||||
|
},
|
||||||
|
|
||||||
|
stretch(shape, scaleX: number, scaleY: number) {
|
||||||
|
return shape
|
||||||
|
},
|
||||||
|
|
||||||
|
transform(shape, bounds) {
|
||||||
|
shape.point = [bounds.minX, bounds.minY]
|
||||||
|
shape.radiusX = bounds.width / 2
|
||||||
|
shape.radiusY = bounds.height / 2
|
||||||
|
|
||||||
|
return shape
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default ellipse
|
|
@ -1,20 +1,87 @@
|
||||||
import Circle from "./circle"
|
import { Bounds, BoundsSnapshot, Shape, Shapes, ShapeType } from "types"
|
||||||
import Dot from "./dot"
|
import circle from "./circle"
|
||||||
import Polyline from "./polyline"
|
import dot from "./dot"
|
||||||
import Rectangle from "./rectangle"
|
import polyline from "./polyline"
|
||||||
|
import rectangle from "./rectangle"
|
||||||
|
import ellipse from "./ellipse"
|
||||||
|
import line from "./line"
|
||||||
|
import ray from "./ray"
|
||||||
|
|
||||||
import { Bounds, Shape, ShapeType } from "types"
|
/*
|
||||||
|
Shape Utiliies
|
||||||
|
|
||||||
export const boundsCache = new WeakMap<Shape, Bounds>([])
|
A shape utility is an object containing utility methods for each type of shape
|
||||||
|
in the application. While shapes may be very different, each shape must support
|
||||||
|
a common set of utility methods, such as hit tests or translations, that
|
||||||
|
|
||||||
const shapes = {
|
Operations throughout the app will call these utility methods
|
||||||
[ShapeType.Circle]: Circle,
|
when performing tests (such as hit tests) or mutations, such as translations.
|
||||||
[ShapeType.Dot]: Dot,
|
*/
|
||||||
[ShapeType.Polyline]: Polyline,
|
|
||||||
[ShapeType.Rectangle]: Rectangle,
|
export interface ShapeUtility<K extends Shape> {
|
||||||
[ShapeType.Ellipse]: Rectangle,
|
// A cache for the computed bounds of this kind of shape.
|
||||||
[ShapeType.Line]: Rectangle,
|
boundsCache: WeakMap<K, Bounds>
|
||||||
[ShapeType.Ray]: Rectangle,
|
|
||||||
|
// Create a new shape.
|
||||||
|
create(props: Partial<K>): K
|
||||||
|
|
||||||
|
// Get the bounds of the a shape.
|
||||||
|
getBounds(this: ShapeUtility<K>, shape: K): Bounds
|
||||||
|
|
||||||
|
// Test whether a point lies within a shape.
|
||||||
|
hitTest(this: ShapeUtility<K>, shape: K, test: number[]): boolean
|
||||||
|
|
||||||
|
// Test whether bounds collide with or contain a shape.
|
||||||
|
hitTestBounds(this: ShapeUtility<K>, shape: K, bounds: Bounds): boolean
|
||||||
|
|
||||||
|
// Apply a rotation to a shape.
|
||||||
|
rotate(this: ShapeUtility<K>, shape: K): K
|
||||||
|
|
||||||
|
// Apply a translation to a shape.
|
||||||
|
translate(this: ShapeUtility<K>, shape: K, delta: number[]): K
|
||||||
|
|
||||||
|
// Transform to fit a new bounding box.
|
||||||
|
transform(this: ShapeUtility<K>, shape: K, bounds: Bounds): K
|
||||||
|
|
||||||
|
// Apply a scale to a shape.
|
||||||
|
scale(this: ShapeUtility<K>, shape: K, scale: number): K
|
||||||
|
|
||||||
|
// Apply a stretch to a shape.
|
||||||
|
stretch(this: ShapeUtility<K>, shape: K, scaleX: number, scaleY: number): K
|
||||||
|
|
||||||
|
// Render a shape to JSX.
|
||||||
|
render(this: ShapeUtility<K>, shape: K): JSX.Element
|
||||||
}
|
}
|
||||||
|
|
||||||
export default shapes
|
// A mapping of shape types to shape utilities.
|
||||||
|
const shapeUtilityMap: { [key in ShapeType]: ShapeUtility<Shapes[key]> } = {
|
||||||
|
[ShapeType.Circle]: circle,
|
||||||
|
[ShapeType.Dot]: dot,
|
||||||
|
[ShapeType.Polyline]: polyline,
|
||||||
|
[ShapeType.Rectangle]: rectangle,
|
||||||
|
[ShapeType.Ellipse]: ellipse,
|
||||||
|
[ShapeType.Line]: line,
|
||||||
|
[ShapeType.Ray]: ray,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper to retrieve a shape utility based on a shape object.
|
||||||
|
* @param shape
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function getShapeUtils(shape: Shape): ShapeUtility<typeof shape> {
|
||||||
|
return shapeUtilityMap[shape.type]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A factory of shape utilities, with typing enforced.
|
||||||
|
* @param shape
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function createShape<T extends Shape>(
|
||||||
|
shape: ShapeUtility<T>
|
||||||
|
): ShapeUtility<T> {
|
||||||
|
return Object.freeze(shape)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default shapeUtilityMap
|
||||||
|
|
87
lib/shapes/line.tsx
Normal file
87
lib/shapes/line.tsx
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
import { v4 as uuid } from "uuid"
|
||||||
|
import * as vec from "utils/vec"
|
||||||
|
import { LineShape, ShapeType } from "types"
|
||||||
|
import { createShape } from "./index"
|
||||||
|
import { boundsContained } from "utils/bounds"
|
||||||
|
import { intersectCircleBounds } from "utils/intersections"
|
||||||
|
|
||||||
|
const line = createShape<LineShape>({
|
||||||
|
boundsCache: new WeakMap([]),
|
||||||
|
|
||||||
|
create(props) {
|
||||||
|
return {
|
||||||
|
id: uuid(),
|
||||||
|
type: ShapeType.Line,
|
||||||
|
name: "Line",
|
||||||
|
parentId: "page0",
|
||||||
|
childIndex: 0,
|
||||||
|
point: [0, 0],
|
||||||
|
vector: [0, 0],
|
||||||
|
rotation: 0,
|
||||||
|
style: {},
|
||||||
|
...props,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render({ id }) {
|
||||||
|
return <circle id={id} cx={4} cy={4} r={4} />
|
||||||
|
},
|
||||||
|
|
||||||
|
getBounds(shape) {
|
||||||
|
if (this.boundsCache.has(shape)) {
|
||||||
|
return this.boundsCache.get(shape)
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
point: [x, y],
|
||||||
|
} = shape
|
||||||
|
|
||||||
|
const bounds = {
|
||||||
|
minX: x,
|
||||||
|
maxX: x + 8,
|
||||||
|
minY: y,
|
||||||
|
maxY: y + 8,
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.boundsCache.set(shape, bounds)
|
||||||
|
|
||||||
|
return bounds
|
||||||
|
},
|
||||||
|
|
||||||
|
hitTest(shape, test) {
|
||||||
|
return vec.dist(shape.point, test) < 4
|
||||||
|
},
|
||||||
|
|
||||||
|
hitTestBounds(this, shape, brushBounds) {
|
||||||
|
const shapeBounds = this.getBounds(shape)
|
||||||
|
return (
|
||||||
|
boundsContained(shapeBounds, brushBounds) ||
|
||||||
|
intersectCircleBounds(shape.point, 4, brushBounds).length > 0
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
rotate(shape) {
|
||||||
|
return shape
|
||||||
|
},
|
||||||
|
|
||||||
|
translate(shape, delta) {
|
||||||
|
shape.point = vec.add(shape.point, delta)
|
||||||
|
return shape
|
||||||
|
},
|
||||||
|
|
||||||
|
scale(shape, scale: number) {
|
||||||
|
return shape
|
||||||
|
},
|
||||||
|
|
||||||
|
stretch(shape, scaleX: number, scaleY: number) {
|
||||||
|
return shape
|
||||||
|
},
|
||||||
|
|
||||||
|
transform(shape, bounds) {
|
||||||
|
return shape
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default line
|
|
@ -1,12 +1,13 @@
|
||||||
import { v4 as uuid } from "uuid"
|
import { v4 as uuid } from "uuid"
|
||||||
import * as vec from "utils/vec"
|
import * as vec from "utils/vec"
|
||||||
import { PolylineShape, ShapeType } from "types"
|
import { PolylineShape, ShapeType } from "types"
|
||||||
import { boundsCache } from "./index"
|
import { createShape } from "./index"
|
||||||
import { intersectPolylineBounds } from "utils/intersections"
|
import { intersectPolylineBounds } from "utils/intersections"
|
||||||
import { boundsCollide, boundsContained } from "utils/bounds"
|
import { boundsCollide, boundsContained } from "utils/bounds"
|
||||||
import { createShape } from "./base-shape"
|
|
||||||
|
|
||||||
const polyline = createShape<PolylineShape>({
|
const polyline = createShape<PolylineShape>({
|
||||||
|
boundsCache: new WeakMap([]),
|
||||||
|
|
||||||
create(props) {
|
create(props) {
|
||||||
return {
|
return {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
|
@ -27,8 +28,8 @@ const polyline = createShape<PolylineShape>({
|
||||||
},
|
},
|
||||||
|
|
||||||
getBounds(shape) {
|
getBounds(shape) {
|
||||||
if (boundsCache.has(shape)) {
|
if (this.boundsCache.has(shape)) {
|
||||||
return boundsCache.get(shape)
|
return this.boundsCache.get(shape)
|
||||||
}
|
}
|
||||||
|
|
||||||
let minX = 0
|
let minX = 0
|
||||||
|
@ -52,7 +53,7 @@ const polyline = createShape<PolylineShape>({
|
||||||
height: maxY - minY,
|
height: maxY - minY,
|
||||||
}
|
}
|
||||||
|
|
||||||
boundsCache.set(shape, bounds)
|
this.boundsCache.set(shape, bounds)
|
||||||
return bounds
|
return bounds
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -88,6 +89,21 @@ const polyline = createShape<PolylineShape>({
|
||||||
stretch(shape, scaleX: number, scaleY: number) {
|
stretch(shape, scaleX: number, scaleY: number) {
|
||||||
return shape
|
return shape
|
||||||
},
|
},
|
||||||
|
|
||||||
|
transform(shape, bounds) {
|
||||||
|
const currentBounds = this.getBounds(shape)
|
||||||
|
|
||||||
|
const scaleX = bounds.width / currentBounds.width
|
||||||
|
const scaleY = bounds.height / currentBounds.height
|
||||||
|
|
||||||
|
shape.points = shape.points.map((point) => {
|
||||||
|
let pt = vec.mulV(point, [scaleX, scaleY])
|
||||||
|
return pt
|
||||||
|
})
|
||||||
|
|
||||||
|
shape.point = [bounds.minX, bounds.minY]
|
||||||
|
return shape
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export default polyline
|
export default polyline
|
||||||
|
|
87
lib/shapes/ray.tsx
Normal file
87
lib/shapes/ray.tsx
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
import { v4 as uuid } from "uuid"
|
||||||
|
import * as vec from "utils/vec"
|
||||||
|
import { RayShape, ShapeType } from "types"
|
||||||
|
import { createShape } from "./index"
|
||||||
|
import { boundsContained } from "utils/bounds"
|
||||||
|
import { intersectCircleBounds } from "utils/intersections"
|
||||||
|
|
||||||
|
const ray = createShape<RayShape>({
|
||||||
|
boundsCache: new WeakMap([]),
|
||||||
|
|
||||||
|
create(props) {
|
||||||
|
return {
|
||||||
|
id: uuid(),
|
||||||
|
type: ShapeType.Ray,
|
||||||
|
name: "Ray",
|
||||||
|
parentId: "page0",
|
||||||
|
childIndex: 0,
|
||||||
|
point: [0, 0],
|
||||||
|
vector: [0, 0],
|
||||||
|
rotation: 0,
|
||||||
|
style: {},
|
||||||
|
...props,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render({ id }) {
|
||||||
|
return <circle id={id} cx={4} cy={4} r={4} />
|
||||||
|
},
|
||||||
|
|
||||||
|
getBounds(shape) {
|
||||||
|
if (this.boundsCache.has(shape)) {
|
||||||
|
return this.boundsCache.get(shape)
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
point: [x, y],
|
||||||
|
} = shape
|
||||||
|
|
||||||
|
const bounds = {
|
||||||
|
minX: x,
|
||||||
|
maxX: x + 8,
|
||||||
|
minY: y,
|
||||||
|
maxY: y + 8,
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.boundsCache.set(shape, bounds)
|
||||||
|
|
||||||
|
return bounds
|
||||||
|
},
|
||||||
|
|
||||||
|
hitTest(shape, test) {
|
||||||
|
return vec.dist(shape.point, test) < 4
|
||||||
|
},
|
||||||
|
|
||||||
|
hitTestBounds(this, shape, brushBounds) {
|
||||||
|
const shapeBounds = this.getBounds(shape)
|
||||||
|
return (
|
||||||
|
boundsContained(shapeBounds, brushBounds) ||
|
||||||
|
intersectCircleBounds(shape.point, 4, brushBounds).length > 0
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
rotate(shape) {
|
||||||
|
return shape
|
||||||
|
},
|
||||||
|
|
||||||
|
translate(shape, delta) {
|
||||||
|
shape.point = vec.add(shape.point, delta)
|
||||||
|
return shape
|
||||||
|
},
|
||||||
|
|
||||||
|
scale(shape, scale: number) {
|
||||||
|
return shape
|
||||||
|
},
|
||||||
|
|
||||||
|
stretch(shape, scaleX: number, scaleY: number) {
|
||||||
|
return shape
|
||||||
|
},
|
||||||
|
|
||||||
|
transform(shape, bounds) {
|
||||||
|
return shape
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default ray
|
|
@ -1,11 +1,12 @@
|
||||||
import { v4 as uuid } from "uuid"
|
import { v4 as uuid } from "uuid"
|
||||||
import * as vec from "utils/vec"
|
import * as vec from "utils/vec"
|
||||||
import { RectangleShape, ShapeType } from "types"
|
import { RectangleShape, ShapeType } from "types"
|
||||||
import { boundsCache } from "./index"
|
import { createShape } from "./index"
|
||||||
import { boundsContained, boundsCollide } from "utils/bounds"
|
import { boundsContained, boundsCollide } from "utils/bounds"
|
||||||
import { createShape } from "./base-shape"
|
|
||||||
|
|
||||||
const rectangle = createShape<RectangleShape>({
|
const rectangle = createShape<RectangleShape>({
|
||||||
|
boundsCache: new WeakMap([]),
|
||||||
|
|
||||||
create(props) {
|
create(props) {
|
||||||
return {
|
return {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
|
@ -26,8 +27,8 @@ const rectangle = createShape<RectangleShape>({
|
||||||
},
|
},
|
||||||
|
|
||||||
getBounds(shape) {
|
getBounds(shape) {
|
||||||
if (boundsCache.has(shape)) {
|
if (this.boundsCache.has(shape)) {
|
||||||
return boundsCache.get(shape)
|
return this.boundsCache.get(shape)
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
@ -44,7 +45,8 @@ const rectangle = createShape<RectangleShape>({
|
||||||
height,
|
height,
|
||||||
}
|
}
|
||||||
|
|
||||||
boundsCache.set(shape, bounds)
|
this.boundsCache.set(shape, bounds)
|
||||||
|
|
||||||
return bounds
|
return bounds
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -74,6 +76,14 @@ const rectangle = createShape<RectangleShape>({
|
||||||
},
|
},
|
||||||
|
|
||||||
stretch(shape, scaleX, scaleY) {
|
stretch(shape, scaleX, scaleY) {
|
||||||
|
shape.size = vec.mulV(shape.size, [scaleX, scaleY])
|
||||||
|
return shape
|
||||||
|
},
|
||||||
|
|
||||||
|
transform(shape, bounds) {
|
||||||
|
shape.point = [bounds.minX, bounds.minY]
|
||||||
|
shape.size = [bounds.width, bounds.height]
|
||||||
|
|
||||||
return shape
|
return shape
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Data, ShapeType } from "types"
|
import { Data, ShapeType } from "types"
|
||||||
import Shapes from "lib/shapes"
|
import shapeUtils from "lib/shapes"
|
||||||
|
|
||||||
export const defaultDocument: Data["document"] = {
|
export const defaultDocument: Data["document"] = {
|
||||||
pages: {
|
pages: {
|
||||||
|
@ -9,7 +9,7 @@ export const defaultDocument: Data["document"] = {
|
||||||
name: "Page 0",
|
name: "Page 0",
|
||||||
childIndex: 0,
|
childIndex: 0,
|
||||||
shapes: {
|
shapes: {
|
||||||
shape3: Shapes[ShapeType.Dot].create({
|
shape3: shapeUtils[ShapeType.Dot].create({
|
||||||
id: "shape3",
|
id: "shape3",
|
||||||
name: "Shape 3",
|
name: "Shape 3",
|
||||||
childIndex: 3,
|
childIndex: 3,
|
||||||
|
@ -20,7 +20,7 @@ export const defaultDocument: Data["document"] = {
|
||||||
strokeWidth: 1,
|
strokeWidth: 1,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
shape0: Shapes[ShapeType.Circle].create({
|
shape0: shapeUtils[ShapeType.Circle].create({
|
||||||
id: "shape0",
|
id: "shape0",
|
||||||
name: "Shape 0",
|
name: "Shape 0",
|
||||||
childIndex: 1,
|
childIndex: 1,
|
||||||
|
@ -32,7 +32,20 @@ export const defaultDocument: Data["document"] = {
|
||||||
strokeWidth: 1,
|
strokeWidth: 1,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
shape2: Shapes[ShapeType.Polyline].create({
|
shape5: shapeUtils[ShapeType.Ellipse].create({
|
||||||
|
id: "shape5",
|
||||||
|
name: "Shape 5",
|
||||||
|
childIndex: 5,
|
||||||
|
point: [250, 100],
|
||||||
|
radiusX: 50,
|
||||||
|
radiusY: 30,
|
||||||
|
style: {
|
||||||
|
fill: "#aaa",
|
||||||
|
stroke: "#777",
|
||||||
|
strokeWidth: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
shape2: shapeUtils[ShapeType.Polyline].create({
|
||||||
id: "shape2",
|
id: "shape2",
|
||||||
name: "Shape 2",
|
name: "Shape 2",
|
||||||
childIndex: 2,
|
childIndex: 2,
|
||||||
|
@ -50,7 +63,7 @@ export const defaultDocument: Data["document"] = {
|
||||||
strokeLinejoin: "round",
|
strokeLinejoin: "round",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
shape1: Shapes[ShapeType.Rectangle].create({
|
shape1: shapeUtils[ShapeType.Rectangle].create({
|
||||||
id: "shape1",
|
id: "shape1",
|
||||||
name: "Shape 1",
|
name: "Shape 1",
|
||||||
childIndex: 1,
|
childIndex: 1,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { current } from "immer"
|
import { current } from "immer"
|
||||||
import { BaseLibShape, Bounds, Data, Shapes } from "types"
|
import { ShapeUtil, Bounds, Data, Shapes } from "types"
|
||||||
import BaseSession from "./base-session"
|
import BaseSession from "./base-session"
|
||||||
import shapes from "lib/shapes"
|
import shapes from "lib/shapes"
|
||||||
import { getBoundsFromPoints } from "utils/utils"
|
import { getBoundsFromPoints } from "utils/utils"
|
||||||
|
@ -68,7 +68,7 @@ export default class BrushSession extends BaseSession {
|
||||||
.map((shape) => ({
|
.map((shape) => ({
|
||||||
id: shape.id,
|
id: shape.id,
|
||||||
test: (brushBounds: Bounds): boolean =>
|
test: (brushBounds: Bounds): boolean =>
|
||||||
(shapes[shape.type] as BaseLibShape<
|
(shapes[shape.type] as ShapeUtil<
|
||||||
Shapes[typeof shape.type]
|
Shapes[typeof shape.type]
|
||||||
>).hitTestBounds(shape, brushBounds),
|
>).hitTestBounds(shape, brushBounds),
|
||||||
})),
|
})),
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import BaseSession from "./base-session"
|
import BaseSession from "./base-session"
|
||||||
import BrushSession from "./brush-session"
|
import BrushSession from "./brush-session"
|
||||||
import TranslateSession from "./translate-session"
|
import TranslateSession from "./translate-session"
|
||||||
|
import TransformSession from "./transform-session"
|
||||||
|
|
||||||
export { BrushSession, BaseSession, TranslateSession }
|
export { BrushSession, BaseSession, TranslateSession, TransformSession }
|
||||||
|
|
217
state/sessions/transform-session.ts
Normal file
217
state/sessions/transform-session.ts
Normal file
|
@ -0,0 +1,217 @@
|
||||||
|
import { Data, TransformEdge, TransformCorner, Bounds } from "types"
|
||||||
|
import * as vec from "utils/vec"
|
||||||
|
import BaseSession from "./base-session"
|
||||||
|
import commands from "state/commands"
|
||||||
|
import { current } from "immer"
|
||||||
|
import { getShapeUtils } from "lib/shapes"
|
||||||
|
import { getCommonBounds } from "utils/utils"
|
||||||
|
|
||||||
|
export default class TransformSession extends BaseSession {
|
||||||
|
delta = [0, 0]
|
||||||
|
transformType: TransformEdge | TransformCorner
|
||||||
|
origin: number[]
|
||||||
|
snapshot: TransformSnapshot
|
||||||
|
currentBounds: Bounds
|
||||||
|
corners: {
|
||||||
|
a: number[]
|
||||||
|
b: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
data: Data,
|
||||||
|
type: TransformCorner | TransformEdge,
|
||||||
|
point: number[]
|
||||||
|
) {
|
||||||
|
super(data)
|
||||||
|
this.origin = point
|
||||||
|
this.transformType = type
|
||||||
|
this.snapshot = getTransformSnapshot(data)
|
||||||
|
|
||||||
|
const { minX, minY, maxX, maxY } = this.snapshot.initialBounds
|
||||||
|
|
||||||
|
this.currentBounds = { ...this.snapshot.initialBounds }
|
||||||
|
|
||||||
|
this.corners = {
|
||||||
|
a: [minX, minY],
|
||||||
|
b: [maxX, maxY],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(data: Data, point: number[]) {
|
||||||
|
const { shapeBounds, currentPageId, selectedIds } = this.snapshot
|
||||||
|
const {
|
||||||
|
document: { pages },
|
||||||
|
} = data
|
||||||
|
|
||||||
|
let [x, y] = point
|
||||||
|
const { corners, transformType } = this
|
||||||
|
|
||||||
|
// Edge Transform
|
||||||
|
|
||||||
|
switch (transformType) {
|
||||||
|
case TransformEdge.Top: {
|
||||||
|
corners.a[1] = y
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case TransformEdge.Right: {
|
||||||
|
corners.b[0] = x
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case TransformEdge.Bottom: {
|
||||||
|
corners.b[1] = y
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case TransformEdge.Left: {
|
||||||
|
corners.a[0] = x
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case TransformCorner.TopLeft: {
|
||||||
|
corners.a[1] = y
|
||||||
|
corners.a[0] = x
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case TransformCorner.TopRight: {
|
||||||
|
corners.b[0] = x
|
||||||
|
corners.a[1] = y
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case TransformCorner.BottomRight: {
|
||||||
|
corners.b[1] = y
|
||||||
|
corners.b[0] = x
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case TransformCorner.BottomLeft: {
|
||||||
|
corners.a[0] = x
|
||||||
|
corners.b[1] = y
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newBounds = {
|
||||||
|
minX: Math.min(corners.a[0], corners.b[0]),
|
||||||
|
minY: Math.min(corners.a[1], corners.b[1]),
|
||||||
|
maxX: Math.max(corners.a[0], corners.b[0]),
|
||||||
|
maxY: Math.max(corners.a[1], corners.b[1]),
|
||||||
|
width: Math.abs(corners.b[0] - corners.a[0]),
|
||||||
|
height: Math.abs(corners.b[1] - corners.a[1]),
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFlippedX = corners.b[0] - corners.a[0] < 0
|
||||||
|
const isFlippedY = corners.b[1] - corners.a[1] < 0
|
||||||
|
|
||||||
|
// const dx = newBounds.minX - currentBounds.minX
|
||||||
|
// const dy = newBounds.minY - currentBounds.minY
|
||||||
|
// const scaleX = newBounds.width / currentBounds.width
|
||||||
|
// const scaleY = newBounds.height / currentBounds.height
|
||||||
|
|
||||||
|
this.currentBounds = newBounds
|
||||||
|
|
||||||
|
selectedIds.forEach((id) => {
|
||||||
|
const { nx, nmx, nw, ny, nmy, nh } = shapeBounds[id]
|
||||||
|
|
||||||
|
const minX = newBounds.minX + (isFlippedX ? nmx : nx) * newBounds.width
|
||||||
|
const minY = newBounds.minY + (isFlippedY ? nmy : ny) * newBounds.height
|
||||||
|
const width = nw * newBounds.width
|
||||||
|
const height = nh * newBounds.height
|
||||||
|
|
||||||
|
const shape = pages[currentPageId].shapes[id]
|
||||||
|
|
||||||
|
getShapeUtils(shape).transform(shape, {
|
||||||
|
minX,
|
||||||
|
minY,
|
||||||
|
maxX: minX + width,
|
||||||
|
maxY: minY + height,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
})
|
||||||
|
// utils.stretch(shape, scaleX, scaleY)
|
||||||
|
})
|
||||||
|
|
||||||
|
// switch (this.transformHandle) {
|
||||||
|
// case TransformEdge.Top:
|
||||||
|
// case TransformEdge.Left:
|
||||||
|
// case TransformEdge.Right:
|
||||||
|
// case TransformEdge.Bottom: {
|
||||||
|
// for (let id in shapeBounds) {
|
||||||
|
// const { ny, nmy, nh } = shapeBounds[id]
|
||||||
|
// const minY = v.my + (v.y1 < v.y0 ? nmy : ny) * v.mh
|
||||||
|
// const height = nh * v.mh
|
||||||
|
|
||||||
|
// const shape = pages[currentPageId].shapes[id]
|
||||||
|
|
||||||
|
// getShapeUtils(shape).transform(shape)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// case TransformCorner.TopLeft:
|
||||||
|
// case TransformCorner.TopRight:
|
||||||
|
// case TransformCorner.BottomLeft:
|
||||||
|
// case TransformCorner.BottomRight: {
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(data: Data) {
|
||||||
|
const { currentPageId } = this.snapshot
|
||||||
|
const { document } = data
|
||||||
|
|
||||||
|
// for (let id in shapes) {
|
||||||
|
// Restore shape using original bounds
|
||||||
|
// document.pages[currentPageId].shapes[id]
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
complete(data: Data) {
|
||||||
|
// commands.translate(data, this.snapshot, getTransformSnapshot(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTransformSnapshot(data: Data) {
|
||||||
|
const {
|
||||||
|
document: { pages },
|
||||||
|
selectedIds,
|
||||||
|
currentPageId,
|
||||||
|
} = current(data)
|
||||||
|
|
||||||
|
// A mapping of selected shapes and their bounds
|
||||||
|
const shapesBounds = Object.fromEntries(
|
||||||
|
Array.from(selectedIds.values()).map((id) => {
|
||||||
|
const shape = pages[currentPageId].shapes[id]
|
||||||
|
return [shape.id, getShapeUtils(shape).getBounds(shape)]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// The common (exterior) bounds of the selected shapes
|
||||||
|
const bounds = getCommonBounds(
|
||||||
|
...Array.from(selectedIds.values()).map((id) => {
|
||||||
|
const shape = pages[currentPageId].shapes[id]
|
||||||
|
return getShapeUtils(shape).getBounds(shape)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Return a mapping of shapes to bounds together with the relative
|
||||||
|
// positions of the shape's bounds within the common bounds shape.
|
||||||
|
return {
|
||||||
|
currentPageId,
|
||||||
|
initialBounds: bounds,
|
||||||
|
selectedIds: new Set(selectedIds),
|
||||||
|
shapeBounds: Object.fromEntries(
|
||||||
|
Array.from(selectedIds.values()).map((id) => {
|
||||||
|
const { minX, minY, width, height } = shapesBounds[id]
|
||||||
|
return [
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
...bounds,
|
||||||
|
nx: (minX - bounds.minX) / bounds.width,
|
||||||
|
ny: (minY - bounds.minY) / bounds.height,
|
||||||
|
nmx: 1 - (minX + width - bounds.minX) / bounds.width,
|
||||||
|
nmy: 1 - (minY + height - bounds.minY) / bounds.height,
|
||||||
|
nw: width / bounds.width,
|
||||||
|
nh: height / bounds.height,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TransformSnapshot = ReturnType<typeof getTransformSnapshot>
|
|
@ -1,9 +1,9 @@
|
||||||
import { createSelectorHook, createState } from "@state-designer/react"
|
import { createSelectorHook, createState } from "@state-designer/react"
|
||||||
import { clamp, getCommonBounds, screenToWorld } from "utils/utils"
|
import { clamp, getCommonBounds, screenToWorld } from "utils/utils"
|
||||||
import * as vec from "utils/vec"
|
import * as vec from "utils/vec"
|
||||||
import { Bounds, Data, PointerInfo, Shape, ShapeType } from "types"
|
import { Data, PointerInfo, TransformCorner, TransformEdge } from "types"
|
||||||
import { defaultDocument } from "./data"
|
import { defaultDocument } from "./data"
|
||||||
import Shapes from "lib/shapes"
|
import { getShapeUtils } from "lib/shapes"
|
||||||
import history from "state/history"
|
import history from "state/history"
|
||||||
import * as Sessions from "./sessions"
|
import * as Sessions from "./sessions"
|
||||||
|
|
||||||
|
@ -43,6 +43,8 @@ const state = createState({
|
||||||
on: {
|
on: {
|
||||||
POINTED_CANVAS: { to: "brushSelecting" },
|
POINTED_CANVAS: { to: "brushSelecting" },
|
||||||
POINTED_BOUNDS: { to: "pointingBounds" },
|
POINTED_BOUNDS: { to: "pointingBounds" },
|
||||||
|
POINTED_BOUNDS_EDGE: { to: "transformingSelection" },
|
||||||
|
POINTED_BOUNDS_CORNER: { to: "transformingSelection" },
|
||||||
POINTED_SHAPE: [
|
POINTED_SHAPE: [
|
||||||
"setPointedId",
|
"setPointedId",
|
||||||
{
|
{
|
||||||
|
@ -84,6 +86,15 @@ const state = createState({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
transformingSelection: {
|
||||||
|
onEnter: "startTransformSession",
|
||||||
|
on: {
|
||||||
|
MOVED_POINTER: "updateTransformSession",
|
||||||
|
PANNED_CAMERA: "updateTransformSession",
|
||||||
|
STOPPED_POINTING: { do: "completeSession", to: "selecting" },
|
||||||
|
CANCELLED: { do: "cancelSession", to: "selecting" },
|
||||||
|
},
|
||||||
|
},
|
||||||
draggingSelection: {
|
draggingSelection: {
|
||||||
onEnter: "startTranslateSession",
|
onEnter: "startTranslateSession",
|
||||||
on: {
|
on: {
|
||||||
|
@ -160,6 +171,7 @@ const state = createState({
|
||||||
updateBrushSession(data, payload: PointerInfo) {
|
updateBrushSession(data, payload: PointerInfo) {
|
||||||
session.update(data, screenToWorld(payload.point, data))
|
session.update(data, screenToWorld(payload.point, data))
|
||||||
},
|
},
|
||||||
|
|
||||||
// Dragging / Translating
|
// Dragging / Translating
|
||||||
startTranslateSession(data, payload: PointerInfo) {
|
startTranslateSession(data, payload: PointerInfo) {
|
||||||
session = new Sessions.TranslateSession(
|
session = new Sessions.TranslateSession(
|
||||||
|
@ -171,6 +183,21 @@ const state = createState({
|
||||||
session.update(data, screenToWorld(payload.point, data))
|
session.update(data, screenToWorld(payload.point, data))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Dragging / Translating
|
||||||
|
startTransformSession(
|
||||||
|
data,
|
||||||
|
payload: PointerInfo & { target: TransformCorner | TransformEdge }
|
||||||
|
) {
|
||||||
|
session = new Sessions.TransformSession(
|
||||||
|
data,
|
||||||
|
payload.target,
|
||||||
|
screenToWorld(payload.point, data)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
updateTransformSession(data, payload: PointerInfo) {
|
||||||
|
session.update(data, screenToWorld(payload.point, data))
|
||||||
|
},
|
||||||
|
|
||||||
// Selection
|
// Selection
|
||||||
setPointedId(data, payload: PointerInfo) {
|
setPointedId(data, payload: PointerInfo) {
|
||||||
data.pointedId = payload.target
|
data.pointedId = payload.target
|
||||||
|
@ -224,31 +251,13 @@ const state = createState({
|
||||||
document: { pages },
|
document: { pages },
|
||||||
} = data
|
} = data
|
||||||
|
|
||||||
|
if (selectedIds.size === 0) return null
|
||||||
|
|
||||||
return getCommonBounds(
|
return getCommonBounds(
|
||||||
...Array.from(selectedIds.values())
|
...Array.from(selectedIds.values())
|
||||||
.map((id) => {
|
.map((id) => {
|
||||||
const shape = pages[currentPageId].shapes[id]
|
const shape = pages[currentPageId].shapes[id]
|
||||||
|
return getShapeUtils(shape).getBounds(shape)
|
||||||
switch (shape.type) {
|
|
||||||
case ShapeType.Dot: {
|
|
||||||
return Shapes[shape.type].getBounds(shape)
|
|
||||||
}
|
|
||||||
case ShapeType.Circle: {
|
|
||||||
return Shapes[shape.type].getBounds(shape)
|
|
||||||
}
|
|
||||||
case ShapeType.Line: {
|
|
||||||
return Shapes[shape.type].getBounds(shape)
|
|
||||||
}
|
|
||||||
case ShapeType.Polyline: {
|
|
||||||
return Shapes[shape.type].getBounds(shape)
|
|
||||||
}
|
|
||||||
case ShapeType.Rectangle: {
|
|
||||||
return Shapes[shape.type].getBounds(shape)
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
)
|
)
|
||||||
|
|
32
types.ts
32
types.ts
|
@ -101,6 +101,22 @@ export interface Bounds {
|
||||||
height: number
|
height: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShapeBounds extends Bounds {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PointSnapshot extends Bounds {
|
||||||
|
nx: number
|
||||||
|
nmx: number
|
||||||
|
ny: number
|
||||||
|
nmy: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoundsSnapshot extends PointSnapshot {
|
||||||
|
nw: number
|
||||||
|
nh: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface Shapes extends Record<ShapeType, Shape> {
|
export interface Shapes extends Record<ShapeType, Shape> {
|
||||||
[ShapeType.Dot]: DotShape
|
[ShapeType.Dot]: DotShape
|
||||||
[ShapeType.Circle]: CircleShape
|
[ShapeType.Circle]: CircleShape
|
||||||
|
@ -120,7 +136,7 @@ export type ShapeSpecificProps<T extends Shape> = Pick<
|
||||||
|
|
||||||
export type ShapeIndicatorProps<T extends Shape> = ShapeSpecificProps<T>
|
export type ShapeIndicatorProps<T extends Shape> = ShapeSpecificProps<T>
|
||||||
|
|
||||||
export type BaseLibShape<K extends Shape> = {
|
export type ShapeUtil<K extends Shape> = {
|
||||||
create(props: Partial<K>): K
|
create(props: Partial<K>): K
|
||||||
getBounds(shape: K): Bounds
|
getBounds(shape: K): Bounds
|
||||||
hitTest(shape: K, test: number[]): boolean
|
hitTest(shape: K, test: number[]): boolean
|
||||||
|
@ -142,3 +158,17 @@ export interface PointerInfo {
|
||||||
metaKey: boolean
|
metaKey: boolean
|
||||||
altKey: boolean
|
altKey: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum TransformEdge {
|
||||||
|
Top = "top_edge",
|
||||||
|
Right = "right_edge",
|
||||||
|
Bottom = "bottom_edge",
|
||||||
|
Left = "left_edge",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TransformCorner {
|
||||||
|
TopLeft = "top_left_corner",
|
||||||
|
TopRight = "top_right_corner",
|
||||||
|
BottomRight = "bottom_right_corner",
|
||||||
|
BottomLeft = "bottom_left_corner",
|
||||||
|
}
|
||||||
|
|
48
utils/hitTests.ts
Normal file
48
utils/hitTests.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import { Bounds } from "types"
|
||||||
|
import * as vec from "./vec"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get whether a point is inside of a bounds.
|
||||||
|
* @param A
|
||||||
|
* @param b
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function pointInBounds(A: number[], b: Bounds) {
|
||||||
|
return !(A[0] < b.minX || A[0] > b.maxX || A[1] < b.minY || A[1] > b.maxY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get whether a point is inside of a circle.
|
||||||
|
* @param A
|
||||||
|
* @param b
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function pointInCircle(A: number[], C: number[], r: number) {
|
||||||
|
return vec.dist(A, C) <= r
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get whether a point is inside of an ellipse.
|
||||||
|
* @param point
|
||||||
|
* @param center
|
||||||
|
* @param rx
|
||||||
|
* @param ry
|
||||||
|
* @param rotation
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function pointInEllipse(
|
||||||
|
A: number[],
|
||||||
|
C: number[],
|
||||||
|
rx: number,
|
||||||
|
ry: number,
|
||||||
|
rotation = 0
|
||||||
|
) {
|
||||||
|
rotation = rotation || 0
|
||||||
|
const cos = Math.cos(rotation)
|
||||||
|
const sin = Math.sin(rotation)
|
||||||
|
const delta = vec.sub(A, C)
|
||||||
|
const tdx = cos * delta[0] + sin * delta[1]
|
||||||
|
const tdy = sin * delta[0] - cos * delta[1]
|
||||||
|
|
||||||
|
return (tdx * tdx) / (rx * rx) + (tdy * tdy) / (ry * ry) <= 1
|
||||||
|
}
|
|
@ -7,10 +7,7 @@ interface Intersection {
|
||||||
points: number[][]
|
points: number[][]
|
||||||
}
|
}
|
||||||
|
|
||||||
function getIntersection(
|
function getIntersection(message: string, ...points: number[][]) {
|
||||||
points: number[][],
|
|
||||||
message = points.length ? "Intersection" : "No intersection"
|
|
||||||
) {
|
|
||||||
return { didIntersect: points.length > 0, message, points }
|
return { didIntersect: points.length > 0, message, points }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,22 +26,22 @@ export function intersectLineSegments(
|
||||||
const u_b = BV[1] * AV[0] - BV[0] * AV[1]
|
const u_b = BV[1] * AV[0] - BV[0] * AV[1]
|
||||||
|
|
||||||
if (ua_t === 0 || ub_t === 0) {
|
if (ua_t === 0 || ub_t === 0) {
|
||||||
return getIntersection([], "Coincident")
|
return getIntersection("coincident")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (u_b === 0) {
|
if (u_b === 0) {
|
||||||
return getIntersection([], "Parallel")
|
return getIntersection("parallel")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (u_b != 0) {
|
if (u_b != 0) {
|
||||||
const ua = ua_t / u_b
|
const ua = ua_t / u_b
|
||||||
const ub = ub_t / u_b
|
const ub = ub_t / u_b
|
||||||
if (0 <= ua && ua <= 1 && 0 <= ub && ub <= 1) {
|
if (0 <= ua && ua <= 1 && 0 <= ub && ub <= 1) {
|
||||||
return getIntersection([vec.add(a1, vec.mul(AV, ua))])
|
return getIntersection("intersection", vec.add(a1, vec.mul(AV, ua)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return getIntersection([])
|
return getIntersection("no intersection")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function intersectCircleLineSegment(
|
export function intersectCircleLineSegment(
|
||||||
|
@ -68,11 +65,11 @@ export function intersectCircleLineSegment(
|
||||||
const deter = b * b - 4 * a * cc
|
const deter = b * b - 4 * a * cc
|
||||||
|
|
||||||
if (deter < 0) {
|
if (deter < 0) {
|
||||||
return { didIntersect: false, message: "outside", points: [] }
|
return getIntersection("outside")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deter === 0) {
|
if (deter === 0) {
|
||||||
return { didIntersect: false, message: "tangent", points: [] }
|
return getIntersection("tangent")
|
||||||
}
|
}
|
||||||
|
|
||||||
var e = Math.sqrt(deter)
|
var e = Math.sqrt(deter)
|
||||||
|
@ -80,17 +77,71 @@ export function intersectCircleLineSegment(
|
||||||
var u2 = (-b - e) / (2 * a)
|
var u2 = (-b - e) / (2 * a)
|
||||||
if ((u1 < 0 || u1 > 1) && (u2 < 0 || u2 > 1)) {
|
if ((u1 < 0 || u1 > 1) && (u2 < 0 || u2 > 1)) {
|
||||||
if ((u1 < 0 && u2 < 0) || (u1 > 1 && u2 > 1)) {
|
if ((u1 < 0 && u2 < 0) || (u1 > 1 && u2 > 1)) {
|
||||||
return { didIntersect: false, message: "outside", points: [] }
|
return getIntersection("outside")
|
||||||
} else {
|
} else {
|
||||||
return { didIntersect: false, message: "inside", points: [] }
|
return getIntersection("inside")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = { didIntersect: true, message: "intersection", points: [] }
|
const results: number[][] = []
|
||||||
if (0 <= u1 && u1 <= 1) result.points.push(vec.lrp(a1, a2, u1))
|
if (0 <= u1 && u1 <= 1) results.push(vec.lrp(a1, a2, u1))
|
||||||
if (0 <= u2 && u2 <= 1) result.points.push(vec.lrp(a1, a2, u2))
|
if (0 <= u2 && u2 <= 1) results.push(vec.lrp(a1, a2, u2))
|
||||||
|
|
||||||
return result
|
return getIntersection("intersection", ...results)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function intersectEllipseLineSegment(
|
||||||
|
center: number[],
|
||||||
|
rx: number,
|
||||||
|
ry: number,
|
||||||
|
a1: number[],
|
||||||
|
a2: number[],
|
||||||
|
rotation = 0
|
||||||
|
) {
|
||||||
|
// If the ellipse or line segment are empty, return no tValues.
|
||||||
|
if (rx === 0 || ry === 0 || vec.isEqual(a1, a2)) {
|
||||||
|
return getIntersection("No intersection")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the semimajor and semiminor axes.
|
||||||
|
rx = rx < 0 ? rx : -rx
|
||||||
|
ry = ry < 0 ? ry : -ry
|
||||||
|
|
||||||
|
// Rotate points and translate so the ellipse is centered at the origin.
|
||||||
|
a1 = vec.sub(vec.rotWith(a1, center, -rotation), center)
|
||||||
|
a2 = vec.sub(vec.rotWith(a2, center, -rotation), center)
|
||||||
|
|
||||||
|
// Calculate the quadratic parameters.
|
||||||
|
const diff = vec.sub(a2, a1)
|
||||||
|
|
||||||
|
var A = (diff[0] * diff[0]) / rx / rx + (diff[1] * diff[1]) / ry / ry
|
||||||
|
var B = (2 * a1[0] * diff[0]) / rx / rx + (2 * a1[1] * diff[1]) / ry / ry
|
||||||
|
var C = (a1[0] * a1[0]) / rx / rx + (a1[1] * a1[1]) / ry / ry - 1
|
||||||
|
|
||||||
|
// Make a list of t values (normalized points on the line where intersections occur).
|
||||||
|
var tValues: number[] = []
|
||||||
|
|
||||||
|
// Calculate the discriminant.
|
||||||
|
var discriminant = B * B - 4 * A * C
|
||||||
|
|
||||||
|
if (discriminant === 0) {
|
||||||
|
// One real solution.
|
||||||
|
tValues.push(-B / 2 / A)
|
||||||
|
} else if (discriminant > 0) {
|
||||||
|
const root = Math.sqrt(discriminant)
|
||||||
|
// Two real solutions.
|
||||||
|
tValues.push((-B + root) / 2 / A)
|
||||||
|
tValues.push((-B - root) / 2 / A)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only points that are on the segment.
|
||||||
|
// Solve for points, then counter-rotate points.
|
||||||
|
const points = tValues
|
||||||
|
.filter((t) => t >= 0 && t <= 1)
|
||||||
|
.map((t) => vec.add(center, vec.add(a1, vec.mul(vec.sub(a2, a1), t))))
|
||||||
|
.map((p) => vec.rotWith(p, center, rotation))
|
||||||
|
|
||||||
|
return getIntersection("intersection", ...points)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function intersectCircleRectangle(
|
export function intersectCircleRectangle(
|
||||||
|
@ -130,6 +181,73 @@ export function intersectCircleRectangle(
|
||||||
return intersections
|
return intersections
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function intersectEllipseRectangle(
|
||||||
|
c: number[],
|
||||||
|
rx: number,
|
||||||
|
ry: number,
|
||||||
|
point: number[],
|
||||||
|
size: number[],
|
||||||
|
rotation = 0
|
||||||
|
): Intersection[] {
|
||||||
|
const tl = point
|
||||||
|
const tr = vec.add(point, [size[0], 0])
|
||||||
|
const br = vec.add(point, size)
|
||||||
|
const bl = vec.add(point, [0, size[1]])
|
||||||
|
|
||||||
|
const intersections: Intersection[] = []
|
||||||
|
|
||||||
|
const topIntersection = intersectEllipseLineSegment(
|
||||||
|
c,
|
||||||
|
rx,
|
||||||
|
ry,
|
||||||
|
tl,
|
||||||
|
tr,
|
||||||
|
rotation
|
||||||
|
)
|
||||||
|
const rightIntersection = intersectEllipseLineSegment(
|
||||||
|
c,
|
||||||
|
rx,
|
||||||
|
ry,
|
||||||
|
tr,
|
||||||
|
br,
|
||||||
|
rotation
|
||||||
|
)
|
||||||
|
const bottomIntersection = intersectEllipseLineSegment(
|
||||||
|
c,
|
||||||
|
rx,
|
||||||
|
ry,
|
||||||
|
bl,
|
||||||
|
br,
|
||||||
|
rotation
|
||||||
|
)
|
||||||
|
const leftIntersection = intersectEllipseLineSegment(
|
||||||
|
c,
|
||||||
|
rx,
|
||||||
|
ry,
|
||||||
|
tl,
|
||||||
|
bl,
|
||||||
|
rotation
|
||||||
|
)
|
||||||
|
|
||||||
|
if (topIntersection.didIntersect) {
|
||||||
|
intersections.push({ ...topIntersection, message: "top" })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rightIntersection.didIntersect) {
|
||||||
|
intersections.push({ ...rightIntersection, message: "right" })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bottomIntersection.didIntersect) {
|
||||||
|
intersections.push({ ...bottomIntersection, message: "bottom" })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leftIntersection.didIntersect) {
|
||||||
|
intersections.push({ ...leftIntersection, message: "left" })
|
||||||
|
}
|
||||||
|
|
||||||
|
return intersections
|
||||||
|
}
|
||||||
|
|
||||||
export function intersectRectangleLineSegment(
|
export function intersectRectangleLineSegment(
|
||||||
point: number[],
|
point: number[],
|
||||||
size: number[],
|
size: number[],
|
||||||
|
@ -180,6 +298,24 @@ export function intersectCircleBounds(
|
||||||
return intersectCircleRectangle(c, r, [minX, minY], [width, height])
|
return intersectCircleRectangle(c, r, [minX, minY], [width, height])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function intersectEllipseBounds(
|
||||||
|
c: number[],
|
||||||
|
rx: number,
|
||||||
|
ry: number,
|
||||||
|
bounds: Bounds,
|
||||||
|
rotation = 0
|
||||||
|
): Intersection[] {
|
||||||
|
const { minX, minY, width, height } = bounds
|
||||||
|
return intersectEllipseRectangle(
|
||||||
|
c,
|
||||||
|
rx,
|
||||||
|
ry,
|
||||||
|
[minX, minY],
|
||||||
|
[width, height],
|
||||||
|
rotation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function intersectLineSegmentBounds(
|
export function intersectLineSegmentBounds(
|
||||||
a1: number[],
|
a1: number[],
|
||||||
a2: number[],
|
a2: number[],
|
||||||
|
|
251
utils/transforms.ts
Normal file
251
utils/transforms.ts
Normal file
|
@ -0,0 +1,251 @@
|
||||||
|
import { Bounds, BoundsSnapshot, ShapeBounds } from "types"
|
||||||
|
|
||||||
|
export function stretchshapesX(shapes: ShapeBounds[]) {
|
||||||
|
const [first, ...rest] = shapes
|
||||||
|
let min = first.minX
|
||||||
|
let max = first.minX + first.width
|
||||||
|
for (let box of rest) {
|
||||||
|
min = Math.min(min, box.minX)
|
||||||
|
max = Math.max(max, box.minX + box.width)
|
||||||
|
}
|
||||||
|
return shapes.map((box) => ({ ...box, x: min, width: max - min }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stretchshapesY(shapes: ShapeBounds[]) {
|
||||||
|
const [first, ...rest] = shapes
|
||||||
|
let min = first.minY
|
||||||
|
let max = first.minY + first.height
|
||||||
|
for (let box of rest) {
|
||||||
|
min = Math.min(min, box.minY)
|
||||||
|
max = Math.max(max, box.minY + box.height)
|
||||||
|
}
|
||||||
|
return shapes.map((box) => ({ ...box, y: min, height: max - min }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function distributeshapesX(shapes: ShapeBounds[]) {
|
||||||
|
const len = shapes.length
|
||||||
|
const sorted = [...shapes].sort((a, b) => a.minX - b.minX)
|
||||||
|
let min = sorted[0].minX
|
||||||
|
|
||||||
|
sorted.sort((a, b) => a.minX + a.width - b.minX - b.width)
|
||||||
|
let last = sorted[len - 1]
|
||||||
|
let max = last.minX + last.width
|
||||||
|
|
||||||
|
let range = max - min
|
||||||
|
let step = range / len
|
||||||
|
return sorted.map((box, i) => ({ ...box, x: min + step * i }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function distributeshapesY(shapes: ShapeBounds[]) {
|
||||||
|
const len = shapes.length
|
||||||
|
const sorted = [...shapes].sort((a, b) => a.minY - b.minY)
|
||||||
|
let min = sorted[0].minY
|
||||||
|
|
||||||
|
sorted.sort((a, b) => a.minY + a.height - b.minY - b.height)
|
||||||
|
let last = sorted[len - 1]
|
||||||
|
let max = last.minY + last.height
|
||||||
|
|
||||||
|
let range = max - min
|
||||||
|
let step = range / len
|
||||||
|
return sorted.map((box, i) => ({ ...box, y: min + step * i }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alignshapesCenterX(shapes: ShapeBounds[]) {
|
||||||
|
let midX = 0
|
||||||
|
for (let box of shapes) midX += box.minX + box.width / 2
|
||||||
|
midX /= shapes.length
|
||||||
|
return shapes.map((box) => ({ ...box, x: midX - box.width / 2 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alignshapesCenterY(shapes: ShapeBounds[]) {
|
||||||
|
let midY = 0
|
||||||
|
for (let box of shapes) midY += box.minY + box.height / 2
|
||||||
|
midY /= shapes.length
|
||||||
|
return shapes.map((box) => ({ ...box, y: midY - box.height / 2 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alignshapesTop(shapes: ShapeBounds[]) {
|
||||||
|
const [first, ...rest] = shapes
|
||||||
|
let y = first.minY
|
||||||
|
for (let box of rest) if (box.minY < y) y = box.minY
|
||||||
|
return shapes.map((box) => ({ ...box, y }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alignshapesBottom(shapes: ShapeBounds[]) {
|
||||||
|
const [first, ...rest] = shapes
|
||||||
|
let maxY = first.minY + first.height
|
||||||
|
for (let box of rest)
|
||||||
|
if (box.minY + box.height > maxY) maxY = box.minY + box.height
|
||||||
|
return shapes.map((box) => ({ ...box, y: maxY - box.height }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alignshapesLeft(shapes: ShapeBounds[]) {
|
||||||
|
const [first, ...rest] = shapes
|
||||||
|
let x = first.minX
|
||||||
|
for (let box of rest) if (box.minX < x) x = box.minX
|
||||||
|
return shapes.map((box) => ({ ...box, x }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alignshapesRight(shapes: ShapeBounds[]) {
|
||||||
|
const [first, ...rest] = shapes
|
||||||
|
let maxX = first.minX + first.width
|
||||||
|
for (let box of rest)
|
||||||
|
if (box.minX + box.width > maxX) maxX = box.minX + box.width
|
||||||
|
return shapes.map((box) => ({ ...box, x: maxX - box.width }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resizers
|
||||||
|
|
||||||
|
export function getBoundingBox(shapes: ShapeBounds[]): Bounds {
|
||||||
|
if (shapes.length === 0) {
|
||||||
|
return {
|
||||||
|
minX: 0,
|
||||||
|
minY: 0,
|
||||||
|
maxX: 0,
|
||||||
|
maxY: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = shapes[0]
|
||||||
|
|
||||||
|
let minX = first.minX
|
||||||
|
let minY = first.minY
|
||||||
|
let maxX = first.minX + first.width
|
||||||
|
let maxY = first.minY + first.height
|
||||||
|
|
||||||
|
for (let box of shapes) {
|
||||||
|
minX = Math.min(minX, box.minX)
|
||||||
|
minY = Math.min(minY, box.minY)
|
||||||
|
maxX = Math.max(maxX, box.minX + box.width)
|
||||||
|
maxY = Math.max(maxY, box.minY + box.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX,
|
||||||
|
minY,
|
||||||
|
maxX,
|
||||||
|
maxY,
|
||||||
|
width: maxX - minX,
|
||||||
|
height: maxY - minY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSnapshots(
|
||||||
|
shapes: ShapeBounds[],
|
||||||
|
bounds: Bounds
|
||||||
|
): Record<string, BoundsSnapshot> {
|
||||||
|
const acc = {} as Record<string, BoundsSnapshot>
|
||||||
|
|
||||||
|
const w = bounds.maxX - bounds.minX
|
||||||
|
const h = bounds.maxY - bounds.minY
|
||||||
|
|
||||||
|
for (let box of shapes) {
|
||||||
|
acc[box.id] = {
|
||||||
|
...box,
|
||||||
|
nx: (box.minX - bounds.minX) / w,
|
||||||
|
ny: (box.minY - bounds.minY) / h,
|
||||||
|
nmx: 1 - (box.minX + box.width - bounds.minX) / w,
|
||||||
|
nmy: 1 - (box.minY + box.height - bounds.minY) / h,
|
||||||
|
nw: box.width / w,
|
||||||
|
nh: box.height / h,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEdgeResizer(shapes: ShapeBounds[], edge: number) {
|
||||||
|
const initial = getBoundingBox(shapes)
|
||||||
|
const snapshots = getSnapshots(shapes, initial)
|
||||||
|
const mshapes = [...shapes]
|
||||||
|
|
||||||
|
let { minX: x0, minY: y0, maxX: x1, maxY: y1 } = initial
|
||||||
|
let { minX: mx, minY: my } = initial
|
||||||
|
let mw = x1 - x0
|
||||||
|
let mh = y1 - y0
|
||||||
|
|
||||||
|
return function edgeResize({ x, y }) {
|
||||||
|
if (edge === 0 || edge === 2) {
|
||||||
|
edge === 0 ? (y0 = y) : (y1 = y)
|
||||||
|
my = y0 < y1 ? y0 : y1
|
||||||
|
mh = Math.abs(y1 - y0)
|
||||||
|
for (let box of mshapes) {
|
||||||
|
const { ny, nmy, nh } = snapshots[box.id]
|
||||||
|
box.minY = my + (y1 < y0 ? nmy : ny) * mh
|
||||||
|
box.height = nh * mh
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
edge === 1 ? (x1 = x) : (x0 = x)
|
||||||
|
mx = x0 < x1 ? x0 : x1
|
||||||
|
mw = Math.abs(x1 - x0)
|
||||||
|
for (let box of mshapes) {
|
||||||
|
const { nx, nmx, nw } = snapshots[box.id]
|
||||||
|
box.minX = mx + (x1 < x0 ? nmx : nx) * mw
|
||||||
|
box.width = nw * mw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
mshapes,
|
||||||
|
{
|
||||||
|
x: mx,
|
||||||
|
y: my,
|
||||||
|
width: mw,
|
||||||
|
height: mh,
|
||||||
|
maxX: mx + mw,
|
||||||
|
maxY: my + mh,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a function that can be used to calculate corner resize transforms.
|
||||||
|
* @param shapes An array of the shapes being resized.
|
||||||
|
* @param corner A number representing the corner being dragged. Top Left: 0, Top Right: 1, Bottom Right: 2, Bottom Left: 3.
|
||||||
|
* @example
|
||||||
|
* const resizer = getCornerResizer(selectedshapes, 3)
|
||||||
|
* resizer(selectedshapes, )
|
||||||
|
*/
|
||||||
|
export function getCornerResizer(shapes: ShapeBounds[], corner: number) {
|
||||||
|
const initial = getBoundingBox(shapes)
|
||||||
|
const snapshots = getSnapshots(shapes, initial)
|
||||||
|
const mshapes = [...shapes]
|
||||||
|
|
||||||
|
let { minX: x0, minY: y0, maxX: x1, maxY: y1 } = initial
|
||||||
|
let { minX: mx, minY: my } = initial
|
||||||
|
let mw = x1 - x0
|
||||||
|
let mh = y1 - y0
|
||||||
|
|
||||||
|
return function cornerResizer({ x, y }) {
|
||||||
|
corner < 2 ? (y0 = y) : (y1 = y)
|
||||||
|
my = y0 < y1 ? y0 : y1
|
||||||
|
mh = Math.abs(y1 - y0)
|
||||||
|
|
||||||
|
corner === 1 || corner === 2 ? (x1 = x) : (x0 = x)
|
||||||
|
mx = x0 < x1 ? x0 : x1
|
||||||
|
mw = Math.abs(x1 - x0)
|
||||||
|
|
||||||
|
for (let box of mshapes) {
|
||||||
|
const { nx, nmx, nw, ny, nmy, nh } = snapshots[box.id]
|
||||||
|
box.minX = mx + (x1 < x0 ? nmx : nx) * mw
|
||||||
|
box.minY = my + (y1 < y0 ? nmy : ny) * mh
|
||||||
|
box.width = nw * mw
|
||||||
|
box.height = nh * mh
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
mshapes,
|
||||||
|
{
|
||||||
|
x: mx,
|
||||||
|
y: my,
|
||||||
|
width: mw,
|
||||||
|
height: mh,
|
||||||
|
maxX: mx + mw,
|
||||||
|
maxY: my + mh,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -249,6 +249,8 @@ export function rot(A: number[], r: number) {
|
||||||
* @param r rotation in radians
|
* @param r rotation in radians
|
||||||
*/
|
*/
|
||||||
export function rotWith(A: number[], C: number[], r: number) {
|
export function rotWith(A: number[], C: number[], r: number) {
|
||||||
|
if (r === 0) return A
|
||||||
|
|
||||||
const s = Math.sin(r)
|
const s = Math.sin(r)
|
||||||
const c = Math.cos(r)
|
const c = Math.cos(r)
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue