import { approximately, assert, assertExists, clamp, CubicBezier2d, Edge2d, exhaustiveSwitchError, Geometry2d, Geometry2dFilters, Geometry2dOptions, getPerfectDashProps, getVerticesCountForArcLength, Group2d, modulate, PerfectDashTerminal, rng, toDomPrecision, Vec, VecLike, VecModel, } from '@tldraw/editor' import { ReactNode, SVGProps } from 'react' /** @public */ export interface BasePathBuilderOpts { strokeWidth: number forceSolid?: boolean onlyFilled?: boolean props?: SVGProps } /** @public */ export interface SolidPathBuilderOpts extends BasePathBuilderOpts { style: 'solid' } /** @public */ export interface DashedPathBuilderOpts extends BasePathBuilderOpts { style: 'dashed' | 'dotted' snap?: number end?: PerfectDashTerminal start?: PerfectDashTerminal lengthRatio?: number } /** @public */ export interface DrawPathBuilderDOpts { strokeWidth: number randomSeed: string offset?: number roundness?: number passes?: number onlyFilled?: boolean } /** @public */ export interface DrawPathBuilderOpts extends BasePathBuilderOpts, DrawPathBuilderDOpts { style: 'draw' } /** @public */ export interface NonePathBuilderOpts extends BasePathBuilderOpts { style: 'none' } /** @public */ export type PathBuilderOpts = | SolidPathBuilderOpts | DashedPathBuilderOpts | DrawPathBuilderOpts | NonePathBuilderOpts /** @public */ export interface PathBuilderCommandOpts { /** * When converting to a draw-style line, how much offset from the original point should be * applied? */ offset?: number /** * When converting to a draw-style line, how much roundness should be applied to the end of this * line? */ roundness?: number /** * When converting to a dash- or dot-style line, should the current segment be merged with the * previous segment when calculating the dash pattern? This is false by default, meaning each * command will start/end on a dash/dot boundary. */ mergeWithPrevious?: boolean } /** @internal */ export interface PathBuilderCommandInfo { tangentStart: VecModel tangentEnd: VecModel length: number } /** @internal */ export interface PathBuilderCommandBase { opts?: PathBuilderCommandOpts x: number y: number isClose: boolean _info?: PathBuilderCommandInfo } /** @public */ export interface PathBuilderLineOpts extends PathBuilderCommandOpts { geometry?: Omit | false dashStart?: PerfectDashTerminal dashEnd?: PerfectDashTerminal } /** @internal */ export interface MoveToPathBuilderCommand extends PathBuilderCommandBase { type: 'move' closeIdx: number | null opts?: PathBuilderLineOpts } /** @internal */ export interface LineToPathBuilderCommand extends PathBuilderCommandBase { type: 'line' } /** @internal */ export interface CubicBezierToPathBuilderCommand extends PathBuilderCommandBase { type: 'cubic' cp1: VecModel cp2: VecModel resolution?: number } /** @internal */ export type PathBuilderCommand = | MoveToPathBuilderCommand | LineToPathBuilderCommand | CubicBezierToPathBuilderCommand /** @public */ export interface PathBuilderToDOpts { startIdx?: number endIdx?: number onlyFilled?: boolean } /** @public */ export class PathBuilder { static lineThroughPoints( points: VecLike[], opts?: PathBuilderLineOpts & { endOffsets?: number } ) { const path = new PathBuilder() path.moveTo(points[0].x, points[0].y, { ...opts, offset: opts?.endOffsets ?? opts?.offset }) for (let i = 1; i < points.length; i++) { const isLast = i === points.length - 1 path.lineTo(points[i].x, points[i].y, isLast ? { offset: opts?.endOffsets } : undefined) } return path } static cubicSplineThroughPoints( points: VecLike[], opts?: PathBuilderLineOpts & { endOffsets?: number } ) { const path = new PathBuilder() const len = points.length const last = len - 2 const k = 1.25 path.moveTo(points[0].x, points[0].y, { ...opts, offset: opts?.endOffsets ?? opts?.offset }) for (let i = 0; i < len - 1; i++) { const p0 = i === 0 ? points[0] : points[i - 1] const p1 = points[i] const p2 = points[i + 1] const p3 = i === last ? p2 : points[i + 2] let cp1x, cp1y, cp2x, cp2y if (i === 0) { cp1x = p0.x cp1y = p0.y } else { cp1x = p1.x + ((p2.x - p0.x) / 6) * k cp1y = p1.y + ((p2.y - p0.y) / 6) * k } let pointOpts = undefined if (i === last) { cp2x = p2.x cp2y = p2.y pointOpts = { offset: opts?.endOffsets } } else { cp2x = p2.x - ((p3.x - p1.x) / 6) * k cp2y = p2.y - ((p3.y - p1.y) / 6) * k } path.cubicBezierTo(p2.x, p2.y, cp1x, cp1y, cp2x, cp2y, pointOpts) } return path } constructor() {} /** @internal */ commands: PathBuilderCommand[] = [] private lastMoveTo: MoveToPathBuilderCommand | null = null private assertHasMoveTo() { assert(this.lastMoveTo, 'Start an SVGPathBuilder with `.moveTo()`') return this.lastMoveTo } moveTo(x: number, y: number, opts?: PathBuilderLineOpts) { this.lastMoveTo = { type: 'move', x, y, closeIdx: null, isClose: false, opts } this.commands.push(this.lastMoveTo) return this } lineTo(x: number, y: number, opts?: PathBuilderCommandOpts) { this.assertHasMoveTo() this.commands.push({ type: 'line', x, y, isClose: false, opts }) return this } circularArcTo( radius: number, largeArcFlag: boolean, sweepFlag: boolean, x2: number, y2: number, opts?: PathBuilderCommandOpts ) { return this.arcTo(radius, radius, largeArcFlag, sweepFlag, 0, x2, y2, opts) } arcTo( rx: number, ry: number, largeArcFlag: boolean, sweepFlag: boolean, xAxisRotationRadians: number, x2: number, y2: number, opts?: PathBuilderCommandOpts ) { // As arc flags make them very sensitive to offsets when we render them in draw mode, we // approximate arcs by converting them to up to 4 (1 per 90° segment) cubic bezier curves. // This algorithm is a Claude special: // https://claude.ai/public/artifacts/5ea0bf18-4afb-4b3d-948d-31b8a77ef1e2 this.assertHasMoveTo() const x1 = this.commands[this.commands.length - 1].x const y1 = this.commands[this.commands.length - 1].y // If the endpoints are identical, don't add a command if (x1 === x2 && y1 === y2) { return this } // If rx or ry is 0, return a straight line if (rx === 0 || ry === 0) { return this.lineTo(x2, y2, opts) } // Convert angle from degrees to radians const phi = xAxisRotationRadians const sinPhi = Math.sin(phi) const cosPhi = Math.cos(phi) // Ensure rx and ry are positive let rx1 = Math.abs(rx) let ry1 = Math.abs(ry) // Step 1: Compute (x1', y1') - transform from ellipse coordinate system to unit circle const dx = (x1 - x2) / 2 const dy = (y1 - y2) / 2 const x1p = cosPhi * dx + sinPhi * dy const y1p = -sinPhi * dx + cosPhi * dy // Correction of out-of-range radii const lambda = (x1p * x1p) / (rx1 * rx1) + (y1p * y1p) / (ry1 * ry1) if (lambda > 1) { const sqrtLambda = Math.sqrt(lambda) rx1 *= sqrtLambda ry1 *= sqrtLambda } // Step 2: Compute (cx', cy') - center of ellipse in transformed system const sign = largeArcFlag !== sweepFlag ? 1 : -1 const term = rx1 * rx1 * ry1 * ry1 - rx1 * rx1 * y1p * y1p - ry1 * ry1 * x1p * x1p const numerator = rx1 * rx1 * y1p * y1p + ry1 * ry1 * x1p * x1p let radicand = term / numerator radicand = radicand < 0 ? 0 : radicand const coef = sign * Math.sqrt(radicand) const cxp = coef * ((rx1 * y1p) / ry1) const cyp = coef * (-(ry1 * x1p) / rx1) // Step 3: Compute (cx, cy) from (cx', cy') - transform back to original coordinate system const cx = cosPhi * cxp - sinPhi * cyp + (x1 + x2) / 2 const cy = sinPhi * cxp + cosPhi * cyp + (y1 + y2) / 2 // Step 4: Compute the start and end angles const ux = (x1p - cxp) / rx1 const uy = (y1p - cyp) / ry1 const vx = (-x1p - cxp) / rx1 const vy = (-y1p - cyp) / ry1 const startAngle = Math.atan2(uy, ux) let endAngle = Math.atan2(vy, vx) // Ensure correct sweep direction if (!sweepFlag && endAngle > startAngle) { endAngle -= 2 * Math.PI } else if (sweepFlag && endAngle < startAngle) { endAngle += 2 * Math.PI } // Calculate the sweep angle const sweepAngle = endAngle - startAngle // Calculate the approximate arc length. General ellipse arc length is expensive - there's // no closed form solution, so we have to do iterative numerical approximation. As we only // use this to control the resolution of later approximations, let's cheat and just use the // circular arc length with the largest radius: const approximateArcLength = Math.max(rx1, ry1) * Math.abs(sweepAngle) // Approximate the arc using cubic bezier curves const numSegments = Math.min(4, Math.ceil(Math.abs(sweepAngle) / (Math.PI / 2))) const resolutionPerSegment = Math.ceil( getVerticesCountForArcLength(approximateArcLength) / numSegments ) const anglePerSegment = sweepAngle / numSegments // Helper function to compute point on ellipse const ellipsePoint = (angle: number) => { return { x: cx + rx1 * Math.cos(angle) * cosPhi - ry1 * Math.sin(angle) * sinPhi, y: cy + rx1 * Math.cos(angle) * sinPhi + ry1 * Math.sin(angle) * cosPhi, } } // Helper function to compute derivative (tangent vector) at a point on the ellipse const ellipseDerivative = (angle: number) => { return { x: -rx1 * Math.sin(angle) * cosPhi - ry1 * Math.cos(angle) * sinPhi, y: -rx1 * Math.sin(angle) * sinPhi + ry1 * Math.cos(angle) * cosPhi, } } // Generate cubic bezier approximations for (let i = 0; i < numSegments; i++) { const theta1 = startAngle + i * anglePerSegment const theta2 = startAngle + (i + 1) * anglePerSegment const deltaTheta = theta2 - theta1 const start = ellipsePoint(theta1) const end = ellipsePoint(theta2) // Get the derivative at the start and end points const d1 = ellipseDerivative(theta1) const d2 = ellipseDerivative(theta2) // Calculate the length of the tangent handles // This is a key factor for the accuracy of the approximation // For a 90° arc, the handle length should be 4/3 * tan(π/8) * r // For smaller arcs, we scale this value by the angle ratio const handleScale = (4 / 3) * Math.tan(deltaTheta / 4) // Create control points that are tangent to the ellipse at the endpoints const cp1x = start.x + handleScale * d1.x const cp1y = start.y + handleScale * d1.y const cp2x = end.x - handleScale * d2.x const cp2y = end.y - handleScale * d2.y const bezierOpts = i === 0 ? opts : { ...opts, mergeWithPrevious: true } this.cubicBezierToWithResolution( end.x, end.y, cp1x, cp1y, cp2x, cp2y, bezierOpts, resolutionPerSegment ) } return this } cubicBezierTo( x: number, y: number, cp1X: number, cp1Y: number, cp2X: number, cp2Y: number, opts?: PathBuilderCommandOpts ) { return this.cubicBezierToWithResolution(x, y, cp1X, cp1Y, cp2X, cp2Y, opts) } private cubicBezierToWithResolution( x: number, y: number, cp1X: number, cp1Y: number, cp2X: number, cp2Y: number, opts?: PathBuilderCommandOpts, resolution?: number ) { this.assertHasMoveTo() this.commands.push({ type: 'cubic', x, y, cp1: { x: cp1X, y: cp1Y }, cp2: { x: cp2X, y: cp2Y }, isClose: false, opts, resolution, }) return this } close() { const lastMoveTo = this.assertHasMoveTo() const lastCommand = this.commands[this.commands.length - 1] if (approximately(lastMoveTo.x, lastCommand.x) && approximately(lastMoveTo.y, lastCommand.y)) { lastCommand.isClose = true } else { this.commands.push({ type: 'line', x: lastMoveTo.x, y: lastMoveTo.y, isClose: true, }) } lastMoveTo.closeIdx = this.commands.length - 1 this.lastMoveTo = null return this } toD(opts: PathBuilderToDOpts = {}) { const { startIdx = 0, endIdx = this.commands.length, onlyFilled = false } = opts const parts = [] let isSkippingCurrentLine = false let didAddMove = false let didAddNaturalMove = false const addMoveIfNeeded = (i: number) => { if (didAddMove || i === 0) return didAddMove = true const command = this.commands[i - 1] parts.push('M', toDomPrecision(command.x), toDomPrecision(command.y)) } for (let i = startIdx; i < endIdx; i++) { const command = this.commands[i] switch (command.type) { case 'move': { const isFilled = command.opts?.geometry === false ? false : (command.opts?.geometry?.isFilled ?? false) if (onlyFilled && !isFilled) { isSkippingCurrentLine = true } else { isSkippingCurrentLine = false didAddMove = true didAddNaturalMove = true parts.push('M', toDomPrecision(command.x), toDomPrecision(command.y)) } break } case 'line': if (isSkippingCurrentLine) break addMoveIfNeeded(i) if (command.isClose && didAddNaturalMove) { parts.push('Z') } else { parts.push('L', toDomPrecision(command.x), toDomPrecision(command.y)) } break case 'cubic': if (isSkippingCurrentLine) break addMoveIfNeeded(i) parts.push( 'C', toDomPrecision(command.cp1.x), toDomPrecision(command.cp1.y), toDomPrecision(command.cp2.x), toDomPrecision(command.cp2.y), toDomPrecision(command.x), toDomPrecision(command.y) ) break default: exhaustiveSwitchError(command, 'type') } } return parts.join(' ') } toSvg(opts: PathBuilderOpts) { if (opts.style === 'none') { return null } if (opts.forceSolid) { return this.toSolidSvg(opts) } switch (opts.style) { case 'solid': return this.toSolidSvg(opts) case 'dashed': case 'dotted': return this.toDashedSvg(opts) case 'draw': { const d = this.toDrawSvg(opts) return d } default: exhaustiveSwitchError(opts, 'style') } } toPath2D(opts: PathBuilderOpts): Path2D { if (opts.style === 'none') { return new Path2D() } if (opts.forceSolid || opts.style === 'solid') { return new Path2D(this.toD({ onlyFilled: opts.onlyFilled })) } if (opts.style === 'draw') { return new Path2D(this.toDrawD(opts)) } return new Path2D(this.toD({ onlyFilled: opts.onlyFilled })) } toGeometry(): PathBuilderGeometry2d | Group2d { const geometries = [] let current: null | { startIdx: number moveCommand: MoveToPathBuilderCommand isClosed: boolean opts?: PathBuilderLineOpts } = null for (let i = 0; i < this.commands.length; i++) { const command = this.commands[i] if (command.type === 'move') { if (current && current.opts?.geometry !== false) { geometries.push( new PathBuilderGeometry2d(this, current.startIdx, i, { ...current.opts?.geometry, isFilled: current.opts?.geometry?.isFilled ?? false, isClosed: current.moveCommand.closeIdx !== null, }) ) } current = { startIdx: i, moveCommand: command, opts: command.opts, isClosed: false } } if (command.isClose) { assert(current, 'No current move command') current.isClosed = true } } if (current && current.opts?.geometry !== false) { geometries.push( new PathBuilderGeometry2d(this, current.startIdx, this.commands.length, { ...current.opts?.geometry, isFilled: current.opts?.geometry?.isFilled ?? false, isClosed: current.moveCommand.closeIdx !== null, }) ) } assert(geometries.length > 0) if (geometries.length === 1) return geometries[0] return new Group2d({ children: geometries }) } private toSolidSvg(opts: PathBuilderOpts) { const { strokeWidth, props } = opts return ( ) } private toDashedSvg(opts: DashedPathBuilderOpts) { const { style, strokeWidth, snap, lengthRatio, props: { markerStart, markerEnd, ...props } = {}, } = opts const parts: ReactNode[] = [] let isCurrentPathClosed = false let isSkippingCurrentLine = false let currentLineOpts: PathBuilderLineOpts | undefined = undefined let currentRun: { startIdx: number endIdx: number isFirst: boolean isLast: boolean length: number lineOpts: PathBuilderLineOpts | undefined pathIsClosed: boolean } | null = null const addCurrentRun = () => { if (!currentRun) return const { startIdx, endIdx, isFirst, isLast, length, lineOpts, pathIsClosed } = currentRun currentRun = null if (startIdx === endIdx && this.commands[startIdx].type === 'move') return const start = lineOpts?.dashStart ?? opts.start const end = lineOpts?.dashEnd ?? opts.end const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(length, strokeWidth, { style, snap, lengthRatio, start: isFirst ? (start ?? (pathIsClosed ? 'outset' : 'none')) : 'outset', end: isLast ? (end ?? (pathIsClosed ? 'outset' : 'none')) : 'outset', }) const d = this.toD({ startIdx, endIdx: endIdx + 1 }) parts.push( ) } for (let i = 0; i < this.commands.length; i++) { const command = this.commands[i] const lastCommand = this.commands[i - 1] if (command.type === 'move') { isCurrentPathClosed = command.closeIdx !== null const isFilled = command.opts?.geometry === false ? false : (command.opts?.geometry?.isFilled ?? false) if (opts.onlyFilled && !isFilled) { isSkippingCurrentLine = true } else { isSkippingCurrentLine = false currentLineOpts = command.opts } continue } if (isSkippingCurrentLine) continue const segmentLength = this.calculateSegmentLength(lastCommand, command) const isFirst = lastCommand.type === 'move' const isLast = command.isClose || i === this.commands.length - 1 || this.commands[i + 1]?.type === 'move' if (currentRun && command.opts?.mergeWithPrevious) { currentRun.length += segmentLength currentRun.endIdx = i currentRun.isLast = isLast } else { addCurrentRun() currentRun = { startIdx: i, endIdx: i, isFirst, isLast, length: segmentLength, lineOpts: currentLineOpts, pathIsClosed: isCurrentPathClosed, } } } addCurrentRun() return ( {parts} ) } private toDrawSvg(opts: DrawPathBuilderOpts) { return } toDrawD(opts: DrawPathBuilderDOpts) { const { strokeWidth, randomSeed, offset: defaultOffset = strokeWidth / 3, roundness: defaultRoundness = strokeWidth * 2, passes = 2, onlyFilled = false, } = opts const parts = [] const commandInfo = this.getCommandInfo() // for each command, we draw the line for the command, plus the corner to the next command. const drawCommands = [] let lastMoveCommandIdx = null for (let i = 0; i < this.commands.length; i++) { const command = this.commands[i] const offset = command.opts?.offset ?? defaultOffset const roundness = command.opts?.roundness ?? defaultRoundness if (command.type === 'move') { lastMoveCommandIdx = i } const nextIdx = command.isClose ? assertExists(lastMoveCommandIdx) + 1 : !this.commands[i + 1] || this.commands[i + 1].type === 'move' ? undefined : i + 1 const nextInfo = nextIdx !== undefined && this.commands[nextIdx] && this.commands[nextIdx]?.type !== 'move' ? commandInfo[nextIdx] : undefined const currentSupportsRoundness = commandsSupportingRoundness[command.type] const nextSupportsRoundness = nextIdx !== undefined ? commandsSupportingRoundness[this.commands[nextIdx].type] : false const currentInfo = commandInfo[i] const tangentToPrev = currentInfo?.tangentEnd const tangentToNext = nextInfo?.tangentStart const roundnessClampedForAngle = currentSupportsRoundness && nextSupportsRoundness && tangentToPrev && tangentToNext && Vec.Len2(tangentToPrev) > 0.01 && Vec.Len2(tangentToNext) > 0.01 ? modulate( Math.abs(Vec.AngleBetween(tangentToPrev, tangentToNext)), [Math.PI / 2, Math.PI], [roundness, 0], true ) : 0 const shortestDistance = Math.min( currentInfo?.length ?? Infinity, nextInfo?.length ?? Infinity ) const offsetLimit = shortestDistance - roundnessClampedForAngle * 2 const offsetAmount = clamp(offset, 0, offsetLimit / 4) const roundnessBeforeClampedForLength = Math.min( roundnessClampedForAngle, (currentInfo?.length ?? Infinity) / 4 ) const roundnessAfterClampedForLength = Math.min( roundnessClampedForAngle, (nextInfo?.length ?? Infinity) / 4 ) const drawCommand = { command, offsetAmount, roundnessBefore: roundnessBeforeClampedForLength, roundnessAfter: roundnessAfterClampedForLength, tangentToPrev: commandInfo[i]?.tangentEnd, tangentToNext: nextInfo?.tangentStart, moveDidClose: false, } drawCommands.push(drawCommand) if (command.isClose && lastMoveCommandIdx !== null) { const lastMoveCommand = drawCommands[lastMoveCommandIdx] lastMoveCommand.moveDidClose = true lastMoveCommand.roundnessAfter = roundnessAfterClampedForLength } else if (command.type === 'move') { lastMoveCommandIdx = i } } for (let pass = 0; pass < passes; pass++) { const random = rng(randomSeed + pass) let lastMoveToOffset = { x: 0, y: 0 } let isSkippingCurrentLine = false for (const { command, offsetAmount, roundnessBefore, roundnessAfter, tangentToNext, tangentToPrev, } of drawCommands) { let offset: VecLike if (command.isClose) { offset = lastMoveToOffset } else { const direction = new Vec(random(), random()).uni() const magnitude = Math.sqrt(Math.abs(random())) * offsetAmount offset = Vec.Mul(direction, magnitude) } if (command.type === 'move') { lastMoveToOffset = offset const isFilled = command.opts?.geometry === false ? false : (command.opts?.geometry?.isFilled ?? false) if (onlyFilled && !isFilled) { isSkippingCurrentLine = true } else { isSkippingCurrentLine = false } } if (isSkippingCurrentLine) continue const offsetPoint = Vec.Add(command, offset) const endPoint = tangentToNext && roundnessAfter > 0 ? Vec.Mul(tangentToNext, -roundnessAfter).add(offsetPoint) : offsetPoint const startPoint = tangentToPrev && roundnessBefore > 0 ? Vec.Mul(tangentToPrev, roundnessBefore).add(offsetPoint) : offsetPoint if (endPoint === offsetPoint || startPoint === offsetPoint) { switch (command.type) { case 'move': parts.push('M', toDomPrecision(endPoint.x), toDomPrecision(endPoint.y)) break case 'line': parts.push('L', toDomPrecision(endPoint.x), toDomPrecision(endPoint.y)) break case 'cubic': { const offsetCp1 = Vec.Add(command.cp1, offset) const offsetCp2 = Vec.Add(command.cp2, offset) parts.push( 'C', toDomPrecision(offsetCp1.x), toDomPrecision(offsetCp1.y), toDomPrecision(offsetCp2.x), toDomPrecision(offsetCp2.y), toDomPrecision(endPoint.x), toDomPrecision(endPoint.y) ) break } default: exhaustiveSwitchError(command, 'type') } } else { switch (command.type) { case 'move': parts.push('M', toDomPrecision(endPoint.x), toDomPrecision(endPoint.y)) break case 'line': parts.push( 'L', toDomPrecision(startPoint.x), toDomPrecision(startPoint.y), 'Q', toDomPrecision(offsetPoint.x), toDomPrecision(offsetPoint.y), toDomPrecision(endPoint.x), toDomPrecision(endPoint.y) ) break case 'cubic': { const offsetCp1 = Vec.Add(command.cp1, offset) const offsetCp2 = Vec.Add(command.cp2, offset) parts.push( 'C', toDomPrecision(offsetCp1.x), toDomPrecision(offsetCp1.y), toDomPrecision(offsetCp2.x), toDomPrecision(offsetCp2.y), toDomPrecision(offsetPoint.x), toDomPrecision(offsetPoint.y) ) break } default: exhaustiveSwitchError(command, 'type') } } } } return parts.join(' ') } private calculateSegmentLength(lastPoint: VecLike, command: PathBuilderCommand) { switch (command.type) { case 'move': return 0 case 'line': return Vec.Dist(lastPoint, command) case 'cubic': return CubicBezier.length( lastPoint.x, lastPoint.y, command.cp1.x, command.cp1.y, command.cp2.x, command.cp2.y, command.x, command.y ) default: exhaustiveSwitchError(command, 'type') } } /** @internal */ getCommands(): readonly PathBuilderCommand[] { return this.commands } /** @internal */ getCommandInfo() { const commandInfo: Array = [] for (let i = 1; i < this.commands.length; i++) { const previous = this.commands[i - 1] const current = this.commands[i] if (current._info) { commandInfo[i] = current._info continue } if (current.type === 'move') { continue } let tangentStart, tangentEnd switch (current.type) { case 'line': tangentStart = tangentEnd = Vec.Sub(previous, current).uni() break case 'cubic': { tangentStart = Vec.Sub(current.cp1, previous).uni() tangentEnd = Vec.Sub(current.cp2, current).uni() break } default: exhaustiveSwitchError(current, 'type') } current._info = { tangentStart, tangentEnd, length: this.calculateSegmentLength(previous, current), } commandInfo[i] = current._info } return commandInfo } } const commandsSupportingRoundness = { line: true, move: true, cubic: false, } as const satisfies Record /** @public */ export class PathBuilderGeometry2d extends Geometry2d { constructor( private readonly path: PathBuilder, private readonly startIdx: number, private readonly endIdx: number, options: Geometry2dOptions ) { super(options) } private _segments: Geometry2d[] | null = null getSegments() { if (this._segments) return this._segments this._segments = [] let last = this.path.commands[this.startIdx] assert(last.type === 'move') for (let i = this.startIdx + 1; i < this.endIdx; i++) { const command = this.path.commands[i] assert(command.type !== 'move') switch (command.type) { case 'line': this._segments.push(new Edge2d({ start: Vec.From(last), end: Vec.From(command) })) break case 'cubic': { this._segments.push( new CubicBezier2d({ start: Vec.From(last), cp1: Vec.From(command.cp1), cp2: Vec.From(command.cp2), end: Vec.From(command), resolution: command.resolution, }) ) break } default: exhaustiveSwitchError(command, 'type') } last = command } return this._segments } override getVertices(filters: Geometry2dFilters): Vec[] { const vs = this.getSegments() .flatMap((s) => s.getVertices(filters)) .filter((vertex, i, vertices) => { const prev = vertices[i - 1] if (!prev) return true return !Vec.Equals(prev, vertex) }) if (this.isClosed) { const last = vs[vs.length - 1] const first = vs[0] if (!Vec.Equals(last, first)) { vs.push(first) } } return vs } override nearestPoint(point: VecLike, _filters?: Geometry2dFilters): Vec { let nearest: Vec | null = null let nearestDistance = Infinity for (const segment of this.getSegments()) { const candidate = segment.nearestPoint(point) const distance = Vec.Dist2(point, candidate) if (distance < nearestDistance) { nearestDistance = distance nearest = candidate } } assert(nearest, 'No nearest point found') return nearest } override hitTestLineSegment( A: VecLike, B: VecLike, distance = 0, filters?: Geometry2dFilters ): boolean { return super.hitTestLineSegment(A, B, distance, filters) } override getSvgPathData(): string { return this.path.toD({ startIdx: this.startIdx, endIdx: this.endIdx }) } } /*! * Adapted from https://github.com/adobe-webplatform/Snap.svg/tree/master * Apache License: https://github.com/adobe-webplatform/Snap.svg/blob/master/LICENSE * https://github.com/adobe-webplatform/Snap.svg/blob/c8e483c9694517e24b282f8f59f985629f4994ce/dist/snap.svg.js#L5786 */ const CubicBezier = { base3(t: number, p1: number, p2: number, p3: number, p4: number) { const t1 = -3 * p1 + 9 * p2 - 9 * p3 + 3 * p4 const t2 = t * t1 + 6 * p1 - 12 * p2 + 6 * p3 return t * t2 - 3 * p1 + 3 * p2 }, /** * Calculate the approximate length of a cubic bezier curve from (x1, y1) to (x4, y4) with * control points (x2, y2) and (x3, y3). */ length( x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, x4: number, y4: number, z = 1 ) { z = z > 1 ? 1 : z < 0 ? 0 : z const z2 = z / 2 const n = 12 let sum = 0 sum = 0 for (let i = 0; i < n; i++) { const ct = z2 * CubicBezier.Tvalues[i] + z2 const xbase = CubicBezier.base3(ct, x1, x2, x3, x4) const ybase = CubicBezier.base3(ct, y1, y2, y3, y4) const comb = xbase * xbase + ybase * ybase sum += CubicBezier.Cvalues[i] * Math.sqrt(comb) } return z2 * sum }, Tvalues: [ -0.1252, 0.1252, -0.3678, 0.3678, -0.5873, 0.5873, -0.7699, 0.7699, -0.9041, 0.9041, -0.9816, 0.9816, ], Cvalues: [ 0.2491, 0.2491, 0.2335, 0.2335, 0.2032, 0.2032, 0.1601, 0.1601, 0.1069, 0.1069, 0.0472, 0.0472, ], }