diff --git a/components/canvas/shape.tsx b/components/canvas/shape.tsx index aa702ef9d..72f940129 100644 --- a/components/canvas/shape.tsx +++ b/components/canvas/shape.tsx @@ -2,6 +2,7 @@ import { memo } from "react" import { useSelector } from "state" import { ShapeType } from "types" import Circle from "./shapes/circle" +import Dot from "./shapes/dot" import Rectangle from "./shapes/rectangle" /* @@ -17,6 +18,8 @@ function Shape({ id }: { id: string }) { }) switch (shape.type) { + case ShapeType.Dot: + return case ShapeType.Circle: return case ShapeType.Rectangle: diff --git a/components/canvas/shapes/circle.tsx b/components/canvas/shapes/circle.tsx index f7905ce54..abe96ca0d 100644 --- a/components/canvas/shapes/circle.tsx +++ b/components/canvas/shapes/circle.tsx @@ -1,5 +1,49 @@ +import state, { useSelector } from "state" import { CircleShape } from "types" +import ShapeGroup from "./shape-group" +import { getPointerEventInfo } from "utils/utils" -export default function Circle({ point, radius }: CircleShape) { - return +interface BaseCircleProps { + point: number[] + radius: number + fill?: string + stroke?: string + strokeWidth?: number +} + +function BaseCircle({ + point, + radius, + fill = "#ccc", + stroke = "none", + strokeWidth = 0, +}: BaseCircleProps) { + return ( + + ) +} + +export default function Circle({ id, point, radius }: CircleShape) { + const isSelected = useSelector((state) => state.values.selectedIds.has(id)) + return ( + + + {isSelected && ( + + )} + + ) } diff --git a/components/canvas/shapes/dot.tsx b/components/canvas/shapes/dot.tsx new file mode 100644 index 000000000..addf46fe0 --- /dev/null +++ b/components/canvas/shapes/dot.tsx @@ -0,0 +1,50 @@ +import { useSelector } from "state" +import { DotShape } from "types" +import ShapeGroup from "./shape-group" + +interface BaseCircleProps { + point: number[] + fill?: string + stroke?: string + strokeWidth?: number +} + +function BaseDot({ + point, + fill = "#ccc", + stroke = "none", + strokeWidth = 0, +}: BaseCircleProps) { + return ( + + + + + ) +} + +export default function Dot({ id, point }: DotShape) { + const isSelected = useSelector((state) => state.values.selectedIds.has(id)) + return ( + + + {isSelected && ( + + )} + + ) +} diff --git a/components/canvas/shapes/rectangle.tsx b/components/canvas/shapes/rectangle.tsx index 0429d1a06..5bbd68964 100644 --- a/components/canvas/shapes/rectangle.tsx +++ b/components/canvas/shapes/rectangle.tsx @@ -1,13 +1,49 @@ +import { useSelector } from "state" import { RectangleShape } from "types" +import ShapeGroup from "./shape-group" -export default function Rectangle({ point, size }: RectangleShape) { +interface BaseRectangleProps { + point: number[] + size: number[] + fill?: string + stroke?: string + strokeWidth?: number +} + +function BaseRectangle({ + point, + size, + fill = "#ccc", + stroke = "none", + strokeWidth = 0, +}: BaseRectangleProps) { return ( ) } + +export default function Rectangle({ id, point, size }: RectangleShape) { + const isSelected = useSelector((state) => state.values.selectedIds.has(id)) + return ( + + + {isSelected && ( + + )} + + ) +} diff --git a/components/canvas/shapes/shape-group.tsx b/components/canvas/shapes/shape-group.tsx new file mode 100644 index 000000000..221e3cef5 --- /dev/null +++ b/components/canvas/shapes/shape-group.tsx @@ -0,0 +1,37 @@ +import React from "react" +import state from "state" +import { Shape } from "types" +import { getPointerEventInfo } from "utils/utils" + +export default function ShapeGroup({ + id, + children, +}: { + id: string + children: React.ReactNode +}) { + return ( + + state.send("POINTED_SHAPE", { id, ...getPointerEventInfo(e) }) + } + onPointerUp={(e) => + state.send("STOPPED_POINTING_SHAPE", { + id, + ...getPointerEventInfo(e), + }) + } + onPointerEnter={(e) => + state.send("HOVERED_SHAPE", { id, ...getPointerEventInfo(e) }) + } + onPointerLeave={(e) => + state.send("UNHOVERED_SHAPE", { + id, + ...getPointerEventInfo(e), + }) + } + > + {children} + + ) +} diff --git a/state/data.ts b/state/data.ts index 6b08386dc..71ed4645c 100644 --- a/state/data.ts +++ b/state/data.ts @@ -38,6 +38,15 @@ export const defaultDocument: Data["document"] = { radius: 25, rotation: 0, }, + shape3: { + id: "shape3", + type: ShapeType.Dot, + name: "Shape 3", + parentId: "page0", + childIndex: 3, + point: [500, 100], + rotation: 0, + }, }, }, }, diff --git a/state/sessions/brush-session.ts b/state/sessions/brush-session.ts index 44896b2fe..a28c7ec11 100644 --- a/state/sessions/brush-session.ts +++ b/state/sessions/brush-session.ts @@ -1,12 +1,14 @@ import { current } from "immer" -import { Bounds, Data, Shape } from "types" +import { Bounds, Data, Shape, ShapeType } from "types" import BaseSession from "./base-session" -import { screenToWorld, getBoundsFromPoints } from "utils/utils" +import shapeUtils from "utils/shapes" +import { getBoundsFromPoints } from "utils/utils" import * as vec from "utils/vec" +import { intersectCircleBounds } from "utils/intersections" interface BrushSnapshot { selectedIds: string[] - shapes: Shape[] + shapes: { shape: Shape; bounds: Bounds }[] } export default class BrushSession extends BaseSession { @@ -24,21 +26,41 @@ export default class BrushSession extends BaseSession { update = (data: Data, point: number[]) => { const { origin, snapshot } = this - const bounds = getBoundsFromPoints(origin, point) - - data.brush = bounds - - const { minX: x, minY: y, width: w, height: h } = bounds + const brushBounds = getBoundsFromPoints(origin, point) data.selectedIds = [ ...snapshot.selectedIds, - ...snapshot.shapes.map((shape) => { - return shape.id - }), + ...snapshot.shapes + .filter(({ shape, bounds }) => { + switch (shape.type) { + case ShapeType.Circle: { + return ( + boundsContained(bounds, brushBounds) || + intersectCircleBounds(shape.point, shape.radius, brushBounds) + .length + ) + } + case ShapeType.Dot: { + return ( + boundsContained(bounds, brushBounds) || + intersectCircleBounds(shape.point, 4, brushBounds).length + ) + } + case ShapeType.Rectangle: { + return ( + boundsContained(bounds, brushBounds) || + boundsCollide(bounds, brushBounds) + ) + } + default: { + return boundsContained(bounds, brushBounds) + } + } + }) + .map(({ shape }) => shape.id), ] - // Narrow the the items on the screen - data.brush = bounds + data.brush = brushBounds } cancel = (data: Data) => { @@ -52,13 +74,105 @@ export default class BrushSession extends BaseSession { static getSnapshot(data: Data) { const { + selectedIds, document: { pages }, currentPageId, } = current(data) + const currentlySelected = new Set(selectedIds) + return { selectedIds: [...data.selectedIds], - shapes: Object.values(pages[currentPageId].shapes), + shapes: Object.values(pages[currentPageId].shapes) + .filter((shape) => !currentlySelected.has(shape.id)) + .map((shape) => { + switch (shape.type) { + case ShapeType.Dot: { + return { + shape, + bounds: shapeUtils[shape.type].getBounds(shape), + } + } + case ShapeType.Circle: { + return { + shape, + bounds: shapeUtils[shape.type].getBounds(shape), + } + } + case ShapeType.Rectangle: { + return { + shape, + bounds: shapeUtils[shape.type].getBounds(shape), + } + } + default: { + return undefined + } + } + }) + .filter(Boolean), } } } + +/** + * Get whether two bounds collide. + * @param a Bounds + * @param b Bounds + * @returns + */ +export function boundsCollide(a: Bounds, b: Bounds) { + return !( + a.maxX < b.minX || + a.minX > b.maxX || + a.maxY < b.minY || + a.minY > b.maxY + ) +} + +/** + * Get whether the bounds of A contain the bounds of B. A perfect match will return true. + * @param a Bounds + * @param b Bounds + * @returns + */ +export function boundsContain(a: Bounds, b: Bounds) { + return ( + a.minX < b.minX && a.minY < b.minY && a.maxY > b.maxY && a.maxX > b.maxX + ) +} + +/** + * Get whether the bounds of A are contained by the bounds of B. + * @param a Bounds + * @param b Bounds + * @returns + */ +export function boundsContained(a: Bounds, b: Bounds) { + return boundsContain(b, a) +} + +/** + * Get whether two bounds are identical. + * @param a Bounds + * @param b Bounds + * @returns + */ +export function boundsAreEqual(a: Bounds, b: Bounds) { + return !( + b.maxX !== a.maxX || + b.minX !== a.minX || + b.maxY !== a.maxY || + b.minY !== a.minY + ) +} + +/** + * 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) +} diff --git a/state/state.ts b/state/state.ts index 83a140c55..afba700e3 100644 --- a/state/state.ts +++ b/state/state.ts @@ -35,7 +35,10 @@ const state = createState({ }, }, brushSelecting: { - onEnter: "startBrushSession", + onEnter: [ + { unless: "isPressingShiftKey", do: "clearSelection" }, + "startBrushSession", + ], on: { MOVED_POINTER: "updateBrushSession", PANNED_CAMERA: "updateBrushSession", @@ -44,6 +47,11 @@ const state = createState({ }, }, }, + conditions: { + isPressingShiftKey(data, payload: { shiftKey: boolean }) { + return payload.shiftKey + }, + }, actions: { cancelSession(data) { session.cancel(data) @@ -62,6 +70,11 @@ const state = createState({ updateBrushSession(data, payload: { point: number[] }) { session.update(data, screenToWorld(payload.point, data)) }, + // Selection + clearSelection(data) { + data.selectedIds = [] + }, + // Camera zoomCamera(data, payload: { delta: number; point: number[] }) { const { camera } = data const p0 = screenToWorld(payload.point, data) @@ -81,6 +94,11 @@ const state = createState({ ) }, }, + values: { + selectedIds(data) { + return new Set(data.selectedIds) + }, + }, }) let session: Sessions.BaseSession diff --git a/types.ts b/types.ts index e0afb9b20..a28c2fcc2 100644 --- a/types.ts +++ b/types.ts @@ -21,18 +21,17 @@ export interface Page { } export enum ShapeType { + Dot = "dot", Circle = "circle", Ellipse = "ellipse", - Square = "square", - Rectangle = "rectangle", Line = "line", - LineSegment = "lineSegment", - Dot = "dot", Ray = "ray", - Glob = "glob", - Spline = "spline", - Cubic = "cubic", - Conic = "conic", + LineSegment = "lineSegment", + Rectangle = "rectangle", + // Glob = "glob", + // Spline = "spline", + // Cubic = "cubic", + // Conic = "conic", } export interface BaseShape { @@ -87,9 +86,9 @@ export interface RectangleShape extends BaseShape { } export type Shape = + | DotShape | CircleShape | EllipseShape - | DotShape | LineShape | RayShape | LineSegmentShape @@ -103,3 +102,13 @@ export interface Bounds { width: number height: number } + +export interface Shapes extends Record { + [ShapeType.Dot]: DotShape + [ShapeType.Circle]: CircleShape + [ShapeType.Ellipse]: EllipseShape + [ShapeType.Line]: LineShape + [ShapeType.Ray]: RayShape + [ShapeType.LineSegment]: LineSegmentShape + [ShapeType.Rectangle]: RectangleShape +} diff --git a/utils/intersections.ts b/utils/intersections.ts new file mode 100644 index 000000000..0c752f858 --- /dev/null +++ b/utils/intersections.ts @@ -0,0 +1,103 @@ +import { Bounds } from "types" +import * as vec from "utils/vec" + +interface Intersection { + didIntersect: boolean + message: string + points: number[][] +} + +export function intersectCircleLine( + c: number[], + r: number, + a1: number[], + a2: number[] +): Intersection { + const a = + (a2[0] - a1[0]) * (a2[0] - a1[0]) + (a2[1] - a1[1]) * (a2[1] - a1[1]) + const b = + 2 * ((a2[0] - a1[0]) * (a1[0] - c[0]) + (a2[1] - a1[1]) * (a1[1] - c[1])) + const cc = + c[0] * c[0] + + c[1] * c[1] + + a1[0] * a1[0] + + a1[1] * a1[1] - + 2 * (c[0] * a1[0] + c[1] * a1[1]) - + r * r + + const deter = b * b - 4 * a * cc + + if (deter < 0) { + return { didIntersect: false, message: "outside", points: [] } + } + + if (deter === 0) { + return { didIntersect: false, message: "tangent", points: [] } + } + + var e = Math.sqrt(deter) + var u1 = (-b + e) / (2 * a) + 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: [] } + } else { + return { didIntersect: false, message: "inside", points: [] } + } + } + + 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)) + + return result +} + +export function intersectCircleRectangle( + c: number[], + r: number, + point: number[], + size: number[] +): 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 = intersectCircleLine(c, r, tl, tr) + if (topIntersection.didIntersect) { + intersections.push({ ...topIntersection, message: "top" }) + } + const rightIntersection = intersectCircleLine(c, r, tr, br) + if (rightIntersection.didIntersect) { + intersections.push({ ...rightIntersection, message: "right" }) + } + const bottomIntersection = intersectCircleLine(c, r, bl, br) + if (bottomIntersection.didIntersect) { + intersections.push({ ...bottomIntersection, message: "bottom" }) + } + const leftIntersection = intersectCircleLine(c, r, tl, bl) + if (leftIntersection.didIntersect) { + intersections.push({ ...leftIntersection, message: "left" }) + } + + return intersections +} + +export function intersectCircleBounds( + c: number[], + r: number, + bounds: Bounds +): Intersection[] { + const { minX, minY, width, height } = bounds + const intersections = intersectCircleRectangle( + c, + r, + [minX, minY], + [width, height] + ) + + return intersections +} diff --git a/utils/shapes.ts b/utils/shapes.ts new file mode 100644 index 000000000..a4777c588 --- /dev/null +++ b/utils/shapes.ts @@ -0,0 +1,310 @@ +import { + boundsCollide, + boundsContain, + pointInBounds, +} from "state/sessions/brush-session" +import { + Shape, + Bounds, + ShapeType, + CircleShape, + DotShape, + RectangleShape, + Shapes, + EllipseShape, + LineShape, + RayShape, + LineSegmentShape, +} from "types" +import { intersectCircleBounds } from "./intersections" +import * as vec from "./vec" + +type BaseShapeUtils = { + getBounds(shape: Shapes[K]): Bounds + hitTest(shape: Shapes[K], test: number[] | Bounds): boolean + rotate(shape: Shapes[K]): Shapes[K] + translate(shape: Shapes[K]): Shapes[K] + scale(shape: Shapes[K], scale: number): Shapes[K] + stretch(shape: Shapes[K], scaleX: number, scaleY: number): Shapes[K] +} + +/* ----------------------- Dot ---------------------- */ + +const DotUtils: BaseShapeUtils = { + getBounds(shape) { + const { + point: [cx, cy], + } = shape + + return { + minX: cx - 2, + maxX: cx + 2, + minY: cy - 2, + maxY: cy + 2, + width: 4, + height: 4, + } + }, + + hitTest(shape, test) { + if ("minX" in test) { + return pointInBounds(shape.point, test) + } + return vec.dist(shape.point, test) < 4 + }, + + rotate(shape) { + return shape + }, + + translate(shape) { + return shape + }, + + scale(shape, scale: number) { + return shape + }, + + stretch(shape, scaleX: number, scaleY: number) { + return shape + }, +} + +/* --------------------- Circle --------------------- */ + +const CircleUtils: BaseShapeUtils = { + getBounds(shape) { + const { + point: [cx, cy], + radius, + } = shape + + return { + minX: cx - radius, + maxX: cx + radius, + minY: cy - radius, + maxY: cy + radius, + width: radius * 2, + height: radius * 2, + } + }, + + hitTest(shape, test) { + if ("minX" in test) { + const bounds = CircleUtils.getBounds(shape) + return ( + boundsContain(bounds, test) || + intersectCircleBounds(shape.point, shape.radius, bounds).length > 0 + ) + } + return vec.dist(shape.point, test) < 4 + }, + + rotate(shape) { + return shape + }, + + translate(shape) { + return shape + }, + + scale(shape, scale: number) { + return shape + }, + + stretch(shape, scaleX: number, scaleY: number) { + return shape + }, +} + +/* --------------------- Ellipse -------------------- */ + +const EllipseUtils: BaseShapeUtils = { + getBounds(shape) { + return { + minX: 0, + minY: 0, + maxX: 0, + maxY: 0, + width: 0, + height: 0, + } + }, + + hitTest(shape) { + return true + }, + + rotate(shape) { + return shape + }, + + translate(shape) { + return shape + }, + + scale(shape, scale: number) { + return shape + }, + + stretch(shape, scaleX: number, scaleY: number) { + return shape + }, +} + +/* ---------------------- Line ---------------------- */ + +const LineUtils: BaseShapeUtils = { + getBounds(shape) { + return { + minX: 0, + minY: 0, + maxX: 0, + maxY: 0, + width: 0, + height: 0, + } + }, + + hitTest(shape) { + return true + }, + + rotate(shape) { + return shape + }, + + translate(shape) { + return shape + }, + + scale(shape, scale: number) { + return shape + }, + + stretch(shape, scaleX: number, scaleY: number) { + return shape + }, +} + +/* ----------------------- Ray ---------------------- */ + +const RayUtils: BaseShapeUtils = { + getBounds(shape) { + return { + minX: Infinity, + minY: Infinity, + maxX: Infinity, + maxY: Infinity, + width: Infinity, + height: Infinity, + } + }, + + hitTest(shape) { + return true + }, + + rotate(shape) { + return shape + }, + + translate(shape) { + return shape + }, + + scale(shape, scale: number) { + return shape + }, + + stretch(shape, scaleX: number, scaleY: number) { + return shape + }, +} + +/* ------------------ Line Segment ------------------ */ + +const LineSegmentUtils: BaseShapeUtils = { + getBounds(shape) { + return { + minX: 0, + minY: 0, + maxX: 0, + maxY: 0, + width: 0, + height: 0, + } + }, + + hitTest(shape) { + return true + }, + + rotate(shape) { + return shape + }, + + translate(shape) { + return shape + }, + + scale(shape, scale: number) { + return shape + }, + + stretch(shape, scaleX: number, scaleY: number) { + return shape + }, +} + +/* -------------------- Rectangle ------------------- */ + +const RectangleUtils: BaseShapeUtils = { + getBounds(shape) { + const { + point: [x, y], + size: [width, height], + } = shape + + return { + minX: x, + maxX: x + width, + minY: y, + maxY: y + height, + width, + height, + } + }, + + hitTest(shape) { + return true + }, + + rotate(shape) { + return shape + }, + + translate(shape) { + return shape + }, + + scale(shape, scale: number) { + return shape + }, + + stretch(shape, scaleX: number, scaleY: number) { + return shape + }, +} + +const shapeUtils: { [K in ShapeType]: BaseShapeUtils } = { + [ShapeType.Dot]: DotUtils, + [ShapeType.Circle]: CircleUtils, + [ShapeType.Ellipse]: EllipseUtils, + [ShapeType.Line]: LineUtils, + [ShapeType.Ray]: RayUtils, + [ShapeType.LineSegment]: LineSegmentUtils, + [ShapeType.Rectangle]: RectangleUtils, +} + +export default shapeUtils