From 2a7f51a2027327faefd6482c7c8f54a52df96d92 Mon Sep 17 00:00:00 2001 From: Pine Date: Mon, 17 Aug 2026 18:37:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=203D=20=E5=9B=AD=E5=8C=BA=E5=BB=BA?= =?UTF-8?q?=E7=AD=91=E6=A8=A1=E5=9E=8B=EF=BC=88=E5=83=8F=E7=B4=A0=E7=BA=A7?= =?UTF-8?q?=E9=87=8D=E5=BB=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Building3D.jsx:React Three Fiber 双良节能大楼 3D 模型 - 按平面图 60×42 网格重建:玻璃顶中庭/成长区/国际区/加速区/楼道/会议室 - 房间编号 A1-A9/I1-I7/G1-G8 + 入驻企业标签 + 门开向过道 - 共享墙/整体地面/一楼挑空大厅 - scripts/parse_layout.py:纯标准库 PNG 像素解析脚本 --- scripts/parse_layout.py | 119 ++++++ src/components/Building3D.jsx | 693 ++++++++++++++++++++++++++++++++++ 2 files changed, 812 insertions(+) create mode 100644 scripts/parse_layout.py create mode 100644 src/components/Building3D.jsx diff --git a/scripts/parse_layout.py b/scripts/parse_layout.py new file mode 100644 index 0000000..7d2b046 --- /dev/null +++ b/scripts/parse_layout.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +按像素精确解析 PNG 平面图 —— 精确颜色分类 + 高分辨率网格化 +颜色规则(依据实际采样校准): + W 黑色(0,0,0) = 墙壁 + O 灰色(176,176,176) = 办公室 + P 蓝色(64,176,240) = 过道 + R 绿色(80,176,96) = 休息厅 + Y 橙色(240,112,16) = 共享工位区 / 楼道 + C 青绿(80,224,192) = 玻璃顶 + . 白色(240,240,240) = 空白/底 +""" +import zlib, struct, sys, collections + +def read_png(path): + with open(path, 'rb') as f: + data = f.read() + assert data[:8] == b'\x89PNG\r\n\x1a\n', 'Not a PNG' + pos = 8 + width = height = None + bit_depth = color_type = None + idat = b'' + while pos < len(data): + ln = struct.unpack('>I', data[pos:pos+4])[0] + typ = data[pos+4:pos+8] + chunk = data[pos+8:pos+8+ln] + pos += 12 + ln + if typ == b'IHDR': + width, height, bit_depth, color_type, _, _, _ = struct.unpack('>IIBBBBB', chunk) + elif typ == b'IDAT': + idat += chunk + elif typ == b'IEND': + break + assert bit_depth == 8 and color_type in (2, 6) and width and height + raw = zlib.decompress(idat) + channels = 4 if color_type == 6 else 3 + bpp = channels + stride = width * bpp + rows = [] + off = 0 + prev = bytearray(stride) + for y in range(height): + ft = raw[off]; off += 1 + line = bytearray(raw[off:off+stride]); off += stride + if ft == 1: + for i in range(bpp, stride): line[i] = (line[i] + line[i-bpp]) & 0xFF + elif ft == 2: + for i in range(stride): line[i] = (line[i] + prev[i]) & 0xFF + elif ft == 3: + for i in range(stride): + a = line[i-bpp] if i >= bpp else 0 + line[i] = (line[i] + ((a + prev[i]) >> 1)) & 0xFF + elif ft == 4: + for i in range(stride): + a = line[i-bpp] if i >= bpp else 0 + b = prev[i] + c = prev[i-bpp] if i >= bpp else 0 + p = a + b - c + pa, pb, pc = abs(p-a), abs(p-b), abs(p-c) + pr = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c) + line[i] = (line[i] + pr) & 0xFF + rows.append(bytes(line)) + prev = line + rgba = [] + for line in rows: + if channels == 4: + rgba.append([(line[i], line[i+1], line[i+2], line[i+3]) for i in range(0, stride, 4)]) + else: + rgba.append([(line[i], line[i+1], line[i+2], 255) for i in range(0, stride, 3)]) + return width, height, rgba + +# 精确色板(中心色 + 容差) +PALETTE = [ + ('W', (0, 0, 0), 70), # 黑 = 墙壁 + ('O', (176, 176, 176), 40), # 灰 = 办公室 + ('P', (64, 176, 240), 45), # 蓝 = 过道 + ('R', (80, 176, 96), 40), # 绿 = 休息厅 + ('Y', (240, 112, 16), 55), # 橙 = 共享工位/楼道 + ('C', (80, 224, 192), 55), # 青绿 = 玻璃顶 + ('.', (240, 240, 240), 40), # 白 = 空白 +] + +def classify(rgb): + r, g, b = rgb[:3] + best, best_d = '?', 1e9 + for tag, (cr, cg, cb), tol in PALETTE: + d = abs(r-cr) + abs(g-cg) + abs(b-cb) + if d <= tol * 3 and d < best_d: + best, best_d = tag, d + return best + +def main(path, grid_cols=48, grid_rows=28, sub=3): + w, h, rgba = read_png(path) + print(f'PNG: {w}x{h} 网格: {grid_cols}x{grid_rows}') + cw, ch = w / grid_cols, h / grid_rows + grid = [] + for gy in range(grid_rows): + row = [] + for gx in range(grid_cols): + cx = int((gx + 0.5) * cw) + cy = int((gy + 0.5) * ch) + votes = collections.Counter() + rad_x, rad_y = max(2, int(cw*0.3)), max(2, int(ch*0.3)) + for dy in range(-rad_y, rad_y+1, sub): + for dx in range(-rad_x, rad_x+1, sub): + yy, xx = cy+dy, cx+dx + if 0 <= yy < h and 0 <= xx < w: + votes[classify(rgba[yy][xx])] += 1 + row.append(votes.most_common(1)[0][0]) + grid.append(row) + # 打印 + print(' ' + ''.join(str(c % 10) for c in range(grid_cols))) + for gy, row in enumerate(grid): + print(f'{gy:2d} ' + ''.join(row)) + return grid + +if __name__ == '__main__': + path = sys.argv[1] if len(sys.argv) > 1 else '详细的布局.png' + main(path) diff --git a/src/components/Building3D.jsx b/src/components/Building3D.jsx new file mode 100644 index 0000000..970ae94 --- /dev/null +++ b/src/components/Building3D.jsx @@ -0,0 +1,693 @@ +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: 'desk', 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: [28, 31], z: [28, 32], type: 'office', zone: '加速区', no: 'A6', company: '人工智能机器人大模型训练' }, + { 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: [6, 12], z: [36, 40], type: 'office', zone: '加速区', no: 'A12', company: '待入驻' }, + { 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, 54], 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, 58], // 北外墙 + [4, 44, 49], [4, 51, 51], [4, 54, 58], // 顶部 G01 南墙(含列51 隔墙;列50 为过道口) + [7, 44, 49], [7, 54, 58], + [10, 44, 49], [10, 54, 58], + [13, 44, 49], [13, 54, 58], + [16, 44, 49], [16, 54, 58], + [19, 44, 49], [19, 54, 58], + [22, 44, 49], [22, 54, 58], + [25, 44, 49], [25, 54, 58], + [27, 0, 44], // 玻璃顶南墙(全高) + [41, 0, 53], // 南外墙 +]; +/* ---- 垂直墙段 [x, z0, z1](像素精确提取)---- */ +const VWALLS = [ + [0, 0, 41], // 西外墙 + [5, 27, 32], [5, 36, 41], + [13, 27, 32], [13, 36, 41], + [17, 27, 32], [17, 36, 41], + [22, 27, 32], [22, 36, 41], + [27, 27, 32], [27, 36, 41], + [31, 36, 41], // 小会议室与楼道之间 + [34, 36, 41], // 楼道与大会议室之间 + [44, 0, 32], [44, 36, 41], // 玻璃顶东墙 / 大会议室西墙 + [46, 36, 41], // 大会议室东墙 + [49, 4, 5], [49, 7, 8], [49, 10, 11], [49, 13, 14], [49, 16, 17], [49, 19, 20], [49, 22, 23], // 成长区段间西侧墙 + [49, 36, 41], // 底部 + [50, 36, 41], // 底部 A12/A13 之间 + [51, 0, 4], // 顶部共享工位西缘(G01 东墙) + [58, 0, 25], // 东外墙 +]; + +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 ( + + + + + + ); +} + +/* 窗(一楼外墙上的窗格:玻璃面板 + 窗框) */ +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 ( + + + + + ); +} + +/* 门(门框 + 门板 + 把手) */ +function Door({ x, z, rotY = 0, y = 0 }) { + return ( + + + + + + + + + + + + + + + ); +} + +/* 办公桌(办公桌 + 座椅) */ +function Desk({ x, z, y = 0, rotY = 0 }) { + return ( + + + + + + + + + + {/* 显示器 */} + + + + + {/* 座椅 */} + + + + + + + + + + ); +} + +/* 圆桌(茶几) */ +function Table({ x, z, y = 0, s = 1 }) { + return ( + + + + + + + + + + + ); +} + +/* 会议长桌 + 座椅 */ +function MeetingTable({ x, z, y = 0, rotY = 0, len = 2.6 }) { + return ( + + + + + + {[0, 1].map((i) => ( + + + + + ))} + {[-0.5, 0, 0.5].map((dx) => ( + + + + + ))} + + ); +} + +/* 讲台(会议室讲台) */ +function Podium({ x, z, y = 0 }) { + return ( + + + + + + + + + + + ); +} + +/* 工位桌(共享工位区) */ +function Workstation({ x, z, y = 0, rotY = 0 }) { + return ( + + + + + + + + + + + + + + + + + + + ); +} + +/* 沙发 */ +function Sofa({ x, z, y = 0, rotY = 0 }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +} + +/* 绿植 */ +function Plant({ x, z, y = 0, s = 1 }) { + return ( + + + + + + + + + + + ); +} + +/* 楼梯(楼道:从二楼通向一楼,真实踏步+扶手) */ +function Stairs({ x, z, y = 0, rotY = 0, steps = 9, stepW = 0.9, stepD = 0.24, rise = 0.21 }) { + const els = []; + for (let i = 0; i < steps; i++) { + const dz = i * stepD; + const h = y + rise * i; + els.push( + + + + + ); + } + // 扶手(两侧) + const railLen = steps * stepD + 0.3; + const railH = y + rise * (steps - 1) + 0.95; + [-stepW / 2 - 0.06, stepW / 2 + 0.06].forEach((rx) => { + els.push( + + + + + ); + els.push( + + + + + ); + }); + return ( + + {els} + + ); +} + +function ZoneTag({ x, y, z, cls, label, sub, no }) { + return ( + +
+ {sub && {sub.text}} + + {no && {no}} + {label} + +
+ + ); +} + +/* ---------- 建筑主体 ---------- */ +function Building() { + const floors = []; + const walls = []; + const roofs = []; + const furniture = []; + const tags = []; + const doors = []; + + /* ===== 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( + + + + + ); + + // 一楼大厅外墙(高度 = FLOOR2,不穿过二楼楼板)+ 窗户 + const hallWalls = [ + { axis: 'z', pos: 0, from: 0, to: 44 }, + { axis: 'z', pos: 27, from: 0, to: 44 }, + { axis: 'x', pos: 0, from: 0, to: 27 }, + { axis: 'x', pos: 44, from: 0, to: 27 }, + ]; + 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( + + ); + // 窗(沿墙均匀分布) + 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(); + } else { + walls.push(); + } + } + }); + + // 大厅立柱 + for (let i = 0; i <= 8; i++) { + const t = i / 8; + const px = hx0 + 0.8 + (hW - 1.6) * t; + furniture.push( + + + ); + furniture.push( + + + ); + } + // 吊灯(二楼楼板下方) + [hcX - 4, hcX, hcX + 4].forEach((lx, li) => { + furniture.push( + + + + + ); + }); + // 大厅家具 + furniture.push(); + furniture.push(); + furniture.push(); + furniture.push(); + furniture.push(); + tags.push(); + + /* ===== 2. 二楼整体地面(两块拼接,避开玻璃顶中庭) ===== */ + // 右上块:x[45,58] z[1,28] + floors.push( + + + + + ); + // 加速块:x[1,58] z[28,41] + floors.push( + + + + + ); + + /* ===== 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(); + furniture.push(); + // 玻璃天窗(青绿半透明 + 框架梁) + const gw = w + 0.4, gd = d + 0.4; + roofs.push( + + + + + ); + for (let k = 0; k <= 8; k++) { + const t = k / 8; + const px = x0 - 0.2 + gw * t; + roofs.push(); + } + for (let k = 0; k <= 4; k++) { + const t = k / 4; + const pz = z0 - 0.2 + gd * t; + roofs.push(); + } + tags.push(); + return; + } + + // 过道顶棚(右侧竖过道、横向过道) + if (r.type === 'hallway') { + roofs.push( + + + + + ); + return; + } + + // 家具按功能区分(地面统一色,靠资产区分) + if (r.type === 'office') { + // 办公室:办公桌(按面积放置多张) + const nX = Math.max(1, Math.floor((r.x[1] - r.x[0]) / 3.5)); + const nZ = Math.max(1, Math.floor((r.z[1] - r.z[0]) / 3.5)); + 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(); + } + } + } 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(); + } + } + } else if (r.type === 'lounge') { + // 休息区:多张沙发 + 茶几 + furniture.push(); + furniture.push(); + furniture.push(); + furniture.push(
); + } else if (r.type === 'meeting') { + // 会议室:按面积区分——大会议室(长桌+讲台),小会议室(单张会议桌) + const area = (r.x[1] - r.x[0]) * (r.z[1] - r.z[0]); + if (area >= 40) { + // 大会议室(M01):两张会议长桌 + 讲台 + furniture.push(); + furniture.push(); + furniture.push(); + } else { + // 小会议室(M02):单张会议长桌 + 四周座椅 + furniture.push(); + } + } else if (r.type === 'stair') { + // 楼道:真实楼梯(二楼 → 一楼) + const sX = (x0 + x1) / 2; + const sZ = (z0 + z1) / 2; + const sLen = z1 - z0; + furniture.push( + + ); + } + // 标签:每个房间显示编号(编号 + 名称),办公室显示入驻企业名 + 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( + + ); + }); + + /* ===== 4. 墙体(像素墙段精确生成,无交叉穿出) ===== */ + HWALLS.forEach(([z, x0, x1], i) => { + walls.push( + + ); + }); + VWALLS.forEach(([x, z0, z1], i) => { + walls.push( + + ); + }); + + // 玻璃顶中庭四周矮围栏(二楼,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(); + walls.push(); + walls.push(); + walls.push(); + + /* ===== 5. 办公室门:全部开向过道一侧(门嵌于墙段位置) ===== */ + ROOMS.forEach((r, i) => { + if (r.type !== 'office') return; + const z0 = cz(r.z[0]), z1 = cz(r.z[1]); + const cZ = (z0 + z1) / 2; + const cX = (cx(r.x[0]) + cx(r.x[1])) / 2; + + if (r.zone === '成长区') { + // 成长区东侧是过道(列50-53),门开在 x=49 墙段位置(列49 是墙) + doors.push(); + } else if (r.zone === '国际区') { + // 国际区西侧是过道,门开在 x=54 墙段位置(列54 是墙) + doors.push(); + } else if (r.zone === '加速区' || r.zone === '管理') { + if (r.z[1] <= 32) { + // 上排:南侧是横向过道(行33-35),门开在 z=32 + doors.push(); + } else { + // 下排:北侧是横向过道,门开在 z=36 + doors.push(); + } + } + }); + + /* ===== 6. 区域标签 ===== */ + tags.push(); + tags.push(); + tags.push(); + tags.push(); + tags.push(); + tags.push(); + + return ( + + {/* 地基 */} + + + + + + {floors} + {walls} + {doors} + {roofs} + {furniture} + {tags} + {/* 底座平台 */} + + + + + + + ); +} + +/* ---------- 场景 ---------- */ +export default function Building3D({ interactive = true }) { + return ( +
+ + + + + + + + + + + + +
+ ); +}