diff --git a/components/canvas/bounds-bg.tsx b/components/canvas/bounds-bg.tsx index ede0fa68c..d12eec3e0 100644 --- a/components/canvas/bounds-bg.tsx +++ b/components/canvas/bounds-bg.tsx @@ -6,10 +6,14 @@ import styled from "styles" export default function BoundsBg() { const rBounds = useRef(null) const bounds = useSelector((state) => state.values.selectedBounds) - const singleSelection = useSelector((s) => { + + const rotation = useSelector((s) => { if (s.data.selectedIds.size === 1) { + const { shapes } = s.data.document.pages[s.data.currentPageId] const selected = Array.from(s.data.selectedIds.values())[0] - return s.data.document.pages[s.data.currentPageId].shapes[selected] + return shapes[selected].rotation + } else { + return 0 } }) @@ -30,12 +34,9 @@ export default function BoundsBg() { rBounds.current.setPointerCapture(e.pointerId) state.send("POINTED_BOUNDS", inputs.pointerDown(e, "bounds")) }} - transform={ - singleSelection && - `rotate(${singleSelection.rotation * (180 / Math.PI)},${ - minX + width / 2 - }, ${minY + width / 2})` - } + transform={`rotate(${rotation * (180 / Math.PI)},${minX + width / 2}, ${ + minY + height / 2 + })`} /> ) } diff --git a/components/canvas/bounds.tsx b/components/canvas/bounds.tsx index 5315b8227..7718d9817 100644 --- a/components/canvas/bounds.tsx +++ b/components/canvas/bounds.tsx @@ -6,15 +6,19 @@ import { TransformCorner, TransformEdge } from "types" import { lerp } from "utils/utils" export default function Bounds() { - const zoom = useSelector((state) => state.data.camera.zoom) - const bounds = useSelector((state) => state.values.selectedBounds) - const singleSelection = useSelector((s) => { + const isBrushing = useSelector((s) => s.isIn("brushSelecting")) + const zoom = useSelector((s) => s.data.camera.zoom) + const bounds = useSelector((s) => s.values.selectedBounds) + + const rotation = useSelector((s) => { if (s.data.selectedIds.size === 1) { + const { shapes } = s.data.document.pages[s.data.currentPageId] const selected = Array.from(s.data.selectedIds.values())[0] - return s.data.document.pages[s.data.currentPageId].shapes[selected] + return shapes[selected].rotation + } else { + return 0 } }) - const isBrushing = useSelector((state) => state.isIn("brushSelecting")) if (!bounds) return null @@ -26,12 +30,9 @@ export default function Bounds() { return ( ({ boundsCache: new WeakMap([]), @@ -33,27 +34,26 @@ const circle = createShape({ }, getBounds(shape) { - if (this.boundsCache.has(shape)) { - return this.boundsCache.get(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) } - const { - point: [x, y], - radius, - } = shape + return translateBounds(this.boundsCache.get(shape), shape.point) + }, - const bounds = { - minX: x, - maxX: x + radius * 2, - minY: y, - maxY: y + radius * 2, - width: radius * 2, - height: radius * 2, - } - - this.boundsCache.set(shape, bounds) - - return bounds + getRotatedBounds(shape) { + return this.getBounds(shape) }, getCenter(shape) { diff --git a/lib/shapes/dot.tsx b/lib/shapes/dot.tsx index d64dd5d34..b552550ce 100644 --- a/lib/shapes/dot.tsx +++ b/lib/shapes/dot.tsx @@ -6,6 +6,7 @@ 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 = createShape({ boundsCache: new WeakMap([]), @@ -33,26 +34,24 @@ const dot = createShape({ }, getBounds(shape) { - if (this.boundsCache.has(shape)) { - return this.boundsCache.get(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) } - const { - point: [x, y], - } = shape + return translateBounds(this.boundsCache.get(shape), shape.point) + }, - const bounds = { - minX: x, - maxX: x + 1, - minY: y, - maxY: y + 1, - width: 1, - height: 1, - } - - this.boundsCache.set(shape, bounds) - - return bounds + getRotatedBounds(shape) { + return this.getBounds(shape) }, getCenter(shape) { diff --git a/lib/shapes/ellipse.tsx b/lib/shapes/ellipse.tsx index 375e1cc9f..df7b4e2d3 100644 --- a/lib/shapes/ellipse.tsx +++ b/lib/shapes/ellipse.tsx @@ -5,6 +5,7 @@ import { createShape } from "./index" import { boundsContained } from "utils/bounds" import { intersectEllipseBounds } from "utils/intersections" import { pointInEllipse } from "utils/hitTests" +import { translateBounds } from "utils/utils" const ellipse = createShape({ boundsCache: new WeakMap([]), @@ -36,28 +37,26 @@ const ellipse = createShape({ }, getBounds(shape) { - if (this.boundsCache.has(shape)) { - return this.boundsCache.get(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) } - const { - point: [x, y], - radiusX, - radiusY, - } = shape + return translateBounds(this.boundsCache.get(shape), shape.point) + }, - 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 + getRotatedBounds(shape) { + return this.getBounds(shape) }, getCenter(shape) { diff --git a/lib/shapes/index.tsx b/lib/shapes/index.tsx index c5e3ea772..2032d9b32 100644 --- a/lib/shapes/index.tsx +++ b/lib/shapes/index.tsx @@ -36,6 +36,9 @@ export interface ShapeUtility { // Get the bounds of the a shape. getBounds(this: ShapeUtility, shape: K): Bounds + // Get the routated bounds of the a shape. + getRotatedBounds(this: ShapeUtility, shape: K): Bounds + // Get the center of the shape getCenter(this: ShapeUtility, shape: K): number[] diff --git a/lib/shapes/line.tsx b/lib/shapes/line.tsx index 7154ac2ad..414f57417 100644 --- a/lib/shapes/line.tsx +++ b/lib/shapes/line.tsx @@ -5,6 +5,7 @@ import { createShape } 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 = createShape({ boundsCache: new WeakMap([]), @@ -41,26 +42,24 @@ const line = createShape({ }, getBounds(shape) { - if (this.boundsCache.has(shape)) { - return this.boundsCache.get(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) } - const { - point: [x, y], - } = shape + return translateBounds(this.boundsCache.get(shape), shape.point) + }, - const bounds = { - minX: x, - maxX: x + 1, - minY: y, - maxY: y + 1, - width: 1, - height: 1, - } - - this.boundsCache.set(shape, bounds) - - return bounds + getRotatedBounds(shape) { + return this.getBounds(shape) }, getCenter(shape) { diff --git a/lib/shapes/polyline.tsx b/lib/shapes/polyline.tsx index d8c4b6131..247c87c67 100644 --- a/lib/shapes/polyline.tsx +++ b/lib/shapes/polyline.tsx @@ -3,7 +3,12 @@ import * as vec from "utils/vec" import { PolylineShape, ShapeType } from "types" import { createShape } from "./index" import { intersectPolylineBounds } from "utils/intersections" -import { boundsCollide, boundsContained } from "utils/bounds" +import { + boundsCollide, + boundsContained, + boundsContainPolygon, +} from "utils/bounds" +import { getBoundsFromPoints, translateBounds } from "utils/utils" const polyline = createShape({ boundsCache: new WeakMap([]), @@ -29,33 +34,16 @@ const polyline = createShape({ }, getBounds(shape) { - if (this.boundsCache.has(shape)) { - return this.boundsCache.get(shape) + if (!this.boundsCache.has(shape)) { + const bounds = getBoundsFromPoints(shape.points) + this.boundsCache.set(shape, bounds) } - let minX = 0 - let minY = 0 - let maxX = 0 - let maxY = 0 + return translateBounds(this.boundsCache.get(shape), shape.point) + }, - for (let [x, y] of shape.points) { - minX = Math.min(x, minX) - minY = Math.min(y, minY) - maxX = Math.max(x, maxX) - maxY = Math.max(y, maxY) - } - - const bounds = { - minX: minX + shape.point[0], - minY: minY + shape.point[1], - maxX: maxX + shape.point[0], - maxY: maxY + shape.point[1], - width: maxX - minX, - height: maxY - minY, - } - - this.boundsCache.set(shape, bounds) - return bounds + getRotatedBounds(shape) { + return this.getBounds(shape) }, getCenter(shape) { @@ -78,15 +66,23 @@ const polyline = createShape({ return false }, - hitTestBounds(this, shape, bounds) { - const shapeBounds = this.getBounds(shape) + 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 ( - boundsContained(shapeBounds, bounds) || - (boundsCollide(shapeBounds, bounds) && - intersectPolylineBounds( - shape.points.map((point) => vec.add(point, shape.point)), - bounds - ).length > 0) + boundsContainPolygon(brushBounds, rotatedCorners) || + intersectPolylineBounds( + shape.points.map((point) => vec.add(point, shape.point)), + brushBounds + ).length > 0 ) }, diff --git a/lib/shapes/ray.tsx b/lib/shapes/ray.tsx index 7b40f4775..cf42fd108 100644 --- a/lib/shapes/ray.tsx +++ b/lib/shapes/ray.tsx @@ -5,6 +5,7 @@ import { createShape } 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 = createShape({ boundsCache: new WeakMap([]), @@ -40,27 +41,25 @@ const ray = createShape({ ) }, + getRotatedBounds(shape) { + return this.getBounds(shape) + }, + getBounds(shape) { - if (this.boundsCache.has(shape)) { - return this.boundsCache.get(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) } - 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 + return translateBounds(this.boundsCache.get(shape), shape.point) }, getCenter(shape) { diff --git a/lib/shapes/rectangle.tsx b/lib/shapes/rectangle.tsx index 955cfebd6..4d2dd6f69 100644 --- a/lib/shapes/rectangle.tsx +++ b/lib/shapes/rectangle.tsx @@ -2,7 +2,8 @@ import { v4 as uuid } from "uuid" import * as vec from "utils/vec" import { RectangleShape, ShapeType } from "types" import { createShape } from "./index" -import { boundsContained, boundsCollide } from "utils/bounds" +import { boundsCollidePolygon, boundsContainPolygon } from "utils/bounds" +import { getBoundsFromPoints, rotateBounds, translateBounds } from "utils/utils" const rectangle = createShape({ boundsCache: new WeakMap([]), @@ -31,31 +32,40 @@ const rectangle = createShape({ }, getBounds(shape) { - if (this.boundsCache.has(shape)) { - return this.boundsCache.get(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) } - const { - point: [x, y], - size: [width, height], - } = shape + return translateBounds(this.boundsCache.get(shape), shape.point) + }, - const bounds = { - minX: x, - maxX: x + width, - minY: y, - maxY: y + height, - width, - height, - } + getRotatedBounds(shape) { + const b = this.getBounds(shape) + const center = [b.minX + b.width / 2, b.minY + b.height / 2] - this.boundsCache.set(shape, bounds) + // Rotate corners of the shape, then find the minimum among those points. + 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 bounds + return getBoundsFromPoints(rotatedCorners) }, getCenter(shape) { - const bounds = this.getBounds(shape) + const bounds = this.getRotatedBounds(shape) return [bounds.minX + bounds.width / 2, bounds.minY + bounds.height / 2] }, @@ -64,10 +74,19 @@ const rectangle = createShape({ }, hitTestBounds(shape, brushBounds) { - const shapeBounds = this.getBounds(shape) + 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 ( - boundsContained(shapeBounds, brushBounds) || - boundsCollide(shapeBounds, brushBounds) + boundsContainPolygon(brushBounds, rotatedCorners) || + boundsCollidePolygon(brushBounds, rotatedCorners) ) }, diff --git a/state/commands/rotate.ts b/state/commands/rotate.ts index 93ad2b62c..9bd66814a 100644 --- a/state/commands/rotate.ts +++ b/state/commands/rotate.ts @@ -16,16 +16,24 @@ export default function translateCommand( do(data) { const { shapes } = data.document.pages[after.currentPageId] - for (let { id, rotation } of after.shapes) { - shapes[id].rotation = rotation + for (let { id, point, rotation } of after.shapes) { + const shape = shapes[id] + shape.rotation = rotation + shape.point = point } + + data.boundsRotation = after.boundsRotation }, undo(data) { const { shapes } = data.document.pages[before.currentPageId] - for (let { id, rotation } of before.shapes) { - shapes[id].rotation = rotation + for (let { id, point, rotation } of before.shapes) { + const shape = shapes[id] + shape.rotation = rotation + shape.point = point } + + data.boundsRotation = before.boundsRotation }, }) ) diff --git a/state/sessions/brush-session.ts b/state/sessions/brush-session.ts index d1d02987c..8d54d9f16 100644 --- a/state/sessions/brush-session.ts +++ b/state/sessions/brush-session.ts @@ -25,7 +25,7 @@ export default class BrushSession extends BaseSession { update = (data: Data, point: number[]) => { const { origin, snapshot } = this - const brushBounds = getBoundsFromPoints(origin, point) + const brushBounds = getBoundsFromPoints([origin, point]) for (let { test, id } of snapshot.shapes) { if (test(brushBounds)) { diff --git a/state/sessions/rotate-session.ts b/state/sessions/rotate-session.ts index a46b595ea..d3bfa66cd 100644 --- a/state/sessions/rotate-session.ts +++ b/state/sessions/rotate-session.ts @@ -18,24 +18,34 @@ export default class RotateSession extends BaseSession { } update(data: Data, point: number[]) { - const { currentPageId, center, shapes } = this.snapshot + const { currentPageId, boundsCenter, shapes } = this.snapshot const { document } = data - const a1 = vec.angle(center, this.origin) - const a2 = vec.angle(center, point) + const a1 = vec.angle(boundsCenter, this.origin) + const a2 = vec.angle(boundsCenter, point) - for (let { id, rotation } of shapes) { + data.boundsRotation = + (this.snapshot.boundsRotation + (a2 - a1)) % (Math.PI * 2) + + for (let { id, center, offset, rotation } of shapes) { const shape = document.pages[currentPageId].shapes[id] shape.rotation = rotation + ((a2 - a1) % (Math.PI * 2)) + const newCenter = vec.rotWith( + center, + boundsCenter, + (a2 - a1) % (Math.PI * 2) + ) + shape.point = vec.sub(newCenter, offset) } } cancel(data: Data) { const { document } = data - for (let shape of this.snapshot.shapes) { - document.pages[this.snapshot.currentPageId].shapes[shape.id].rotation = - shape.rotation + for (let { id, point, rotation } of this.snapshot.shapes) { + const shape = document.pages[this.snapshot.currentPageId].shapes[id] + shape.rotation = rotation + shape.point = point } } @@ -46,9 +56,10 @@ export default class RotateSession extends BaseSession { export function getRotateSnapshot(data: Data) { const { + boundsRotation, selectedIds, - document: { pages }, currentPageId, + document: { pages }, } = current(data) const shapes = Array.from(selectedIds.values()).map( @@ -63,18 +74,28 @@ export function getRotateSnapshot(data: Data) { // The common (exterior) bounds of the selected shapes const bounds = getCommonBounds(...Object.values(shapesBounds)) - const center = [ + const boundsCenter = [ bounds.minX + bounds.width / 2, bounds.minY + bounds.height / 2, ] return { currentPageId, - center, - shapes: shapes.map(({ id, rotation }) => ({ - id, - rotation, - })), + boundsCenter, + boundsRotation, + shapes: shapes.map(({ id, point, rotation }) => { + const bounds = shapesBounds[id] + const offset = [bounds.width / 2, bounds.height / 2] + const center = vec.add(offset, [bounds.minX, bounds.minY]) + + return { + id, + point, + rotation, + offset, + center, + } + }), } } diff --git a/state/state.ts b/state/state.ts index 7372675fb..be8d568de 100644 --- a/state/state.ts +++ b/state/state.ts @@ -29,6 +29,7 @@ const initialData: Data = { zoom: 1, }, brush: undefined, + boundsRotation: 0, pointedId: null, hoveredId: null, selectedIds: new Set([]), @@ -180,6 +181,7 @@ const state = createState({ brushSelecting: { onEnter: [ { unless: "isPressingShiftKey", do: "clearSelectedIds" }, + "clearBoundsRotation", "startBrushSession", ], on: { @@ -708,6 +710,10 @@ const state = createState({ restoreSavedData(data) { history.load(data) }, + + clearBoundsRotation(data) { + data.boundsRotation = 0 + }, }, values: { selectedIds(data) { @@ -726,12 +732,14 @@ const state = createState({ if (selectedIds.size === 0) return null - if (selectedIds.size === 1 && !getShapeUtils(shapes[0]).canTransform) { - return null + if (selectedIds.size === 1) { + const shapeUtils = getShapeUtils(shapes[0]) + if (!shapeUtils.canTransform) return null + return shapeUtils.getBounds(shapes[0]) } return getCommonBounds( - ...shapes.map((shape) => getShapeUtils(shape).getBounds(shape)) + ...shapes.map((shape) => getShapeUtils(shape).getRotatedBounds(shape)) ) }, }, diff --git a/types.ts b/types.ts index bd34c00e6..73c83f09c 100644 --- a/types.ts +++ b/types.ts @@ -18,6 +18,7 @@ export interface Data { zoom: number } brush?: Bounds + boundsRotation: number selectedIds: Set pointedId?: string hoveredId?: string @@ -168,6 +169,10 @@ export interface Bounds { height: number } +export interface RotatedBounds extends Bounds { + rotation: number +} + export interface ShapeBounds extends Bounds { id: string } diff --git a/utils/bounds.ts b/utils/bounds.ts index 55780968f..5cd50316f 100644 --- a/utils/bounds.ts +++ b/utils/bounds.ts @@ -1,4 +1,8 @@ import { Bounds } from "types" +import { + intersectPolygonBounds, + intersectPolylineBounds, +} from "./intersections" /** * Get whether two bounds collide. @@ -37,6 +41,23 @@ export function boundsContained(a: Bounds, b: Bounds) { return boundsContain(b, a) } +/** + * Get whether a set of points are all contained by a bounding box. + * @returns + */ +export function boundsContainPolygon(a: Bounds, points: number[][]) { + return points.every((point) => pointInBounds(point, a)) +} + +/** + * Get whether a polygon collides a bounding box. + * @param points + * @param b + */ +export function boundsCollidePolygon(a: Bounds, points: number[][]) { + return intersectPolygonBounds(points, a).length > 0 +} + /** * Get whether two bounds are identical. * @param a Bounds diff --git a/utils/intersections.ts b/utils/intersections.ts index 8ffd14bca..553fcc646 100644 --- a/utils/intersections.ts +++ b/utils/intersections.ts @@ -342,3 +342,21 @@ export function intersectPolylineBounds(points: number[][], bounds: Bounds) { return intersections } + +export function intersectPolygonBounds(points: number[][], bounds: Bounds) { + const { minX, minY, width, height } = bounds + const intersections: Intersection[] = [] + + for (let i = 1; i < points.length + 1; i++) { + intersections.push( + ...intersectRectangleLineSegment( + [minX, minY], + [width, height], + points[i - 1], + points[i % points.length] + ) + ) + } + + return intersections +} diff --git a/utils/utils.ts b/utils/utils.ts index a4066f44f..e6b782900 100644 --- a/utils/utils.ts +++ b/utils/utils.ts @@ -41,21 +41,21 @@ export function getCommonBounds(...b: Bounds[]) { return bounds } -export function getBoundsFromPoints(a: number[], b: number[]) { - const minX = Math.min(a[0], b[0]) - const maxX = Math.max(a[0], b[0]) - const minY = Math.min(a[1], b[1]) - const maxY = Math.max(a[1], b[1]) +// export function getBoundsFromPoints(a: number[], b: number[]) { +// const minX = Math.min(a[0], b[0]) +// const maxX = Math.max(a[0], b[0]) +// const minY = Math.min(a[1], b[1]) +// const maxY = Math.max(a[1], b[1]) - return { - minX, - maxX, - minY, - maxY, - width: maxX - minX, - height: maxY - minY, - } -} +// return { +// minX, +// maxX, +// minY, +// maxY, +// width: maxX - minX, +// height: maxY - minY, +// } +// } // A helper for getting tangents. export function getCircleTangentToPoint( @@ -962,3 +962,61 @@ export function vectorToPoint(point: number[] | Vector | undefined) { } return point } + +export function getBoundsFromPoints(points: number[][]): Bounds { + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + + for (let [x, y] of points) { + minX = Math.min(x, minX) + minY = Math.min(y, minY) + maxX = Math.max(x, maxX) + maxY = Math.max(y, maxY) + } + + return { + minX, + minY, + maxX, + maxY, + width: maxX - minX, + height: maxY - minY, + } +} + +/** + * Move a bounding box without recalculating it. + * @param bounds + * @param delta + * @returns + */ +export function translateBounds(bounds: Bounds, delta: number[]) { + return { + minX: bounds.minX + delta[0], + minY: bounds.minY + delta[1], + maxX: bounds.maxX + delta[0], + maxY: bounds.maxY + delta[1], + width: bounds.width, + height: bounds.height, + } +} + +export function rotateBounds( + bounds: Bounds, + center: number[], + rotation: number +) { + const [minX, minY] = vec.rotWith([bounds.minX, bounds.minY], center, rotation) + const [maxX, maxY] = vec.rotWith([bounds.maxX, bounds.maxY], center, rotation) + + return { + minX, + minY, + maxX, + maxY, + width: bounds.width, + height: bounds.height, + } +}