UNPKG

tldraw

Version:

A tiny little drawing editor.

509 lines (508 loc) • 17 kB
import { jsx } from "react/jsx-runtime"; import { assert, clamp, exhaustiveSwitchError, getPerfectDashProps, Group2d, invLerp, lerp, Polygon2d, Polyline2d, rng, toDomPrecision, Vec } from "@tldraw/editor"; function getVerticesCountForLength(length, spacing = 20) { return Math.max(8, Math.ceil(length / spacing)); } class PathBuilder { static throughPoints(points, opts) { const path = new PathBuilder(); path.moveTo(points[0].x, points[0].y, opts); for (let i = 1; i < points.length; i++) { path.lineTo(points[i].x, points[i].y); } return path; } constructor() { } lines = []; currentLine() { const lastLine = this.lines[this.lines.length - 1]; assert(lastLine, "Start an SVGPathBuilder with `.moveTo()`"); assert(!lastLine.closed, "Cannot work on a closed line"); return lastLine; } moveTo(x, y, opts) { this.lines.push({ initial: { type: "moveTo", x, y, opts }, segments: [], closed: false }); return this; } lineTo(x, y, opts) { this.currentLine().segments.push({ type: "lineTo", x, y, opts }); return this; } arcTo(radius, largeArcFlag, sweepFlag, x, y, opts) { this.currentLine().segments.push({ type: "arcTo", radius, largeArcFlag, sweepFlag, x, y, opts }); return this; } close() { this.currentLine().closed = true; return this; } toD(opts = {}) { const closedOnly = opts.closedOnly ?? false; const parts = []; for (const { initial, segments, closed } of this.lines) { if (closedOnly && !closed) continue; parts.push("M", toDomPrecision(initial.x), toDomPrecision(initial.y)); for (const segment of segments) { switch (segment.type) { case "lineTo": parts.push("L", toDomPrecision(segment.x), toDomPrecision(segment.y)); break; case "arcTo": parts.push( "A", segment.radius, segment.radius, 0, segment.largeArcFlag ? "1" : "0", segment.sweepFlag ? "1" : "0", toDomPrecision(segment.x), toDomPrecision(segment.y) ); break; } } if (closed) { parts.push("Z"); } } return parts.join(" "); } toSvg(opts) { 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": return this.toDrawSvg(opts); default: exhaustiveSwitchError(opts, "style"); } } toGeometry() { const geometries = []; for (const { initial, segments, closed } of this.lines) { if (initial.opts?.geometry === false) continue; const vertices = [new Vec(initial.x, initial.y)]; for (const segment of segments) { switch (segment.type) { case "lineTo": { vertices.push(new Vec(segment.x, segment.y)); break; } case "arcTo": { const info = getArcSegmentInfo(vertices[vertices.length - 1], segment); if (info === null) break; if (info === "straight-line") { vertices.push(new Vec(segment.x, segment.y)); break; } const verticesCount = getVerticesCountForLength(info.length); for (let i = 0; i < verticesCount + 1; i++) { const t = i / verticesCount * info.sweepAngle; const point = Vec.Rot(info.startVector, t).mul(info.radius).add(info.center); vertices.push(point); } break; } default: exhaustiveSwitchError(segment, "type"); } } const geometry = closed ? new Polygon2d({ points: vertices, isFilled: false, ...initial.opts?.geometry }) : new Polyline2d({ points: vertices, ...initial.opts?.geometry }); geometries.push(geometry); } if (geometries.length === 0) return null; if (geometries.length === 1) return geometries[0]; return new Group2d({ children: geometries }); } toSolidSvg(opts) { const { strokeWidth, props } = opts; return /* @__PURE__ */ jsx("path", { strokeWidth, d: this.toD(), ...props }); } toDashedSvg(opts) { const { style, strokeWidth, snap, end, start, lengthRatio, props: { markerStart, markerEnd, ...props } = {} } = opts; const parts = []; for (const { initial, segments, closed } of this.lines) { let lastPoint = initial; for (let i = 0; i < segments.length; i++) { const segment = segments[i]; const isFirst = i === 0; const isLast = i === segments.length - 1 && !closed; const segmentLength = this.segmentLength(lastPoint, segment); const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( segmentLength, strokeWidth, { style, snap, lengthRatio, start: isFirst ? closed ? "none" : start : "outset", end: isLast ? closed ? "none" : end : "outset" } ); switch (segment.type) { case "lineTo": parts.push( /* @__PURE__ */ jsx( "line", { x1: toDomPrecision(lastPoint.x), y1: toDomPrecision(lastPoint.y), x2: toDomPrecision(segment.x), y2: toDomPrecision(segment.y), strokeDasharray, strokeDashoffset, markerStart: isFirst ? markerStart : void 0, markerEnd: isLast ? markerEnd : void 0 }, i ) ); break; case "arcTo": parts.push( /* @__PURE__ */ jsx( "path", { d: [ "M", toDomPrecision(lastPoint.x), toDomPrecision(lastPoint.y), "A", segment.radius, segment.radius, 0, segment.largeArcFlag ? "1" : "0", segment.sweepFlag ? "1" : "0", toDomPrecision(segment.x), toDomPrecision(segment.y) ].join(" "), strokeDasharray, strokeDashoffset, markerStart: isFirst ? markerStart : void 0, markerEnd: isLast ? markerEnd : void 0 }, i ) ); break; default: exhaustiveSwitchError(segment, "type"); } lastPoint = segment; } if (closed && lastPoint !== initial) { const dist = Vec.Dist(lastPoint, initial); const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(dist, strokeWidth, { style, snap, lengthRatio, start: "outset", end: "none" }); parts.push( /* @__PURE__ */ jsx( "line", { x1: toDomPrecision(lastPoint.x), y1: toDomPrecision(lastPoint.y), x2: toDomPrecision(initial.x), y2: toDomPrecision(initial.y), strokeDasharray, strokeDashoffset, markerEnd }, "last" ) ); } } return /* @__PURE__ */ jsx("g", { strokeWidth, ...props, children: parts }); } toDrawSvg(opts) { const { strokeWidth, randomSeed, offset: defaultOffset = strokeWidth / 3, roundness: defaultRoundness = strokeWidth * 2, passes = 2, props } = opts; const parts = []; const tangents = this.lines.map(({ initial, segments, closed }) => { const tangents2 = []; const segmentCount = closed ? segments.length + 1 : segments.length; for (let i = 0; i < segmentCount; i++) { let previous = segments[i - 1]; let current = segments[i]; let next = segments[i + 1]; if (!previous) previous = initial; if (!current) { current = initial; next = segments[0]; } if (!next) { next = initial; } let tangentBefore, tangentAfter; switch (current.type) { case "lineTo": case "moveTo": { tangentBefore = Vec.Sub(previous, current).norm(); break; } case "arcTo": { const info = getArcSegmentInfo(previous, current); if (info === null || info === "straight-line") { tangentBefore = Vec.Sub(current, previous).norm().per(); break; } tangentBefore = Vec.Per(info.endVector).mul(Math.sign(info.sweepAngle)); break; } default: exhaustiveSwitchError(current, "type"); } switch (next.type) { case "lineTo": case "moveTo": { tangentAfter = Vec.Sub(next, current).norm(); break; } case "arcTo": { const info = getArcSegmentInfo(current, next); if (info === null || info === "straight-line") { tangentAfter = Vec.Sub(next, current).norm().per(); break; } tangentAfter = Vec.Per(info.startVector).mul(Math.sign(info.sweepAngle)); break; } default: exhaustiveSwitchError(next, "type"); } tangents2.push({ tangentBefore, tangentAfter }); } return tangents2; }); for (let pass = 0; pass < passes; pass++) { for (let lineIdx = 0; lineIdx < this.lines.length; lineIdx++) { const { initial, segments, closed } = this.lines[lineIdx]; const random = rng(randomSeed + pass + lineIdx); const initialOffset = initial.opts?.offset ?? defaultOffset; const initialPOffset = { x: initial.x + random() * initialOffset, y: initial.y + random() * initialOffset }; const offsetPoints = []; let lastDistance = Vec.Dist(initialPOffset, segments[0]); for (let i = 0; i < segments.length; i++) { const segment = segments[i]; const nextSegment = i === segments.length - 1 ? closed ? segments[0] : null : segments[i + 1]; const nextDistance = nextSegment ? Vec.Dist(segment, nextSegment) : Infinity; const shortestDistance = Math.min(lastDistance, nextDistance) - (segment.opts?.roundness ?? defaultRoundness); const offset = clamp(segment.opts?.offset ?? defaultOffset, 0, shortestDistance / 10); const offsetPoint = { x: segment.x + random() * offset, y: segment.y + random() * offset }; offsetPoints.push(offsetPoint); lastDistance = nextDistance; } if (closed) { const roundness = initial.opts?.roundness ?? defaultRoundness; offsetPoints.push(initialPOffset); const next = offsetPoints[0]; const nudgeAmount = Math.min(Vec.Dist(initialPOffset, next) / 2, roundness); const nudged = Vec.Nudge(initialPOffset, next, nudgeAmount); parts.push("M", toDomPrecision(nudged.x), toDomPrecision(nudged.y)); } else { parts.push("M", toDomPrecision(initialPOffset.x), toDomPrecision(initialPOffset.y)); } const segmentCount = closed ? segments.length + 1 : segments.length; for (let i = 0; i < segmentCount; i++) { const segment = i === segments.length ? initial : segments[i]; const roundness = segment.opts?.roundness ?? defaultRoundness; const offsetP = offsetPoints[i]; const { tangentBefore, tangentAfter } = tangents[lineIdx][i]; const previousOffsetP = i === 0 ? initialPOffset : offsetPoints[i - 1]; const nextOffsetP = i === segments.length - 1 && !closed ? null : offsetPoints[(i + 1) % offsetPoints.length]; switch (segment.type) { case "lineTo": case "moveTo": { if (!nextOffsetP || roundness === 0) { parts.push("L", toDomPrecision(offsetP.x), toDomPrecision(offsetP.y)); break; } const clampedRoundness = lerp( roundness, 0, clamp( invLerp( Math.PI / 2, Math.PI, Math.abs(Vec.AngleBetween(tangentBefore, tangentAfter)) ), 0, 1 ) ); const nudgeBeforeAmount = Math.min( Vec.Dist(previousOffsetP, offsetP) / 2, clampedRoundness ); const nudgeBefore = Vec.Mul(tangentBefore, nudgeBeforeAmount).add(offsetP); const nudgeAfterAmount = Math.min( Vec.Dist(nextOffsetP, offsetP) / 2, clampedRoundness ); const nudgeAfter = Vec.Mul(tangentAfter, nudgeAfterAmount).add(offsetP); parts.push( "L", toDomPrecision(nudgeBefore.x), toDomPrecision(nudgeBefore.y), "Q", toDomPrecision(offsetP.x), toDomPrecision(offsetP.y), toDomPrecision(nudgeAfter.x), toDomPrecision(nudgeAfter.y) ); break; } case "arcTo": parts.push( "A", segment.radius, segment.radius, 0, segment.largeArcFlag ? "1" : "0", segment.sweepFlag ? "1" : "0", toDomPrecision(offsetP.x), toDomPrecision(offsetP.y) ); break; default: exhaustiveSwitchError(segment, "type"); } } if (closed) { parts.push("Z"); } } } return /* @__PURE__ */ jsx("path", { strokeWidth, d: parts.join(" "), ...props }); } segmentLength(lastPoint, segment) { switch (segment.type) { case "lineTo": return Vec.Dist(lastPoint, segment); case "arcTo": { const info = getArcSegmentInfo(lastPoint, segment); if (info === null) return 0; if (info === "straight-line") return Vec.Dist(lastPoint, segment); return info.length; } default: exhaustiveSwitchError(segment, "type"); } } } /*! * Adapted from https://github.com/rveciana/svg-path-properties * MIT License: https://github.com/rveciana/svg-path-properties/blob/master/LICENSE * https://github.com/rveciana/svg-path-properties/blob/74d850d14998274f6eae279424bdc2194f156490/src/arc.ts#L121 */ function getArcSegmentInfo(lastPoint, { radius, largeArcFlag, sweepFlag, x, y }) { radius = Math.abs(radius); if (lastPoint.x === x && lastPoint.y === y) { return null; } if (radius === 0) { return "straight-line"; } const dx = (lastPoint.x - x) / 2; const dy = (lastPoint.y - y) / 2; const radiiCheck = Math.pow(dx, 2) / Math.pow(radius, 2) + Math.pow(dy, 2) / Math.pow(radius, 2); if (radiiCheck > 1) { radius = Math.sqrt(radiiCheck) * radius; } const cSquareNumerator = Math.pow(radius, 2) * Math.pow(radius, 2) - Math.pow(radius, 2) * Math.pow(dy, 2) - Math.pow(radius, 2) * Math.pow(dx, 2); const cSquareRootDenom = Math.pow(radius, 2) * Math.pow(dy, 2) + Math.pow(radius, 2) * Math.pow(dx, 2); let cRadicand = cSquareNumerator / cSquareRootDenom; cRadicand = cRadicand < 0 ? 0 : cRadicand; const cCoef = (largeArcFlag !== sweepFlag ? 1 : -1) * Math.sqrt(cRadicand); const transformedCenter = { x: cCoef * (radius * dy / radius), y: cCoef * (-(radius * dx) / radius) }; const center = { x: transformedCenter.x + (lastPoint.x + x) / 2, y: transformedCenter.y + (lastPoint.y + y) / 2 }; const startVector = { x: (dx - transformedCenter.x) / radius, y: (dy - transformedCenter.y) / radius }; const endVector = { x: (-dx - transformedCenter.x) / radius, y: (-dy - transformedCenter.y) / radius }; let sweepAngle = Vec.AngleBetween(startVector, endVector); if (!sweepFlag && sweepAngle > 0) { sweepAngle -= 2 * Math.PI; } else if (sweepFlag && sweepAngle < 0) { sweepAngle += 2 * Math.PI; } sweepAngle %= 2 * Math.PI; return { length: Math.abs(sweepAngle * radius), radius, sweepAngle, startVector, endVector, center }; } export { PathBuilder }; //# sourceMappingURL=PathBuilder.mjs.map