/** * 艾动智能 · 36 仓充电柜 参数化 3D 模型(Three.js) * * 设计约束(AGENTS.md「操作规则」): * - 明亮 / 浅色基调,⛔ 不做暗色科技风; * - 柜子规格按真实产品:6 列 × 6 行 = 36 个独立仓体,每仓带状态指示灯。 * * 工程约束: * - canvas 透明背景(alpha),柜子"浮"在页面浅色渐变上 ⇒ 不存在图片硬边界; * - 纯参数化建模,不依赖任何外部模型文件(我们没有、也生成不出高精度模型资产); * - 性能:DPR 上限 2、页面隐藏/滚出视口时暂停渲染; * - 降级:WebGL 不可用或脚本异常 ⇒ 页面回落到静态主视觉图(见 index.html)。 */ import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; /* ============================ 规格常量 ============================ */ const COLS = 6; const ROWS = 6; const CELL_W = 0.62; // 单仓横向步进 const CELL_H = 0.72; // 单仓纵向步进 const DOOR_W = 0.54; const DOOR_H = 0.64; const BODY_H = ROWS * CELL_H; // 4.32 const BASE_H = 0.20; const CAP_H = 0.24; /* 深度(Z 轴)。 ⚠️ 原先柜体只有 0.62 深,而宽 3.72 ⇒ 宽:深 ≈ 6:1,薄得像一块板, 从侧面看尤其难看。真实充电柜深得多,这里加到 1.18 ⇒ 宽:深 ≈ 3.2:1。 */ const BODY_D = 1.18; const CAP_D = 1.24; // 顶盖略外挑 const PLINTH_D = 1.22; // 底座略外挑 const DOOR_Z = BODY_D / 2 + 0.07; // 仓门铰链平面(柜体前面之外一点) const CAVITY_D = 0.85; // 仓内深度 const BRAND = 0x0366e6; // 取自 logo 的品牌蓝 const LIGHT_CYAN = 0x17e4ff; // 状态灯 /** 上面 3 排用深色玻璃仓门、下面 3 排用浅色仓门 —— 与主视觉图的层次一致 */ const DARK_ROWS = 3; /* ============================ 渲染器 ============================ */ const canvas = document.getElementById('cabinet-canvas'); /* 移动端降质:手机 GPU 弱,36 仓 + 阴影 + 环境反射很容易发烫掉帧。 判据用「窄屏 或 粗指针(触摸) 或 低 DPR」三者之一。 */ const IS_MOBILE = (window.innerWidth <= 860) || (window.matchMedia && window.matchMedia('(pointer: coarse)').matches) || (window.devicePixelRatio || 1) < 2; const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, // 透明背景,让柜子浮在页面上 antialias: !IS_MOBILE, // 移动端关 MSAA,靠 DPR 与后处理兜不住就算了 powerPreference: 'high-performance', }); renderer.setClearColor(0x000000, 0); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, IS_MOBILE ? 1.5 : 2)); renderer.shadowMap.enabled = !IS_MOBILE; // 移动端用假接触阴影(地面那张),不开实时阴影 renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.05; const scene = new THREE.Scene(); /* 相机:略低于人眼、微微仰视,产品感更强。 ⚠️ 距离是算出来的,不是凭感觉:柜体总高约 4.76 单位(底座0.20+主体4.32+顶盖0.24), 摄像机看向 target(0, 2.36, 0)。38° 垂直视角下 可见高度 = 2·d·tan19° = 0.689·d。 - 原 d≈8.4 ⇒ 柜子占 82%,顶天顶地; - 现取 CAM_DIST=12.2 ⇒ 实际 d≈11.4 ⇒ 柜子占约 61%,上下各留约 20% 呼吸空间。 */ const CAM_DIST = 13.0; const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 100); camera.position.set( CAM_DIST * 0.52, // x CAM_DIST * 0.35, // y CAM_DIST * 0.76, // z ); /* ============================ 环境反射 ============================ */ /** 程序化生成"影棚渐变"环境贴图 —— 让深色玻璃仓门有细腻反射,无需外部 HDR 文件。 */ function buildEnvironment() { const pmrem = new THREE.PMREMGenerator(renderer); const envScene = new THREE.Scene(); // 一个内部着色的渐变球:上白、中浅灰、下稍暗,模拟影棚柔光箱 const geo = new THREE.SphereGeometry(10, 32, 16); const mat = new THREE.ShaderMaterial({ side: THREE.BackSide, uniforms: { top: { value: new THREE.Color(0xffffff) }, mid: { value: new THREE.Color(0xdfe4ec) }, bot: { value: new THREE.Color(0x9aa3b2) }, }, vertexShader: ` varying float vY; void main() { vY = normalize(position).y; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`, fragmentShader: ` uniform vec3 top; uniform vec3 mid; uniform vec3 bot; varying float vY; void main() { float t = clamp(vY * 0.5 + 0.5, 0.0, 1.0); vec3 c = t < 0.5 ? mix(bot, mid, t * 2.0) : mix(mid, top, (t - 0.5) * 2.0); gl_FragColor = vec4(c, 1.0); }`, }); envScene.add(new THREE.Mesh(geo, mat)); const env = pmrem.fromScene(envScene, 0.04).texture; pmrem.dispose(); geo.dispose(); mat.dispose(); return env; } scene.environment = buildEnvironment(); /* ============================ 灯光 ============================ */ function buildLights() { scene.add(new THREE.HemisphereLight(0xffffff, 0xc8d0dc, 0.55)); // 主光:右上前侧,冷白,投影(移动端为省电只留光照、不投影) const key = new THREE.DirectionalLight(0xffffff, 2.1); key.position.set(5, 8, 6); if (!IS_MOBILE) { key.castShadow = true; key.shadow.mapSize.set(2048, 2048); key.shadow.camera.near = 1; key.shadow.camera.far = 30; key.shadow.camera.left = -6; key.shadow.camera.right = 6; key.shadow.camera.top = 8; key.shadow.camera.bottom = -2; key.shadow.bias = -0.0008; key.shadow.normalBias = 0.02; } scene.add(key); // 补光:左侧,柔 const fill = new THREE.DirectionalLight(0xdfe8ff, 0.7); fill.position.set(-6, 4, 4); scene.add(fill); // 轮廓光:品牌蓝,从左后打,勾出柜体边缘 const rim = new THREE.DirectionalLight(BRAND, 1.5); rim.position.set(-5, 5, -6); scene.add(rim); // 顶部灯带的自发光点光,让蓝光落在柜顶 const stripGlow = new THREE.PointLight(BRAND, 6, 6, 2); stripGlow.position.set(0, BODY_H + CAP_H + 0.12, 1.2); scene.add(stripGlow); } buildLights(); /* ============================ 柜体模型 ============================ */ const cabinet = new THREE.Group(); const doors = []; // { pivot, mesh, row, col, open, target, light } const doorMaterialCache = new Map(); /** 仓门材质(按行分深浅,缓存复用) */ function doorMaterial(dark) { const key = dark ? 'dark' : 'light'; if (doorMaterialCache.has(key)) return doorMaterialCache.get(key); const m = new THREE.MeshStandardMaterial( dark ? { color: 0x1b2432, roughness: 0.18, metalness: 0.35 } // 深色玻璃 : { color: 0xd9dde4, roughness: 0.42, metalness: 0.12 }, // 浅色门板 ); doorMaterialCache.set(key, m); return m; } function buildBody() { const w = COLS * CELL_W; // 主体 const body = new THREE.Mesh( new THREE.BoxGeometry(w, BODY_H, BODY_D), new THREE.MeshStandardMaterial({ color: 0xe9ebef, roughness: 0.55, metalness: 0.08 }), ); body.position.y = BASE_H + BODY_H / 2; body.castShadow = true; body.receiveShadow = true; cabinet.add(body); // 顶盖(略宽略深,深色,中间嵌品牌蓝灯带) const cap = new THREE.Mesh( new THREE.BoxGeometry(w + 0.08, CAP_H, CAP_D), new THREE.MeshStandardMaterial({ color: 0x2b3038, roughness: 0.4, metalness: 0.3 }), ); cap.position.y = BASE_H + BODY_H + CAP_H / 2; cap.castShadow = true; cabinet.add(cap); const strip = new THREE.Mesh( new THREE.BoxGeometry(w - 0.5, 0.045, 0.05), new THREE.MeshStandardMaterial({ color: 0x0a2a5e, emissive: BRAND, emissiveIntensity: 2.4, roughness: 0.3, }), ); strip.position.set(0, BASE_H + BODY_H + CAP_H - 0.07, CAP_D / 2 + 0.012); cabinet.add(strip); // 底座 + 支脚 const plinth = new THREE.Mesh( new THREE.BoxGeometry(w + 0.04, BASE_H, PLINTH_D), new THREE.MeshStandardMaterial({ color: 0x3a4048, roughness: 0.5, metalness: 0.25 }), ); plinth.position.y = BASE_H / 2; plinth.castShadow = true; plinth.receiveShadow = true; cabinet.add(plinth); for (const sx of [-1, 1]) { for (const sz of [-1, 1]) { const foot = new THREE.Mesh( new THREE.CylinderGeometry(0.055, 0.05, 0.06, 16), new THREE.MeshStandardMaterial({ color: 0x22262c, roughness: 0.6 }), ); foot.position.set(sx * (w / 2 - 0.14), 0.03, sz * (PLINTH_D / 2 - 0.14)); cabinet.add(foot); } } } /** 单个仓体:门框 + 门 + 把手 + 状态灯;门挂在 pivot 上以便绕铰链开合 */ function buildCompartment(row, col) { const dark = row < ROWS / 2; const x = (col - (COLS - 1) / 2) * CELL_W; const y = BASE_H + (row + 0.5) * CELL_H; // 门框(深色,凸出在柜体前面) const frame = new THREE.Mesh( new THREE.BoxGeometry(CELL_W - 0.02, CELL_H - 0.02, 0.06), new THREE.MeshStandardMaterial({ color: 0x353b45, roughness: 0.45, metalness: 0.3 }), ); frame.position.set(x, y, BODY_D / 2 + 0.03); cabinet.add(frame); // 铰链在左侧:pivot 放在门左缘 const pivot = new THREE.Group(); pivot.position.set(x - DOOR_W / 2, y, DOOR_Z); cabinet.add(pivot); const door = new THREE.Mesh( new THREE.BoxGeometry(DOOR_W, DOOR_H, 0.05), doorMaterial(dark), ); door.position.set(DOOR_W / 2, 0, 0.025); door.castShadow = true; pivot.add(door); // 把手 const handle = new THREE.Mesh( new THREE.BoxGeometry(0.035, 0.16, 0.035), new THREE.MeshStandardMaterial({ color: 0xb9c0cb, roughness: 0.25, metalness: 0.85 }), ); handle.position.set(DOOR_W - 0.07, 0, 0.055); pivot.add(handle); // 状态灯(门框右上角,自发光) const light = new THREE.Mesh( new THREE.BoxGeometry(0.1, 0.022, 0.02), new THREE.MeshStandardMaterial({ color: 0x08243f, emissive: LIGHT_CYAN, emissiveIntensity: 2.2, roughness: 0.4, }), ); light.position.set(x + CELL_W / 2 - 0.1, y + CELL_H / 2 - 0.07, BODY_D / 2 + 0.065); cabinet.add(light); // 仓内(开门后可见的暗腔 + 弱光) const cavity = new THREE.Mesh( new THREE.BoxGeometry(CELL_W - 0.08, CELL_H - 0.08, CAVITY_D), new THREE.MeshStandardMaterial({ color: 0x111720, roughness: 0.9 }), ); cavity.position.set(x, y, BODY_D / 2 - CAVITY_D / 2 + 0.02); cabinet.add(cavity); doors.push({ pivot, door, light, row, col, open: false, target: 0, hover: false, baseEmissive: 2.2, phase: (row * COLS + col) * 0.37, }); } function buildCabinet() { buildBody(); for (let r = 0; r < ROWS; r++) { for (let c = 0; c < COLS; c++) buildCompartment(r, c); } cabinet.position.y = 0; scene.add(cabinet); } buildCabinet(); /* ============================ 地面 + 软阴影 ============================ */ /** 用一张径向渐变贴图做假接触阴影,比实时阴影便宜且边缘更柔。 */ function buildFloor() { const size = 256; const cv = document.createElement('canvas'); cv.width = cv.height = size; const ctx = cv.getContext('2d'); const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2); g.addColorStop(0, 'rgba(20,30,45,0.34)'); g.addColorStop(0.55, 'rgba(20,30,45,0.12)'); g.addColorStop(1, 'rgba(20,30,45,0)'); ctx.fillStyle = g; ctx.fillRect(0, 0, size, size); const tex = new THREE.CanvasTexture(cv); const shadow = new THREE.Mesh( new THREE.PlaneGeometry(7.5, 4.5), new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false }), ); shadow.rotation.x = -Math.PI / 2; shadow.position.y = 0.005; shadow.renderOrder = -1; scene.add(shadow); } buildFloor(); /* ============================ 交互 ============================ */ const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0, BASE_H + BODY_H / 2, 0); controls.enableDamping = true; controls.dampingFactor = 0.06; controls.enablePan = false; // 距离上下限跟着取景走:默认约 12.1,允许 9.5–17 controls.minDistance = 9.5; controls.maxDistance = 17; controls.minPolarAngle = 0.35; controls.maxPolarAngle = Math.PI / 2 - 0.03; // 不钻到地面以下 controls.autoRotate = true; controls.autoRotateSpeed = 0.55; // 用户一交互就停自动旋转,避免"拖着还转" controls.addEventListener('start', () => { controls.autoRotate = false; }); // 触屏:单指旋转、双指缩放;页面滚动交给舞台外的区域(canvas 已设 touch-action:none) controls.touches = { ONE: THREE.TOUCH.ROTATE, TWO: THREE.TOUCH.DOLLY_ROTATE }; const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(-10, -10); let hovered = null; renderer.domElement.addEventListener('pointermove', (e) => { const r = renderer.domElement.getBoundingClientRect(); pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1; pointer.y = -((e.clientY - r.top) / r.height) * 2 + 1; }); renderer.domElement.addEventListener('pointerleave', () => { pointer.set(-10, -10); }); renderer.domElement.addEventListener('click', () => { if (!hovered) return; const d = doors.find((x) => x.door === hovered); if (!d) return; d.open = !d.open; d.target = d.open ? -Math.PI * 0.62 : 0; }); /* ============================ 尺寸自适应 ============================ */ function resize() { const stage = renderer.domElement.parentElement; const w = Math.max(1, stage.clientWidth); const h = Math.max(1, stage.clientHeight); renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); } window.addEventListener('resize', resize); resize(); /* ============================ 主循环 ============================ */ const clock = new THREE.Clock(); let visible = true; // 页面隐藏 / 舞台滚出视口时暂停,省电省 CPU document.addEventListener('visibilitychange', () => { visible = !document.hidden; if (visible) clock.getDelta(); }); if ('IntersectionObserver' in window) { new IntersectionObserver((entries) => { visible = entries[0].isIntersecting && !document.hidden; if (visible) clock.getDelta(); }, { threshold: 0.01 }).observe(renderer.domElement); } function tick() { requestAnimationFrame(tick); if (!visible) return; const dt = Math.min(clock.getDelta(), 0.05); const t = clock.elapsedTime; // 仓门开合(缓动) for (const d of doors) { const cur = d.pivot.rotation.y; const next = cur + (d.target - cur) * Math.min(1, dt * 7); d.pivot.rotation.y = next; // 悬停高亮 const wantEmissive = d.door === hovered ? 0.55 : 0; d.light.material.emissiveIntensity += (d.baseEmissive + wantEmissive - d.light.material.emissiveIntensity) * Math.min(1, dt * 8); // 状态灯轻微呼吸(有在充的仓才亮;这里取一部分做"使用中"的观感) if ((d.row + d.col) % 3 === 0) { const pulse = 0.75 + 0.25 * Math.sin(t * 2.2 + d.phase); d.light.material.emissiveIntensity *= pulse; } } controls.update(); renderer.render(scene, camera); } tick(); /* ============================ 悬停射线检测 ============================ */ setInterval(() => { if (!visible) return; raycaster.setFromCamera(pointer, camera); const hits = raycaster.intersectObjects(doors.map((d) => d.door), false); const first = hits.length ? hits[0].object : null; if (first !== hovered) { hovered = first; renderer.domElement.style.cursor = hovered ? 'pointer' : 'grab'; } }, 60); // 供页面判断 3D 是否就绪(失败则回落静态图) window.__cabinet3d = { ok: true, doors: doors.length };