前言
起初是因为懒得背公式,想尝试从本质(降群法,交换子)出发还原魔方,然后又觉得这是一个可计算的问题,利用编程思维学习还原魔方应该有点帮助,最后发现三阶魔方的状态就已经多达 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 写的代码看不懂,只是实现了功能,无法发现隐藏的危险,在生产上使用会有风险
有句话说得好 把难题清清楚楚地写出来,便已经解决了一半
参考资料: