renames shapes -> shape utils

This commit is contained in:
Steve Ruiz 2021-05-20 10:49:40 +01:00
parent eea9af5f31
commit 10b0c50294
22 changed files with 29 additions and 48 deletions

173
lib/shape-utils/circle.tsx Normal file
View file

@ -0,0 +1,173 @@
import { v4 as uuid } from "uuid"
import * as vec from "utils/vec"
import { CircleShape, ShapeType, TransformCorner, TransformEdge } from "types"
import { registerShapeUtils } from "./index"
import { boundsContained } from "utils/bounds"
import { intersectCircleBounds } from "utils/intersections"
import { pointInCircle } from "utils/hitTests"
import { getTransformAnchor, translateBounds } from "utils/utils"
const circle = registerShapeUtils<CircleShape>({
boundsCache: new WeakMap([]),
create(props) {
return {
id: uuid(),
type: ShapeType.Circle,
isGenerated: false,
name: "Circle",
parentId: "page0",
childIndex: 0,
point: [0, 0],
rotation: 0,
radius: 20,
style: {
fill: "#c6cacb",
stroke: "#000",
},
...props,
}
},
render({ id, radius }) {
return <circle id={id} cx={radius} cy={radius} r={radius} />
},
getBounds(shape) {
if (!this.boundsCache.has(shape)) {
const { radius } = shape
const bounds = {
minX: 0,
maxX: radius * 2,
minY: 0,
maxY: radius * 2,
width: radius * 2,
height: radius * 2,
}
this.boundsCache.set(shape, bounds)
}
return translateBounds(this.boundsCache.get(shape), shape.point)
},
getRotatedBounds(shape) {
return this.getBounds(shape)
},
getCenter(shape) {
return [shape.point[0] + shape.radius, shape.point[1] + shape.radius]
},
hitTest(shape, point) {
return pointInCircle(
point,
vec.addScalar(shape.point, shape.radius),
shape.radius
)
},
hitTestBounds(shape, bounds) {
const shapeBounds = this.getBounds(shape)
return (
boundsContained(shapeBounds, bounds) ||
intersectCircleBounds(
vec.addScalar(shape.point, shape.radius),
shape.radius,
bounds
).length > 0
)
},
rotate(shape) {
return shape
},
translate(shape, delta) {
shape.point = vec.add(shape.point, delta)
return shape
},
scale(shape, scale) {
return shape
},
transform(shape, bounds, { type, initialShape, scaleX, scaleY }) {
const anchor = getTransformAnchor(type, scaleX < 0, scaleY < 0)
// Set the new corner or position depending on the anchor
switch (anchor) {
case TransformCorner.TopLeft: {
shape.radius = Math.min(bounds.width, bounds.height) / 2
shape.point = [
bounds.maxX - shape.radius * 2,
bounds.maxY - shape.radius * 2,
]
break
}
case TransformCorner.TopRight: {
shape.radius = Math.min(bounds.width, bounds.height) / 2
shape.point = [bounds.minX, bounds.maxY - shape.radius * 2]
break
}
case TransformCorner.BottomRight: {
shape.radius = Math.min(bounds.width, bounds.height) / 2
shape.point = [
bounds.maxX - shape.radius * 2,
bounds.maxY - shape.radius * 2,
]
break
break
}
case TransformCorner.BottomLeft: {
shape.radius = Math.min(bounds.width, bounds.height) / 2
shape.point = [bounds.maxX - shape.radius * 2, bounds.minY]
break
}
case TransformEdge.Top: {
shape.radius = bounds.height / 2
shape.point = [
bounds.minX + (bounds.width / 2 - shape.radius),
bounds.minY,
]
break
}
case TransformEdge.Right: {
shape.radius = bounds.width / 2
shape.point = [
bounds.maxX - shape.radius * 2,
bounds.minY + (bounds.height / 2 - shape.radius),
]
break
}
case TransformEdge.Bottom: {
shape.radius = bounds.height / 2
shape.point = [
bounds.minX + (bounds.width / 2 - shape.radius),
bounds.maxY - shape.radius * 2,
]
break
}
case TransformEdge.Left: {
shape.radius = bounds.width / 2
shape.point = [
bounds.minX,
bounds.minY + (bounds.height / 2 - shape.radius),
]
break
}
}
return shape
},
transformSingle(shape, bounds, info) {
return this.transform(shape, bounds, info)
},
canTransform: true,
})
export default circle

99
lib/shape-utils/dot.tsx Normal file
View file

@ -0,0 +1,99 @@
import { v4 as uuid } from "uuid"
import * as vec from "utils/vec"
import { DotShape, ShapeType } from "types"
import { registerShapeUtils } from "./index"
import { boundsContained } from "utils/bounds"
import { intersectCircleBounds } from "utils/intersections"
import styled from "styles"
import { DotCircle } from "components/canvas/misc"
import { translateBounds } from "utils/utils"
const dot = registerShapeUtils<DotShape>({
boundsCache: new WeakMap([]),
create(props) {
return {
id: uuid(),
type: ShapeType.Dot,
isGenerated: false,
name: "Dot",
parentId: "page0",
childIndex: 0,
point: [0, 0],
rotation: 0,
style: {
fill: "#c6cacb",
stroke: "#000",
},
...props,
}
},
render({ id }) {
return <DotCircle id={id} cx={0} cy={0} r={4} />
},
getBounds(shape) {
if (!this.boundsCache.has(shape)) {
const bounds = {
minX: 0,
maxX: 1,
minY: 0,
maxY: 1,
width: 1,
height: 1,
}
this.boundsCache.set(shape, bounds)
}
return translateBounds(this.boundsCache.get(shape), shape.point)
},
getRotatedBounds(shape) {
return this.getBounds(shape)
},
getCenter(shape) {
return shape.point
},
hitTest(shape, test) {
return true
},
hitTestBounds(this, shape, brushBounds) {
const shapeBounds = this.getBounds(shape)
return (
boundsContained(shapeBounds, brushBounds) ||
intersectCircleBounds(shape.point, 4, brushBounds).length > 0
)
},
rotate(shape) {
return shape
},
scale(shape, scale: number) {
return shape
},
translate(shape, delta) {
shape.point = vec.add(shape.point, delta)
return shape
},
transform(shape, bounds) {
shape.point = [bounds.minX, bounds.minY]
return shape
},
transformSingle(shape, bounds, info) {
return this.transform(shape, bounds, info)
},
canTransform: false,
})
export default dot

126
lib/shape-utils/ellipse.tsx Normal file
View file

@ -0,0 +1,126 @@
import { v4 as uuid } from "uuid"
import * as vec from "utils/vec"
import { EllipseShape, ShapeType } from "types"
import { registerShapeUtils } from "./index"
import { boundsContained } from "utils/bounds"
import { intersectEllipseBounds } from "utils/intersections"
import { pointInEllipse } from "utils/hitTests"
import {
getBoundsFromPoints,
getRotatedCorners,
rotateBounds,
translateBounds,
} from "utils/utils"
const ellipse = registerShapeUtils<EllipseShape>({
boundsCache: new WeakMap([]),
create(props) {
return {
id: uuid(),
type: ShapeType.Ellipse,
isGenerated: false,
name: "Ellipse",
parentId: "page0",
childIndex: 0,
point: [0, 0],
radiusX: 20,
radiusY: 20,
rotation: 0,
style: {
fill: "#c6cacb",
stroke: "#000",
},
...props,
}
},
render({ id, radiusX, radiusY }) {
return (
<ellipse id={id} cx={radiusX} cy={radiusY} rx={radiusX} ry={radiusY} />
)
},
getBounds(shape) {
if (!this.boundsCache.has(shape)) {
const { radiusX, radiusY } = shape
const bounds = {
minX: 0,
maxX: radiusX * 2,
minY: 0,
maxY: radiusY * 2,
width: radiusX * 2,
height: radiusY * 2,
}
this.boundsCache.set(shape, bounds)
}
return translateBounds(this.boundsCache.get(shape), shape.point)
},
getRotatedBounds(shape) {
return getBoundsFromPoints(getRotatedCorners(shape))
},
getCenter(shape) {
return [shape.point[0] + shape.radiusX, shape.point[1] + shape.radiusY]
},
hitTest(shape, point) {
return pointInEllipse(
point,
vec.add(shape.point, [shape.radiusX, shape.radiusY]),
shape.radiusX,
shape.radiusY,
shape.rotation
)
},
hitTestBounds(this, shape, brushBounds) {
// TODO: Account for rotation
const shapeBounds = this.getBounds(shape)
return (
boundsContained(shapeBounds, brushBounds) ||
intersectEllipseBounds(
vec.add(shape.point, [shape.radiusX, shape.radiusY]),
shape.radiusX,
shape.radiusY,
brushBounds,
shape.rotation
).length > 0
)
},
rotate(shape) {
return shape
},
translate(shape, delta) {
shape.point = vec.add(shape.point, delta)
return shape
},
scale(shape, scale: number) {
return shape
},
transform(shape, bounds) {
shape.point = [bounds.minX, bounds.minY]
shape.radiusX = bounds.width / 2
shape.radiusY = bounds.height / 2
return shape
},
transformSingle(shape, bounds, info) {
return this.transform(shape, bounds, info)
},
canTransform: true,
})
export default ellipse

123
lib/shape-utils/index.tsx Normal file
View file

@ -0,0 +1,123 @@
import {
Bounds,
BoundsSnapshot,
Shape,
Shapes,
ShapeType,
TransformCorner,
TransformEdge,
} 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"
/*
Shape Utiliies
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
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
// Get the routated bounds of the a shape.
getRotatedBounds(this: ShapeUtility<K>, shape: K): Bounds
// Get the center of the shape
getCenter(this: ShapeUtility<K>, shape: K): number[]
// 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,
info: {
type: TransformEdge | TransformCorner
initialShape: K
scaleX: number
scaleY: number
}
): K
transformSingle(
this: ShapeUtility<K>,
shape: K,
bounds: Bounds,
info: {
type: TransformEdge | TransformCorner
initialShape: K
scaleX: number
scaleY: number
}
): K
// Apply a scale to a shape.
scale(this: ShapeUtility<K>, shape: K, scale: number): K
// Render a shape to JSX.
render(this: ShapeUtility<K>, shape: K): JSX.Element
// Whether to show transform controls when this shape is selected.
canTransform: boolean
}
// 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 registerShapeUtils<T extends Shape>(
shape: ShapeUtility<T>
): ShapeUtility<T> {
return Object.freeze(shape)
}
export default shapeUtilityMap

107
lib/shape-utils/line.tsx Normal file
View file

@ -0,0 +1,107 @@
import { v4 as uuid } from "uuid"
import * as vec from "utils/vec"
import { LineShape, ShapeType } from "types"
import { registerShapeUtils } from "./index"
import { boundsContained } from "utils/bounds"
import { intersectCircleBounds } from "utils/intersections"
import { DotCircle } from "components/canvas/misc"
import { translateBounds } from "utils/utils"
const line = registerShapeUtils<LineShape>({
boundsCache: new WeakMap([]),
create(props) {
return {
id: uuid(),
type: ShapeType.Line,
isGenerated: false,
name: "Line",
parentId: "page0",
childIndex: 0,
point: [0, 0],
direction: [0, 0],
rotation: 0,
style: {
fill: "#c6cacb",
stroke: "#000",
},
...props,
}
},
render({ id, direction }) {
const [x1, y1] = vec.add([0, 0], vec.mul(direction, 100000))
const [x2, y2] = vec.sub([0, 0], vec.mul(direction, 100000))
return (
<g id={id}>
<line x1={x1} y1={y1} x2={x2} y2={y2} />
<DotCircle cx={0} cy={0} r={4} />
</g>
)
},
getBounds(shape) {
if (!this.boundsCache.has(shape)) {
const bounds = {
minX: 0,
maxX: 1,
minY: 0,
maxY: 1,
width: 1,
height: 1,
}
this.boundsCache.set(shape, bounds)
}
return translateBounds(this.boundsCache.get(shape), shape.point)
},
getRotatedBounds(shape) {
return this.getBounds(shape)
},
getCenter(shape) {
return shape.point
},
hitTest(shape, test) {
return true
},
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
},
transform(shape, bounds) {
shape.point = [bounds.minX, bounds.minY]
return shape
},
transformSingle(shape, bounds, info) {
return this.transform(shape, bounds, info)
},
canTransform: false,
})
export default line

View file

@ -0,0 +1,133 @@
import { v4 as uuid } from "uuid"
import * as vec from "utils/vec"
import { PolylineShape, ShapeType } from "types"
import { registerShapeUtils } from "./index"
import { intersectPolylineBounds } from "utils/intersections"
import {
boundsCollide,
boundsContained,
boundsContainPolygon,
} from "utils/bounds"
import { getBoundsFromPoints, translateBounds } from "utils/utils"
const polyline = registerShapeUtils<PolylineShape>({
boundsCache: new WeakMap([]),
create(props) {
return {
id: uuid(),
type: ShapeType.Polyline,
isGenerated: false,
name: "Polyline",
parentId: "page0",
childIndex: 0,
point: [0, 0],
points: [[0, 0]],
rotation: 0,
style: {},
...props,
}
},
render({ id, points }) {
return <polyline id={id} points={points.toString()} />
},
getBounds(shape) {
if (!this.boundsCache.has(shape)) {
const bounds = getBoundsFromPoints(shape.points)
this.boundsCache.set(shape, bounds)
}
return translateBounds(this.boundsCache.get(shape), shape.point)
},
getRotatedBounds(shape) {
return this.getBounds(shape)
},
getCenter(shape) {
const bounds = this.getBounds(shape)
return [bounds.minX + bounds.width / 2, bounds.minY + bounds.height / 2]
},
hitTest(shape, point) {
let pt = vec.sub(point, shape.point)
let prev = shape.points[0]
for (let i = 1; i < shape.points.length; i++) {
let curr = shape.points[i]
if (vec.distanceToLineSegment(prev, curr, pt) < 4) {
return true
}
prev = curr
}
return false
},
hitTestBounds(this, shape, brushBounds) {
const b = this.getBounds(shape)
const center = [b.minX + b.width / 2, b.minY + b.height / 2]
const rotatedCorners = [
[b.minX, b.minY],
[b.maxX, b.minY],
[b.maxX, b.maxY],
[b.minX, b.maxY],
].map((point) => vec.rotWith(point, center, shape.rotation))
return (
boundsContainPolygon(brushBounds, rotatedCorners) ||
intersectPolylineBounds(
shape.points.map((point) => vec.add(point, shape.point)),
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
},
transform(
shape,
bounds,
{ initialShape, initialShapeBounds, isFlippedX, isFlippedY }
) {
shape.points = shape.points.map((_, i) => {
const [x, y] = initialShape.points[i]
return [
bounds.width *
(isFlippedX
? 1 - x / initialShapeBounds.width
: x / initialShapeBounds.width),
bounds.height *
(isFlippedY
? 1 - y / initialShapeBounds.height
: y / initialShapeBounds.height),
]
})
shape.point = [bounds.minX, bounds.minY]
return shape
},
transformSingle(shape, bounds, info) {
return this.transform(shape, bounds, info)
},
canTransform: true,
})
export default polyline

103
lib/shape-utils/ray.tsx Normal file
View file

@ -0,0 +1,103 @@
import { v4 as uuid } from "uuid"
import * as vec from "utils/vec"
import { RayShape, ShapeType } from "types"
import { registerShapeUtils } from "./index"
import { boundsContained } from "utils/bounds"
import { intersectCircleBounds } from "utils/intersections"
import { DotCircle } from "components/canvas/misc"
import { translateBounds } from "utils/utils"
const ray = registerShapeUtils<RayShape>({
boundsCache: new WeakMap([]),
create(props) {
return {
id: uuid(),
type: ShapeType.Ray,
isGenerated: false,
name: "Ray",
parentId: "page0",
childIndex: 0,
point: [0, 0],
direction: [0, 1],
rotation: 0,
style: {
fill: "#c6cacb",
stroke: "#000",
strokeWidth: 1,
},
...props,
}
},
render({ id, direction }) {
const [x2, y2] = vec.add([0, 0], vec.mul(direction, 100000))
return (
<g id={id}>
<line x1={0} y1={0} x2={x2} y2={y2} />
<DotCircle cx={0} cy={0} r={4} />
</g>
)
},
getRotatedBounds(shape) {
return this.getBounds(shape)
},
getBounds(shape) {
if (!this.boundsCache.has(shape)) {
const bounds = {
minX: 0,
maxX: 1,
minY: 0,
maxY: 1,
width: 1,
height: 1,
}
this.boundsCache.set(shape, bounds)
}
return translateBounds(this.boundsCache.get(shape), shape.point)
},
getCenter(shape) {
return shape.point
},
hitTest(shape, test) {
return true
},
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
},
transform(shape, bounds) {
shape.point = [bounds.minX, bounds.minY]
return shape
},
canTransform: false,
})
export default ray

View file

@ -0,0 +1,136 @@
import { v4 as uuid } from "uuid"
import * as vec from "utils/vec"
import {
RectangleShape,
ShapeType,
TransformCorner,
TransformEdge,
} from "types"
import { registerShapeUtils } from "./index"
import { boundsCollidePolygon, boundsContainPolygon } from "utils/bounds"
import {
getBoundsFromPoints,
getRotatedCorners,
rotateBounds,
translateBounds,
} from "utils/utils"
const rectangle = registerShapeUtils<RectangleShape>({
boundsCache: new WeakMap([]),
create(props) {
return {
id: uuid(),
type: ShapeType.Rectangle,
isGenerated: false,
name: "Rectangle",
parentId: "page0",
childIndex: 0,
point: [0, 0],
size: [1, 1],
rotation: 0,
style: {
fill: "#c6cacb",
stroke: "#000",
},
...props,
}
},
render({ id, size }) {
return <rect id={id} width={size[0]} height={size[1]} />
},
getBounds(shape) {
if (!this.boundsCache.has(shape)) {
const [width, height] = shape.size
const bounds = {
minX: 0,
maxX: width,
minY: 0,
maxY: height,
width,
height,
}
this.boundsCache.set(shape, bounds)
}
return translateBounds(this.boundsCache.get(shape), shape.point)
},
getRotatedBounds(shape) {
return getBoundsFromPoints(
getRotatedCorners(this.getBounds(shape), shape.rotation)
)
},
getCenter(shape) {
const bounds = this.getRotatedBounds(shape)
return [bounds.minX + bounds.width / 2, bounds.minY + bounds.height / 2]
},
hitTest(shape) {
return true
},
hitTestBounds(shape, brushBounds) {
const rotatedCorners = getRotatedCorners(
this.getBounds(shape),
shape.rotation
)
return (
boundsContainPolygon(brushBounds, rotatedCorners) ||
boundsCollidePolygon(brushBounds, rotatedCorners)
)
},
rotate(shape) {
return shape
},
translate(shape, delta) {
shape.point = vec.add(shape.point, delta)
return shape
},
scale(shape, scale) {
return shape
},
transform(shape, bounds, { initialShape, scaleX, scaleY }) {
if (shape.rotation === 0) {
shape.size = [bounds.width, bounds.height]
shape.point = [bounds.minX, bounds.minY]
} else {
// Center shape in resized bounds
shape.size = vec.mul(
initialShape.size,
Math.min(Math.abs(scaleX), Math.abs(scaleY))
)
shape.point = vec.sub(
vec.med([bounds.minX, bounds.minY], [bounds.maxX, bounds.maxY]),
vec.div(shape.size, 2)
)
}
// Set rotation for flipped shapes
shape.rotation = initialShape.rotation
if (scaleX < 0) shape.rotation *= -1
if (scaleY < 0) shape.rotation *= -1
return shape
},
transformSingle(shape, bounds) {
shape.size = [bounds.width, bounds.height]
shape.point = [bounds.minX, bounds.minY]
return shape
},
canTransform: true,
})
export default rectangle