tldraw
Version:
A tiny little drawing editor.
330 lines (329 loc) • 12.9 kB
JavaScript
import {
centerOfCircleFromThreePoints,
clamp,
exhaustiveSwitchError,
getPointOnCircle,
getPolygonVertices,
HALF_PI,
PI,
PI2,
rng,
Vec,
WeakCache
} from "@tldraw/editor";
import { STROKE_SIZES } from "../arrow/shared.mjs";
import { PathBuilder } from "../shared/PathBuilder.mjs";
const pathCache = new WeakCache();
function getGeoShapePath(shape) {
return pathCache.get(shape, _getGeoPath);
}
function _getGeoPath(shape) {
const w = Math.max(1, shape.props.w);
const h = Math.max(1, shape.props.h + shape.props.growY);
const cx = w / 2;
const cy = h / 2;
const sw = STROKE_SIZES[shape.props.size] * shape.props.scale;
const isFilled = shape.props.fill !== "none";
switch (shape.props.geo) {
case "arrow-down": {
const ox = w * 0.16;
const oy = Math.min(w, h) * 0.38;
return new PathBuilder().moveTo(ox, 0, { geometry: { isFilled } }).lineTo(w - ox, 0).lineTo(w - ox, h - oy).lineTo(w, h - oy).lineTo(w / 2, h).lineTo(0, h - oy).lineTo(ox, h - oy).close();
}
case "arrow-left": {
const ox = Math.min(w, h) * 0.38;
const oy = h * 0.16;
return new PathBuilder().moveTo(ox, 0, { geometry: { isFilled } }).lineTo(ox, oy).lineTo(w, oy).lineTo(w, h - oy).lineTo(ox, h - oy).lineTo(ox, h).lineTo(0, h / 2).close();
}
case "arrow-right": {
const ox = Math.min(w, h) * 0.38;
const oy = h * 0.16;
return new PathBuilder().moveTo(0, oy, { geometry: { isFilled } }).lineTo(w - ox, oy).lineTo(w - ox, 0).lineTo(w, h / 2).lineTo(w - ox, h).lineTo(w - ox, h - oy).lineTo(0, h - oy).close();
}
case "arrow-up": {
const ox = w * 0.16;
const oy = Math.min(w, h) * 0.38;
return new PathBuilder().moveTo(w / 2, 0, { geometry: { isFilled } }).lineTo(w, oy).lineTo(w - ox, oy).lineTo(w - ox, h).lineTo(ox, h).lineTo(ox, oy).lineTo(0, oy).close();
}
case "check-box": {
const size = Math.min(w, h) * 0.82;
const ox = (w - size) / 2;
const oy = (h - size) / 2;
return new PathBuilder().moveTo(0, 0, { geometry: { isFilled } }).lineTo(w, 0).lineTo(w, h).lineTo(0, h).close().moveTo(clamp(ox + size * 0.25, 0, w), clamp(oy + size * 0.52, 0, h), {
geometry: { isInternal: true, isFilled: false },
offset: 0
}).lineTo(clamp(ox + size * 0.45, 0, w), clamp(oy + size * 0.82, 0, h)).lineTo(clamp(ox + size * 0.82, 0, w), clamp(oy + size * 0.22, 0, h), { offset: 0 });
}
case "diamond":
return new PathBuilder().moveTo(cx, 0, { geometry: { isFilled } }).lineTo(w, cy).lineTo(cx, h).lineTo(0, cy).close();
case "ellipse":
return new PathBuilder().moveTo(0, cy, { geometry: { isFilled } }).arcTo(cx, cy, false, true, 0, w, cy).arcTo(cx, cy, false, true, 0, 0, cy).close();
case "heart": {
const o = w / 4;
const k = h / 4;
return new PathBuilder().moveTo(cx, h, { geometry: { isFilled } }).cubicBezierTo(0, k * 1.2, o * 1.5, k * 3, 0, k * 2.5).cubicBezierTo(cx, k * 0.9, 0, -k * 0.32, o * 1.85, -k * 0.32).cubicBezierTo(w, k * 1.2, o * 2.15, -k * 0.32, w, -k * 0.32).cubicBezierTo(cx, h, w, k * 2.5, o * 2.5, k * 3).close();
}
case "hexagon":
return PathBuilder.lineThroughPoints(getPolygonVertices(w, h, 6), {
geometry: { isFilled }
}).close();
case "octagon":
return PathBuilder.lineThroughPoints(getPolygonVertices(w, h, 8), {
geometry: { isFilled }
}).close();
case "oval":
return getStadiumPath(w, h, isFilled);
case "pentagon":
return PathBuilder.lineThroughPoints(getPolygonVertices(w, h, 5), {
geometry: { isFilled }
}).close();
case "rectangle":
return new PathBuilder().moveTo(0, 0, { geometry: { isFilled } }).lineTo(w, 0).lineTo(w, h).lineTo(0, h).close();
case "rhombus": {
const offset = Math.min(w * 0.38, h * 0.38);
return new PathBuilder().moveTo(offset, 0, { geometry: { isFilled } }).lineTo(w, 0).lineTo(w - offset, h).lineTo(0, h).close();
}
case "rhombus-2": {
const offset = Math.min(w * 0.38, h * 0.38);
return new PathBuilder().moveTo(0, 0, { geometry: { isFilled } }).lineTo(w - offset, 0).lineTo(w, h).lineTo(offset, h).close();
}
case "star":
return getStarPath(w, h, isFilled);
case "trapezoid": {
const offset = Math.min(w * 0.38, h * 0.38);
return new PathBuilder().moveTo(offset, 0, { geometry: { isFilled } }).lineTo(w - offset, 0).lineTo(w, h).lineTo(0, h).close();
}
case "triangle":
return new PathBuilder().moveTo(cx, 0, { geometry: { isFilled } }).lineTo(w, h).lineTo(0, h).close();
case "x-box":
return getXBoxPath(w, h, sw, shape.props.dash, isFilled);
case "cloud":
return getCloudPath(w, h, shape.id, shape.props.size, shape.props.scale, isFilled);
default:
exhaustiveSwitchError(shape.props.geo);
}
}
function getXBoxPath(w, h, sw, dash, isFilled) {
const cx = w / 2;
const cy = h / 2;
const path = new PathBuilder().moveTo(0, 0, { geometry: { isFilled } }).lineTo(w, 0).lineTo(w, h).lineTo(0, h).close();
if (dash === "dashed" || dash === "dotted") {
return path.moveTo(0, 0, {
geometry: { isInternal: true, isFilled: false },
dashStart: "skip",
dashEnd: "outset"
}).lineTo(cx, cy).moveTo(w, h, {
geometry: { isInternal: true, isFilled: false },
dashStart: "skip",
dashEnd: "outset"
}).lineTo(cx, cy).moveTo(0, h, {
geometry: { isInternal: true, isFilled: false },
dashStart: "skip",
dashEnd: "outset"
}).lineTo(cx, cy).moveTo(w, 0, {
geometry: { isInternal: true, isFilled: false },
dashStart: "skip",
dashEnd: "outset"
}).lineTo(cx, cy);
}
const inset = dash === "draw" ? 0.62 : 0;
path.moveTo(clamp(sw * inset, 0, w), clamp(sw * inset, 0, h), {
geometry: { isInternal: true, isFilled: false }
}).lineTo(clamp(w - sw * inset, 0, w), clamp(h - sw * inset, 0, h)).moveTo(clamp(w - sw * inset, 0, w), clamp(sw * inset, 0, h)).lineTo(clamp(sw * inset, 0, w), clamp(h - sw * inset, 0, h));
return path;
}
function getStadiumPath(w, h, isFilled) {
if (h > w) {
const r2 = w / 2;
return new PathBuilder().moveTo(0, r2, { geometry: { isFilled } }).arcTo(r2, r2, false, true, 0, w, r2).lineTo(w, h - r2).arcTo(r2, r2, false, true, 0, 0, h - r2).close();
}
const r = h / 2;
return new PathBuilder().moveTo(r, h, { geometry: { isFilled } }).arcTo(r, r, false, true, 0, r, 0).lineTo(w - r, 0).arcTo(r, r, false, true, 0, w - r, h).close();
}
function getStarPath(w, h, isFilled) {
const sides = 5;
const step = PI2 / sides / 2;
const rightMostIndex = Math.floor(sides / 4) * 2;
const leftMostIndex = sides * 2 - rightMostIndex;
const topMostIndex = 0;
const bottomMostIndex = Math.floor(sides / 2) * 2;
const maxX = Math.cos(-HALF_PI + rightMostIndex * step) * w / 2;
const minX = Math.cos(-HALF_PI + leftMostIndex * step) * w / 2;
const minY = Math.sin(-HALF_PI + topMostIndex * step) * h / 2;
const maxY = Math.sin(-HALF_PI + bottomMostIndex * step) * h / 2;
const diffX = w - Math.abs(maxX - minX);
const diffY = h - Math.abs(maxY - minY);
const offsetX = w / 2 + minX - (w / 2 - maxX);
const offsetY = h / 2 + minY - (h / 2 - maxY);
const ratio = 1;
const cx = (w - offsetX) / 2;
const cy = (h - offsetY) / 2;
const ox = (w + diffX) / 2;
const oy = (h + diffY) / 2;
const ix = ox * ratio / 2;
const iy = oy * ratio / 2;
return PathBuilder.lineThroughPoints(
Array.from(Array(sides * 2), (_, i) => {
const theta = -HALF_PI + i * step;
return new Vec(
cx + (i % 2 ? ix : ox) * Math.cos(theta),
cy + (i % 2 ? iy : oy) * Math.sin(theta)
);
}),
{ geometry: { isFilled } }
).close();
}
function getOvalPerimeter(h, w) {
if (h > w) return (PI * (w / 2) + (h - w)) * 2;
else return (PI * (h / 2) + (w - h)) * 2;
}
function getPillPoints(width, height, numPoints) {
const radius = Math.min(width, height) / 2;
const longSide = Math.max(width, height) - radius * 2;
const circumference = Math.PI * (radius * 2) + 2 * longSide;
const spacing = circumference / numPoints;
const sections = width > height ? [
{
type: "straight",
start: new Vec(radius, 0),
delta: new Vec(1, 0)
},
{
type: "arc",
center: new Vec(width - radius, radius),
startAngle: -PI / 2
},
{
type: "straight",
start: new Vec(width - radius, height),
delta: new Vec(-1, 0)
},
{
type: "arc",
center: new Vec(radius, radius),
startAngle: PI / 2
}
] : [
{
type: "straight",
start: new Vec(width, radius),
delta: new Vec(0, 1)
},
{
type: "arc",
center: new Vec(radius, height - radius),
startAngle: 0
},
{
type: "straight",
start: new Vec(0, height - radius),
delta: new Vec(0, -1)
},
{
type: "arc",
center: new Vec(radius, radius),
startAngle: PI
}
];
let sectionOffset = 0;
const points = [];
for (let i = 0; i < numPoints; i++) {
const section = sections[0];
if (section.type === "straight") {
points.push(Vec.Add(section.start, Vec.Mul(section.delta, sectionOffset)));
} else {
points.push(
getPointOnCircle(section.center, radius, section.startAngle + sectionOffset / radius)
);
}
sectionOffset += spacing;
let sectionLength = section.type === "straight" ? longSide : PI * radius;
while (sectionOffset > sectionLength) {
sectionOffset -= sectionLength;
sections.push(sections.shift());
sectionLength = sections[0].type === "straight" ? longSide : PI * radius;
}
}
return points;
}
const SIZES = {
s: 50,
m: 70,
l: 100,
xl: 130
};
const BUMP_PROTRUSION = 0.2;
function getCloudPath(width, height, seed, size, scale, isFilled) {
const path = new PathBuilder();
const getRandom = rng(seed);
const pillCircumference = getOvalPerimeter(width, height);
const numBumps = Math.max(
Math.ceil(pillCircumference / SIZES[size]),
6,
Math.ceil(pillCircumference / Math.min(width, height))
);
const targetBumpProtrusion = pillCircumference / numBumps * BUMP_PROTRUSION;
const innerWidth = Math.max(width - targetBumpProtrusion * 2, 1);
const innerHeight = Math.max(height - targetBumpProtrusion * 2, 1);
const innerCircumference = getOvalPerimeter(innerWidth, innerHeight);
const distanceBetweenPointsOnPerimeter = innerCircumference / numBumps;
const paddingX = (width - innerWidth) / 2;
const paddingY = (height - innerHeight) / 2;
const bumpPoints = getPillPoints(innerWidth, innerHeight, numBumps).map((p) => {
return p.addXY(paddingX, paddingY);
});
const maxWiggleX = width < 20 ? 0 : targetBumpProtrusion * 0.3;
const maxWiggleY = height < 20 ? 0 : targetBumpProtrusion * 0.3;
const wiggledPoints = bumpPoints.slice(0);
for (let i = 0; i < Math.floor(numBumps / 2); i++) {
wiggledPoints[i] = Vec.AddXY(
wiggledPoints[i],
getRandom() * maxWiggleX * scale,
getRandom() * maxWiggleY * scale
);
wiggledPoints[numBumps - i - 1] = Vec.AddXY(
wiggledPoints[numBumps - i - 1],
getRandom() * maxWiggleX * scale,
getRandom() * maxWiggleY * scale
);
}
for (let i = 0; i < wiggledPoints.length; i++) {
const j = i === wiggledPoints.length - 1 ? 0 : i + 1;
const leftWigglePoint = wiggledPoints[i];
const rightWigglePoint = wiggledPoints[j];
const leftPoint = bumpPoints[i];
const rightPoint = bumpPoints[j];
const distanceBetweenOriginalPoints = Vec.Dist(leftPoint, rightPoint);
const curvatureOffset = distanceBetweenPointsOnPerimeter - distanceBetweenOriginalPoints;
const distanceBetweenWigglePoints = Vec.Dist(leftWigglePoint, rightWigglePoint);
const relativeSize = distanceBetweenWigglePoints / distanceBetweenOriginalPoints;
const finalDistance = (Math.max(paddingX, paddingY) + curvatureOffset) * relativeSize;
const arcPoint = Vec.Lrp(leftPoint, rightPoint, 0.5).add(
Vec.Sub(rightPoint, leftPoint).uni().per().mul(finalDistance)
);
if (arcPoint.x < 0) {
arcPoint.x = 0;
} else if (arcPoint.x > width) {
arcPoint.x = width;
}
if (arcPoint.y < 0) {
arcPoint.y = 0;
} else if (arcPoint.y > height) {
arcPoint.y = height;
}
const center = centerOfCircleFromThreePoints(leftWigglePoint, rightWigglePoint, arcPoint);
const radius = Vec.Dist(
center ? center : Vec.Average([leftWigglePoint, rightWigglePoint]),
leftWigglePoint
);
if (i === 0) {
path.moveTo(leftWigglePoint.x, leftWigglePoint.y, { geometry: { isFilled } });
}
path.circularArcTo(radius, false, true, rightWigglePoint.x, rightWigglePoint.y);
}
return path.close();
}
export {
getGeoShapePath
};
//# sourceMappingURL=getGeoShapePath.mjs.map