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 { motion } from "framer-motion"
|
||||
import styled from "styles"
|
||||
import inputs from "state/inputs"
|
||||
import { useRef } from "react"
|
||||
import { TransformCorner, TransformEdge } from "types"
|
||||
|
||||
export default function Bounds() {
|
||||
const zoom = useSelector((state) => state.data.camera.zoom)
|
||||
|
@ -16,6 +16,8 @@ export default function Bounds() {
|
|||
const p = 4 / zoom
|
||||
const cp = p * 2
|
||||
|
||||
if (width < p || height < p) return null
|
||||
|
||||
return (
|
||||
<g pointerEvents={isBrushing ? "none" : "all"}>
|
||||
<StyledBounds
|
||||
|
@ -27,61 +29,61 @@ export default function Bounds() {
|
|||
/>
|
||||
{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
|
||||
x={minX + p}
|
||||
y={minY}
|
||||
width={Math.max(0, width - p * 2)}
|
||||
height={p}
|
||||
edge="top_edge"
|
||||
edge={TransformEdge.Top}
|
||||
/>
|
||||
<EdgeVertical
|
||||
x={maxX}
|
||||
y={minY + p}
|
||||
width={p}
|
||||
height={Math.max(0, height - p * 2)}
|
||||
edge="right_edge"
|
||||
edge={TransformEdge.Right}
|
||||
/>
|
||||
<EdgeHorizontal
|
||||
x={minX + p}
|
||||
y={maxY}
|
||||
width={Math.max(0, width - p * 2)}
|
||||
height={p}
|
||||
edge="bottom_edge"
|
||||
edge={TransformEdge.Bottom}
|
||||
/>
|
||||
<EdgeVertical
|
||||
x={minX}
|
||||
y={minY + p}
|
||||
width={p}
|
||||
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
|
||||
width: number
|
||||
height: number
|
||||
corner:
|
||||
| "top_left_corner"
|
||||
| "top_right_corner"
|
||||
| "bottom_right_corner"
|
||||
| "bottom_left_corner"
|
||||
corner: TransformCorner
|
||||
}) {
|
||||
const rRotateCorner = useRef<SVGRectElement>(null)
|
||||
const rCorner = useRef<SVGRectElement>(null)
|
||||
|
@ -166,7 +164,7 @@ function EdgeHorizontal({
|
|||
y: number
|
||||
width: number
|
||||
height: number
|
||||
edge: "top_edge" | "bottom_edge"
|
||||
edge: TransformEdge.Top | TransformEdge.Bottom
|
||||
}) {
|
||||
const rEdge = useRef<SVGRectElement>(null)
|
||||
|
||||
|
@ -205,7 +203,7 @@ function EdgeVertical({
|
|||
y: number
|
||||
width: number
|
||||
height: number
|
||||
edge: "right_edge" | "left_edge"
|
||||
edge: TransformEdge.Right | TransformEdge.Left
|
||||
}) {
|
||||
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", {
|
||||
stroke: "none",
|
||||
fill: "none",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useCallback, useRef, memo } from "react"
|
||||
import state, { useSelector } from "state"
|
||||
import inputs from "state/inputs"
|
||||
import shapes from "lib/shapes"
|
||||
import { getShapeUtils } from "lib/shapes"
|
||||
import styled from "styles"
|
||||
|
||||
function Shape({ id }: { id: string }) {
|
||||
|
@ -41,7 +41,6 @@ function Shape({ id }: { id: string }) {
|
|||
(e: React.PointerEvent) => state.send("UNHOVERED_SHAPE", { id }),
|
||||
[id]
|
||||
)
|
||||
|
||||
return (
|
||||
<StyledGroup
|
||||
ref={rGroup}
|
||||
|
@ -52,9 +51,7 @@ function Shape({ id }: { id: string }) {
|
|||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
<defs>
|
||||
{shapes[shape.type] ? shapes[shape.type].render(shape) : null}
|
||||
</defs>
|
||||
<defs>{getShapeUtils(shape).render(shape)}</defs>
|
||||
<HoverIndicator as="use" xlinkHref={"#" + id} />
|
||||
<use xlinkHref={"#" + id} {...shape.style} />
|
||||
<Indicator as="use" xlinkHref={"#" + id} />
|
||||
|
@ -65,7 +62,7 @@ function Shape({ id }: { id: string }) {
|
|||
const Indicator = styled("path", {
|
||||
fill: "none",
|
||||
stroke: "transparent",
|
||||
zStrokeWidth: 1,
|
||||
zStrokeWidth: [1, 1],
|
||||
pointerEvents: "none",
|
||||
strokeLineCap: "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 * as vec from "utils/vec"
|
||||
import { CircleShape, ShapeType } from "types"
|
||||
import { boundsCache } from "./index"
|
||||
import { createShape } from "./index"
|
||||
import { boundsContained } from "utils/bounds"
|
||||
import { intersectCircleBounds } from "utils/intersections"
|
||||
import { createShape } from "./base-shape"
|
||||
|
||||
const circle = createShape<CircleShape>({
|
||||
boundsCache: new WeakMap([]),
|
||||
|
||||
create(props) {
|
||||
return {
|
||||
id: uuid(),
|
||||
|
@ -27,8 +28,8 @@ const circle = createShape<CircleShape>({
|
|||
},
|
||||
|
||||
getBounds(shape) {
|
||||
if (boundsCache.has(shape)) {
|
||||
return boundsCache.get(shape)
|
||||
if (this.boundsCache.has(shape)) {
|
||||
return this.boundsCache.get(shape)
|
||||
}
|
||||
|
||||
const {
|
||||
|
@ -45,7 +46,8 @@ const circle = createShape<CircleShape>({
|
|||
height: radius * 2,
|
||||
}
|
||||
|
||||
boundsCache.set(shape, bounds)
|
||||
this.boundsCache.set(shape, bounds)
|
||||
|
||||
return bounds
|
||||
},
|
||||
|
||||
|
@ -84,6 +86,13 @@ const circle = createShape<CircleShape>({
|
|||
stretch(shape, scaleX, scaleY) {
|
||||
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
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import { v4 as uuid } from "uuid"
|
||||
import * as vec from "utils/vec"
|
||||
import { DotShape, ShapeType } from "types"
|
||||
import { boundsCache } from "./index"
|
||||
import { createShape } from "./index"
|
||||
import { boundsContained } from "utils/bounds"
|
||||
import { intersectCircleBounds } from "utils/intersections"
|
||||
import { createShape } from "./base-shape"
|
||||
|
||||
const dot = createShape<DotShape>({
|
||||
boundsCache: new WeakMap([]),
|
||||
|
||||
create(props) {
|
||||
return {
|
||||
id: uuid(),
|
||||
|
@ -22,12 +23,12 @@ const dot = createShape<DotShape>({
|
|||
},
|
||||
|
||||
render({ id }) {
|
||||
return <circle id={id} cx={4} cy={4} r={4} />
|
||||
return <circle id={id} cx={0} cy={0} r={4} />
|
||||
},
|
||||
|
||||
getBounds(shape) {
|
||||
if (boundsCache.has(shape)) {
|
||||
return boundsCache.get(shape)
|
||||
if (this.boundsCache.has(shape)) {
|
||||
return this.boundsCache.get(shape)
|
||||
}
|
||||
|
||||
const {
|
||||
|
@ -36,14 +37,15 @@ const dot = createShape<DotShape>({
|
|||
|
||||
const bounds = {
|
||||
minX: x,
|
||||
maxX: x + 8,
|
||||
maxX: x + 1,
|
||||
minY: y,
|
||||
maxY: y + 8,
|
||||
width: 8,
|
||||
height: 8,
|
||||
maxY: y + 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}
|
||||
|
||||
boundsCache.set(shape, bounds)
|
||||
this.boundsCache.set(shape, bounds)
|
||||
|
||||
return bounds
|
||||
},
|
||||
|
||||
|
@ -75,6 +77,12 @@ const dot = createShape<DotShape>({
|
|||
stretch(shape, scaleX: number, scaleY: number) {
|
||||
return shape
|
||||
},
|
||||
|
||||
transform(shape, bounds) {
|
||||
shape.point = [bounds.minX, bounds.minY]
|
||||
|
||||
return shape
|
||||
},
|
||||
})
|
||||
|
||||
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 Dot from "./dot"
|
||||
import Polyline from "./polyline"
|
||||
import Rectangle from "./rectangle"
|
||||
import { Bounds, BoundsSnapshot, Shape, Shapes, ShapeType } from "types"
|
||||
import circle from "./circle"
|
||||
import dot from "./dot"
|
||||
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 = {
|
||||
[ShapeType.Circle]: Circle,
|
||||
[ShapeType.Dot]: Dot,
|
||||
[ShapeType.Polyline]: Polyline,
|
||||
[ShapeType.Rectangle]: Rectangle,
|
||||
[ShapeType.Ellipse]: Rectangle,
|
||||
[ShapeType.Line]: Rectangle,
|
||||
[ShapeType.Ray]: Rectangle,
|
||||
Operations throughout the app will call these utility methods
|
||||
when performing tests (such as hit tests) or mutations, such as translations.
|
||||
*/
|
||||
|
||||
export interface ShapeUtility<K extends Shape> {
|
||||
// A cache for the computed bounds of this kind of shape.
|
||||
boundsCache: WeakMap<K, Bounds>
|
||||
|
||||
// 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 * as vec from "utils/vec"
|
||||
import { PolylineShape, ShapeType } from "types"
|
||||
import { boundsCache } from "./index"
|
||||
import { createShape } from "./index"
|
||||
import { intersectPolylineBounds } from "utils/intersections"
|
||||
import { boundsCollide, boundsContained } from "utils/bounds"
|
||||
import { createShape } from "./base-shape"
|
||||
|
||||
const polyline = createShape<PolylineShape>({
|
||||
boundsCache: new WeakMap([]),
|
||||
|
||||
create(props) {
|
||||
return {
|
||||
id: uuid(),
|
||||
|
@ -27,8 +28,8 @@ const polyline = createShape<PolylineShape>({
|
|||
},
|
||||
|
||||
getBounds(shape) {
|
||||
if (boundsCache.has(shape)) {
|
||||
return boundsCache.get(shape)
|
||||
if (this.boundsCache.has(shape)) {
|
||||
return this.boundsCache.get(shape)
|
||||
}
|
||||
|
||||
let minX = 0
|
||||
|
@ -52,7 +53,7 @@ const polyline = createShape<PolylineShape>({
|
|||
height: maxY - minY,
|
||||
}
|
||||
|
||||
boundsCache.set(shape, bounds)
|
||||
this.boundsCache.set(shape, bounds)
|
||||
return bounds
|
||||
},
|
||||
|
||||
|
@ -88,6 +89,21 @@ const polyline = createShape<PolylineShape>({
|
|||
stretch(shape, scaleX: number, scaleY: number) {
|
||||
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
|
||||
|
|
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 * as vec from "utils/vec"
|
||||
import { RectangleShape, ShapeType } from "types"
|
||||
import { boundsCache } from "./index"
|
||||
import { createShape } from "./index"
|
||||
import { boundsContained, boundsCollide } from "utils/bounds"
|
||||
import { createShape } from "./base-shape"
|
||||
|
||||
const rectangle = createShape<RectangleShape>({
|
||||
boundsCache: new WeakMap([]),
|
||||
|
||||
create(props) {
|
||||
return {
|
||||
id: uuid(),
|
||||
|
@ -26,8 +27,8 @@ const rectangle = createShape<RectangleShape>({
|
|||
},
|
||||
|
||||
getBounds(shape) {
|
||||
if (boundsCache.has(shape)) {
|
||||
return boundsCache.get(shape)
|
||||
if (this.boundsCache.has(shape)) {
|
||||
return this.boundsCache.get(shape)
|
||||
}
|
||||
|
||||
const {
|
||||
|
@ -44,7 +45,8 @@ const rectangle = createShape<RectangleShape>({
|
|||
height,
|
||||
}
|
||||
|
||||
boundsCache.set(shape, bounds)
|
||||
this.boundsCache.set(shape, bounds)
|
||||
|
||||
return bounds
|
||||
},
|
||||
|
||||
|
@ -74,6 +76,14 @@ const rectangle = createShape<RectangleShape>({
|
|||
},
|
||||
|
||||
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
|
||||
},
|
||||
})
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Data, ShapeType } from "types"
|
||||
import Shapes from "lib/shapes"
|
||||
import shapeUtils from "lib/shapes"
|
||||
|
||||
export const defaultDocument: Data["document"] = {
|
||||
pages: {
|
||||
|
@ -9,7 +9,7 @@ export const defaultDocument: Data["document"] = {
|
|||
name: "Page 0",
|
||||
childIndex: 0,
|
||||
shapes: {
|
||||
shape3: Shapes[ShapeType.Dot].create({
|
||||
shape3: shapeUtils[ShapeType.Dot].create({
|
||||
id: "shape3",
|
||||
name: "Shape 3",
|
||||
childIndex: 3,
|
||||
|
@ -20,7 +20,7 @@ export const defaultDocument: Data["document"] = {
|
|||
strokeWidth: 1,
|
||||
},
|
||||
}),
|
||||
shape0: Shapes[ShapeType.Circle].create({
|
||||
shape0: shapeUtils[ShapeType.Circle].create({
|
||||
id: "shape0",
|
||||
name: "Shape 0",
|
||||
childIndex: 1,
|
||||
|
@ -32,7 +32,20 @@ export const defaultDocument: Data["document"] = {
|
|||
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",
|
||||
name: "Shape 2",
|
||||
childIndex: 2,
|
||||
|
@ -50,7 +63,7 @@ export const defaultDocument: Data["document"] = {
|
|||
strokeLinejoin: "round",
|
||||
},
|
||||
}),
|
||||
shape1: Shapes[ShapeType.Rectangle].create({
|
||||
shape1: shapeUtils[ShapeType.Rectangle].create({
|
||||
id: "shape1",
|
||||
name: "Shape 1",
|
||||
childIndex: 1,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { current } from "immer"
|
||||
import { BaseLibShape, Bounds, Data, Shapes } from "types"
|
||||
import { ShapeUtil, Bounds, Data, Shapes } from "types"
|
||||
import BaseSession from "./base-session"
|
||||
import shapes from "lib/shapes"
|
||||
import { getBoundsFromPoints } from "utils/utils"
|
||||
|
@ -68,7 +68,7 @@ export default class BrushSession extends BaseSession {
|
|||
.map((shape) => ({
|
||||
id: shape.id,
|
||||
test: (brushBounds: Bounds): boolean =>
|
||||
(shapes[shape.type] as BaseLibShape<
|
||||
(shapes[shape.type] as ShapeUtil<
|
||||
Shapes[typeof shape.type]
|
||||
>).hitTestBounds(shape, brushBounds),
|
||||
})),
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import BaseSession from "./base-session"
|
||||
import BrushSession from "./brush-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 { clamp, getCommonBounds, screenToWorld } from "utils/utils"
|
||||
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 Shapes from "lib/shapes"
|
||||
import { getShapeUtils } from "lib/shapes"
|
||||
import history from "state/history"
|
||||
import * as Sessions from "./sessions"
|
||||
|
||||
|
@ -43,6 +43,8 @@ const state = createState({
|
|||
on: {
|
||||
POINTED_CANVAS: { to: "brushSelecting" },
|
||||
POINTED_BOUNDS: { to: "pointingBounds" },
|
||||
POINTED_BOUNDS_EDGE: { to: "transformingSelection" },
|
||||
POINTED_BOUNDS_CORNER: { to: "transformingSelection" },
|
||||
POINTED_SHAPE: [
|
||||
"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: {
|
||||
onEnter: "startTranslateSession",
|
||||
on: {
|
||||
|
@ -160,6 +171,7 @@ const state = createState({
|
|||
updateBrushSession(data, payload: PointerInfo) {
|
||||
session.update(data, screenToWorld(payload.point, data))
|
||||
},
|
||||
|
||||
// Dragging / Translating
|
||||
startTranslateSession(data, payload: PointerInfo) {
|
||||
session = new Sessions.TranslateSession(
|
||||
|
@ -171,6 +183,21 @@ const state = createState({
|
|||
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
|
||||
setPointedId(data, payload: PointerInfo) {
|
||||
data.pointedId = payload.target
|
||||
|
@ -224,31 +251,13 @@ const state = createState({
|
|||
document: { pages },
|
||||
} = data
|
||||
|
||||
if (selectedIds.size === 0) return null
|
||||
|
||||
return getCommonBounds(
|
||||
...Array.from(selectedIds.values())
|
||||
.map((id) => {
|
||||
const shape = pages[currentPageId].shapes[id]
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
return getShapeUtils(shape).getBounds(shape)
|
||||
})
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
|
32
types.ts
32
types.ts
|
@ -101,6 +101,22 @@ export interface Bounds {
|
|||
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> {
|
||||
[ShapeType.Dot]: DotShape
|
||||
[ShapeType.Circle]: CircleShape
|
||||
|
@ -120,7 +136,7 @@ export type ShapeSpecificProps<T extends Shape> = Pick<
|
|||
|
||||
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
|
||||
getBounds(shape: K): Bounds
|
||||
hitTest(shape: K, test: number[]): boolean
|
||||
|
@ -142,3 +158,17 @@ export interface PointerInfo {
|
|||
metaKey: 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[][]
|
||||
}
|
||||
|
||||
function getIntersection(
|
||||
points: number[][],
|
||||
message = points.length ? "Intersection" : "No intersection"
|
||||
) {
|
||||
function getIntersection(message: string, ...points: number[][]) {
|
||||
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]
|
||||
|
||||
if (ua_t === 0 || ub_t === 0) {
|
||||
return getIntersection([], "Coincident")
|
||||
return getIntersection("coincident")
|
||||
}
|
||||
|
||||
if (u_b === 0) {
|
||||
return getIntersection([], "Parallel")
|
||||
return getIntersection("parallel")
|
||||
}
|
||||
|
||||
if (u_b != 0) {
|
||||
const ua = ua_t / u_b
|
||||
const ub = ub_t / u_b
|
||||
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(
|
||||
|
@ -68,11 +65,11 @@ export function intersectCircleLineSegment(
|
|||
const deter = b * b - 4 * a * cc
|
||||
|
||||
if (deter < 0) {
|
||||
return { didIntersect: false, message: "outside", points: [] }
|
||||
return getIntersection("outside")
|
||||
}
|
||||
|
||||
if (deter === 0) {
|
||||
return { didIntersect: false, message: "tangent", points: [] }
|
||||
return getIntersection("tangent")
|
||||
}
|
||||
|
||||
var e = Math.sqrt(deter)
|
||||
|
@ -80,17 +77,71 @@ export function intersectCircleLineSegment(
|
|||
var u2 = (-b - e) / (2 * a)
|
||||
if ((u1 < 0 || u1 > 1) && (u2 < 0 || u2 > 1)) {
|
||||
if ((u1 < 0 && u2 < 0) || (u1 > 1 && u2 > 1)) {
|
||||
return { didIntersect: false, message: "outside", points: [] }
|
||||
return getIntersection("outside")
|
||||
} else {
|
||||
return { didIntersect: false, message: "inside", points: [] }
|
||||
return getIntersection("inside")
|
||||
}
|
||||
}
|
||||
|
||||
const result = { didIntersect: true, message: "intersection", points: [] }
|
||||
if (0 <= u1 && u1 <= 1) result.points.push(vec.lrp(a1, a2, u1))
|
||||
if (0 <= u2 && u2 <= 1) result.points.push(vec.lrp(a1, a2, u2))
|
||||
const results: number[][] = []
|
||||
if (0 <= u1 && u1 <= 1) results.push(vec.lrp(a1, a2, u1))
|
||||
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(
|
||||
|
@ -130,6 +181,73 @@ export function intersectCircleRectangle(
|
|||
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(
|
||||
point: number[],
|
||||
size: number[],
|
||||
|
@ -180,6 +298,24 @@ export function intersectCircleBounds(
|
|||
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(
|
||||
a1: 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
|
||||
*/
|
||||
export function rotWith(A: number[], C: number[], r: number) {
|
||||
if (r === 0) return A
|
||||
|
||||
const s = Math.sin(r)
|
||||
const c = Math.cos(r)
|
||||
|
||||
|
|
Loading…
Reference in a new issue