Adds transforming, ellipse

This commit is contained in:
Steve Ruiz 2021-05-14 13:44:23 +01:00
parent d99507de5b
commit b50045c9b7
21 changed files with 1223 additions and 158 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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
}

View file

@ -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

View file

@ -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
View 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

View file

@ -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
View 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

View file

@ -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
View 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

View file

@ -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
},
})

View file

@ -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,

View file

@ -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),
})),

View file

@ -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 }

View 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>

View file

@ -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)
)

View file

@ -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
View 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
}

View file

@ -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
View 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,
},
]
}
}

View file

@ -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)