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

2026-04-29 10:303阅读0评论SEO教程
  • 内容介绍
  • 文章标签
  • 相关推荐
问题描述:

从上篇帖子继续 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>

模型的额外输出
image851×1068 77.8 KB

网友解答:
--【壹】--:

从上篇帖子继续 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>

模型的额外输出
image851×1068 77.8 KB

问题描述:

从上篇帖子继续 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>

模型的额外输出
image851×1068 77.8 KB

网友解答:
--【壹】--:

从上篇帖子继续 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>

模型的额外输出
image851×1068 77.8 KB