Tldraw/packages/editor/src/lib/editor/shapeutils/GeoShapeUtil/GeoShapeUtil.tsx

984 wiersze
23 KiB
TypeScript

/* eslint-disable react-hooks/rules-of-hooks */
import {
Box2d,
getPolygonVertices,
getRoundedInkyPolygonPath,
getRoundedPolygonPoints,
linesIntersect,
PI,
PI2,
pointInPolygon,
TAU,
Vec2d,
VecLike,
} from '@tldraw/primitives'
import { TLDashType, TLGeoShape } from '@tldraw/tlschema'
import { SVGContainer } from '../../../components/SVGContainer'
import { FONT_FAMILIES, LABEL_FONT_SIZES, TEXT_PROPS } from '../../../constants'
import { Editor } from '../../Editor'
import { BaseBoxShapeUtil } from '../BaseBoxShapeUtil'
import { TLOnEditEndHandler, TLOnResizeHandler } from '../ShapeUtil'
import { getTextLabelSvgElement } from '../shared/getTextLabelSvgElement'
import { HyperlinkButton } from '../shared/HyperlinkButton'
import { TextLabel } from '../shared/TextLabel'
import { TLExportColors } from '../shared/TLExportColors'
import { useForceSolid } from '../shared/useForceSolid'
import { DashStyleEllipse, DashStyleEllipseSvg } from './components/DashStyleEllipse'
import { DashStyleOval, DashStyleOvalSvg } from './components/DashStyleOval'
import { DashStylePolygon, DashStylePolygonSvg } from './components/DashStylePolygon'
import { DrawStyleEllipseSvg, getEllipseIndicatorPath } from './components/DrawStyleEllipse'
import { DrawStylePolygon, DrawStylePolygonSvg } from './components/DrawStylePolygon'
import { SolidStyleEllipse, SolidStyleEllipseSvg } from './components/SolidStyleEllipse'
import {
getOvalIndicatorPath,
SolidStyleOval,
SolidStyleOvalSvg,
} from './components/SolidStyleOval'
import { SolidStylePolygon, SolidStylePolygonSvg } from './components/SolidStylePolygon'
const LABEL_PADDING = 16
const MIN_SIZE_WITH_LABEL = 17 * 3
/** @public */
export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
static override type = 'geo'
canEdit = () => true
override defaultProps(): TLGeoShape['props'] {
return {
w: 100,
h: 100,
geo: 'rectangle',
color: 'black',
labelColor: 'black',
fill: 'none',
dash: 'draw',
size: 'm',
font: 'draw',
text: '',
align: 'middle',
verticalAlign: 'middle',
growY: 0,
url: '',
}
}
hitTestLineSegment(shape: TLGeoShape, A: VecLike, B: VecLike): boolean {
const outline = this.outline(shape)
// Check the outline
for (let i = 0; i < outline.length; i++) {
const C = outline[i]
const D = outline[(i + 1) % outline.length]
if (linesIntersect(A, B, C, D)) return true
}
// Also check lines, if any
const lines = getLines(shape.props, 0)
if (lines !== undefined) {
for (const [C, D] of lines) {
if (linesIntersect(A, B, C, D)) return true
}
}
return false
}
hitTestPoint(shape: TLGeoShape, point: VecLike): boolean {
const outline = this.outline(shape)
if (shape.props.fill === 'none') {
const zoomLevel = this.editor.zoomLevel
const offsetDist = this.editor.getStrokeWidth(shape.props.size) / zoomLevel
// Check the outline
for (let i = 0; i < outline.length; i++) {
const C = outline[i]
const D = outline[(i + 1) % outline.length]
if (Vec2d.DistanceToLineSegment(C, D, point) < offsetDist) return true
}
// Also check lines, if any
const lines = getLines(shape.props, 1)
if (lines !== undefined) {
for (const [C, D] of lines) {
if (Vec2d.DistanceToLineSegment(C, D, point) < offsetDist) return true
}
}
return false
}
return pointInPolygon(point, outline)
}
getBounds(shape: TLGeoShape) {
return new Box2d(0, 0, shape.props.w, shape.props.h + shape.props.growY)
}
getCenter(shape: TLGeoShape) {
return new Vec2d(shape.props.w / 2, (shape.props.h + shape.props.growY) / 2)
}
getOutline(shape: TLGeoShape) {
const w = Math.max(1, shape.props.w)
const h = Math.max(1, shape.props.h + shape.props.growY)
const cx = w / 2
const cy = h / 2
switch (shape.props.geo) {
case 'triangle': {
return [new Vec2d(cx, 0), new Vec2d(w, h), new Vec2d(0, h)]
}
case 'diamond': {
return [new Vec2d(cx, 0), new Vec2d(w, cy), new Vec2d(cx, h), new Vec2d(0, cy)]
}
case 'pentagon': {
return getPolygonVertices(w, h, 5)
}
case 'hexagon': {
return getPolygonVertices(w, h, 6)
}
case 'octagon': {
return getPolygonVertices(w, h, 8)
}
case 'ellipse': {
// Perimeter of the ellipse
const q = Math.pow(cx - cy, 2) / Math.pow(cx + cy, 2)
const p = PI * (cx + cy) * (1 + (3 * q) / (10 + Math.sqrt(4 - 3 * q)))
// Number of points
let len = Math.max(4, Math.ceil(p / 10))
// Round length to nearest multiple of 4
// In some cases, this stops the outline overlapping with the indicator
// (it doesn't prevent all cases though, eg: when the shape is on the edge of a group)
len = Math.ceil(len / 4) * 4
// Size of step
const step = PI2 / len
const a = Math.cos(step)
const b = Math.sin(step)
let sin = 0
let cos = 1
let ts = 0
let tc = 1
const points: Vec2d[] = Array(len)
for (let i = 0; i < len; i++) {
points[i] = new Vec2d(cx + cx * cos, cy + cy * sin)
ts = b * cos + a * sin
tc = a * cos - b * sin
sin = ts
cos = tc
}
return points
}
case 'oval': {
const len = 10
const points: Vec2d[] = Array(len * 2)
if (h > w) {
for (let i = 0; i < len; i++) {
const t1 = -PI + (PI * i) / (len - 2)
const t2 = (PI * i) / (len - 2)
points[i] = new Vec2d(cx + cx * Math.cos(t1), cx + cx * Math.sin(t1))
points[i + len] = new Vec2d(cx + cx * Math.cos(t2), h - cx + cx * Math.sin(t2))
}
} else {
for (let i = 0; i < len; i++) {
const t1 = -TAU + (PI * i) / (len - 2)
const t2 = TAU + (PI * -i) / (len - 2)
points[i] = new Vec2d(w - cy + cy * Math.cos(t1), h - cy + cy * Math.sin(t1))
points[i + len] = new Vec2d(cy - cy * Math.cos(t2), h - cy + cy * Math.sin(t2))
}
}
return points
}
case 'star': {
// Most of this code is to offset the center, a 5 point star
// will need to be moved downward because from its center [0,0]
// it will have a bigger minY than maxY. This is because it'll
// have 2 points at the bottom.
const sides = 5
const step = PI2 / sides / 2
const rightMostIndex = Math.floor(sides / 4) * 2
const leftMostIndex = sides * 2 - rightMostIndex
const topMostIndex = 0
const bottomMostIndex = Math.floor(sides / 2) * 2
const maxX = (Math.cos(-TAU + rightMostIndex * step) * w) / 2
const minX = (Math.cos(-TAU + leftMostIndex * step) * w) / 2
const minY = (Math.sin(-TAU + topMostIndex * step) * h) / 2
const maxY = (Math.sin(-TAU + bottomMostIndex * step) * h) / 2
const diffX = w - Math.abs(maxX - minX)
const diffY = h - Math.abs(maxY - minY)
const offsetX = w / 2 + minX - (w / 2 - maxX)
const offsetY = h / 2 + minY - (h / 2 - maxY)
const ratio = 1
const cx = (w - offsetX) / 2
const cy = (h - offsetY) / 2
const ox = (w + diffX) / 2
const oy = (h + diffY) / 2
const ix = (ox * ratio) / 2
const iy = (oy * ratio) / 2
return Array.from(Array(sides * 2)).map((_, i) => {
const theta = -TAU + i * step
return new Vec2d(
cx + (i % 2 ? ix : ox) * Math.cos(theta),
cy + (i % 2 ? iy : oy) * Math.sin(theta)
)
})
}
case 'rhombus': {
const offset = Math.min(w * 0.38, h * 0.38)
return [new Vec2d(offset, 0), new Vec2d(w, 0), new Vec2d(w - offset, h), new Vec2d(0, h)]
}
case 'rhombus-2': {
const offset = Math.min(w * 0.38, h * 0.38)
return [new Vec2d(0, 0), new Vec2d(w - offset, 0), new Vec2d(w, h), new Vec2d(offset, h)]
}
case 'trapezoid': {
const offset = Math.min(w * 0.38, h * 0.38)
return [new Vec2d(offset, 0), new Vec2d(w - offset, 0), new Vec2d(w, h), new Vec2d(0, h)]
}
case 'arrow-right': {
const ox = Math.min(w, h) * 0.38
const oy = h * 0.16
return [
new Vec2d(0, oy),
new Vec2d(w - ox, oy),
new Vec2d(w - ox, 0),
new Vec2d(w, h / 2),
new Vec2d(w - ox, h),
new Vec2d(w - ox, h - oy),
new Vec2d(0, h - oy),
]
}
case 'arrow-left': {
const ox = Math.min(w, h) * 0.38
const oy = h * 0.16
return [
new Vec2d(ox, 0),
new Vec2d(ox, oy),
new Vec2d(w, oy),
new Vec2d(w, h - oy),
new Vec2d(ox, h - oy),
new Vec2d(ox, h),
new Vec2d(0, h / 2),
]
}
case 'arrow-up': {
const ox = w * 0.16
const oy = Math.min(w, h) * 0.38
return [
new Vec2d(w / 2, 0),
new Vec2d(w, oy),
new Vec2d(w - ox, oy),
new Vec2d(w - ox, h),
new Vec2d(ox, h),
new Vec2d(ox, oy),
new Vec2d(0, oy),
]
}
case 'arrow-down': {
const ox = w * 0.16
const oy = Math.min(w, h) * 0.38
return [
new Vec2d(ox, 0),
new Vec2d(w - ox, 0),
new Vec2d(w - ox, h - oy),
new Vec2d(w, h - oy),
new Vec2d(w / 2, h),
new Vec2d(0, h - oy),
new Vec2d(ox, h - oy),
]
}
case 'check-box':
case 'x-box':
case 'rectangle': {
return [new Vec2d(0, 0), new Vec2d(w, 0), new Vec2d(w, h), new Vec2d(0, h)]
}
}
}
onEditEnd: TLOnEditEndHandler<TLGeoShape> = (shape) => {
const {
id,
type,
props: { text },
} = shape
if (text.trimEnd() !== shape.props.text) {
this.editor.updateShapes([
{
id,
type,
props: {
text: text.trimEnd(),
},
},
])
}
}
render(shape: TLGeoShape) {
const { id, type, props } = shape
const forceSolid = useForceSolid()
const strokeWidth = this.editor.getStrokeWidth(props.size)
const { w, color, labelColor, fill, dash, growY, font, align, verticalAlign, size, text } =
props
const getShape = () => {
const h = props.h + growY
switch (props.geo) {
case 'ellipse': {
if (dash === 'solid' || (dash === 'draw' && forceSolid)) {
return (
<SolidStyleEllipse strokeWidth={strokeWidth} w={w} h={h} color={color} fill={fill} />
)
} else if (dash === 'dashed' || dash === 'dotted') {
return (
<DashStyleEllipse
id={id}
strokeWidth={strokeWidth}
w={w}
h={h}
dash={dash === 'dashed' ? dash : size === 's' && forceSolid ? 'dashed' : dash}
color={color}
fill={fill}
/>
)
} else if (dash === 'draw') {
return (
<SolidStyleEllipse strokeWidth={strokeWidth} w={w} h={h} color={color} fill={fill} />
)
}
break
}
case 'oval': {
if (dash === 'solid' || (dash === 'draw' && forceSolid)) {
return (
<SolidStyleOval strokeWidth={strokeWidth} w={w} h={h} color={color} fill={fill} />
)
} else if (dash === 'dashed' || dash === 'dotted') {
return (
<DashStyleOval
id={id}
strokeWidth={strokeWidth}
w={w}
h={h}
dash={dash === 'dashed' ? dash : size === 's' && forceSolid ? 'dashed' : dash}
color={color}
fill={fill}
/>
)
} else if (dash === 'draw') {
return (
<SolidStyleOval strokeWidth={strokeWidth} w={w} h={h} color={color} fill={fill} />
)
}
break
}
default: {
const outline = this.outline(shape)
const lines = getLines(shape.props, strokeWidth)
if (dash === 'solid' || (dash === 'draw' && forceSolid)) {
return (
<SolidStylePolygon
fill={fill}
color={color}
strokeWidth={strokeWidth}
outline={outline}
lines={lines}
/>
)
} else if (dash === 'dashed' || dash === 'dotted') {
return (
<DashStylePolygon
dash={dash === 'dashed' ? dash : size === 's' && forceSolid ? 'dashed' : dash}
fill={fill}
color={color}
strokeWidth={strokeWidth}
outline={outline}
lines={lines}
/>
)
} else if (dash === 'draw') {
return (
<DrawStylePolygon
id={id}
fill={fill}
color={color}
strokeWidth={strokeWidth}
outline={outline}
lines={lines}
/>
)
}
}
}
}
return (
<>
<SVGContainer id={id}>{getShape()}</SVGContainer>
<TextLabel
id={id}
type={type}
font={font}
fill={fill}
size={size}
align={align}
verticalAlign={verticalAlign}
text={text}
labelColor={this.editor.getCssColor(labelColor)}
wrap
/>
{'url' in shape.props && shape.props.url && (
<HyperlinkButton url={shape.props.url} zoomLevel={this.editor.zoomLevel} />
)}
</>
)
}
indicator(shape: TLGeoShape) {
const { id, props } = shape
const { w, h, growY, size } = props
const forceSolid = useForceSolid()
const strokeWidth = this.editor.getStrokeWidth(size)
switch (props.geo) {
case 'ellipse': {
if (props.dash === 'draw' && !forceSolid) {
return <path d={getEllipseIndicatorPath(id, w, h + growY, strokeWidth)} />
}
return <ellipse cx={w / 2} cy={(h + growY) / 2} rx={w / 2} ry={(h + growY) / 2} />
}
case 'oval': {
return <path d={getOvalIndicatorPath(w, h + growY)} />
}
default: {
const outline = this.outline(shape)
let path: string
if (props.dash === 'draw' && !forceSolid) {
const polygonPoints = getRoundedPolygonPoints(id, outline, 0, strokeWidth * 2, 1)
path = getRoundedInkyPolygonPath(polygonPoints)
} else {
path = 'M' + outline[0] + 'L' + outline.slice(1) + 'Z'
}
const lines = getLines(shape.props, strokeWidth)
if (lines) {
for (const [A, B] of lines) {
path += `M${A.x},${A.y}L${B.x},${B.y}`
}
}
return <path d={path} />
}
}
}
toSvg(shape: TLGeoShape, font: string, colors: TLExportColors) {
const { id, props } = shape
const strokeWidth = this.editor.getStrokeWidth(props.size)
let svgElm: SVGElement
switch (props.geo) {
case 'ellipse': {
switch (props.dash) {
case 'draw':
svgElm = DrawStyleEllipseSvg({
id,
w: props.w,
h: props.h,
color: props.color,
fill: props.fill,
strokeWidth,
colors,
})
break
case 'solid':
svgElm = SolidStyleEllipseSvg({
strokeWidth,
w: props.w,
h: props.h,
color: props.color,
fill: props.fill,
colors,
})
break
default:
svgElm = DashStyleEllipseSvg({
id,
strokeWidth,
w: props.w,
h: props.h,
dash: props.dash,
color: props.color,
fill: props.fill,
colors,
})
break
}
break
}
case 'oval': {
switch (props.dash) {
case 'draw':
svgElm = DashStyleOvalSvg({
id,
strokeWidth,
w: props.w,
h: props.h,
dash: props.dash,
color: props.color,
fill: props.fill,
colors,
})
break
case 'solid':
svgElm = SolidStyleOvalSvg({
strokeWidth,
w: props.w,
h: props.h,
color: props.color,
fill: props.fill,
colors,
})
break
default:
svgElm = DashStyleOvalSvg({
id,
strokeWidth,
w: props.w,
h: props.h,
dash: props.dash,
color: props.color,
fill: props.fill,
colors,
})
}
break
}
default: {
const outline = this.outline(shape)
const lines = getLines(shape.props, strokeWidth)
switch (props.dash) {
case 'draw':
svgElm = DrawStylePolygonSvg({
id,
fill: props.fill,
color: props.color,
strokeWidth,
outline,
lines,
colors,
})
break
case 'solid':
svgElm = SolidStylePolygonSvg({
fill: props.fill,
color: props.color,
strokeWidth,
outline,
lines,
colors,
})
break
default:
svgElm = DashStylePolygonSvg({
dash: props.dash,
fill: props.fill,
color: props.color,
strokeWidth,
outline,
lines,
colors,
})
break
}
break
}
}
if (props.text) {
const bounds = this.bounds(shape)
const rootTextElm = getTextLabelSvgElement({
editor: this.editor,
shape,
font,
bounds,
})
const textElm = rootTextElm.cloneNode(true) as SVGTextElement
textElm.setAttribute('fill', colors.fill[shape.props.labelColor])
textElm.setAttribute('stroke', 'none')
const textBgEl = rootTextElm.cloneNode(true) as SVGTextElement
textBgEl.setAttribute('stroke-width', '2')
textBgEl.setAttribute('fill', colors.background)
textBgEl.setAttribute('stroke', colors.background)
const groupEl = document.createElementNS('http://www.w3.org/2000/svg', 'g')
groupEl.append(textBgEl)
groupEl.append(textElm)
if (svgElm.nodeName === 'g') {
svgElm.appendChild(groupEl)
return svgElm
} else {
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
g.appendChild(svgElm)
g.appendChild(groupEl)
return g
}
}
return svgElm
}
onResize: TLOnResizeHandler<TLGeoShape> = (
shape,
{ initialBounds, handle, newPoint, scaleX, scaleY }
) => {
let w = initialBounds.width * scaleX
let h = initialBounds.height * scaleY
let overShrinkX = 0
let overShrinkY = 0
if (shape.props.text.trim()) {
let newW = Math.max(Math.abs(w), MIN_SIZE_WITH_LABEL)
let newH = Math.max(Math.abs(h), MIN_SIZE_WITH_LABEL)
if (newW < MIN_SIZE_WITH_LABEL && newH === MIN_SIZE_WITH_LABEL) {
newW = MIN_SIZE_WITH_LABEL
}
if (newW === MIN_SIZE_WITH_LABEL && newH < MIN_SIZE_WITH_LABEL) {
newH = MIN_SIZE_WITH_LABEL
}
const labelSize = getLabelSize(this.editor, {
...shape,
props: {
...shape.props,
w: newW,
h: newH,
},
})
const nextW = Math.max(Math.abs(w), labelSize.w) * Math.sign(w)
const nextH = Math.max(Math.abs(h), labelSize.h) * Math.sign(h)
overShrinkX = Math.abs(nextW) - Math.abs(w)
overShrinkY = Math.abs(nextH) - Math.abs(h)
w = nextW
h = nextH
}
const offset = new Vec2d(0, 0)
// x offsets
if (scaleX < 0) {
offset.x += w
}
if (handle === 'left' || handle === 'top_left' || handle === 'bottom_left') {
offset.x += scaleX < 0 ? overShrinkX : -overShrinkX
}
// y offsets
if (scaleY < 0) {
offset.y += h
}
if (handle === 'top' || handle === 'top_left' || handle === 'top_right') {
offset.y += scaleY < 0 ? overShrinkY : -overShrinkY
}
const { x, y } = offset.rot(shape.rotation).add(newPoint)
return {
x,
y,
props: {
w: Math.max(Math.abs(w), 1),
h: Math.max(Math.abs(h), 1),
growY: 0,
},
}
}
onBeforeCreate = (shape: TLGeoShape) => {
if (!shape.props.text) {
if (shape.props.growY) {
// No text / some growY, set growY to 0
return {
...shape,
props: {
...shape.props,
growY: 0,
},
}
} else {
// No text / no growY, nothing to change
return
}
}
const prevHeight = shape.props.h
const nextHeight = getLabelSize(this.editor, shape).h
let growY: number | null = null
if (nextHeight > prevHeight) {
growY = nextHeight - prevHeight
} else {
if (shape.props.growY) {
growY = 0
}
}
if (growY !== null) {
return {
...shape,
props: {
...shape.props,
growY,
},
}
}
}
onBeforeUpdate = (prev: TLGeoShape, next: TLGeoShape) => {
const prevText = prev.props.text.trimEnd()
const nextText = next.props.text.trimEnd()
if (
prevText === nextText &&
prev.props.font === next.props.font &&
prev.props.size === next.props.size
) {
return
}
if (prevText && !nextText) {
return {
...next,
props: {
...next.props,
growY: 0,
},
}
}
const prevWidth = prev.props.w
const prevHeight = prev.props.h
const nextSize = getLabelSize(this.editor, next)
const nextWidth = nextSize.w
const nextHeight = nextSize.h
// When entering the first character in a label (not pasting in multiple characters...)
if (!prevText && nextText && nextText.length === 1) {
let w = Math.max(prevWidth, nextWidth)
let h = Math.max(prevHeight, nextHeight)
// If both the width and height were less than the minimum size, make the shape square
if (prev.props.w < MIN_SIZE_WITH_LABEL && prev.props.h < MIN_SIZE_WITH_LABEL) {
w = Math.max(w, MIN_SIZE_WITH_LABEL)
h = Math.max(h, MIN_SIZE_WITH_LABEL)
w = Math.max(w, h)
h = Math.max(w, h)
}
// Don't set a growY—at least, not until we've implemented a growX property
return {
...next,
props: {
...next.props,
w,
h,
growY: 0,
},
}
}
let growY: number | null = null
if (nextHeight > prevHeight) {
growY = nextHeight - prevHeight
} else {
if (prev.props.growY) {
growY = 0
}
}
if (growY !== null) {
return {
...next,
props: {
...next.props,
growY,
w: Math.max(next.props.w, nextWidth),
},
}
}
if (nextWidth > prev.props.w) {
return {
...next,
props: {
...next.props,
w: nextWidth,
},
}
}
}
onDoubleClick = (shape: TLGeoShape) => {
// Little easter egg: double-clicking a rectangle / checkbox while
// holding alt will toggle between check-box and rectangle
if (this.editor.inputs.altKey) {
switch (shape.props.geo) {
case 'rectangle': {
return {
...shape,
props: {
geo: 'check-box' as const,
},
}
}
case 'check-box': {
return {
...shape,
props: {
geo: 'rectangle' as const,
},
}
}
}
}
return
}
}
function getLabelSize(editor: Editor, shape: TLGeoShape) {
const text = shape.props.text.trimEnd()
if (!text) {
return { w: 0, h: 0 }
}
const minSize = editor.textMeasure.measureText('w', {
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[shape.props.font],
fontSize: LABEL_FONT_SIZES[shape.props.size],
width: 'fit-content',
maxWidth: '100px',
})
// TODO: Can I get these from somewhere?
const sizes = {
s: 2,
m: 3.5,
l: 5,
xl: 10,
}
const size = editor.textMeasure.measureText(text, {
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[shape.props.font],
fontSize: LABEL_FONT_SIZES[shape.props.size],
width: 'fit-content',
minWidth: minSize.w + 'px',
maxWidth:
Math.max(
// Guard because a DOM nodes can't be less 0
0,
// A 'w' width that we're setting as the min-width
Math.ceil(minSize.w + sizes[shape.props.size]),
// The actual text size
Math.ceil(shape.props.w - LABEL_PADDING * 2)
) + 'px',
})
return {
w: size.w + LABEL_PADDING * 2,
h: size.h + LABEL_PADDING * 2,
}
}
function getLines(props: TLGeoShape['props'], sw: number) {
switch (props.geo) {
case 'x-box': {
return getXBoxLines(props.w, props.h, sw, props.dash)
}
case 'check-box': {
return getCheckBoxLines(props.w, props.h)
}
default: {
return undefined
}
}
}
function getXBoxLines(w: number, h: number, sw: number, dash: TLDashType) {
const inset = dash === 'draw' ? 0.62 : 0
if (dash === 'dashed') {
return [
[new Vec2d(0, 0), new Vec2d(w / 2, h / 2)],
[new Vec2d(w, h), new Vec2d(w / 2, h / 2)],
[new Vec2d(0, h), new Vec2d(w / 2, h / 2)],
[new Vec2d(w, 0), new Vec2d(w / 2, h / 2)],
]
}
return [
[new Vec2d(sw * inset, sw * inset), new Vec2d(w - sw * inset, h - sw * inset)],
[new Vec2d(sw * inset, h - sw * inset), new Vec2d(w - sw * inset, sw * inset)],
]
}
function getCheckBoxLines(w: number, h: number) {
const size = Math.min(w, h) * 0.82
const ox = (w - size) / 2
const oy = (h - size) / 2
return [
[new Vec2d(ox + size * 0.25, oy + size * 0.52), new Vec2d(ox + size * 0.45, oy + size * 0.82)],
[new Vec2d(ox + size * 0.45, oy + size * 0.82), new Vec2d(ox + size * 0.82, oy + size * 0.22)],
]
}