0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

多重縁取り正多角形をStylusで描く

Last updated at Posted at 2025-10-15

:point_up: 要求仕様

01-spec.png

  • あるサイズの正方形領域に描画するものとし、その中で最大限大きく描き(内接)、余白は均等(上下左右中央寄せ)にする
  • ある一辺が底辺(一番下の水平な辺)になる
  • 一番外側に輪郭線(縁取り)があり、そこから内側に向けて一定の間隔で同じ太さの輪郭線を重ねていく
    • 「間隔」とは辺の間隔であり、頂点のではない
  • 最後の内側塗り潰すことも透明にすることもできる

これを Stylus で作る。

:confetti_ball: 完成品

※記事投稿時点で Qiita は Stylus のシンタックスハイライト未対応
// 曲座標変換
polar(r, a)
  return r * cos(a) r * sin(a)

// 小数第1位まで丸める
myRound(v)
  return round(v, 1)

// メイン
MBRegNGon(n, size, numBorders, borderW, gapW, fillsCenter)

  size    = unit(size,    "")
  borderW = unit(borderW, "")
  gapW    = unit(gapW,    "")

  cos180divN = cos(180deg / n)
  rOut = size / 2 / (  // 外接円の半径
    odd(n) ? cos(90deg / n) : n % 4 == 2 ? 1 : cos180divN
  )
  rIn = rOut * cos180divN  // 内接円の半径
  h = odd(n) ? rOut + rIn : 2 * rIn
  xOffset = size / 2
  yOffset = (size + h) / 2 - rIn

  commands = ()
  numLaps = numBorders * 2 + (fillsCenter ? 1 : 0)
  for i in 0 ... numLaps

    rInReductionI = borderW * ceil(i / 2) + gapW * floor(i / 2)
    rOutI = rOut - rInReductionI / cos180divN

    for point in 0 ... n

      angle = 90deg + 180deg * (2 * point - 1) / n
      xy = polar(rOutI, angle)
      coord = join(",", myRound(xy[0] + xOffset), myRound(xy[1] + yOffset))
      push(commands, join(" ", point == 0 ? "M" : "L", coord))

    push(commands, "Z")
  
  return join(" ", commands)

デモはこちら↓
ただし見せ方の都合で色々違う部分もある。

See the Pen Multi-bordered Regular Polygon with clip-path by Stylus by 森の子リスのミーコの大冒険 (@phroneris) on CodePen.

Desmos でグラフとしてこね回したい方はこちら↓
ただし SVG とは座標系の方向が違うため、少し違う式になっている箇所もある。


以下、これを作る道筋を記す。

:robot: 戦略

戦略をざっくり立てる。

MBRegNGon(良い感じの引数)
  for ...  // 良い感じにループ
    ...  // 良い感じに処理
  return "良い感じのパス"

.selector
  clip-path: path(evenodd, MBRegNGon(良い感じの引数))
  • 良い感じにループして良い感じに処理する
  • clip-path プロパティの path() 関数SVG パスを描く
    • 第 1 引数に evenodd を指定することで、奇数回囲まれた領域のみ描画される
  • そういう良い感じのパスを返すような関数 MBRegNGon() を自作する
    • 多重縁取り正 $n$ 角形: Multi-bordered Regular n-gon

手続きも考える。

02-steps.png

  • 正多角形を描くには極座標が便利なのでそうする
  • 右下の頂点(底辺の右端)から時計回りで周回描画
  • 一周したら線幅または間隔の分だけ径を縮小して再周回、というのを必要な回数だけやる
    • 何周目かの偶奇によって描画とくり抜きが入れ替わる
  • 最後を塗り潰す場合は 1 周多く周回
  • 正多角形全体のサイズを算出し、正方形領域に対しての中央寄せに用いる

:triangular_ruler: 最大の正多角形の幾何計算

線幅とかの内側の話は置いておいて、まず一番外側の正多角形について考える。

証明や導出は省くが、一辺が $s$ の正方形に内接する正 $n$ 角形の性質や各値は次の通り。
尚、Stylus での実装の際に 1 周 = 360° の度数法の方が扱いやすいので、この段階から 1 周 = 2π の弧度法ではなく度数法の方で表す。

03-geometry.png

  • 正方形への内接必ず幅方向で発生し、$n$ が 4 の倍数でない限り高さ方向には余白が生じる
  • 外接円の半径 $R$:
    • $n$ が奇数: $\require{gensymb} R = \dfrac{s}{2 \cos \dfrac{90\degree}{n}}$
    • $n$ が単偶数(4 の倍数でない偶数): $R = \dfrac{s}{2}$
    • $n$ が 4 の倍数: $\require{gensymb} R = \dfrac{s}{2 \cos \dfrac{180\degree}{n}}$
  • 内接円の半径 $\require{gensymb} r = R \cos \dfrac{180\degree}{n}$
  • $w = s$
  • 高さ $h$:
    • $n$ が奇数: $h = R + r$
    • $n$ が偶数: $h = 2r$
      • 特に $n$ が 4 の倍数なら $w$$s$ とも一致

ぶっちゃけ $n$ が奇数の場合の外半径を自力で導けなかったので ChatGPT にやって貰い、納得行くまで確認した。
個人的には、奇数の際には正 $2n$ 角形を経由すれば導けると理解した。

:keyboard: 実装

ユーティリティ関数を用意

これらを置く場所は、自作関数 MBRegNGon() の外でも中でも良い。

極座標関数

polar(r, a)
  return r * cos(a) r * sin(a)

尚、Stylus の自作関数では多値を返せるが、r * cos(a), r * sin(a) のように間をカンマで区切るとエラーになってしまった。
return は無くても良かったりする。

丸め関数

myRound(v)
  return round(v, 1)

三角関数を使いまくるので、生の数値のまま採用すると正三角形ですら "M 70,65.3109 L 0,65.3109 L 35,4.68911 Z" みたいに細か過ぎて伝わらない小数の嵐になる。
1/100px を見分けられる人類が存在しない事を祈って、65.310965.3 のように小数第 1 位まで簡略化する。

ただし誤差が蓄積しないよう、後のパス座標指定においては M x,yL x,y 等の絶対座標コマンドだけを使用し、m dx,dyl dx,dy 等の相対座標コマンドは使わない。

求めるパスを返す MBRegNGon() を自作

引数と制約

自作関数 MBRegNGon() は、引数を次の通り取るものとする。

MBRegNGon() 定義部
MBRegNGon(n, size, numBorders, borderW, gapW, fillsCenter)
  // ...
  • n: 正 $n$ 角形(3 以上の自然数、$n$
  • size: 描画領域の正方形の一辺 (十分大きなピクセル値$s$)
  • numBorders: 輪郭線を何周描くか(自然数)
  • borderW: 輪郭線の太さ(ピクセル値
  • gapW: 輪郭線同士の間隔(ピクセル値
  • fillsCenter: 最後の内側を塗り潰すか(論理値)

そして、CSS 関数 path() の中MBRegNGon(5, 70px, 2, 3px, 2px, false) のように呼び出す。

.reg-pentagon
  clip-path: path(evenodd, MBRegNGon(5, 70px, 2, 3px, 2px, false))
//                         ↑ = "M 56.6,68.3 L 13.4,68.3 ..."

ただし、path() に渡す SVG パス文字列は座標値に単位無しの数値しか扱えないという制約がある。
70px のようなピクセル値が使えないだけでなく、CSS の width 等と違って 50% のようなパーセント値も使えない。

.reg-pentagon
  clip-path: path("M 56.6,68.3 ...")      // OK
  clip-path: path("M 56.6px,68.3px ...")  // BAD
  clip-path: path("M 80.9%,97.6% ...")    // BAD

実は、MBRegNGon() に引数 sizeborderWgapWピクセル値で渡すとしているのは、あくまで呼び出し時のコードのわかりやすさのためでしかない。

.reg-pentagon
  //                       ↓ どれが回数でどれが長さ?
  clip-path: path(evenodd, MBRegNGon(5, 70, 2, 3, 2, false))
  //                       ↓ px単位の値が長さだな!
  clip-path: path(evenodd, MBRegNGon(5, 70px, 2, 3px, 2px, false))

そういう事情のため、これら 3 個の引数は渡して早々 size = unit(size, "") のようにして単位を剥奪している。留年しろ!

MBRegNGon() 定義部
MBRegNGon(n, size, numBorders, borderW, gapW, fillsCenter)

  size    = unit(size,    "")  // 70px → 70
  borderW = unit(borderW, "")  //  3px →  3
  gapW    = unit(gapW,    "")  //  2px →  2

  // 後に続く...

いつか CSS 関数 shape() が全ブラウザーで実装されれば、いかにも CSS らしくピクセル値やパーセント値が使えるので、この制約から解放されるだけでなく size 引数すら不要になるぞ!

幾何計算を準備

ようやく本質的な処理を書き始められる。

MBRegNGon() 定義部
MBRegNGon(n, size, numBorders, borderW, gapW, fillsCenter)

  // 前の続き...

  cos180divN = cos(180deg / n)
  rOut = size / 2 / (  // 外接円の半径
    odd(n) ? cos(90deg / n) : n % 4 == 2 ? 1 : cos180divN
  )
  rIn = rOut * cos180divN  // 内接円の半径
  h = odd(n) ? rOut + rIn : 2 * rIn
  xOffset = size / 2
  yOffset = (size + h) / 2 - rIn

  // 後に続く...

外接円・内接円の半径がこの計算のキモなので、これさえできればどうという事は無い。

$\require{gensymb} \cos \dfrac{180\degree}{n}$ が頻出するので cos180divN として定義している。
$R$rOut$r$rIn としている。
$w$ は明らかに $s$ (size) そのものなので定義していない。

04-center-offset.png

後に polar() 関数を使うと中心を (0, 0) とした座標が得られるが、SVG の座標系は左上が (0, 0) なのでそのままだと 1/4 しか見えなくなる。
要求仕様にある通り今回は上下左右中央寄せにしたいので、そのために xOffsetyOffset を準備しておく。
上下左右といっても左右は必ず領域に内接するので、実質的には上下だけ調整する。

座標系が右向き $x$ 軸、下向き $y$ 軸である事に留意すると、$x_{o}$ (xOffset)、$y_{o}$ (yOffset) は

x_{o} = \dfrac{s}{2}, \qquad y_{o} = \dfrac{s + h}{2} - r

と表せる。

周回数だけループ

MBRegNGon() 定義部
MBRegNGon(n, size, numBorders, borderW, gapW, fillsCenter)

  // 前の続き...

  commands = ()
  numLaps = numBorders * 2 + (fillsCenter ? 1 : 0)
  for i in 0 ... numLaps

    rInReductionI = borderW * ceil(i / 2) + gapW * floor(i / 2)
    rOutI = rOut - rInReductionI / cos180divN

  // 後に続く...

空リスト commands を用意し、後でここに SVG パスコマンドをどんどん追加していく。

周回数 numLaps は、輪郭線 1 本あたり線自身と間隔分とで 2 周あり、最後に中心を塗り潰す場合にもう 1 周追加されるので、上記のような定義になる。
(条件分岐の $\mathrm{\LaTeX}$ を Qiita で適切に組むのがめんどいので、数式は省略)

for 文の範囲指定 0 ... numLaps0 .. numLaps - 1 と同じだが(numLaps が自然数である限り)、せっかく ... 演算子があるので使う。

05-laps.png

各回の周回描画における中心から頂点までの距離、つまり外側から $i$ 番目(0 始まり)の正多角形における外半径の一般項 $R_{i}$ (rOutI) は、各回同士の輪郭線の太さ $b$ (borderW) とその間隔 $g$ (gapW) を用いて

\require{gensymb}
R_{i} = R - \dfrac{b \left\lceil \dfrac{i}{2} \right\rceil + g \left\lfloor \dfrac{i}{2} \right\rfloor}{\cos \dfrac{180\degree}{n}}

と表せる。
2 項目の分母 $b \left\lceil \dfrac{i}{2} \right\rceil + g \left\lfloor \dfrac{i}{2} \right\rfloor$ (rInReductionI) は、一番外側の内半径 $r$ (rIn) に対する $i$ 番目の内半径の減少分にあたる。

頂点数だけループ

MBRegNGon() 定義部
MBRegNGon(n, size, numBorders, borderW, gapW, fillsCenter)

  // 前の続き...

    for point in 0 ... n

      angle = 90deg + 180deg * (2 * point - 1) / n
      xy = polar(rOutI, angle)
      coord = join(",", myRound(xy[0] + xOffset), myRound(xy[1] + yOffset))
      push(commands, join(" ", point == 0 ? "M" : "L", coord))

    push(commands, "Z")

  // 後に続く...

各頂点の角度を求め、極座標から直交座標に変換し、xOffsetyOffset を加えて myRound() で丸めてカンマで繋ぎ、SVG パスコマンドとして commands リストの末尾に追加していく。

06-points.png

底辺の右端から時計回りに描画していくと決めたので、座標系が右向き $x$ 軸、下向き $y$ 軸である事に留意すると、$p$ 番目(0 始まりpoint)の頂点の $x$ 軸に対する中心角 $\theta_{p}$ (angle) は、

\require{gensymb}
\theta_{p} = 90\degree - \dfrac{180\degree}{n} + \dfrac{360\degree}{n} p

と表せる。

得られた座標の SVG パスコマンド化に際しては、最初の頂点であれば M コマンド(絶対座標でそこに筆を下ろす)、それ以降であれば L コマンド(絶対座標まで前回の座標から直線を引く)とする。

全ての頂点を処理し終えたら、最後に Z コマンド(最後の座標から最初の座標に向けて直線で結ぶ)を加えてパスを閉じる。

具体例として、size = 70 の正三角形の初回描画であれば、commands リストの中身は次のように累積していく。

commands = ()                                       // 頂点描画前
commands = ("M 70,65.3")                            // 右下
commands = ("M 70,65.3" "L 0,65.3")                 // 左下
commands = ("M 70,65.3" "L 0,65.3" "L 35,4.7")      // 上
commands = ("M 70,65.3" "L 0,65.3" "L 35,4.7" "Z")  // 今回の描画完了

パスを生成して返す

MBRegNGon() 定義部
MBRegNGon(n, size, numBorders, borderW, gapW, fillsCenter)

  // 前の続き...
  
  return join(" ", commands)

全ての周回を終えたら、commands リストをスペース区切りで結合して返す。
めでたし。

:warning: 諸注意

途中にガミガミ挟んでも鬱陶しいのでこちらにまとめている。

一部の引数は無意味になる場合がある

numBorders = 0 の場合、MBRegNGon() はただの塗り潰された最大の正多角形を描くだけになるので、borderWgapWfillsCenter は無意味となる。

numBorders = 1 かつ fillsCenter = false の場合、MBRegNGon() は一番外側の輪郭線を描くだけになるので、gapW は無意味となる。

そのような場合を考慮して、これらの引数を省略できるようにデフォルト値を用意しておく手もある。

MBRegNGon() 定義部
- MBRegNGon(n, size, numBorders, borderW, gapW, fillsCenter)
+ MBRegNGon(n, size, numBorders, borderW = 1px, gapW = 1px, fillsCenter = false)
.reg-pentagon--fill-only
  clip-path: path(evenodd, MBRegNGon(5, 70px, 0))       // エラーにならない
.reg-pentagon--one-border-only
  clip-path: path(evenodd, MBRegNGon(5, 70px, 1, 2px))  // エラーにならない

より堅牢な単位剥奪

size = unit(size, "") のような方法では、70em70% 等のピクセル値以外の単位も無差別に剥奪してしまう。
ピクセル値かどうかの検証も行いたいなら、そういう関数を組んでそれを通すのが良いだろう。

MBRegNGon() 定義部とその手前
+ unPX(v)
+   if unit(v) == "px"
+     return unit(v, "")
+   else
+     error("この引数はピクセル単位でなければなりません: " + v)

MBRegNGon(n, size, numBorders, borderW, gapW, fillsCenter)

-   size    = unit(size,    "")
-   borderW = unit(borderW, "")
-   gapW    = unit(gapW,    "")
+   size    = unPX(size)
+   borderW = unPX(borderW)
+   gapW    = unPX(gapW)

  // 後に続く...

ただ、n のようなピクセル値以外の引数はケアしないのかとか、小数を許容すべきかとか、面倒を見る範囲を考えるとキリが無い気がする。

rInReductionI の別解(非推奨)

よりプログラミングじみたやり方として、ループ前に rInReductionI の初期値を定義し、各回の終わりに i の偶奇に応じて rInReductionI へ加算する、という手もある。

MBRegNGon() 定義部
  MBRegNGon(n, size, numBorders, borderW, gapW, fillsCenter)

    // 諸々...

+   rInReductionI = 0
    for i in 0 ... numLaps

-     rInReductionI = borderW * ceil(i / 2) + gapW * floor(i / 2)
      rOutI = rOut - rInReductionI / cos180divN

      // 諸々...

+     rInReductionI += even(i) ? borderW : gapW

    return join(" ", commands)

もし borderWgapW整数値ならこれでも良いだろう。
しかし実際には borderW = 2.5px のような小数値も指定できるので(やって欲しくはないが)、誤差の蓄積を考慮するとやはり一般項を用いる方が良いと思う。

もっと言えば、rInReductionI を使わず外半径 rOutI 自体に同様の事をする手すらある。
初期値定義は rOutI = rOut、減算は rOutI -= (even(i) ? borderW : gapW) / cos180divN とすれば理論上成り立つ。
しかし、rOutI なんて小数にしかならないぐらいの値なので尚更採用したくない。

周回するうちに外半径は 0 以下になり得る

周回数 numBorders をやたら多くしたり、輪郭太さ borderW や間隔 gapW をやたら大きくしたりすると、各周回における外半径 rOutI は際限無く小さくなって 0 や負の値に突入し、図形としてえらい事になる

例えば MBRegNGon(3, 100px, 15, 3px, 2px, true) でこう。

07-non-positive-rOut.png

これを防ぐために、冒頭の CodePen では rOutI を定義した直後のブロックを unless rOutI <= 0 下に収めている。
手元でコンパイルするような環境で、防止というより検知したいなら、warn() if rOutI <= 0 とする手もある。

MBRegNGon() 定義部
  MBRegNGon(n, size, numBorders, borderW, gapW, fillsCenter)

    // 諸々...

    for i in 0 ... numLaps

      rInReductionI = borderW * ceil(i / 2) + gapW * floor(i / 2)
      rOutI = rOut - rInReductionI / cos180divN
+     warn("" + s("[%s][%s] 外半径が 0 以下です: rOutI = %s", n, i, rOutI)) if rOutI <= 0
+     unless rOutI <= 0

+       // 諸々のインデントを1段深める

    return join(" ", commands)

<愚痴>
ってか warn() とか error() とかが厳密に文字列値しか受け入れてくれないの、不便~。
おかげでせっかくのデバッグ向け関数の s()expected "msg" to be a string, but got literal っつって怒られちゃうから、"" + s() なんて本質的でない回避策を取らされる。
まぁリテラルを渡せたら何か面倒があって仕方無いんだろうけど…。
</愚痴>

コマンド文字列の結合は s() でもできる、けど…

抜粋して再掲
coord = join(",", myRound(xy[0] + xOffset), myRound(xy[1] + yOffset))
push(commands, join(" ", point == 0 ? "M" : "L", coord))

join() する値が 2 個だけなら、join(" ", point == 0 ? "M" : "L", coord) の代わりに s("%s %s", point == 0 ? "M" : "L", coord) としても良いように思えるかもしれない。

しかし join() の返す値が文字列であるのに対して、s() が返す値はリテラルとなる(上の愚痴にもある通り)、という違いがある。
もしどうしてもやるなら、"M" 等は M のようなリテラルにしなければならない(ただしその名前の変数が無いなら)し、coord は既に文字列なので unquote(coord) とするか、coord の作成時点で join() ではなく s() でやらなければならない。

join(" ", point == 0 ? "M" : "L", coord)        // "M 70,65.3"    // OK
s("%s %s", point == 0 ? "M" : "L", coord)       // "M" "70,65.3"  // BAD
s("%s %s", point == 0 ? M : L, unquote(coord))  // M 70,65.3      // OK

coord = s("%s,%s", myRound(xy[0] + xOffset), myRound(xy[1] + yOffset))
s("%s %s", point == 0 ? M : L, coord)           // M 70,65.3      // OK

なぜそんな留意が要るかというと、最後の return join(" ", commands) でパス文字列になる際、各コマンドが "M" "70,65.3" のような文字列リテラルだと結合結果が '"M" "70,65.3"' のように引用符に引用符を重ねた値になってしまい、path() に渡すには不適となるため。

.reg-triangle
  clip-path: path("M 70,65.3 ...")      // OK
  clip-path: path('"M" "70,65.3" ...')  // BAD

そんな感じで、今回わざわざ s() でやるメリットは無いだろう。

clip-path はマジで要素をくり抜く

当たり前じゃんと言われても一応警告したい。

clip-path はマジで要素をくり抜くので、要素自体にこれを適用するのは要注意。
テキスト等の中身があれば中身ごとくり抜くし、要素がリンクであればリンクのクリック判定すらくり抜く

AVIF アニメーション:
08-clip-behavior.avif

あくまで背景(のような物)としてくり抜きたいだけであれば、お馴染みの ::before または ::after 疑似要素を要素いっぱいに敷いてそれをくり抜くのが無難だろう。

.reg-pentagon
  width: 70px  // ここだけ変えれば後ろには @width で適用される
  height: @width
  position: relative
  ::before
    display: block
    content: ""
    position: absolute
    inset: 0  // top/right/bottom/left 全て 0
    z-index: -1  // 中にテキスト等を入れるならこういうのが必須
    background-color: #FF3B14
    clip-path: path(evenodd, MBRegNGon(5, @width, 2, 3px, 2px, false))

:thought_balloon: 感想

いや…普通に <svg> でやれば?

真面目に、Stylus でやるデメリットとして CSS のコンパイルに時間がかかる事を感じた。
多角形の頂点数や内側への周回数が増えるとパス文字列がどんどん伸びていき、CodePen のようなリアルタイムプレビューにおいてはちょっと色や余白を調整したいだけでも毎回律儀にパスを再計算せざるを得ないので体験が悪化した。

それにわざわざ自作しなくても、<svg> ならなんか JavaScript で良い感じにやるライブラリとかあるんじゃないの?
まぁ手元でコンパイルするのだとしたら、結局は HTML と CSS のどちらのコンパイルに時間をかけて良いかという話になるだけだが。

おわり

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?