Canvas 里画正三角形不稀奇,但把切角正三角形、圆角正三角形和莱诺三角形放在同一套坐标体系里,问题就不只是moveTo/lineTo那么简单了。我在做图形编辑器、可视化面板和教学演示时反复遇到同一类麻烦:正三角形坐标写死还能凑合,一旦改成切角,六个切点顺序错了;改成圆角,半径大一点路径就自交;改成莱诺三角形,圆弧方向一反,画面直接变成三片内凹花瓣。这四个图形看起来像四个独立案例,其实底层都围绕“中心点、外接圆、边长、顶点顺序”这套基准展开。把这套基准吃透,后面每增加一种变形,只是换路径生成规则,不用推倒重来。下面按从业者实操顺序拆开讲,适合已经会一点 Canvas 路径、想把几何图形画准的人,也适合刚接触arc、arcTo、路径闭合的新手对照复现。
1. 四种三角形先分成“同一基准 + 三种变形”
1.1 正三角形是整套图形的坐标基准
我习惯先不碰切角、圆角这些花样,只写一个函数把正三角形的三个顶点算出来。原因很直接:切角正三角形的截断点、圆角正三角形的相切点、莱诺三角形的圆心,全部依赖这三个顶点。如果顶点靠手写坐标,后面每换一次画布尺寸就得重新调,参数一多必然乱。正三角形的边长记作side,外接圆半径记作R,两者关系是R = side / Math.sqrt(3);内切圆半径记作rIn,关系是rIn = side * Math.sqrt(3) / 6;高是side * Math.sqrt(3) / 2。这几个值不是背概念,后面限制圆角半径、算切点距离都要用。
Canvas 的 y 轴向下,所以顶点朝向要提前定。我常用“顶点朝上”的布局:上方顶点 A 在中心点正上方,B、C 落在下方左右两侧,三个点均匀分布在外接圆上。这样做的好处是视觉重心和普通三角形图标一致,切角和圆角的边界也容易判断。顶点角度可以按-90°、30°、150°分布,换算成 Canvas 坐标就是 A 在cy - R,B、C 的 y 都是cy + R * Math.sin(Math.PI / 6),x 分别是中心点加减R * Math.cos(Math.PI / 6)。这套算法比“先画一条底边再算高”更省心,因为所有变形都从三个顶点数组出发,不需要分别处理底边和斜边。
提示:如果你把正三角形旋转过,不要再用固定角度硬算,直接把三个顶点绕中心点旋转同一个角度,后续切点、圆角、圆弧都跟着旋转,逻辑不会分叉。
1.2 切角、圆角、莱诺三角形分别改了什么
切角正三角形的本质是“沿边截断”。它在每条边靠近顶点的地方取两个点,把原来的尖角用一条短直线切掉,最后得到一个六边形。参数通常用截断比例p表示,p是沿着边从顶点往相邻顶点走的比例,常取0.15到0.35。p越小越接近原三角形,p越大切得越狠;p = 0.5时相邻切点会重合,六边形退化成中点三角形,所以实际使用时要把p控制在0到0.5之间。这个细节很多人第一次画会忽略,结果六个点里出现重复点,填充看起来没问题,描边却在角上出现小缺口。
圆角正三角形的本质是“用圆弧替换尖角”。它不是把三个角简单画成三个圆,而是让圆弧和相邻两条边相切,再用直线连接切点。等边三角形内角是60°,圆角半径记作r,顶点到切点的距离是d = r / tan(30°) = r * Math.sqrt(3)。这个d不能超过边长的一半,否则相邻两个角的切点会越过边中点,路径开始互相挤压。由此得到圆角半径上限:rMax = side * Math.sqrt(3) / 6,刚好等于正三角形内切圆半径。r取到rMax时,三段圆弧会连成内切圆,看起来不再是圆角三角形,而是一个圆。所以想要三角形轮廓明显,通常取rMax的30%到60%更合适。
莱诺三角形,很多资料也译作勒洛三角形,英文是 Reuleaux triangle。它的构造不是“把三角形磨圆”,而是以正三角形的三个顶点分别为圆心,以边长为半径,画三段连接另外两个顶点的圆弧。三段弧的圆心角都是60°,拼起来形成一条定宽曲线。它和圆角三角形最容易混淆:圆角三角形的圆弧在顶点附近,莱诺三角形的圆弧却跨过整条边,凸出方向朝外。判断方法很简单,看圆弧圆心在哪里——圆角三角形的圆心在角平分线内侧,莱诺三角形的圆心就在正三角形的顶点上。
1.3 参数化写法的好处比想象中大
我早期图省事,直接在画布上写三组坐标,正三角形倒是画出来了,后来加切角功能时又复制一份代码改数字,结果画布一缩放,所有坐标全对不上。后来改成“先算顶点,再生成路径”的结构,情况立刻清楚:正三角形只负责getEquilateralVertices(cx, cy, side),切角函数只负责根据p算六个点,圆角函数只负责根据r调arcTo,莱诺三角形只负责根据边长画三段弧。四个函数共享同一个中心点和边长,切换图形时只换路径生成部分,画布尺寸、设备像素比、描边样式都不用重复写。
这种拆法还有一个实际好处:调试时可以把顶点数组打印出来,检查顺序是不是顺时针或逆时针一致。切角路径对点的顺序非常敏感,圆角路径对起点位置敏感,莱诺三角形对圆弧方向敏感。把顶点顺序固定成 A、B、C 顺时针排列,后面所有计算都按同一套顺序推,出错时容易定位。比如切角六个点始终按“AB 边靠 A、AB 边靠 B、BC 边靠 B、BC 边靠 C、CA 边靠 C、CA 边靠 A”排列,路径闭合就不会交叉。
2. 正三角形:从外接圆参数到像素级路径
2.1 顶点计算公式与角度偏移
顶点朝上的正三角形,我通常把中心点当作外接圆圆心。设中心点为(cx, cy),边长为side,外接圆半径R = side / Math.sqrt(3)。A 点放在正上方,坐标是(cx, cy - R);B 点在右下方,坐标是(cx + R * cos(30°), cy + R * sin(30°));C 点在左下方,坐标是(cx - R * cos(30°), cy + R * sin(30°))。Canvas 里Math.sin(Math.PI / 6)就是0.5,Math.cos(Math.PI / 6)约等于0.866,代入后 B、C 的 y 都在中心点下方半个外接圆半径处。三个点连起来就是标准正三角形,重心和外接圆圆心重合,后续布局比较省心。
如果你想要“底边水平、尖角朝上”,这组公式已经满足;如果想要尖角朝下,把三个顶点绕中心旋转180°即可,不需要重算另一套公式。旋转函数可以写成对每个点做x' = cx + (x - cx) * cosθ - (y - cy) * sinθ,y' = cy + (x - cx) * sinθ + (y - cy) * cosθ。这种写法在画旋转动画时也能直接复用,比每次改顶点数组更可靠。实际项目里我还会保留一个angleOffset参数,默认-Math.PI / 2表示顶点朝上,需要斜着摆时只改这个偏移量。
function getEquilateralVertices(cx, cy, side, rotation = 0) { const R = side / Math.sqrt(3); const baseAngles = [-Math.PI / 2, Math.PI / 6, Math.PI * 5 / 6]; return baseAngles.map((angle) => { const a = angle + rotation; return { x: cx + R * Math.cos(a), y: cy + R * Math.sin(a) }; }); } function drawEquilateralTriangle(ctx, cx, cy, side) { const [A, B, C] = getEquilateralVertices(cx, cy, side); ctx.beginPath(); ctx.moveTo(A.x, A.y); ctx.lineTo(B.x, B.y); ctx.lineTo(C.x, C.y); ctx.closePath(); }注意上面用了baseAngles的方式,A 的角度是-90°,B 是30°,C 是150°。在 Canvas 坐标里-90°指向正上方,30°指向右下方,150°指向左下方,正好对应顶点朝上的三角形。这个写法比单独写三个Math.cos、Math.sin更整齐,后面切角、圆角、莱诺三角形都能直接复用同一个顶点数组。
2.2 抗锯齿、设备像素比与描边细节
正三角形的斜边很多,抗锯齿问题比矩形明显。如果画布 CSS 尺寸是400 × 400,而实际像素尺寸只有400 × 400,在高分屏上会显得发虚。我的做法是先把画布的实际宽高设成cssWidth * devicePixelRatio,再用ctx.scale(dpr, dpr)把绘图坐标缩回 CSS 像素。这样路径坐标仍按逻辑尺寸写,描边不会因为设备像素比变化而变粗。下面这段是初始化画布的常用模板,逻辑尺寸和像素尺寸分开管理,后续四个图形都共用。
function setupCanvas(canvas, cssWidth, cssHeight) { const dpr = window.devicePixelRatio || 1; canvas.style.width = `${cssWidth}px`; canvas.style.height = `${cssHeight}px`; canvas.width = Math.round(cssWidth * dpr); canvas.height = Math.round(cssHeight * dpr); const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx; }描边时还有一个容易忽略的点:stroke()是沿着路径中心向两边各画一半线宽。如果线宽是2px,实际视觉边界会比路径多出1px。做图形对比时问题不大,但如果要把四个图形严格对齐到同一个外接框,最好先fill()再stroke(),不要用描边去撑大小。需要内描边效果时,可以用ctx.save()、ctx.clip()把路径裁剪出来,再在内部描边,但那样代码会重一些,普通演示没必要。
lineJoin对正三角形影响不大,因为三个角都是尖角,默认miter就能出锐利效果;但切角正三角形有六个钝角,圆角正三角形没有尖角,莱诺三角形是三段圆弧相接。为了统一风格,我一般会在描边前设置ctx.lineJoin = 'round'和ctx.lineCap = 'round',尤其莱诺三角形的三段弧连接处,用圆角连接更自然。正三角形如果希望特别锋利,可以单独把lineJoin设回miter,同时把miterLimit调到合适值,避免极端参数下出现尖刺。
2.3 正三角形作为对照组的实测价值
我每次做这组图形,都会先把正三角形单独画在左上角当对照组。它的作用不是“凑数”,而是校验中心点、边长和缩放比例。切角正三角形如果看起来明显偏离正三角形轮廓,大概率是切点算错或顺序乱了;圆角正三角形如果外轮廓和正三角形对不上,可能是圆角半径超过上限;莱诺三角形如果宽度和正三角形对不上,通常是圆弧半径误用了外接圆半径R,而不是边长side。把正三角形放在旁边,一眼就能看出问题出在参数还是路径方向。
实测时我建议先画一个side = 180的正三角形,中心点放在画布中间,再分别画三个变形图形。正三角形的外接圆半径约103.92,内切圆半径约51.96,高约155.88。切角比例取0.25时,六个切点都在边的四分之一处,切角边长度是45,视觉上比较像“被削掉三个角”的徽章。圆角半径先取20,顶点到切点距离约34.64,远小于边长一半90,路径安全。莱诺三角形的圆弧半径直接取180,三段弧的圆心分别在三个顶点上。先把这组参数跑通,再改尺寸和颜色,排查成本最低。
3. 切角正三角形:截角比例、切点计算与路径闭合
3.1 切角不是圆角,先定截断比例
切角正三角形最容易被误解成“把角磨圆”,其实它是用直线把角截掉。做法是在每条边靠近顶点的地方取两个点,然后用一条短直线连接这两个点,形成新的斜边。假设正三角形顶点按 A、B、C 顺时针排列,边 AB 上靠近 A 的切点记作 P0,靠近 B 的切点记作 P1;边 BC 上靠近 B 的切点记作 P2,靠近 C 的切点记作 P3;边 CA 上靠近 C 的切点记作 P4,靠近 A 的切点记作 P5。最后按 P0、P1、P2、P3、P4、P5 的顺序连成闭合六边形,三个原顶点就被切掉了。
截断比例p表示从顶点向相邻顶点移动的距离占边长的比例。以边 AB 为例,P0 是 A 点向 B 点走p * side,P1 是