UNPKG

@tldraw/tlschema

Version:

tldraw infinite canvas SDK (schema).

646 lines (557 loc) 22.3 kB
import { VecModel } from './geometry-types' // Each point = 3 Float16s = 6 bytes = 8 base64 chars (legacy format) const _POINT_B64_LENGTH = 8 // First point in delta encoding = 3 Float32s = 12 bytes = 16 base64 chars const FIRST_POINT_B64_LENGTH = 16 // First point in 2D delta encoding = 2 Float32s = 8 bytes = 12 base64 chars (incl. padding) const FIRST_POINT_2D_B64_LENGTH = 12 // Pressure value supplied when decoding non-pressure (2D) paths const DEFAULT_PRESSURE = 0.5 /** Draw segment path encoded with 2 dimensions, XY — the constant pressure Z is dropped. @public */ export const DIM_2D = 2 /** Draw segment path encoded with 3 dimensions, XYZ. @public */ export const DIM_3D = 3 // O(1) lookup table for base64 decoding (maps char code -> 6-bit value) const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' const B64_LOOKUP = new Uint8Array(128) for (let i = 0; i < 64; i++) { B64_LOOKUP[BASE64_CHARS.charCodeAt(i)] = i } // Mask for one base64 sextet: the low 6 bits select an index into BASE64_CHARS (0–63). const SIX_BIT_MASK = 0x3f // '=' padding character, appended on encode so the output length is a multiple of 4. const PADDING_CHAR_CODE = '='.charCodeAt(0) // Precomputed powers of 2 for Float16 exponents (exp - 15, so indices 0-30 map to 2^-15 to 2^15) const POW2 = new Float64Array(31) for (let i = 0; i < 31; i++) { POW2[i] = Math.pow(2, i - 15) } const POW2_SUBNORMAL = Math.pow(2, -14) / 1024 // For subnormal numbers // Precomputed mantissa values: 1 + frac/1024 for all 1024 possible frac values // Avoids division in hot path const MANTISSA = new Float64Array(1024) for (let i = 0; i < 1024; i++) { MANTISSA[i] = 1 + i / 1024 } declare global { interface Uint8Array { toBase64?(): string } interface Uint8ArrayConstructor { fromBase64?(base64: string): Uint8Array } } function nativeGetFloat16(dataView: DataView, offset: number): number { return (dataView as any).getFloat16(offset, true) } function fallbackGetFloat16(dataView: DataView, offset: number): number { return float16BitsToNumber(dataView.getUint16(offset, true)) } const getFloat16 = typeof (DataView.prototype as any).getFloat16 === 'function' ? nativeGetFloat16 : fallbackGetFloat16 function nativeSetFloat16(dataView: DataView, offset: number, value: number): void { ;(dataView as any).setFloat16(offset, value, true) } function fallbackSetFloat16(dataView: DataView, offset: number, value: number): void { dataView.setUint16(offset, numberToFloat16Bits(value), true) } const setFloat16 = typeof (DataView.prototype as any).setFloat16 === 'function' ? nativeSetFloat16 : fallbackSetFloat16 function nativeBase64ToUint8Array(base64: string): Uint8Array { return Uint8Array.fromBase64!(base64) } /** @internal */ export function fallbackBase64ToUint8Array(base64: string): Uint8Array { // Strip up to 2 '=' padding characters to determine the real byte count. // The 2D point layout (8 + 4(n-1) bytes) is not a multiple of 3, so encoded // paths can carry padding the original multiple-of-3-only decoder couldn't read. const paddedLength = base64.length let padding = 0 if (paddedLength > 0 && base64.charCodeAt(paddedLength - 1) === PADDING_CHAR_CODE) { padding++ if (paddedLength > 1 && base64.charCodeAt(paddedLength - 2) === PADDING_CHAR_CODE) { padding++ } } const numBytes = Math.floor((paddedLength * 3) / 4) - padding const bytes = new Uint8Array(numBytes) let byteIndex = 0 // The reverse of encoding: each 4 chars are 4 six-bit values that pack back into // one 24-bit number, which we then read out as 3 bytes (& 255 keeps one byte). const fullGroups = Math.floor((paddedLength - padding) / 4) * 4 for (let i = 0; i < fullGroups; i += 4) { const c0 = B64_LOOKUP[base64.charCodeAt(i)] const c1 = B64_LOOKUP[base64.charCodeAt(i + 1)] const c2 = B64_LOOKUP[base64.charCodeAt(i + 2)] const c3 = B64_LOOKUP[base64.charCodeAt(i + 3)] const bitmap = (c0 << 18) | (c1 << 12) | (c2 << 6) | c3 bytes[byteIndex++] = (bitmap >> 16) & 255 // top byte (bits 23–16) bytes[byteIndex++] = (bitmap >> 8) & 255 // middle byte (bits 15–8) bytes[byteIndex++] = bitmap & 255 // bottom byte (bits 7–0) } // Final group when padded: 3 valid chars -> 2 bytes, 2 valid chars -> 1 byte. if (padding === 1) { const c0 = B64_LOOKUP[base64.charCodeAt(fullGroups)] const c1 = B64_LOOKUP[base64.charCodeAt(fullGroups + 1)] const c2 = B64_LOOKUP[base64.charCodeAt(fullGroups + 2)] const bitmap = (c0 << 18) | (c1 << 12) | (c2 << 6) bytes[byteIndex++] = (bitmap >> 16) & 255 bytes[byteIndex++] = (bitmap >> 8) & 255 } else if (padding === 2) { const c0 = B64_LOOKUP[base64.charCodeAt(fullGroups)] const c1 = B64_LOOKUP[base64.charCodeAt(fullGroups + 1)] const bitmap = (c0 << 18) | (c1 << 12) bytes[byteIndex++] = (bitmap >> 16) & 255 } return bytes } function nativeUint8ArrayToBase64(uint8Array: Uint8Array): string { return uint8Array.toBase64!() } /** @internal */ export function fallbackUint8ArrayToBase64(uint8Array: Uint8Array): string { const len = uint8Array.length const fullGroups = Math.floor(len / 3) * 3 let result = '' // base64 represents 3 bytes (24 bits) as 4 characters of 6 bits each. For each // group of 3 bytes we pack them into one 24-bit number, then read it back out as // four 6-bit slices and use each slice (a value 0–63) to index into the 64-char // alphabet. `>> n` shifts the wanted slice down to the bottom; SIX_BIT_MASK then // discards everything above those 6 bits. for (let i = 0; i < fullGroups; i += 3) { const byte1 = uint8Array[i] const byte2 = uint8Array[i + 1] const byte3 = uint8Array[i + 2] const bitmap = (byte1 << 16) | (byte2 << 8) | byte3 result += BASE64_CHARS[(bitmap >> 18) & SIX_BIT_MASK] + // bits 23–18 (top sextet) BASE64_CHARS[(bitmap >> 12) & SIX_BIT_MASK] + // bits 17–12 BASE64_CHARS[(bitmap >> 6) & SIX_BIT_MASK] + // bits 11–6 BASE64_CHARS[bitmap & SIX_BIT_MASK] // bits 5–0 (bottom sextet) } // A trailing 1 or 2 bytes can't fill a whole 4-char group, so we emit only the // chars their bits cover and pad the rest with '=' to keep the length a multiple // of 4. Standard base64 — matches the native API and Node's Buffer, so a path // encoded by the fallback round-trips on a runtime that decodes with the native one. const remaining = len - fullGroups if (remaining === 1) { // 8 bits → 2 sextets (the 2nd only partly filled), then "==" const bitmap = uint8Array[fullGroups] << 16 result += BASE64_CHARS[(bitmap >> 18) & SIX_BIT_MASK] + BASE64_CHARS[(bitmap >> 12) & SIX_BIT_MASK] + '==' } else if (remaining === 2) { // 16 bits → 3 sextets (the 3rd only partly filled), then "=" const bitmap = (uint8Array[fullGroups] << 16) | (uint8Array[fullGroups + 1] << 8) result += BASE64_CHARS[(bitmap >> 18) & SIX_BIT_MASK] + BASE64_CHARS[(bitmap >> 12) & SIX_BIT_MASK] + BASE64_CHARS[(bitmap >> 6) & SIX_BIT_MASK] + '=' } return result } /** * Convert a Uint8Array to base64. * Processes bytes in groups of 3 to produce 4 base64 characters. * * @internal */ const uint8ArrayToBase64 = typeof Uint8Array.prototype.toBase64 === 'function' ? nativeUint8ArrayToBase64 : fallbackUint8ArrayToBase64 /** * Convert a base64 string to Uint8Array. * * @internal */ const base64ToUint8Array = typeof Uint8Array.fromBase64 === 'function' ? nativeBase64ToUint8Array : fallbackBase64ToUint8Array /** * Convert Float16 bits to a number using optimized lookup tables. * Handles normal numbers, subnormal numbers, zero, infinity, and NaN. * * @param bits - The 16-bit Float16 value to decode * @returns The decoded number value * @internal */ export function float16BitsToNumber(bits: number): number { const sign = bits >> 15 const exp = (bits >> 10) & 0x1f const frac = bits & 0x3ff if (exp === 0) { // Subnormal or zero - rare case return sign ? -frac * POW2_SUBNORMAL : frac * POW2_SUBNORMAL } if (exp === 31) { // Infinity or NaN - very rare return frac ? NaN : sign ? -Infinity : Infinity } // Normal case - two table lookups, one multiply, no division const magnitude = POW2[exp] * MANTISSA[frac] return sign ? -magnitude : magnitude } /** * Convert a number to Float16 bits. * Handles normal numbers, subnormal numbers, zero, infinity, and NaN. * * @param value - The number to encode as Float16 * @returns The 16-bit Float16 representation of the number * @internal */ export function numberToFloat16Bits(value: number): number { if (value === 0) return Object.is(value, -0) ? 0x8000 : 0 if (!Number.isFinite(value)) { if (Number.isNaN(value)) return 0x7e00 return value > 0 ? 0x7c00 : 0xfc00 } const sign = value < 0 ? 1 : 0 value = Math.abs(value) // Find exponent and mantissa const exp = Math.floor(Math.log2(value)) let expBiased = exp + 15 if (expBiased >= 31) { // Overflow to infinity return (sign << 15) | 0x7c00 } if (expBiased <= 0) { // Subnormal or underflow const frac = Math.round(value * Math.pow(2, 14) * 1024) return (sign << 15) | (frac & 0x3ff) } // Normal number const mantissa = value / Math.pow(2, exp) - 1 let frac = Math.round(mantissa * 1024) // Handle rounding overflow: if frac rounds to 1024, increment exponent if (frac >= 1024) { frac = 0 expBiased++ if (expBiased >= 31) { // Overflow to infinity return (sign << 15) | 0x7c00 } } return (sign << 15) | (expBiased << 10) | frac } /** * Utilities for encoding and decoding points using base64 and Float16 encoding. * Provides functions for converting between VecModel arrays and compact base64 strings, * as well as individual point encoding/decoding operations. * * @public */ export class b64Vecs { /** * Encode a single point (x, y, z) to 8 base64 characters using legacy Float16 encoding. * Each coordinate is encoded as a Float16 value, resulting in 6 bytes total. * * @param x - The x coordinate * @param y - The y coordinate * @param z - The z coordinate * @returns An 8-character base64 string representing the point * @internal */ static _legacyEncodePoint(x: number, y: number, z: number): string { const buffer = new Uint8Array(6) const dataView = new DataView(buffer.buffer) setFloat16(dataView, 0, x) setFloat16(dataView, 2, y) setFloat16(dataView, 4, z) return uint8ArrayToBase64(buffer) } /** * Convert an array of VecModels to a base64 string using legacy Float16 encoding. * Uses Float16 encoding for each coordinate (x, y, z). If a point's z value is * undefined, it defaults to 0.5. * * @param points - An array of VecModel objects to encode * @returns A base64-encoded string containing all points * @internal Used only for migrations from legacy format */ static _legacyEncodePoints(points: VecModel[]): string { if (points.length === 0) return '' // 3 Float16s per point = 6 bytes per point const buffer = new Uint8Array(points.length * 6) const dataView = new DataView(buffer.buffer) for (let i = 0; i < points.length; i++) { const p = points[i] const offset = i * 6 setFloat16(dataView, offset, p.x) setFloat16(dataView, offset + 2, p.y) setFloat16(dataView, offset + 4, p.z ?? 0.5) } return uint8ArrayToBase64(buffer) } /** * Convert a legacy base64 string back to an array of VecModels. * Decodes Float16-encoded coordinates (x, y, z) from the base64 string. * * @param base64 - The base64-encoded string containing point data * @returns An array of VecModel objects decoded from the string * @internal Used only for migrations from legacy format */ static _legacyDecodePoints(base64: string): VecModel[] { const bytes = base64ToUint8Array(base64) const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) const result: VecModel[] = [] for (let offset = 0; offset < bytes.length; offset += 6) { result.push({ x: getFloat16(dataView, offset), y: getFloat16(dataView, offset + 2), z: getFloat16(dataView, offset + 4), }) } return result } /** * Encode an array of VecModels using delta encoding for improved precision. * The first point is stored as Float32 (high precision for absolute position), * subsequent points are stored as Float16 deltas from the previous point. * This provides full precision for the starting position and excellent precision * for deltas between consecutive points (which are typically small values). * * Format: * - First point: 3 Float32 values = 12 bytes = 16 base64 chars * - Delta points: 3 Float16 values each = 6 bytes = 8 base64 chars each * * @param points - An array of VecModel objects to encode * @param dim - Encoding dimension; `2` routes through the 2D variant (drops z), `3` (default) keeps x, y, z * @returns A base64-encoded string containing delta-encoded points * @public */ static encodePoints(points: VecModel[], dim?: 2 | 3): string { if (dim === DIM_2D) return b64Vecs.encodePoints2D(points) if (points.length === 0) return '' // First point: 3 Float32s = 12 bytes // Remaining points: 3 Float16s each = 6 bytes each const firstPointBytes = 12 const deltaBytes = (points.length - 1) * 6 const totalBytes = firstPointBytes + deltaBytes const buffer = new Uint8Array(totalBytes) const dataView = new DataView(buffer.buffer) // First point is stored as Float32 for full precision const first = points[0] dataView.setFloat32(0, first.x, true) // little-endian dataView.setFloat32(4, first.y, true) dataView.setFloat32(8, first.z ?? 0.5, true) // Subsequent points are Float16 deltas from the previous point let prevX = first.x let prevY = first.y let prevZ = first.z ?? 0.5 for (let i = 1; i < points.length; i++) { const p = points[i] const z = p.z ?? 0.5 const offset = firstPointBytes + (i - 1) * 6 setFloat16(dataView, offset, p.x - prevX) setFloat16(dataView, offset + 2, p.y - prevY) setFloat16(dataView, offset + 4, z - prevZ) prevX = p.x prevY = p.y prevZ = z } return uint8ArrayToBase64(buffer) } /** * Decode a delta-encoded base64 string back to an array of absolute VecModels. * The first point is stored as Float32 (high precision), subsequent points are * Float16 deltas that are accumulated to reconstruct absolute positions. * * @param base64 - The base64-encoded string containing delta-encoded point data * @param dim - Encoding dimension; `2` expects x/y only (z supplied as 0.5), `3` (default) expects x/y/z * @returns An array of VecModel objects with absolute coordinates * @public */ static decodePoints(base64: string, dim?: 2 | 3): VecModel[] { if (dim === DIM_2D) return b64Vecs.decodePoints2D(base64) if (base64.length === 0) return [] const bytes = base64ToUint8Array(base64) const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) const result: VecModel[] = [] // First point is Float32 (12 bytes) let x = dataView.getFloat32(0, true) let y = dataView.getFloat32(4, true) let z = dataView.getFloat32(8, true) result.push({ x, y, z }) // Subsequent points are Float16 deltas - accumulate to get absolute positions const firstPointBytes = 12 for (let offset = firstPointBytes; offset < bytes.length; offset += 6) { x += getFloat16(dataView, offset) y += getFloat16(dataView, offset + 2) z += getFloat16(dataView, offset + 4) result.push({ x, y, z }) } return result } /** * Get the first point from a delta-encoded base64 string. * The first point is stored as Float32 for full precision. * * @param b64Points - The delta-encoded base64 string * @param dim - Encoding dimension; `2` expects x/y only (z supplied as 0.5), `3` (default) expects x/y/z * @returns The first point as a VecModel, or null if the string is too short * @public */ static decodeFirstPoint(b64Points: string, dim?: 2 | 3): VecModel | null { if (dim === DIM_2D) return b64Vecs.decodeFirstPoint2D(b64Points) // First point needs 16 base64 chars (12 bytes as Float32) if (b64Points.length < FIRST_POINT_B64_LENGTH) return null const bytes = base64ToUint8Array(b64Points.slice(0, FIRST_POINT_B64_LENGTH)) const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) return { x: dataView.getFloat32(0, true), y: dataView.getFloat32(4, true), z: dataView.getFloat32(8, true), } } /** * Get the last point from a delta-encoded base64 string. * Requires decoding all points to accumulate deltas. * * @param b64Points - The delta-encoded base64 string * @param dim - Encoding dimension; `2` expects x/y only (z supplied as 0.5), `3` (default) expects x/y/z * @returns The last point as a VecModel, or null if the string is too short * @public */ static decodeLastPoint(b64Points: string, dim?: 2 | 3): VecModel | null { if (dim === DIM_2D) return b64Vecs.decodeLastPoint2D(b64Points) if (b64Points.length < FIRST_POINT_B64_LENGTH) return null const bytes = base64ToUint8Array(b64Points) const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) // Start with first point (Float32) let x = dataView.getFloat32(0, true) let y = dataView.getFloat32(4, true) let z = dataView.getFloat32(8, true) // Accumulate all Float16 deltas to get the last point const firstPointBytes = 12 for (let offset = firstPointBytes; offset < bytes.length; offset += 6) { x += getFloat16(dataView, offset) y += getFloat16(dataView, offset + 2) z += getFloat16(dataView, offset + 4) } return { x, y, z } } /** * Encode an array of VecModels as 2D delta-encoded points, dropping z entirely. * Use for draw shapes from devices that don't report pressure, where z is a * constant 0.5 and storing it wastes ~33% of per-point bytes. * * Format: * - First point: 2 Float32 values (x, y) = 8 bytes * - Delta points: 2 Float16 values (dx, dy) = 4 bytes each * * @param points - An array of VecModel objects to encode (z is discarded) * @returns A base64-encoded string containing 2D delta-encoded points * @public */ static encodePoints2D(points: VecModel[]): string { if (points.length === 0) return '' const firstPointBytes = 8 const deltaBytes = (points.length - 1) * 4 const buffer = new Uint8Array(firstPointBytes + deltaBytes) const dataView = new DataView(buffer.buffer) const first = points[0] dataView.setFloat32(0, first.x, true) dataView.setFloat32(4, first.y, true) let prevX = first.x let prevY = first.y for (let i = 1; i < points.length; i++) { const p = points[i] const offset = firstPointBytes + (i - 1) * 4 setFloat16(dataView, offset, p.x - prevX) setFloat16(dataView, offset + 2, p.y - prevY) prevX = p.x prevY = p.y } return uint8ArrayToBase64(buffer) } /** * Decode a 2D delta-encoded base64 string back to an array of absolute VecModels. * The z coordinate is always set to 0.5 (the default pressure value) so downstream * consumers don't need a separate code path. * * @param base64 - The base64-encoded string containing 2D delta-encoded point data * @returns An array of VecModel objects with absolute (x, y) and z = 0.5 * @public */ static decodePoints2D(base64: string): VecModel[] { if (base64.length === 0) return [] const bytes = base64ToUint8Array(base64) const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) const result: VecModel[] = [] let x = dataView.getFloat32(0, true) let y = dataView.getFloat32(4, true) result.push({ x, y, z: DEFAULT_PRESSURE }) const firstPointBytes = 8 for (let offset = firstPointBytes; offset < bytes.length; offset += 4) { x += getFloat16(dataView, offset) y += getFloat16(dataView, offset + 2) result.push({ x, y, z: DEFAULT_PRESSURE }) } return result } /** * Get the first point from a 2D delta-encoded base64 string. * * @param b64Points - The 2D delta-encoded base64 string * @returns The first point with z = 0.5, or null if the string is too short * @public */ static decodeFirstPoint2D(b64Points: string): VecModel | null { if (b64Points.length < FIRST_POINT_2D_B64_LENGTH) return null const bytes = base64ToUint8Array(b64Points.slice(0, FIRST_POINT_2D_B64_LENGTH)) const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) return { x: dataView.getFloat32(0, true), y: dataView.getFloat32(4, true), z: DEFAULT_PRESSURE, } } /** * Get the last point from a 2D delta-encoded base64 string. * Requires decoding all points to accumulate deltas. * * @param b64Points - The 2D delta-encoded base64 string * @returns The last point with z = 0.5, or null if the string is too short * @public */ static decodeLastPoint2D(b64Points: string): VecModel | null { if (b64Points.length < FIRST_POINT_2D_B64_LENGTH) return null const bytes = base64ToUint8Array(b64Points) const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) let x = dataView.getFloat32(0, true) let y = dataView.getFloat32(4, true) const firstPointBytes = 8 for (let offset = firstPointBytes; offset < bytes.length; offset += 4) { x += getFloat16(dataView, offset) y += getFloat16(dataView, offset + 2) } return { x, y, z: DEFAULT_PRESSURE } } /** * Whether an encoded path contains only a single point (a "dot"), inferred from * the encoded length without decoding — cheap enough for the render path. * * The single-point length depends on the encoding dimension, so this takes the * segment's `dim`: a one-point path is `FIRST_POINT_B64_LENGTH` chars (3D) or * `FIRST_POINT_2D_B64_LENGTH` chars (2D). Keeping this beside the layout constants * is deliberate — it is the single source of truth for "how long is one point", so * callers never hard-code a length threshold (which silently breaks when a new * encoding is added). * * @param b64Points - The encoded path string * @param dim - Encoding dimension; `2` for (x, y), `3` (default) for (x, y, z) * @returns true if the path encodes exactly one point * @public */ static isSinglePoint(b64Points: string, dim?: 2 | 3): boolean { return b64Points.length <= (dim === DIM_2D ? FIRST_POINT_2D_B64_LENGTH : FIRST_POINT_B64_LENGTH) } }