简介:一份基于HTML5与矢量图形技术的室内地图资源包,面向网页前端开发者和位置服务应用人员,可用于大型商超、机场、展馆等复杂环境下的室内导航与区域标注。压缩包共171个文件,体积约2.47MB,以114个JavaScript脚本为主体,辅以HTML页面、CSS样式、JSON与XML数据,以及少量地图文件、Python工具和图标资源,覆盖地图渲染、交互控制、图层管理和数据配置等环节。该包已有1355人学习下载。内含可独立运行的示例页面与矢量地图插件,比如zhengjia1入口页、jquery-jvectormap组件和Bootstrap主题样式,并配齐字体图标与样式表,方便直接套用或改造。GeoJSON类数据与辅助脚本则有助于理解室内空间的几何建模、商铺点击变色、地图缩放拖拽等交互实现,对想快速搭建室内地图应用的前端开发者有较强参考价值。1. 室内矢量地图:不是放大版路网,是一套可计算的空间数据地下车库找车、商场里找一家店、园区里开会找楼栋,这些日常场景背后靠的都是同一张底图,就是室内矢量地图。它和把CAD图纸扫描成图片完全是两回事:墙、门、扶梯、柜台、工位全部变成带坐标、带属性、可查询的要素数据。栅格图放大会糊、没法检索,而"这一层有几个安全出口、最近的电梯在哪"这类问题,只有矢量数据答得上来。做室内定位、安防巡更、资产管理和物联网业务的人,迟早要碰这个方向。这篇我会从一张CAD图纸开始,带你走完清洗、配准、建模、渲染的完整链路,并告诉你哪些环节最容易翻车。2. 数据从哪来:把CAD图纸洗成带坐标的矢量要素2.1 三种数据源,谁最适合当室内地图底图做室内矢量地图第一个要回答的问题是:底图数据从哪来?我接触过的项目里,最常见的三种来源是CAD施工图、BIM模型和现场实测采集,它们的差异非常明显。数据源坐标系单位要素完整度主要成本CAD图纸(DXF/DWG)图纸任意原点毫米墙体门洞齐全,但图层混乱清洗和配准耗时BIM模型项目基点毫米带房间语义和构件类型导出管线复杂,容易丢属性实测采集自定义或绝对坐标米精度最高,但只有现场点线人力成本高我一般会优先用CAD图纸作为底图,理由是大多数既有建筑都能找到竣工图,虽然图层命名乱七八糟,但墙体和门窗的相对位置是准确的。BIM模型看起来美好,导出成GeoJSON时经常丢掉楼层和房间名称,处理成本反而更高。实测采集只用于CAD缺失或图纸与现场不符的局部区域,比如后来加建的隔断和柜台。2.2 用ezdxf把墙体线抽出来,先解决图层混乱拿到DXF文件后别急着配准,第一步是把有用的几何要素从图层里捞出来。常见的坑是:墙体在"wall"或"墙"图层,标注文字在"text"图层,有些门还藏在块参照里。下面这段代码用ezdxf读取DXF,并按图层名抽取LINE和LWPOLYLINE两种实体。import ezdxf import json # 读取DXF文件,注意ezdxf只能直接读DXF,DWG需要先另存为DXF doc = ezdxf.readfile("floor_plan.dxf") msp = doc.modelspace() # 常见墙体图层名,按项目实际情况扩充 target_layers = {"wall", "walls", "墙体", "WALL"} lines = [] for e in msp: # 跳过被冻结或关闭的图层 if e.dxf.layer not in target_layers: continue if e.dxftype() == "LINE": start = e.dxf.start end = e.dxf.end lines.append({ "type": "LineString", "coordinates": [[start.x, start.y], [end.x, end.y]] }) elif e.dxftype() == "LWPOLYLINE": pts = list(e.get_points()) coords = [[p[0], p[1]] for p in pts] # 闭合多段线,首尾重复一次方便渲染时形成闭环 if e.closed: coords.append(coords[0]) lines.append({ "type": "LineString", "coordinates": coords }) # 写入GeoJSON,x/y单位是图纸单位,通常是毫米 with open("walls.geojson", "w", encoding="utf-8") as f: json.dump({"type": "FeatureCollection", "features": [ {"type": "Feature", "properties": {"layer": e.dxf.layer, "type": "wall"}, "geometry": g} for e, g in zip(msp, lines) ]}, f, ensure_ascii=False)这段代码的逻辑是:遍历模型空间所有实体,先用图层名过滤,再按实体类型提取坐标。注意两个参数容易踩坑:一是LWPOLYLINE的closed判断,闭合多段线不重复首尾点的话,渲染时墙体边缘会漏一条边;二是这里坐标单位继承自DXF设置,绝大多数施工图是毫米单位,后面配准时要按毫米换算成米。如果发现墙体线还是缺漏,先检查这条线是不是SPLINE或者ELLIPSE类型,很多弧形墙体用这类实体表达,上面的代码会跳过它们。常见做法是把它们转成多段线再处理,CAD里可以用PE命令拟合,脚本里则先识别再单独采样。2.3 坐标配准:控制点至少要3个,且不能共线图纸坐标往往原点随意、单位是毫米,要让它能和真实经纬度对应,必须做坐标配准。最常用的是仿射变换,公式是:经度 = aX + bY + c,纬度 = dX + eY + f,其中X、Y是图纸坐标。别把这事想复杂,用最小二乘解六个系数就够了。import numpy as np import json # 手动挑选的控制点:图纸坐标(毫米) -> 实测经纬度(度) # 每个点是一条 (x_mm, y_mm, lng, lat) 记录 control_points = [ (123456.7, 234567.8, 116.301234, 39.901234), (124567.8, 233456.7, 116.302345, 39.900123), (123456.7, 233456.7, 116.301234, 39.900123), ] A, bx, by = [], [], [] for x, y, lng, lat in control_points: A.append([x, y, 1, 0, 0, 0]) A.append([0, 0, 0, x, y, 1]) bx.append(lng) by.append(lat) M = np.array(A) b_lng_arr = np.array(bx) b_lat_arr = np.array(by) # 最小二乘解,rcond=None是numpy推荐的写法 coef_lng = np.linalg.lstsq(M, b_lng_arr, rcond=None)[0] coef_lat = np.linalg.lstsq(M, b_lat_arr, rcond=None)[0] def transform(x_mm, y_mm): lng = coef_lng[0] * x_mm + coef_lng[1] * y_mm + coef_lng[2] lat = coef_lat[0] * x_mm + coef_lat[1] * y_mm + coef_lat[2] return lng, lat # 验证:用控制点残差判断配准质量 for x, y, lng, lat in control_points: tlng, tlat = transform(x, y) print(f"control: ({tlng:.6f},{tlat:.6f}) expected: ({lng:.6f},{lat:.6f})")配准质量几乎完全取决于控制点的选择,这是整个流程里最玄学也最影响后续效果的一步。控制点要分布在整个楼层,不能集中在一条线上,否则变换会被某个方向主导,角落区域偏移很大。也不能只靠三个点,至少找五六个明显的地标,比如柱子中心、墙角、电梯门中点。用激光测距仪或者拿高精度第三方地图底图比对都可以。还有一个容易忽略的细节:如果目标项目只做单楼层展示,不叠加全球地图,那完全可以不转经纬度,保留局部坐标,渲染时把中心点锚定到画布中央就行。转经纬度虽然通用,但会引入投影误差,对精细的室内图反而有害。我一般把决策规则写成这样:需要接室内定位、需要叠加室外路网,就配准到真实坐标;纯展示与查询,就留在局部坐标。3. 室内GeoJSON的结构设计:楼层、要素与属性规范3.1 以楼层为单位的FeatureCollection,别把全楼塞进一个文件室内矢量和室外地图最大的差别是多了"楼层"这个维度。常见做法是每层一个GeoJSON文件,而不是整栋楼一个FeatureCollection。这样按楼层加载、按楼层切换都方便,文件体积也可控。每层的FeatureCollection里,所有要素都要带上楼层号属性,避免切换楼层时混入其它层的数据。{ "type": "FeatureCollection", "floor": 2, "unit": "m", "features": [ { "type": "Feature", "properties": { "floor_no": 2, "type": "wall", "name": "" }, "geometry": { "type": "LineString", "coordinates": [[12.5, 8.2], [12.5, 20.8]] } }, { "type": "Feature", "properties": { "floor_no": 2, "type": "poi", "name": "服务台", "category": "service" }, "geometry": { "type": "Point", "coordinates": [16.3, 14.7] } } ] }上面这个示例里,我在FeatureCollection顶层放了floor和unit两个字段,这是很多人忽略的元数据。floor让加载器知道该切到几层,unit告诉渲染器坐标单位是米还是毫米。要素坐标用局部坐标系时,单位统一成米更直观;CAD图纸是毫米,清洗阶段就该除以1000,不要让单位问题拖到渲染时才暴露。3.2 局部坐标加锚点,还是直接转Web Mercator这是个选型问题,直接决定后面的工作量。两者对比下来各有适用场景。方案优点缺点适用场景局部坐标 + 锚点精度高、无投影变形、加载快叠加室外地图时需再做一次仿射单楼宇展示与查询Web Mercator(3857)能和室外地图无缝叠加纬度越高变形越明显跨楼宇联动、室外室内一体化我在实际项目里的选择标准很朴素:如果需求里出现"和室外道路联动"或者"多个楼栋统一视图",就统一转成3857;如果只是单栋楼内部做导航展示,保留局部坐标。局部坐标还有一个好处,就是室内定位输出的坐标往往是相对坐标系,直接能对上,少一层换算。3.3 每层至少要有五类要素,少一类后面都要补梳理过多份室内地图数据结构后,我总结出一个底线:不管项目大小,每层至少要维护五类要素。它们是墙、门、POI、可通行区域、障碍物。少任何一类,后面接寻路或做碰撞检测时都得回头重建。类型几何关键属性示例wallLineString/Polygonheight, material隔墙、承重墙doorLineString/Pointopen_dir, width出入口、防火门poiPointname, category, floor_no服务台、卫生间、店铺navigablePolygonarea_type走道、大厅obstaclePolygonradius立柱、花坛navigable区域很多人一开始不做,等到要算从A到B的路径时才后悔。因为寻路算法需要知道哪些区域人能走,而不是靠墙线去推断。哪怕初期只把大中庭和主通道画出来,也比完全没有强。这类区域可以从CAD地面铺装图层里抽,没有的话就手动描一圈,成本不高但要趁早。4. 渲染落地:Canvas自绘与地图SDK叠加两套方案4.1 最小可跑的Canvas渲染器,不依赖任何地图SDK很多室内地图项目希望完全可控,不想被地图SDK的坐标系和样式约束绑架,这时候用Canvas自绘是最直接的路子。下面这个HTML文件把上一节的GeoJSON画到画布上,包含自动适配视图和HiDPI支持,拷到本地就能跑。<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <style> body { margin: 0; } canvas { display: block; background: #f5f5f5; } </style> </head> <body> <canvas id="map"></canvas> <script> const geojson = { "type": "FeatureCollection", "features": [ { "type": "Feature", "properties": { "type": "wall" }, "geometry": { "type": "LineString", "coordinates": [[0, 0], [100, 0], [100, 60], [0, 60], [0, 0]] } }, { "type": "Feature", "properties": { "type": "poi", "name": "前台" }, "geometry": { "type": "Point", "coordinates": [50, 30] } } ] }; const canvas = document.getElementById("map"); const ctx = canvas.getContext("2d"); // 适配高分屏:先按DPR放大画布物理尺寸,再让CSS用逻辑尺寸显示 const dpr = window.devicePixelRatio || 1; canvas.width = canvas.offsetWidth * dpr; canvas.height = canvas.offsetHeight * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 遍历要素算出包围盒,再缩放到视口内 let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; geojson.features.forEach(f => { const c = f.geometry.coordinates; if (f.geometry.type === "Point") { minX = Math.min(minX, c[0]); minY = Math.min(minY, c[1]); maxX = Math.max(maxX, c[0]); maxY = Math.max(maxY, c[1]); } else { c.forEach(p => { minX = Math.min(minX, p[0]); minY = Math.min(minY, p[1]); maxX = Math.max(maxX, p[0]); maxY = Math.max(maxY, p[1]); }); } }); const margin = 30; const scale = Math.min( (canvas.offsetWidth - margin * 2) / (maxX - minX), (canvas.offsetHeight - margin * 2) / (maxY - minY) ); const offsetX = margin + (canvas.offsetWidth - (maxX - minX) * scale) / 2 - minX * scale; const offsetY = margin + (canvas.offsetHeight - (maxY - minY) * scale) / 2 - minY * scale; function px(x, y) { return [x * scale + offsetX, canvas.offsetHeight - (y * scale + offsetY)]; } // 绘制所有要素 geojson.features.forEach(f => { const props = f.properties; ctx.save(); ctx.lineWidth = 2; ctx.strokeStyle = props.type === "wall" ? "#333" : "#999"; ctx.fillStyle = "#e74c3c"; if (f.geometry.type === "LineString") { ctx.beginPath(); f.geometry.coordinates.forEach((p, i) => { const [sx, sy] = px(p[0], p[1]); i === 0 ? ctx.moveTo(sx, sy) : ctx.lineTo(sx, sy); }); ctx.stroke(); } else if (f.geometry.type === "Point") { const [sx, sy] = px(f.geometry.coordinates[0], f.geometry.coordinates[1]); ctx.beginPath(); ctx.arc(sx, sy, 5, 0, Math.PI * 2); ctx.fill(); // 标注文字,不随地图旋转时应保持水平 ctx.fillStyle = "#000"; ctx.font = "12px sans-serif"; ctx.fillText(props.name || "", sx + 8, sy - 8); } ctx.restore(); }); </script> </body> </html>这段代码的关键在于两处:第一是devicePixelRatio处理,如果不做这一步,高分屏上线条和文字全是糊的,这是Canvas绘制最常被骂"怎么这么毛"的原因;第二是坐标变换函数px,把局部坐标换算成屏幕坐标,y轴翻转是因为Canvas原点在左上角,而地图坐标原点通常在左下角。跑通这个最小例子后,交互功能都是增量式添加。缩放可以监听wheel事件调整scale和offsetX/offsetY,平移监听mousedown和mousemove,原理就是改这两个偏移量后重新绘制。不要一开始就上复杂引擎,先把单层静态渲染跑通,再加交互,排错会容易得多。4.2 用地图SDK叠加室内图层:省事但要注意坐标系如果项目要求室内外地图无缝切换,用现成地图SDK叠加GeoJSON层会更高效。常见做法是把室内要素放在单独的图层,用室内地图自己的样式规则渲染。下面是Mapbox GL JS里加载室内矢量图层的最小结构。map.addSource('indoor-f2', { type: 'geojson', data: 'floor2.geojson' }); map.addLayer({ id: 'indoor-f2-wall', type: 'line', source: 'indoor-f2', filter: ['==', ['get', 'type'], 'wall'], paint: { 'line-color': '#444', 'line-width': 2 } }); map.addLayer({ id: 'indoor-f2-poi', type: 'symbol', source: 'indoor-f2', filter: ['==', ['get', 'type'], 'poi'], layout: { 'text-field': ['get', 'name'], 'text-allow-overlap': true } });用SDK叠加时最容易出错的是坐标系。室内GeoJSON如果是局部坐标,必须先通过锚点仿射变换转成3857或经纬度,不然图层会跑到非洲海岸线上。我在项目里通常把这个转换做在数据导出阶段,而不是渲染阶段,这样前端代码保持简洁,也方便多个楼层统一处理。另外filter里用get表达式读取属性来区分图层样式,比把要素分散到多个source更易维护。4.3 命中检测与要素分级显示静态画完还不算能用,交互体验决定这个地图是否真正可用。两个功能必须做:命中检测和分级显示。命中检测解决"鼠标点到一个POI,我怎么知道点是哪一个",Canvas方案里最实用的是离屏Canvas打标法:在隐藏画布上用不同的颜色索引绘制每个要素,鼠标移动时读取该像素颜色,映射回要素ID。这个方法实现简单,且不依赖任何几何计算库。分级显示解决"楼层信息太多,缩小视图时文字挤成一团"的问题。常见做法是按当前缩放级别过滤POI,比如100米以上视野只显示电梯、楼梯和卫生间,50米以内才显示店铺名。用Mapbox时直接在filter里加zoom表达式,Canvas自绘时则维护一个当前可见POI数组,根据scale阈值决定画哪些。这个阈值没有标准答案,建议结合实际楼层面积,先按50米和20米两档试,再看效果微调。5. 室内矢量地图避坑指南:坐标漂移、断线、文字翻转5.1 墙体导出后缺漏或断线,怎么看都接不上现象:清洗完的GeoJSON里,某些墙体明显缺失,或者一堵完整的墙被截成几段,线段端点对不齐。原因是CAD图纸里墙体不一定是单一图层或单一实体,可能被做成了块参照,也可能用了SPLINE曲线,还可能是两根半截线叠在一起。解决方法是导出前先炸开所有INSERT块,再把SPLINE和ELLIPSE按精度转成多段线。我处理图纸时会写一个预处理脚本:先遍历块定义,把块内的几何递归平铺到模型空间,再统一按图层抽取。别指望一次到位,多轮检查和补漏是常态。5.2 叠加到真实地图后POI整体漂移几十米现象:室内图单独看一切正常,切换到室外底图时,所有POI和楼层轮廓都偏移到隔壁街区。原因是控制点选取有问题,常见两种情况:控制点数量不够,或者控制点集中在一条直线上,导致最小二乘解在某个方向严重失真。解决方法是至少选5个均匀分布的控制点,并在解算后打印每个控制点的残差。残差超过1米就要重新选点,超过5米说明有控制点坐标抄错了,这个先检查再继续。这个排查思路我可以确认很有用,因为大多数漂移都是控制点选得烂,而不是变换公式写错了。5.3 地图旋转后标注文字是倒的现象:地图支持旋转时,POI文字跟着地图一起翻转,90度、180度的时候字完全没法看。原因是文字被当作地理要素一起参与了旋转变换,而标注应该始终面向阅读者。解决方法是分两层画:几何要素进变换矩阵,文字用屏幕坐标固定角度绘制。Mapbox GL JS里对应的是把symbol图层的text-allow-overlap设为true、text-rotation-alignment设为viewport,这样文字永远垂直屏幕。这个问题在交互式室内地图里几乎必现,我第一次做旋转功能时就翻过车,后来统一用viewport对齐才根治。5.4 高分屏上线条发虚,字体边缘模糊现象:同样的Canvas代码,在普通屏上清晰,在Retina屏幕上全是毛边。原因是没处理devicePixelRatio,Canvas物理像素和CSS像素不一致。解决方法是前面代码里写过的:先按dpr放大canvas.width,再setTransform(dpr,0,0,dpr,0,0)。这个坑每次新起一个渲染项目都会碰上,建议直接把这段写成公共初始化函数,别每次手敲。5.5 单层GeoJSON五六百KB,加载后交互卡顿现象:楼层要素多,尤其墙体导成很密集的折线以后,文件体积暴涨,地图拖动掉帧。原因有两个:CAD导出的曲线被离散成海量顶点,以及GeoJSON没有做任何抽稀。解决方法是先用简化工具按Douglas-Peucker算法抽稀,阈值设为0.05到0.1米,视觉几乎看不出区别,体量能砍掉一半以上。线段抽稀要保留墙体端点,避免转角被磨圆;多边形抽稀则要留意不能把门的开口给闭掉。文件还是大的话,就按区域切瓦片或者按层懒加载,视口近的地方加载精细层,远处用粗糙层。6. 更进一步:给地图加上路网,从静态展示到可寻路静态地图做完,接下来大概率要接寻路和定位。这一步的核心是把"能看的地图"升级成"能算的地图"。做法是在现有要素之上叠加路网图层:把可通行区域的中心线连成图结构,节点是岔路口和关键点,边是步行路径,然后在这个图上跑A*或Dijkstra。路网数据可以手动描一遍主要通道,也可以从navigable多边形里用中轴线算法自动提取。我个人的建议是一开始先手动描,一条商场主通道也就二三十个节点,手动描半小时能抵算法调一整天。路网建好后,要在POI和路网之间加"接入边",否则寻路起点在店铺里却上不了路网。我处理时会给每个POI增加一个nearest_node属性,在数据预处理阶段计算它到最近路网节点的距离和接入边,避免寻路时现场算。这一步做完,从服务台导航到某家店铺就是一条简单的图查询了。验证路网不要只拿坐标纸画条线看直不直。我的习惯是随机挑五个起点和终点组合,生成路径后在现场用测距轮走一遍,对比计算距离和实际距离。误差主要来自路网中心线没对准实际走道中线,这时候调整节点位置比优化算法更有效。接定位系统时,定位模块输出(x, y, floor)局部坐标,前端只需要把坐标命中到最近的navigable区域,再沿路网输出路径,整条链路就闭环了。这个方向最大的教训是:数据规范永远比渲染漂亮重要。我见过不少项目花大量时间调Canvas阴影和动效,结果一个角落的墙体少了根线,寻路就穿墙而过。每次接新楼层,先检查五类要素是否齐整,再谈界面效果。这条路不难走,坑也都在明面上,把它当成数据工程来做,室内矢量地图就能真正用起来,希望帮到你。本文还有配套的精品资源,点击获取
简介:一份基于HTML5与矢量图形技术的室内地图资源包,面向网页前端开发者和位置服务应用人员,可用于大型商超、机场、展馆等复杂环境下的室内导航与区域标注。压缩包共171个文件,体积约2.47MB,以114个JavaScript脚本为主体,辅以HTML页面、CSS样式、JSON与XML数据,以及少量地图文件、Python工具和图标资源,覆盖地图渲染、交互控制、图层管理和数据配置等环节。该包已有1355人学习下载。内含可独立运行的示例页面与矢量地图插件,比如zhengjia1入口页、jquery-jvectormap组件和Bootstrap主题样式,并配齐字体图标与样式表,方便直接套用或改造。GeoJSON类数据与辅助脚本则有助于理解室内空间的几何建模、商铺点击变色、地图缩放拖拽等交互实现,对想快速搭建室内地图应用的前端开发者有较强参考价值。1. 室内矢量地图:不是放大版路网,是一套可计算的空间数据地下车库找车、商场里找一家店、园区里开会找楼栋,这些日常场景背后靠的都是同一张底图,就是室内矢量地图。它和把CAD图纸扫描成图片完全是两回事:墙、门、扶梯、柜台、工位全部变成带坐标、带属性、可查询的要素数据。栅格图放大会糊、没法检索,而"这一层有几个安全出口、最近的电梯在哪"这类问题,只有矢量数据答得上来。做室内定位、安防巡更、资产管理和物联网业务的人,迟早要碰这个方向。这篇我会从一张CAD图纸开始,带你走完清洗、配准、建模、渲染的完整链路,并告诉你哪些环节最容易翻车。2. 数据从哪来:把CAD图纸洗成带坐标的矢量要素2.1 三种数据源,谁最适合当室内地图底图做室内矢量地图第一个要回答的问题是:底图数据从哪来?我接触过的项目里,最常见的三种来源是CAD施工图、BIM模型和现场实测采集,它们的差异非常明显。数据源坐标系单位要素完整度主要成本CAD图纸(DXF/DWG)图纸任意原点毫米墙体门洞齐全,但图层混乱清洗和配准耗时BIM模型项目基点毫米带房间语义和构件类型导出管线复杂,容易丢属性实测采集自定义或绝对坐标米精度最高,但只有现场点线人力成本高我一般会优先用CAD图纸作为底图,理由是大多数既有建筑都能找到竣工图,虽然图层命名乱七八糟,但墙体和门窗的相对位置是准确的。BIM模型看起来美好,导出成GeoJSON时经常丢掉楼层和房间名称,处理成本反而更高。实测采集只用于CAD缺失或图纸与现场不符的局部区域,比如后来加建的隔断和柜台。2.2 用ezdxf把墙体线抽出来,先解决图层混乱拿到DXF文件后别急着配准,第一步是把有用的几何要素从图层里捞出来。常见的坑是:墙体在"wall"或"墙"图层,标注文字在"text"图层,有些门还藏在块参照里。下面这段代码用ezdxf读取DXF,并按图层名抽取LINE和LWPOLYLINE两种实体。import ezdxf import json # 读取DXF文件,注意ezdxf只能直接读DXF,DWG需要先另存为DXF doc = ezdxf.readfile("floor_plan.dxf") msp = doc.modelspace() # 常见墙体图层名,按项目实际情况扩充 target_layers = {"wall", "walls", "墙体", "WALL"} lines = [] for e in msp: # 跳过被冻结或关闭的图层 if e.dxf.layer not in target_layers: continue if e.dxftype() == "LINE": start = e.dxf.start end = e.dxf.end lines.append({ "type": "LineString", "coordinates": [[start.x, start.y], [end.x, end.y]] }) elif e.dxftype() == "LWPOLYLINE": pts = list(e.get_points()) coords = [[p[0], p[1]] for p in pts] # 闭合多段线,首尾重复一次方便渲染时形成闭环 if e.closed: coords.append(coords[0]) lines.append({ "type": "LineString", "coordinates": coords }) # 写入GeoJSON,x/y单位是图纸单位,通常是毫米 with open("walls.geojson", "w", encoding="utf-8") as f: json.dump({"type": "FeatureCollection", "features": [ {"type": "Feature", "properties": {"layer": e.dxf.layer, "type": "wall"}, "geometry": g} for e, g in zip(msp, lines) ]}, f, ensure_ascii=False)这段代码的逻辑是:遍历模型空间所有实体,先用图层名过滤,再按实体类型提取坐标。注意两个参数容易踩坑:一是LWPOLYLINE的closed判断,闭合多段线不重复首尾点的话,渲染时墙体边缘会漏一条边;二是这里坐标单位继承自DXF设置,绝大多数施工图是毫米单位,后面配准时要按毫米换算成米。如果发现墙体线还是缺漏,先检查这条线是不是SPLINE或者ELLIPSE类型,很多弧形墙体用这类实体表达,上面的代码会跳过它们。常见做法是把它们转成多段线再处理,CAD里可以用PE命令拟合,脚本里则先识别再单独采样。2.3 坐标配准:控制点至少要3个,且不能共线图纸坐标往往原点随意、单位是毫米,要让它能和真实经纬度对应,必须做坐标配准。最常用的是仿射变换,公式是:经度 = aX + bY + c,纬度 = dX + eY + f,其中X、Y是图纸坐标。别把这事想复杂,用最小二乘解六个系数就够了。import numpy as np import json # 手动挑选的控制点:图纸坐标(毫米) -> 实测经纬度(度) # 每个点是一条 (x_mm, y_mm, lng, lat) 记录 control_points = [ (123456.7, 234567.8, 116.301234, 39.901234), (124567.8, 233456.7, 116.302345, 39.900123), (123456.7, 233456.7, 116.301234, 39.900123), ] A, bx, by = [], [], [] for x, y, lng, lat in control_points: A.append([x, y, 1, 0, 0, 0]) A.append([0, 0, 0, x, y, 1]) bx.append(lng) by.append(lat) M = np.array(A) b_lng_arr = np.array(bx) b_lat_arr = np.array(by) # 最小二乘解,rcond=None是numpy推荐的写法 coef_lng = np.linalg.lstsq(M, b_lng_arr, rcond=None)[0] coef_lat = np.linalg.lstsq(M, b_lat_arr, rcond=None)[0] def transform(x_mm, y_mm): lng = coef_lng[0] * x_mm + coef_lng[1] * y_mm + coef_lng[2] lat = coef_lat[0] * x_mm + coef_lat[1] * y_mm + coef_lat[2] return lng, lat # 验证:用控制点残差判断配准质量 for x, y, lng, lat in control_points: tlng, tlat = transform(x, y) print(f"control: ({tlng:.6f},{tlat:.6f}) expected: ({lng:.6f},{lat:.6f})")配准质量几乎完全取决于控制点的选择,这是整个流程里最玄学也最影响后续效果的一步。控制点要分布在整个楼层,不能集中在一条线上,否则变换会被某个方向主导,角落区域偏移很大。也不能只靠三个点,至少找五六个明显的地标,比如柱子中心、墙角、电梯门中点。用激光测距仪或者拿高精度第三方地图底图比对都可以。还有一个容易忽略的细节:如果目标项目只做单楼层展示,不叠加全球地图,那完全可以不转经纬度,保留局部坐标,渲染时把中心点锚定到画布中央就行。转经纬度虽然通用,但会引入投影误差,对精细的室内图反而有害。我一般把决策规则写成这样:需要接室内定位、需要叠加室外路网,就配准到真实坐标;纯展示与查询,就留在局部坐标。3. 室内GeoJSON的结构设计:楼层、要素与属性规范3.1 以楼层为单位的FeatureCollection,别把全楼塞进一个文件室内矢量和室外地图最大的差别是多了"楼层"这个维度。常见做法是每层一个GeoJSON文件,而不是整栋楼一个FeatureCollection。这样按楼层加载、按楼层切换都方便,文件体积也可控。每层的FeatureCollection里,所有要素都要带上楼层号属性,避免切换楼层时混入其它层的数据。{ "type": "FeatureCollection", "floor": 2, "unit": "m", "features": [ { "type": "Feature", "properties": { "floor_no": 2, "type": "wall", "name": "" }, "geometry": { "type": "LineString", "coordinates": [[12.5, 8.2], [12.5, 20.8]] } }, { "type": "Feature", "properties": { "floor_no": 2, "type": "poi", "name": "服务台", "category": "service" }, "geometry": { "type": "Point", "coordinates": [16.3, 14.7] } } ] }上面这个示例里,我在FeatureCollection顶层放了floor和unit两个字段,这是很多人忽略的元数据。floor让加载器知道该切到几层,unit告诉渲染器坐标单位是米还是毫米。要素坐标用局部坐标系时,单位统一成米更直观;CAD图纸是毫米,清洗阶段就该除以1000,不要让单位问题拖到渲染时才暴露。3.2 局部坐标加锚点,还是直接转Web Mercator这是个选型问题,直接决定后面的工作量。两者对比下来各有适用场景。方案优点缺点适用场景局部坐标 + 锚点精度高、无投影变形、加载快叠加室外地图时需再做一次仿射单楼宇展示与查询Web Mercator(3857)能和室外地图无缝叠加纬度越高变形越明显跨楼宇联动、室外室内一体化我在实际项目里的选择标准很朴素:如果需求里出现"和室外道路联动"或者"多个楼栋统一视图",就统一转成3857;如果只是单栋楼内部做导航展示,保留局部坐标。局部坐标还有一个好处,就是室内定位输出的坐标往往是相对坐标系,直接能对上,少一层换算。3.3 每层至少要有五类要素,少一类后面都要补梳理过多份室内地图数据结构后,我总结出一个底线:不管项目大小,每层至少要维护五类要素。它们是墙、门、POI、可通行区域、障碍物。少任何一类,后面接寻路或做碰撞检测时都得回头重建。类型几何关键属性示例wallLineString/Polygonheight, material隔墙、承重墙doorLineString/Pointopen_dir, width出入口、防火门poiPointname, category, floor_no服务台、卫生间、店铺navigablePolygonarea_type走道、大厅obstaclePolygonradius立柱、花坛navigable区域很多人一开始不做,等到要算从A到B的路径时才后悔。因为寻路算法需要知道哪些区域人能走,而不是靠墙线去推断。哪怕初期只把大中庭和主通道画出来,也比完全没有强。这类区域可以从CAD地面铺装图层里抽,没有的话就手动描一圈,成本不高但要趁早。4. 渲染落地:Canvas自绘与地图SDK叠加两套方案4.1 最小可跑的Canvas渲染器,不依赖任何地图SDK很多室内地图项目希望完全可控,不想被地图SDK的坐标系和样式约束绑架,这时候用Canvas自绘是最直接的路子。下面这个HTML文件把上一节的GeoJSON画到画布上,包含自动适配视图和HiDPI支持,拷到本地就能跑。<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <style> body { margin: 0; } canvas { display: block; background: #f5f5f5; } </style> </head> <body> <canvas id="map"></canvas> <script> const geojson = { "type": "FeatureCollection", "features": [ { "type": "Feature", "properties": { "type": "wall" }, "geometry": { "type": "LineString", "coordinates": [[0, 0], [100, 0], [100, 60], [0, 60], [0, 0]] } }, { "type": "Feature", "properties": { "type": "poi", "name": "前台" }, "geometry": { "type": "Point", "coordinates": [50, 30] } } ] }; const canvas = document.getElementById("map"); const ctx = canvas.getContext("2d"); // 适配高分屏:先按DPR放大画布物理尺寸,再让CSS用逻辑尺寸显示 const dpr = window.devicePixelRatio || 1; canvas.width = canvas.offsetWidth * dpr; canvas.height = canvas.offsetHeight * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 遍历要素算出包围盒,再缩放到视口内 let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; geojson.features.forEach(f => { const c = f.geometry.coordinates; if (f.geometry.type === "Point") { minX = Math.min(minX, c[0]); minY = Math.min(minY, c[1]); maxX = Math.max(maxX, c[0]); maxY = Math.max(maxY, c[1]); } else { c.forEach(p => { minX = Math.min(minX, p[0]); minY = Math.min(minY, p[1]); maxX = Math.max(maxX, p[0]); maxY = Math.max(maxY, p[1]); }); } }); const margin = 30; const scale = Math.min( (canvas.offsetWidth - margin * 2) / (maxX - minX), (canvas.offsetHeight - margin * 2) / (maxY - minY) ); const offsetX = margin + (canvas.offsetWidth - (maxX - minX) * scale) / 2 - minX * scale; const offsetY = margin + (canvas.offsetHeight - (maxY - minY) * scale) / 2 - minY * scale; function px(x, y) { return [x * scale + offsetX, canvas.offsetHeight - (y * scale + offsetY)]; } // 绘制所有要素 geojson.features.forEach(f => { const props = f.properties; ctx.save(); ctx.lineWidth = 2; ctx.strokeStyle = props.type === "wall" ? "#333" : "#999"; ctx.fillStyle = "#e74c3c"; if (f.geometry.type === "LineString") { ctx.beginPath(); f.geometry.coordinates.forEach((p, i) => { const [sx, sy] = px(p[0], p[1]); i === 0 ? ctx.moveTo(sx, sy) : ctx.lineTo(sx, sy); }); ctx.stroke(); } else if (f.geometry.type === "Point") { const [sx, sy] = px(f.geometry.coordinates[0], f.geometry.coordinates[1]); ctx.beginPath(); ctx.arc(sx, sy, 5, 0, Math.PI * 2); ctx.fill(); // 标注文字,不随地图旋转时应保持水平 ctx.fillStyle = "#000"; ctx.font = "12px sans-serif"; ctx.fillText(props.name || "", sx + 8, sy - 8); } ctx.restore(); }); </script> </body> </html>这段代码的关键在于两处:第一是devicePixelRatio处理,如果不做这一步,高分屏上线条和文字全是糊的,这是Canvas绘制最常被骂"怎么这么毛"的原因;第二是坐标变换函数px,把局部坐标换算成屏幕坐标,y轴翻转是因为Canvas原点在左上角,而地图坐标原点通常在左下角。跑通这个最小例子后,交互功能都是增量式添加。缩放可以监听wheel事件调整scale和offsetX/offsetY,平移监听mousedown和mousemove,原理就是改这两个偏移量后重新绘制。不要一开始就上复杂引擎,先把单层静态渲染跑通,再加交互,排错会容易得多。4.2 用地图SDK叠加室内图层:省事但要注意坐标系如果项目要求室内外地图无缝切换,用现成地图SDK叠加GeoJSON层会更高效。常见做法是把室内要素放在单独的图层,用室内地图自己的样式规则渲染。下面是Mapbox GL JS里加载室内矢量图层的最小结构。map.addSource('indoor-f2', { type: 'geojson', data: 'floor2.geojson' }); map.addLayer({ id: 'indoor-f2-wall', type: 'line', source: 'indoor-f2', filter: ['==', ['get', 'type'], 'wall'], paint: { 'line-color': '#444', 'line-width': 2 } }); map.addLayer({ id: 'indoor-f2-poi', type: 'symbol', source: 'indoor-f2', filter: ['==', ['get', 'type'], 'poi'], layout: { 'text-field': ['get', 'name'], 'text-allow-overlap': true } });用SDK叠加时最容易出错的是坐标系。室内GeoJSON如果是局部坐标,必须先通过锚点仿射变换转成3857或经纬度,不然图层会跑到非洲海岸线上。我在项目里通常把这个转换做在数据导出阶段,而不是渲染阶段,这样前端代码保持简洁,也方便多个楼层统一处理。另外filter里用get表达式读取属性来区分图层样式,比把要素分散到多个source更易维护。4.3 命中检测与要素分级显示静态画完还不算能用,交互体验决定这个地图是否真正可用。两个功能必须做:命中检测和分级显示。命中检测解决"鼠标点到一个POI,我怎么知道点是哪一个",Canvas方案里最实用的是离屏Canvas打标法:在隐藏画布上用不同的颜色索引绘制每个要素,鼠标移动时读取该像素颜色,映射回要素ID。这个方法实现简单,且不依赖任何几何计算库。分级显示解决"楼层信息太多,缩小视图时文字挤成一团"的问题。常见做法是按当前缩放级别过滤POI,比如100米以上视野只显示电梯、楼梯和卫生间,50米以内才显示店铺名。用Mapbox时直接在filter里加zoom表达式,Canvas自绘时则维护一个当前可见POI数组,根据scale阈值决定画哪些。这个阈值没有标准答案,建议结合实际楼层面积,先按50米和20米两档试,再看效果微调。5. 室内矢量地图避坑指南:坐标漂移、断线、文字翻转5.1 墙体导出后缺漏或断线,怎么看都接不上现象:清洗完的GeoJSON里,某些墙体明显缺失,或者一堵完整的墙被截成几段,线段端点对不齐。原因是CAD图纸里墙体不一定是单一图层或单一实体,可能被做成了块参照,也可能用了SPLINE曲线,还可能是两根半截线叠在一起。解决方法是导出前先炸开所有INSERT块,再把SPLINE和ELLIPSE按精度转成多段线。我处理图纸时会写一个预处理脚本:先遍历块定义,把块内的几何递归平铺到模型空间,再统一按图层抽取。别指望一次到位,多轮检查和补漏是常态。5.2 叠加到真实地图后POI整体漂移几十米现象:室内图单独看一切正常,切换到室外底图时,所有POI和楼层轮廓都偏移到隔壁街区。原因是控制点选取有问题,常见两种情况:控制点数量不够,或者控制点集中在一条直线上,导致最小二乘解在某个方向严重失真。解决方法是至少选5个均匀分布的控制点,并在解算后打印每个控制点的残差。残差超过1米就要重新选点,超过5米说明有控制点坐标抄错了,这个先检查再继续。这个排查思路我可以确认很有用,因为大多数漂移都是控制点选得烂,而不是变换公式写错了。5.3 地图旋转后标注文字是倒的现象:地图支持旋转时,POI文字跟着地图一起翻转,90度、180度的时候字完全没法看。原因是文字被当作地理要素一起参与了旋转变换,而标注应该始终面向阅读者。解决方法是分两层画:几何要素进变换矩阵,文字用屏幕坐标固定角度绘制。Mapbox GL JS里对应的是把symbol图层的text-allow-overlap设为true、text-rotation-alignment设为viewport,这样文字永远垂直屏幕。这个问题在交互式室内地图里几乎必现,我第一次做旋转功能时就翻过车,后来统一用viewport对齐才根治。5.4 高分屏上线条发虚,字体边缘模糊现象:同样的Canvas代码,在普通屏上清晰,在Retina屏幕上全是毛边。原因是没处理devicePixelRatio,Canvas物理像素和CSS像素不一致。解决方法是前面代码里写过的:先按dpr放大canvas.width,再setTransform(dpr,0,0,dpr,0,0)。这个坑每次新起一个渲染项目都会碰上,建议直接把这段写成公共初始化函数,别每次手敲。5.5 单层GeoJSON五六百KB,加载后交互卡顿现象:楼层要素多,尤其墙体导成很密集的折线以后,文件体积暴涨,地图拖动掉帧。原因有两个:CAD导出的曲线被离散成海量顶点,以及GeoJSON没有做任何抽稀。解决方法是先用简化工具按Douglas-Peucker算法抽稀,阈值设为0.05到0.1米,视觉几乎看不出区别,体量能砍掉一半以上。线段抽稀要保留墙体端点,避免转角被磨圆;多边形抽稀则要留意不能把门的开口给闭掉。文件还是大的话,就按区域切瓦片或者按层懒加载,视口近的地方加载精细层,远处用粗糙层。6. 更进一步:给地图加上路网,从静态展示到可寻路静态地图做完,接下来大概率要接寻路和定位。这一步的核心是把"能看的地图"升级成"能算的地图"。做法是在现有要素之上叠加路网图层:把可通行区域的中心线连成图结构,节点是岔路口和关键点,边是步行路径,然后在这个图上跑A*或Dijkstra。路网数据可以手动描一遍主要通道,也可以从navigable多边形里用中轴线算法自动提取。我个人的建议是一开始先手动描,一条商场主通道也就二三十个节点,手动描半小时能抵算法调一整天。路网建好后,要在POI和路网之间加"接入边",否则寻路起点在店铺里却上不了路网。我处理时会给每个POI增加一个nearest_node属性,在数据预处理阶段计算它到最近路网节点的距离和接入边,避免寻路时现场算。这一步做完,从服务台导航到某家店铺就是一条简单的图查询了。验证路网不要只拿坐标纸画条线看直不直。我的习惯是随机挑五个起点和终点组合,生成路径后在现场用测距轮走一遍,对比计算距离和实际距离。误差主要来自路网中心线没对准实际走道中线,这时候调整节点位置比优化算法更有效。接定位系统时,定位模块输出(x, y, floor)局部坐标,前端只需要把坐标命中到最近的navigable区域,再沿路网输出路径,整条链路就闭环了。这个方向最大的教训是:数据规范永远比渲染漂亮重要。我见过不少项目花大量时间调Canvas阴影和动效,结果一个角落的墙体少了根线,寻路就穿墙而过。每次接新楼层,先检查五类要素是否齐整,再谈界面效果。这条路不难走,坑也都在明面上,把它当成数据工程来做,室内矢量地图就能真正用起来,希望帮到你。本文还有配套的精品资源,点击获取