diff --git a/components/canvas/bounds.tsx b/components/canvas/bounds.tsx index 39a6c1ccd..fe64907e3 100644 --- a/components/canvas/bounds.tsx +++ b/components/canvas/bounds.tsx @@ -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 ( {width * zoom > 8 && ( <> - - - - + + + + )} @@ -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(null) const rCorner = useRef(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(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(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", diff --git a/components/canvas/shape.tsx b/components/canvas/shape.tsx index 46e3a0f70..652adfef3 100644 --- a/components/canvas/shape.tsx +++ b/components/canvas/shape.tsx @@ -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 ( - - {shapes[shape.type] ? shapes[shape.type].render(shape) : null} - + {getShapeUtils(shape).render(shape)} @@ -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", diff --git a/lib/shapes/base-shape.tsx b/lib/shapes/base-shape.tsx deleted file mode 100644 index 358656d5f..000000000 --- a/lib/shapes/base-shape.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Bounds, Shape } from "types" - -export default interface BaseLibShape { - create(props: Partial): K - getBounds(this: BaseLibShape, shape: K): Bounds - hitTest(this: BaseLibShape, shape: K, test: number[]): boolean - hitTestBounds(this: BaseLibShape, shape: K, bounds: Bounds): boolean - rotate(this: BaseLibShape, shape: K): K - translate(this: BaseLibShape, shape: K, delta: number[]): K - scale(this: BaseLibShape, shape: K, scale: number): K - stretch(this: BaseLibShape, shape: K, scaleX: number, scaleY: number): K - render(this: BaseLibShape, shape: K): JSX.Element -} - -export function createShape( - shape: BaseLibShape -): BaseLibShape { - return shape -} diff --git a/lib/shapes/circle.tsx b/lib/shapes/circle.tsx index 8c051288f..b2adc6f17 100644 --- a/lib/shapes/circle.tsx +++ b/lib/shapes/circle.tsx @@ -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({ + boundsCache: new WeakMap([]), + create(props) { return { id: uuid(), @@ -27,8 +28,8 @@ const circle = createShape({ }, 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({ height: radius * 2, } - boundsCache.set(shape, bounds) + this.boundsCache.set(shape, bounds) + return bounds }, @@ -84,6 +86,13 @@ const circle = createShape({ 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 diff --git a/lib/shapes/dot.tsx b/lib/shapes/dot.tsx index 60629b2c3..27d0e362d 100644 --- a/lib/shapes/dot.tsx +++ b/lib/shapes/dot.tsx @@ -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({ + boundsCache: new WeakMap([]), + create(props) { return { id: uuid(), @@ -22,12 +23,12 @@ const dot = createShape({ }, render({ id }) { - return + return }, 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({ 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({ stretch(shape, scaleX: number, scaleY: number) { return shape }, + + transform(shape, bounds) { + shape.point = [bounds.minX, bounds.minY] + + return shape + }, }) export default dot diff --git a/lib/shapes/ellipse.tsx b/lib/shapes/ellipse.tsx new file mode 100644 index 000000000..bb00a9da6 --- /dev/null +++ b/lib/shapes/ellipse.tsx @@ -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({ + 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 ( + + ) + }, + + 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 diff --git a/lib/shapes/index.tsx b/lib/shapes/index.tsx index 0c12afd49..4d61394d9 100644 --- a/lib/shapes/index.tsx +++ b/lib/shapes/index.tsx @@ -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([]) +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 { + // A cache for the computed bounds of this kind of shape. + boundsCache: WeakMap + + // Create a new shape. + create(props: Partial): K + + // Get the bounds of the a shape. + getBounds(this: ShapeUtility, shape: K): Bounds + + // Test whether a point lies within a shape. + hitTest(this: ShapeUtility, shape: K, test: number[]): boolean + + // Test whether bounds collide with or contain a shape. + hitTestBounds(this: ShapeUtility, shape: K, bounds: Bounds): boolean + + // Apply a rotation to a shape. + rotate(this: ShapeUtility, shape: K): K + + // Apply a translation to a shape. + translate(this: ShapeUtility, shape: K, delta: number[]): K + + // Transform to fit a new bounding box. + transform(this: ShapeUtility, shape: K, bounds: Bounds): K + + // Apply a scale to a shape. + scale(this: ShapeUtility, shape: K, scale: number): K + + // Apply a stretch to a shape. + stretch(this: ShapeUtility, shape: K, scaleX: number, scaleY: number): K + + // Render a shape to JSX. + render(this: ShapeUtility, shape: K): JSX.Element } -export default shapes +// A mapping of shape types to shape utilities. +const shapeUtilityMap: { [key in ShapeType]: ShapeUtility } = { + [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 { + return shapeUtilityMap[shape.type] +} + +/** + * A factory of shape utilities, with typing enforced. + * @param shape + * @returns + */ +export function createShape( + shape: ShapeUtility +): ShapeUtility { + return Object.freeze(shape) +} + +export default shapeUtilityMap diff --git a/lib/shapes/line.tsx b/lib/shapes/line.tsx new file mode 100644 index 000000000..54c2db6b5 --- /dev/null +++ b/lib/shapes/line.tsx @@ -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({ + 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 + }, + + 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 diff --git a/lib/shapes/polyline.tsx b/lib/shapes/polyline.tsx index 4ea7e5820..d00ea8e2b 100644 --- a/lib/shapes/polyline.tsx +++ b/lib/shapes/polyline.tsx @@ -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({ + boundsCache: new WeakMap([]), + create(props) { return { id: uuid(), @@ -27,8 +28,8 @@ const polyline = createShape({ }, 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({ height: maxY - minY, } - boundsCache.set(shape, bounds) + this.boundsCache.set(shape, bounds) return bounds }, @@ -88,6 +89,21 @@ const polyline = createShape({ 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 diff --git a/lib/shapes/ray.tsx b/lib/shapes/ray.tsx new file mode 100644 index 000000000..1f9895a5d --- /dev/null +++ b/lib/shapes/ray.tsx @@ -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({ + 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 + }, + + 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 diff --git a/lib/shapes/rectangle.tsx b/lib/shapes/rectangle.tsx index 4c9203d0c..11882859b 100644 --- a/lib/shapes/rectangle.tsx +++ b/lib/shapes/rectangle.tsx @@ -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({ + boundsCache: new WeakMap([]), + create(props) { return { id: uuid(), @@ -26,8 +27,8 @@ const rectangle = createShape({ }, 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({ height, } - boundsCache.set(shape, bounds) + this.boundsCache.set(shape, bounds) + return bounds }, @@ -74,6 +76,14 @@ const rectangle = createShape({ }, 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 }, }) diff --git a/state/data.ts b/state/data.ts index 46f34bae9..7a83525fb 100644 --- a/state/data.ts +++ b/state/data.ts @@ -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, diff --git a/state/sessions/brush-session.ts b/state/sessions/brush-session.ts index fc1722b86..80e962fb4 100644 --- a/state/sessions/brush-session.ts +++ b/state/sessions/brush-session.ts @@ -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), })), diff --git a/state/sessions/index.ts b/state/sessions/index.ts index f285bb2f8..89bf4f87e 100644 --- a/state/sessions/index.ts +++ b/state/sessions/index.ts @@ -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 } diff --git a/state/sessions/transform-session.ts b/state/sessions/transform-session.ts new file mode 100644 index 000000000..01ed7b3c6 --- /dev/null +++ b/state/sessions/transform-session.ts @@ -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 diff --git a/state/state.ts b/state/state.ts index 2deaed136..694556837 100644 --- a/state/state.ts +++ b/state/state.ts @@ -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) ) diff --git a/types.ts b/types.ts index 1e4526528..bc898e23f 100644 --- a/types.ts +++ b/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.Dot]: DotShape [ShapeType.Circle]: CircleShape @@ -120,7 +136,7 @@ export type ShapeSpecificProps = Pick< export type ShapeIndicatorProps = ShapeSpecificProps -export type BaseLibShape = { +export type ShapeUtil = { create(props: Partial): 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", +} diff --git a/utils/hitTests.ts b/utils/hitTests.ts new file mode 100644 index 000000000..ad120cb78 --- /dev/null +++ b/utils/hitTests.ts @@ -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 +} diff --git a/utils/intersections.ts b/utils/intersections.ts index 9ea7a18b3..8ffd14bca 100644 --- a/utils/intersections.ts +++ b/utils/intersections.ts @@ -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[], diff --git a/utils/transforms.ts b/utils/transforms.ts new file mode 100644 index 000000000..3ff4fab4b --- /dev/null +++ b/utils/transforms.ts @@ -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 { + const acc = {} as Record + + 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, + }, + ] + } +} diff --git a/utils/vec.ts b/utils/vec.ts index 3ec15fc66..8997bca3f 100644 --- a/utils/vec.ts +++ b/utils/vec.ts @@ -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)