From b5dfd81540ab266cf777b0073ccddffefe690d76 Mon Sep 17 00:00:00 2001 From: David Sheldrick Date: Fri, 19 Apr 2024 14:56:55 +0100 Subject: [PATCH] WebGL Minimap (#3510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR replaces our current minimap implementation with one that uses WebGL ### Change Type - [x] `sdk` — Changes the tldraw SDK - [ ] `dotcom` — Changes the tldraw.com web app - [ ] `docs` — Changes to the documentation, examples, or templates. - [ ] `vs code` — Changes to the vscode plugin - [ ] `internal` — Does not affect user-facing stuff - [ ] `bugfix` — Bug fix - [ ] `feature` — New feature - [x] `improvement` — Improving existing features - [ ] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [ ] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know ### Test Plan 1. Add a step-by-step description of how to test your PR here. 2. - [ ] Unit Tests - [ ] End to end tests ### Release Notes - Add a brief release note for your PR here. --------- Co-authored-by: Steve Ruiz --- packages/editor/api-report.md | 4 + packages/editor/api/api.json | 80 +++ packages/editor/src/lib/editor/Editor.ts | 77 ++- packages/editor/src/lib/hooks/usePeerIds.ts | 10 +- packages/editor/src/lib/hooks/usePresence.ts | 14 +- packages/editor/src/lib/primitives/Mat.ts | 13 +- .../ui/components/Minimap/DefaultMinimap.tsx | 171 +++--- .../ui/components/Minimap/MinimapManager.ts | 486 ++++++++---------- .../src/lib/ui/components/Minimap/getRgba.ts | 16 + .../components/Minimap/minimap-webgl-setup.ts | 148 ++++++ .../Minimap/minimap-webgl-shapes.ts | 144 ++++++ 11 files changed, 743 insertions(+), 420 deletions(-) create mode 100644 packages/tldraw/src/lib/ui/components/Minimap/getRgba.ts create mode 100644 packages/tldraw/src/lib/ui/components/Minimap/minimap-webgl-setup.ts create mode 100644 packages/tldraw/src/lib/ui/components/Minimap/minimap-webgl-shapes.ts diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index 1aa6fda13..a8039822c 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -682,6 +682,8 @@ export class Editor extends EventEmitter { getCameraState(): "idle" | "moving"; getCanRedo(): boolean; getCanUndo(): boolean; + getCollaborators(): TLInstancePresence[]; + getCollaboratorsOnCurrentPage(): TLInstancePresence[]; getContainer: () => HTMLElement; getContentFromCurrentPage(shapes: TLShape[] | TLShapeId[]): TLContent | undefined; // @internal @@ -693,6 +695,8 @@ export class Editor extends EventEmitter { getCurrentPageId(): TLPageId; getCurrentPageRenderingShapesSorted(): TLShape[]; getCurrentPageShapeIds(): Set; + // @internal (undocumented) + getCurrentPageShapeIdsSorted(): TLShapeId[]; getCurrentPageShapes(): TLShape[]; getCurrentPageShapesSorted(): TLShape[]; getCurrentPageState(): TLInstancePageState; diff --git a/packages/editor/api/api.json b/packages/editor/api/api.json index ce3f1394f..7dc444dd9 100644 --- a/packages/editor/api/api.json +++ b/packages/editor/api/api.json @@ -10059,6 +10059,86 @@ "isAbstract": false, "name": "getCanUndo" }, + { + "kind": "Method", + "canonicalReference": "@tldraw/editor!Editor#getCollaborators:member(1)", + "docComment": "/**\n * Returns a list of presence records for all peer collaborators. This will return the latest presence record for each connected user.\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "getCollaborators(): " + }, + { + "kind": "Content", + "text": "import(\"@tldraw/tlschema\")." + }, + { + "kind": "Reference", + "text": "TLInstancePresence", + "canonicalReference": "@tldraw/tlschema!TLInstancePresence:interface" + }, + { + "kind": "Content", + "text": "[]" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 1, + "endIndex": 4 + }, + "releaseTag": "Public", + "isProtected": false, + "overloadIndex": 1, + "parameters": [], + "isOptional": false, + "isAbstract": false, + "name": "getCollaborators" + }, + { + "kind": "Method", + "canonicalReference": "@tldraw/editor!Editor#getCollaboratorsOnCurrentPage:member(1)", + "docComment": "/**\n * Returns a list of presence records for all peer collaborators on the current page. This will return the latest presence record for each connected user.\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "getCollaboratorsOnCurrentPage(): " + }, + { + "kind": "Content", + "text": "import(\"@tldraw/tlschema\")." + }, + { + "kind": "Reference", + "text": "TLInstancePresence", + "canonicalReference": "@tldraw/tlschema!TLInstancePresence:interface" + }, + { + "kind": "Content", + "text": "[]" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 1, + "endIndex": 4 + }, + "releaseTag": "Public", + "isProtected": false, + "overloadIndex": 1, + "parameters": [], + "isOptional": false, + "isAbstract": false, + "name": "getCollaboratorsOnCurrentPage" + }, { "kind": "Property", "canonicalReference": "@tldraw/editor!Editor#getContainer:member", diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index e4e9fa1bf..c74be045e 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -2619,15 +2619,7 @@ export class Editor extends EventEmitter { * @public */ animateToUser(userId: string): this { - const presences = this.store.query.records('instance_presence', () => ({ - userId: { eq: userId }, - })) - - const presence = [...presences.get()] - .sort((a, b) => { - return a.lastActivityTimestamp - b.lastActivityTimestamp - }) - .pop() + const presence = this.getCollaborators().find((c) => c.userId === userId) if (!presence) return this @@ -2883,6 +2875,45 @@ export class Editor extends EventEmitter { z: point.z ?? 0.5, } } + // Collaborators + + @computed + private _getCollaboratorsQuery() { + return this.store.query.records('instance_presence', () => ({ + userId: { neq: this.user.getId() }, + })) + } + + /** + * Returns a list of presence records for all peer collaborators. + * This will return the latest presence record for each connected user. + * + * @public + */ + @computed + getCollaborators() { + const allPresenceRecords = this._getCollaboratorsQuery().get() + if (!allPresenceRecords.length) return EMPTY_ARRAY + const userIds = [...new Set(allPresenceRecords.map((c) => c.userId))].sort() + return userIds.map((id) => { + const latestPresence = allPresenceRecords + .filter((c) => c.userId === id) + .sort((a, b) => b.lastActivityTimestamp - a.lastActivityTimestamp)[0] + return latestPresence + }) + } + + /** + * Returns a list of presence records for all peer collaborators on the current page. + * This will return the latest presence record for each connected user. + * + * @public + */ + @computed + getCollaboratorsOnCurrentPage() { + const currentPageId = this.getCurrentPageId() + return this.getCollaborators().filter((c) => c.currentPageId === currentPageId) + } // Following @@ -2894,9 +2925,9 @@ export class Editor extends EventEmitter { * @public */ startFollowingUser(userId: string): this { - const leaderPresences = this.store.query.records('instance_presence', () => ({ - userId: { eq: userId }, - })) + const leaderPresences = this._getCollaboratorsQuery() + .get() + .filter((p) => p.userId === userId) const thisUserId = this.user.getId() @@ -2905,7 +2936,7 @@ export class Editor extends EventEmitter { } // If the leader is following us, then we can't follow them - if (leaderPresences.get().some((p) => p.followingUserId === thisUserId)) { + if (leaderPresences.some((p) => p.followingUserId === thisUserId)) { return this } @@ -2924,7 +2955,7 @@ export class Editor extends EventEmitter { const moveTowardsUser = () => { // Stop following if we can't find the user - const leaderPresence = [...leaderPresences.get()] + const leaderPresence = [...leaderPresences] .sort((a, b) => { return a.lastActivityTimestamp - b.lastActivityTimestamp }) @@ -3281,6 +3312,14 @@ export class Editor extends EventEmitter { return this._currentPageShapeIds.get() } + /** + * @internal + */ + @computed + getCurrentPageShapeIdsSorted() { + return Array.from(this.getCurrentPageShapeIds()).sort() + } + /** * Get the ids of shapes on a page. * @@ -3893,7 +3932,7 @@ export class Editor extends EventEmitter { * @public */ getShapePageTransform(shape: TLShape | TLShapeId): Mat { - const id = typeof shape === 'string' ? shape : this.getShape(shape)!.id + const id = typeof shape === 'string' ? shape : shape.id return this._getShapePageTransformCache().get(id) ?? Mat.Identity() } @@ -4227,7 +4266,7 @@ export class Editor extends EventEmitter { @computed getCurrentPageBounds(): Box | undefined { let commonBounds: Box | undefined - this.getCurrentPageShapeIds().forEach((shapeId) => { + this.getCurrentPageShapeIdsSorted().forEach((shapeId) => { const bounds = this.getShapeMaskedPageBounds(shapeId) if (!bounds) return if (!commonBounds) { @@ -8159,7 +8198,11 @@ export class Editor extends EventEmitter { // it will be 0,0 when its actual screen position is equal // to screenBounds.point. This is confusing! currentScreenPoint.set(sx, sy) - currentPagePoint.set(sx / cz - cx, sy / cz - cy, sz) + const nx = sx / cz - cx + const ny = sy / cz - cy + if (isFinite(nx) && isFinite(ny)) { + currentPagePoint.set(nx, ny, sz) + } this.inputs.isPen = info.type === 'pointer' && info.isPen diff --git a/packages/editor/src/lib/hooks/usePeerIds.ts b/packages/editor/src/lib/hooks/usePeerIds.ts index 22308aa99..add0bb996 100644 --- a/packages/editor/src/lib/hooks/usePeerIds.ts +++ b/packages/editor/src/lib/hooks/usePeerIds.ts @@ -1,5 +1,4 @@ import { useComputed, useValue } from '@tldraw/state' -import { useMemo } from 'react' import { uniq } from '../utils/uniq' import { useEditor } from './useEditor' @@ -10,17 +9,12 @@ import { useEditor } from './useEditor' */ export function usePeerIds() { const editor = useEditor() - const $presences = useMemo(() => { - return editor.store.query.records('instance_presence', () => ({ - userId: { neq: editor.user.getId() }, - })) - }, [editor]) const $userIds = useComputed( 'userIds', - () => uniq($presences.get().map((p) => p.userId)).sort(), + () => uniq(editor.getCollaborators().map((p) => p.userId)).sort(), { isEqual: (a, b) => a.join(',') === b.join?.(',') }, - [$presences] + [editor] ) return useValue($userIds) diff --git a/packages/editor/src/lib/hooks/usePresence.ts b/packages/editor/src/lib/hooks/usePresence.ts index 6f75337d5..55e51950a 100644 --- a/packages/editor/src/lib/hooks/usePresence.ts +++ b/packages/editor/src/lib/hooks/usePresence.ts @@ -1,6 +1,5 @@ import { useValue } from '@tldraw/state' import { TLInstancePresence } from '@tldraw/tlschema' -import { useMemo } from 'react' import { useEditor } from './useEditor' // TODO: maybe move this to a computed property on the App class? @@ -11,21 +10,12 @@ import { useEditor } from './useEditor' export function usePresence(userId: string): TLInstancePresence | null { const editor = useEditor() - const $presences = useMemo(() => { - return editor.store.query.records('instance_presence', () => ({ - userId: { eq: userId }, - })) - }, [editor, userId]) - const latestPresence = useValue( `latestPresence:${userId}`, () => { - return $presences - .get() - .slice() - .sort((a, b) => b.lastActivityTimestamp - a.lastActivityTimestamp)[0] + return editor.getCollaborators().find((c) => c.userId === userId) }, - [] + [editor] ) return latestPresence ?? null diff --git a/packages/editor/src/lib/primitives/Mat.ts b/packages/editor/src/lib/primitives/Mat.ts index b2388fcb1..14d874c21 100644 --- a/packages/editor/src/lib/primitives/Mat.ts +++ b/packages/editor/src/lib/primitives/Mat.ts @@ -39,12 +39,13 @@ export class Mat { equals(m: Mat | MatModel) { return ( - this.a === m.a && - this.b === m.b && - this.c === m.c && - this.d === m.d && - this.e === m.e && - this.f === m.f + this === m || + (this.a === m.a && + this.b === m.b && + this.c === m.c && + this.d === m.d && + this.e === m.e && + this.f === m.f) ) } diff --git a/packages/tldraw/src/lib/ui/components/Minimap/DefaultMinimap.tsx b/packages/tldraw/src/lib/ui/components/Minimap/DefaultMinimap.tsx index a84eba262..82436126a 100644 --- a/packages/tldraw/src/lib/ui/components/Minimap/DefaultMinimap.tsx +++ b/packages/tldraw/src/lib/ui/components/Minimap/DefaultMinimap.tsx @@ -1,18 +1,13 @@ import { ANIMATION_MEDIUM_MS, - Box, TLPointerEventInfo, - TLShapeId, Vec, getPointerInfo, - intersectPolygonPolygon, normalizeWheel, releasePointerCapture, setPointerCapture, - useComputed, useEditor, useIsDarkMode, - useQuickReactor, } from '@tldraw/editor' import * as React from 'react' import { MinimapManager } from './MinimapManager' @@ -24,67 +19,78 @@ export function DefaultMinimap() { const rCanvas = React.useRef(null!) const rPointing = React.useRef(false) - const isDarkMode = useIsDarkMode() - const devicePixelRatio = useComputed('dpr', () => editor.getInstanceState().devicePixelRatio, [ - editor, - ]) - const presences = React.useMemo(() => editor.store.query.records('instance_presence'), [editor]) - - const minimap = React.useMemo(() => new MinimapManager(editor), [editor]) + const minimapRef = React.useRef() React.useEffect(() => { - // Must check after render - const raf = requestAnimationFrame(() => { - minimap.updateColors() - minimap.render() - }) - return () => { - cancelAnimationFrame(raf) - } - }, [editor, minimap, isDarkMode]) + const minimap = new MinimapManager(editor, rCanvas.current) + minimapRef.current = minimap + return minimapRef.current.close + }, [editor]) const onDoubleClick = React.useCallback( (e: React.MouseEvent) => { if (!editor.getCurrentPageShapeIds().size) return + if (!minimapRef.current) return - const point = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, false) + const point = minimapRef.current.minimapScreenPointToPagePoint( + e.clientX, + e.clientY, + false, + false + ) - const clampedPoint = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, true) + const clampedPoint = minimapRef.current.minimapScreenPointToPagePoint( + e.clientX, + e.clientY, + false, + true + ) - minimap.originPagePoint.setTo(clampedPoint) - minimap.originPageCenter.setTo(editor.getViewportPageBounds().center) + minimapRef.current.originPagePoint.setTo(clampedPoint) + minimapRef.current.originPageCenter.setTo(editor.getViewportPageBounds().center) editor.centerOnPoint(point, { duration: ANIMATION_MEDIUM_MS }) }, - [editor, minimap] + [editor] ) const onPointerDown = React.useCallback( (e: React.PointerEvent) => { + if (!minimapRef.current) return const elm = e.currentTarget setPointerCapture(elm, e) if (!editor.getCurrentPageShapeIds().size) return rPointing.current = true - minimap.isInViewport = false + minimapRef.current.isInViewport = false - const point = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, false) + const point = minimapRef.current.minimapScreenPointToPagePoint( + e.clientX, + e.clientY, + false, + false + ) - const clampedPoint = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, true) + const clampedPoint = minimapRef.current.minimapScreenPointToPagePoint( + e.clientX, + e.clientY, + false, + true + ) const _vpPageBounds = editor.getViewportPageBounds() - minimap.isInViewport = _vpPageBounds.containsPoint(clampedPoint) + minimapRef.current.isInViewport = _vpPageBounds.containsPoint(clampedPoint) - if (minimap.isInViewport) { - minimap.originPagePoint.setTo(clampedPoint) - minimap.originPageCenter.setTo(_vpPageBounds.center) + if (minimapRef.current.isInViewport) { + minimapRef.current.originPagePoint.setTo(clampedPoint) + minimapRef.current.originPageCenter.setTo(_vpPageBounds.center) } else { const delta = Vec.Sub(_vpPageBounds.center, _vpPageBounds.point) const pagePoint = Vec.Add(point, delta) - minimap.originPagePoint.setTo(pagePoint) - minimap.originPageCenter.setTo(point) + minimapRef.current.originPagePoint.setTo(pagePoint) + minimapRef.current.originPageCenter.setTo(point) editor.centerOnPoint(point, { duration: ANIMATION_MEDIUM_MS }) } @@ -98,16 +104,24 @@ export function DefaultMinimap() { document.body.addEventListener('pointerup', release) }, - [editor, minimap] + [editor] ) const onPointerMove = React.useCallback( (e: React.PointerEvent) => { - const point = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, e.shiftKey, true) + if (!minimapRef.current) return + const point = minimapRef.current.minimapScreenPointToPagePoint( + e.clientX, + e.clientY, + e.shiftKey, + true + ) if (rPointing.current) { - if (minimap.isInViewport) { - const delta = minimap.originPagePoint.clone().sub(minimap.originPageCenter) + if (minimapRef.current.isInViewport) { + const delta = minimapRef.current.originPagePoint + .clone() + .sub(minimapRef.current.originPageCenter) editor.centerOnPoint(Vec.Sub(point, delta)) return } @@ -115,7 +129,7 @@ export function DefaultMinimap() { editor.centerOnPoint(point) } - const pagePoint = minimap.getPagePoint(e.clientX, e.clientY) + const pagePoint = minimapRef.current.getPagePoint(e.clientX, e.clientY) const screenPoint = editor.pageToScreen(pagePoint) @@ -130,7 +144,7 @@ export function DefaultMinimap() { editor.dispatch(info) }, - [editor, minimap] + [editor] ) const onWheel = React.useCallback( @@ -150,73 +164,16 @@ export function DefaultMinimap() { [editor] ) - // Update the minimap's dpr when the dpr changes - useQuickReactor( - 'update when dpr changes', - () => { - const dpr = devicePixelRatio.get() - minimap.setDpr(dpr) + const isDarkMode = useIsDarkMode() - const canvas = rCanvas.current as HTMLCanvasElement - const rect = canvas.getBoundingClientRect() - const width = rect.width * dpr - const height = rect.height * dpr - - // These must happen in order - canvas.width = width - canvas.height = height - minimap.canvasScreenBounds.set(rect.x, rect.y, width, height) - - minimap.cvs = rCanvas.current - }, - [devicePixelRatio, minimap] - ) - - useQuickReactor( - 'minimap render when pagebounds or collaborators changes', - () => { - const shapeIdsOnCurrentPage = editor.getCurrentPageShapeIds() - const commonBoundsOfAllShapesOnCurrentPage = editor.getCurrentPageBounds() - const viewportPageBounds = editor.getViewportPageBounds() - - const _dpr = devicePixelRatio.get() // dereference - - minimap.contentPageBounds = commonBoundsOfAllShapesOnCurrentPage - ? Box.Expand(commonBoundsOfAllShapesOnCurrentPage, viewportPageBounds) - : viewportPageBounds - - minimap.updateContentScreenBounds() - - // All shape bounds - - const allShapeBounds = [] as (Box & { id: TLShapeId })[] - - shapeIdsOnCurrentPage.forEach((id) => { - let pageBounds = editor.getShapePageBounds(id) as Box & { id: TLShapeId } - if (!pageBounds) return - - const pageMask = editor.getShapeMask(id) - - if (pageMask) { - const intersection = intersectPolygonPolygon(pageMask, pageBounds.corners) - if (!intersection) { - return - } - pageBounds = Box.FromPoints(intersection) as Box & { id: TLShapeId } - } - - if (pageBounds) { - pageBounds.id = id // kinda dirty but we want to include the id here - allShapeBounds.push(pageBounds) - } - }) - - minimap.pageBounds = allShapeBounds - minimap.collaborators = presences.get() - minimap.render() - }, - [editor, minimap] - ) + React.useEffect(() => { + // need to wait a tick for next theme css to be applied + // otherwise the minimap will render with the wrong colors + setTimeout(() => { + minimapRef.current?.updateColors() + minimapRef.current?.render() + }) + }, [isDarkMode]) return (
diff --git a/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts b/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts index eeef0fd7f..3e7757b15 100644 --- a/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts +++ b/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts @@ -1,114 +1,159 @@ import { Box, + ComputedCache, Editor, - PI2, - TLInstancePresence, - TLShapeId, + TLShape, Vec, + atom, clamp, + computed, + react, uniqueId, } from '@tldraw/editor' +import { getRgba } from './getRgba' +import { BufferStuff, appendVertices, setupWebGl } from './minimap-webgl-setup' +import { pie, rectangle, roundedRectangle } from './minimap-webgl-shapes' export class MinimapManager { - constructor(public editor: Editor) {} - - dpr = 1 - - colors = { - shapeFill: 'rgba(144, 144, 144, .1)', - selectFill: '#2f80ed', - viewportFill: 'rgba(144, 144, 144, .1)', + disposables = [] as (() => void)[] + close = () => this.disposables.forEach((d) => d()) + gl: ReturnType + shapeGeometryCache: ComputedCache + constructor( + public editor: Editor, + public readonly elem: HTMLCanvasElement + ) { + this.gl = setupWebGl(elem) + this.shapeGeometryCache = editor.store.createComputedCache('webgl-geometry', (r: TLShape) => { + const bounds = editor.getShapeMaskedPageBounds(r.id) + if (!bounds) return null + const arr = new Float32Array(12) + rectangle(arr, 0, bounds.x, bounds.y, bounds.w, bounds.h) + return arr + }) + this.colors = this._getColors() + this.disposables.push(this._listenForCanvasResize(), react('minimap render', this.render)) } - id = uniqueId() - cvs: HTMLCanvasElement | null = null - pageBounds: (Box & { id: TLShapeId })[] = [] - collaborators: TLInstancePresence[] = [] + private _getColors() { + const style = getComputedStyle(this.editor.getContainer()) - canvasScreenBounds = new Box() - canvasPageBounds = new Box() + return { + shapeFill: getRgba(style.getPropertyValue('--color-text-3').trim()), + selectFill: getRgba(style.getPropertyValue('--color-selected').trim()), + viewportFill: getRgba(style.getPropertyValue('--color-muted-1').trim()), + } + } - contentPageBounds = new Box() - contentScreenBounds = new Box() + private colors: ReturnType + // this should be called after dark/light mode changes have propagated to the dom + updateColors() { + this.colors = this._getColors() + } + + readonly id = uniqueId() + @computed + getDpr() { + return this.editor.getInstanceState().devicePixelRatio + } + + @computed + getContentPageBounds() { + const viewportPageBounds = this.editor.getViewportPageBounds() + const commonShapeBounds = this.editor.getCurrentPageBounds() + return commonShapeBounds + ? Box.Expand(commonShapeBounds, viewportPageBounds) + : viewportPageBounds + } + + @computed + getContentScreenBounds() { + const contentPageBounds = this.getContentPageBounds() + const topLeft = this.editor.pageToScreen(contentPageBounds.point) + const bottomRight = this.editor.pageToScreen( + new Vec(contentPageBounds.maxX, contentPageBounds.maxY) + ) + return new Box(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y) + } + + private _getCanvasBoundingRect() { + const { x, y, width, height } = this.elem.getBoundingClientRect() + return new Box(x, y, width, height) + } + + private readonly canvasBoundingClientRect = atom('canvasBoundingClientRect', new Box()) + + getCanvasScreenBounds() { + return this.canvasBoundingClientRect.get() + } + + private _listenForCanvasResize() { + const observer = new ResizeObserver(() => { + const rect = this._getCanvasBoundingRect() + this.canvasBoundingClientRect.set(rect) + }) + observer.observe(this.elem) + return () => observer.disconnect() + } + + @computed + getCanvasSize() { + const rect = this.canvasBoundingClientRect.get() + const dpr = this.getDpr() + return new Vec(rect.width * dpr, rect.height * dpr) + } + + @computed + getCanvasClientPosition() { + return this.canvasBoundingClientRect.get().point + } originPagePoint = new Vec() originPageCenter = new Vec() isInViewport = false - debug = false + /** Get the canvas's true bounds converted to page bounds. */ + @computed getCanvasPageBounds() { + const canvasScreenBounds = this.getCanvasScreenBounds() + const contentPageBounds = this.getContentPageBounds() - setDpr(dpr: number) { - this.dpr = +dpr.toFixed(2) - } + const aspectRatio = canvasScreenBounds.width / canvasScreenBounds.height - updateContentScreenBounds = () => { - const { contentScreenBounds, contentPageBounds: content, canvasScreenBounds: canvas } = this - - let { x, y, w, h } = contentScreenBounds - - if (content.w > content.h) { - const sh = canvas.w / (content.w / content.h) - if (sh > canvas.h) { - x = (canvas.w - canvas.w * (canvas.h / sh)) / 2 - y = 0 - w = canvas.w * (canvas.h / sh) - h = canvas.h - } else { - x = 0 - y = (canvas.h - sh) / 2 - w = canvas.w - h = sh - } - } else if (content.w < content.h) { - const sw = canvas.h / (content.h / content.w) - x = (canvas.w - sw) / 2 - y = 0 - w = sw - h = canvas.h - } else { - x = canvas.h / 2 - y = 0 - w = canvas.h - h = canvas.h + let targetWidth = contentPageBounds.width + let targetHeight = targetWidth / aspectRatio + if (targetHeight < contentPageBounds.height) { + targetHeight = contentPageBounds.height + targetWidth = targetHeight * aspectRatio } - contentScreenBounds.set(x, y, w, h) + const box = new Box(0, 0, targetWidth, targetHeight) + box.center = contentPageBounds.center + return box } - /** Get the canvas's true bounds converted to page bounds. */ - updateCanvasPageBounds = () => { - const { canvasPageBounds, canvasScreenBounds, contentPageBounds, contentScreenBounds } = this - - canvasPageBounds.set( - 0, - 0, - contentPageBounds.width / (contentScreenBounds.width / canvasScreenBounds.width), - contentPageBounds.height / (contentScreenBounds.height / canvasScreenBounds.height) - ) - - canvasPageBounds.center = contentPageBounds.center + @computed getCanvasPageBoundsArray() { + const { x, y, w, h } = this.getCanvasPageBounds() + return new Float32Array([x, y, w, h]) } - getScreenPoint = (x: number, y: number) => { - const { canvasScreenBounds } = this + getPagePoint = (clientX: number, clientY: number) => { + const canvasPageBounds = this.getCanvasPageBounds() + const canvasScreenBounds = this.getCanvasScreenBounds() - const screenX = (x - canvasScreenBounds.minX) * this.dpr - const screenY = (y - canvasScreenBounds.minY) * this.dpr + // first offset the canvas position + let x = clientX - canvasScreenBounds.x + let y = clientY - canvasScreenBounds.y - return { x: screenX, y: screenY } - } + // then multiply by the ratio between the page and screen bounds + x *= canvasPageBounds.width / canvasScreenBounds.width + y *= canvasPageBounds.height / canvasScreenBounds.height - getPagePoint = (x: number, y: number) => { - const { contentPageBounds, contentScreenBounds, canvasPageBounds } = this + // then add the canvas page bounds' offset + x += canvasPageBounds.minX + y += canvasPageBounds.minY - const { x: screenX, y: screenY } = this.getScreenPoint(x, y) - - return new Vec( - canvasPageBounds.minX + (screenX * contentPageBounds.width) / contentScreenBounds.width, - canvasPageBounds.minY + (screenY * contentPageBounds.height) / contentScreenBounds.height, - 1 - ) + return new Vec(x, y, 1) } minimapScreenPointToPagePoint = ( @@ -123,13 +168,13 @@ export class MinimapManager { let { x: px, y: py } = this.getPagePoint(x, y) if (clampToBounds) { - const shapesPageBounds = this.editor.getCurrentPageBounds() + const shapesPageBounds = this.editor.getCurrentPageBounds() ?? new Box() const vpPageBounds = viewportPageBounds - const minX = (shapesPageBounds?.minX ?? 0) - vpPageBounds.width / 2 - const maxX = (shapesPageBounds?.maxX ?? 0) + vpPageBounds.width / 2 - const minY = (shapesPageBounds?.minY ?? 0) - vpPageBounds.height / 2 - const maxY = (shapesPageBounds?.maxY ?? 0) + vpPageBounds.height / 2 + const minX = shapesPageBounds.minX - vpPageBounds.width / 2 + const maxX = shapesPageBounds.maxX + vpPageBounds.width / 2 + const minY = shapesPageBounds.minY - vpPageBounds.height / 2 + const maxY = shapesPageBounds.maxY + vpPageBounds.height / 2 const lx = Math.max(0, minX + vpPageBounds.width - px) const rx = Math.max(0, -(maxX - vpPageBounds.width - px)) @@ -171,209 +216,110 @@ export class MinimapManager { return new Vec(px, py) } - updateColors = () => { - const style = getComputedStyle(this.editor.getContainer()) - - this.colors = { - shapeFill: style.getPropertyValue('--color-text-3').trim(), - selectFill: style.getPropertyValue('--color-selected').trim(), - viewportFill: style.getPropertyValue('--color-muted-1').trim(), - } - } - render = () => { - const { cvs, pageBounds } = this - this.updateCanvasPageBounds() + // make sure we update when dark mode switches + const context = this.gl.context + const canvasSize = this.getCanvasSize() - const { editor, canvasScreenBounds, canvasPageBounds, contentPageBounds, contentScreenBounds } = - this - const { width: cw, height: ch } = canvasScreenBounds + this.gl.setCanvasPageBounds(this.getCanvasPageBoundsArray()) - const selectedShapeIds = new Set(editor.getSelectedShapeIds()) - const viewportPageBounds = editor.getViewportPageBounds() + this.elem.width = canvasSize.x + this.elem.height = canvasSize.y + context.viewport(0, 0, canvasSize.x, canvasSize.y) - if (!cvs || !pageBounds) { - return + // this affects which color transparent shapes are blended with + // during rendering. If we were to invert this any shapes narrower + // than 1 px in screen space would have much lower contrast. e.g. + // draw shapes on a large canvas. + if (this.editor.user.getIsDarkMode()) { + context.clearColor(1, 1, 1, 0) + } else { + context.clearColor(0, 0, 0, 0) } - const ctx = cvs.getContext('2d')! + context.clear(context.COLOR_BUFFER_BIT) - if (!ctx) { - throw new Error('Minimap (shapes): Could not get context') - } + const selectedShapes = new Set(this.editor.getSelectedShapeIds()) - ctx.resetTransform() - ctx.globalAlpha = 1 - ctx.clearRect(0, 0, cw, ch) + const colors = this.colors + let selectedShapeOffset = 0 + let unselectedShapeOffset = 0 - // Transform canvas + const ids = this.editor.getCurrentPageShapeIdsSorted() - const sx = contentScreenBounds.width / contentPageBounds.width - const sy = contentScreenBounds.height / contentPageBounds.height + for (let i = 0, len = ids.length; i < len; i++) { + const shapeId = ids[i] + const geometry = this.shapeGeometryCache.get(shapeId) + if (!geometry) continue - ctx.translate((cw - contentScreenBounds.width) / 2, (ch - contentScreenBounds.height) / 2) - ctx.scale(sx, sy) - ctx.translate(-contentPageBounds.minX, -contentPageBounds.minY) + const len = geometry.length - // shapes - const shapesPath = new Path2D() - const selectedPath = new Path2D() - - const { shapeFill, selectFill, viewportFill } = this.colors - - // When there are many shapes, don't draw rounded rectangles; - // consider using the shape's size instead. - - let pb: Box & { id: TLShapeId } - for (let i = 0, n = pageBounds.length; i < n; i++) { - pb = pageBounds[i] - ;(selectedShapeIds.has(pb.id) ? selectedPath : shapesPath).rect( - pb.minX, - pb.minY, - pb.width, - pb.height - ) - } - - // Fill the shapes paths - ctx.fillStyle = shapeFill - ctx.fill(shapesPath) - - // Fill the selected paths - ctx.fillStyle = selectFill - ctx.fill(selectedPath) - - if (this.debug) { - // Page bounds - const commonBounds = Box.Common(pageBounds) - const { minX, minY, width, height } = commonBounds - ctx.strokeStyle = 'green' - ctx.lineWidth = 2 / sx - ctx.strokeRect(minX + 1 / sx, minY + 1 / sy, width - 2 / sx, height - 2 / sy) - } - - // Brush - { - const { brush } = editor.getInstanceState() - if (brush) { - const { x, y, w, h } = brush - ctx.beginPath() - MinimapManager.sharpRect(ctx, x, y, w, h) - ctx.closePath() - ctx.fillStyle = viewportFill - ctx.fill() + if (selectedShapes.has(shapeId)) { + appendVertices(this.gl.selectedShapes, selectedShapeOffset, geometry) + selectedShapeOffset += len + } else { + appendVertices(this.gl.unselectedShapes, unselectedShapeOffset, geometry) + unselectedShapeOffset += len } } - // Viewport - { - const { minX, minY, width, height } = viewportPageBounds - - ctx.beginPath() - - const rx = 12 / sx - const ry = 12 / sx - MinimapManager.roundedRect( - ctx, - minX, - minY, - width, - height, - Math.min(width / 4, rx), - Math.min(height / 4, ry) - ) - ctx.closePath() - ctx.fillStyle = viewportFill - ctx.fill() - - if (this.debug) { - ctx.strokeStyle = 'orange' - ctx.strokeRect(minX + 1 / sx, minY + 1 / sy, width - 2 / sx, height - 2 / sy) - } - } - - // Show collaborator cursors - - // Padding for canvas bounds edges - const px = 2.5 / sx - const py = 2.5 / sy - - const currentPageId = editor.getCurrentPageId() - - let collaborator: TLInstancePresence - for (let i = 0; i < this.collaborators.length; i++) { - collaborator = this.collaborators[i] - if (collaborator.currentPageId !== currentPageId) { - continue - } - - ctx.beginPath() - ctx.ellipse( - clamp(collaborator.cursor.x, canvasPageBounds.minX + px, canvasPageBounds.maxX - px), - clamp(collaborator.cursor.y, canvasPageBounds.minY + py, canvasPageBounds.maxY - py), - 5 / sx, - 5 / sy, - 0, - 0, - PI2 - ) - ctx.fillStyle = collaborator.color - ctx.fill() - } - - if (this.debug) { - ctx.lineWidth = 2 / sx - - { - // Minimap Bounds - const { minX, minY, width, height } = contentPageBounds - ctx.strokeStyle = 'red' - ctx.strokeRect(minX + 1 / sx, minY + 1 / sy, width - 2 / sx, height - 2 / sy) - } - - { - // Canvas Bounds - const { minX, minY, width, height } = canvasPageBounds - ctx.strokeStyle = 'blue' - ctx.strokeRect(minX + 1 / sx, minY + 1 / sy, width - 2 / sx, height - 2 / sy) - } - } + this.drawViewport() + this.drawShapes(this.gl.unselectedShapes, unselectedShapeOffset, colors.shapeFill) + this.drawShapes(this.gl.selectedShapes, selectedShapeOffset, colors.selectFill) + this.drawCollaborators() } - static roundedRect( - ctx: CanvasRenderingContext2D | Path2D, - x: number, - y: number, - width: number, - height: number, - rx: number, - ry: number - ) { - if (rx < 1 && ry < 1) { - ctx.rect(x, y, width, height) - return - } - - ctx.moveTo(x + rx, y) - ctx.lineTo(x + width - rx, y) - ctx.quadraticCurveTo(x + width, y, x + width, y + ry) - ctx.lineTo(x + width, y + height - ry) - ctx.quadraticCurveTo(x + width, y + height, x + width - rx, y + height) - ctx.lineTo(x + rx, y + height) - ctx.quadraticCurveTo(x, y + height, x, y + height - ry) - ctx.lineTo(x, y + ry) - ctx.quadraticCurveTo(x, y, x + rx, y) + private drawShapes(stuff: BufferStuff, len: number, color: Float32Array) { + this.gl.prepareTriangles(stuff, len) + this.gl.setFillColor(color) + this.gl.drawTriangles(len) } - static sharpRect( - ctx: CanvasRenderingContext2D | Path2D, - x: number, - y: number, - width: number, - height: number, - _rx?: number, - _ry?: number - ) { - ctx.rect(x, y, width, height) + private drawViewport() { + const viewport = this.editor.getViewportPageBounds() + const zoom = this.getCanvasPageBounds().width / this.getCanvasScreenBounds().width + const len = roundedRectangle(this.gl.viewport.vertices, viewport, 4 * zoom) + + this.gl.prepareTriangles(this.gl.viewport, len) + this.gl.setFillColor(this.colors.viewportFill) + this.gl.drawTriangles(len) + } + + drawCollaborators() { + const collaborators = this.editor.getCollaboratorsOnCurrentPage() + if (!collaborators.length) return + + const zoom = this.getCanvasPageBounds().width / this.getCanvasScreenBounds().width + + // just draw a little circle for each collaborator + const numSegmentsPerCircle = 20 + const dataSizePerCircle = numSegmentsPerCircle * 6 + const totalSize = dataSizePerCircle * collaborators.length + + // expand vertex array if needed + if (this.gl.collaborators.vertices.length < totalSize) { + this.gl.collaborators.vertices = new Float32Array(totalSize) + } + + const vertices = this.gl.collaborators.vertices + let offset = 0 + for (const { cursor } of collaborators) { + pie(vertices, { + center: Vec.From(cursor), + radius: 2 * zoom, + offset, + numArcSegments: numSegmentsPerCircle, + }) + offset += dataSizePerCircle + } + + this.gl.prepareTriangles(this.gl.collaborators, totalSize) + + offset = 0 + for (const { color } of collaborators) { + this.gl.setFillColor(getRgba(color)) + this.gl.context.drawArrays(this.gl.context.TRIANGLES, offset / 2, dataSizePerCircle / 2) + offset += dataSizePerCircle + } } } diff --git a/packages/tldraw/src/lib/ui/components/Minimap/getRgba.ts b/packages/tldraw/src/lib/ui/components/Minimap/getRgba.ts new file mode 100644 index 000000000..43726f6b6 --- /dev/null +++ b/packages/tldraw/src/lib/ui/components/Minimap/getRgba.ts @@ -0,0 +1,16 @@ +const memo = {} as Record + +export function getRgba(colorString: string) { + if (memo[colorString]) { + return memo[colorString] + } + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') + context!.fillStyle = colorString + context!.fillRect(0, 0, 1, 1) + const [r, g, b, a] = context!.getImageData(0, 0, 1, 1).data + const result = new Float32Array([r / 255, g / 255, b / 255, a / 255]) + + memo[colorString] = result + return result +} diff --git a/packages/tldraw/src/lib/ui/components/Minimap/minimap-webgl-setup.ts b/packages/tldraw/src/lib/ui/components/Minimap/minimap-webgl-setup.ts new file mode 100644 index 000000000..0f5585d26 --- /dev/null +++ b/packages/tldraw/src/lib/ui/components/Minimap/minimap-webgl-setup.ts @@ -0,0 +1,148 @@ +import { roundedRectangleDataSize } from './minimap-webgl-shapes' + +export function setupWebGl(canvas: HTMLCanvasElement | null) { + if (!canvas) throw new Error('Canvas element not found') + + const context = canvas.getContext('webgl2', { + premultipliedAlpha: false, + }) + if (!context) throw new Error('Failed to get webgl2 context') + + const vertexShaderSourceCode = `#version 300 es + precision mediump float; + + in vec2 shapeVertexPosition; + + uniform vec4 canvasPageBounds; + + // taken (with thanks) from + // https://webglfundamentals.org/webgl/lessons/webgl-2d-matrices.html + void main() { + // convert the position from pixels to 0.0 to 1.0 + vec2 zeroToOne = (shapeVertexPosition - canvasPageBounds.xy) / canvasPageBounds.zw; + + // convert from 0->1 to 0->2 + vec2 zeroToTwo = zeroToOne * 2.0; + + // convert from 0->2 to -1->+1 (clipspace) + vec2 clipSpace = zeroToTwo - 1.0; + + gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1); + }` + + const vertexShader = context.createShader(context.VERTEX_SHADER) + if (!vertexShader) { + throw new Error('Failed to create vertex shader') + } + context.shaderSource(vertexShader, vertexShaderSourceCode) + context.compileShader(vertexShader) + if (!context.getShaderParameter(vertexShader, context.COMPILE_STATUS)) { + throw new Error('Failed to compile vertex shader') + } + + const fragmentShaderSourceCode = `#version 300 es + precision mediump float; + + uniform vec4 fillColor; + out vec4 outputColor; + + void main() { + outputColor = fillColor; + }` + + const fragmentShader = context.createShader(context.FRAGMENT_SHADER) + if (!fragmentShader) { + throw new Error('Failed to create fragment shader') + } + context.shaderSource(fragmentShader, fragmentShaderSourceCode) + context.compileShader(fragmentShader) + if (!context.getShaderParameter(fragmentShader, context.COMPILE_STATUS)) { + throw new Error('Failed to compile fragment shader') + } + + const program = context.createProgram() + if (!program) { + throw new Error('Failed to create program') + } + context.attachShader(program, vertexShader) + context.attachShader(program, fragmentShader) + context.linkProgram(program) + if (!context.getProgramParameter(program, context.LINK_STATUS)) { + throw new Error('Failed to link program') + } + context.useProgram(program) + + const shapeVertexPositionAttributeLocation = context.getAttribLocation( + program, + 'shapeVertexPosition' + ) + if (shapeVertexPositionAttributeLocation < 0) { + throw new Error('Failed to get shapeVertexPosition attribute location') + } + context.enableVertexAttribArray(shapeVertexPositionAttributeLocation) + + const canvasPageBoundsLocation = context.getUniformLocation(program, 'canvasPageBounds') + const fillColorLocation = context.getUniformLocation(program, 'fillColor') + + const selectedShapesBuffer = context.createBuffer() + if (!selectedShapesBuffer) throw new Error('Failed to create buffer') + + const unselectedShapesBuffer = context.createBuffer() + if (!unselectedShapesBuffer) throw new Error('Failed to create buffer') + + return { + context, + selectedShapes: allocateBuffer(context, 1024), + unselectedShapes: allocateBuffer(context, 4096), + viewport: allocateBuffer(context, roundedRectangleDataSize), + collaborators: allocateBuffer(context, 1024), + + prepareTriangles(stuff: BufferStuff, len: number) { + context.bindBuffer(context.ARRAY_BUFFER, stuff.buffer) + context.bufferData(context.ARRAY_BUFFER, stuff.vertices, context.STATIC_DRAW, 0, len) + context.enableVertexAttribArray(shapeVertexPositionAttributeLocation) + context.vertexAttribPointer( + shapeVertexPositionAttributeLocation, + 2, + context.FLOAT, + false, + 0, + 0 + ) + }, + + drawTriangles(len: number) { + context.drawArrays(context.TRIANGLES, 0, len / 2) + }, + + setFillColor(color: Float32Array) { + context.uniform4fv(fillColorLocation, color) + }, + + setCanvasPageBounds(bounds: Float32Array) { + context.uniform4fv(canvasPageBoundsLocation, bounds) + }, + } +} + +export type BufferStuff = ReturnType + +function allocateBuffer(context: WebGL2RenderingContext, size: number) { + const buffer = context.createBuffer() + if (!buffer) throw new Error('Failed to create buffer') + return { buffer, vertices: new Float32Array(size) } +} + +export function appendVertices(bufferStuff: BufferStuff, offset: number, data: Float32Array) { + let len = bufferStuff.vertices.length + while (len < offset + data.length) { + len *= 2 + } + if (len != bufferStuff.vertices.length) { + const newVertices = new Float32Array(len) + newVertices.set(bufferStuff.vertices) + bufferStuff.vertices = newVertices + } + + bufferStuff.vertices.set(data, offset) +} diff --git a/packages/tldraw/src/lib/ui/components/Minimap/minimap-webgl-shapes.ts b/packages/tldraw/src/lib/ui/components/Minimap/minimap-webgl-shapes.ts new file mode 100644 index 000000000..283e89344 --- /dev/null +++ b/packages/tldraw/src/lib/ui/components/Minimap/minimap-webgl-shapes.ts @@ -0,0 +1,144 @@ +import { Box, HALF_PI, PI, PI2, Vec } from '@tldraw/editor' + +export const numArcSegmentsPerCorner = 10 + +export const roundedRectangleDataSize = + // num triangles in corners + 4 * 6 * numArcSegmentsPerCorner + + // num triangles in center rect + 12 + + // num triangles in outer rects + 4 * 12 + +export function pie( + array: Float32Array, + { + center, + radius, + numArcSegments = 20, + startAngle = 0, + endAngle = PI2, + offset = 0, + }: { + center: Vec + radius: number + numArcSegments?: number + startAngle?: number + endAngle?: number + offset?: number + } +) { + const angle = (endAngle - startAngle) / numArcSegments + let i = offset + for (let a = startAngle; a < endAngle; a += angle) { + array[i++] = center.x + array[i++] = center.y + array[i++] = center.x + Math.cos(a) * radius + array[i++] = center.y + Math.sin(a) * radius + array[i++] = center.x + Math.cos(a + angle) * radius + array[i++] = center.y + Math.sin(a + angle) * radius + } + return array +} + +/** @internal **/ +export function rectangle( + array: Float32Array, + offset: number, + x: number, + y: number, + w: number, + h: number +) { + array[offset++] = x + array[offset++] = y + array[offset++] = x + array[offset++] = y + h + array[offset++] = x + w + array[offset++] = y + + array[offset++] = x + w + array[offset++] = y + array[offset++] = x + array[offset++] = y + h + array[offset++] = x + w + array[offset++] = y + h +} + +export function roundedRectangle(data: Float32Array, box: Box, radius: number): number { + const numArcSegments = numArcSegmentsPerCorner + radius = Math.min(radius, Math.min(box.w, box.h) / 2) + // first draw the inner box + const innerBox = Box.ExpandBy(box, -radius) + if (innerBox.w <= 0 || innerBox.h <= 0) { + // just draw a circle + pie(data, { center: box.center, radius: radius, numArcSegments: numArcSegmentsPerCorner * 4 }) + return numArcSegmentsPerCorner * 4 * 6 + } + let offset = 0 + // draw center rect first + rectangle(data, offset, innerBox.minX, innerBox.minY, innerBox.w, innerBox.h) + offset += 12 + // then top rect + rectangle(data, offset, innerBox.minX, box.minY, innerBox.w, radius) + offset += 12 + // then right rect + rectangle(data, offset, innerBox.maxX, innerBox.minY, radius, innerBox.h) + offset += 12 + // then bottom rect + rectangle(data, offset, innerBox.minX, innerBox.maxY, innerBox.w, radius) + offset += 12 + // then left rect + rectangle(data, offset, box.minX, innerBox.minY, radius, innerBox.h) + offset += 12 + + // draw the corners + + // top left + pie(data, { + numArcSegments, + offset, + center: innerBox.point, + radius, + startAngle: PI, + endAngle: PI * 1.5, + }) + + offset += numArcSegments * 6 + + // top right + pie(data, { + numArcSegments, + offset, + center: Vec.Add(innerBox.point, new Vec(innerBox.w, 0)), + radius, + startAngle: PI * 1.5, + endAngle: PI2, + }) + + offset += numArcSegments * 6 + + // bottom right + pie(data, { + numArcSegments, + offset, + center: Vec.Add(innerBox.point, innerBox.size), + radius, + startAngle: 0, + endAngle: HALF_PI, + }) + + offset += numArcSegments * 6 + + // bottom left + pie(data, { + numArcSegments, + offset, + center: Vec.Add(innerBox.point, new Vec(0, innerBox.h)), + radius, + startAngle: HALF_PI, + endAngle: PI, + }) + + return roundedRectangleDataSize +}