66
30

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

正多面体5種類全部描くぞ。HTMLとCSSだけで。

Last updated at Posted at 2022-07-25

CSSの底力を(もう一度)見せてやる

スクリーンショット 2022-07-29 15.34.43.png

チラシを見ると頭の中でCSSを組み始めてしまうCSSオタクですこんにちわ。
というわけで今回もHTMLとCSSだけで正多面体描いていこうと思います。
例によってSVGもCANVASもjsも使いません。
(というかsvgでもthree.jsでも大変さは変わらない気がするけど)

ただHTMLとCSSを素で記述するのは大変なので、
PUGとStylusを利用して記述していきます。

結果だけ見たい方はこちら

準備

まず3Dで十全に動かす準備として、入念に要素を入れ子にします。

pug
.container
	.view
		.view__inner
			// 多面体全体
			.solid
				// 多面体の各平面
				.face
stylus
.view
	transform-style preserve-3d
	transition all .2s linear 0s
	animation rotation 10s linear 0s infinite
	&:hover
		animation-play-state paused
	&__inner
		transform-style preserve-3d
		transition all .2s linear 0s

@keyframes rotation
	from
		transform rotateY(0deg)
	to
		transform rotateY(360deg)

.viewは回転用のレイヤーになります。
.view__innerはコントローラー(後述)用のレイヤーです。
.solidからが多面体本体となります。
.faceは各平面で、各多面体によって数が変わります。

正六面体と基本となる記述を書く

手始めに一番簡単な正六面体から始めましょう。
全ての面が直角に接しているのでやりやすいです。

正六面体を描くと共に各多面体に共通する部分の記述も一緒に書いていきます。

.solidの記述

pug
.solid.solid--hexa
  - for (var v = 1; v <= 6; v++)
	.face.face--hexa.face--square(class=`face--${v}` data-num = v)

六面体なので.faceを6個作ります。
stylus側では再利用しやすいように一辺の長さを変数に格納しておきます。

stylus
$w = 100px
.solid
	position absolute
	top 50%
	left 50%
	backface-visibility visible
	transform-style preserve-3d
	transform-origin 50% 50% 0
	&--hexa
		width $w
		height $w
		margin-left - ($w / 2)
		margin-top - ($w / 2)

各多面体で共通となる部分は.solidに、
正六面体固有の部分は.solid--hexaに記述しました。

.faceの記述

次に.faceの記述です。

stylus
$w = 100px
.solid
	position absolute
	top 50%
	left 50%
	backface-visibility visible
	transform-style preserve-3d
	transform-origin 50% 50% 0
	&--hexa
		width $w
		height $w
		margin-left - ($w / 2)
		margin-top - ($w / 2)
+ .face
+ 	position absolute
+ 	top 0
+ 	left 0
+ 	width 100%
+ 	height 100%
+	background-color rgba(#fff, .15)
+	color rgba(#fff, .75)
+ 	&::before
+ 		position absolute
+ 		left 50%
+ 		content attr(data-num)
+ 		margin-top -15px
+ 		margin-left -15px
+ 		width 30px
+ 		height 30px
+ 		border-radius 15px
+ 		box-sizing border-box
+ 		text-align center
+ 		line-height 30px
+	&--square
+		transform-origin 50% 50% 0
+		&::before
+			top 50%
+	&--hexa
+		border 1px solid rgba(#fff, .75)

面に対して共通になる部分は.faceに、
正方形独自の部分は.face--squareに、
正六面体独自の設定は.face--hexaに分けて記述しました。
まぁ正方形は正六面体でしか使わないので別に分けなくても良いんですがこう書いておくとわかりやすいので。

.face::beforeの部分は各面に丸囲み数字を入れるためのものです。
必要ない場合は消してください。
この丸囲み数字を面の中心に置きたいのですが、面の中心は各多角形によって異なるため、
.face--square:beforeの方のtopで指定しています(leftは共通)。

各面の記述

では各面を3D移動していきます。

stylus
$w = 100px
.solid
	position absolute
	top 50%
	left 50%
	backface-visibility visible
	transform-style preserve-3d
	transform-origin 50% 50% 0
	&--hexa
		width $w
		height $w
		margin-left - ($w / 2)
		margin-top - ($w / 2)
 .face
	position absolute
	top 0
	left 0
	width 100%
	height 100%
	background-color rgba(#fff, .15)
	color rgba(#fff, .75)
	&::before
		position absolute
		left 50%
		content attr(data-num)
		margin-top -15px
		margin-left -15px
		width 30px
		height 30px
		border-radius 15px
		box-sizing border-box
		text-align center
		line-height 30px
	&--square
		transform-origin 50% 50% 0
		&::before
			top 50%
	&--hexa
		border 1px solid rgba(#fff, .75)
+		for num in (0..1)
+			&^[0]--{num * 5 + 1}
+				transform rotateX((num - 1) * 180deg + 90deg) translateZ($w / 2)
+		for num in (2..5)
+			&^[0]--{num}
+				transform rotateY((num - 2) * 90deg) translateZ($w / 2)

基本的にどの多面体であっても、z軸(画面の奥→手前)方向に内接球の半径分引き出してから、XYZ軸のどれかで回転することによって所定の場所に移動させることになります。

正六面体の内接球の半径は一辺の1/2になるので簡単です。
translateZ($w / 2)の記述で手前側に引き出しています。
そのあと、1番目と6番目の面を上と下に90度回転(X軸回転)、
残りの面はY軸回転で90度ずつずらしています。

これで正六面体は完成です。
ただし、この状態だと面がY軸を直角になっているため回してもなんとなくカッコ良くありません
そこで、頂点を結んだ対角線をY軸と一致させることでカッコ良く回したいと思います。

多面体の軸を回転移動させる

正多面体自体の中心と頂点を結ぶ線は外接球(circumscribed sphere)の半径と等しく、また正多面体自体の中心と各面の中心を結ぶ線は内接球(inscribed sphere)の半径と等しくなります。
この二つの半径は面の正多角形の対角線を隔て直角を構成しているため、二つの線が交わる角度は逆コサイン関数により求めることができます。

正六面体では、一辺の長さを1とした場合、
内接球の半径は一辺の半分(1/2)です。
また外接級半径は正方形の対角線の半分(√2/2)と一辺の半分(1/2)からなる直角三角形の斜辺となるので三平方の定理で求めることができます。
つまり、

\begin{align}
内接球半径& = \frac{1}{2} \\
外接球半径& = \sqrt{\bigl(\frac{\sqrt{2}}{2}\bigr)^2+\bigl(\frac{1}{2}\bigr)^2} \\
&= \sqrt{\frac{2}{4}+\frac{1}{4}} \\
&= \sqrt{\frac{3}{4}} \\
&= \frac{\sqrt{3}}{2} \\
\end{align}

となるので、これらが構成する角度は、

\begin{align}
\arccos\frac{\frac{1}{2}}{\frac{\sqrt{3}}{2}}& = \arccos\frac{1}{\sqrt{3}} \\
& \approx 54.73561031724534° 
\end{align}
jsでの計算
Math.acos(1 / (3 ** 0.5)) / Math.PI * 180
// -> 54.73561031724534

となります。
stylusは三角関数(sin, cos, tan)と冪乗演算子(**)を搭載していますが、逆三角関数(asin, acos, atan)は搭載されていないため、この計算をstylus上で行うことはできません。
しょうがないのでjsで計算した値を変数に入れて使いました。

※ 本来はわざわざ度(deg)に直さずラジアン(rad)のままでもcssで使えますが、日本人的に度の方が理解しやすいので変換しています。
※ ちなみにcss自体でも「CSS Values and Units Module Level 4」で三角関数や逆三角関数などの数々の数学関数の実装が提案されていますが(→参照)、現在のところブラウザへの搭載状況は芳しくありません。

この角度は「マジックアングル(magic angle)」と呼ばれており、NMR(核磁気共鳴)やMRIにおいて重要な角度らしいです。マジックアングルと呼ばれるのはこの54.7356度だけなのですが、他の名称を知らないので以後、各多面体の外接球半径と内接球半径がなす角度をマジックアングルと呼称することにします。
(参考:[wikipwdia] Magic angle(英語記事)
(正式名称誰か教えてください)

stylus
$w = 100px
+ $ma = 54.73561031724534deg
.solid
	position absolute
	top 50%
	left 50%
	backface-visibility visible
	transform-style preserve-3d
	transform-origin 50% 50% 0
	&--hexa
		width $w
		height $w
		margin-left - ($w / 2)
		margin-top - ($w / 2)
+		transform rotate3d(1, 0, 1, $ma)
 .face
	position absolute
	top 0
	left 0
	width 100%
	height 100%
	background-color rgba(#fff, .15)
	color rgba(#fff, .75)
	&::before
		position absolute
		left 50%
		content attr(data-num)
		margin-top -15px
		margin-left -15px
		width 30px
		height 30px
		border-radius 15px
		box-sizing border-box
		text-align center
		line-height 30px
	&--square
		transform-origin 50% 50% 0
		&::before
			top 50%
	&--hexa
		border 1px solid rgba(#fff, .75)
		for num in (0..1)
			&^[0]--{num * 5 + 1}
				transform rotateX((num - 1) * 180deg + 90deg) translateZ($solid.hexa.rs * $w.hexa)
		for num in (2..5)
			&^[0]--{num}
				transform rotateY((num - 2) * 90deg) translateZ($solid.hexa.rs * $w.hexa)

回転させるにあたって、正六面体のみ、頂点が一つもXYZ軸を通らないのでrotateX()rotateY()rotateZ()ではなく、回転軸を指定できるrotate3D()を使います(他では使いません)。
回転軸の指定は原点(0, 0, 0)からXZ面の手前右側に45度進む線(1, 0, 1)です。この軸に沿ってマジックアングル分回転させると頂点がY軸上に来るようになります。

コントローラーの記述

ただ回すだけでも面白くないので、コントローラーで色々な設定をON/OFFできるようにします。

pug
+ -
+	const
+		controlList = [
+			{
+				id: 'perspective',
+				name: 'pers',
+				checked: false
+			},
+			{
+				id: 'rotatex90',
+				name: 'x90deg',
+				checked: false
+			},
+			{
+				id: 'rotatez45',
+				name: 'z45deg',
+				checked: false
+			},
+			{
+				id: 'stop',
+				name: 'stop',
+				checked: false
+			},
+			{
+				id: 'background',
+				name: 'bg',
+				checked: true
+			},
+			{
+				id: 'number',
+				name: 'num',
+				checked: true
+			}
+		];
+ each obj in controlList
+	input(type='checkbox', id=obj.id, checked=obj.checked)
+ .control
+	each obj in controlList
+		label.control__label(for=obj.id)=obj.name
.container
	.view
		.view__inner
			.solid.solid--hexa
				- for (var v = 1; v <= 6; v++)
					.face.face--hexa.face--square(class=`face--${v}` data-num = v)
stylus
input
	display none
.control
	position absolute
	top 0
	left 0
	padding 0 8px 8px 0
	color #fff
	flex-flow row wrap
	z-index 10
	font-size 0
	&__label
		margin 8px 0 0 8px
		display inline-block
		background-color #888
		padding 0 8px
		border-radius 8px
		line-height 1.4
		text-transform uppercase
		cursor pointer
		font-size 12px
		line-height 20px
#perspective:checked
	& ~ .control [for=perspective]
		background-color #88f
	& ~ .container
		perspective 500px
#stop:checked
	& ~ .control [for=stop]
		background-color #88f
	& ~ .container .view
		animation-play-state paused
#rotatex90:checked
	& ~ .control [for=rotatex90]
		background-color #88f
	& ~ .container .view__inner
		transform rotateX(90deg)
#rotatez45:checked
	& ~ .control [for=rotatez45]
		background-color #88f
	& ~ .container .view__inner
		transform rotateZ(45deg)
#rotatex90:checked ~ #rotatez45:checked
	& ~ .container .view__inner
		transform rotateZ(45deg) rotateX(90deg)
#background:checked
	& ~ .control [for=background]
		background-color #88f
#background:not(:checked)
	& ~ .container .face
		background-color transparent
#number:checked
	& ~ .control [for=number]
		background-color #88f
#number:not(:checked)
	& ~ .container .face:before
		opacity 0

これでちょっと傾けたり、パースペクティブ(遠近法)をON/OFFできるようになりました。
正六面体と基本の記述はこれで完成です。

See the Pen hedrons by ichimonzi (@ichimonzi) on CodePen.

※全部をいったん一つのcodepenに描いてから一つずつ独立させる、という方法を採ったので、書き方が若干冗長になっています。

この調子、どんどん描こう、多面体。

正四面体を描く

正六面体以外を描くにあたって問題となることがあります。
それは当たり前の話なのですが、正六面体以外、構成する面が長方形じゃない、ということです。
cssのclip-pathプロパティを使うことで長方形から任意の形に変形することはできますが、このプロパティは面(背景色)は指定できても境界線(枠線)は指定できません。
そこで、枠線用の要素を線の数だけ用意することで、これに対処したいと思います。

pug
.solid.solid--tetra
	- for (var v = 1; v <= 4; v++)
		.face.face--tetra.face--tri(class=`face--${v}` data-num = v)
			- for (var x = 1; x <= 3; x++)
				.face__border(class=`face__border--${x}`)

.faceの中に.face__borderを辺の数だけ設置します、正四面体の場合構成面は正三角形なので.face__borderは三つとなります。

正三角形の設定

今回から設定が複雑になっていくので、変数はオブジェクト型で書いていきます。
まず面に対しての三角形の変数群です。

stylus
$w = {
	tetra: 100px
}
$face = {
	tri: {
		h: sin(60deg),
		rl: 1 / (cos(30deg) * 2),
		rs: tan(30deg) / 2
	}
}

長方形の底辺が正三角形の一辺と接する時にちょうどおさまる長方形の幅と高さを考えます。
幅は一辺と等しくなるので改めて計算する必要はありません。高さは正三角形の高さと等しくなります。
これを$face.tri.hとします。
これに一辺の長さ$w.tetraをかければ、一辺の長さを後で変更した時も一緒に変更されるようになります。
同じく外接円半径を$face.tri.rl、内接円半径を$face.tri.rsに入れておきます。

stylus
.face
	position absolute
	top 0
	left 0
	width 100%
	height 100%
	&::before
		position absolute
		left 50%
		content attr(data-num)
		margin-top -15px
		margin-left -15px
		width 30px
		height 30px
		border-radius 15px
		box-sizing border-box
		text-align center
		line-height 30px
+	&__border
+		position absolute
+		top 0
+		left 0
+		width 100%
+		height 100%
+		box-sizing border-box
+	&--tri
+		clip-path polygon(
+			50% 0,
+			100% 100%,
+			0 100%
+		)
+		transform-origin 50% (100% / $face.tri.h * $face.tri.rl) 0
+		&::before
+			top (100% / $face.tri.h * $face.tri.rl)
+		& ^[0]__border
+			transform-origin 50% (100% / $face.tri.h * $face.tri.rl) 0
+			width 100%
+			for num in (1..3)
+				&--{num}
+					transform rotateZ((num - 1) * 120deg)

正三角形の場合、clip-pathはかなり単純です。高ささえ正しければ、上辺の中点と右下、左下を結ぶだけで正三角形ができます。
transform-originのY座標やtop外接円半径 / 外接長方形の高さをパーセンテージで入れておきます。

\frac{\frac{1}{2\cos30°}}{\sin60°} \approx 66.66666663347881\%

一応計算値を書き出しましたが、ここはstylusが自動でやってくれます。
あとはz軸に沿って120°ずつ回すことで、枠線用要素.face__borderが所定の位置に移動してくれます。

これで正三角形の用意ができました。
この正三角形は正八面体と正二十面体でも使うことになります。

.solidと.faceの残りの設定

stylus
.solid
	position absolute
	top 50%
	left 50%
	backface-visibility visible
	transform-style preserve-3d
	transform-origin 50% 50% 0
+	&--tetra
+		width $w.tetra
+		height $face.tri.h * $w.tetra
+		margin-left - ($w.tetra / 2)
+		margin-top - ($face.tri.h * $w.tetra / 2)

.solidに関しては特筆すべき部分はありません。
高さが正六面体と異なり$wではなくなりました。

続いて正四面体用の変数群です。

stylus
$solid = {
	tetra: {
		rl: sqrt(3 / 8),
		rs: (1 / sqrt(24)),
		da: 70.52877936550931deg
	}
}
jsでの計算
Math.atan(Math.sqrt(8)) / Math.PI * 180
// -> 70.52877936550931

(参照:[wikipedia] 正四面体

ここら辺から自前の計算ではよくわからなくなってくるのでwikipedia要参照。
便利だぜwikipedia。

外接球半径を$solid.tetra.rl、内接球半径を$solid.tetra.rsとしています。
sqrt()関数は自作関数です。
いちいち冪乗演算子で書くのが面倒だったので用意しました。

stylus
sqrt(num)
  num ** (1/2)

$solid.tetra.daは二面角(dihedral angle)といって多面体の隣り合う面同士がなす角度です。
正四面体の場合はこの角度だけで各面の移動ができます。

stylus
.face
	position absolute
	top 0
	left 0
	width 100%
	height 100%
	&::before
		position absolute
		left 50%
		content attr(data-num)
		margin-top -15px
		margin-left -15px
		width 30px
		height 30px
		border-radius 15px
		box-sizing border-box
		text-align center
		line-height 30px
	&__border
		position absolute
		top 0
		left 0
		width 100%
		height 100%
		box-sizing border-box
	&--tri
		clip-path polygon(
			50% 0,
			100% 100%,
			0 100%
		)
		transform-origin 50% (100% / $face.tri.h * $face.tri.rl) 0
		&::before
			top (100% / $face.tri.h * $face.tri.rl)
		& ^[0]__border
			transform-origin 50% (100% / $face.tri.h * $face.tri.rl) 0
			width 100%
			for num in (1..3)
				&--{num}
					transform rotateZ((num - 1) * 120deg)
+	&--tetra
+		background-color rgba(#fff, .15)
+		color rgba(#fff, .75)
+		&::before
+			border 1px solid rgba(#fff, .75)
+		& ^[0]__border
+			border-bottom 1px solid rgba(#fff, .75)
+		&^[0]--1
+			transform rotateY(180deg) rotateX(-90deg) translateZ($solid.tetra.rs * $w.tetra)
+		for num in (0..2)
+			&^[0]--{num + 2}
+				transform rotateY(num * 120deg) rotateX(90deg - $solid.tetra.da) translateZ($solid.tetra.rs * $w.tetra)

全ての面を$solid.tetra.rs * $w.tetraで内接球半径分手前に押し出します。
そして1枚目の面はY軸回転で逆さまにしたあと下に90°移動させます。
これは面の辺同士を接するようにするためです。
2〜4枚目の面は90°から二面角を引いた分だけX軸で傾け、Y軸回転で120°ずつずらします。
これで正四面体は完成です。
最初から頂点がY軸を通っているので軸調整は必要ないです。

ちなみに正四面体のみ、頂点と多面体の中心点を通る線が反対側の頂点を通りません。

See the Pen hedrons by ichimonzi (@ichimonzi) on CodePen.

正八面体を描く

正八面体も正四面体と同じく正三角形で構成されるため、上で書いた正三角形のコードが流用できます。
pugは以下になります。

pug
.solid.solid--octa
	- for (var v = 1; v <= 8; v++)
		.face.face--octa.face--tri(class=`face--${v}` data-num = v)
			- for (var x = 1; x <= 3; x++)
				.face__border(class=`face__border--${x}`)

正八面体の変数群は以下です。

$solid = {
	octa: {
		rl: (1 / sqrt(2)),
		rs: (1 / sqrt(6)),
		da: 109.47122063449069deg,
		ma: 35.264389682754654deg		
	}
}

(参考:[wikipedia 正八面体][wikipedia] Octahedron(英語)
(※ 二面角が何故か日本語版には載っていなかった)

二面角($solid.octa.da)とマジックアングル($solid.octa.ma)は以下の計算で求められます。

\begin{align}
da &= \arccos (−\frac{1}{3}) \\
&\approx 109.47122063449069deg \\
ma &= \arccos \frac{\frac{1}{\sqrt{6}}}{\frac{1}{\sqrt{2}}} \\
&= \arccos \frac{\sqrt{2}}{\sqrt{6}} \\
&\approx 54.73561031724534deg
\end{align}
jsでの計算
Math.acos(-1 / 3) / Math.PI * 180
// 109.47122063449069 

Math.acos(Math.sqrt(2) / Math.sqrt(6)) / Math.PI * 180
// 54.73561031724534

.solidの設定

.solid
	position absolute
	top 50%
	left 50%
	backface-visibility visible
	transform-style preserve-3d
	transform-origin 50% 50% 0
+	&--octa
+		width $w.octa
+		height $face.tri.h * $w.octa
+		margin-left - ($w.octa / 2)
+		margin-top - ($face.tri.h * $w.octa / 2)
+		transform translateY(- $face.tri.rs * $w.octa / 2)

正三角形の中点は外接長方形の中点より低いのでその分のギャップをtranslateYで調整しています。
ギャップは内接円半径の半分です。

.faceの設定

stylus
.face
	position absolute
	top 0
	left 0
	width 100%
	height 100%
	&::before
		position absolute
		left 50%
		content attr(data-num)
		margin-top -15px
		margin-left -15px
		width 30px
		height 30px
		border-radius 15px
		box-sizing border-box
		text-align center
		line-height 30px
	&__border
		position absolute
		top 0
		left 0
		width 100%
		height 100%
		box-sizing border-box
	&--tri
		clip-path polygon(
			50% 0,
			100% 100%,
			0 100%
		)
		transform-origin 50% (100% / $face.tri.h * $face.tri.rl) 0
		&::before
			top (100% / $face.tri.h * $face.tri.rl)
		& ^[0]__border
			transform-origin 50% (100% / $face.tri.h * $face.tri.rl) 0
			width 100%
			for num in (1..3)
				&--{num}
					transform rotateZ((num - 1) * 120deg)
+	&--octa
+		background-color rgba(#fff, .15)
+		color rgba(#fff, .75)
+		&::before
+			border 1px solid rgba(#fff, .75)
+		& ^[0]__border
+			border-bottom 1px solid rgba(#fff, .75)
+		for num in (1..4)
+			&^[0]--{num}
+				transform rotateY((num - 1) * 90deg) rotateX(90deg - $solid.octa.ma) translateZ($solid.octa.rs * $w.octa)
+			&^[0]--{num + 4}
+				transform rotateY((num - 1) * 90deg) rotateX(-90deg + $solid.octa.ma) translateZ($solid.octa.rs * $w.octa) rotateZ(180deg)

全ての面を$solid.octa.rs * $w.octaで内接球半径分手前に押し出します。
そのあと90°上に倒したのちマジックアングル分戻すことで頂点の一つがY軸と重なります。
この状態でY軸に沿って90°ずつ回転させれば上半分の配置が終了します。
下半分は逆に90°下に倒したのちマジックアングル分戻し、同じくY軸に沿って90°ずつ回転させれば完了です。

これで正八面体が完成しました。

See the Pen hedrons by ichimonzi (@ichimonzi) on CodePen.

正十二面体を描く

そろそろ自分が何のためにこんなことをしているのかよくわからなくなってくる頃合いですが、
深く考えてはいけません。
ラマヌジャンのようにナーマギリ女神に導かれるままコードを書き進めましょう(?)

pug
.solid.solid--dodeca
	- for (var v = 1; v <= 12; v++)
		.face.face--dodeca.face--penta(class=`face--${v}` data-num = v)
			- for (var x = 1; x <= 5; x++)
				.face__border(class=`face__border--${x}`)

正十二面体は英語では「ドデカヘドロン(dodecahedron)」と言います。
かっこいいね!
面の数は12、構成する正多角形はこれまで出てこなかった正五角形です。
なので新しく正五角形用の設定を書きます。

正五角形の設定

正五角形用の変数群は以下になります。

stylus
$face = {
	penta: {
		w: (cos(72deg) * 2) + 1,
		h: (tan(72deg) / 2),
		rl: 1 / (cos(54deg) * 2),
		rs: tan(54deg) / 2,
		v1y: sin(36deg),
		v2x: cos(72deg)
	}
}

底辺に正五角形の一辺が接する外接長方形を作ります。
幅は$face.penta.w、高さは$face.penta.hとなり、いよいよ幅も高さも一辺の長さとは異なることになります。
$face.penta.rlは外接円半径、$face.penta.rsは内接円半径です。
$face.penta.v1yは上辺とも底辺とも接しない2つの頂点のY座標、
$face.penta.v2xは底辺と接する二つの頂点のX座標計算用の数値です。

stylus
.face
	position absolute
	top 0
	left 0
	width 100%
	height 100%
	&::before
		position absolute
		left 50%
		content attr(data-num)
		margin-top -15px
		margin-left -15px
		width 30px
		height 30px
		border-radius 15px
		box-sizing border-box
		text-align center
		line-height 30px
	&__border
		position absolute
		top 0
		left 0
		width 100%
		height 100%
		box-sizing border-box
+	&--penta
+		clip-path polygon(
+			50% 0,
+			100% (100% / $face.penta.h * $face.penta.v1y),
+			(100% / $face.penta.w * ($face.penta.v2x+ 1)) 100%,
+			(100% / $face.penta.w * $face.penta.v2x) 100%,
+			0 (100% / $face.penta.h * $face.penta.v1y)
+		)
+		transform-origin 50% (100% / $face.penta.h * $face.penta.rl) 0
+		&^[0]::before
+			top (100% / $face.penta.h * $face.penta.rl)
+		& ^[0]__border
+			transform-origin 50% (100% / $face.penta.h * $face.penta.rl) 0
+			width 100%
+			for num in (1..5)
+				&--{num}
+					transform rotateZ((num - 1) * 72deg)

clip-pathプロパティは最初の点が上辺の中心でそれ以降v1yv2xを交互に使いながら指定します。
あとは正三角形の時とあまり変わりません。
transform-originのY座標やtopプロパティも同じく外接円半径/外接長方形の高さとなります。
枠線用要素は72°ずつ回転させることで所定の位置に収まります。

.solidの設定

stylus

$solid = {
	dodeca: {
		rl: ((sqrt(15) + sqrt(3)) / 4),
		rs: sqrt((25 + (11 * sqrt(5))) / 40),
		da: 116.56505117707799deg,
		ma: 37.3773681406497deg
	}
}

(参考:[wikipedia 正十二面体]

二面角($solid.dodeca.da)とマジックアングル($solid.dodeca.ma)は以下の計算で求められます。

\begin{align}
da &= \arccos (−\frac{1}{\sqrt{5}}) \\
&\approx 116.56505117707799deg \\
ma &= \arccos \frac{\sqrt{\frac{25+11\sqrt{5}}{40}}}{\frac{\sqrt{15}+\sqrt{3}}{4}} \\
&= \arccos \frac{2\sqrt{\frac{25+11\sqrt{5}}{10}}}{\sqrt{15}+\sqrt{3}} \\
&\approx 37.37736814064969deg
\end{align}
jsによる計算
Math.acos(-1 / Math.sqrt(5)) / Math.PI * 180
// 116.56505117707799 

Math.acos((Math.sqrt((25 + (11 * Math.sqrt(5))) / 10) * 2) / (Math.sqrt(15) + Math.sqrt(3))) / Math.PI * 180
// 37.37736814064969

...何この式超怖い。

.solidのstylusは以下になります。

stylus
.solid
	position absolute
	top 50%
	left 50%
	backface-visibility visible
	transform-style preserve-3d
	transform-origin 50% 50% 0
+	&--dodeca
+		width $face.penta.w * $w.dodeca
+		height $face.penta.h * $w.dodeca
+		margin-left - ($face.penta.w * $w.dodeca / 2)
+		margin-top - ($face.penta.h * $w.dodeca / 2)

ギャップ調整については後述します。

.faceの設定

stylus
.face
	position absolute
	top 0
	left 0
	width 100%
	height 100%
	&::before
		position absolute
		left 50%
		content attr(data-num)
		margin-top -15px
		margin-left -15px
		width 30px
		height 30px
		border-radius 15px
		box-sizing border-box
		text-align center
		line-height 30px
	&__border
		position absolute
		top 0
		left 0
		width 100%
		height 100%
		box-sizing border-box
	&--penta
		clip-path polygon(
			50% 0,
			100% (100% / $face.penta.h * $face.penta.v1y),
			(100% / $face.penta.w * ($face.penta.v2x+ 1)) 100%,
			(100% / $face.penta.w * $face.penta.v2x) 100%,
			0 (100% / $face.penta.h * $face.penta.v1y)
		)
		transform-origin 50% (100% / $face.penta.h * $face.penta.rl) 0
		&^[0]::before
			top (100% / $face.penta.h * $face.penta.rl)
		& ^[0]__border
			transform-origin 50% (100% / $face.penta.h * $face.penta.rl) 0
			width 100%
			for num in (1..5)
				&--{num}
					transform rotateZ((num - 1) * 72deg)
+	&--dodeca
+		background-color rgba(#fff, .15)
+		color rgba(#fff, .75)
+		&::before
+			border 1px solid rgba(#fff, .75)
+		& ^[0]__border
+			border-bottom 1px solid rgba(#fff, .75)
+		for num in (0..1)
+			&^[0]--{num * 6 + 1}
+				transform rotateZ(num * 180deg) rotateY(num * 180deg) translateZ($solid.dodeca.rs * $w.dodeca)
+		for num in (1..5)
+			&^[0]--{num + 1}
+				transform rotateZ((num - 1) * 72deg) rotateX(180deg + $solid.dodeca.da) translateZ($solid.dodeca.rs * $w.dodeca) rotateZ(180deg)
+			&^[0]--{num + 7}
+				transform rotateZ((num - 1) * 72deg + 36deg) rotateX(- $solid.dodeca.da) translateZ($solid.dodeca.rs * $w.dodeca)

いよいよ難解になってまいりました。
全ての面を$solid.dodeca.rs * $w.dodecaで内接球半径分手前に押し出します。
今回は1枚目と7枚目を中心として、残りをそれぞれ5枚ずつ周りに配置していくイメージです。
1枚目と7枚目はそれぞれ上下逆さまに原点越しに相対させます。
2枚目〜6枚目は中心とする面の上下逆状態から180°から二面角を引いた角度だけ逆方向に倒します。
そのあとそれぞれを72°ずつ回転させれば1枚目の周りに配置できます。
8枚目〜12枚目は180°から「180°から二面角を引いた角度」を引いた角度になるのでつまり二面角のマイナス値です。
さらに全てを36°傾けたあと72°ずつ回転させれば7枚目の周りに配置できます。

回転軸の調整

stylus
.solid
	position absolute
	top 50%
	left 50%
	backface-visibility visible
	transform-style preserve-3d
	transform-origin 50% 50% 0
	&--dodeca
		width $face.penta.w * $w.dodeca
		height $face.penta.h * $w.dodeca
		margin-left - ($face.penta.w * $w.dodeca / 2)
		margin-top - ($face.penta.h * $w.dodeca / 2)
+		transform rotateX(90deg - $solid.dodeca.ma) translateY(-($face.penta.rl - $face.penta.rs) * $w.dodeca / 2)

頂点をY軸まで倒すには、90°上側に倒したあとマジックアングルの角度だけ引き戻す、ということが必要になります。
また正五角形とその外接長方形の中心はY座標が異なるため、このギャップをtranslateYで調整します。

以上で正十二面体が完成となります。

See the Pen dodecahedron by ichimonzi (@ichimonzi) on CodePen.

ぜひパースペクティブをONにして見てほしい。
圧倒的な立体感。
すごい(語彙力の欠如)。

正二十面体を描く

長かったですねここまで。
では最後に張り切って二重面体を描いていきたいと思います。

pug
.solid.solid--icosa
	- for (var v = 1; v <= 20; v++)
		.face.face--icosa.face--tri(class=`face--${v}` data-num = v)
			- for (var x = 1; x <= 3; x++)
				.face__border(class=`face__border--${x}`)

正十二面体は英語では「イコサヘドロン(icosahedron)」と言います。
かっこいいね!
正十二面体の構成面は正三角形なのでこれまた前に作ったものを流用しましょう。

変数群は以下になります。

.solidの設定

$solid = {
	icosa: {
		rl: (sqrt(10 + (2 * sqrt(5))) / 4),
		rs: (((3 * sqrt(3)) + sqrt(15)) / 12),
		da: 138.1896851042214deg,
		ma: 37.37736814064969deg
	}
}

(参考:[wikipedia 正二十面体][wikipedia Regular icosahedron(英語)]

何回も「面体」と打ち込んでるせいで明太子食べたくなってきました。
二面角($solid.icosa.da)とマジックアングル($solid.icosa.ma)は以下の計算で求められます。

\begin{align}
da &= \arccos (−\frac{\sqrt{5}}{3}) \\
&\approx 138.1896851042214° \\
ma &= \arccos \frac{\frac{3\sqrt{3}+\sqrt{15}}{12}}{\frac{\sqrt{10+2\sqrt{5}}}{4}} \\
&= \arccos \frac{3\sqrt{3}+\sqrt{15}}{3\sqrt{10+2\sqrt{5}}} \\
&\approx 37.37736814064969°
\end{align}
jsによる計算
Math.acos(-(Math.sqrt(5) / 3)) / Math.PI * 180
// 138.1896851042214

Math.acos(((3 * Math.sqrt(3)) + Math.sqrt(15)) / (3 * Math.sqrt(10 + (2 * Math.sqrt(5))))) / Math.PI * 180 
// 37.37736814064969

実はマジックアングルが正十二面体と一緒です。
ということはアークコサインの中の分数もどうにかすれば同じ形になるはずなのですが
よくわかりませんでした。
....一応計算はできてるので次に進みます。

.solidの設定

stylus
.solid
	position absolute
	top 50%
	left 50%
	backface-visibility visible
	transform-style preserve-3d
	transform-origin 50% 50% 0
+	&--icosa
+		width $w.icosa
+		height $face.tri.h * $w.icosa
+		margin-left - ($w.icosa / 2)
+		margin-top - ($face.tri.h * $w.icosa / 2)
+		transform rotateX(90deg) translateY(- $face.tri.rs * $w.icosa / 2)

正八面体と同じくY軸方向(軸を倒す前のZ軸方向)にギャップがあるのでそれを直しています。
…本来ならこれで良いはずなのですが、ちょうど90°倒した時だけChromeでレンダリングの不具合が起こるようなので、
もう一つ先の頂点までさらに移動させます。

stylus
.solid
	position absolute
	top 50%
	left 50%
	backface-visibility visible
	transform-style preserve-3d
	transform-origin 50% 50% 0
+	&--icosa
+		width $w.icosa
+		height $face.tri.h * $w.icosa
+		margin-left - ($w.icosa / 2)
+		margin-top - ($face.tri.h * $w.icosa / 2)
-		transform rotateX(90deg) translateY(- $face.tri.rs * $w.icosa / 2)
+		transform rotateX(270deg + ($solid.icosa.ma * 2) - $solid.icosa.da) translateY(- $face.tri.rs * $w.icosa / 2)

レンダリンクの不具合の理由はよくわかりませんでした…。

.faceの設定

stylus
.face
	position absolute
	top 0
	left 0
	width 100%
	height 100%
	&::before
		position absolute
		left 50%
		content attr(data-num)
		margin-top -15px
		margin-left -15px
		width 30px
		height 30px
		border-radius 15px
		box-sizing border-box
		text-align center
		line-height 30px
	&__border
		position absolute
		top 0
		left 0
		width 100%
		height 100%
		box-sizing border-box
	&--tri
		clip-path polygon(
			50% 0,
			100% 100%,
			0 100%
		)
		transform-origin 50% (100% / $face.tri.h * $face.tri.rl) 0
		&::before
			top (100% / $face.tri.h * $face.tri.rl)
		& ^[0]__border
			transform-origin 50% (100% / $face.tri.h * $face.tri.rl) 0
			width 100%
			for num in (1..3)
				&--{num}
					transform rotateZ((num - 1) * 120deg)
+	&--icosa
+		background-color rgba(#fff, .15)
+		color rgba(#fff, .75)
+		&::before
+			border 1px solid rgba(#fff, .75)
+		& ^[0]__border
+			border-bottom 1px solid rgba(#fff, .75)
+		for num in (1..5)
+			&^[0]--{num}
+					transform rotateZ((num - 1) * 72deg) rotateX(- $solid.icosa.ma) translateZ($solid.icosa.rs * $w.icosa)
+			&^[0]--{num + 5}
+					transform rotateZ((num - 1) * 72deg) rotateX(180deg - $solid.icosa.ma) translateZ($solid.icosa.rs * $w.icosa)
+			&^[0]--{num + 10}
+					transform rotateZ((num - 1) * 72deg) rotateX(-(180deg - $solid.icosa.da) - $solid.icosa.ma) translateZ($solid.icosa.rs * $w.icosa) rotateZ(180deg)
+			&^[0]--{num + 15}
+					transform rotateZ((num - 1) * 72deg) rotateX($solid.icosa.da - $solid.icosa.ma) translateZ($solid.icosa.rs * $w.icosa) rotateZ(180deg)

流石に今までで一番難解です。
今まで通り全ての面は$solid.dodeca.rs * $w.icosaで内接球半径分手前に押し出しています。
5枚ずつに分けて考えます。
まずマジックアングル分動かして頂点の一つをZ軸に接するようにします。
この状態でZ軸を回転軸にして72°ずつ動かせば1枚目から5枚目を配置できます。
6枚目から10枚目は1枚目から5枚目のまだ接する面がない辺に接地させます。
つまりマジックアングル分+「180°-二面角」分動かします。
11枚目〜20枚目はこれと180°違うことをやれば自然と相対した形に配置できます。

とうとう正二十面体も完成しました。
これを素のCSSで書いていたら発狂していたでしょう。
stylus万歳。

See the Pen hedrons by ichimonzi (@ichimonzi) on CodePen.

まとめ

全5種の多面体を一堂に集めて回してみました。
全ての多面体は一辺の長さを$wで定義しており、ここを変えるだけで多面体の大きさを変えられます。
こちらの方はコントローラーを大幅に強化しています。
「same radius」をONにしたあと「opacity off」をしてみると、ゲームのポリゴンがバグったみたいなの正多面体同士の干渉具合が見られます。面と面が干渉して凸凹になっていて、ちゃんとCSSで3D描けるんだなってちょっと感動します。

あとこちらにはおまけとして「切頂二十面体(truncated icosahedron)」を追加しました。
「Truncated ICOSA」を押すと正二十面体と正十二面体を部品としてサッカーボール型が組み上がるのが見れるはずです。

See the Pen hedrons by ichimonzi (@ichimonzi) on CodePen.

ちなみにJSで3D座標をいじりたい方はDOMMatrix APIというものが用意されています。これを使って正二十面体の最初の5枚を指定する例をコメントアウトで書いておきました。(最終的にはcssのtransform: matrix3d()に変換しています)。

作ってみて

三角関数と逆三角関数と平方根を一生分使い切った気がする。
拡大してよく見てみると面どうしがちゃんとくっついてないように見えるのは、
CSSの限界なのか、計算と座標移動が複雑すぎて値が狂ってしまうのか、
…それとも書き手の計算ミスなのか。

終わりに

Chromeで特定の角度でなぜか面が表示されなくなる、という不具合に悩まされ、場所によっては迂遠な記述をせざるを得なかったのが心残りです。

CSSでやることじゃねぇなと思いました。

66
30
1

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
66
30

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?