从定位点到贴墙走线:设备与链路的可视化复盘
体量做出来之后,这个项目真正的需求才浮出来:在一栋楼的某一层,放一台设备,让它和另一层的另一台设备连起来。 这篇文章讲这条交互链路里的三个子系统——定位点、走线、渲染——以及四个让我反复返工的坑。
一、定位点:先解决"点放在哪"
最初的做法是点到哪放哪,结果三个设备叠在同一个坐标上,模型糊成一团,标签也重叠。
第二版加了避让:
const clash = (tx, tz) => levelPins.some((it) => Math.hypot(it.x - tx, it.z - tz) < 1.7);
if (clash(px, pz)) {
for (let k = 1; k <= 24 && !found; k++) {
const ang = (k * Math.PI) / 3; // 60 度步进
const d = 1.8 * Math.ceil(k / 6); // 第 1 圈 1.8m,第 2 圈 3.6m…
const tx = px + Math.cos(ang) * d;
const tz = pz + Math.sin(ang) * d;
if (!clash(tx, tz) && test(tx, tz)) { px = tx; pz = tz; found = true; }
}
}
两个要点:
- 1.7 m 的冲突半径是按设备占地(约 0.9 m)加标签宽度定的,太小会重叠,太大会一开始就被弹到很远;
test(tx, tz)必须跟着走——外扩到第六圈就可能跑出楼板,那时候只能放弃错开,原地放下。
命名规则是"设备类型 + 编号":同层同类设备计数自增,所以第一个永远是"服务器 1"。改名走浮层,不占用主界面。
二、四种设备:低模也要认得出来
一共四种:架式服务器、台式电脑、网络交换机、监控摄像头。
都是程序化生成的几何,没有任何贴图,靠轮廓差异区分:
| 设备 | 特征几何 | 辨识点 |
|---|---|---|
| 架式服务器 | 扁平机箱 + 面板横条 + 指示灯 | 横向分层条纹 |
| 台式电脑 | 显示器(薄板立起)+ 主机箱(竖长方体) | 双体量组合 |
| 网络交换机 | 扁盒 + 前面板一排小方块 | 一排"网口" |
| 监控摄像头 | 立杆 + 云台 + 镜头筒 | 竖直杆 + 前伸筒 |
尺寸按真实比例:占地约 0.9 m(服务器机箱 0.88 × 0.62,交换机 0.9 × 0.5,机箱高 0.15 m),摄像头立杆 0.72 m、整体高约 0.8 m。没有放大——在 40 m 的楼板上,设备就该是这么小的一点,放大反而失真。
底部加一个光环(RingGeometry(0.5, 0.66)),选中时换成绿色外环。标签挂在 y = 1.5 的位置。
三、走线:可见性图 + Dijkstra,不是栅格 A*
链路最初的实现是直接连两点,穿过核心筒也照穿。这在小场景里能糊过去,一旦设备分处两个核心筒两侧就明显不合理。
改成在楼板平面内做真实寻路:
- 可行区域 = 楼板轮廓内缩 0.35 m(
insetRing),避免线贴着墙皮走; - 障碍 = 核心筒多边形;
- 方法 = 可见性图(visible graph)+ Dijkstra:把所有轮廓顶点、障碍顶点、起终点两两判断"连线是否完全在可行域内",能连就加一条边,边上权重是欧氏距离;
- 线段合法性用 18 个采样点判,边界容差
EDGE_EPS = 0.06; - 最后用 0.05 的容差做一次
simplify,把共线的中间点去掉。
选可见性图而不是栅格 A*,是因为这套数据里障碍很少(一栋楼一个核心筒),而顶点数量也就几十个。O(n²) 的连线判定在 n≈60 时是毫秒级,而且路径天然是"贴着转角走"的直线段,视觉上更像人走的路线,不像栅格路径那样有锯齿。
跨楼栋的路径分五段:
本层室内 → 本层核心筒 → 下到一层 → 出楼门 → 室外贴地 → 对方楼门 → 上到目标层 → 目标核心筒 → 目标点
三处高度常量:
const FLOOR_Y = 0.08; // 室内走线离楼板面
const WALK_Y = 0.14; // 室外贴地(略高一点,避免 z-fighting)
const PORT_H = 0.82; // 设备本体上的接线口高度
室外那一段走的是园区地面,高度固定在 0.14 m——不贴地的走线在等轴视角下会显得"浮在空中"。
四、渲染:一根管子要画两层
链路是"一根发光的管子,里面有光点在流动"。实际渲染拆成三层:
| 层 | 半径 | 材质 | 作用 |
|---|---|---|---|
| 外管 | 0.11 | 半透明 MeshBasicMaterial | 给体积感 |
| 内芯 | 0.055 | 更亮的不透明色 | 主体亮度 |
| 光点 | 0.17 | 白色球体 | 流动方向 |
光点和箭头按长度分配数量,上限压住,否则一条 200 m 的长链路会生成几百个球:
const seg = Math.max(24, Math.min(500, Math.round(len * 1.6))); // 采样点
const nDots = Math.max(2, Math.min(9, Math.round(len / 16))); // 光点
const nArrow = Math.max(1, Math.min(9, Math.round(len / 26))); // 箭头
流速 11 m/s,用 (t + i / n) % 1 让多个光点均匀分布在同一曲线上——这样改速度不会导致光点"跳"。
箭头用 ConeGeometry 沿曲线切线摆,比让光点自己变形便宜得多。
五、四个坑
坑 1:TransformControls 不是 Object3D。 three.js 0.186 里它是 Controls 的子类,直接 scene.add(gizmo) 会报错,必须 scene.add(gizmo.getHelper())。这个改动是 0.16x 之后才有的,网上大量旧教程还是 scene.add(control)。
坑 2:拖动手柄会留下幽灵标签。 现象是每拖动一次设备,场景里就多一个同名标签,越拖越多。
根因:拖拽写回 store 后触发了 onChange,而 onChange 的做法是整组销毁重建——旧的 CSS2DObject 对应的 DOM 节点在重建路径上没有被清掉,于是每重建一次就泄漏一个。
修法是把"重建"改成"就地更新",并且显式回收 DOM:
function disposePinObject(obj) {
obj.traverse((o) => {
if (o.isCSS2DObject && o.element.parentNode) {
o.element.parentNode.removeChild(o.element);
}
});
}
// onChange 不再重建,改为:
pinStore.onChange = () => { renderPinList(); syncPinObjects(); };
syncPinObjects() 负责按 id 增删、更新位置与角度,只有设备类型变了才重建那一个。改完用脚本量了一遍:修复前 DOM 标签随拖动次数 1→2→2→3 增长,修复后稳定为 1。
坑 3:刷新之后链路全丢。 这个坑最典型。
现象:页面刷新,"全显"模式下一条链路都没有;但新建链路立刻就有了。
根因不是显示状态,是时序:pinStore 在模块初始化时就从 localStorage 读回数据并触发重建,而那时 floors.json 还没加载完——linkCtx() 里的 byId 是空 Map,buildLinkPoints() 拿不到楼栋就返回 null,每条链路被静默跳过,linkObjects.size 恒为 0。
诊断输出很能说明问题:{pins: 3, pairs: 2, objs: 0, visible: 0, mode: "all"}——数据在、配对在、对象是 0。
修法是在资源加载完成后补一次重建:
build().then(() => {
renderLinks();
renderPins();
renderPinList();
syncOverviewGhosts(); // 有设备的楼变半透明,否则链路被楼体挡住
});
教训:凡是"读 localStorage + 依赖异步资源"的模块,初始化路径一定要能在资源就绪后重放一次。 静默跳过比抛异常更难查。
坑 4:链路开关的语义。 第一版是"楼层模式下只显示本栋的链路",这是个隐式过滤,用户看不到为什么某些链路消失了。改成显式三态开关:全显 / 关闭 / 仅选中。默认全显,刷新后立刻生效。隐式规则要改成显式开关,这条在交互设计上几乎没有反例。
六、口径
链路走线是示意性的:它保证不穿核心筒、不越出楼板、贴地贴墙,但它不是真实管网——没有桥架、没有竖井、没有强弱电分离。室外那段走地面也是简化,真实布线会走地下管廊。
也就是说,它能回答"这两台设备怎么连、大概经过哪些位置",不能回答"这套线缆要多少米、走哪个桥架"。
代码:gitee.com/aowind/company-builds(MIT)