Adds utils, brush selection testing for shapes.

This commit is contained in:
Steve Ruiz 2021-05-11 11:13:07 +01:00
parent 9a01f5aac6
commit 7ee3a1ef3d
11 changed files with 765 additions and 32 deletions

View file

@ -2,6 +2,7 @@ import { memo } from "react"
import { useSelector } from "state" import { useSelector } from "state"
import { ShapeType } from "types" import { ShapeType } from "types"
import Circle from "./shapes/circle" import Circle from "./shapes/circle"
import Dot from "./shapes/dot"
import Rectangle from "./shapes/rectangle" import Rectangle from "./shapes/rectangle"
/* /*
@ -17,6 +18,8 @@ function Shape({ id }: { id: string }) {
}) })
switch (shape.type) { switch (shape.type) {
case ShapeType.Dot:
return <Dot {...shape} />
case ShapeType.Circle: case ShapeType.Circle:
return <Circle {...shape} /> return <Circle {...shape} />
case ShapeType.Rectangle: case ShapeType.Rectangle:

View file

@ -1,5 +1,49 @@
import state, { useSelector } from "state"
import { CircleShape } from "types" import { CircleShape } from "types"
import ShapeGroup from "./shape-group"
import { getPointerEventInfo } from "utils/utils"
export default function Circle({ point, radius }: CircleShape) { interface BaseCircleProps {
return <circle cx={point[0]} cy={point[1]} r={radius} fill="black" /> point: number[]
radius: number
fill?: string
stroke?: string
strokeWidth?: number
}
function BaseCircle({
point,
radius,
fill = "#ccc",
stroke = "none",
strokeWidth = 0,
}: BaseCircleProps) {
return (
<circle
cx={point[0] + strokeWidth}
cy={point[1] + strokeWidth}
r={radius - strokeWidth}
fill={fill}
stroke={stroke}
strokeWidth={strokeWidth}
/>
)
}
export default function Circle({ id, point, radius }: CircleShape) {
const isSelected = useSelector((state) => state.values.selectedIds.has(id))
return (
<ShapeGroup id={id}>
<BaseCircle point={point} radius={radius} />
{isSelected && (
<BaseCircle
point={point}
radius={radius}
fill="none"
stroke="blue"
strokeWidth={1}
/>
)}
</ShapeGroup>
)
} }

View file

@ -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 (
<g>
<circle
cx={point[0] + strokeWidth}
cy={point[1] + strokeWidth}
r={8}
fill="transparent"
stroke="none"
strokeWidth="0"
/>
<circle
cx={point[0] + strokeWidth}
cy={point[1] + strokeWidth}
r={Math.max(1, 4 - strokeWidth)}
fill={fill}
stroke={stroke}
strokeWidth={strokeWidth}
/>
</g>
)
}
export default function Dot({ id, point }: DotShape) {
const isSelected = useSelector((state) => state.values.selectedIds.has(id))
return (
<ShapeGroup id={id}>
<BaseDot point={point} />
{isSelected && (
<BaseDot point={point} fill="none" stroke="blue" strokeWidth={1} />
)}
</ShapeGroup>
)
}

View file

@ -1,13 +1,49 @@
import { useSelector } from "state"
import { RectangleShape } from "types" 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 ( return (
<rect <rect
x={point[0]} x={point[0] + strokeWidth}
y={point[1]} y={point[1] + strokeWidth}
width={size[0]} width={size[0] - strokeWidth * 2}
height={size[1]} height={size[1] - strokeWidth * 2}
fill="black" fill={fill}
stroke={stroke}
strokeWidth={strokeWidth}
/> />
) )
} }
export default function Rectangle({ id, point, size }: RectangleShape) {
const isSelected = useSelector((state) => state.values.selectedIds.has(id))
return (
<ShapeGroup id={id}>
<BaseRectangle point={point} size={size} />
{isSelected && (
<BaseRectangle
point={point}
size={size}
fill="none"
stroke="blue"
strokeWidth={1}
/>
)}
</ShapeGroup>
)
}

View file

@ -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 (
<g
onPointerDown={(e) =>
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}
</g>
)
}

View file

@ -38,6 +38,15 @@ export const defaultDocument: Data["document"] = {
radius: 25, radius: 25,
rotation: 0, rotation: 0,
}, },
shape3: {
id: "shape3",
type: ShapeType.Dot,
name: "Shape 3",
parentId: "page0",
childIndex: 3,
point: [500, 100],
rotation: 0,
},
}, },
}, },
}, },

View file

@ -1,12 +1,14 @@
import { current } from "immer" import { current } from "immer"
import { Bounds, Data, Shape } from "types" import { Bounds, Data, Shape, ShapeType } from "types"
import BaseSession from "./base-session" 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 * as vec from "utils/vec"
import { intersectCircleBounds } from "utils/intersections"
interface BrushSnapshot { interface BrushSnapshot {
selectedIds: string[] selectedIds: string[]
shapes: Shape[] shapes: { shape: Shape; bounds: Bounds }[]
} }
export default class BrushSession extends BaseSession { export default class BrushSession extends BaseSession {
@ -24,21 +26,41 @@ export default class BrushSession extends BaseSession {
update = (data: Data, point: number[]) => { update = (data: Data, point: number[]) => {
const { origin, snapshot } = this const { origin, snapshot } = this
const bounds = getBoundsFromPoints(origin, point) const brushBounds = getBoundsFromPoints(origin, point)
data.brush = bounds
const { minX: x, minY: y, width: w, height: h } = bounds
data.selectedIds = [ data.selectedIds = [
...snapshot.selectedIds, ...snapshot.selectedIds,
...snapshot.shapes.map((shape) => { ...snapshot.shapes
return shape.id .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 = brushBounds
data.brush = bounds
} }
cancel = (data: Data) => { cancel = (data: Data) => {
@ -52,13 +74,105 @@ export default class BrushSession extends BaseSession {
static getSnapshot(data: Data) { static getSnapshot(data: Data) {
const { const {
selectedIds,
document: { pages }, document: { pages },
currentPageId, currentPageId,
} = current(data) } = current(data)
const currentlySelected = new Set(selectedIds)
return { return {
selectedIds: [...data.selectedIds], 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)
}

View file

@ -35,7 +35,10 @@ const state = createState({
}, },
}, },
brushSelecting: { brushSelecting: {
onEnter: "startBrushSession", onEnter: [
{ unless: "isPressingShiftKey", do: "clearSelection" },
"startBrushSession",
],
on: { on: {
MOVED_POINTER: "updateBrushSession", MOVED_POINTER: "updateBrushSession",
PANNED_CAMERA: "updateBrushSession", PANNED_CAMERA: "updateBrushSession",
@ -44,6 +47,11 @@ const state = createState({
}, },
}, },
}, },
conditions: {
isPressingShiftKey(data, payload: { shiftKey: boolean }) {
return payload.shiftKey
},
},
actions: { actions: {
cancelSession(data) { cancelSession(data) {
session.cancel(data) session.cancel(data)
@ -62,6 +70,11 @@ const state = createState({
updateBrushSession(data, payload: { point: number[] }) { updateBrushSession(data, payload: { point: number[] }) {
session.update(data, screenToWorld(payload.point, data)) session.update(data, screenToWorld(payload.point, data))
}, },
// Selection
clearSelection(data) {
data.selectedIds = []
},
// Camera
zoomCamera(data, payload: { delta: number; point: number[] }) { zoomCamera(data, payload: { delta: number; point: number[] }) {
const { camera } = data const { camera } = data
const p0 = screenToWorld(payload.point, 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 let session: Sessions.BaseSession

View file

@ -21,18 +21,17 @@ export interface Page {
} }
export enum ShapeType { export enum ShapeType {
Dot = "dot",
Circle = "circle", Circle = "circle",
Ellipse = "ellipse", Ellipse = "ellipse",
Square = "square",
Rectangle = "rectangle",
Line = "line", Line = "line",
LineSegment = "lineSegment",
Dot = "dot",
Ray = "ray", Ray = "ray",
Glob = "glob", LineSegment = "lineSegment",
Spline = "spline", Rectangle = "rectangle",
Cubic = "cubic", // Glob = "glob",
Conic = "conic", // Spline = "spline",
// Cubic = "cubic",
// Conic = "conic",
} }
export interface BaseShape { export interface BaseShape {
@ -87,9 +86,9 @@ export interface RectangleShape extends BaseShape {
} }
export type Shape = export type Shape =
| DotShape
| CircleShape | CircleShape
| EllipseShape | EllipseShape
| DotShape
| LineShape | LineShape
| RayShape | RayShape
| LineSegmentShape | LineSegmentShape
@ -103,3 +102,13 @@ export interface Bounds {
width: number width: number
height: number height: number
} }
export interface Shapes extends Record<ShapeType, Shape> {
[ShapeType.Dot]: DotShape
[ShapeType.Circle]: CircleShape
[ShapeType.Ellipse]: EllipseShape
[ShapeType.Line]: LineShape
[ShapeType.Ray]: RayShape
[ShapeType.LineSegment]: LineSegmentShape
[ShapeType.Rectangle]: RectangleShape
}

103
utils/intersections.ts Normal file
View file

@ -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
}

310
utils/shapes.ts Normal file
View file

@ -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<K extends ShapeType> = {
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<ShapeType.Dot> = {
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<ShapeType.Circle> = {
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<ShapeType.Ellipse> = {
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<ShapeType.Line> = {
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<ShapeType.Ray> = {
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<ShapeType.LineSegment> = {
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<ShapeType.Rectangle> = {
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<K> } = {
[ShapeType.Dot]: DotUtils,
[ShapeType.Circle]: CircleUtils,
[ShapeType.Ellipse]: EllipseUtils,
[ShapeType.Line]: LineUtils,
[ShapeType.Ray]: RayUtils,
[ShapeType.LineSegment]: LineSegmentUtils,
[ShapeType.Rectangle]: RectangleUtils,
}
export default shapeUtils