Adds lines, improves transforms

This commit is contained in:
Steve Ruiz 2021-05-14 22:05:21 +01:00
parent b50045c9b7
commit b8d3b35b07
14 changed files with 260 additions and 125 deletions

View file

@ -27,7 +27,7 @@ export default function Bounds() {
height={height}
pointerEvents="none"
/>
{width * zoom > 8 && (
{width * zoom > 8 && height * zoom > 8 && (
<>
<EdgeHorizontal
x={minX + p}

View file

@ -7,13 +7,12 @@ import styled from "styles"
function Shape({ id }: { id: string }) {
const rGroup = useRef<SVGGElement>(null)
const shape = useSelector(
({ data: { currentPageId, document } }) =>
document.pages[currentPageId].shapes[id]
)
const isSelected = useSelector((state) => state.values.selectedIds.has(id))
const shape = useSelector(
({ data }) => data.document.pages[data.currentPageId].shapes[id]
)
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
e.stopPropagation()
@ -33,12 +32,12 @@ function Shape({ id }: { id: string }) {
)
const handlePointerEnter = useCallback(
(e: React.PointerEvent) => state.send("HOVERED_SHAPE", { id }),
() => state.send("HOVERED_SHAPE", { id }),
[id]
)
const handlePointerLeave = useCallback(
(e: React.PointerEvent) => state.send("UNHOVERED_SHAPE", { id }),
() => state.send("UNHOVERED_SHAPE", { id }),
[id]
)
return (

19
lib/shapes/base-shape.tsx Normal file
View file

@ -0,0 +1,19 @@
import { Bounds, Shape } from "types"
export default interface ShapeUtil<K extends Shape> {
create(props: Partial<K>): K
getBounds(this: ShapeUtil<K>, shape: K): Bounds
hitTest(this: ShapeUtil<K>, shape: K, test: number[]): boolean
hitTestBounds(this: ShapeUtil<K>, shape: K, bounds: Bounds): boolean
rotate(this: ShapeUtil<K>, shape: K): K
translate(this: ShapeUtil<K>, shape: K, delta: number[]): K
scale(this: ShapeUtil<K>, shape: K, scale: number): K
stretch(this: ShapeUtil<K>, shape: K, scaleX: number, scaleY: number): K
render(this: ShapeUtil<K>, shape: K): JSX.Element
}
export function createShape<T extends Shape>(
shape: ShapeUtil<T>
): ShapeUtil<T> {
return shape
}

View file

@ -88,8 +88,12 @@ const circle = createShape<CircleShape>({
},
transform(shape, bounds) {
shape.point = [bounds.minX, bounds.minY]
// shape.point = [bounds.minX, bounds.minY]
shape.radius = Math.min(bounds.width, bounds.height) / 2
shape.point = [
bounds.minX + bounds.width / 2 - shape.radius,
bounds.minY + bounds.height / 2 - shape.radius,
]
return shape
},

View file

@ -41,7 +41,14 @@ export interface ShapeUtility<K extends Shape> {
translate(this: ShapeUtility<K>, shape: K, delta: number[]): K
// Transform to fit a new bounding box.
transform(this: ShapeUtility<K>, shape: K, bounds: Bounds): K
transform(
this: ShapeUtility<K>,
shape: K,
bounds: Bounds & { isFlippedX: boolean; isFlippedY: boolean },
initialShape: K,
initialShapeBounds: BoundsSnapshot,
initialBounds: Bounds
): K
// Apply a scale to a shape.
scale(this: ShapeUtility<K>, shape: K, scale: number): K

View file

@ -16,15 +16,23 @@ const line = createShape<LineShape>({
parentId: "page0",
childIndex: 0,
point: [0, 0],
vector: [0, 0],
direction: [0, 0],
rotation: 0,
style: {},
...props,
}
},
render({ id }) {
return <circle id={id} cx={4} cy={4} r={4} />
render({ id, direction }) {
const [x1, y1] = vec.add([0, 0], vec.mul(direction, 100000))
const [x2, y2] = vec.sub([0, 0], vec.mul(direction, 100000))
return (
<g id={id}>
<line x1={x1} y1={y1} x2={x2} y2={y2} />
<circle cx={0} cy={0} r={4} />
</g>
)
},
getBounds(shape) {
@ -38,11 +46,11 @@ const line = createShape<LineShape>({
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,
}
this.boundsCache.set(shape, bounds)
@ -55,11 +63,7 @@ const line = createShape<LineShape>({
},
hitTestBounds(this, shape, brushBounds) {
const shapeBounds = this.getBounds(shape)
return (
boundsContained(shapeBounds, brushBounds) ||
intersectCircleBounds(shape.point, 4, brushBounds).length > 0
)
return true
},
rotate(shape) {
@ -80,6 +84,8 @@ const line = createShape<LineShape>({
},
transform(shape, bounds) {
shape.point = [bounds.minX, bounds.minY]
return shape
},
})

View file

@ -90,15 +90,20 @@ const polyline = createShape<PolylineShape>({
return shape
},
transform(shape, bounds) {
const currentBounds = this.getBounds(shape)
transform(shape, bounds, initialShape, initialShapeBounds) {
shape.points = shape.points.map((_, i) => {
const [x, y] = initialShape.points[i]
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
return [
bounds.width *
(bounds.isFlippedX
? 1 - x / initialShapeBounds.width
: x / initialShapeBounds.width),
bounds.height *
(bounds.isFlippedY
? 1 - y / initialShapeBounds.height
: y / initialShapeBounds.height),
]
})
shape.point = [bounds.minX, bounds.minY]

View file

@ -16,7 +16,7 @@ const ray = createShape<RayShape>({
parentId: "page0",
childIndex: 0,
point: [0, 0],
vector: [0, 0],
direction: [0, 0],
rotation: 0,
style: {},
...props,

View file

@ -1,5 +1,6 @@
import translate from "./translate-command"
import transform from "./transform-command"
const commands = { translate }
const commands = { translate, transform }
export default commands

View file

@ -0,0 +1,67 @@
import Command from "./command"
import history from "../history"
import { Data } from "types"
import { TransformSnapshot } from "state/sessions/transform-session"
import { getShapeUtils } from "lib/shapes"
export default function translateCommand(
data: Data,
before: TransformSnapshot,
after: TransformSnapshot
) {
history.execute(
data,
new Command({
name: "translate_shapes",
category: "canvas",
do(data) {
const { shapeBounds, initialBounds, currentPageId, selectedIds } = after
const { shapes } = data.document.pages[currentPageId]
selectedIds.forEach((id) => {
const { initialShape, initialShapeBounds } = shapeBounds[id]
const shape = shapes[id]
getShapeUtils(shape).transform(
shape,
{
...initialShapeBounds,
isFlippedX: false,
isFlippedY: false,
},
initialShape,
initialShapeBounds,
initialBounds
)
})
},
undo(data) {
const {
shapeBounds,
initialBounds,
currentPageId,
selectedIds,
} = before
const { shapes } = data.document.pages[currentPageId]
selectedIds.forEach((id) => {
const { initialShape, initialShapeBounds } = shapeBounds[id]
const shape = shapes[id]
getShapeUtils(shape).transform(
shape,
{
...initialShapeBounds,
isFlippedX: false,
isFlippedY: false,
},
initialShape,
initialShapeBounds,
initialBounds
)
})
},
})
)
}

View file

@ -15,7 +15,7 @@ export const defaultDocument: Data["document"] = {
childIndex: 3,
point: [500, 100],
style: {
fill: "#aaa",
fill: "#AAA",
stroke: "#777",
strokeWidth: 1,
},
@ -27,7 +27,7 @@ export const defaultDocument: Data["document"] = {
point: [100, 100],
radius: 50,
style: {
fill: "#aaa",
fill: "#AAA",
stroke: "#777",
strokeWidth: 1,
},
@ -40,7 +40,7 @@ export const defaultDocument: Data["document"] = {
radiusX: 50,
radiusY: 30,
style: {
fill: "#aaa",
fill: "#AAA",
stroke: "#777",
strokeWidth: 1,
},
@ -70,7 +70,19 @@ export const defaultDocument: Data["document"] = {
point: [300, 300],
size: [200, 200],
style: {
fill: "#aaa",
fill: "#AAA",
stroke: "#777",
strokeWidth: 1,
},
}),
shape6: shapeUtils[ShapeType.Line].create({
id: "shape6",
name: "Shape 6",
childIndex: 1,
point: [400, 400],
direction: [0.2, 0.2],
style: {
fill: "#AAA",
stroke: "#777",
strokeWidth: 1,
},

View file

@ -1,7 +1,7 @@
import { current } from "immer"
import { ShapeUtil, Bounds, Data, Shapes } from "types"
import BaseSession from "./base-session"
import shapes from "lib/shapes"
import shapes, { getShapeUtils } from "lib/shapes"
import { getBoundsFromPoints } from "utils/utils"
import * as vec from "utils/vec"
@ -68,9 +68,7 @@ export default class BrushSession extends BaseSession {
.map((shape) => ({
id: shape.id,
test: (brushBounds: Bounds): boolean =>
(shapes[shape.type] as ShapeUtil<
Shapes[typeof shape.type]
>).hitTestBounds(shape, brushBounds),
getShapeUtils(shape).hitTestBounds(shape, brushBounds),
})),
}
}

View file

@ -1,4 +1,10 @@
import { Data, TransformEdge, TransformCorner, Bounds } from "types"
import {
Data,
TransformEdge,
TransformCorner,
Bounds,
BoundsSnapshot,
} from "types"
import * as vec from "utils/vec"
import BaseSession from "./base-session"
import commands from "state/commands"
@ -11,7 +17,6 @@ export default class TransformSession extends BaseSession {
transformType: TransformEdge | TransformCorner
origin: number[]
snapshot: TransformSnapshot
currentBounds: Bounds
corners: {
a: number[]
b: number[]
@ -29,8 +34,6 @@ export default class TransformSession extends BaseSession {
const { minX, minY, maxX, maxY } = this.snapshot.initialBounds
this.currentBounds = { ...this.snapshot.initialBounds }
this.corners = {
a: [minX, minY],
b: [maxX, maxY],
@ -38,130 +41,144 @@ export default class TransformSession extends BaseSession {
}
update(data: Data, point: number[]) {
const { shapeBounds, currentPageId, selectedIds } = this.snapshot
const {
document: { pages },
} = data
shapeBounds,
initialBounds,
currentPageId,
selectedIds,
} = this.snapshot
const { shapes } = data.document.pages[currentPageId]
let [x, y] = point
const { corners, transformType } = this
const {
corners: { a, b },
transformType,
} = this
// Edge Transform
switch (transformType) {
case TransformEdge.Top: {
corners.a[1] = y
a[1] = y
break
}
case TransformEdge.Right: {
corners.b[0] = x
b[0] = x
break
}
case TransformEdge.Bottom: {
corners.b[1] = y
b[1] = y
break
}
case TransformEdge.Left: {
corners.a[0] = x
a[0] = x
break
}
case TransformCorner.TopLeft: {
corners.a[1] = y
corners.a[0] = x
a[1] = y
a[0] = x
break
}
case TransformCorner.TopRight: {
corners.b[0] = x
corners.a[1] = y
b[0] = x
a[1] = y
break
}
case TransformCorner.BottomRight: {
corners.b[1] = y
corners.b[0] = x
b[1] = y
b[0] = x
break
}
case TransformCorner.BottomLeft: {
corners.a[0] = x
corners.b[1] = y
a[0] = x
b[1] = y
break
}
}
// Calculate new common (externior) bounding box
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]),
minX: Math.min(a[0], b[0]),
minY: Math.min(a[1], b[1]),
maxX: Math.max(a[0], b[0]),
maxY: Math.max(a[1], b[1]),
width: Math.abs(b[0] - a[0]),
height: Math.abs(b[1] - a[1]),
}
const isFlippedX = corners.b[0] - corners.a[0] < 0
const isFlippedY = corners.b[1] - corners.a[1] < 0
const isFlippedX = b[0] < a[0]
const isFlippedY = b[1] < a[1]
// 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
// Now work backward to calculate a new bounding box for each of the shapes.
selectedIds.forEach((id) => {
const { nx, nmx, nw, ny, nmy, nh } = shapeBounds[id]
const { initialShape, initialShapeBounds } = shapeBounds[id]
const { nx, nmx, nw, ny, nmy, nh } = initialShapeBounds
const shape = shapes[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, {
const newShapeBounds = {
minX,
minY,
maxX: minX + width,
maxY: minY + height,
width,
height,
isFlippedX,
isFlippedY,
}
// Pass the new data to the shape's transform utility for mutation.
// Most shapes should be able to transform using only the bounding box,
// however some shapes (e.g. those with internal points) will need more
// data here too.
getShapeUtils(shape).transform(
shape,
newShapeBounds,
initialShape,
initialShapeBounds,
initialBounds
)
})
// 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
const {
shapeBounds,
initialBounds,
currentPageId,
selectedIds,
} = this.snapshot
// for (let id in shapes) {
// Restore shape using original bounds
// document.pages[currentPageId].shapes[id]
// }
const { shapes } = data.document.pages[currentPageId]
selectedIds.forEach((id) => {
const shape = shapes.shapes[id]
const { initialShape, initialShapeBounds } = shapeBounds[id]
getShapeUtils(shape).transform(
shape,
{
...initialShapeBounds,
isFlippedX: false,
isFlippedY: false,
},
initialShape,
initialShapeBounds,
initialBounds
)
})
}
complete(data: Data) {
// commands.translate(data, this.snapshot, getTransformSnapshot(data))
commands.transform(data, this.snapshot, getTransformSnapshot(data))
}
}
@ -172,21 +189,18 @@ export function getTransformSnapshot(data: Data) {
currentPageId,
} = current(data)
const pageShapes = pages[currentPageId].shapes
// A mapping of selected shapes and their bounds
const shapesBounds = Object.fromEntries(
Array.from(selectedIds.values()).map((id) => {
const shape = pages[currentPageId].shapes[id]
const shape = pageShapes[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)
})
)
const bounds = getCommonBounds(...Object.values(shapesBounds))
// Return a mapping of shapes to bounds together with the relative
// positions of the shape's bounds within the common bounds shape.
@ -200,7 +214,9 @@ export function getTransformSnapshot(data: Data) {
return [
id,
{
...bounds,
initialShape: pageShapes[id],
initialShapeBounds: {
...shapesBounds[id],
nx: (minX - bounds.minX) / bounds.width,
ny: (minY - bounds.minY) / bounds.height,
nmx: 1 - (minX + width - bounds.minX) / bounds.width,
@ -208,6 +224,7 @@ export function getTransformSnapshot(data: Data) {
nw: width / bounds.width,
nh: height / bounds.height,
},
},
]
})
),

View file

@ -65,12 +65,12 @@ export interface EllipseShape extends BaseShape {
export interface LineShape extends BaseShape {
type: ShapeType.Line
vector: number[]
direction: number[]
}
export interface RayShape extends BaseShape {
type: ShapeType.Ray
vector: number[]
direction: number[]
}
export interface PolylineShape extends BaseShape {