4542 字
23 分钟
Three.js 极简 3D 魔方

前言#

起初是因为懒得背公式,想尝试从本质(降群法,交换子)出发还原魔方,然后又觉得这是一个可计算的问题,利用编程思维学习还原魔方应该有点帮助,最后发现三阶魔方的状态就已经多达 4.325 千亿亿,这实在太多了,一般算法的时间复杂度根本无法满足…

但是博客文章的 markdown 居然可以用 iframe 嵌入页面,如果做一个赛博魔方嵌入到文章里,这也太酷了吧

魔方定义#

由 26 个小立方体(6 个中心块、12 个棱块和 8 个角块)组成的正方体

问题拆解#

生成 26 个块#

生成 26 个 Mesh 就可以了,AI 建议共用一个 BoxGeometry 节省性能开销,每个块的边长为 0.98,留出 0.02 的空隙作为魔方的间隙

const geometry = new THREE.BoxGeometry(0.98, 0.98, 0.98);

初始化块的位置#

Three.js 中 mesh.position 默认就是物体的几何中心点位置

一般把中心块放在 (0, 0, 0) 坐标中心,其他块的坐标都可以简单通过中心块进行平移推导出来,把 x,y,z 三个轴的边界值找出来,分别是从 -1 到 1,通过三层循环遍历就可以生成出来

const CUBE = new THREE.Group();
// 块颜色
const material = new THREE.MeshBasicMaterial({ color: 0x0000ff });
for (let x = -1; x <= 1; x++) {
for (let y = -1; y <= 1; y++) {
for (let z = -1; z <= 1; z++) {
// 网格
const cubelet = new THREE.Mesh(geometry, materials);
cubelet.position.set(x, y, z);
CUBE.add(cubelet);
}
}
}

虽然已经生成出三阶魔方,但我们还可以进一步推广生成二阶、n 阶魔方

已知:

  • 不存在一阶魔方
  • 奇数阶的魔方有中心块
  • 偶数阶的魔方没有中心块

如果魔方没有中心块,相当于中心块被压缩成坐标原点,因为 position 默认是物体的几何中心,所以是 x,y,z 轴,每个坐标都减去一个块边长的一半,随便找个块的坐标进行推算

二阶坐标 (0.5, 0.5, 0.5)
三阶坐标 (1, 1, 1)
四阶坐标 (1.5, 1.5, 1.5)
五阶坐标 (2, 2, 2)
...

抽象一下,写成块的边长通用公式

// num 是阶数
function order(num) {
return (num - 1) / 2;
}

初始化块面的颜色#

Mesh 的第二个材质参数可以传递数组设置每个面的颜色,创建六个面的 MeshPhongMaterial 材质颜色数组,在创建 Mesh 时应用

// 颜色:红(R),橙(L),白(U),黄(D),绿(B),蓝(F)
const COLORS = ["#fb3636", "#ff9351", "#ffffff", "#fade70", "#51acfa", "#9de16f"];
let materials = [];
for (let color of COLORS) {
// 材质
const material = new THREE.MeshPhongMaterial({ color: color });
materials.push(material);
}
// ...
const cubelet = new THREE.Mesh(geometry, materials);

实现旋转#

魔方是按照某一层进行旋转的,例如 R 层旋转的时候,属于 R 层的所有 Mesh 都要跟着旋转,所以需要对所有属于某一层的 Mesh 进行标记

魔方分别存在 U、D、F、B、R、L、M、S、E 层,其中 M、S、E 是中间层,如果是高阶魔方,需要通过 M0,M1,M2…进行区别标记

如何识别出 Mesh 属于哪一层?

  • 可以通过坐标识别
    • x 轴等于边长是 R 层,等于负边长是 L 层,否则就是 M 层
    • y 轴等于边长是 U 层,等于负边长是 D 层,否则就是 E 层
    • z 轴等于边长是 F 层,等于负边长是 B 层,否则就是 S 层

这样每次某一层旋转完后,重新通过这个规则对刚才旋转过的 Mesh 坐标判断,设置旋转状态标记就可以了

function setCubeMark(mesh) {
if (mesh.position.x === LEN) {
mesh.userData.rotationMark.x = 'R';
} else if (mesh.position.x === -LEN) {
mesh.userData.rotationMark.x = 'L';
} else {
mesh.userData.rotationMark.x = (N > 3) ? 'M' + Math.abs(mesh.position.x - LEN) : 'M';
}
if (mesh.position.y === LEN) {
mesh.userData.rotationMark.y = 'U';
} else if (mesh.position.y === -LEN) {
mesh.userData.rotationMark.y = 'D';
} else {
mesh.userData.rotationMark.y = (N > 3) ? 'E' + Math.abs(mesh.position.y - LEN) : 'E';
}
if (mesh.position.z === LEN) {
mesh.userData.rotationMark.z = 'F';
} else if (mesh.position.z === -LEN) {
mesh.userData.rotationMark.z = 'B';
} else {
mesh.userData.rotationMark.z = (N > 3) ? 'S' + Math.abs(mesh.position.z - LEN) : 'S';
}
}

判断旋转方向#

这是最难的部分,跟 AI 聊了很久,尝试过几种方案,例如把拖拽向量投影到选中 mesh 的平面上,再通过叉积,点积等方式进行方向的判断,但效果达不到预期,其他方案实现起来又比较麻烦

后来在 github 上找开源项目的方案参考,看到一位 大佬的方案 突然眼前一亮,没想到可以用这么简单的方式实现,这还是在大学时候做的,如今在谷歌工作(不愧是大佬)很难想到比这个更简单的实现方案了,直接借鉴了其判断旋转方向部分的实现

大佬思路很简单,先预设六个面的法向量,分别和拖拽向量进行对比

  • 哪个夹角更小,就是往哪个轴的方向旋转
  • 然后通过鼠标点击 mesh 面的法向量判断具体是围绕哪个轴进行旋转

把核心的代码抽离出来简单改一下就可以判断出旋转方向

PS:大佬的源码中,x 轴旋转方向搞反了,在 Three.js 中旋转方向是根据右手坐标系确定,顺时针是负数,逆时针是正数

// 六个面的法向量
const XLine = new THREE.Vector3(1, 0, 0);
const XLineAd = new THREE.Vector3(-1, 0, 0);
const YLine = new THREE.Vector3(0, 1, 0);
const YLineAd = new THREE.Vector3(0, -1, 0);
const ZLine = new THREE.Vector3(0, 0, 1);
const ZLineAd = new THREE.Vector3(0, 0, -1);
// 鼠标点击面的法向量
let normalize;
/**
* 获取旋转轴方向
*/
function getDirection(vector3) {
let direction;
// 判断差向量和x、y、z轴的夹角
let xAngle = vector3.angleTo(XLine);
let xAngleAd = vector3.angleTo(XLineAd);
let yAngle = vector3.angleTo(YLine);
let yAngleAd = vector3.angleTo(YLineAd);
let zAngle = vector3.angleTo(ZLine);
let zAngleAd = vector3.angleTo(ZLineAd);
// 最小夹角
let minAngle = Math.min(xAngle, xAngleAd, yAngle, yAngleAd, zAngle, zAngleAd);
switch (minAngle) {
case xAngle:
direction = 0; // 向x轴正方向旋转90度(还要区分是绕z轴还是绕y轴)
if (normalize.equals(YLine)) {
direction = direction + 0.1; // 绕z轴顺时针
} else if (normalize.equals(YLineAd)) {
direction = direction + 0.2; // 绕z轴逆时针
} else if (normalize.equals(ZLine)) {
direction = direction + 0.3; // 绕y轴逆时针
} else {
direction = direction + 0.4; // 绕y轴顺时针
}
break;
case xAngleAd:
direction = 1; // 向x轴反方向旋转90度
if (normalize.equals(YLine)) {
direction = direction + 0.1; // 绕z轴逆时针
} else if (normalize.equals(YLineAd)) {
direction = direction + 0.2; // 绕z轴顺时针
} else if (normalize.equals(ZLine)) {
direction = direction + 0.3; // 绕y轴顺时针
} else {
direction = direction + 0.4; // 绕y轴逆时针
}
break;
case yAngle:
direction = 2; // 向y轴正方向旋转90度
if (normalize.equals(ZLine)) {
direction = direction + 0.1; // 绕x轴顺时针
} else if (normalize.equals(ZLineAd)) {
direction = direction + 0.2; // 绕x轴逆时针
} else if (normalize.equals(XLine)) {
direction = direction + 0.3; // 绕z轴逆时针
} else {
direction = direction + 0.4; // 绕z轴顺时针
}
break;
case yAngleAd:
direction = 3; // 向y轴反方向旋转90度
if (normalize.equals(ZLine)) {
direction = direction + 0.1; // 绕x轴逆时针
} else if (normalize.equals(ZLineAd)) {
direction = direction + 0.2; // 绕x轴顺时针
} else if (normalize.equals(XLine)) {
direction = direction + 0.3; // 绕z轴顺时针
} else {
direction = direction + 0.4; // 绕z轴逆时针
}
break;
case zAngle:
direction = 4; // 向z轴正方向旋转90度
if (normalize.equals(YLine)) {
direction = direction + 0.1; // 绕x轴逆时针
} else if (normalize.equals(YLineAd)) {
direction = direction + 0.2; // 绕x轴顺时针
} else if (normalize.equals(XLine)) {
direction = direction + 0.3; // 绕y轴顺时针
} else {
direction = direction + 0.4; // 绕y轴逆时针
}
break;
case zAngleAd:
direction = 5; // 向z轴反方向旋转90度
if (normalize.equals(YLine)) {
direction = direction + 0.1; // 绕x轴顺时针
} else if (normalize.equals(YLineAd)) {
direction = direction + 0.2; // 绕x轴逆时针
} else if (normalize.equals(XLine)) {
direction = direction + 0.3; // 绕y轴逆时针
} else {
direction = direction + 0.4; // 绕y轴顺时针
}
break;
default:
break;
}
return direction;
}
/**
* 根据 mark 和 旋转轴方向
* 找出需要旋转的 meshGroup 并执行旋转
*/
function findWhichOperation(marks, direction, elements) {
let mark;
let xyz;
// 负数-顺时针,正数-逆时针
let dir;
switch (direction) {
// 绕z轴顺时针
case 0.1:
case 1.2:
case 2.4:
case 3.3:
mark = marks['z'];
xyz = 'z';
dir = -1;
break;
// 绕z轴逆时针
case 0.2:
case 1.1:
case 2.3:
case 3.4:
mark = marks['z'];
xyz = 'z';
dir = 1;
break;
// 绕y轴顺时针
case 0.4:
case 1.3:
case 4.3:
case 5.4:
mark = marks['y'];
xyz = 'y';
dir = -1;
break;
// 绕y轴逆时针
case 1.4:
case 0.3:
case 4.4:
case 5.3:
mark = marks['y'];
xyz = 'y';
dir = 1;
break;
// 绕x轴顺时针
case 2.1:
case 3.2:
case 4.2:
case 5.1:
mark = marks['x'];
xyz = 'x';
dir = -1;
break;
// 绕x轴逆时针
case 2.2:
case 3.1:
case 4.1:
case 5.2:
mark = marks['x'];
xyz = 'x';
dir = 1;
break;
default:
break;
}
let meshGroup = elements.filter(e => e.userData.rotationMark[xyz] === mark);
groupRotation(meshGroup, xyz, dir);
}

旋转动画#

通过前面的判断,我们可以得到:

  • meshGroup 旋转的 mesh 层
  • xyz 旋转围绕的轴
  • dir 顺时针或逆时针

把 meshGroup 放到一个临时的旋转组 rotationGroup 中,通过 Quaternion 计算旋转角度进行插值动画

旋转完成后再把 rotationGroup 的 mesh 放回原来的 CUBE 组,add() 和 attach() 都会从原父对象的 children 中移除,所以要拷贝一份进行遍历,但 attach() 会保留子对象的世界坐标,add() 不会,所以使用 attach()

旋转后的坐标会有精度误差,可能出现 0.9999999999999998、1.0000000000000002 等情况,需要取整处理,最后再重新判断 mesh 的标记

PS: 之前整理草稿不知道怎么把这段给整没了,又重新写了一遍 QAQ

const line = getAroundLine(xyz);
startQuat = rotationGroup.quaternion.clone();
// 绕旋转轴90度
endQuat = new THREE.Quaternion().setFromAxisAngle(line, dir * Math.PI / 2).multiply(startQuat);
/**
* 旋转动画
*/
function animateRotation(time) {
// 根据动画时间计算插帧,范围[0~1]
const t = Math.min((time - startTime) / duration, 1);
if (rotating) {
rotationGroup.quaternion.slerpQuaternions(startQuat, endQuat, t);
if (t >= 1) {
// cube.attach 后 rotationGroup.children 会减少,所以拷贝一份遍历
let group = [...rotationGroup.children];
for (let mesh of group) {
// attach 可以保持视觉位置不变,add 会跳变
// 精度误差可能会改变坐标,所以先 attach,再更新坐标
CUBE.attach(mesh);
const worldPosition = new THREE.Vector3();
mesh.getWorldPosition(worldPosition);
// 旋转后的世界坐标是小数,需要取整处理
let position = fixWorldNormal(worldPosition);
mesh.position.set(position.x, position.y, position.z);
setCubeMark(mesh, false);
}
scene.remove(rotationGroup);
return;
}
}
requestAnimationFrame(animateRotation);
}

还原魔方#

通过 AI 和查阅资料,对比各种还原算法的实现方案,发现自己实现不太现实,比较简单的方案也是通过判断魔方的状态,然后去查“魔方公式库”,调用具体的旋转公式,相当于人类背公式的方式还原魔方,这样还原的步数也挺多,还不如直接调已经实现的算法库

我选择了大佬同款算法库 cubejs,根据文档说明,按照平面展开图的顺序读取魔方状态,用以下格式作为参数:

|************|
|*U1**U2**U3*|
|************|
|*U4**U5**U6*|
|************|
|*U7**U8**U9*|
|************|
************|************|************|************
*L1**L2**L3*|*F1**F2**F3*|*R1**R2**R3*|*B1**B2**B3*
************|************|************|************
*L4**L5**L6*|*F4**F5**F6*|*R4**R5**R6*|*B4**B5**B6*
************|************|************|************
*L7**L8**L9*|*F7**F8**F9*|*R7**R8**R9*|*B7**B8**B9*
************|************|************|************
|************|
|*D1**D2**D3*|
|************|
|*D4**D5**D6*|
|************|
|*D7**D8**D9*|
|************|
UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB

调算法库就只需要读取出当前魔方的状态,这也是第二个难点,怎么读取魔方的状态

旋转规则#

要读取魔方的状态,就需要在初始化的时候对每个 mesh 面进行标记,还要考虑旋转后标记的变化,这个变化理论上是有规律可循的,这里卡了我挺久,一直问 AI 加上不断调试代码,最后才找出规律,搞出合适的转换结构

旋转规则:

  • U 面只会绕 y 轴旋转
    • 逆时针旋转:U、D两面不变,F => R ,R => B,B => L, L => F
    • 顺时针旋转:U、D两面不变,R => F ,B => R,L => B, F => L
  • 其他面以此类推,中间层也同理

通过定义旋转变换规则的映射表,在初始化魔方时,标记每个 mesh 面的初始状态,每次旋转后,查映射表更新 mesh 面的最新标记

// 旋转变换规则
const ROTATION_RULES = {
U: { axis: 'y', '1': { U: 'U', D: 'D', F: 'R', R: 'B', B: 'L', L: 'F' }, '-1': { U: 'U', D: 'D', R: 'F', B: 'R', L: 'B', F: 'L' } },
D: { axis: 'y', '1': { U: 'U', D: 'D', F: 'L', L: 'B', B: 'R', R: 'F' }, '-1': { U: 'U', D: 'D', L: 'F', B: 'L', R: 'B', F: 'R' } },
F: { axis: 'z', '1': { F: 'F', B: 'B', U: 'L', L: 'D', D: 'R', R: 'U' }, '-1': { F: 'F', B: 'B', L: 'U', D: 'L', R: 'D', U: 'R' } },
B: { axis: 'z', '1': { B: 'B', F: 'F', U: 'R', R: 'D', D: 'L', L: 'U' }, '-1': { B: 'B', F: 'F', R: 'U', D: 'R', L: 'D', U: 'L' } },
L: { axis: 'x', '1': { L: 'L', R: 'R', U: 'F', F: 'D', D: 'B', B: 'U' }, '-1': { L: 'L', R: 'R', F: 'U', D: 'F', B: 'D', U: 'B' } },
R: { axis: 'x', '1': { R: 'R', L: 'L', U: 'B', B: 'D', D: 'F', F: 'U' }, '-1': { R: 'R', L: 'L', B: 'U', D: 'B', F: 'D', U: 'F' } },
M: { axis: 'x', '1': { U: 'F', F: 'D', D: 'B', B: 'U' }, '-1': { F: 'U', D: 'F', B: 'D', B: 'U' } },
E: { axis: 'y', '1': { F: 'L', L: 'B', B: 'R', R: 'F' }, '-1': { D: 'L', B: 'L', R: 'B', F: 'R' } },
S: { axis: 'z', '1': { U: 'L', L: 'D', D: 'R', R: 'U' }, '-1': { L: 'U', D: 'L', R: 'D', U: 'R' } }
};
// 在原来的旋转层标记基础上初始化魔方状态的标记
function setCubeMark(mesh, init) {
if (mesh.position.x === LEN) {
mesh.userData.rotationMark.x = 'R';
if (init) mesh.userData.facelet.x = 'R';
} else if (mesh.position.x === -LEN) {
mesh.userData.rotationMark.x = 'L';
if (init) mesh.userData.facelet.x = 'L';
} else {
mesh.userData.rotationMark.x = (N > 3) ? 'M' + Math.abs(mesh.position.x - LEN) : 'M';
if (init) mesh.userData.facelet.x = 'M';
}
if (mesh.position.y === LEN) {
mesh.userData.rotationMark.y = 'U';
if (init) mesh.userData.facelet.y = 'U';
} else if (mesh.position.y === -LEN) {
mesh.userData.rotationMark.y = 'D';
if (init) mesh.userData.facelet.y = 'D';
} else {
mesh.userData.rotationMark.y = (N > 3) ? 'E' + Math.abs(mesh.position.y - LEN) : 'E';
if (init) mesh.userData.facelet.y = 'E';
}
if (mesh.position.z === LEN) {
mesh.userData.rotationMark.z = 'F';
if (init) mesh.userData.facelet.z = 'F';
} else if (mesh.position.z === -LEN) {
mesh.userData.rotationMark.z = 'B';
if (init) mesh.userData.facelet.z = 'B';
} else {
mesh.userData.rotationMark.z = (N > 3) ? 'S' + Math.abs(mesh.position.z - LEN) : 'S';
if (init) mesh.userData.facelet.z = 'S';
}
}
/**
* 旋转后按照规则更新 mesh 的 facelet 标记
*/
function updateFacelet(mesh, xyz, dir) {
let mark = mesh.userData.rotationMark[xyz];
mark = (mark.length === 1) ? mark : mark[0];
let facelet = mesh.userData.facelet[xyz];
if (facelet) {
// 用旋转后的下一个标记覆盖
facelet = ROTATION_RULES[mark][dir][facelet];
}
// 绕 x 旋转 => z 和 y 互换
// 绕 y 旋转 => x 和 z 互换
// 绕 z 旋转 => x 和 y 互换
switch (xyz) {
case 'x': {
let temp = mesh.userData.facelet['z'];
mesh.userData.facelet['z'] = mesh.userData.facelet['y'];
mesh.userData.facelet['y'] = temp;
break;
}
case 'y': {
let temp = mesh.userData.facelet['x'];
mesh.userData.facelet['x'] = mesh.userData.facelet['z'];
mesh.userData.facelet['z'] = temp;
break;
}
case 'z': {
let temp = mesh.userData.facelet['y'];
mesh.userData.facelet['y'] = mesh.userData.facelet['x'];
mesh.userData.facelet['x'] = temp;
break;
}
default:
break;
}
}

读取魔方状态#

最后就筛选出每一层的 Mesh,按照平面展开图的 U1,U2,U3…的顺序规则读取每个面的状态标记,组合成 UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB 的形式调用算法库

// z 从小到大
// z 相同,x 从小到大
U_meshGroup.sort((a, b) => {
if (Math.abs(a.position.z - b.position.z) > 0) {
return a.position.z - b.position.z;
} else {
return a.position.x - b.position.x;
}
});
// y 从大到小
// y 相同,z 从大到小
R_meshGroup.sort((a, b) => {
if (Math.abs(a.position.y - b.position.y) > 0) {
return b.position.y - a.position.y;
} else {
return b.position.z - a.position.z;
}
});
// y 从大到小
// y 相同,x 从小到大
F_meshGroup.sort((a, b) => {
if (Math.abs(a.position.y - b.position.y) > 0) {
return b.position.y - a.position.y;
} else {
return a.position.x - b.position.x;
}
});
// z 从大到小
// z 相同,x 从小到大
D_meshGroup.sort((a, b) => {
if (Math.abs(a.position.z - b.position.z) > 0) {
return b.position.z - a.position.z;
} else {
return a.position.x - b.position.x;
}
});
// y 从大到小
// y 相同,z 从小到大
L_meshGroup.sort((a, b) => {
if (Math.abs(a.position.y - b.position.y) > 0) {
return b.position.y - a.position.y;
} else {
return a.position.z - b.position.z;
}
});
// y 从大到小
// y 相同,x 从大到小
B_meshGroup.sort((a, b) => {
if (Math.abs(a.position.y - b.position.y) > 0) {
return b.position.y - a.position.y;
} else {
return b.position.x - a.position.x;
}
});
let seq = [...U_meshGroup.map(mesh => mesh.userData.facelet['y']), ...R_meshGroup.map(mesh => mesh.userData.facelet['x']),
...F_meshGroup.map(mesh => mesh.userData.facelet['z']), ...D_meshGroup.map(mesh => mesh.userData.facelet['y']),
...L_meshGroup.map(mesh => mesh.userData.facelet['x']), ...B_meshGroup.map(mesh => mesh.userData.facelet['z'])].join('');

解析旋转命令#

最后就是解析算法库返回的解,返回的格式是: U L2 B2 U2 L2 B2 U' R2 U2 L2 U' B2 L R U' F L2 D' B2 D' U F'

  • L 表示 L 层顺时针旋转 90 度
  • L2 表示 L 层顺时针旋转 180 度
  • U’ 表示 U 层逆时针旋转

PS:cubejs 定义的顺时针是从每个面看,例如 L 面是从左往右看,和 Three.js 的右手坐标系相反

// 旋转指令缓存
const COMMAND_MAP = new Map();
function initCommand() {
// three.js 是根据右手坐标系确定顺时针方向,角度正数-逆时针,角度负数-顺时针
// cubejs 定义的顺时针是从每个面看,所以 L B D 的角度方向要相反
COMMAND_MAP.set("R", { xyz: 'x', dir: -1, mark: 'R' });
COMMAND_MAP.set("R'", { xyz: 'x', dir: 1, mark: 'R' });
COMMAND_MAP.set("L", { xyz: 'x', dir: 1, mark: 'L' });
COMMAND_MAP.set("L'", { xyz: 'x', dir: -1, mark: 'L' });
COMMAND_MAP.set("U", { xyz: 'y', dir: -1, mark: 'U' });
COMMAND_MAP.set("U'", { xyz: 'y', dir: 1, mark: 'U' });
COMMAND_MAP.set("D", { xyz: 'y', dir: 1, mark: 'D' });
COMMAND_MAP.set("D'", { xyz: 'y', dir: -1, mark: 'D' });
COMMAND_MAP.set("F", { xyz: 'z', dir: -1, mark: 'F' });
COMMAND_MAP.set("F'", { xyz: 'z', dir: 1, mark: 'F' });
COMMAND_MAP.set("B", { xyz: 'z', dir: 1, mark: 'B' });
COMMAND_MAP.set("B'", { xyz: 'z', dir: -1, mark: 'B' });
}
/**
* 解析魔方转动指令
*/
function parseCommand(commands) {
if (!commands) return;
if (rotating || interval) return;
console.log(commands);
// 解析命令 => 层 + 旋转方向 + 旋转轴
let count = 0;
let commandsArray = commands.split(' ');
interval = setInterval(() => {
let command = commandsArray[count];
count++;
let num = 1;
// 如果指令需要操作两次
if (command[command.length - 1] === '2') {
command = command.slice(0, -1);
num = 2;
}
if (COMMAND_MAP.has(command)) {
let commandInfo = COMMAND_MAP.get(command);
let meshGroup = CUBE.children.filter(e => e.userData.rotationMark[commandInfo.xyz] === commandInfo.mark);
startTime = performance.now();
groupRotation(meshGroup, commandInfo.xyz, commandInfo.dir * num);
} else {
console.error("can not parse: " + command);
}
if (count >= commandsArray.length) {
clearInterval(interval);
interval = null;
}
// 旋转太快 mesh 会重叠,必须大于 duration
}, duration + 100);
}

最后#

其实一个多月前就已经实现了,只是懒得写成文章,最近才忙完搬家的事情,一直想换一个好一点的居住环境,现在起床睁开眼睛就能看到阳光洒在被子上,这采光就很惬意,不禁让人想多躺一会,于是人也变得更懒了(不是,实体魔方还没还原呢!)

为什么要将问题拆解再一一解决,直接用 AI 写不好吗?

  • 初衷就是写着玩,通过 AI 直接生成就没意思了
  • 不同水平的人使用 AI 得到的效果不一样
  • 如果 AI 写的代码看不懂,只是实现了功能,无法发现隐藏的危险,在生产上使用会有风险

有句话说得好 把难题清清楚楚地写出来,便已经解决了一半

c-loop
/
simple-rubiks-cube
Waiting for api.github.com...
00K
0K
0K
Waiting...

参考资料:

Three.js 极简 3D 魔方
https://cloop.zone.id/posts/teach/simple-rubiks-cube/
作者
Cloop
发布于
2025-12-02
许可协议
CC BY-NC-SA 4.0