小米mimo-v2.5-pro 3d 魔方简要测试

从上篇帖子继续 Deepseek v4 pro 3d魔方简要测试 测试环境 cherry studio, 上游是opencode go, 思考模式默认 思考时间 937.6s 测试结果 https://imgbed.snemc.cn/i/f5400f9b7e02.gif (图片大于 4 MB) 概要...
小米mimo-v2.5-pro 3d 魔方简要测试
小米mimo-v2.5-pro 3d 魔方简要测试

从上篇帖子继续 Deepseek v4 pro 3d魔方简要测试

测试环境 cherry studio, 上游是opencode go, 思考模式默认
思考时间 937.6s

测试结果

https://imgbed.snemc.cn/i/f5400f9b7e02.gif(图片大于 4 MB)

概要:
相当完美,鼠标操作底部那一排似乎有些转不过来

提示词:

你是一名精通图形学算法与 Web 交互的前端专家。请仅用一个 HTML 文件,基于 Three.js (ES Modules) 实现一个物理级高保真、支持自然手势的 3x3 魔方。
一、 交付规范
单文件架构:HTML/CSS/JS 必须合并在一个文件中。
依赖管理:必须通过 importmap 从 unpkg 或 cdn.skypack 引入 Three.js 及其 OrbitControls、Tween.js。
零素材依赖:禁止加载任何外部图片/贴图,所有材质纹理必须使用 HTML5 Canvas API 程序化动态生成。
二、 视觉与物理标准
模型构建:
场景需包含 27 个独立的小方块(Cubies)。
物理间隙:小方块之间必须保留微小的物理间距(Spacing),不可紧贴。
倒角质感:通过 Canvas 绘制带有圆角矩形(Rounded Rect)的贴纸纹理,模拟真实魔方的塑料黑边与贴纸高光效果。
光影环境:
必须开启 ShadowMap。
配置环境光(Ambient)与平行光(Directional),确保魔方有清晰的立体感和阴影投射。
三、 核心逻辑考点(数据结构与变换)
禁止维护复杂的 3D 状态数组,请使用基于“空间位置”的动态计算方案:
动态层级筛选:
不要写死索引。当需要旋转某一层时,遍历所有方块,根据其在世界坐标系(World Position)下的 x, y, z 值与阈值(Epsilon)来判断它是否属于当前旋转层。
Pivot 变换机制(关键考点):
实现旋转时,必须创建一个临时的 Pivot(轴心对象)。
核心API:使用 pivot.attach(object) 将选中的方块挂载到轴心,旋转轴心,动画结束后使用 scene.attach(object) 将方块放回场景。
作用:利用 attach 自动计算世界矩阵变换,避免手动处理复杂的四元数乘法。
坐标清洗:
每次旋转结束后,必须对所有方块的位置(Position)和旋转(Rotation)进行 Math.round() 取整处理,消除浮点数累积误差,防止魔方“散架”。
四、 交互系统考点(算法重难点)
这是区分初级与高级开发者的核心点,请实现类似原生 App 的自然手势体验:
操作分离:
左键拖拽:旋转魔方的某一层。
右键拖拽:旋转视角(OrbitControls)。
基于投影向量的手势识别算法:
射线检测:点击时获取被点击方块的“表面法线(Face Normal)”。
意图判断:
根据法线,锁定潜在的两个旋转轴(例如点击前面,潜在轴为 X 或 Y)。
将这两个 3D 轴的向量**投影(Project)**到 2D 屏幕空间。
计算用户鼠标滑动的 2D 向量与这两个投影向量的点积(Dot Product),选择匹配度最高的轴作为旋转轴。
方向修正与实时跟随:
实现1:1 实时跟手:鼠标移动多少像素,魔方层转动对应角度。
符号修正:通过叉乘(Cross Product)或投影符号判断,确保无论从魔方正面、背面还是顶面操作,鼠标向右划动始终对应“向右转”的视觉逻辑(解决方向反转 bug)。
磁吸效果:
松开鼠标后,自动计算最近的 90 度倍数,使用 Tween.js 播放回弹动画并对齐网格。
五、 代码质量
代码需包含清晰的注释,解释“手势投影算法”和“Pivot 挂载逻辑”。
具备 Scramble(打乱)和 Reset(重置)功能按钮。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3×3 魔方 — Three.js Physical-Grade Rubik's Cube</title>
<style>
  *{margin:0;padding:0;box-sizing:border-box}
  body{
    overflow:hidden;
    background:#0d0d1a;
    font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
  }
  canvas{display:block}
  #ui{
    position:fixed;bottom:30px;left:50%;transform:translateX(-50%);
    display:flex;gap:14px;z-index:10;
  }
  .btn{
    padding:12px 30px;font-size:15px;font-weight:600;
    border:2px solid rgba(255,255,255,.15);border-radius:14px;
    cursor:pointer;color:#fff;
    backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);
    background:rgba(255,255,255,.08);
    transition:all .3s cubic-bezier(.4,0,.2,1);
    letter-spacing:.5px;user-select:none;
  }
  .btn:hover{
    background:rgba(255,255,255,.16);border-color:rgba(255,255,255,.35);
    transform:translateY(-2px);box-shadow:0 10px 30px rgba(0,0,0,.4);
  }
  .btn:active{transform:translateY(0);box-shadow:0 4px 12px rgba(0,0,0,.3)}
  .btn.scramble{border-color:rgba(245,87,108,.5)}
  .btn.scramble:hover{background:rgba(245,87,108,.22)}
  .btn.reset{border-color:rgba(78,205,196,.5)}
  .btn.reset:hover{background:rgba(78,205,196,.22)}
  .btn:disabled{opacity:.4;pointer-events:none}
  #info{
    position:fixed;top:20px;width:100%;text-align:center;
    color:rgba(255,255,255,.45);font-size:13px;z-index:10;pointer-events:none;
  }
</style>
</head>
<body>
<div id="info">🖱️ 左键拖拽旋转层 · 右键拖拽旋转视角 · 滚轮缩放</div>
<div id="ui">
  <button class="btn scramble" id="btnScramble">🎲 打乱</button>
  <button class="btn reset" id="btnReset">🔄 重置</button>
</div>

<!-- ═══════════ Import Map: Three.js + OrbitControls ═══════════ -->
<script type="importmap">
{
  "imports": {
    "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
    "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
  }
}
</script>

<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

/* ════════════════════════════════════════════════════════════════
   Section 1 · 轻量 Tween 引擎(模拟 Tween.js API)
   ════════════════════════════════════════════════════════════════ */
const _tweens = [];

class Tween {
  constructor(obj) {
    this._o = obj;          // 被驱动的对象
    this._s = {};           // 起始值快照
    this._e = {};           // 目标值
    this._d = 300;          // 持续时间 ms
    this._ef = t => t;      // 缓动函数
    this._uc = null;        // onUpdate 回调
    this._cc = null;        // onComplete 回调
    this._t0 = 0;           // 起始时间戳
    this._r = false;        // 是否运行中
  }
  to(target, dur) { this._e = target; if (dur != null) this._d = dur; return this; }
  easing(fn) { this._ef = fn; return this; }
  onUpdate(fn) { this._uc = fn; return this; }
  onComplete(fn) { this._cc = fn; return this; }
  start() {
    for (const k in this._e) this._s[k] = this._o[k];
    this._t0 = performance.now();
    this._r = true;
    _tweens.push(this);
    return this;
  }
  _tick(now) {
    if (!this._r) return false;
    const t = Math.min((now - this._t0) / this._d, 1);
    const e = this._ef(t);
    for (const k in this._e)
      this._o[k] = this._s[k] + (this._e[k] - this._s[k]) * e;
    if (this._uc) this._uc(this._o);
    if (t >= 1) { this._r = false; if (this._cc) this._cc(); return false; }
    return true;
  }
}

function tickTweens() {
  const n = performance.now();
  for (let i = _tweens.length - 1; i >= 0; i--)
    if (!_tweens[i]._tick(n)) _tweens.splice(i, 1);
}

const easeOutCubic   = t => 1 - Math.pow(1 - t, 3);
const easeInOutCubic = t => t < .5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2;

/* ════════════════════════════════════════════════════════════════
   Section 2 · 常量 & 配色
   ════════════════════════════════════════════════════════════════ */
const GAP     = 1;              // 方块中心距
const SPACING = 0.08;           // 物理间隙
const SIZE    = GAP - SPACING;  // 小方块视觉边长
const EPS     = 0.1;            // 层判断容差
const HPI     = Math.PI / 2;    // 90°

const FACE_COLORS = {
  R: '#B71234',   // +X  红
  L: '#FF5800',   // -X  橙
  U: '#FFFFFF',   // +Y  白
  D: '#FFD500',   // -Y  黄
  F: '#009B48',   // +Z  绿
  B: '#0046AD',   // -Z  蓝
};

/* ════════════════════════════════════════════════════════════════
   Section 3 · Canvas API 程序化纹理(零外部图片)
   ════════════════════════════════════════════════════════════════ */

/** 绘制圆角矩形路径 */
function roundRect(ctx, x, y, w, h, r) {
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.arcTo(x + w, y,     x + w, y + h, r);
  ctx.arcTo(x + w, y + h, x,     y + h, r);
  ctx.arcTo(x,     y + h, x,     y,     r);
  ctx.arcTo(x,     y,     x + w, y,     r);
  ctx.closePath();
}

/**
 * 生成贴纸纹理
 * 层次:黑塑料底 → 阴影凹槽 → 彩色圆角贴纸 → 多层渐变高光
 */
function makeSticker(color) {
  const S = 256;
  const cvs = document.createElement('canvas');
  cvs.width = cvs.height = S;
  const c = cvs.getContext('2d');

  // ① 黑色塑料底座
  c.fillStyle = '#111';
  c.fillRect(0, 0, S, S);

  const m = 18, r = 24;

  // ② 贴纸外缘阴影(模拟凹槽)
  roundRect(c, m - 3, m - 3, S - 2*m + 6, S - 2*m + 6, r + 3);
  c.fillStyle = 'rgba(0,0,0,0.35)';
  c.fill();

  // ③ 彩色圆角贴纸
  roundRect(c, m, m, S - 2*m, S - 2*m, r);
  c.fillStyle = color;
  c.fill();

  // ④ 对角线渐变高光(模拟真实光泽)
  roundRect(c, m, m, S - 2*m, S - 2*m, r);
  const g1 = c.createLinearGradient(m, m, S - m, S - m);
  g1.addColorStop(0,   'rgba(255,255,255,0.30)');
  g1.addColorStop(0.4, 'rgba(255,255,255,0.06)');
  g1.addColorStop(0.6, 'rgba(0,0,0,0)');
  g1.addColorStop(1,   'rgba(0,0,0,0.20)');
  c.fillStyle = g1;
  c.fill();

  // ⑤ 顶部高光条(模拟边缘反光)
  roundRect(c, m, m, S - 2*m, S - 2*m, r);
  const g2 = c.createLinearGradient(m, m, m, m + 55);
  g2.addColorStop(0, 'rgba(255,255,255,0.28)');
  g2.addColorStop(1, 'rgba(255,255,255,0)');
  c.fillStyle = g2;
  c.fill();

  const tex = new THREE.CanvasTexture(cvs);
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}

/** 纯黑内部纹理 */
function makeBlackTex() {
  const c = document.createElement('canvas');
  c.width = c.height = 8;
  const ctx = c.getContext('2d');
  ctx.fillStyle = '#111';
  ctx.fillRect(0, 0, 8, 8);
  return new THREE.CanvasTexture(c);
}

// 预生成所有纹理
const TEX = {};
for (const [k, v] of Object.entries(FACE_COLORS)) TEX[k] = makeSticker(v);
const TEX_BLACK = makeBlackTex();

/* ════════════════════════════════════════════════════════════════
   Section 4 · 场景 / 相机 / 渲染器 / 光照
   ════════════════════════════════════════════════════════════════ */

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);

const camera = new THREE.PerspectiveCamera(40, innerWidth / innerHeight, 0.1, 100);
camera.position.set(5.5, 4.5, 5.5);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
document.body.appendChild(renderer.domElement);

// ── 光照:环境光 + 主平行光(ShadowMap)+ 补光 ──
scene.add(new THREE.AmbientLight(0xffffff, 0.5));

const sun = new THREE.DirectionalLight(0xffffff, 1.5);
sun.position.set(5, 8, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 0.5;
sun.shadow.camera.far  = 30;
sun.shadow.camera.left   = -5;
sun.shadow.camera.right  =  5;
sun.shadow.camera.top    =  5;
sun.shadow.camera.bottom = -5;
sun.shadow.bias = -0.0001;
scene.add(sun);

const fill = new THREE.DirectionalLight(0x8888ff, 0.3);
fill.position.set(-4, 3, -5);
scene.add(fill);

const rim = new THREE.DirectionalLight(0xffffff, 0.2);
rim.position.set(0, -3, -6);
scene.add(rim);

// ── 地面(ShadowMaterial 接收阴影)──
const ground = new THREE.Mesh(
  new THREE.PlaneGeometry(20, 20),
  new THREE.ShadowMaterial({ opacity: 0.25 })
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -2.2;
ground.receiveShadow = true;
scene.add(ground);

/* ════════════════════════════════════════════════════════════════
   Section 5 · OrbitControls(右键旋转视角)
   ════════════════════════════════════════════════════════════════ */

const orbit = new OrbitControls(camera, renderer.domElement);
orbit.enableDamping  = true;
orbit.dampingFactor  = 0.08;
orbit.enablePan      = false;
orbit.minDistance     = 4;
orbit.maxDistance     = 15;
// 左键 → undefined(交给魔方交互),中键 → 缩放,右键 → 轨道旋转
orbit.mouseButtons   = { LEFT: undefined, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE };

renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());

/* ════════════════════════════════════════════════════════════════
   Section 6 · 构建 27 个独立 Cubies
   ════════════════════════════════════════════════════════════════ */

const cubies = [];
const cubieGeo = new THREE.BoxGeometry(SIZE, SIZE, SIZE); // 共享几何体

/**
 * 为指定 (x,y,z) 位置的 cubie 生成六面材质
 * BoxGeometry 面序:[+X, -X, +Y, -Y, +Z, -Z]
 */
function cubieMaterials(x, y, z) {
  return [
    x ===  1 ? TEX.R : TEX_BLACK,
    x === -1 ? TEX.L : TEX_BLACK,
    y ===  1 ? TEX.U : TEX_BLACK,
    y === -1 ? TEX.D : TEX_BLACK,
    z ===  1 ? TEX.F : TEX_BLACK,
    z === -1 ? TEX.B : TEX_BLACK,
  ].map(t => new THREE.MeshStandardMaterial({ map: t, roughness: 0.35, metalness: 0 }));
}

// 生成 3×3×3 = 27 个方块
for (let x = -1; x <= 1; x++)
for (let y = -1; y <= 1; y++)
for (let z = -1; z <= 1; z++) {
  const mesh = new THREE.Mesh(cubieGeo, cubieMaterials(x, y, z));
  mesh.position.set(x * GAP, y * GAP, z * GAP);
  mesh.castShadow    = true;
  mesh.receiveShadow = true;
  scene.add(mesh);
  cubies.push(mesh);
}

/* ════════════════════════════════════════════════════════════════
   Section 7 · 坐标清洗 — 消除浮点累积误差
   ════════════════════════════════════════════════════════════════ */

/**
 * 每次旋转结束后调用:
 * 1. 位置四舍五入到整数(GAP=1)
 * 2. 旋转矩阵元素四舍五入到 {-1, 0, 1}
 *    → 保证后续层判断精确匹配
 */
function cleanCubies() {
  const _mat = new THREE.Matrix4();
  for (const c of cubies) {
    // 位置取整
    c.position.set(
      Math.round(c.position.x),
      Math.round(c.position.y),
      Math.round(c.position.z)
    );
    // 旋转矩阵取整
    c.updateMatrix();
    _mat.copy(c.matrix);
    const e = _mat.elements;
    for (let i = 0; i < 16; i++) e[i] = Math.round(e[i]);
    c.quaternion.setFromRotationMatrix(_mat);
  }
}

/* ════════════════════════════════════════════════════════════════
   Section 8 · 工具函数
   ════════════════════════════════════════════════════════════════ */

/** 将方向向量吸附到最近的主轴 (±X, ±Y, ±Z) */
function snapToAxis(v) {
  const ax = Math.abs(v.x), ay = Math.abs(v.y), az = Math.abs(v.z);
  if (ax >= ay && ax >= az) return new THREE.Vector3(Math.sign(v.x), 0, 0);
  if (ay >= ax && ay >= az) return new THREE.Vector3(0, Math.sign(v.y), 0);
  return new THREE.Vector3(0, 0, Math.sign(v.z));
}

/** 给定面法线 → 返回与其垂直的两个主轴 */
function perpAxes(n) {
  if (Math.abs(n.x) > 0.5) return [new THREE.Vector3(0,1,0), new THREE.Vector3(0,0,1)];
  if (Math.abs(n.y) > 0.5) return [new THREE.Vector3(1,0,0), new THREE.Vector3(0,0,1)];
  return [new THREE.Vector3(1,0,0), new THREE.Vector3(0,1,0)];
}

/** 3D 轴向量 → 轴名字符 */
function axName(v) {
  if (Math.abs(v.x) > 0.5) return 'x';
  if (Math.abs(v.y) > 0.5) return 'y';
  return 'z';
}

/** 3D 世界点 → 屏幕像素坐标 */
function toScreen(p) {
  const v = p.clone().project(camera);
  return new THREE.Vector2(
    (v.x + 1) * 0.5 * innerWidth,
    (1 - v.y) * 0.5 * innerHeight
  );
}

/** 将 3D 方向向量投影为屏幕像素方向 */
function screenDir(dir3, origin3) {
  const a = toScreen(origin3);
  const b = toScreen(origin3.clone().add(dir3));
  return new THREE.Vector2(b.x - a.x, b.y - a.y);
}

/* ════════════════════════════════════════════════════════════════
   Section 9 · 旋转状态机
   ════════════════════════════════════════════════════════════════ */

let interacting = false;    // 鼠标左键按下追踪中
let animating   = false;    // 补间动画播放中
let scrambling  = false;    // 打乱序列执行中

let pivot    = null;        // 临时轴心 Object3D
let rotAx    = null;        // 当前旋转轴 'x'|'y'|'z'
let rotLayer = 0;           // 当前层号 -1|0|1
let angle    = 0;           // 累计旋转弧度
let mouse0   = { x: 0, y: 0 };

// ── 手势预计算缓存(初始化旋转时计算一次)──
let gDir  = null;           // 屏幕空间旋转方向(归一化 2D 向量)
let gRate = 1;              // 灵敏度:弧度 / 像素

// ── 点击信息缓存 ──
let clickNormal = null;     // 点击面法线(已吸附主轴)
let clickPoint  = null;     // 点击的世界坐标
let clickCubie  = null;     // 被点击的 cubie Mesh

const rc = new THREE.Raycaster();

/* ════════════════════════════════════════════════════════════════
   Section 10 · 交互事件处理
   ════════════════════════════════════════════════════════════════ */

function ndc(e) {
  return new THREE.Vector2(
    (e.clientX / innerWidth) * 2 - 1,
   -(e.clientY / innerHeight) * 2 + 1
  );
}

// ─── pointerdown ───
renderer.domElement.addEventListener('pointerdown', e => {
  if (animating || scrambling || e.button !== 0) return;

  rc.setFromCamera(ndc(e), camera);
  const hits = rc.intersectObjects(cubies);
  if (!hits.length) return;

  const hit = hits[0];
  clickCubie  = hit.object;
  // 将局部法线转换到世界空间并吸附到主轴
  clickNormal = snapToAxis(hit.face.normal.clone().transformDirection(clickCubie.matrixWorld));
  clickPoint  = hit.point.clone();
  mouse0      = { x: e.clientX, y: e.clientY };
  angle       = 0;
  pivot       = null;
  rotAx       = null;
  interacting = true;
  orbit.enabled = false; // 暂时禁用轨道控制,避免冲突
});

// ─── pointermove ───
renderer.domElement.addEventListener('pointermove', e => {
  if (!interacting || animating) return;

  const dx = e.clientX - mouse0.x;
  const dy = e.clientY - mouse0.y;

  // 阈值防误触:拖动不足 10px 不触发旋转
  if (!pivot && dx * dx + dy * dy < 100) return;

  // 首次超过阈值 → 初始化层旋转
  if (!pivot) initLayerRotation(dx, dy);
  if (!pivot) return;

  // ── 实时跟手:将鼠标总位移投影到旋转屏幕方向 ──
  const proj = dx * gDir.x + dy * gDir.y; // 像素分量
  angle = proj * gRate;                    // 转为弧度
  pivot.rotation[rotAx] = angle;
});

// ─── pointerup ───
renderer.domElement.addEventListener('pointerup', e => {
  if (e.button !== 0) return;
  interacting = false;

  if (pivot) {
    // 磁吸效果:计算最近的 90° 倍数
    const target = Math.round(angle / HPI) * HPI;
    animating = true;
    new Tween({ a: angle })
      .to({ a: target }, 200)
      .easing(easeOutCubic)
      .onUpdate(o => { pivot.rotation[rotAx] = o.a; })
      .onComplete(() => releasePivot())
      .start();
  } else {
    orbit.enabled = true; // 未触发旋转,恢复轨道控制
  }
});

/* ════════════════════════════════════════════════════════════════
   Section 11 · 核心手势识别算法 & Pivot 变换机制

   ┌─────────────────────────────────────────────────────────────┐
   │  算法概览:                                                  │
   │  1. 由面法线确定两个潜在旋转轴(均与法线垂直)                   │
   │  2. 计算每个轴的「正旋转切线方向」 = axis × normal              │
   │  3. 将两条切线投影到 2D 屏幕空间                                │
   │  4. 鼠标拖拽方向与两条投影做点积 → 选匹配度最高的轴               │
   │  5. 用数值微分法计算该轴的屏幕灵敏度(像素/弧度)                  │
   │  6. 创建 Pivot → attach() 挂载层内方块 → 旋转 → detach         │
   └─────────────────────────────────────────────────────────────┘
   ════════════════════════════════════════════════════════════════ */

/**
 * 初始化层旋转
 * 在首次拖动超过阈值时调用,一次性完成:
 * - 选择旋转轴和层
 * - 预计算屏幕灵敏度
 * - 创建 Pivot 并挂载 cubies
 */
function initLayerRotation(dx, dy) {
  const [ax1, ax2] = perpAxes(clickNormal);

  // 正旋转切线方向 = axis × faceNormal
  // 物理含义:该方向是「正角速度旋转时,面表面的运动方向」
  const tan1 = new THREE.Vector3().crossVectors(ax1, clickNormal);
  const tan2 = new THREE.Vector3().crossVectors(ax2, clickNormal);

  // 投影到屏幕像素空间
  const sTan1 = screenDir(tan1, clickPoint);
  const sTan2 = screenDir(tan2, clickPoint);

  // 拖拽方向(归一化)
  const drag = new THREE.Vector2(dx, dy).normalize();

  // 点积比较:选择与拖拽方向更匹配的轴
  const dot1 = Math.abs(drag.dot(sTan1.clone().normalize()));
  const dot2 = Math.abs(drag.dot(sTan2.clone().normalize()));

  const chosen = dot1 >= dot2 ? ax1 : ax2;
  rotAx = axName(chosen);

  // 动态计算层号:根据被点击 cubie 在旋转轴上的世界坐标
  rotLayer = Math.round(clickCubie.position[rotAx] / GAP);

  // ── 数值微分法:计算屏幕灵敏度(像素/弧度)──
  // 对点击点做微小旋转,观察屏幕位移
  const eps = 0.01;
  const s0  = toScreen(clickPoint);
  const sp  = toScreen(clickPoint.clone().applyAxisAngle(chosen, eps));
  const deriv = new THREE.Vector2(sp.x - s0.x, sp.y - s0.y).divideScalar(eps);
  // deriv 的方向 = 正旋转在屏幕上的运动方向
  // deriv 的长度 = 像素/弧度

  gDir  = deriv.clone().normalize();
  gRate = 1 / deriv.length(); // 弧度/像素

  // 轴几乎垂直于屏幕(看不到旋转)→ 不触发
  if (deriv.length() < 0.5) return;

  // ── Pivot 变换机制(关键考点)──
  pivot = new THREE.Object3D(); // 临时轴心,位于世界原点
  scene.add(pivot);

  for (const c of cubies) {
    // 基于世界坐标动态筛选层,不依赖固定索引
    if (Math.abs(c.position[rotAx] / GAP - rotLayer) < EPS) {
      pivot.attach(c);
      // attach() 自动计算并保持 cubie 的世界变换
      // cubie 成为 pivot 的子节点,后续随 pivot 旋转
    }
  }
}

/** 释放 Pivot:将方块归还场景 + 坐标清洗 */
function releasePivot() {
  if (!pivot) return;

  // 将所有子对象挂回场景(自动保持旋转后的世界变换)
  const kids = [...pivot.children]; // 复制数组,因为 attach 会修改 children
  for (const c of kids) scene.attach(c);

  cleanCubies();   // 坐标清洗:消除浮点误差
  scene.remove(pivot);
  pivot = null;
  rotAx = null;
  animating = false;
  orbit.enabled = true;
}

/* ════════════════════════════════════════════════════════════════
   Section 12 · 打乱(Scramble)& 重置(Reset)
   ════════════════════════════════════════════════════════════════ */

/** 通用层旋转(供 scramble 调用) */
function doRotation(ax, layer, ang, dur) {
  return new Promise(resolve => {
    const p = new THREE.Object3D();
    scene.add(p);
    for (const c of cubies)
      if (Math.abs(c.position[ax] / GAP - layer) < EPS) p.attach(c);

    animating = true;
    new Tween({ a: 0 })
      .to({ a: ang }, dur)
      .easing(easeInOutCubic)
      .onUpdate(o => { p.rotation[ax] = o.a; })
      .onComplete(() => {
        for (const c of [...p.children]) scene.attach(c);
        cleanCubies();
        scene.remove(p);
        animating = false;
        resolve();
      })
      .start();
  });
}

async function scrambleCube() {
  if (animating || scrambling || interacting) return;
  scrambling = true;
  orbit.enabled = false;
  document.getElementById('btnScramble').disabled = true;
  document.getElementById('btnReset').disabled   = true;

  const AXES   = ['x', 'y', 'z'];
  const LAYERS = [-1, 0, 1];
  let lastA = '', lastL = -99;

  for (let i = 0; i < 20; i++) {
    let a, l;
    // 避免连续旋转同一层(无意义)
    do { a = AXES[Math.random() * 3 | 0]; l = LAYERS[Math.random() * 3 | 0]; }
    while (a === lastA && l === lastL);

    const dir = (Math.random() < 0.5 ? 1 : -1) * HPI;
    await doRotation(a, l, dir, Math.max(40, 100 - 3 * i));
    lastA = a; lastL = l;
  }

  scrambling = false;
  orbit.enabled = true;
  document.getElementById('btnScramble').disabled = false;
  document.getElementById('btnReset').disabled   = false;
}

function resetCube() {
  if (animating || scrambling || interacting) return;
  let i = 0;
  for (let x = -1; x <= 1; x++)
  for (let y = -1; y <= 1; y++)
  for (let z = -1; z <= 1; z++) {
    const c = cubies[i++];
    c.position.set(x * GAP, y * GAP, z * GAP);
    c.quaternion.identity();
  }
}

document.getElementById('btnScramble').addEventListener('click', scrambleCube);
document.getElementById('btnReset').addEventListener('click', resetCube);

/* ════════════════════════════════════════════════════════════════
   Section 13 · 窗口自适应 & 渲染循环
   ════════════════════════════════════════════════════════════════ */

addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});

(function loop() {
  requestAnimationFrame(loop);
  tickTweens();
  orbit.update();
  renderer.render(scene, camera);
})();

</script>
</body>
</html>

模型的额外输出

image

1 个帖子 - 1 位参与者

阅读完整话题

来源: linux.do查看原文