Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions web/src/cases/f2l-view.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { f2lGrayStickers } from './f2l-view'

describe('f2lGrayStickers', () => {
it('FR のペアだけ色を残し、上段と FR スロットのほかのパーツを灰色にする', () => {
// F2L 1(F' U' F):角は URF、エッジは UL にある
const gray = f2lGrayStickers('RUURUUUUFRBBURRURRFFDFFRFFRDDBDDDDDDFFLLLLLLLLLUBBBBBB')
for (const i of [8, 9, 20, 3, 37]) expect(gray.has(i)).toBe(false)
for (const i of [6, 18, 38, 0, 36, 47, 7, 19, 29, 26, 15, 23, 12]) expect(gray.has(i)).toBe(true)
// 下の2段のほかの場所(センターや FL スロット)は色のまま
for (const i of [4, 22, 13, 21, 24]) expect(gray.has(i)).toBe(false)
})
})
35 changes: 35 additions & 0 deletions web/src/cases/f2l-view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// F2L のケースの図で、どのシールを色付きで見せるか。
// facelets は 54 文字(U 0-8・R 9-17・F 18-26・D 27-35・L 36-44・B 45-53)、標準の向き(上面=最終層、FR スロット)。

/** 上段と FR スロットの、パーツごとのシールの位置。 */
const PIECES: number[][] = [
// 上段の角(URF・UFL・ULB・UBR)
[8, 9, 20],
[6, 18, 38],
[0, 36, 47],
[2, 45, 11],
// 上段のエッジ(UR・UF・UL・UB)
[5, 10],
[7, 19],
[3, 37],
[1, 46],
// FR スロット(DFR の角、FR のエッジ)
[29, 26, 15],
[23, 12],
]

const sameColors = (a: string[], b: string[]) => a.length === b.length && [...a].sort().join() === [...b].sort().join()

/**
* 灰色にするシールの位置。上段と FR スロットにあるパーツのうち、FR のペア(D・F・R の角と F・R のエッジ)でないものを灰色にする。
* 下の2段のほかの場所は揃っているので、色のまま残す。
*/
export function f2lGrayStickers(facelets: string): Set<number> {
const gray = new Set<number>()
for (const piece of PIECES) {
const colors = piece.map((i) => facelets[i])
const pair = piece.length === 3 ? ['D', 'F', 'R'] : ['F', 'R']
if (!sameColors(colors, pair)) for (const i of piece) gray.add(i)
}
return gray
}
76 changes: 63 additions & 13 deletions web/src/components/CaseDiagram.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useId } from 'react'
import type { CaseInfo } from '../api'
import { f2lGrayStickers } from '../cases/f2l-view'
import { pllArrows } from '../cases/pll-arrows'

// 標準の向き(上面=黄、前面=緑)での表示色。
Expand All @@ -17,7 +18,7 @@ const S = 14 // シール1枚の大きさ
const G = 2 // すき間

/**
* ケースの図。OLL/PLL は上面(+側面の上段)、F2L は上面+前面+右面。
* ケースの図。OLL/PLL は上面(+側面の上段)、F2L は右手前の上から見た立体(上面・前面・右面)。
* OLLは最終層の色以外を灰色にし、向きだけが分かるようにする。
* arrows を付けると、PLL ではどのパーツをどこへ動かすかを矢印で重ねる(小さい図では見づらいので、大きい図だけで使う)。
*/
Expand All @@ -43,18 +44,7 @@ export function CaseDiagram({
const cells: React.ReactNode[] = []
const step = S + G

if (c.set === 'F2L') {
// 上面(U 0-8)、前面(F 18-26)、右面(R 9-17)を展開図で
for (let i = 0; i < 9; i++) cells.push(cell((i % 3) * step, Math.floor(i / 3) * step, i))
for (let i = 0; i < 9; i++) cells.push(cell((i % 3) * step, 3 * step + G + Math.floor(i / 3) * step, 18 + i))
for (let i = 0; i < 9; i++) cells.push(cell(3 * step + G + (i % 3) * step, 3 * step + G + Math.floor(i / 3) * step, 9 + i))
const w = 6 * step + G
return (
<svg viewBox={`-2 -2 ${w + 4} ${w + 4}`} width={size} height={size} role="img" aria-label="ケースの図">
{cells}
</svg>
)
}
if (c.set === 'F2L') return <F2LCube facelets={f} size={size} />

// 上面+側面の上段(細長いシール)
const o = 6 // 側面ぶんの余白
Expand Down Expand Up @@ -110,3 +100,63 @@ export function CaseDiagram({
</svg>
)
}

/** 立体の図の1マスの長さ。 */
const U3 = 10
const COS30 = Math.cos(Math.PI / 6)
type Vec = [number, number]
// 右手前の上から見た等角図の向き:前面の左→右(X)、右面の手前→奥(Z)、下(Y)
const X: Vec = [COS30 * U3, 0.5 * U3]
const Z: Vec = [COS30 * U3, -0.5 * U3]
const Y: Vec = [0, U3]
// 手前・左・上の角を原点にする
const at = (x: number, z: number, y: number): Vec => [X[0] * x + Z[0] * z + Y[0] * y, X[1] * x + Z[1] * z + Y[1] * y]

/** 原点を角にして、a・b の向きに広がる1マスのシール(すき間のぶん内側に縮める)。 */
function quad(o: Vec, a: Vec, b: Vec): string {
const k = 0.08
const p = (s: number, t: number) => `${(o[0] + a[0] * s + b[0] * t).toFixed(2)},${(o[1] + a[1] * s + b[1] * t).toFixed(2)}`
return [p(k, k), p(1 - k, k), p(1 - k, 1 - k), p(k, 1 - k)].join(' ')
}

/**
* F2L のケースの図。キューブを右手前の上から見た立体で、上面・前面・右面を描く。
* FR のペア(角とエッジ)だけ色を付け、上段のほかのパーツは灰色にする。
*/
function F2LCube({ facelets, size }: { facelets: string; size: number }) {
const gray = f2lGrayStickers(facelets)
const fill = (i: number) => (gray.has(i) ? GRAY : (COLORS[facelets[i]] ?? GRAY))
const stickers: React.ReactNode[] = []
const add = (i: number, o: Vec, a: Vec, b: Vec) =>
stickers.push(<polygon key={i} points={quad(o, a, b)} fill={fill(i)} />)
for (let r = 0; r < 3; r++) {
for (let col = 0; col < 3; col++) {
// 上面:U0 が奥の左。行は奥から手前、列は左から右
add(3 * r + col, at(col, 2 - r, 0), X, Z)
// 前面:F18 が上の左
add(18 + 3 * r + col, at(col, 0, r), X, Y)
// 右面:R9 が上の手前。列は手前から奥
add(9 + 3 * r + col, at(3, col, r), Z, Y)
}
}
// キューブの輪郭(六角形)を黒で敷いて、シールのすき間を黒く見せる
const outline = [at(0, 0, 0), at(0, 3, 0), at(3, 3, 0), at(3, 3, 3), at(3, 0, 3), at(0, 0, 3)]
.map((v) => v.join(','))
.join(' ')
const w = 6 * X[0]
const top = 3 * Z[1]
const h = 3 * Y[1] - top + 3 * X[1]
const pad = 1.5
return (
<svg
viewBox={`${-pad} ${top - pad} ${w + 2 * pad} ${h + 2 * pad}`}
width={size}
height={size}
role="img"
aria-label="ケースの図"
>
<polygon points={outline} fill="#14181c" stroke="#14181c" strokeWidth={1.2} strokeLinejoin="round" />
{stickers}
</svg>
)
}
Loading