Deepseek v4 pro 3d魔方简要测试

提示词: 你是一名精通图形学算法与 Web 交互的前端专家。请仅用一个 HTML 文件,基于 Three.js (ES Modules) 实现一个物理级高保真、支持自然手势的 3x3 魔方。 一、 交付规范 单文件架构:HTML/CSS/JS 必须合并在一个文件中。 依赖管理:必须通过 import...
Deepseek v4 pro 3d魔方简要测试
Deepseek v4 pro 3d魔方简要测试

提示词:

你是一名精通图形学算法与 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(重置)功能按钮。

测试结果

https://imgbed.snemc.cn/i/2ba0883a5353.gif(图片大于 4 MB)
概述:
有颜色错乱和转动轴向错乱问题
打乱按钮无效
测试花费 0.6元
思考时间(不含输出答案时间) 346.7s
测试轮数: 1 轮

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>3x3 魔方 - 物理级高保真手势交互</title>
    <style>
        :root {
            --bg: #1a1a2e;
            --panel-bg: #16213e;
            --text: #e0e0e0;
            --accent: #e94560;
            --accent2: #0f3460;
            --gold: #d4a574;
        }

        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            background: var(--bg);
            font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
            overflow: hidden;
            height: 100vh;
            width: 100vw;
            user-select: none;
            -webkit-user-select: none;
            -webkit-tap-highlight-color: transparent;
            cursor: default;
        }

        #canvas-container {
            position: fixed;
            inset: 0;
            z-index: 1;
        }
        #canvas-container canvas {
            display: block;
        }
        #canvas-container.grabbing canvas {
            cursor: grabbing;
        }
        #canvas-container.right-drag canvas {
            cursor: grab;
        }

        /* 底部控制面板 */
        .control-panel {
            position: fixed;
            bottom: 28px;
            left: 50%;
            transform: translateX(-50%);
            z-index: 10;
            display: flex;
            gap: 16px;
            align-items: center;
        }
        .btn {
            padding: 12px 28px;
            border: none;
            border-radius: 30px;
            font-size: 15px;
            font-weight: 600;
            letter-spacing: 0.5px;
            cursor: pointer;
            transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
            backdrop-filter: blur(12px);
            -webkit-backdrop-filter: blur(12px);
            outline: none;
            position: relative;
            overflow: hidden;
        }
        .btn-scramble {
            background: rgba(233, 68, 96, 0.85);
            color: #fff;
            box-shadow: 0 8px 24px rgba(233, 68, 96, 0.35);
        }
        .btn-scramble:hover {
            background: rgba(245, 80, 108, 0.95);
            box-shadow: 0 12px 32px rgba(233, 68, 96, 0.5);
            transform: translateY(-3px);
        }
        .btn-scramble:active {
            transform: translateY(0);
            box-shadow: 0 4px 12px rgba(233, 68, 96, 0.3);
        }
        .btn-reset {
            background: rgba(255, 255, 255, 0.12);
            color: #e0e0e0;
            border: 1.5px solid rgba(255, 255, 255, 0.25);
            box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
        }
        .btn-reset:hover {
            background: rgba(255, 255, 255, 0.2);
            border-color: rgba(255, 255, 255, 0.45);
            box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
            transform: translateY(-3px);
        }
        .btn-reset:active {
            transform: translateY(0);
        }
        .btn:disabled {
            opacity: 0.45;
            cursor: not-allowed;
            pointer-events: none;
            transform: none !important;
        }

        /* 提示标签 */
        .hint-tag {
            position: fixed;
            top: 24px;
            left: 50%;
            transform: translateX(-50%);
            z-index: 10;
            background: rgba(0, 0, 0, 0.55);
            color: #ccc;
            font-size: 13px;
            padding: 8px 20px;
            border-radius: 20px;
            letter-spacing: 0.4px;
            pointer-events: none;
            transition: opacity 0.5s;
        }

        /* 响应式 */
        @media (max-width: 600px) {
            .btn {
                padding: 10px 20px;
                font-size: 13px;
                border-radius: 24px;
            }
            .control-panel {
                gap: 10px;
                bottom: 18px;
            }
            .hint-tag {
                font-size: 11px;
                top: 12px;
                padding: 6px 14px;
            }
        }
    </style>
</head>
<body>

    <div id="canvas-container"></div>
    <div class="hint-tag" id="hint-tag">🖱️ 左键拖拽旋转层 &nbsp;|&nbsp; 右键拖拽旋转视角 &nbsp;|&nbsp; 滚轮缩放</div>
    <div class="control-panel">
        <button class="btn btn-scramble" id="btn-scramble" title="随机打乱魔方">🔀 打乱</button>
        <button class="btn btn-reset" id="btn-reset" title="重置魔方到初始状态">↺ 重置</button>
    </div>

    <!-- Import Map:ES Modules 依赖 -->
    <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/",
                "@tweenjs/tween.js": "https://unpkg.com/@tweenjs/tween.js@20.0.0/dist/tween.esm.js"
            }
        }
    </script>

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

        // ==================== DOM 元素 ====================
        const container = document.getElementById('canvas-container');
        const hintTag = document.getElementById('hint-tag');
        const btnScramble = document.getElementById('btn-scramble');
        const btnReset = document.getElementById('btn-reset');

        // ==================== 常量与配置 ====================
        const CUBIE_SIZE = 0.9; // 小方块边长
        const GRID_SPACING = 1.0; // 格点间距
        const GAP = GRID_SPACING - CUBIE_SIZE; // 物理间隙 ≈ 0.1
        const EPSILON = 0.15; // 层级筛选阈值
        const ANGLE_PER_NDC = Math.PI; // NDC距离→弧度映射系数(1 NDC ≈ 180°)
        const MAGNETIC_DURATION = 280; // 磁吸动画时长(ms)
        const SCRAMBLE_STEPS = 20; // 打乱步数
        const SCRAMBLE_STEP_DURATION = 160; // 打乱每步动画时长(ms)

        // 魔方标准颜色(贴纸颜色)
        const STICKER_COLORS = {
            right: '#E53935', // +X 红
            left: '#FB8C00', // -X 橙
            top: '#FAFAFA', // +Y 白
            bottom: '#FDD835', // -Y 黄
            front: '#43A047', // +Z 绿
            back: '#1E88E5', // -Z 蓝
        };
        const PLASTIC_COLOR = '#1a1a1a'; // 塑料黑边颜色

        // ==================== Three.js 初始化 ====================
        const scene = new THREE.Scene();
        scene.background = new THREE.Color('#1a1a2e');
        scene.fog = new THREE.Fog('#1a1a2e', 6, 22);

        // 渲染器
        const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
        renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.shadowMap.enabled = true;
        renderer.shadowMap.type = THREE.PCFSoftShadowMap;
        renderer.toneMapping = THREE.ACESFilmicToneMapping;
        renderer.toneMappingExposure = 1.2;
        renderer.outputColorSpace = THREE.SRGBColorSpace;
        container.appendChild(renderer.domElement);
        const canvas = renderer.domElement;

        // 相机
        const camera = new THREE.PerspectiveCamera(48, window.innerWidth / window.innerHeight, 0.5, 40);
        camera.position.set(5.5, 3.8, 6.5);
        camera.lookAt(0, 0, 0);

        // OrbitControls —— 仅响应右键旋转 & 中键缩放
        const controls = new OrbitControls(camera, canvas);
        controls.target.set(0, 0, 0);
        controls.enableDamping = true;
        controls.dampingFactor = 0.12;
        controls.minDistance = 4;
        controls.maxDistance = 14;
        controls.maxPolarAngle = Math.PI * 0.75;
        // 关键:左键=null(留给魔方操作),中键=缩放,右键=旋转
        controls.mouseButtons = {
            LEFT: null,
            MIDDLE: THREE.MOUSE ? THREE.MOUSE.DOLLY : 1,
            RIGHT: THREE.MOUSE ? THREE.MOUSE.ROTATE : 0,
        };
        controls.touches = {
            ONE: THREE.TOUCH ? THREE.TOUCH.ROTATE : 0,
            TWO: THREE.TOUCH ? THREE.TOUCH.DOLLY_PAN : 1,
        };
        controls.update();

        // ==================== 光照与阴影 ====================
        // 环境光 —— 提供基础照明
        const ambientLight = new THREE.AmbientLight('#8899bb', 1.6);
        scene.add(ambientLight);

        // 半球光 —— 模拟天空/地面散射
        const hemiLight = new THREE.HemisphereLight('#bcc8e8', '#3a3040', 1.0);
        scene.add(hemiLight);

        // 主平行光 —— 投射阴影
        const sunLight = new THREE.DirectionalLight('#fffef5', 5.5);
        sunLight.position.set(8, 14, 6);
        sunLight.castShadow = true;
        sunLight.shadow.mapSize.width = 2048;
        sunLight.shadow.mapSize.height = 2048;
        sunLight.shadow.camera.near = 0.5;
        sunLight.shadow.camera.far = 50;
        sunLight.shadow.camera.left = -10;
        sunLight.shadow.camera.right = 10;
        sunLight.shadow.camera.top = 10;
        sunLight.shadow.camera.bottom = -10;
        sunLight.shadow.bias = -0.00025;
        sunLight.shadow.normalBias = 0.02;
        scene.add(sunLight);

        // 补光 —— 减少暗面过黑
        const fillLight = new THREE.DirectionalLight('#c8d0ff', 2.2);
        fillLight.position.set(-3, 2, -4);
        scene.add(fillLight);

        // ==================== 地面(接收阴影) ====================
        const groundGeo = new THREE.PlaneGeometry(20, 20);
        const groundMat = new THREE.MeshStandardMaterial({
            color: '#2a2a35',
            roughness: 0.65,
            metalness: 0.05,
        });
        const ground = new THREE.Mesh(groundGeo, groundMat);
        ground.position.y = -3.5;
        ground.rotation.x = -Math.PI / 2;
        ground.receiveShadow = true;
        ground.name = 'ground';
        scene.add(ground);

        // 微妙的网格辅助线(装饰)
        const gridHelper = new THREE.PolarGridHelper(9, 48, 32, 64, '#333344', '#222233');
        gridHelper.position.y = -3.49;
        scene.add(gridHelper);

        // ==================== 贴纸纹理生成(Canvas API) ====================
        /**
         * 使用 HTML5 Canvas 程序化生成带圆角矩形的高光贴纸纹理
         * @param {string} colorHex - 贴纸颜色
         * @returns {THREE.CanvasTexture}
         */
        function createStickerTexture(colorHex) {
            const size = 256;
            const canvas = document.createElement('canvas');
            canvas.width = size;
            canvas.height = size;
            const ctx = canvas.getContext('2d');

            // 1. 塑料黑底(模拟魔方塑料边框)
            ctx.fillStyle = PLASTIC_COLOR;
            ctx.fillRect(0, 0, size, size);

            // 2. 圆角矩形贴纸
            const margin = 22;
            const radius = 34;
            const x = margin;
            const y = margin;
            const w = size - margin * 2;
            const h = size - margin * 2;

            ctx.beginPath();
            ctx.moveTo(x + radius, y);
            ctx.lineTo(x + w - radius, y);
            ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
            ctx.lineTo(x + w, y + h - radius);
            ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
            ctx.lineTo(x + radius, y + h);
            ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
            ctx.lineTo(x, y + radius);
            ctx.quadraticCurveTo(x, y, x + radius, y);
            ctx.closePath();

            // 填充贴纸颜色
            ctx.fillStyle = colorHex;
            ctx.fill();

            // 3. 高光渐变(模拟贴纸表面的微妙光泽)
            const highlightGrad = ctx.createLinearGradient(x, y, x, y + h);
            highlightGrad.addColorStop(0, 'rgba(255,255,255,0.22)');
            highlightGrad.addColorStop(0.25, 'rgba(255,255,255,0.06)');
            highlightGrad.addColorStop(0.55, 'rgba(0,0,0,0.0)');
            highlightGrad.addColorStop(0.8, 'rgba(0,0,0,0.04)');
            highlightGrad.addColorStop(1, 'rgba(0,0,0,0.14)');
            ctx.fillStyle = highlightGrad;
            ctx.fill();

            // 4. 贴纸边缘微妙的暗线(模拟贴纸厚度)
            ctx.beginPath();
            ctx.moveTo(x + radius, y);
            ctx.lineTo(x + w - radius, y);
            ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
            ctx.lineTo(x + w, y + h - radius);
            ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
            ctx.lineTo(x + radius, y + h);
            ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
            ctx.lineTo(x, y + radius);
            ctx.quadraticCurveTo(x, y, x + radius, y);
            ctx.closePath();
            ctx.strokeStyle = 'rgba(0,0,0,0.18)';
            ctx.lineWidth = 2.5;
            ctx.stroke();

            const texture = new THREE.CanvasTexture(canvas);
            texture.colorSpace = THREE.SRGBColorSpace;
            texture.minFilter = THREE.LinearMipmapLinearFilter;
            texture.magFilter = THREE.LinearFilter;
            texture.generateMipmaps = true;
            return texture;
        }

        // 预生成6种颜色的贴纸纹理(缓存复用)
        const stickerTextureCache = {};
        for (const [key, colorHex] of Object.entries(STICKER_COLORS)) {
            stickerTextureCache[key] = createStickerTexture(colorHex);
        }

        // 塑料面材质(无贴纸,纯黑)
        const plasticMaterial = new THREE.MeshStandardMaterial({
            color: 0x1a1a1a,
            roughness: 0.55,
            metalness: 0.08,
        });

        // ==================== 构建魔方 ====================
        /**
         * 所有27个小方块的引用数组
         * @type {THREE.Mesh[]}
         */
        const allCubies = [];
        /**
         * 每个cubie的初始状态(用于Reset)
         * @type {Map<THREE.Mesh, {position: THREE.Vector3, rotation: THREE.Euler}>}
         */
        const initialStates = new Map();

        /**
         * 根据cubie的初始格点位置判断某个面是否外露
         * @param {number} gridX - 格点X坐标 (-1, 0, 1)
         * @param {number} gridY - 格点Y坐标 (-1, 0, 1)
         * @param {number} gridZ - 格点Z坐标 (-1, 0, 1)
         * @returns {Object} 6个面是否外露的布尔映射
         */
        function getExposedFaces(gridX, gridY, gridZ) {
            return {
                posX: Math.abs(gridX - 1) < 0.01, // +X面外露(右面,红)
                negX: Math.abs(gridX + 1) < 0.01, // -X面外露(左面,橙)
                posY: Math.abs(gridY - 1) < 0.01, // +Y面外露(顶面,白)
                negY: Math.abs(gridY + 1) < 0.01, // -Y面外露(底面,黄)
                posZ: Math.abs(gridZ - 1) < 0.01, // +Z面外露(前面,绿)
                negZ: Math.abs(gridZ + 1) < 0.01, // -Z面外露(后面,蓝)
            };
        }

        /**
         * 为单个cubie创建6个面的材质数组
         * BoxGeometry的面顺序:+X(0), -X(1), +Y(2), -Y(3), +Z(4), -Z(5)
         */
        function createCubieMaterials(gridX, gridY, gridZ) {
            const exposed = getExposedFaces(gridX, gridY, gridZ);
            const faceConfigs = [
                { exposed: exposed.posX, stickerKey: 'right' }, // +X → 右面红
                { exposed: exposed.negX, stickerKey: 'left' }, // -X → 左面橙
                { exposed: exposed.posY, stickerKey: 'top' }, // +Y → 顶面白
                { exposed: exposed.negY, stickerKey: 'bottom' }, // -Y → 底面黄
                { exposed: exposed.posZ, stickerKey: 'front' }, // +Z → 前面绿
                { exposed: exposed.negZ, stickerKey: 'back' }, // -Z → 后面蓝
            ];

            return faceConfigs.map((config) => {
                if (config.exposed) {
                    const tex = stickerTextureCache[config.stickerKey];
                    return new THREE.MeshStandardMaterial({
                        map: tex,
                        roughness: 0.32,
                        metalness: 0.02,
                        color: 0xffffff,
                    });
                } else {
                    return plasticMaterial.clone();
                }
            });
        }

        // 创建27个cubie
        const cubieGroup = new THREE.Group(); // 用于组织所有cubie
        cubieGroup.name = 'cubieGroup';
        scene.add(cubieGroup);

        for (let gx = -1; gx <= 1; gx++) {
            for (let gy = -1; gy <= 1; gy++) {
                for (let gz = -1; gz <= 1; gz++) {
                    const geo = new THREE.BoxGeometry(CUBIE_SIZE, CUBIE_SIZE, CUBIE_SIZE);
                    const materials = createCubieMaterials(gx, gy, gz);
                    const mesh = new THREE.Mesh(geo, materials);
                    mesh.position.set(gx * GRID_SPACING, gy * GRID_SPACING, gz * GRID_SPACING);
                    mesh.rotation.set(0, 0, 0);
                    mesh.castShadow = true;
                    mesh.receiveShadow = true;
                    mesh.name = `cubie_${gx}_${gy}_${gz}`;
                    // 存储初始格点坐标(用于调试和初始状态判断)
                    mesh.userData = {
                        initialGrid: { x: gx, y: gy, z: gz },
                    };
                    cubieGroup.add(mesh);
                    allCubies.push(mesh);
                    initialStates.set(mesh, {
                        position: mesh.position.clone(),
                        rotation: mesh.rotation.clone(),
                    });
                }
            }
        }

        // ==================== 交互状态机 ====================
        const InteractionState = Object.freeze({
            IDLE: 'idle',
            DRAGGING: 'dragging',
            ANIMATING: 'animating',
            SCRAMBLING: 'scrambling',
        });

        let interactionState = InteractionState.IDLE;
        let activePivot = null; // 当前旋转轴心(THREE.Group)
        let activeAxisWorld = null; // 当前旋转轴(世界空间Vector3)
        let activeLayerValue = null; // 当前旋转层的坐标值
        let activeAxisIndex = null; // 轴索引:0=X, 1=Y, 2=Z
        let dragStartNDC = null; // 鼠标按下时的NDC坐标
        let projectedAxisScreen = null; // 投影到屏幕空间的轴方向(归一化Vector2)
        let currentDragAngle = 0; // 当前拖拽累积角度
        let selectedCubies = []; // 当前选中的cubie列表

        // ==================== 工具函数 ====================
        /**
         * 将3D世界坐标投影到NDC空间
         */
        function worldToNDC(worldPos) {
            const ndc = worldPos.clone().project(camera);
            return new THREE.Vector2(ndc.x, ndc.y);
        }

        /**
         * 获取鼠标事件的NDC坐标
         */
        function mouseToNDC(event) {
            return new THREE.Vector2(
                (event.clientX / window.innerWidth) * 2 - 1,
                -(event.clientY / window.innerHeight) * 2 + 1
            );
        }

        /**
         * 将3D世界轴向量投影到屏幕空间,返回归一化的2D方向
         * @param {THREE.Vector3} worldAxis - 世界空间中的轴向量(如(1,0,0))
         * @param {THREE.Vector3} referencePoint - 参考点(用于投影)
         * @returns {THREE.Vector2} 屏幕空间中的归一化方向
         */
        function projectAxisToScreen(worldAxis, referencePoint) {
            const p1 = worldToNDC(referencePoint);
            const p2 = worldToNDC(referencePoint.clone().add(worldAxis));
            const dir = new THREE.Vector2(p2.x - p1.x, p2.y - p1.y);
            const len = dir.length();
            if (len < 1e-9) return new THREE.Vector2(1, 0); // 退化情况
            return dir.normalize();
        }

        /**
         * 坐标清洗:消除浮点数累积误差
         * - position 四舍五入到整数
         * - rotation 欧拉角四舍五入到最接近的 π/2 倍数
         */
        function snapCubieTransform(cubie) {
            // 清洗位置
            cubie.position.x = Math.round(cubie.position.x);
            cubie.position.y = Math.round(cubie.position.y);
            cubie.position.z = Math.round(cubie.position.z);
            // 清洗旋转(欧拉角 → 最接近的 π/2 倍数)
            const halfPi = Math.PI / 2;
            cubie.rotation.x = Math.round(cubie.rotation.x / halfPi) * halfPi;
            cubie.rotation.y = Math.round(cubie.rotation.y / halfPi) * halfPi;
            cubie.rotation.z = Math.round(cubie.rotation.z / halfPi) * halfPi;
        }

        /**
         * 对所有cubie执行坐标清洗
         */
        function snapAllCubies() {
            allCubies.forEach(snapCubieTransform);
        }

        /**
         * 动态层级筛选:根据世界坐标和轴索引,找出属于同一旋转层的所有cubie
         * @param {number} axisIndex - 轴索引 0=X, 1=Y, 2=Z
         * @param {number} layerValue - 该层的坐标值
         * @returns {THREE.Mesh[]}
         */
        function getCubiesInLayer(axisIndex, layerValue) {
            const result = [];
            const axisKey = ['x', 'y', 'z'][axisIndex];
            for (const cubie of allCubies) {
                // 获取cubie在世界空间中的位置
                const worldPos = new THREE.Vector3();
                cubie.getWorldPosition(worldPos);
                if (Math.abs(worldPos[axisKey] - layerValue) < EPSILON) {
                    result.push(cubie);
                }
            }
            return result;
        }

        /**
         * 获取cubie当前世界坐标在指定轴上的值
         */
        function getCubieWorldAxisValue(cubie, axisIndex) {
            const worldPos = new THREE.Vector3();
            cubie.getWorldPosition(worldPos);
            return ['x', 'y', 'z'][axisIndex] === 'x' ? worldPos.x : ['x', 'y', 'z'][axisIndex] === 'y' ? worldPos.y :
                worldPos.z;
        }

        // ==================== Pivot 旋转机制 ====================
        /**
         * 创建轴心对象并挂载选中的cubie
         * @param {THREE.Mesh[]} cubies - 要挂载的cubie列表
         * @returns {THREE.Group} pivot
         */
        function createPivotAndAttach(cubies) {
            const pivot = new THREE.Group();
            pivot.name = 'rotationPivot';
            // Pivot放在世界原点,旋转轴通过原点
            scene.add(pivot);
            for (const cubie of cubies) {
                // pivot.attach() 保持世界变换,将cubie挂载到pivot下
                pivot.attach(cubie);
            }
            return pivot;
        }

        /**
         * 释放pivot:将所有cubie放回场景,然后移除pivot
         * @param {THREE.Group} pivot
         */
        function releasePivot(pivot) {
            const children = [...pivot.children]; // 复制数组(遍历时会被修改)
            for (const cubie of children) {
                // scene.attach() 保持世界变换,将cubie放回场景
                scene.attach(cubie);
                // 放回cubieGroup以保持组织结构
                cubieGroup.attach(cubie);
            }
            scene.remove(pivot);
            if (pivot.geometry) pivot.geometry.dispose();
            if (pivot.material) {
                if (Array.isArray(pivot.material)) {
                    pivot.material.forEach(m => m.dispose());
                } else {
                    pivot.material.dispose();
                }
            }
        }

        /**
         * 执行完整的层旋转(带磁吸动画)
         * @param {THREE.Group} pivot
         * @param {THREE.Vector3} axisWorld - 世界空间旋转轴
         * @param {number} targetAngle - 目标角度(弧度)
         * @param {number} duration - 动画时长(ms)
         * @returns {Promise<void>}
         */
        function animatePivotToTarget(pivot, axisWorld, targetAngle, duration) {
            return new Promise((resolve) => {
                // 获取pivot当前绕该轴的角度
                const axisKey = ['x', 'y', 'z'][activeAxisIndex];
                const startAngle = pivot.rotation[axisKey];

                // 使用Tween.js
                const tweenData = { angle: startAngle };
                const tween = new TWEEN.Tween(tweenData)
                    .to({ angle: targetAngle }, duration)
                    .easing(TWEEN.Easing.Cubic.Out)
                    .onUpdate(() => {
                        pivot.rotation[axisKey] = tweenData.angle;
                    })
                    .onComplete(() => {
                        pivot.rotation[axisKey] = targetAngle;
                        resolve();
                    })
                    .start();
            });
        }

        // ==================== 手势投影算法(核心考点) ====================
        /**
         * 根据面法线确定两个潜在旋转轴
         * 原理:用户可以绕与法线垂直的任意轴旋转
         * @param {THREE.Vector3} worldNormal - 世界空间中的面法线
         * @returns {{axisA: THREE.Vector3, axisB: THREE.Vector3, indexA: number, indexB: number}}
         */
        function getPotentialAxes(worldNormal) {
            const absX = Math.abs(worldNormal.x);
            const absY = Math.abs(worldNormal.y);
            const absZ = Math.abs(worldNormal.z);

            // 法线的主要分量决定了两个潜在旋转轴
            if (absX >= absY && absX >= absZ) {
                // 法线接近X轴 → 潜在旋转轴为Y和Z
                return {
                    axisA: new THREE.Vector3(0, 1, 0),
                    axisB: new THREE.Vector3(0, 0, 1),
                    indexA: 1,
                    indexB: 2,
                };
            } else if (absY >= absX && absY >= absZ) {
                // 法线接近Y轴 → 潜在旋转轴为X和Z
                return {
                    axisA: new THREE.Vector3(1, 0, 0),
                    axisB: new THREE.Vector3(0, 0, 1),
                    indexA: 0,
                    indexB: 2,
                };
            } else {
                // 法线接近Z轴 → 潜在旋转轴为X和Y
                return {
                    axisA: new THREE.Vector3(1, 0, 0),
                    axisB: new THREE.Vector3(0, 1, 0),
                    indexA: 0,
                    indexB: 1,
                };
            }
        }

        /**
         * 计算鼠标滑动向量与两个投影轴的点积,选择匹配度最高的轴
         * @param {THREE.Vector2} dragVector - 鼠标滑动2D向量(NDC空间)
         * @param {THREE.Vector2} projA - 轴A在屏幕空间的投影方向
         * @param {THREE.Vector2} projB - 轴B在屏幕空间的投影方向
         * @returns {{chosenIndex: number, dotProduct: number, chosenProj: THREE.Vector2}}
         */
        function selectBestAxis(dragVector, projA, projB, indexA, indexB) {
            const dotA = Math.abs(dragVector.dot(projA));
            const dotB = Math.abs(dragVector.dot(projB));
            if (dotA >= dotB) {
                return {
                    chosenIndex: indexA,
                    dotProduct: dragVector.dot(projA),
                    chosenProj: projA,
                };
            } else {
                return {
                    chosenIndex: indexB,
                    dotProduct: dragVector.dot(projB),
                    chosenProj: projB,
                };
            }
        }

        // ==================== 鼠标事件处理 ====================
        let mouseDownWorldPoint = null; // 鼠标按下时的3D交点
        let hasMovedSignificantly = false; // 是否产生了有效拖拽
        const MIN_DRAG_THRESHOLD_NDC = 0.015; // 最小拖拽阈值(NDC单位)

        canvas.addEventListener('pointerdown', (event) => {
            // 仅处理左键(button === 0)
            if (event.button !== 0) return;
            // 非空闲状态不响应
            if (interactionState !== InteractionState.IDLE) return;

            // 射线检测
            const ndc = mouseToNDC(event);
            const raycaster = new THREE.Raycaster();
            raycaster.setFromCamera(new THREE.Vector2(ndc.x, ndc.y), camera);
            const intersects = raycaster.intersectObjects(allCubies, false);

            if (intersects.length === 0) return; // 未命中魔方

            const intersect = intersects[0];
            const clickedCubie = intersect.object;
            const faceNormalLocal = intersect.face.normal.clone();
            // 将面法线转换到世界空间
            const worldNormal = faceNormalLocal.transformDirection(clickedCubie.matrixWorld).round();
            // 获取交点世界位置
            mouseDownWorldPoint = intersect.point.clone();

            // 【手势投影算法】根据法线确定两个潜在旋转轴
            const { axisA, axisB, indexA, indexB } = getPotentialAxes(worldNormal);

            // 将两个3D轴投影到屏幕空间
            const projA = projectAxisToScreen(axisA, mouseDownWorldPoint);
            const projB = projectAxisToScreen(axisB, mouseDownWorldPoint);

            // 存储以备mousemove使用(此时尚未决定选哪个轴)
            dragStartNDC = ndc;
            hasMovedSignificantly = false;
            currentDragAngle = 0;
            selectedCubies = [];
            activePivot = null;
            activeAxisWorld = null;
            activeLayerValue = null;
            activeAxisIndex = null;
            projectedAxisScreen = null;

            // 暂存潜在轴信息
            canvas._gestureData = {
                axisA,
                axisB,
                indexA,
                indexB,
                projA,
                projB,
                clickedCubie,
                worldNormal,
            };

            interactionState = InteractionState.DRAGGING;
            container.classList.add('grabbing');
            controls.enabled = false; // 拖拽期间禁用OrbitControls
            hintTag.style.opacity = '0.4';
        });

        canvas.addEventListener('pointermove', (event) => {
            if (interactionState !== InteractionState.DRAGGING) return;
            if (!canvas._gestureData) return;

            const currentNDC = mouseToNDC(event);
            const dragVector = new THREE.Vector2(
                currentNDC.x - dragStartNDC.x,
                currentNDC.y - dragStartNDC.y
            );
            const dragLength = dragVector.length();

            // 检查是否超过最小阈值
            if (!hasMovedSignificantly && dragLength < MIN_DRAG_THRESHOLD_NDC) {
                return; // 微小移动,忽略
            }

            const gd = canvas._gestureData;

            if (!hasMovedSignificantly) {
                // 首次显著移动:选择最佳旋转轴
                hasMovedSignificantly = true;
                const selection = selectBestAxis(dragVector, gd.projA, gd.projB, gd.indexA, gd.indexB);
                activeAxisIndex = selection.chosenIndex;
                const rawDot = selection.dotProduct;

                // 确定世界旋转轴
                const axisVectors = [
                    new THREE.Vector3(1, 0, 0),
                    new THREE.Vector3(0, 1, 0),
                    new THREE.Vector3(0, 0, 1),
                ];
                activeAxisWorld = axisVectors[activeAxisIndex].clone();
                projectedAxisScreen = selection.chosenProj.clone();

                // 动态层级筛选:根据被点击cubie的世界坐标确定旋转层
                const clickedCubie = gd.clickedCubie;
                const worldPos = new THREE.Vector3();
                clickedCubie.getWorldPosition(worldPos);
                const axisKey = ['x', 'y', 'z'][activeAxisIndex];
                activeLayerValue = Math.round(worldPos[axisKey]);

                // 筛选同一层的所有cubie
                selectedCubies = getCubiesInLayer(activeAxisIndex, activeLayerValue);

                // 创建Pivot并挂载选中的cubie
                activePivot = createPivotAndAttach(selectedCubies);
                activePivot.rotation.set(0, 0, 0);
                currentDragAngle = 0;

                // 更新gestureData
                gd._rawDotSign = rawDot;
            }

            // 计算旋转角度:鼠标滑动向量在投影轴上的投影(带符号)
            const projectionOnAxis = dragVector.dot(projectedAxisScreen);
            // 转换为弧度(1 NDC ≈ π 弧度 ≈ 180°)
            const angle = projectionOnAxis * ANGLE_PER_NDC;

            // 符号修正:确保"向右滑=向右转"的视觉逻辑
            // 使用叉乘判断:dragVector × projectedAxisScreen 的符号决定旋转方向
            const crossZ = dragVector.x * projectedAxisScreen.y - dragVector.y * projectedAxisScreen.x;
            const signCorrection = crossZ >= 0 ? 1 : -1;
            const correctedAngle = Math.abs(angle) * signCorrection;

            currentDragAngle = correctedAngle;

            // 实时更新pivot旋转(1:1跟手)
            const axisKey = ['x', 'y', 'z'][activeAxisIndex];
            activePivot.rotation.set(0, 0, 0);
            activePivot.rotation[axisKey] = correctedAngle;
        });

        window.addEventListener('pointerup', async (event) => {
            if (interactionState !== InteractionState.DRAGGING) return;

            container.classList.remove('grabbing');
            hintTag.style.opacity = '1';

            if (!hasMovedSignificantly || !activePivot || selectedCubies.length === 0) {
                // 无有效拖拽:清理
                if (activePivot) {
                    releasePivot(activePivot);
                }
                cleanupDragState();
                controls.enabled = true;
                return;
            }

            // 【磁吸效果】:计算最近的90度倍数
            const halfPi = Math.PI / 2;
            const rawAngle = currentDragAngle;
            const targetAngle = Math.round(rawAngle / halfPi) * halfPi;

            // 如果目标角度为0(即回到原位),直接释放
            if (Math.abs(targetAngle) < 0.001) {
                releasePivot(activePivot);
                snapAllCubies();
                cleanupDragState();
                controls.enabled = true;
                return;
            }

            // 执行磁吸动画
            interactionState = InteractionState.ANIMATING;
            btnScramble.disabled = true;
            btnReset.disabled = true;

            const pivotRef = activePivot;
            const axisRef = activeAxisWorld;
            const cubiesRef = selectedCubies;

            await animatePivotToTarget(pivotRef, axisRef, targetAngle, MAGNETIC_DURATION);

            // 动画完成:释放cubie回场景
            releasePivot(pivotRef);
            // 【坐标清洗】:消除浮点数累积误差
            snapAllCubies();

            cleanupDragState();
            controls.enabled = true;
            btnScramble.disabled = false;
            btnReset.disabled = false;
            interactionState = InteractionState.IDLE;
        });

        /**
         * 清理拖拽状态
         */
        function cleanupDragState() {
            activePivot = null;
            activeAxisWorld = null;
            activeLayerValue = null;
            activeAxisIndex = null;
            dragStartNDC = null;
            projectedAxisScreen = null;
            currentDragAngle = 0;
            selectedCubies = [];
            hasMovedSignificantly = false;
            mouseDownWorldPoint = null;
            delete canvas._gestureData;
            if (interactionState === InteractionState.DRAGGING) {
                interactionState = InteractionState.IDLE;
            }
            container.classList.remove('grabbing');
        }

        // 防止右键菜单弹出(在canvas上)
        canvas.addEventListener('contextmenu', (event) => {
            event.preventDefault();
        });

        // ==================== Scramble(打乱) ====================
        /**
         * 执行打乱序列
         * 随机选择层和轴,执行一系列90度旋转
         */
        async function scramble() {
            if (interactionState !== InteractionState.IDLE) return;
            interactionState = InteractionState.SCRAMBLING;
            btnScramble.disabled = true;
            btnReset.disabled = true;
            controls.enabled = false;
            hintTag.textContent = '🔀 正在打乱...';
            hintTag.style.opacity = '1';

            const axisVectors = [
                new THREE.Vector3(1, 0, 0), // X轴
                new THREE.Vector3(0, 1, 0), // Y轴
                new THREE.Vector3(0, 0, 1), // Z轴
            ];
            const layerValues = [-1, 0, 1]; // 三层
            const angleOptions = [-Math.PI / 2, Math.PI / 2]; // ±90°

            // 生成随机打乱序列(避免连续同轴旋转)
            const steps = [];
            let lastAxisIndex = -1;
            for (let i = 0; i < SCRAMBLE_STEPS; i++) {
                let axisIndex;
                do {
                    axisIndex = Math.floor(Math.random() * 3);
                } while (axisIndex === lastAxisIndex && SCRAMBLE_STEPS > 3);
                lastAxisIndex = axisIndex;
                const layerValue = layerValues[Math.floor(Math.random() * 3)];
                const angle = angleOptions[Math.floor(Math.random() * 2)];
                steps.push({ axisIndex, layerValue, angle });
            }

            for (let i = 0; i < steps.length; i++) {
                const { axisIndex, layerValue, angle } = steps[i];
                const cubies = getCubiesInLayer(axisIndex, layerValue);
                if (cubies.length === 0) continue;

                const pivot = createPivotAndAttach(cubies);
                const axisKey = ['x', 'y', 'z'][axisIndex];
                pivot.rotation.set(0, 0, 0);

                await animatePivotToTarget(pivot, axisVectors[axisIndex], angle, SCRAMBLE_STEP_DURATION);
                releasePivot(pivot);
                snapAllCubies();

                // 小延迟让动画呼吸
                if (i < steps.length - 1) {
                    await new Promise(r => setTimeout(r, 40));
                }
            }

            snapAllCubies();
            controls.enabled = true;
            btnScramble.disabled = false;
            btnReset.disabled = false;
            interactionState = InteractionState.IDLE;
            hintTag.textContent = '🖱️ 左键拖拽旋转层 | 右键拖拽旋转视角 | 滚轮缩放';
            hintTag.style.opacity = '1';
        }

        // ==================== Reset(重置) ====================
        /**
         * 重置所有cubie到初始状态(使用动画平滑过渡)
         */
        async function resetCube() {
            if (interactionState !== InteractionState.IDLE) return;
            interactionState = InteractionState.ANIMATING;
            btnScramble.disabled = true;
            btnReset.disabled = true;
            controls.enabled = false;
            hintTag.textContent = '↺ 正在重置...';
            hintTag.style.opacity = '1';

            // 收集所有需要动画的cubie及其目标状态
            const animations = [];
            for (const cubie of allCubies) {
                const initState = initialStates.get(cubie);
                if (!initState) continue;
                const targetPos = initState.position.clone();
                const targetRot = initState.rotation.clone();

                // 如果当前位置/旋转与目标相同,跳过
                const posDist = cubie.position.distanceTo(targetPos);
                const rotDist = Math.abs(cubie.rotation.x - targetRot.x) +
                    Math.abs(cubie.rotation.y - targetRot.y) +
                    Math.abs(cubie.rotation.z - targetRot.z);
                if (posDist < 0.001 && rotDist < 0.001) continue;

                animations.push({
                    cubie,
                    startPos: cubie.position.clone(),
                    targetPos,
                    startRot: {
                        x: cubie.rotation.x,
                        y: cubie.rotation.y,
                        z: cubie.rotation.z,
                    },
                    targetRot: {
                        x: targetRot.x,
                        y: targetRot.y,
                        z: targetRot.z,
                    },
                });
            }

            if (animations.length === 0) {
                // 无需动画
                snapAllCubies();
                finishReset();
                return;
            }

            // 使用Tween并行动画所有cubie
            const tweenData = { progress: 0 };
            const duration = 400;
            const tween = new TWEEN.Tween(tweenData)
                .to({ progress: 1 }, duration)
                .easing(TWEEN.Easing.Cubic.InOut)
                .onUpdate(() => {
                    const t = tweenData.progress;
                    for (const anim of animations) {
                        anim.cubie.position.lerpVectors(anim.startPos, anim.targetPos, t);
                        anim.cubie.rotation.x = anim.startRot.x + (anim.targetRot.x - anim.startRot.x) * t;
                        anim.cubie.rotation.y = anim.startRot.y + (anim.targetRot.y - anim.startRot.y) * t;
                        anim.cubie.rotation.z = anim.startRot.z + (anim.targetRot.z - anim.startRot.z) * t;
                    }
                })
                .onComplete(() => {
                    for (const anim of animations) {
                        anim.cubie.position.copy(anim.targetPos);
                        anim.cubie.rotation.set(anim.targetRot.x, anim.targetRot.y, anim.targetRot.z);
                    }
                    snapAllCubies();
                    finishReset();
                })
                .start();

            // 等待tween完成
            await new Promise(resolve => {
                const check = setInterval(() => {
                    if (tweenData.progress >= 1) {
                        clearInterval(check);
                        resolve();
                    }
                }, 50);
                // 安全超时
                setTimeout(() => { clearInterval(check);
                    resolve(); }, duration + 200);
            });
        }

        function finishReset() {
            snapAllCubies();
            controls.enabled = true;
            btnScramble.disabled = false;
            btnReset.disabled = false;
            interactionState = InteractionState.IDLE;
            hintTag.textContent = '🖱️ 左键拖拽旋转层 | 右键拖拽旋转视角 | 滚轮缩放';
            hintTag.style.opacity = '1';
        }

        // ==================== 按钮事件 ====================
        btnScramble.addEventListener('click', () => {
            if (interactionState === InteractionState.IDLE) {
                scramble();
            }
        });
        btnReset.addEventListener('click', () => {
            if (interactionState === InteractionState.IDLE) {
                resetCube();
            }
        });

        // 键盘快捷键
        window.addEventListener('keydown', (event) => {
            if (event.key === 's' || event.key === 'S') {
                if (interactionState === InteractionState.IDLE) scramble();
            }
            if (event.key === 'r' || event.key === 'R') {
                if (interactionState === InteractionState.IDLE) resetCube();
            }
        });

        // ==================== 窗口大小适配 ====================
        window.addEventListener('resize', () => {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
        });

        // ==================== 渲染循环 ====================
        function animate(timestamp) {
            requestAnimationFrame(animate);

            // 更新Tween动画
            TWEEN.update(timestamp);

            // 更新OrbitControls(仅在非拖拽/非动画状态)
            if (interactionState === InteractionState.IDLE) {
                controls.enabled = true;
                controls.update();
            } else if (interactionState === InteractionState.DRAGGING) {
                controls.enabled = false;
            }

            // 渲染
            renderer.render(scene, camera);
        }

        // ==================== 启动 ====================
        console.log('🧊 3x3 魔方已就绪');
        console.log('   - 左键拖拽:手势旋转层(投影向量匹配算法)');
        console.log('   - 右键拖拽:OrbitControls 旋转视角');
        console.log('   - 滚轮:缩放');
        console.log('   - 按钮/快捷键 S=打乱 R=重置');
        console.log('   - Pivot挂载机制 + 坐标清洗 + 磁吸效果');
        console.log('   - 物理间隙:', GAP.toFixed(2), '单位');
        console.log('   - 倒角贴纸:Canvas程序化生成圆角矩形纹理');
        requestAnimationFrame(animate);
    </script>
</body>
</html>

1 个帖子 - 1 位参与者

阅读完整话题

来源: linux.do查看原文