Files
DPM/src/components/Building3D.jsx
T

729 lines
33 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Edges, Grid, Html } from '@react-three/drei';
/* =========================================================
双良节能大楼 · 3D 建筑模型(React Three Fiber
按「详细的布局.png」像素级重建 —— 60×42 网格
--------------------------------------------------------
设计原则:
· 颜色仅供理解,渲染统一:所有地面 = 同一浅灰色
· 靠资产区分功能:办公室=办公桌 · 会议室=会议长桌+讲台
· 休息区=沙发 · 共享工位=工位桌 · 楼道=真实楼梯
· 一楼大厅(玻璃顶下方挑空)外墙 + 窗户
· 二楼楼板避开玻璃顶中庭(两块拼接,无覆盖/无悬空)
· 相邻办公室共享一堵墙;过道两侧不设墙,门开向过道
========================================================= */
const U = 0.5; // 网格单位 → 世界单位(60×42 = 30×21
const FLOOR2 = 1.9; // 二楼楼板高度
const ROOM_H = 1.5; // 二楼墙高
const HALL_H = 2.4; // 一楼大厅净高(至玻璃顶)
const T = 0.14; // 墙厚
// 统一地面色(所有地面同一个颜色)
const FLOOR_COLOR = '#e7ecf3';
const WALL_COLOR = '#eef3fb';
const WALL_EDGE = '#9db1cf';
/* ---- 房间矩形(60×42 坐标 [x0,x1,z0,z1,type,no]----
编号规则:成长区 G01-G08 · 国际区 I01-I07 · 加速区 A01-A12
会议室 M · 楼道 S · 共享工位区 W · 休息区 L · 过道 H
注意:编号用于逐间修正定位,保持稳定 */
const ROOMS = [
// 玻璃顶(二楼中庭挑空)
{ x: [1, 44], z: [1, 27], type: 'glass', no: 'T' },
// 右上:成长区(左 8 间) | 过道 | 国际区(右 7 间)
// 编号从底部开始:G1 在最下(行23-24),G8 在最上(行1-3
// 企业按 Excel 场地安排:成长1=Facebook 越南跨境电商 → 成长8=综合直播私域平台
{ x: [45, 51], z: [1, 3], type: 'office', zone: '成长区', no: 'G8', company: '综合性云南直播私域平台' },
{ x: [53, 54], z: [1, 3], type: 'hallway', no: 'H01' },
{ x: [55, 58], z: [1, 3], type: 'stair', no: 'W01' },
{ x: [45, 48], z: [5, 6], type: 'office', zone: '成长区', no: 'G7', company: '云南星瑞航空' },
{ x: [45, 48], z: [8, 9], type: 'office', zone: '成长区', no: 'G6', company: '五华区丽裳文化' },
{ x: [45, 48], z: [11, 12], type: 'office', zone: '成长区', no: 'G5', company: '南菌优培食用菌' },
{ x: [45, 48], z: [14, 15], type: 'office', zone: '成长区', no: 'G4', company: '昆明云韵体育' },
{ x: [45, 48], z: [17, 18], type: 'office', zone: '成长区', no: 'G3', company: '朵哈·玫瑰特色产业链' },
{ x: [45, 48], z: [20, 21], type: 'office', zone: '成长区', no: 'G2', company: '研X同行者网络' },
{ x: [45, 48], z: [23, 24], type: 'office', zone: '成长区', no: 'G1', company: 'Facebook越南跨境电商' },
{ x: [50, 53], z: [5, 25], type: 'hallway', no: 'H02' },
// 国际区编号从底部开始:I1 在最下(行23-24),I7 在最上(行5-6
// 企业按 Excel:国际1=仰光客厅 → 国际7=达岸教育管理
{ x: [55, 57], z: [5, 6], type: 'office', zone: '国际区', no: 'I7', company: '达岸教育管理' },
{ x: [55, 57], z: [8, 9], type: 'office', zone: '国际区', no: 'I6', company: '昆明舒诺生物科技' },
{ x: [55, 57], z: [11, 12], type: 'office', zone: '国际区', no: 'I5', company: '滇缅国际设计' },
{ x: [55, 57], z: [14, 15], type: 'office', zone: '国际区', no: 'I4', company: '酷享野农AI农业' },
{ x: [55, 57], z: [17, 18], type: 'office', zone: '国际区', no: 'I3', company: '中越生物医疗' },
{ x: [55, 57], z: [20, 21], type: 'office', zone: '国际区', no: 'I2', company: '云南上古绝学文化' },
{ x: [55, 57], z: [23, 24], type: 'office', zone: '国际区', no: 'I1', company: '仰光客厅' },
{ x: [45, 58], z: [26, 27], type: 'hallway', no: 'H03' },
// 加速区:11 间办公室(下排6间 + 上排5间),编号从右下角开始
// 下排从右到左:A1-A5 + 左下角园区管理办公室
// 上排从右到左:A6-A10
// 企业按 Excel 场地安排:加速1=云南派音 → 加速10=云南大学AI平台
// ===== 上排(5 间 + 1 待入驻)=====
{ x: [1, 4], z: [28, 32], type: 'office', zone: '加速区', no: 'A11', company: '待入驻' },
{ x: [6, 12], z: [28, 32], type: 'office', zone: '加速区', no: 'A10', company: '待入驻' },
{ x: [14, 16], z: [28, 32], type: 'office', zone: '加速区', no: 'A9', company: '云南大学AI+创业平台' },
{ x: [18, 21], z: [28, 32], type: 'office', zone: '加速区', no: 'A8', company: '瀚颖AI+教育信息咨询' },
{ x: [23, 26], z: [28, 32], type: 'office', zone: '加速区', no: 'A7', company: '云南廷秀文旅康养' },
{ x: [6, 12], z: [36, 40], type: 'office', zone: '加速区', no: 'A6', company: '人工智能机器人大模型训练' }, // x: [28, 31], z: [28, 32]
{ x: [32, 43], z: [28, 32], type: 'desk', no: 'W02' },
{ x: [45, 48], z: [28, 32], type: 'lounge', no: 'L01' },
{ x: [50, 58], z: [28, 32], type: 'hallway', no: 'H04' },
// 横向过道(行33-35
{ x: [1, 58], z: [33, 35], type: 'hallway', no: 'H05' },
// ===== 下排(6 间:A1-A5 + 园区管理办公室)=====
{ x: [1, 4], z: [36, 40], type: 'office', zone: '管理', no: 'M', company: '园区管理办公室' }, // x: [1, 4], z: [36, 40],
{ x: [28, 31], z: [28, 32], type: 'office', zone: '加速区', no: '', company: '' }, // x: [6, 12], z: [36, 40]
{ x: [14, 16], z: [36, 40], type: 'office', zone: '加速区', no: 'A5', company: '昆明智海银高文化科技' },
{ x: [18, 21], z: [36, 40], type: 'office', zone: '加速区', no: 'A4', company: '云南宸中低空经济' },
{ x: [23, 26], z: [36, 40], type: 'office', zone: '加速区', no: 'A3', company: '中泰研学合作' },
{ x: [28, 30], z: [36, 40], type: 'meeting', no: 'M02' }, // 小会议室(A12 位置)
{ x: [32, 33], z: [36, 40], type: 'stair', no: 'S01' }, // 楼道(真实楼梯通向一楼)
{ x: [35, 45], z: [36, 40], type: 'meeting', no: 'M01' }, // 大会议室(长桌+讲台)
{ x: [47, 49], z: [36, 40], type: 'office', zone: '加速区', no: 'A2', company: '米勒克尔蓝宝石珠宝' },
{ x: [51, 53.5], z: [36, 40], type: 'office', zone: '加速区', no: 'A1', company: '云南派音人工智能科技' },
];
/* ---- 水平墙段 [z, x0, x1](像素精确提取)----
说明:行1-3 顶部 G01 南墙在行4;行4/7/10/13/16/19/22/25 为成长区/国际区段间墙;
过道(列50-53)处无墙;行27 玻璃顶南墙;行28-40 加速区内部墙(单格垂直墙点) */
const HWALLS = [
[0, 0.5, 57.5], // 北外墙
[4, 44.5, 48.5],
[4, 51, 51],
[4, 54, 57.5], // 顶部 G01 南墙(含列51 隔墙;列50 为过道口)
[7, 44.5, 48.5],
[7, 54, 57.5],
[10, 44.5, 48.5],
[10, 54, 57.5],
[13, 44.5, 48.5],
[13, 54, 57.5],
[16, 44.5, 48.5],
[16, 54, 57.5],
[19, 44.5, 48.5],
[19, 54, 57.5],
[22, 44.5, 48.5],
[22, 54, 57.5],
[25, 44.5, 48.5],
[25, 54, 57.5],
[27, 0, 44], // 玻璃顶南墙(全高)
// 过道上侧
[32.5, 0.5, 2.5],
[32.5, 5, 10],
[32.5, 13, 15],
[32.5, 17.3, 19],
[32.5, 21, 25],
// z=36 横墙已去除;补相邻垂直墙(z36.6-40.5)间的横向短墙段(z=36.6,x 从第一堵起到第二堵-1)
[36, 0.5, 2.5],
[36, 5, 10],
[36, 13, 14.5],
[36, 17, 19],
[36, 22, 24],
[36, 27, 29],
[36, 33.5, 34], // 过道
[36, 37, 44],
[36, 46.3, 48.5],
[36, 51, 53.1], // 派音右
[41, 0, 53], // 南外墙
];
/* ---- 垂直墙段 [x, z0, z1](像素精确提取)---- */
const VWALLS = [
[0, 0.5, 41], // 西外墙
[5, 27, 32], [5, 36.6, 40.5],
[13, 27, 32], [13, 36.6, 40.5],
[17, 27, 32], [17, 36.6, 40.5],
[22, 27, 32], [22, 36.6, 40.5],
[27, 27, 32], [27, 36.6, 40.5],
[31, 36.6, 40.5], // 小会议室与楼道之间
[34, 36.6, 40.5], // 楼道与大会议室之间
[44, 0.5, 32], [44, 36.6, 40.5], // 玻璃顶东墙 / 大会议室西墙
[49, 4, 5], [49, 7, 8], [49, 10, 11], [49, 13, 14], [49, 16, 17], [49, 19, 20], [49, 22, 23], // 成长区段间西侧墙
[53.5, 4, 5], [53.5, 7, 8], [53.5, 10, 11], [53.5, 13, 14], [53.5, 16, 17], [53.5, 19, 20], [53.5, 22, 23], // 成长区段间西侧墙
[48.5, 36.6, 40.5], // 底部 A12/A13 之间
[53.5, 36.6, 40.5], // 底部 A1 右墙(X-0.5=53.5Z-0.5=[35.5,40.5]
[51, 0.5, 3.4], // 顶部共享工位西缘(G01 东墙)
[58, 0.5, 24.7], // 东外墙(原 [58,0,25],延伸覆盖底部 A1 东侧)
];
const W = 60, D = 42;
const cx = (x) => (x - W / 2) * U;
const cz = (z) => (z - D / 2) * U;
/* ---------- 基础组件 ---------- */
function Wall({ x, z, w, h, axis = 'z', color = WALL_COLOR, edge = WALL_EDGE, y = 0 }) {
const size = axis === 'z' ? [w, h, T] : [T, h, w];
return (
<mesh position={[x, y + h / 2, z]} castShadow receiveShadow>
<boxGeometry args={size} />
<meshStandardMaterial color={color} roughness={0.82} metalness={0.03} />
<Edges color={edge} threshold={20} />
</mesh>
);
}
/* 窗(一楼外墙上的窗格:玻璃面板 + 窗框) */
function Window({ x, z, w, h = 1.0, axis = 'z', y = 0.85 }) {
const size = axis === 'z' ? [w, h, 0.04] : [0.04, h, w];
return (
<mesh position={[x, y + h / 2, z]} castShadow>
<boxGeometry args={size} />
<meshStandardMaterial color="#bfe0ff" transparent opacity={0.5} roughness={0.08} metalness={0.2} />
</mesh>
);
}
/* 办公桌(办公桌 + 座椅) */
function Desk({ x, z, y = 0, rotY = 0 }) {
return (
<group position={[x, y, z]} rotation={[0, rotY, 0]}>
<mesh position={[0, 0.4, 0]} castShadow>
<boxGeometry args={[1.1, 0.05, 0.55]} />
<meshStandardMaterial color="#f0e6d2" roughness={0.6} metalness={0.05} />
</mesh>
<mesh position={[0, 0.2, 0]} castShadow>
<boxGeometry args={[0.06, 0.4, 0.5]} />
<meshStandardMaterial color="#d8cbb2" roughness={0.7} />
</mesh>
{/* 显示器 */}
<mesh position={[0, 0.62, -0.1]} castShadow>
<boxGeometry args={[0.4, 0.3, 0.04]} />
<meshStandardMaterial color="#2e3a4e" roughness={0.3} metalness={0.2} />
</mesh>
{/* 座椅 */}
<mesh position={[0.5, 0.24, 0]} castShadow>
<boxGeometry args={[0.45, 0.08, 0.45]} />
<meshStandardMaterial color="#5c6b85" roughness={0.7} />
</mesh>
<mesh position={[0.5, 0.45, 0]} castShadow>
<boxGeometry args={[0.45, 0.4, 0.06]} />
<meshStandardMaterial color="#5c6b85" roughness={0.7} />
</mesh>
</group>
);
}
/* 圆桌(茶几) */
function Table({ x, z, y = 0, s = 1 }) {
return (
<group position={[x, y, z]}>
<mesh position={[0, 0.18 * s, 0]} castShadow receiveShadow>
<cylinderGeometry args={[0.22 * s, 0.26 * s, 0.34 * s, 24]} />
<meshStandardMaterial color="#e9eef7" roughness={0.6} metalness={0.05} />
</mesh>
<mesh position={[0, 0.38 * s, 0]} castShadow>
<cylinderGeometry args={[0.32 * s, 0.32 * s, 0.05 * s, 28]} />
<meshStandardMaterial color="#ffffff" roughness={0.45} metalness={0.06} />
</mesh>
</group>
);
}
/* 会议长桌 + 座椅 */
function MeetingTable({ x, z, y = 0, rotY = 0, len = 2.6 }) {
return (
<group position={[x, y, z]} rotation={[0, rotY, 0]}>
<mesh position={[0, 0.42, 0]} castShadow>
<boxGeometry args={[len, 0.05, 1.0]} />
<meshStandardMaterial color="#e8dcc4" roughness={0.55} metalness={0.05} />
</mesh>
{[0, 1].map((i) => (
<mesh key={`leg${i}`} position={[len / 2 - 0.15 - i * (len - 0.3), 0.2, 0]} castShadow>
<boxGeometry args={[0.07, 0.4, 0.9]} />
<meshStandardMaterial color="#d8cbb2" roughness={0.7} />
</mesh>
))}
{[-0.5, 0, 0.5].map((dx) => (
<mesh key={`chair${dx}`} position={[dx * (len / 2 - 0.4), 0.24, 0.75]} castShadow>
<boxGeometry args={[0.5, 0.08, 0.5]} />
<meshStandardMaterial color="#4f5f7a" roughness={0.7} />
</mesh>
))}
</group>
);
}
/* 会议室小办公桌(小型方桌 + 座椅,多张布置,不穿墙) */
function MeetingDesk({ x, z, y = 0, rotY = 0 }) {
return (
<group position={[x, y, z]} rotation={[0, rotY, 0]}>
{/* 桌面 */}
<mesh position={[0, 0.42, 0]} castShadow>
<boxGeometry args={[0.9, 0.05, 0.6]} />
<meshStandardMaterial color="#e8dcc4" roughness={0.55} metalness={0.05} />
</mesh>
{/* 桌腿 */}
<mesh position={[0, 0.2, 0]} castShadow>
<boxGeometry args={[0.06, 0.46, 0.5]} />
<meshStandardMaterial color="#d8cbb2" roughness={0.7} />
</mesh>
{/* 座椅(桌前) */}
<mesh position={[0, 0.24, 0.45]} castShadow>
<boxGeometry args={[0.45, 0.08, 0.45]} />
<meshStandardMaterial color="#4f5f7a" roughness={0.7} />
</mesh>
<mesh position={[0, 0.45, 0.45]} castShadow>
<boxGeometry args={[0.45, 0.4, 0.06]} />
<meshStandardMaterial color="#4f5f7a" roughness={0.7} />
</mesh>
</group>
);
}
/* 讲台(会议室讲台) */
function Podium({ x, z, y = 0 }) { return (
<group position={[x, y, z]}>
<mesh position={[0, 0.5, 0]} castShadow>
<boxGeometry args={[0.7, 1.0, 0.5]} />
<meshStandardMaterial color="#b9986a" roughness={0.6} metalness={0.1} />
</mesh>
<mesh position={[0, 1.02, 0]}>
<boxGeometry args={[0.76, 0.05, 0.56]} />
<meshStandardMaterial color="#a8875c" roughness={0.5} metalness={0.1} />
</mesh>
</group>
);
}
/* 工位桌(共享工位区) */
function Workstation({ x, z, y = 0, rotY = 0 }) {
return (
<group position={[x, y, z]} rotation={[0, rotY, 0]}>
<mesh position={[0, 0.4, 0]} castShadow>
<boxGeometry args={[1.0, 0.05, 0.6]} />
<meshStandardMaterial color="#e7dcc8" roughness={0.6} metalness={0.05} />
</mesh>
<mesh position={[0, 0.2, 0]} castShadow>
<boxGeometry args={[0.05, 0.4, 0.55]} />
<meshStandardMaterial color="#d0c3ab" roughness={0.7} />
</mesh>
<mesh position={[0, 0.6, 0]} castShadow>
<boxGeometry args={[0.5, 0.34, 0.04]} />
<meshStandardMaterial color="#33415c" roughness={0.3} metalness={0.2} />
</mesh>
<mesh position={[0.48, 0.24, 0]} castShadow>
<boxGeometry args={[0.4, 0.08, 0.4]} />
<meshStandardMaterial color="#6b7a94" roughness={0.7} />
</mesh>
</group>
);
}
/* 沙发 */
function Sofa({ x, z, y = 0, rotY = 0 }) {
return (
<group position={[x, y, z]} rotation={[0, rotY, 0]}>
<mesh position={[0, 0.2, 0]} castShadow>
<boxGeometry args={[0.9, 0.18, 0.4]} />
<meshStandardMaterial color="#8fbfa8" roughness={0.75} />
</mesh>
<mesh position={[0, 0.4, -0.15]} castShadow>
<boxGeometry args={[0.9, 0.42, 0.15]} />
<meshStandardMaterial color="#7fb39c" roughness={0.75} />
</mesh>
<mesh position={[-0.38, 0.33, 0.02]} castShadow>
<boxGeometry args={[0.14, 0.28, 0.38]} />
<meshStandardMaterial color="#7fb39c" roughness={0.75} />
</mesh>
<mesh position={[0.38, 0.33, 0.02]} castShadow>
<boxGeometry args={[0.14, 0.28, 0.38]} />
<meshStandardMaterial color="#7fb39c" roughness={0.75} />
</mesh>
<mesh position={[-0.3, 0.28, 0.22]} castShadow>
<boxGeometry args={[0.18, 0.14, 0.12]} />
<meshStandardMaterial color="#e8d9a8" roughness={0.8} />
</mesh>
</group>
);
}
/* 绿植 */
function Plant({ x, z, y = 0, s = 1 }) {
return (
<group position={[x, y, z]}>
<mesh position={[0, 0.2 * s, 0]} castShadow>
<cylinderGeometry args={[0.08 * s, 0.11 * s, 0.4 * s, 16]} />
<meshStandardMaterial color="#c9b28a" roughness={0.8} />
</mesh>
<mesh position={[0, 0.56 * s, 0]} castShadow>
<sphereGeometry args={[0.3 * s, 20, 20]} />
<meshStandardMaterial color="#3fae7f" roughness={0.7} />
</mesh>
</group>
);
}
/* 楼梯(楼梯间:从二楼通向一楼 —— 踏步自二楼逐级向下延伸至地面,居中铺满房间) */
function Stairs({ x, z, y = 0, rotY = 0, steps = 9, stepW = 0.9, stepD = 0.24, rise }) {
// 默认向下:最高一级落在 y(二楼楼面),最低一级落在 y - rise*(steps-1) = 0(一楼地面)
const r = rise ?? (steps > 1 ? y / (steps - 1) : y);
const len = steps * stepD;
const els = [];
for (let i = 0; i < steps; i++) {
const dz = i * stepD - len / 2; // 居中铺满房间(避免穿墙)
const h = y - r * i; // 从二楼向下逐级降低
els.push(
<mesh key={`st${i}`} position={[0, h - r / 2, dz]} castShadow receiveShadow>
<boxGeometry args={[stepW, r, stepD]} />
<meshStandardMaterial color="#e8eef5" roughness={0.6} metalness={0.02} />
</mesh>
);
}
return (
<group position={[x, y, z]} rotation={[0, rotY, 0]}>
{els}
</group>
);
}
function ZoneTag({ x, y, z, cls, label, sub, no }) {
return (
<Html position={[x, y, z]} center distanceFactor={13} zIndexRange={[40, 0]} style={{ pointerEvents: 'none' }}>
<div className={`bd3d-tag${cls ? ` ${cls}` : ''}`}>
{sub && <span className={`bd3d-bubble ${sub.cls}`}>{sub.text}</span>}
<span className="bd3d-roomname">
{no && <b className="bd3d-no">{no}</b>}
{label}
</span>
</div>
</Html>
);
}
/* ---------- 建筑主体 ---------- */
function Building() {
const floors = [];
const walls = [];
const roofs = [];
const furniture = [];
const tags = [];
/* ===== 1. 一楼大厅(玻璃顶下方挑空) ===== */
const hx0 = cx(0), hx1 = cx(44);
const hz0 = cz(0), hz1 = cz(27);
const hcX = (hx0 + hx1) / 2, hcZ = (hz0 + hz1) / 2;
const hW = hx1 - hx0, hD = hz1 - hz0;
// 一楼地面(统一色)
floors.push(
<mesh key="hf" position={[hcX, 0.02, hcZ]} receiveShadow>
<boxGeometry args={[hW - 0.3, 0.04, hD - 0.3]} />
<meshStandardMaterial color={FLOOR_COLOR} roughness={0.9} metalness={0} />
</mesh>
);
// 一楼大厅外墙(高度 = FLOOR2,不穿过二楼楼板)+ 窗户
const hallWalls = [
{ axis: 'z', pos: 0, from: 0, to: 44 },
{ axis: 'z', pos: 41.1, from: 0, to: 44 },
{ axis: 'x', pos: 0, from: 0, to: 41 },
{ axis: 'x', pos: 44, from: 0, to: 41 },
];
hallWalls.forEach((wl, i) => {
const posW = wl.axis === 'z' ? cz(wl.pos) : cx(wl.pos);
const fromW = wl.axis === 'z' ? cx(wl.from) : cz(wl.from);
const toW = wl.axis === 'z' ? cx(wl.to) : cz(wl.to);
walls.push(
<Wall key={`hw${i}`} x={wl.axis === 'z' ? (fromW + toW) / 2 : posW} z={wl.axis === 'z' ? posW : (fromW + toW) / 2}
w={Math.abs(toW - fromW)} h={FLOOR2} axis={wl.axis} color="#e9f0f8" edge="#9db1cf" y={0} />
);
// 窗(沿墙均匀分布)
const len = Math.abs(toW - fromW);
const winN = Math.max(2, Math.floor(len / 2.4));
for (let wi = 0; wi < winN; wi++) {
const t = (wi + 0.5) / winN;
const pos = fromW + len * t;
if (wl.axis === 'z') {
walls.push(<Window key={`win${i}_${wi}`} x={pos} z={posW + (wl.pos === 27 ? -0.03 : 0.03)} w={len / winN - 0.4} axis="z" />);
} else {
walls.push(<Window key={`win${i}_${wi}`} x={posW + (wl.pos === 44 ? -0.03 : 0.03)} z={pos} w={len / winN - 0.4} axis="x" />);
}
}
});
// 大厅立柱
for (let i = 0; i <= 8; i++) {
const t = i / 8;
const px = hx0 + 0.8 + (hW - 1.6) * t;
furniture.push(<mesh key={`c1${i}`} position={[px, FLOOR2 / 2, hz0 + 1.5]} castShadow>
<cylinderGeometry args={[0.09, 0.12, FLOOR2, 12]} />
<meshStandardMaterial color="#eef2f9" roughness={0.5} metalness={0.1} />
</mesh>);
furniture.push(<mesh key={`c2${i}`} position={[px, FLOOR2 / 2, hz1 - 1.5]} castShadow>
<cylinderGeometry args={[0.09, 0.12, FLOOR2, 12]} />
<meshStandardMaterial color="#eef2f9" roughness={0.5} metalness={0.1} />
</mesh>);
}
// 吊灯(二楼楼板下方)
[hcX - 4, hcX, hcX + 4].forEach((lx, li) => {
furniture.push(
<group key={`hl${li}`} position={[lx, 0, hcZ]}>
<mesh position={[0, FLOOR2 - 0.4, 0]}><cylinderGeometry args={[0.04, 0.04, 0.35, 10]} /><meshStandardMaterial color="#b9c8de" roughness={0.3} metalness={0.5} /></mesh>
<mesh position={[0, FLOOR2 - 0.75, 0]}><sphereGeometry args={[0.18, 24, 24]} /><meshStandardMaterial color="#fff3cf" emissive="#ffd97a" emissiveIntensity={1.1} roughness={0.15} /></mesh>
</group>
);
});
// 大厅家具
furniture.push(<Sofa key="hs1" x={hcX - 2} z={hcZ + 1.5} />);
furniture.push(<Sofa key="hs2" x={hcX + 2} z={hcZ - 1.5} />);
furniture.push(<Table key="ht1" x={hcX} z={hcZ} s={1.2} />);
furniture.push(<Plant key="hp1" x={hx0 + 2.4} z={hcZ} s={1.3} />);
furniture.push(<Plant key="hp2" x={hx1 - 2.4} z={hcZ} s={1.3} />);
tags.push(<ZoneTag key="hall" x={hcX} y={FLOOR2 + 0.4} z={hcZ} cls="glass" label="一楼大厅" />);
/* ===== 2. 二楼整体地面(两块拼接,避开玻璃顶中庭) ===== */
// 右上块:x[45,58] z[1,28]
floors.push(
<mesh key="f2r" position={[(cx(45) + cx(58)) / 2, FLOOR2, (cz(1) + cz(28)) / 2]} receiveShadow castShadow>
<boxGeometry args={[cx(58) - cx(45) + 0.2, 0.05, cz(28) - cz(1) + 0.2]} />
<meshStandardMaterial color={FLOOR_COLOR} roughness={0.88} metalness={0} />
</mesh>
);
// 加速块:x[1,58] z[28,41]
floors.push(
<mesh key="f2a" position={[(cx(1) + cx(58)) / 2, FLOOR2, (cz(28) + cz(41)) / 2]} receiveShadow castShadow>
<boxGeometry args={[cx(58) - cx(1) + 0.2, 0.05, cz(41) - cz(28) + 0.2]} />
<meshStandardMaterial color={FLOOR_COLOR} roughness={0.88} metalness={0} />
</mesh>
);
/* ===== 3. 房间家具 + 标签(地面统一色,不铺色块) ===== */
ROOMS.forEach((r, i) => {
const x0 = cx(r.x[0]), x1 = cx(r.x[1]);
const z0 = cz(r.z[0]), z1 = cz(r.z[1]);
const cX = (x0 + x1) / 2, cZ = (z0 + z1) / 2;
const w = x1 - x0, d = z1 - z0;
if (r.type === 'glass') {
// 玻璃顶:二楼中庭挑空;绿植放中庭平台
furniture.push(<Plant key={`g1${i}`} x={x0 + 1.8} z={z0 + 1.8} y={FLOOR2} s={1} />);
furniture.push(<Plant key={`g2${i}`} x={x1 - 1.8} z={z1 - 1.8} y={FLOOR2} s={1} />);
// 玻璃天窗(青绿半透明 + 框架梁)
const gw = w + 0.4, gd = d + 0.4;
roofs.push(
<mesh key="growf" position={[cX, FLOOR2 + ROOM_H + 0.25, cZ]} castShadow>
<boxGeometry args={[gw, 0.05, gd]} />
<meshStandardMaterial color="#8fd8c0" transparent opacity={0.28} roughness={0.05} metalness={0.25} />
</mesh>
);
for (let k = 0; k <= 8; k++) {
const t = k / 8;
const px = x0 - 0.2 + gw * t;
roofs.push(<mesh key={`gx${i}_${k}`} position={[px, FLOOR2 + ROOM_H + 0.28, cZ]}><boxGeometry args={[0.06, 0.09, gd + 0.4]} /><meshStandardMaterial color="#dbe7f5" roughness={0.4} metalness={0.2} /></mesh>);
}
for (let k = 0; k <= 4; k++) {
const t = k / 4;
const pz = z0 - 0.2 + gd * t;
roofs.push(<mesh key={`gz${i}_${k}`} position={[cX, FLOOR2 + ROOM_H + 0.28, pz]}><boxGeometry args={[gw + 0.4, 0.09, 0.06]} /><meshStandardMaterial color="#dbe7f5" roughness={0.4} metalness={0.2} /></mesh>);
}
tags.push(<ZoneTag key={`zt${i}`} x={cX} y={FLOOR2 + ROOM_H + 0.7} z={cZ} cls="glass" label="玻璃顶" />);
return;
}
// 过道不渲染顶棚(hallway:无顶棚,避免半透明色块干扰地面观感)
// 家具按功能区分(地面统一色,靠资产区分)
if (r.type === 'office') {
// 办公室:办公桌按房间面积排布,留边距不穿墙
const roomW = r.x[1] - r.x[0];
const roomD = r.z[1] - r.z[0];
// 窄房间(宽 < 2.6):单桌且旋转 90°(桌+椅沿深度方向排布,避免横向穿墙)
const narrow = roomW < 2.6;
if (narrow) {
furniture.push(
<Desk key={`d${i}_0`} x={cX} z={cZ} y={FLOOR2} rotY={Math.PI / 2} />
);
} else {
const nX = Math.max(1, Math.min(3, Math.floor(roomW / 2.4)));
const nZ = Math.max(1, Math.min(2, Math.floor(roomD / 2.4)));
const total = Math.min(4, nX * nZ);
let idx = 0;
for (let ix = 0; ix < nX && idx < total; ix++) {
for (let iz = 0; iz < nZ && idx < total; iz++, idx++) {
// 留边距居中,确保桌面/椅不穿墙
const dx = (ix + 0.5) / nX - 0.5;
const dz = (iz + 0.5) / nZ - 0.5;
furniture.push(
<Desk key={`d${i}_${idx}`} x={cX + dx * (w - 1.6)} z={cZ + dz * (d - 1.6)} y={FLOOR2} />
);
}
}
}
} else if (r.type === 'desk') {
// 共享工位区:工位桌多张
const nX = Math.max(2, Math.floor((r.x[1] - r.x[0]) / 3));
const nZ = Math.max(1, Math.floor((r.z[1] - r.z[0]) / 3));
let idx = 0;
for (let ix = 0; ix < nX; ix++) {
for (let iz = 0; iz < nZ; iz++, idx++) {
const dx = (ix + 0.5) / nX - 0.5;
const dz = (iz + 0.5) / nZ - 0.5;
furniture.push(<Workstation key={`ws${i}_${idx}`} x={cX + dx * w} z={cZ + dz * d} y={FLOOR2} />);
}
}
} else if (r.type === 'lounge') {
// 休息区:多张沙发 + 茶几
furniture.push(<Sofa key={`l1${i}`} x={cX - 0.6} z={cZ - 0.5} y={FLOOR2} rotY={0.5} />);
furniture.push(<Sofa key={`l2${i}`} x={cX + 0.6} z={cZ - 0.5} y={FLOOR2} rotY={-0.5} />);
furniture.push(<Sofa key={`l3${i}`} x={cX} z={cZ + 0.7} y={FLOOR2} rotY={Math.PI} />);
furniture.push(<Table key={`l4${i}`} x={cX} z={cZ} y={FLOOR2} s={0.8} />);
} else if (r.type === 'meeting') {
// 会议室:多张小办公桌(MeetingDesk)按房间网格排布,不穿墙且满布
const roomW = r.x[1] - r.x[0];
const roomD = r.z[1] - r.z[0];
const nD = Math.max(1, Math.min(6, Math.floor((roomW - 0.8) / 1.2))); // 沿 X 列(留边距,桌宽0.9+隙)
const nR = Math.max(1, Math.min(4, Math.floor((roomD - 0.8) / 1.2))); // 沿 Z 行
const total = Math.min(12, nD * nR); // 上限 12 张小桌
let idx = 0;
for (let ix = 0; ix < nD && idx < total; ix++) {
for (let iz = 0; iz < nR && idx < total; iz++, idx++) {
// 留边距居中(每侧 0.5 单位),避免穿墙
const dx = (ix + 0.5) / nD - 0.5;
const dz = (iz + 0.5) / nR - 0.5;
furniture.push(
<MeetingDesk key={`md${i}_${idx}`}
x={cX + dx * (w - 1.0)} z={cZ + dz * (d - 1.0)} y={FLOOR2} />
);
}
}
// 大会议室额外加讲台
const aBig = roomW * roomD >= 40;
if (aBig) furniture.push(<Podium key={`m3${i}`} x={cX - (w - 1.0) / 2 + 0.6} z={cZ} y={FLOOR2} />);
} else if (r.type === 'stair') {
// 楼梯间:真实楼梯(二楼 → 一楼),自动按房间宽/深选择梯段方向与级数
const alongX = (x1 - x0) >= (z1 - z0); // 房间宽≥深 → 梯段沿 X 方向
const sX = (x0 + x1) / 2;
const sZ = (z0 + z1) / 2;
const sLen = alongX ? (x1 - x0) : (z1 - z0);
const steps = Math.max(4, Math.min(11, Math.round(sLen / 0.45)));
furniture.push(
<Stairs key={`st${i}`} x={sX} z={sZ} y={FLOOR2} rotY={alongX ? Math.PI / 2 : Math.PI}
steps={steps} stepW={Math.min(0.9, (alongX ? d : w) - 0.1)} stepD={sLen / steps} />
);
}
// 标签:每个房间显示编号(编号 + 名称),办公室显示入驻企业名;过道不显示标签
if (r.type === 'hallway') return;
const noLabel = r.no || '';
const typeLabel =
r.type === 'desk' ? '共享工位区' :
r.type === 'lounge' ? '休息区' :
r.type === 'meeting' ? '会议室' :
r.type === 'stair' ? '楼梯间' :
r.type === 'office' ? (r.company || r.zone || '办公室') :
'过道';
tags.push(
<ZoneTag key={`zt${i}`} x={cX} y={FLOOR2 + ROOM_H + 0.55} z={cZ}
cls={r.type === 'office' ? 'office' : r.type}
no={noLabel}
label={typeLabel}
/>
);
});
/* ===== 4. 墙体(像素墙段精确生成,无交叉穿出) ===== */
HWALLS.forEach(([z, x0, x1], i) => {
walls.push(
<Wall key={`h_${i}`} x={cx((x0 + x1) / 2)} z={cz(z)} w={(x1 - x0 + 1) * U} h={ROOM_H} axis="z" y={FLOOR2} />
);
});
VWALLS.forEach(([x, z0, z1], i) => {
walls.push(
<Wall key={`v_${i}`} x={cx(x)} z={cz((z0 + z1) / 2)} w={(z1 - z0 + 1) * U} h={ROOM_H} axis="x" y={FLOOR2} />
);
});
// 玻璃顶中庭四周矮围栏(二楼,0.65 高)
const gl = ROOMS.find((r) => r.type === 'glass');
const gx0 = cx(gl.x[0]), gx1 = cx(gl.x[1]);
const gz0 = cz(gl.z[0]), gz1 = cz(gl.z[1]);
const railH = 0.65;
walls.push(<Wall key="railN" x={(gx0 + gx1) / 2} z={gz0} w={gx1 - gx0} h={railH} axis="z" y={FLOOR2} />);
walls.push(<Wall key="railS" x={(gx0 + gx1) / 2} z={gz1} w={gx1 - gx0} h={railH} axis="z" y={FLOOR2} />);
walls.push(<Wall key="railW" x={gx0} z={(gz0 + gz1) / 2} w={gz1 - gz0} h={railH} axis="x" y={FLOOR2} />);
walls.push(<Wall key="railE" x={gx1} z={(gz0 + gz1) / 2} w={gz1 - gz0} h={railH} axis="x" y={FLOOR2} />);
/* ===== 过道/大厅绿植(靠墙摆放 · 严禁穿墙 · 排列整齐) =====
每盆绿植按【墙线 wall(x 或 z)+ 内侧方向 side(+1 向过道内侧 / -1)+ 沿墙间距】确定:
中心离墙 = 冠半径 + 固定余量 0.15,保证完全落在过道地面内、不越墙、不挡门 */
const plant = (s, wall, side, offset) => ({
s,
x: wall.kind === 'x' ? wall.v + side * (0.28 * s + 0.18) : offset,
z: wall.kind === 'z' ? wall.v + side * (0.28 * s + 0.18) : offset,
});
// wall: 沿墙线;offset: 沿该墙线的间距(x 或 z 坐标)
const corridorPlants = [
// —— H04 竖向过道西墙(x=50,过道 x50-58,内侧 = +x 方向)——
plant(0.9, { kind: 'x', v: 50 }, 1, 30),
plant(0.8, { kind: 'x', v: 50 }, 1, 29.2),
// —— H02 竖向过道(x50-53)绿植靠东内侧 x≈50.6,沿 z 等距 ——
plant(0.85, { kind: 'x', v: 50.5 }, 1, 20),
plant(0.85, { kind: 'x', v: 50.5 }, 1, 14),
plant(0.85, { kind: 'x', v: 50.5 }, 1, 8),
// —— H03 横向过道北墙(z=28,过道在 z<28,内侧 = -z)——
// plant(0.9, { kind: 'z', v: 28 }, -1, 48),
// plant(0.9, { kind: 'z', v: 28 }, -1, 54),
// plant(0.9, { kind: 'z', v: 28 }, -1, 56),
// —— 中庭围栏(z=1 北外墙内侧 +z / z=27 南缘内侧 -z)——
// plant(0.85, { kind: 'z', v: 1 }, 1, 40),
// plant(0.85, { kind: 'z', v: 27 }, -1, 40),
// plant(0.85, { kind: 'z', v: 27 }, -1, 2),
// —— W02 共享工位区南缘(z=32,内侧 = +z 回工位区)——
plant(0.8, { kind: 'z', v: 32 }, 1, 36),
plant(0.8, { kind: 'z', v: 32 }, 1, 40),
];
corridorPlants.forEach((p, i) => {
furniture.push(<Plant key={`cp${i}`} x={cx(p.x)} z={cz(p.z)} y={FLOOR2} s={p.s} />);
});
return (
<group>
{/* 地基 */}
<mesh position={[0, -0.09, 0]} receiveShadow castShadow>
<boxGeometry args={[W * U + 0.4, 0.18, D * U + 0.4]} />
<meshStandardMaterial color="#eef2f8" roughness={0.92} metalness={0} />
<Edges color="#eef2f8" threshold={20} />
</mesh>
{floors}
{walls}
{roofs}
{furniture}
{tags}
{/* 底座平台 */}
<mesh position={[0, -0.38, 0]} receiveShadow castShadow>
<boxGeometry args={[W * U + 0.8, 0.44, D * U + 0.8]} />
<meshStandardMaterial color="#eef2f8" roughness={0.75} metalness={0.02} />
<Edges color="#eef2f8" threshold={20} />
</mesh>
</group>
);
}
/* ---------- 场景 ---------- */
export default function Building3D({ interactive = true }) {
return (
<div className="bd3d-wrap">
<Canvas
shadows
dpr={[1, 1.75]}
camera={{ position: [22.2, 17.6, 26.7], fov: 38 }}
gl={{ antialias: true, alpha: true }}
>
<ambientLight intensity={0.55} />
<directionalLight position={[14, 22, 12]} intensity={1.1} castShadow shadow-mapSize={[2048, 2048]}
shadow-camera-left={-26} shadow-camera-right={26} shadow-camera-top={26} shadow-camera-bottom={-26} />
<directionalLight position={[-14, 10, -10]} intensity={0.3} />
<hemisphereLight args={['#d3e0f0', '#d3e0f0', 0.35]} />
<Building />
<Grid position={[0, -0.62, 0]} args={[80, 80]} cellSize={0.8} cellThickness={0.6} cellColor="#d3e0f0"
sectionSize={4} sectionThickness={1} sectionColor="#eef2f8" fadeDistance={40} fadeStrength={2.5} infiniteGrid />
<OrbitControls makeDefault autoRotate={interactive} autoRotateSpeed={0.5} enablePan={false} enableZoom={interactive}
minDistance={10} maxDistance={55} maxPolarAngle={Math.PI / 2.05} minPolarAngle={Math.PI / 7} target={[0, 1.6, 0]} />
</Canvas>
</div>
);
}