マークダウンにルービックキューブ表示記法を追加する

2024/12/16 投稿

はじめに

この自作ブログで今後、ルービックキューブに関する記事を書こうと思っています。その時に毎回説明画像を作成するのは面倒なので、マークダウンにルービックキューブの表記を追加できるようにしました。SVGによる平面的な表示はunifiedremarkプラグインとして追加する方法で実装しました。Three.jsによる3D表示はrehypeReactでコードブロックを変換する方法で実装しました。

できたもの

平面的な表示

コードブロックの言語部分にrubik-[size]を指定することで、平面的な表示ができます。中身には展開図のように各面の色を指定します。展開図の順番は1段目に背面、2段目に上面、3段目には左から順に左面、前面、右面、4段目には下面を書きます。
```rubik-4 W W W W W W W W W W W W W W W W R R R R R R R R R R R R R R R R G G G G Y Y Y Y B B B B G G G G Y Y Y Y B B B B G G G G Y Y Y Y B B B B G G G G Y Y Y Y B B B B O O O O O O O O O O O O O O O O ```

各面の色は以下表の通りです。

記号
W
R
G
B
オレンジO
Y
K
H

3D表示

コードブロックの言語部分にrubik3D-[size]を指定することで、3D表示ができます。平面的な表示と同様に各面の色を指定します。マウスポインタで回転させることができます。
```rubik3D-3 W W W W W W W W W R R R R R R R R R G G G Y Y Y B B B G G G Y Y Y B B B G G G Y Y Y B B B O O O O O O O O O ```

実装例

平面的な表示

import { Plugin } from 'unified'; import { visit } from 'unist-util-visit'; import type { Parent, Code, RootContent } from 'mdast'; // ASTノード拡張 type ExtendedRootContent = RootContent | { type: 'html'; value: string }; export const remarkRubik: Plugin = () => { return (tree) => { visit(tree, 'code', (node, index, parent) => { const codeNode = node as Code; const parentNode = parent as Parent; if (codeNode.lang && codeNode.lang.startsWith('rubik-') && codeNode.value) { // サイズ取得 const sizeMatch = codeNode.lang.match(/^rubik-(\d+)$/); const size = sizeMatch ? parseInt(sizeMatch[1], 10) : 3; // 正しいパースで面データ取得 const { top, front, right, back, left, bottom } = parseCode(codeNode.value); // facesConfigは { top, left, front, bottom, right, back } の順を前提としているから // parseCodeの結果を再配置する const facesInOriginalOrder = { back, top, left, front, right, bottom }; const svgContent = generateSVGContent(size, facesInOriginalOrder); const wrappedContent = ` <div class="col-start-1 col-span-4 md:col-span-3 lg:col-span-3"> ${svgContent} </div> `; const htmlNode: ExtendedRootContent = { type: 'html', value: wrappedContent, }; parentNode.children[index!] = htmlNode; } }); }; }; const parseCode = (code: string): Record<string, string[][]> => { const lines = code.trim().split("\n").map((line) => line.trim().split(" ")); const size = lines[0].length; const back = lines.slice(0, size); const top = lines.slice(size, size * 2); const left = lines.slice(size * 2, size * 3).map((row) => row.slice(0, size)); const front = lines.slice(size * 2, size * 3).map((row) => row.slice(size, size * 2)); const right = lines.slice(size * 2, size * 3).map((row) => row.slice(size * 2, size * 3)); const bottom = lines.slice(size * 3); return { top, front, right, back, left, bottom }; }; //-------------------------- // SVG生成関連ヘルパー //-------------------------- const CELL_SIZE = 30; type FaceData = string[][]; const colorMap: Record<string, string> = { w: "white", r: "red", g: "green", b: "blue", o: "orange", y: "yellow", h: "gray", k: "black", }; const mapColor = (c: string) => colorMap[c.toLowerCase()] || "gray"; const drawSquare = (x: number, y: number, size: number, color: string) => { return `<rect x="${x}" y="${y}" width="${size}" height="${size}" fill="${color}" stroke="black"></rect>`; }; const drawParallelogram = (points: Array<[number, number]>, color: string) => { const pointsStr = points.map(([px, py]) => `${px},${py}`).join(' '); return `<polygon points="${pointsStr}" fill="${color}" stroke="black"></polygon>`; }; const facesConfig = { back: { drawCell: (i: number, j: number, color: string) => { // 元々topとして描かれていた位置関係そのまま return drawSquare(180 + j * CELL_SIZE, i * CELL_SIZE, CELL_SIZE, color); } }, top: { drawCell: (i: number, j: number, color: string) => { const x = 180 + j * CELL_SIZE - i * (CELL_SIZE / 2); const y = 120 + i * (CELL_SIZE / 2); const dx = -CELL_SIZE / 2; const dy = CELL_SIZE / 2; const points: Array<[number, number]> = [ [x, y], [x + CELL_SIZE, y], [x + CELL_SIZE + dx, y + dy], [x + dx, y + dy], ]; return drawParallelogram(points, color); } }, left: { drawCell: (i: number, j: number, color: string) => { return drawSquare(j * CELL_SIZE, 180 + i * CELL_SIZE, CELL_SIZE, color); } }, front: { drawCell: (i: number, j: number, color: string) => { return drawSquare(120 + j * CELL_SIZE, 180 + i * CELL_SIZE, CELL_SIZE, color); } }, right: { drawCell: (i: number, j: number, color: string) => { const x = 240 + j * (CELL_SIZE / 2); const y = 180 + i * CELL_SIZE - j * (CELL_SIZE / 2); const dx = CELL_SIZE / 2; const dy = -CELL_SIZE / 2; const points: Array<[number, number]> = [ [x, y], [x + dx, y + dy], [x + dx, y + dy + CELL_SIZE], [x, y + CELL_SIZE], ]; return drawParallelogram(points, color); } }, bottom: { drawCell: (i: number, j: number, color: string) => { return drawSquare(120 + j * CELL_SIZE, 300 + i * CELL_SIZE, CELL_SIZE, color); } }, }; type Faces = { top: FaceData; left: FaceData; front: FaceData; bottom: FaceData; right: FaceData; back: FaceData }; const generateSVGContent = (size: number, faces: Faces) => { let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 420" class="rubik-cube">`; for (const [faceName, faceData] of Object.entries(faces) as [keyof Faces, FaceData][]) { const { drawCell } = facesConfig[faceName]; for (let i = 0; i < size; i++) { for (let j = 0; j < size; j++) { const color = mapColor(faceData[i][j]); svg += drawCell(i, j, color); } } } svg += `</svg>`; return svg; };

3D表示

"use client"; import React, { useRef, useEffect } from "react"; import * as THREE from "three"; import { OrbitControls, LineSegments2, LineSegmentsGeometry, LineMaterial } from "three-stdlib"; type Props = { code: string; }; const MarkdownRubikCode = ({ code }: Props) => { const mountRef = useRef<HTMLDivElement>(null); useEffect(() => { const mount = mountRef.current; const parent = mount?.parentElement; const width = parent?.offsetWidth || 0; const scene = new THREE.Scene(); scene.background = new THREE.Color(0xffffff); const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 1000); camera.position.set(6, 6, 6); const renderer = new THREE.WebGLRenderer({ antialias: true, }); renderer.setSize(width, width); mount?.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.enableZoom = false; const colorMap: { [key: string]: number } = { W: 0xffffff, R: 0xc41e3a, G: 0x009e60, B: 0x0051ba, O: 0xff5800, Y: 0xffd500, H: 0x808080, K: 0x000000, }; const cubeSize = 1; const parseCode = (code: string): Record<string, string[][]> => { const lines = code.trim().split("\n").map((line) => line.trim().split(" ")); const size = lines[0].length; const back = lines.slice(0, size); const top = lines.slice(size, size * 2); const left = lines.slice(size * 2, size * 3).map((row) => row.slice(0, size)); const front = lines.slice(size * 2, size * 3).map((row) => row.slice(size, size * 2)); const right = lines.slice(size * 2, size * 3).map((row) => row.slice(size * 2, size * 3)); const bottom = lines.slice(size * 3); return { top, front, right, back, left, bottom }; }; const { top, front, right, back, left, bottom } = parseCode(code); const createCube = (x: number, y: number, z: number, faces: string[]) => { const geometry = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize); const materials = [ new THREE.MeshBasicMaterial({ color: colorMap[faces[0]] || 0x808080 }), new THREE.MeshBasicMaterial({ color: colorMap[faces[1]] || 0x808080 }), new THREE.MeshBasicMaterial({ color: colorMap[faces[2]] || 0x808080 }), new THREE.MeshBasicMaterial({ color: colorMap[faces[3]] || 0x808080 }), new THREE.MeshBasicMaterial({ color: colorMap[faces[4]] || 0x808080 }), new THREE.MeshBasicMaterial({ color: colorMap[faces[5]] || 0x808080 }), ]; const cube = new THREE.Mesh(geometry, materials); const edges = new THREE.EdgesGeometry(geometry); const edgeGeometry = new LineSegmentsGeometry().fromEdgesGeometry(edges); const edgeMaterial = new LineMaterial({ color: 0x000000, linewidth: 4, }); edgeMaterial.resolution.set(window.innerWidth, window.innerHeight); const edgeLine = new LineSegments2(edgeGeometry, edgeMaterial); const group = new THREE.Group(); group.add(cube); group.add(edgeLine); group.position.set(x, y, z); return group; }; const size = top.length; const offset = (size - 1) / 2; const cubes = new THREE.Group(); for (let x = 0; x < size; x++) { for (let y = 0; y < size; y++) { for (let z = 0; z < size; z++) { const faces = [ x === size - 1 ? right[size - 1 - y][size - 1 - z] : "K", x === 0 ? left[size - 1 - y][z] : "K", y === size - 1 ? top[z][x] : "K", y === 0 ? bottom[size - 1 - z][x] : "K", z === size - 1 ? front[size - 1 - y][x] : "K", z === 0 ? back[y][x] : "K", ]; const cube = createCube(x - offset, y - offset, z - offset, faces); cubes.add(cube); } } } scene.add(cubes); const animate = () => { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); }; animate(); const handleResize = () => { if (mount && parent) { camera.aspect = 1; camera.updateProjectionMatrix(); renderer.setSize(width, width); } }; window.addEventListener("resize", handleResize); handleResize(); return () => { window.removeEventListener("resize", handleResize); mount?.removeChild(renderer.domElement); renderer.dispose(); }; }, [code]); return ( <div className="col-start-1 col-span-4 md:col-span-3 lg:col-span-3" > <div ref={mountRef} style={{ width: "100%" }} /> </div> ); }; export default MarkdownRubikCode;