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