## 1. 用户管理:角色过滤(页面总共只有三种角色,默认罗列全部太吵) 前端加角色下拉 + 后端 `GET /users` 新增 `role` 参数(0/1/2,越界当作不过滤)。 后端把 `build_user_list_where` 从**二元穷举 match 重构为条件累加** —— 加第三个过滤器时穷举会涨到 8 分支且极易漏。同时修一个潜在坑: **OR 条件必须整体加括号**,否则 `phone LIKE ? OR name LIKE ?` 与 `organization_id = ?` 用 AND 混排时会变成 `(A AND phone LIKE ?) OR name LIKE ?`,把组织/角色过滤整个击穿 (搜个姓名就跨组织串数据)。 新增 6 个守门单测(含"占位符个数必须与 bind 次数一致"的反证), 已自证能失败:去掉 OR 括号 → 2 个用例 FAILED,还原 → 6 passed。 ## 2. 数据范围:仅操作员(role=0)可配 按用户口径: - **总管理员**:豁免范围限制,不需要配; - **企业管理员**:天然管理本组织全部设备,配项目级范围反而有歧义; - **操作员**:才需要设置可管理的项目。 前端对该按钮按 `record.role !== 0` 置灰并加 title 说明。 **创建/编辑时操作员必须选组织**(没有企业归属就看不到任何设备),校验文案也分开提示。 ## 3. 柜子详情:透出电表读数 接口早已返回 `meter_wh`/`meter_at`,但**前端从没接过**(用户看不到)。 现加到详情页基本信息行(IMEI/ICCID/名称 之后),Wh → kWh 展示并附收到时刻; `null` = 从未上报过,显示 `-` 而**不是 0 度**。 ## ⛔ 表号:系统里没有,做不了 全仓搜不到 `meter_no`/`meter_id`/`meter_sn`/`表号`,协议里 `cabinet.meter` 只是读数(Wh),**没有电表编号字段**。这需要硬件侧在协议里加字段, 平台无法单方面补。 ## ⚠️ 我这次犯的流程错误(已修正) 改了 Rust 代码后**只跑了 cargo check/test,没重新 `zigbuild` release 产物**, `deploy -SkipBuild` 于是把几小时前的旧二进制发上去了 —— 第一次实测角色过滤 三种角色全返回、监控词。重建后才通过。 **教训:改 Rust 后必须重建 release 产物再发。** ## 验收 - `cargo check --all-targets` 两服务:**0 error / 0 warning** - `cargo test --bin pms-api`:**210 passed**(204 + 6 新) - web `npm run typecheck`:exit 0 - **角色过滤实测**(测试环境,临时超管+自签 JWT,用完即删): ``` role=0 → 2 人,role 集合 [0] OK role=1 → 1 人,role 集合 [1] OK role=2 → 2 人,role 集合 [2] OK 不带 role → [0,1,2] OK role=0 + keyword → 1 人 OK(keyword 单独查命中 0,证明不是靠关键字通过) role=9 越界 → 5 人 OK(当作不过滤) ``` - 已发布测试 + 生产(四服务 active、0 ERROR、消费链探测通过、二进制时间戳已核对)
431 lines
15 KiB
JavaScript
431 lines
15 KiB
JavaScript
/**
|
||
* 艾动智能 · 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 };
|