s3m-maplibre 0.2.9 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1466 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN" class="dark scroll-smooth">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>S3M for MapLibre - 完整开发指南与 API 参考</title>
7
+ <meta name="description" content="SuperMap S3M/S3MB 三维瓦片格式原生 MapLibre / Mapbox 图层核心架构、API 文档与使用手册" />
8
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏢</text></svg>" />
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+ <script>
11
+ tailwind.config = {
12
+ darkMode: 'class',
13
+ theme: {
14
+ extend: {
15
+ colors: {
16
+ brand: {
17
+ 50: '#f0f9ff',
18
+ 400: '#38bdf8',
19
+ 500: '#0ea5e9',
20
+ 600: '#0284c7',
21
+ }
22
+ }
23
+ }
24
+ }
25
+ }
26
+ </script>
27
+ <style>
28
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
29
+ ::-webkit-scrollbar-track { background: transparent; }
30
+ ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; }
31
+ ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.28); }
32
+
33
+ body {
34
+ background-color: #0b101b;
35
+ color: #cbd5e1;
36
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
37
+ }
38
+ .doc-content h1 {
39
+ font-size: 2.25rem;
40
+ font-weight: 800;
41
+ letter-spacing: -0.025em;
42
+ color: #f8fafc;
43
+ margin-top: 1.5rem;
44
+ margin-bottom: 1.25rem;
45
+ padding-bottom: 0.75rem;
46
+ border-bottom: 1px solid rgba(255,255,255,0.1);
47
+ }
48
+ .doc-content h2 {
49
+ font-size: 1.6rem;
50
+ font-weight: 700;
51
+ color: #f1f5f9;
52
+ margin-top: 2.75rem;
53
+ margin-bottom: 1rem;
54
+ padding-bottom: 0.4rem;
55
+ border-bottom: 1px solid rgba(255,255,255,0.08);
56
+ }
57
+ .doc-content h3 {
58
+ font-size: 1.25rem;
59
+ font-weight: 600;
60
+ color: #e2e8f0;
61
+ margin-top: 2rem;
62
+ margin-bottom: 0.75rem;
63
+ }
64
+ .doc-content p {
65
+ margin-top: 0.85rem;
66
+ margin-bottom: 0.85rem;
67
+ line-height: 1.78;
68
+ color: #94a3b8;
69
+ }
70
+ .doc-content ul {
71
+ list-style-type: disc;
72
+ padding-left: 1.5rem;
73
+ margin-top: 0.75rem;
74
+ margin-bottom: 0.75rem;
75
+ color: #94a3b8;
76
+ line-height: 1.7;
77
+ }
78
+ .doc-content ol {
79
+ list-style-type: decimal;
80
+ padding-left: 1.5rem;
81
+ margin-top: 0.75rem;
82
+ margin-bottom: 0.75rem;
83
+ color: #94a3b8;
84
+ line-height: 1.7;
85
+ }
86
+ .doc-content li { margin-bottom: 0.35rem; }
87
+ .doc-content a {
88
+ color: #38bdf8;
89
+ text-decoration: underline;
90
+ text-underline-offset: 3px;
91
+ transition: color 0.15s;
92
+ }
93
+ .doc-content a:hover { color: #7dd3fc; }
94
+ .doc-content code:not(pre code) {
95
+ background: rgba(255,255,255,0.08);
96
+ color: #e2e8f0;
97
+ padding: 0.18rem 0.38rem;
98
+ border-radius: 0.375rem;
99
+ font-size: 0.86em;
100
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
101
+ border: 1px solid rgba(255,255,255,0.06);
102
+ }
103
+ .doc-content hr {
104
+ margin: 3rem 0;
105
+ border-color: rgba(255,255,255,0.08);
106
+ }
107
+ .doc-content strong { color: #f8fafc; font-weight: 600; }
108
+
109
+ .doc-heading { position: relative; }
110
+ .heading-anchor-link {
111
+ position: absolute;
112
+ left: -1.25rem;
113
+ top: 50%;
114
+ transform: translateY(-50%);
115
+ color: #38bdf8;
116
+ opacity: 0;
117
+ font-weight: 400;
118
+ transition: opacity 0.15s;
119
+ }
120
+ .doc-heading:hover .heading-anchor-link { opacity: 1; }
121
+ .doc-heading a.heading-text { text-decoration: none; color: inherit; }
122
+
123
+ .sidebar-link {
124
+ display: block;
125
+ padding: 0.35rem 0.75rem;
126
+ margin-bottom: 0.15rem;
127
+ border-radius: 0.375rem;
128
+ color: #94a3b8;
129
+ font-size: 0.875rem;
130
+ text-decoration: none !important;
131
+ transition: all 0.15s;
132
+ }
133
+ .sidebar-link:hover {
134
+ background: rgba(255,255,255,0.05);
135
+ color: #f1f5f9;
136
+ }
137
+ .sidebar-link.active {
138
+ background: rgba(56,189,248,0.12);
139
+ color: #38bdf8;
140
+ font-weight: 600;
141
+ }
142
+ .toc-link.active {
143
+ color: #38bdf8;
144
+ font-weight: 600;
145
+ border-left: 2px solid #38bdf8;
146
+ padding-left: 0.5rem;
147
+ }
148
+
149
+ .table-responsive th, .table-responsive td {
150
+ padding: 0.75rem 1rem;
151
+ vertical-align: top;
152
+ }
153
+ </style>
154
+ </head>
155
+ <body class="min-h-screen flex flex-col">
156
+
157
+ <header class="sticky top-0 z-40 w-full border-b border-white/10 bg-[#0b101b]/90 backdrop-blur-md">
158
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
159
+
160
+ <div class="flex items-center gap-3">
161
+ <a href="../demo/index.html" class="flex items-center gap-2.5 text-white font-bold text-lg tracking-tight hover:opacity-90 transition">
162
+ <span class="w-8 h-8 rounded-lg bg-gradient-to-tr from-cyan-500 to-blue-600 flex items-center justify-center text-white shadow-lg shadow-cyan-500/20">
163
+ 🏢
164
+ </span>
165
+ <span>s3m-maplibre</span>
166
+ </a>
167
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono font-medium bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">
168
+ v0.2.10
169
+ </span>
170
+ </div>
171
+
172
+ <div class="flex items-center gap-4">
173
+ <div class="relative hidden sm:block w-64 lg:w-80">
174
+ <input
175
+ id="searchInput"
176
+ type="text"
177
+ placeholder="搜索文档 (如: pick, lod, 投影...)"
178
+ class="w-full bg-[#131b2e] border border-white/10 rounded-lg px-3.5 py-1.5 text-xs text-slate-200 placeholder-slate-500 focus:outline-none focus:border-cyan-400 transition"
179
+ />
180
+ <span class="absolute right-2.5 top-2 text-[10px] font-mono text-slate-500 border border-white/10 rounded px-1">/</span>
181
+ </div>
182
+
183
+ <a href="../demo/index.html" target="_blank" class="hidden md:inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-cyan-500 text-slate-950 font-semibold hover:bg-cyan-400 transition shadow-md shadow-cyan-500/10">
184
+ <span>在线三维 Demo</span>
185
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
186
+ </a>
187
+
188
+ <a href="https://github.com/joo1es/s3m-maplibre" target="_blank" class="p-2 text-slate-400 hover:text-white transition" title="GitHub 仓库">
189
+ <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.53 1.032 1.53 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"></path></svg>
190
+ </a>
191
+ </div>
192
+ </div>
193
+ </header>
194
+
195
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full flex-1 flex gap-8">
196
+
197
+ <aside id="sidebar" class="hidden md:block w-64 shrink-0 py-8 border-r border-white/5 pr-4 h-[calc(100vh-4rem)] sticky top-16 overflow-y-auto">
198
+ <div class="text-xs font-semibold text-slate-400 tracking-wider uppercase mb-3 px-2 select-none">
199
+ 文档导航
200
+ </div>
201
+ <nav class="space-y-0.5" id="navLinks">
202
+ <a href="#目录" class="sidebar-link depth-2">目录</a>
203
+ <a href="#安装" class="sidebar-link depth-2">安装</a>
204
+ <a href="#typescript" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">TypeScript</a>
205
+ <a href="#宿主兼容" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">宿主兼容</a>
206
+ <a href="#快速开始" class="sidebar-link depth-2">快速开始</a>
207
+ <a href="#核心概念" class="sidebar-link depth-2">核心概念</a>
208
+ <a href="#坐标链路" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">坐标链路</a>
209
+ <a href="#锚点" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">锚点</a>
210
+ <a href="#瓦片与-lod" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">瓦片与 LOD</a>
211
+ <a href="#单体化" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">单体化</a>
212
+ <a href="#s3mlayer" class="sidebar-link depth-2">`S3MLayer`</a>
213
+ <a href="#构造函数" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">构造函数</a>
214
+ <a href="#数据集管理" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">数据集管理</a>
215
+ <a href="#样式" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">样式</a>
216
+ <a href="#棱线" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">棱线</a>
217
+ <a href="#拾取与空间查询" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">拾取与空间查询</a>
218
+ <a href="#其他" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">其他</a>
219
+ <a href="#样式-s3mstyle" class="sidebar-link depth-2">样式(S3MStyle)</a>
220
+ <a href="#着色模式" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">着色模式</a>
221
+ <a href="#关于-按单体-配色" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">关于「按单体」配色</a>
222
+ <a href="#关于-shading" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">关于 `shading`</a>
223
+ <a href="#关于光照" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">关于光照</a>
224
+ <a href="#拾取与属性" class="sidebar-link depth-2">拾取与属性</a>
225
+ <a href="#投影" class="sidebar-link depth-2">投影</a>
226
+ <a href="#按业务字段着色" class="sidebar-link depth-2">按业务字段着色</a>
227
+ <a href="#过滤-抬高" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">过滤 / 抬高</a>
228
+ <a href="#为什么这么重-以及怎么减" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">为什么这么重,以及怎么减</a>
229
+ <a href="#纯-three-js-使用" class="sidebar-link depth-2">纯 three.js 使用</a>
230
+ <a href="#底层-api" class="sidebar-link depth-2">底层 API</a>
231
+ <a href="#parses3mb" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">`parseS3MB`</a>
232
+ <a href="#meshopt-压缩几何" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">MeshOpt 压缩几何</a>
233
+ <a href="#s3mtileset" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">`S3MTileset`</a>
234
+ <a href="#单体属性工具" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">单体属性工具</a>
235
+ <a href="#性能调优" class="sidebar-link depth-2">性能调优</a>
236
+ <a href="#批量改单体样式" class="sidebar-link depth-3 pl-3 text-slate-400 hover:text-cyan-300 text-xs">批量改单体样式</a>
237
+ <a href="#已知限制" class="sidebar-link depth-2">已知限制</a>
238
+ <a href="#数据与许可" class="sidebar-link depth-2">数据与许可</a>
239
+
240
+ </nav>
241
+ </aside>
242
+
243
+ <main class="flex-1 min-w-0 py-8 lg:py-10 max-w-4xl doc-content">
244
+
245
+ <h1 id="api-参考" class="doc-heading group">
246
+ <span class="heading-anchor-link">#</span>
247
+ <a href="#api-参考" class="heading-text">API 参考</a>
248
+ </h1>
249
+
250
+
251
+ <div class="callout my-4 rounded-xl border-l-4 border-cyan-400 bg-cyan-950/20 px-5 py-3 text-slate-300 shadow-sm">
252
+ <div class="text-sm leading-relaxed">版本 0.2.8 · 对应 <code>s3m-maplibre</code> 的 <code>src/index.ts</code> 导出面</div>
253
+ </div>
254
+
255
+ <hr />
256
+
257
+ <h2 id="目录" class="doc-heading group">
258
+ <span class="heading-anchor-link">#</span>
259
+ <a href="#目录" class="heading-text">目录</a>
260
+ </h2>
261
+
262
+ <ul>
263
+ <li><a href="#安装">安装</a></li>
264
+ <li><a href="#快速开始">快速开始</a></li>
265
+ <li><a href="#核心概念">核心概念</a></li>
266
+ <li><a href="#s3mlayer"><code>S3MLayer</code></a></li>
267
+ <li><a href="#样式s3mstyle">样式(S3MStyle)</a></li>
268
+ <li><a href="#拾取与属性">拾取与属性</a></li>
269
+ <li><a href="#投影">投影</a></li>
270
+ <li><a href="#按业务字段着色">按业务字段着色</a></li>
271
+ <li><a href="#纯-threejs-使用">纯 three.js 使用</a></li>
272
+ <li><a href="#底层-api">底层 API</a></li>
273
+ <li><a href="#性能调优">性能调优</a></li>
274
+ <li><a href="#已知限制">已知限制</a></li>
275
+ </ul>
276
+ <hr />
277
+
278
+ <h2 id="安装" class="doc-heading group">
279
+ <span class="heading-anchor-link">#</span>
280
+ <a href="#安装" class="heading-text">安装</a>
281
+ </h2>
282
+
283
+
284
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
285
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
286
+ <span class="font-semibold text-cyan-400 tracking-wider">BASH</span>
287
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="npm%20install%20s3m-maplibre%20three">
288
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
289
+ <span class="copy-status">复制</span>
290
+ </button>
291
+ </div>
292
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>npm install s3m-maplibre three</code></pre>
293
+ </div>
294
+
295
+ <p><code>three</code> 是 peer 依赖,需要宿主项目自己提供(要求 <code>&gt;=0.168.0</code>,该版本起自带 TypeScript 类型)。</p>
296
+ <p><code>maplibre-gl</code> <strong>不是</strong>本包的依赖:图层只是一个符合 <code>CustomLayerInterface</code> 的普通对象,你通过 <code>map.addLayer()</code> 挂上去即可。</p>
297
+
298
+ <h3 id="typescript" class="doc-heading group">
299
+ <span class="heading-anchor-link">#</span>
300
+ <a href="#typescript" class="heading-text">TypeScript</a>
301
+ </h3>
302
+
303
+ <p>包内置类型声明(<code>dist/index.d.ts</code>),由 TS 源码直接编译产出,开箱可用。公开 API 会暴露 <code>THREE.Mesh</code> / <code>THREE.Group</code> 等对象,three <code>&gt;=0.168</code> 自带类型;更老的版本需要 <code>@types/three</code>。</p>
304
+
305
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
306
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
307
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
308
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="import%20%7B%20S3MLayer%2C%20STYLE%2C%20type%20S3MPickResult%20%7D%20from%20's3m-maplibre'%3B">
309
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
310
+ <span class="copy-status">复制</span>
311
+ </button>
312
+ </div>
313
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>import { S3MLayer, STYLE, type S3MPickResult } from &#39;s3m-maplibre&#39;;</code></pre>
314
+ </div>
315
+
316
+
317
+ <h3 id="宿主兼容" class="doc-heading group">
318
+ <span class="heading-anchor-link">#</span>
319
+ <a href="#宿主兼容" class="heading-text">宿主兼容</a>
320
+ </h3>
321
+
322
+ <p>图层是 MapLibre / Mapbox 都能挂的普通对象,不依赖任何一方的类型:</p>
323
+ <ul>
324
+ <li><code>render(gl, matrix)</code> 同时接受 <code>Float32Array(16)</code>(MapLibre v4)、<code>{ defaultProjectionData: { mainMatrix } }</code>(MapLibre v5)和 <code>modelViewProjectionMatrix</code>(Mapbox)。</li>
325
+ <li>只用到宿主的 <code>getCanvas / triggerRepaint / on / off / getBearing</code>,其余全部可选。</li>
326
+ </ul>
327
+ <hr />
328
+
329
+ <h2 id="快速开始" class="doc-heading group">
330
+ <span class="heading-anchor-link">#</span>
331
+ <a href="#快速开始" class="heading-text">快速开始</a>
332
+ </h2>
333
+
334
+
335
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
336
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
337
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
338
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="import%20maplibregl%20from%20'maplibre-gl'%3B%0Aimport%20%7B%20S3MLayer%2C%20STYLE%20%7D%20from%20's3m-maplibre'%3B%0A%0Aconst%20map%20%3D%20new%20maplibregl.Map(%7B%0A%20%20container%3A%20'map'%2C%0A%20%20style%3A%20'https%3A%2F%2Fdemotiles.maplibre.org%2Fstyle.json'%2C%0A%20%20center%3A%20%5B121.4739%2C%2031.244%5D%2C%0A%20%20zoom%3A%2016.5%2C%0A%20%20pitch%3A%2060%2C%0A%20%20antialias%3A%20true%2C%20%2F%2F%20%E5%BB%BA%E7%AD%91%E8%BE%B9%E7%BC%98%E9%9C%80%E8%A6%81%E6%8A%97%E9%94%AF%E9%BD%BF%0A%7D)%3B%0A%0Aconst%20layer%20%3D%20new%20S3MLayer(%7B%0A%20%20datasets%3A%20%5B%0A%20%20%20%20%7B%0A%20%20%20%20%20%20id%3A%20'jingmo'%2C%0A%20%20%20%20%20%20name%3A%20'%E7%B2%BE%E6%A8%A1'%2C%0A%20%20%20%20%20%20configUrl%3A%20'https%3A%2F%2Fhost%2Fiserver%2Fservices%2Fsvc%2Frest%2Frealspace%2Fdatas%2Fjingmo%2Fdata%2Fpath%2Fjingmo.scp'%2C%0A%20%20%20%20%7D%2C%0A%20%20%5D%2C%0A%20%20style%3A%20%7B%20mode%3A%20STYLE.WHITE%2C%20opacity%3A%200.9%20%7D%2C%0A%20%20onPick%3A%20(f)%20%3D%3E%20console.log(f%3F.smid%2C%20f%3F.height)%2C%0A%7D)%3B%0A%0Amap.on('load'%2C%20()%20%3D%3E%20map.addLayer(layer))%3B">
339
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
340
+ <span class="copy-status">复制</span>
341
+ </button>
342
+ </div>
343
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>import maplibregl from &#39;maplibre-gl&#39;;
344
+ import { S3MLayer, STYLE } from &#39;s3m-maplibre&#39;;
345
+
346
+ const map = new maplibregl.Map({
347
+ container: &#39;map&#39;,
348
+ style: &#39;https://demotiles.maplibre.org/style.json&#39;,
349
+ center: [121.4739, 31.244],
350
+ zoom: 16.5,
351
+ pitch: 60,
352
+ antialias: true, // 建筑边缘需要抗锯齿
353
+ });
354
+
355
+ const layer = new S3MLayer({
356
+ datasets: [
357
+ {
358
+ id: &#39;jingmo&#39;,
359
+ name: &#39;精模&#39;,
360
+ configUrl: &#39;https://host/iserver/services/svc/rest/realspace/datas/jingmo/data/path/jingmo.scp&#39;,
361
+ },
362
+ ],
363
+ style: { mode: STYLE.WHITE, opacity: 0.9 },
364
+ onPick: (f) =&gt; console.log(f?.smid, f?.height),
365
+ });
366
+
367
+ map.on(&#39;load&#39;, () =&gt; map.addLayer(layer));</code></pre>
368
+ </div>
369
+
370
+ <hr />
371
+
372
+ <h2 id="核心概念" class="doc-heading group">
373
+ <span class="heading-anchor-link">#</span>
374
+ <a href="#核心概念" class="heading-text">核心概念</a>
375
+ </h2>
376
+
377
+
378
+ <h3 id="坐标链路" class="doc-heading group">
379
+ <span class="heading-anchor-link">#</span>
380
+ <a href="#坐标链路" class="heading-text">坐标链路</a>
381
+ </h3>
382
+
383
+
384
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
385
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
386
+ <span class="font-semibold text-cyan-400 tracking-wider">CODE</span>
387
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="S3M%20%E9%A1%B6%E7%82%B9%EF%BC%88%E6%95%B0%E6%8D%AE%E9%9B%86%E5%B1%80%E9%83%A8%20ENU%EF%BC%8C%E7%B1%B3%EF%BC%89%0A%20%20%E2%86%92%20%E6%95%B0%E6%8D%AE%E9%9B%86%20Group%20%E5%81%8F%E7%A7%BB%EF%BC%9A%E6%8A%8A%E5%90%84%E6%95%B0%E6%8D%AE%E9%9B%86%E9%94%9A%E7%82%B9%E5%AF%B9%E9%BD%90%E5%88%B0%E3%80%8C%E5%9C%BA%E6%99%AF%E9%94%9A%E7%82%B9%E3%80%8D%0A%20%20%E2%86%92%20L%20%E7%9F%A9%E9%98%B5%EF%BC%9AENU%20%E2%86%92%20Web%20Mercator%20%E5%BD%92%E4%B8%80%E5%8C%96%E5%9D%90%E6%A0%87%0A%20%20%E2%86%92%20MapLibre%20%E4%BC%A0%E5%85%A5%E7%9A%84%20mercator%E2%86%92clip%20%E7%9F%A9%E9%98%B5">
388
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
389
+ <span class="copy-status">复制</span>
390
+ </button>
391
+ </div>
392
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>S3M 顶点(数据集局部 ENU,米)
393
+ → 数据集 Group 偏移:把各数据集锚点对齐到「场景锚点」
394
+ → L 矩阵:ENU → Web Mercator 归一化坐标
395
+ → MapLibre 传入的 mercator→clip 矩阵</code></pre>
396
+ </div>
397
+
398
+ <p>three 场景原点固定在<strong>场景锚点</strong>(第一个成功加载的数据集的位置),所有顶点只保留「相对锚点的米」。这是 3D Tiles <code>RTC_CENTER</code> 的同类做法,目的是避免 float32 在墨卡托大坐标下抖动。</p>
399
+ <p>多数据集叠加时,每个数据集一个 <code>THREE.Group</code>,用 <code>position</code> 承担锚点差。因此<strong>所有数据集应当位于同一城市量级</strong>(几公里内),跨城市叠加会重新引入精度问题。</p>
400
+
401
+ <h3 id="锚点" class="doc-heading group">
402
+ <span class="heading-anchor-link">#</span>
403
+ <a href="#锚点" class="heading-text">锚点</a>
404
+ </h3>
405
+
406
+ <p>锚点来自数据集配置的 <code>position</code>,但会做一致性校验:若 <code>position</code> 与 <code>geoBounds</code> 中心偏差超过 0.5°,判定为生产时的占位脏数据,回退到 <code>geoBounds</code> 中心。</p>
407
+
408
+ <h3 id="瓦片与-lod" class="doc-heading group">
409
+ <span class="heading-anchor-link">#</span>
410
+ <a href="#瓦片与-lod" class="heading-text">瓦片与 LOD</a>
411
+ </h3>
412
+
413
+ <p>配置里的 <code>tiles</code>(桌面导出)或 <code>rootTiles</code>(iServer,可多棵)是瓦片树入口。LOD 按规范里的 <code>RangeMode</code> 判定:</p>
414
+
415
+ <div class="table-responsive my-6 overflow-x-auto rounded-xl border border-white/10 bg-[#121826]/70 shadow-lg">
416
+ <table class="w-full text-left text-sm text-slate-300">
417
+ <thead class="bg-white/5 text-slate-200 font-semibold border-b border-white/10">
418
+ <tr><th>rangeMode</th><th>含义</th><th>判定</th></tr>
419
+ </thead>
420
+ <tbody class="divide-y divide-white/5">
421
+ <tr><td>0</td><td><code>Distance_From_EyePoint</code></td><td>相机到包围球中心距离 < rangeValue 时细化</td></tr>
422
+ <tr><td>1</td><td><code>Pixel_Size_OnScreen</code></td><td>包围球投影像素半径 > rangeValue 时细化</td></tr>
423
+ <tr><td>2</td><td><code>Geometric_Error</code></td><td>按屏幕像素半径近似处理</td></tr>
424
+ </tbody>
425
+ </table>
426
+ </div>
427
+
428
+
429
+ <h3 id="单体化" class="doc-heading group">
430
+ <span class="heading-anchor-link">#</span>
431
+ <a href="#单体化" class="heading-text">单体化</a>
432
+ </h3>
433
+
434
+ <p>S3M 瓦片里可能带 <code>pickInfo</code>:<code>SMID → batchId + 顶点区间</code>。解析时会把它展开成逐顶点的 <code>aBatchId</code> 属性,于是 GPU 可以按单体着色、按单体拾取。没有单体化信息的几何 <code>batchId = -1</code>,只能整块拾取。</p>
435
+
436
+ <h4 id="batch-粒度-多个-smid-共用一个-batch" class="doc-heading group">
437
+ <span class="heading-anchor-link">#</span>
438
+ <a href="#batch-粒度-多个-smid-共用一个-batch" class="heading-text">batch 粒度:多个 SMID 共用一个 batch</a>
439
+ </h4>
440
+
441
+ <p>单体化的分辨率只到 <strong>batch</strong>,不是 SMID。实测有数据会把若干相邻铺面合并成一个 batch(白模里甚至 81 个 mesh 共用一个材质码),于是「给 3 号楼单独上色」这件事在几何层面就不成立。</p>
442
+ <p>内部规则(确定性的,不再「最后写的说了算」):</p>
443
+ <ul>
444
+ <li><strong>任一 SMID 需要隐藏 → 整批隐藏</strong>;</li>
445
+ <li><strong>颜色 / 抬高取 SMID 最小的那一个</strong>;</li>
446
+ <li>真的发生时只 <code>console.warn</code> 一次,不会每次重写纹理都刷屏。</li>
447
+ </ul>
448
+ <p>先摸清数据能细到什么程度:</p>
449
+
450
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
451
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
452
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
453
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="const%20audit%20%3D%20layer.auditBatches()%3B%0A%2F%2F%20%7B%20smids%2C%20batches%2C%20conflicts%3A%20%5B%7B%20datasetId%2C%20batchId%2C%20smids%20%7D%5D%2C%20conflictingSmids%20%7D">
454
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
455
+ <span class="copy-status">复制</span>
456
+ </button>
457
+ </div>
458
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>const audit = layer.auditBatches();
459
+ // { smids, batches, conflicts: [{ datasetId, batchId, smids }], conflictingSmids }</code></pre>
460
+ </div>
461
+
462
+ <p><code>conflicts</code> 为空说明这批数据可以放心按单体操作;不为空时,这些单体对应「按 batch 罩层」之类的做法,靠本库的单体化通道做不到。</p>
463
+ <hr />
464
+
465
+ <h2 id="s3mlayer" class="doc-heading group">
466
+ <span class="heading-anchor-link">#</span>
467
+ <a href="#s3mlayer" class="heading-text"><code>S3MLayer</code></a>
468
+ </h2>
469
+
470
+ <p>MapLibre 自定义图层。<code>type === &#39;custom&#39;</code>、<code>renderingMode === &#39;3d&#39;</code>。</p>
471
+
472
+ <h3 id="构造函数" class="doc-heading group">
473
+ <span class="heading-anchor-link">#</span>
474
+ <a href="#构造函数" class="heading-text">构造函数</a>
475
+ </h3>
476
+
477
+
478
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
479
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
480
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
481
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="new%20S3MLayer(options%3F%3A%20S3MLayerOptions)">
482
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
483
+ <span class="copy-status">复制</span>
484
+ </button>
485
+ </div>
486
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>new S3MLayer(options?: S3MLayerOptions)</code></pre>
487
+ </div>
488
+
489
+
490
+ <div class="table-responsive my-6 overflow-x-auto rounded-xl border border-white/10 bg-[#121826]/70 shadow-lg">
491
+ <table class="w-full text-left text-sm text-slate-300">
492
+ <thead class="bg-white/5 text-slate-200 font-semibold border-b border-white/10">
493
+ <tr><th>参数</th><th>类型</th><th>默认</th><th>说明</th></tr>
494
+ </thead>
495
+ <tbody class="divide-y divide-white/5">
496
+ <tr><td><code>id</code></td><td><code>string</code></td><td><code>&#39;s3m&#39;</code></td><td>图层 id</td></tr>
497
+ <tr><td><code>configUrl</code></td><td><code>string</code></td><td>—</td><td>单数据集简写</td></tr>
498
+ <tr><td><code>datasets</code></td><td><code>S3MDatasetOptions[]</code></td><td><code>[]</code></td><td>多数据集叠加</td></tr>
499
+ <tr><td><code>style</code></td><td><code>Partial&lt;S3MStyle&gt;</code></td><td>见<a href="#样式s3mstyle">样式</a></td><td>初始样式</td></tr>
500
+ <tr><td><code>pickable</code></td><td><code>boolean</code></td><td><code>true</code></td><td>是否响应点击</td></tr>
501
+ <tr><td><code>maxTiles</code></td><td><code>number</code></td><td><code>64</code></td><td>每个数据集驻留瓦片上限,超了淘汰最久没用的(不是停止加载)</td></tr>
502
+ <tr><td><code>maxConcurrent</code></td><td><code>number</code></td><td><code>4</code></td><td>每个数据集并发请求数</td></tr>
503
+ <tr><td><code>projection</code></td><td>`'perspective' \</td><td>'orthographic'`</td><td><code>&#39;perspective&#39;</code></td><td>投影模式,见<a href="#投影">投影</a></td></tr>
504
+ <tr><td><code>onStats</code></td><td><code>(s: S3MStats) =&gt; void</code></td><td>—</td><td>统计更新</td></tr>
505
+ <tr><td><code>onReady</code></td><td><code>(entry) =&gt; void</code></td><td>—</td><td>某个数据集就绪</td></tr>
506
+ <tr><td><code>onPick</code></td><td>`(f: S3MPickResult \</td><td>null) => void`</td><td>—</td><td>点击拾取结果</td></tr>
507
+ <tr><td><code>onError</code></td><td><code>(e: unknown) =&gt; void</code></td><td>—</td><td>瓦片加载 / 解析错误</td></tr>
508
+ </tbody>
509
+ </table>
510
+ </div>
511
+
512
+ <p><code>configUrl</code> 与 <code>datasets</code> 二选一;同时给时以 <code>datasets</code> 为准。</p>
513
+
514
+ <h3 id="数据集管理" class="doc-heading group">
515
+ <span class="heading-anchor-link">#</span>
516
+ <a href="#数据集管理" class="heading-text">数据集管理</a>
517
+ </h3>
518
+
519
+
520
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
521
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
522
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
523
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="addDataset(ds%3A%20S3MDatasetOptions)%3A%20Promise%3CS3MDatasetEntry%3E%0AremoveDataset(id%3A%20string)%3A%20void%0AsetDatasetVisible(id%3A%20string%2C%20visible%3A%20boolean)%3A%20void">
524
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
525
+ <span class="copy-status">复制</span>
526
+ </button>
527
+ </div>
528
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>addDataset(ds: S3MDatasetOptions): Promise&lt;S3MDatasetEntry&gt;
529
+ removeDataset(id: string): void
530
+ setDatasetVisible(id: string, visible: boolean): void</code></pre>
531
+ </div>
532
+
533
+
534
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
535
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
536
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
537
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="interface%20S3MDatasetOptions%20%7B%0A%20%20id%3A%20string%3B%20%20%20%20%20%20%20%20%20%20%20%2F%2F%20%E5%94%AF%E4%B8%80%E6%A0%87%E8%AF%86%0A%20%20name%3F%3A%20string%3B%20%20%20%20%20%20%20%20%2F%2F%20%E5%B1%95%E7%A4%BA%E5%90%8D%0A%20%20configUrl%3A%20string%3B%20%20%20%20%2F%2F%20.scp%20%E5%9C%B0%E5%9D%80%0A%20%20visible%3F%3A%20boolean%3B%20%20%20%20%2F%2F%20%E9%BB%98%E8%AE%A4%20true%0A%20%20maxTiles%3F%3A%20number%3B%20%20%20%20%2F%2F%20%E8%A6%86%E7%9B%96%E5%9B%BE%E5%B1%82%E7%BA%A7%E9%85%8D%E7%BD%AE%0A%7D">
538
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
539
+ <span class="copy-status">复制</span>
540
+ </button>
541
+ </div>
542
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>interface S3MDatasetOptions {
543
+ id: string; // 唯一标识
544
+ name?: string; // 展示名
545
+ configUrl: string; // .scp 地址
546
+ visible?: boolean; // 默认 true
547
+ maxTiles?: number; // 覆盖图层级配置
548
+ }</code></pre>
549
+ </div>
550
+
551
+ <p><code>addDataset</code> 在图层 <code>onAdd</code> 之前调用会抛错(此时还没有 WebGL 上下文)。</p>
552
+
553
+ <h3 id="样式" class="doc-heading group">
554
+ <span class="heading-anchor-link">#</span>
555
+ <a href="#样式" class="heading-text">样式</a>
556
+ </h3>
557
+
558
+
559
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
560
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
561
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
562
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="setStyle(patch%3F%3A%20Partial%3CS3MStyle%3E)%3A%20void%0AgetStyle()%3A%20S3MStyle%20%26%20%7B%20modeLabel%3A%20string%20%7D">
563
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
564
+ <span class="copy-status">复制</span>
565
+ </button>
566
+ </div>
567
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>setStyle(patch?: Partial&lt;S3MStyle&gt;): void
568
+ getStyle(): S3MStyle &amp; { modeLabel: string }</code></pre>
569
+ </div>
570
+
571
+ <p><code>setStyle</code> 是<strong>合并</strong>语义,只传要改的字段。除了 <code>shading</code>(编译期宏,需重编译着色器)之外,其余字段都是 uniform / 材质状态更新,热切换零成本。</p>
572
+
573
+ <h3 id="棱线" class="doc-heading group">
574
+ <span class="heading-anchor-link">#</span>
575
+ <a href="#棱线" class="heading-text">棱线</a>
576
+ </h3>
577
+
578
+
579
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
580
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
581
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
582
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="setEdges(enabled%3A%20boolean)%3A%20void">
583
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
584
+ <span class="copy-status">复制</span>
585
+ </button>
586
+ </div>
587
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>setEdges(enabled: boolean): void</code></pre>
588
+ </div>
589
+
590
+ <p>优先用<strong>数据自带的棱线包</strong>(<code>primitiveType === 13</code> 的索引包,本质是线段对):解析时就建好 <code>LineSegments</code>,开关只是切 <code>visible</code>,成本为零,颜色跟随 <code>style.edgeColor</code>。</p>
591
+ <p>数据里没有棱线包时才用 <code>EdgesGeometry</code> 在 CPU 端现算。几十万面一次性建会卡住主线程(实测 20 个网格 1.9s),所以内部按 8ms 一帧切片执行,超过 12 万索引的几何直接跳过。</p>
592
+ <p>棱线本身画成<strong>屏幕空间等宽线</strong>(<code>src/render/EdgeLines.ts</code>):WebGL 的 <code>lineWidth</code> 在绝大多数平台被忽略、恒定 1px,只有把每条棱摊成面向屏幕的四边形才能真的调粗细。因此:</p>
593
+ <ul>
594
+ <li><code>edgeWidth</code>(像素)控全局棱线粗细,缩放时线宽恒定;</li>
595
+ <li>选中单体的描边默认也走同一套画法(<code>outlineStyle: &#39;edges&#39;</code>),只挑这个单体自己的三角面算折边,<code>outlineWidthPx</code> 控粗细;</li>
596
+ <li>数据自带的棱线包是原始线段,仍是 1px,只受颜色 / 透明度控制。</li>
597
+ </ul>
598
+
599
+ <div class="callout my-4 rounded-xl border-l-4 border-cyan-400 bg-cyan-950/20 px-5 py-3 text-slate-300 shadow-sm">
600
+ <div class="text-sm leading-relaxed">注意:棱线包<strong>不能</strong>当三角面画。把它当 <code>TRIANGLES</code> 提交,每条棱线都会被连成一根几十上百米的尖刺,整片模型变成放射状三角面。</div>
601
+ </div>
602
+
603
+
604
+ <h3 id="拾取与空间查询" class="doc-heading group">
605
+ <span class="heading-anchor-link">#</span>
606
+ <a href="#拾取与空间查询" class="heading-text">拾取与空间查询</a>
607
+ </h3>
608
+
609
+
610
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
611
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
612
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
613
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="pick(x%3A%20number%2C%20y%3A%20number)%3A%20S3MPickResult%20%7C%20null%0ApickFromEvent(e)%3A%20S3MPickResult%20%7C%20null%0ApickPosition(x%3A%20number%2C%20y%3A%20number)%3A%20%7B%20lngLat%3A%20%5Bnumber%2C%20number%5D%3B%20altitude%3A%20number%3B%20point%3A%20%5Bnumber%2C%20number%2C%20number%5D%20%7D%20%7C%20null%0AdrillPick(x%3A%20number%2C%20y%3A%20number%2C%20limit%3F%3A%20number)%3A%20S3MPickResult%5B%5D%0Aselect(result%3A%20S3MPickResult%20%7C%20null%2C%20opts%3F%3A%20%7B%20silent%3F%3A%20boolean%20%7D)%3A%20void%0AgetFeatureBounds(smid%3A%20number)%3A%20%5B%5Bnumber%2C%20number%5D%2C%20%5Bnumber%2C%20number%5D%5D%20%7C%20null%0AflyToFeature(smid%3A%20number%2C%20options%3F%3A%20Record%3Cstring%2C%20unknown%3E)%3A%20boolean">
614
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
615
+ <span class="copy-status">复制</span>
616
+ </button>
617
+ </div>
618
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>pick(x: number, y: number): S3MPickResult | null
619
+ pickFromEvent(e): S3MPickResult | null
620
+ pickPosition(x: number, y: number): { lngLat: [number, number]; altitude: number; point: [number, number, number] } | null
621
+ drillPick(x: number, y: number, limit?: number): S3MPickResult[]
622
+ select(result: S3MPickResult | null, opts?: { silent?: boolean }): void
623
+ getFeatureBounds(smid: number): [[number, number], [number, number]] | null
624
+ flyToFeature(smid: number, options?: Record&lt;string, unknown&gt;): boolean</code></pre>
625
+ </div>
626
+
627
+ <p><code>pick</code> 的坐标是<strong>相对 canvas 左上角的 CSS 像素</strong>。<code>pickFromEvent</code> 接受 MapLibre 的 <code>MouseEvent</code> 或原生事件,自动换算。返回值包含单体属性、中心经纬度、以及精确交点世界坐标(<code>point: [x,y,z]</code>, <code>lngLat: [lng,lat]</code>, <code>altitude: number</code>)。</p>
628
+ <ul>
629
+ <li><code>pickPosition(x, y)</code>:拾取指定屏幕像素对应的精确三维世界坐标。优先对命中的建筑网格精确表面求交;未命中建筑时与地表面(z=0)求交。</li>
630
+ <li><code>drillPick(x, y, limit?)</code>:视线穿透拾取,沿射线命中所有单体,按由近至远距离排序返回。</li>
631
+ <li><code>getFeatureBounds(smid)</code>:获取指定 SMID 单体的经纬度包围盒 <code>[[minLng, minLat], [maxLng, maxLat]]</code>(自动叠加单体平移/拔高)。</li>
632
+ <li><code>flyToFeature(smid, options?)</code>:聚焦飞向单体,内部自动调用宿主地图的 <code>map.fitBounds</code>。</li>
633
+ </ul>
634
+ <p>拾取走 GPU:把「物体编号 + batchId」编码成 RGBA 渲染到离屏目标,再读那一个像素。代价与三角面数无关,且天然只命中最前面的可见面。</p>
635
+ <p>离屏目标只有 <strong>1×1</strong>:viewport 尺寸仍是整块画布、原点平移到 <code>(-px, -(h-1-py))</code>,于是整幅画面按原样光栅化,只有点击点那一格落在目标上。着色器、矩阵、深度测试全都不变,但每点一次不再渲染一整屏(移动端收益最明显)。</p>
636
+ <p><code>select</code> 负责高亮与描边,传 <code>null</code> 清除选中。</p>
637
+
638
+ <h3 id="其他" class="doc-heading group">
639
+ <span class="heading-anchor-link">#</span>
640
+ <a href="#其他" class="heading-text">其他</a>
641
+ </h3>
642
+
643
+
644
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
645
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
646
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
647
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="getBounds()%3A%20%5B%5Bnumber%2C%20number%5D%2C%20%5Bnumber%2C%20number%5D%5D%20%7C%20null%20%20%2F%2F%20%E5%90%88%E5%B9%B6%E7%BB%8F%E7%BA%AC%E5%BA%A6%E8%8C%83%E5%9B%B4%0AsetProjection(mode%3A%20'perspective'%20%7C%20'orthographic')%3A%20void%20%20%2F%2F%20%E6%8A%95%E5%BD%B1%E6%A8%A1%E5%BC%8F%EF%BC%8C%E8%A7%81%5B%E6%8A%95%E5%BD%B1%5D(%23%E6%8A%95%E5%BD%B1)%0AsetLodRangeScale(scale%3A%20number%2C%20datasetId%3F%3A%20string)%3A%20void%20%20%2F%2F%20%E7%BC%A9%E6%94%BE%20LOD%20%E5%88%87%E6%8D%A2%E8%B7%9D%E7%A6%BB%E9%98%88%E5%80%BC%EF%BC%88%3E1%20%E6%9B%B4%E5%80%BE%E5%90%91%E7%B2%97%E6%A8%A1%EF%BC%8C%3C1%20%E6%8F%90%E5%89%8D%E7%BB%86%E5%8C%96%EF%BC%89%0Areadonly%20lodRangeScale%3A%20number%0Areadonly%20stats%3A%20S3MStats%0Areadonly%20fps%3A%20number%0Adispose()%3A%20void">
648
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
649
+ <span class="copy-status">复制</span>
650
+ </button>
651
+ </div>
652
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>getBounds(): [[number, number], [number, number]] | null // 合并经纬度范围
653
+ setProjection(mode: &#39;perspective&#39; | &#39;orthographic&#39;): void // 投影模式,见[投影](#投影)
654
+ setLodRangeScale(scale: number, datasetId?: string): void // 缩放 LOD 切换距离阈值(&gt;1 更倾向粗模,&lt;1 提前细化)
655
+ readonly lodRangeScale: number
656
+ readonly stats: S3MStats
657
+ readonly fps: number
658
+ dispose(): void</code></pre>
659
+ </div>
660
+
661
+
662
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
663
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
664
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
665
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="interface%20S3MStats%20%7B%0A%20%20tiles%3A%20number%3B%20loading%3A%20number%3B%20failed%3A%20number%3B%0A%20%20meshes%3A%20number%3B%20vertices%3A%20number%3B%20triangles%3A%20number%3B%20datasets%3A%20number%3B%0A%7D">
666
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
667
+ <span class="copy-status">复制</span>
668
+ </button>
669
+ </div>
670
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>interface S3MStats {
671
+ tiles: number; loading: number; failed: number;
672
+ meshes: number; vertices: number; triangles: number; datasets: number;
673
+ }</code></pre>
674
+ </div>
675
+
676
+ <hr />
677
+
678
+ <h2 id="样式-s3mstyle" class="doc-heading group">
679
+ <span class="heading-anchor-link">#</span>
680
+ <a href="#样式-s3mstyle" class="heading-text">样式(S3MStyle)</a>
681
+ </h2>
682
+
683
+
684
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
685
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
686
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
687
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="interface%20S3MStyle%20%7B%0A%20%20mode%3A%20S3MStyleMode%3B%0A%20%20colorA%3A%20string%3B%20%20%20%20%20%20%20%2F%2F%20%E7%99%BD%E8%86%9C%E5%9F%BA%E8%89%B2%EF%BC%9B%E9%A1%B6%2F%E4%BE%A7%E5%88%86%E8%89%B2%E4%B8%AD%E7%9A%84%E9%A1%B6%E9%9D%A2%E8%89%B2%EF%BC%9B%E9%AB%98%E5%BA%A6%E6%B8%90%E5%8F%98%E4%BD%8E%E7%AB%AF%E8%89%B2%0A%20%20colorB%3A%20string%3B%20%20%20%20%20%20%20%2F%2F%20%E9%A1%B6%2F%E4%BE%A7%E5%88%86%E8%89%B2%E4%B8%AD%E7%9A%84%E4%BE%A7%E7%AB%8B%E9%9D%A2%E8%89%B2%EF%BC%9B%E9%AB%98%E5%BA%A6%2F%E6%A5%BC%E5%B1%82%E6%B8%90%E5%8F%98%E9%AB%98%E7%AB%AF%E8%89%B2%0A%20%20colorC%3A%20string%3B%20%20%20%20%20%20%20%2F%2F%20%E5%8A%A8%E6%80%81%E6%89%AB%E6%8F%8F%E8%89%B2%20%2F%20%E5%A4%8D%E5%90%88%E6%B8%90%E5%8F%98%E9%AB%98%E7%AB%AF%E8%89%B2%0A%20%20selectColor%3A%20string%3B%20%20%2F%2F%20%E9%80%89%E4%B8%AD%E5%8D%95%E4%BD%93%E9%AB%98%E4%BA%AE%E8%89%B2%0A%20%20floorHeight%3A%20number%3B%20%20%2F%2F%20%E6%A5%BC%E5%B1%82%E4%BC%B0%E7%AE%97%E5%B1%82%E9%AB%98%EF%BC%8C%E9%BB%98%E8%AE%A4%203.2%0A%20%20speed%3A%20number%3B%20%20%20%20%20%20%20%20%2F%2F%20%E5%8A%A8%E6%80%81%E6%89%AB%E6%8F%8F%E9%80%9F%E5%BA%A6%E5%80%8D%E7%8E%87%0A%20%20opacity%3A%20number%3B%20%20%20%20%20%20%2F%2F%200.05%20~%201%0A%20%20shading%3A%20'flat'%20%7C%20'smooth'%3B%0A%20%20dimOthers%3A%20boolean%3B%20%20%20%2F%2F%20%E9%80%89%E4%B8%AD%E6%97%B6%E5%8E%8B%E6%9A%97%E5%85%B6%E4%BB%96%E5%BB%BA%E7%AD%91%0A%20%20outline%3A%20boolean%3B%20%20%20%20%20%2F%2F%20%E9%80%89%E4%B8%AD%E5%8D%95%E4%BD%93%E6%8F%8F%E8%BE%B9%0A%20%20outlineStyle%3F%3A%20'edges'%20%7C%20'hull'%3B%20%2F%2F%20%E6%8F%8F%E8%BE%B9%E7%94%BB%E6%B3%95%EF%BC%9A'edges'%20%E5%8D%95%E4%BD%93%E6%A3%B1%E7%BA%BF%EF%BC%88%E9%BB%98%E8%AE%A4%EF%BC%89%7C%20'hull'%20%E8%83%8C%E9%9D%A2%E5%A4%96%E6%89%A9%E8%BD%AE%E5%BB%93%0A%20%20outlineColor%3F%3A%20string%3B%20%20%2F%2F%20%E6%8F%8F%E8%BE%B9%E9%A2%9C%E8%89%B2%EF%BC%8C%E9%BB%98%E8%AE%A4%E8%B7%9F%E9%9A%8F%20selectColor%0A%20%20outlineWidthPx%3F%3A%20number%3B%20%2F%2F%20%E9%80%89%E4%B8%AD%E6%A3%B1%E7%BA%BF%E7%B2%97%E7%BB%86%EF%BC%88%E5%83%8F%E7%B4%A0%EF%BC%8C%E9%BB%98%E8%AE%A4%203%EF%BC%89%EF%BC%8C%E4%BB%85%20outlineStyle%3A%20'edges'%0A%20%20outlineWidth%3F%3A%20number%3B%20%20%20%2F%2F%20%E5%A4%96%E6%89%A9%E8%BD%AE%E5%BB%93%E7%B2%97%E7%BB%86%EF%BC%88%E7%B1%B3%EF%BC%8C%E9%BB%98%E8%AE%A4%200.9%EF%BC%89%EF%BC%8C%E4%BB%85%20outlineStyle%3A%20'hull'%0A%20%20edges%3A%20boolean%3B%20%20%20%20%20%20%20%2F%2F%20%E5%85%A8%E5%B1%80%E6%A3%B1%E7%BA%BF%0A%20%20edgeColor%3A%20string%3B%20%20%20%20%2F%2F%20%E6%A3%B1%E7%BA%BF%E9%A2%9C%E8%89%B2%0A%20%20edgeWidth%3F%3A%20number%3B%20%20%20%2F%2F%20%E5%85%A8%E5%B1%80%E6%A3%B1%E7%BA%BF%E7%B2%97%E7%BB%86%EF%BC%88%E5%83%8F%E7%B4%A0%EF%BC%8C%E9%BB%98%E8%AE%A4%201.2%EF%BC%89%0A%20%20edgeOpacity%3F%3A%20number%3B%20%2F%2F%20%E6%A3%B1%E7%BA%BF%E9%80%8F%E6%98%8E%E5%BA%A6%EF%BC%880.1%20~%201.0%EF%BC%89%EF%BC%8C%E9%BB%98%E8%AE%A4%200.45%0A%20%20roofThreshold%3F%3A%20number%3B%20%2F%2F%20%E9%A1%B6%E9%9D%A2%E6%B3%95%E7%BA%BF%E5%88%A4%E5%AE%9A%E9%98%88%E5%80%BC%EF%BC%8C%E9%BB%98%E8%AE%A4%200.65%0A%7D">
688
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
689
+ <span class="copy-status">复制</span>
690
+ </button>
691
+ </div>
692
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>interface S3MStyle {
693
+ mode: S3MStyleMode;
694
+ colorA: string; // 白膜基色;顶/侧分色中的顶面色;高度渐变低端色
695
+ colorB: string; // 顶/侧分色中的侧立面色;高度/楼层渐变高端色
696
+ colorC: string; // 动态扫描色 / 复合渐变高端色
697
+ selectColor: string; // 选中单体高亮色
698
+ floorHeight: number; // 楼层估算层高,默认 3.2
699
+ speed: number; // 动态扫描速度倍率
700
+ opacity: number; // 0.05 ~ 1
701
+ shading: &#39;flat&#39; | &#39;smooth&#39;;
702
+ dimOthers: boolean; // 选中时压暗其他建筑
703
+ outline: boolean; // 选中单体描边
704
+ outlineStyle?: &#39;edges&#39; | &#39;hull&#39;; // 描边画法:&#39;edges&#39; 单体棱线(默认)| &#39;hull&#39; 背面外扩轮廓
705
+ outlineColor?: string; // 描边颜色,默认跟随 selectColor
706
+ outlineWidthPx?: number; // 选中棱线粗细(像素,默认 3),仅 outlineStyle: &#39;edges&#39;
707
+ outlineWidth?: number; // 外扩轮廓粗细(米,默认 0.9),仅 outlineStyle: &#39;hull&#39;
708
+ edges: boolean; // 全局棱线
709
+ edgeColor: string; // 棱线颜色
710
+ edgeWidth?: number; // 全局棱线粗细(像素,默认 1.2)
711
+ edgeOpacity?: number; // 棱线透明度(0.1 ~ 1.0),默认 0.45
712
+ roofThreshold?: number; // 顶面法线判定阈值,默认 0.65
713
+ }</code></pre>
714
+ </div>
715
+
716
+
717
+ <h3 id="着色模式" class="doc-heading group">
718
+ <span class="heading-anchor-link">#</span>
719
+ <a href="#着色模式" class="heading-text">着色模式</a>
720
+ </h3>
721
+
722
+
723
+ <div class="table-responsive my-6 overflow-x-auto rounded-xl border border-white/10 bg-[#121826]/70 shadow-lg">
724
+ <table class="w-full text-left text-sm text-slate-300">
725
+ <thead class="bg-white/5 text-slate-200 font-semibold border-b border-white/10">
726
+ <tr><th>常量</th><th>值</th><th>说明</th></tr>
727
+ </thead>
728
+ <tbody class="divide-y divide-white/5">
729
+ <tr><td><code>STYLE.WHITE</code></td><td>0</td><td>白膜:忽略贴图与顶点色,纯色 + 光照</td></tr>
730
+ <tr><td><code>STYLE.TEXTURE</code></td><td>1</td><td>原色:贴图 × 顶点色</td></tr>
731
+ <tr><td><code>STYLE.SOLID</code></td><td>2</td><td>单色(<code>colorA</code>)</td></tr>
732
+ <tr><td><code>STYLE.HEIGHT</code></td><td>3</td><td>按高度连续渐变(<code>colorA</code> → <code>colorB</code>)</td></tr>
733
+ <tr><td><code>STYLE.FLOORS</code></td><td>4</td><td>按楼层离散分层,层高取 <code>floorHeight</code></td></tr>
734
+ <tr><td><code>STYLE.BATCH</code></td><td>5</td><td>按单体自动分配色相</td></tr>
735
+ <tr><td><code>STYLE.DYNAMIC</code></td><td>6</td><td>动态扫描:底色按高度渐变 + 扫描线自下而上循环</td></tr>
736
+ <tr><td><code>STYLE.TOP_SIDE</code></td><td>7</td><td>顶面/侧面分色:顶面(<code>colorA</code>)与立面/侧面(<code>colorB</code>)智能分色</td></tr>
737
+ <tr><td><code>STYLE.CUSTOM</code></td><td>8</td><td>自定义复合模式:顶面分色 + 侧立面高度渐变</td></tr>
738
+ </tbody>
739
+ </table>
740
+ </div>
741
+
742
+ <p>所有模式都在同一个 <code>MeshStandardMaterial</code> 的片元着色器里用 <code>uMode</code> 分支实现,切换不重建几何、不重建材质。</p>
743
+
744
+ <h3 id="关于-按单体-配色" class="doc-heading group">
745
+ <span class="heading-anchor-link">#</span>
746
+ <a href="#关于-按单体-配色" class="heading-text">关于「按单体」配色</a>
747
+ </h3>
748
+
749
+ <p><code>STYLE.BATCH</code> 的色相是对 <code>batchId</code> 做哈希(<code>s3mHash</code>)得到的。这里有个很容易踩的坑:<strong><code>batchId</code> 在着色器里是 varying</strong>。GPU 逐像素做透视校正插值时,即使一个三角形三个顶点的 <code>aBatchId</code> 完全相等,三次乘加的舍入误差也不会正好抵消,结果会带 <code>1e-7</code> 量级的相对误差,而且逐像素不同(不同 GPU / 驱动还不一样)。</p>
750
+ <p>这个误差本身无害,但哈希是混沌放大器:<code>fract(p<em>0.1031)</code> → <code>p</em>(p+33.33)</code> → <code>p*2p</code>,每级放大几十倍。实测 <code>batchId≈4.2 万</code> 时,<code>1e-7</code> 的抖动就能把哈希输出从单一值扩散到 <code>0.20~0.78</code> 整个区间 —— 表现就是<strong>整屏彩色噪点</strong>,而且只在部分机器上出现。</p>
751
+ <p>所以凡是「按 id 取值」的地方都先取整(<code>s3mBatchId()</code> = <code>floor(vBatchId + 0.5)</code>):按单体配色、单体状态纹理(隐藏 / 独立改色)采样、选中判定统一走它。同一个单体在所有像素上得到完全相同的颜色,跨 GPU 一致。</p>
752
+
753
+ <h3 id="关于-shading" class="doc-heading group">
754
+ <span class="heading-anchor-link">#</span>
755
+ <a href="#关于-shading" class="heading-text">关于 <code>shading</code></a>
756
+ </h3>
757
+
758
+ <p><strong>默认 <code>&#39;flat&#39;</code>(平面着色)</strong>。原因:白模 / OSGB 转 S3M 的数据,法线多是共享顶点平均出来的,平滑插值会让墙面出现大片斑驳。平面着色在片元里用屏幕空间导数算法线,墙面干净得多。</p>
759
+ <p>倾斜摄影这类本身法线连续的模型,可以改成 <code>&#39;smooth&#39;</code>。</p>
760
+
761
+ <h3 id="关于光照" class="doc-heading group">
762
+ <span class="heading-anchor-link">#</span>
763
+ <a href="#关于光照" class="heading-text">关于光照</a>
764
+ </h3>
765
+
766
+ <p>主光与补光方向<strong>每帧跟随地图 bearing 重算</strong>(主光在镜头后上方偏左 150°、仰角 50°;补光偏右 215°、仰角 30°)。相当于一盏头灯,保证任意旋转角度下白模都是受光的,不会转到背光面就一片死灰。</p>
767
+ <p>如果你需要固定的物理太阳方向,可以自行替换 <code>layer</code> 内部的 <code>_keyLight</code> / <code>_fillLight</code>(非公开 API,升级可能变化)。</p>
768
+ <hr />
769
+
770
+ <h2 id="拾取与属性" class="doc-heading group">
771
+ <span class="heading-anchor-link">#</span>
772
+ <a href="#拾取与属性" class="heading-text">拾取与属性</a>
773
+ </h2>
774
+
775
+
776
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
777
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
778
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
779
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="interface%20S3MPickResult%20%7B%0A%20%20datasetId%3A%20string%3B%0A%20%20datasetName%3A%20string%3B%0A%20%20objectId%3A%20number%3B%20%20%20%20%20%20%2F%2F%20%E7%BD%91%E6%A0%BC%E5%9C%A8%E5%9B%BE%E5%B1%82%E5%86%85%E7%9A%84%E7%BC%96%E5%8F%B7%0A%20%20batchId%3A%20number%3B%20%20%20%20%20%20%20%2F%2F%20%E5%8D%95%E4%BD%93%E7%BC%96%E5%8F%B7%EF%BC%8C-1%20%E8%A1%A8%E7%A4%BA%E6%97%A0%E5%8D%95%E4%BD%93%E5%8C%96%E4%BF%A1%E6%81%AF%0A%20%20skeletonName%3A%20string%3B%0A%20%20centerLngLat%3A%20%5Bnumber%2C%20number%5D%20%7C%20null%3B%0A%20%20mesh%3A%20THREE.Mesh%20%7C%20THREE.InstancedMesh%3B%0A%0A%20%20%2F%2F%20%E4%BB%A5%E4%B8%8B%E7%94%B1%E5%87%A0%E4%BD%95%E5%AE%9E%E6%97%B6%E8%AE%A1%E7%AE%97%0A%20%20smid%3A%20number%20%7C%20null%3B%20%20%20%2F%2F%20S3M%20%E5%8D%95%E4%BD%93%E5%8C%96%E4%BF%A1%E6%81%AF%E9%87%8C%E7%9A%84%20SMID%0A%20%20height%3F%3A%20number%3B%20%20%20%20%20%20%20%2F%2F%20%E7%B1%B3%EF%BC%8C%E5%8D%95%E4%BD%93%E5%8C%85%E5%9B%B4%E7%9B%92%20z%20%E8%B7%A8%E5%BA%A6%0A%20%20baseElevation%3F%3A%20number%3B%0A%20%20size%3F%3A%20%5Bnumber%2C%20number%5D%3B%20%20%20%2F%2F%20%E5%A4%96%E6%8E%A5%E7%9B%92%20%5B%E5%AE%BD%2C%20%E6%B7%B1%5D%0A%20%20bboxArea%3F%3A%20number%3B%20%20%20%20%20%20%20%20%20%2F%2F%20%E5%A4%96%E6%8E%A5%E7%9B%92%E5%BA%95%E9%9D%A2%E7%A7%AF%EF%BC%88%E3%8E%A1%EF%BC%89%0A%20%20floors%3F%3A%20number%3B%20%20%20%20%20%20%20%20%20%20%20%2F%2F%20%E9%AB%98%E5%BA%A6%20%2F%20floorHeight%0A%20%20triangles%3F%3A%20number%3B%20%20%20%20%20%20%20%20%2F%2F%20%E6%9C%AC%E5%8D%95%E4%BD%93%E8%87%AA%E8%BA%AB%E4%B8%89%E8%A7%92%E9%9D%A2%E6%95%B0%0A%20%20vertices%3F%3A%20number%3B%0A%20%20bbox%3F%3A%20%5B%5Bnumber%2C%20number%2C%20number%5D%2C%20%5Bnumber%2C%20number%2C%20number%5D%5D%3B%0A%7D">
780
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
781
+ <span class="copy-status">复制</span>
782
+ </button>
783
+ </div>
784
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>interface S3MPickResult {
785
+ datasetId: string;
786
+ datasetName: string;
787
+ objectId: number; // 网格在图层内的编号
788
+ batchId: number; // 单体编号,-1 表示无单体化信息
789
+ skeletonName: string;
790
+ centerLngLat: [number, number] | null;
791
+ mesh: THREE.Mesh | THREE.InstancedMesh;
792
+
793
+ // 以下由几何实时计算
794
+ smid: number | null; // S3M 单体化信息里的 SMID
795
+ height?: number; // 米,单体包围盒 z 跨度
796
+ baseElevation?: number;
797
+ size?: [number, number]; // 外接盒 [宽, 深]
798
+ bboxArea?: number; // 外接盒底面积(㎡)
799
+ floors?: number; // 高度 / floorHeight
800
+ triangles?: number; // 本单体自身三角面数
801
+ vertices?: number;
802
+ bbox?: [[number, number, number], [number, number, number]];
803
+ }</code></pre>
804
+ </div>
805
+
806
+
807
+ <div class="callout my-4 rounded-xl border-l-4 border-cyan-400 bg-cyan-950/20 px-5 py-3 text-slate-300 shadow-sm">
808
+ <div class="text-sm leading-relaxed"><strong>注意</strong>:<code>height</code> / <code>floors</code> / <code>bboxArea</code> 这些是<strong>从几何算出来的</strong>,不是业务属性表。OSGB 转 S3M 的数据通常不带属性表,<code>smid</code> 是与 iServer 侧数据对齐的唯一标识,业务字段需要你自己按 <code>smid</code> 去外部关联。</div>
809
+ </div>
810
+
811
+ <p>需要接业务属性时,用 <code>onPick</code> 拿到 <code>smid</code> 后自行查询:</p>
812
+
813
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
814
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
815
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
816
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="new%20S3MLayer(%7B%0A%20%20onPick%3A%20async%20(f)%20%3D%3E%20%7B%0A%20%20%20%20if%20(!f%3F.smid)%20return%3B%0A%20%20%20%20const%20info%20%3D%20await%20fetch(%60%2Fapi%2Fbuilding%2F%24%7Bf.smid%7D%60).then((r)%20%3D%3E%20r.json())%3B%0A%20%20%20%20showPanel(%7B%20...f%2C%20...info%20%7D)%3B%0A%20%20%7D%2C%0A%7D)%3B">
817
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
818
+ <span class="copy-status">复制</span>
819
+ </button>
820
+ </div>
821
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>new S3MLayer({
822
+ onPick: async (f) =&gt; {
823
+ if (!f?.smid) return;
824
+ const info = await fetch(`/api/building/${f.smid}`).then((r) =&gt; r.json());
825
+ showPanel({ ...f, ...info });
826
+ },
827
+ });</code></pre>
828
+ </div>
829
+
830
+ <hr />
831
+
832
+ <h2 id="投影" class="doc-heading group">
833
+ <span class="heading-anchor-link">#</span>
834
+ <a href="#投影" class="heading-text">投影</a>
835
+ </h2>
836
+
837
+
838
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
839
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
840
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
841
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="new%20S3MLayer(%7B%20projection%3A%20'orthographic'%20%7D)%20%20%20%2F%2F%20%E6%9E%84%E9%80%A0%E6%97%B6%E6%8C%87%E5%AE%9A%0Alayer.setProjection('orthographic'%20%7C%20'perspective')%20%20%2F%2F%20%E8%BF%90%E8%A1%8C%E6%97%B6%E5%88%87%E6%8D%A2%EF%BC%8C%E4%B8%8B%E4%B8%80%E5%B8%A7%E7%94%9F%E6%95%88%0Alayer.projection%20%20%2F%2F%20%E5%BD%93%E5%89%8D%E6%A8%A1%E5%BC%8F">
842
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
843
+ <span class="copy-status">复制</span>
844
+ </button>
845
+ </div>
846
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>new S3MLayer({ projection: &#39;orthographic&#39; }) // 构造时指定
847
+ layer.setProjection(&#39;orthographic&#39; | &#39;perspective&#39;) // 运行时切换,下一帧生效
848
+ layer.projection // 当前模式</code></pre>
849
+ </div>
850
+
851
+ <p>MapLibre 没有正交相机,custom layer 每帧拿到的是透视 MVP。但 MapLibre ≥ 5 的 <code>render()</code> 入参会把 <code>projectionMatrix</code>(view→clip)和 <code>modelViewProjectionMatrix</code>(world→clip)分开给,视图矩阵可以精确解出来:</p>
852
+
853
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
854
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
855
+ <span class="font-semibold text-cyan-400 tracking-wider">CODE</span>
856
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="V%20%3D%20P%E2%81%BB%C2%B9%20%C3%97%20MVP%0A%E6%AD%A3%E4%BA%A4%20MVP%20%3D%20P_ortho%20%C2%B7%20V%20%EF%BC%8CP_ortho%20%E5%8D%8A%E9%AB%98%20%3D%20%E7%9B%B8%E6%9C%BA%E8%B7%9D%20%C3%97%20tan(fov%2F2)">
857
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
858
+ <span class="copy-status">复制</span>
859
+ </button>
860
+ </div>
861
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>V = P⁻¹ × MVP
862
+ 正交 MVP = P_ortho · V ,P_ortho 半高 = 相机距 × tan(fov/2)</code></pre>
863
+ </div>
864
+
865
+ <p>半高取「目标深度处透视视锥的半高」,于是正交与透视在目标平面上逐点重合;zoom 改的是相机距离,距离减半 = 半高减半 = 放大一倍,pan / pitch / rotate 与透视手感一致。拾取、选中描边、按单体着色 / 隐藏 / 抬高都读同一个相机,自动跟随,不需要额外适配。</p>
866
+ <p><strong>边界</strong>:</p>
867
+ <ul>
868
+ <li>正交只覆盖本层。底图瓦片、地形、DOM Marker / 弹窗仍走 MapLibre 的透视投影,和正交层差 ≈ 楼高 / 相机距(城市尺度几十到上百像素,俯视时屋顶线就能差出 ~90px)。要用正交,App 需自行把这些层退出。</li>
869
+ <li>需要 MapLibre ≥ 5(要拿到独立的投影矩阵)。v4 只给合并矩阵,会 <code>console.warn</code> 一次并保持透视,不会静默画错。</li>
870
+ <li>解出的视图矩阵会做刚体校验(3×3 正交、底行 <code>[0,0,0,1]</code>);校验不过说明两个输入矩阵不同源,同样退回透视并告警。</li>
871
+ </ul>
872
+ <p>底层数学在 <code>src/render/OrthoProjection.ts</code>,<code>npm test</code> 里的 <code>tools/test-ortho.mjs</code> 用合成矩阵钉住:视图矩阵还原、目标平面重合、正交语义、zoom 映射、near/far 深度映射、三类非法输入拒绝。</p>
873
+ <hr />
874
+
875
+ <h2 id="按业务字段着色" class="doc-heading group">
876
+ <span class="heading-anchor-link">#</span>
877
+ <a href="#按业务字段着色" class="heading-text">按业务字段着色</a>
878
+ </h2>
879
+
880
+ <p>S3M 瓦片里<strong>只有单体 ID</strong>(SMID)和顶点区间,没有任何业务字段;业务属性在 iServer 的<strong>数据服务</strong>里(<code>data-xxx/rest/data</code>),和三维服务(<code>3D-xxx/rest/realspace</code>)是两个服务。所以按字段着色是「三维 + 数据」的跨服务查询,需要单独配一个数据源。</p>
881
+
882
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
883
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
884
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
885
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="const%20layer%20%3D%20new%20S3MLayer(%7B%0A%20%20datasets%3A%20%5B%7B%20id%3A%20'jingmo'%2C%20configUrl%3A%20'...%2Fjingmo.scp'%20%7D%5D%2C%0A%20%20attributes%3A%20%7B%0A%20%20%20%20dataServiceUrl%3A%20'https%3A%2F%2Fhost%2Fiserver%2Fservices%2Fdata-xxx%2Frest%2Fdata'%2C%0A%20%20%20%20datasetName%3A%20'DataSource%3Azhoubian'%2C%20%20%20%2F%2F%20%E6%95%B0%E6%8D%AE%E6%BA%90%3A%E6%95%B0%E6%8D%AE%E9%9B%86%EF%BC%8C%E5%8F%AA%E5%86%99%E6%95%B0%E6%8D%AE%E9%9B%86%E5%90%8D%E6%97%B6%E9%BB%98%E8%AE%A4%E8%A1%A5%20DataSource%3A%0A%20%20%20%20%2F%2F%20idField%20%E9%BB%98%E8%AE%A4%20SmID%EF%BC%8C%E5%AF%B9%E5%BA%94%20scp%20%E9%87%8C%E7%9A%84%20idFieldName%0A%20%20%7D%2C%0A%7D)%3B%0A%0A%2F%2F%20%E5%88%86%E7%B1%BB%E5%AD%97%E6%AE%B5%EF%BC%9A%E5%90%8C%E4%B8%80%E5%8F%96%E5%80%BC%E5%90%8C%E8%89%B2%0Aconst%20result%20%3D%20await%20layer.colorByField(%7B%20field%3A%20'KEYID'%2C%20type%3A%20'category'%20%7D)%3B%0A%0A%2F%2F%20%E6%95%B0%E5%80%BC%E5%AD%97%E6%AE%B5%EF%BC%9A%E8%BF%9E%E7%BB%AD%E8%89%B2%E5%B8%A6%20%2F%20%E6%8C%89%E9%98%88%E5%80%BC%E5%88%86%E7%BA%A7%0Aawait%20layer.colorByField(%7B%20field%3A%20'SmMaxZ'%2C%20type%3A%20'ramp'%20%7D)%3B%0Aawait%20layer.colorByField(%7B%20field%3A%20'SmMaxZ'%2C%20type%3A%20'breaks'%2C%20breaks%3A%20%5B10%2C%2030%2C%2060%5D%20%7D)%3B%0A%0Aresult.legend%3B%20%20%20%20%2F%2F%20%5B%7B%20label%2C%20color%2C%20count%20%7D%5D%20%20%E7%9B%B4%E6%8E%A5%E6%8B%BF%E5%8E%BB%E7%94%BB%E5%9B%BE%E4%BE%8B%0Aresult.matched%3B%20%20%20%2F%2F%20%E7%9C%9F%E6%AD%A3%E4%B8%8A%E4%BA%86%E8%89%B2%E7%9A%84%E5%8D%95%E4%BD%93%E6%95%B0">
886
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
887
+ <span class="copy-status">复制</span>
888
+ </button>
889
+ </div>
890
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>const layer = new S3MLayer({
891
+ datasets: [{ id: &#39;jingmo&#39;, configUrl: &#39;.../jingmo.scp&#39; }],
892
+ attributes: {
893
+ dataServiceUrl: &#39;https://host/iserver/services/data-xxx/rest/data&#39;,
894
+ datasetName: &#39;DataSource:zhoubian&#39;, // 数据源:数据集,只写数据集名时默认补 DataSource:
895
+ // idField 默认 SmID,对应 scp 里的 idFieldName
896
+ },
897
+ });
898
+
899
+ // 分类字段:同一取值同色
900
+ const result = await layer.colorByField({ field: &#39;KEYID&#39;, type: &#39;category&#39; });
901
+
902
+ // 数值字段:连续色带 / 按阈值分级
903
+ await layer.colorByField({ field: &#39;SmMaxZ&#39;, type: &#39;ramp&#39; });
904
+ await layer.colorByField({ field: &#39;SmMaxZ&#39;, type: &#39;breaks&#39;, breaks: [10, 30, 60] });
905
+
906
+ result.legend; // [{ label, color, count }] 直接拿去画图例
907
+ result.matched; // 真正上了色的单体数</code></pre>
908
+ </div>
909
+
910
+ <p>几个要点:</p>
911
+ <ul>
912
+ <li><strong>默认范围是「当前已加载的单体」</strong>(<code>getLoadedSmids()</code>),不是整个数据集。实测每条要素的响应约 5KB(模型数据集每条都挂着 <code>geometry.model</code>,见下面的「为什么这么重」),全量几千栋会变成几十 MB,所以只查加载进来的那部分,按 200 个一批分片;</li>
913
+ <li>查询结果按 SMID 缓存<strong>整行</strong>(<code>AttributeQuery</code>),换字段只是重算颜色、重写一张一维纹理,不重建几何、不重编译着色器,也不再发一次请求;</li>
914
+ <li>手动 <code>setFeatureStyle()</code> 的颜色优先级高于专题色;</li>
915
+ <li><code>transform</code> 用来归并取值:<code>KEYID</code> / <code>ENTITYID</code> 这种基本一栋一个值的字段直接上分类色,着出来就是「随机色」,要么改用 <code>ramp</code> / <code>breaks</code>,要么用 <code>transform</code> 把编码截到能分组的层级,比如 <code>(v) =&gt; v.slice(0, 6)</code>;</li>
916
+ <li><code>breaks</code> 不给就用色带档数等分;<code>colors</code> 不给就用内置 12 色板。</li>
917
+ </ul>
918
+ <p>不想接数据服务时,也可以:</p>
919
+
920
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
921
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
922
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
923
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="%2F%2F%20%E8%87%AA%E5%B7%B1%E7%AE%97%E5%A5%BD%E9%A2%9C%E8%89%B2%EF%BC%88%E6%AF%94%E5%A6%82%E6%8C%89%E5%87%A0%E4%BD%95%E6%B4%BE%E7%94%9F%E9%87%8F%EF%BC%9A%E9%AB%98%E5%BA%A6%20%2F%20%E9%9D%A2%E7%A7%AF%20%2F%20%E6%A5%BC%E5%B1%82%EF%BC%89%0Alayer.setThematicColors(new%20Map(%5B%5B423%2C%20'%23ef4444'%5D%2C%20%5B424%2C%20'%2322c55e'%5D%5D))%3B%0Alayer.clearThematic()%3B">
924
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
925
+ <span class="copy-status">复制</span>
926
+ </button>
927
+ </div>
928
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>// 自己算好颜色(比如按几何派生量:高度 / 面积 / 楼层)
929
+ layer.setThematicColors(new Map([[423, &#39;#ef4444&#39;], [424, &#39;#22c55e&#39;]]));
930
+ layer.clearThematic();</code></pre>
931
+ </div>
932
+
933
+ <p><code>AttributeQuery</code> 也能单独用(不依赖图层):</p>
934
+
935
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
936
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
937
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
938
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="import%20%7B%20AttributeQuery%20%7D%20from%20's3m-maplibre'%3B%0A%0Aconst%20query%20%3D%20new%20AttributeQuery(%7B%20dataServiceUrl%2C%20datasetName%3A%20'DataSource%3Azhoubian'%20%7D)%3B%0Aconst%20schema%20%3D%20await%20query.loadSchema()%3B%20%20%20%20%20%20%20%20%20%20%2F%2F%20%5B%7B%20name%3A%20'SmID'%20%7D%2C%20...%5D%EF%BC%8C%E4%B9%9F%E8%83%BD%E7%94%A8%E6%9D%A5%E5%88%A4%E6%96%AD%E6%9C%89%E6%B2%A1%E6%9C%89%E5%B1%9E%E6%80%A7%E8%A1%A8%0Aconst%20rows%20%3D%20await%20query.queryBySmids(%5B423%2C%20424%5D)%3B%20%2F%2F%20Map%3Csmid%2C%20%7B%20SmID%2C%20ENTITYID%2C%20KEYID%2C%20...%20%7D%3E">
939
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
940
+ <span class="copy-status">复制</span>
941
+ </button>
942
+ </div>
943
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>import { AttributeQuery } from &#39;s3m-maplibre&#39;;
944
+
945
+ const query = new AttributeQuery({ dataServiceUrl, datasetName: &#39;DataSource:zhoubian&#39; });
946
+ const schema = await query.loadSchema(); // [{ name: &#39;SmID&#39; }, ...],也能用来判断有没有属性表
947
+ const rows = await query.queryBySmids([423, 424]); // Map&lt;smid, { SmID, ENTITYID, KEYID, ... }&gt;</code></pre>
948
+ </div>
949
+
950
+ <p>拾取结果里的 <code>attributes</code> 不在 <code>PickResult</code> 上:<code>pick()</code> 只负责拿 SMID,业务字段要按 SMID 再查一次。demo 里的属性卡就是这么补的(点楼 → 查 <code>featureResults</code> → 追加字段行)。</p>
951
+
952
+ <h3 id="过滤-抬高" class="doc-heading group">
953
+ <span class="heading-anchor-link">#</span>
954
+ <a href="#过滤-抬高" class="heading-text">过滤 / 抬高</a>
955
+ </h3>
956
+
957
+ <p>同一份属性除了上色,还能直接拿来过滤和抬高——都是「查一次字段 → 写进每网格的单体状态纹理」,不重建几何、不重编译着色器。</p>
958
+
959
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
960
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
961
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
962
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="%2F%2F%20%E5%8F%AA%E6%98%BE%E7%A4%BA%2030~80%20%E7%B1%B3%E4%B9%8B%E9%97%B4%E7%9A%84%E5%BB%BA%E7%AD%91%0Aawait%20layer.filterByField(%7B%20field%3A%20'SmMaxZ'%2C%20min%3A%2030%2C%20max%3A%2080%20%7D)%3B%0A%0A%2F%2F%20%E6%8C%89%E7%BC%96%E7%A0%81%E7%B2%BE%E7%A1%AE%E5%8C%B9%E9%85%8D%EF%BC%88values%20%E6%8C%89%20String(value)%20%E6%AF%94%EF%BC%89%0Aawait%20layer.filterByField(%7B%20field%3A%20'KEYID'%2C%20values%3A%20%5B'C10100SVK57YDUALQHRR00'%5D%20%7D)%3B%0A%0A%2F%2F%20%E5%8F%8D%E8%BF%87%E6%9D%A5%EF%BC%9A%E9%9A%90%E8%97%8F%2020%20%E7%B1%B3%E4%BB%A5%E4%B8%8B%E7%9A%84%0Aawait%20layer.filterByField(%7B%20field%3A%20'SmMaxZ'%2C%20max%3A%2020%2C%20mode%3A%20'hide'%20%7D)%3B%0A%0A%2F%2F%20%E8%87%AA%E5%AE%9A%E4%B9%89%E6%9D%A1%E4%BB%B6%0Aawait%20layer.filterByField(%7B%20field%3A%20'SmMaxZ'%2C%20keep%3A%20(v)%20%3D%3E%20Number(v)%20%3E%20100%20%7D)%3B%0A%0Alayer.clearFieldFilter()%3B%20%20%20%2F%2F%20%E6%81%A2%E5%A4%8D%E5%85%A8%E9%87%8F%EF%BC%8C%E4%B8%8D%E5%BD%B1%E5%93%8D%20setFilter()%20%E7%9A%84%E8%A7%84%E5%88%99%0A%0A%2F%2F%20%E6%8A%AC%E9%AB%98%EF%BC%9A%E5%AD%97%E6%AE%B5%E5%80%BC%20%C3%97%20%E5%80%8D%E7%8E%87%EF%BC%88%E7%B1%B3%EF%BC%89%0Aawait%20layer.elevateByField(%7B%20field%3A%20'SmMaxZ'%2C%20scale%3A%200.5%20%7D)%3B%0A%0A%2F%2F%20%E6%88%96%E8%80%85%E8%87%AA%E5%B7%B1%E6%98%A0%E5%B0%84%EF%BC%8C%E7%AC%AC%E4%BA%8C%E4%B8%AA%E5%8F%82%E6%95%B0%E6%98%AF%E6%95%B0%E6%8D%AE%E5%88%86%E5%B8%83%0Aawait%20layer.elevateByField(%7B%0A%20%20field%3A%20'SmMaxZ'%2C%0A%20%20map%3A%20(v%2C%20r)%20%3D%3E%20((v%20-%20r.min)%20%2F%20(r.max%20-%20r.min))%20*%20200%2C%0A%20%20max%3A%20120%2C%20%2F%2F%20%E5%B0%81%E9%A1%B6%0A%7D)%3B%0Alayer.clearThematicElevation()%3B">
963
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
964
+ <span class="copy-status">复制</span>
965
+ </button>
966
+ </div>
967
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>// 只显示 30~80 米之间的建筑
968
+ await layer.filterByField({ field: &#39;SmMaxZ&#39;, min: 30, max: 80 });
969
+
970
+ // 按编码精确匹配(values 按 String(value) 比)
971
+ await layer.filterByField({ field: &#39;KEYID&#39;, values: [&#39;C10100SVK57YDUALQHRR00&#39;] });
972
+
973
+ // 反过来:隐藏 20 米以下的
974
+ await layer.filterByField({ field: &#39;SmMaxZ&#39;, max: 20, mode: &#39;hide&#39; });
975
+
976
+ // 自定义条件
977
+ await layer.filterByField({ field: &#39;SmMaxZ&#39;, keep: (v) =&gt; Number(v) &gt; 100 });
978
+
979
+ layer.clearFieldFilter(); // 恢复全量,不影响 setFilter() 的规则
980
+
981
+ // 抬高:字段值 × 倍率(米)
982
+ await layer.elevateByField({ field: &#39;SmMaxZ&#39;, scale: 0.5 });
983
+
984
+ // 或者自己映射,第二个参数是数据分布
985
+ await layer.elevateByField({
986
+ field: &#39;SmMaxZ&#39;,
987
+ map: (v, r) =&gt; ((v - r.min) / (r.max - r.min)) * 200,
988
+ max: 120, // 封顶
989
+ });
990
+ layer.clearThematicElevation();</code></pre>
991
+ </div>
992
+
993
+ <p>返回值够用:<code>filterByField</code> 给 <code>{ total, matched, missing, hidden }</code>,<code>elevateByField</code> 给 <code>{ matched, missing, min, max, appliedMax }</code>。</p>
994
+ <p>几个约定:</p>
995
+ <ul>
996
+ <li><code>filterByField</code> 与 <code>setFilter()</code> 是<strong>叠加</strong>关系(两个都放行才显示);被隐藏的单体不渲染、也不参与拾取;<code>onMissing</code> 控制查不到属性的单体怎么算(默认保留);</li>
997
+ <li><code>keep</code> 模式记的是「命中集合」,之后才加载进来的单体不在集合里,同样隐藏——符合「只显示命中的」,也不会漏出新瓦片里的建筑;</li>
998
+ <li><code>elevateByField</code> 的抬高量写在单体状态纹理的第四通道,顶点着色器加到 z 上;手动 <code>setFeatureStyle(smid, { elevation })</code> 优先级更高;</li>
999
+ <li>抬高只改显示、不改几何,但<strong>拾取和高亮都跟着抬高后的位置走</strong>:GPU 拾取的那一趟渲染会采样同一张状态纹理,选中棱线也会加上同样的偏移,所以抬起来的楼点得中、描边也贴在楼上;</li>
1000
+ <li>上色 / 过滤 / 抬高共用同一个 <code>AttributeQuery</code> 缓存,切字段不会重复查;</li>
1001
+ <li>不想走网络时,用 <code>setThematicElevation(new Map([[423, 20]]))</code> 直接塞 SMID → 抬高米数。</li>
1002
+ </ul>
1003
+
1004
+ <h3 id="为什么这么重-以及怎么减" class="doc-heading group">
1005
+ <span class="heading-anchor-link">#</span>
1006
+ <a href="#为什么这么重-以及怎么减" class="heading-text">为什么这么重,以及怎么减</a>
1007
+ </h3>
1008
+
1009
+ <p>数据服务没有「只取属性」的接口。iServer 2026 上逐项试过,结论如下(数据集 <code>DataSource:zhoubian</code>,4461 条):</p>
1010
+
1011
+ <div class="table-responsive my-6 overflow-x-auto rounded-xl border border-white/10 bg-[#121826]/70 shadow-lg">
1012
+ <table class="w-full text-left text-sm text-slate-300">
1013
+ <thead class="bg-white/5 text-slate-200 font-semibold border-b border-white/10">
1014
+ <tr><th>尝试</th><th>结果</th></tr>
1015
+ </thead>
1016
+ <tbody class="divide-y divide-white/5">
1017
+ <tr><td><code>queryOption: &#39;ATTRIBUTE&#39;</code>(body / query string / 大小写)</td><td><strong>完全被忽略</strong>,几何照返回</td></tr>
1018
+ <tr><td><code>hasGeometry</code> / <code>returnGeometry</code> / <code>withGeometry</code>(顶层与 <code>queryParameter</code> 内)</td><td>全被忽略</td></tr>
1019
+ <tr><td><code>fromIndex</code> / <code>toIndex</code></td><td>被忽略,不能翻页</td></tr>
1020
+ <tr><td><code>maxFeatures</code></td><td>✅ 生效,但只能「取前 N 条」</td></tr>
1021
+ <tr><td><code>.fastjson</code> 替代 <code>.json</code></td><td>✅ 模型数据集 −40~50%</td></tr>
1022
+ <tr><td>逐条 <code>GET /rest/data/feature/{a}-{b}-{idx}.json</code></td><td>957B/条(<code>model</code> 为 null),但 300 条并发 16 要 2~14s,反而不如一次 POST</td></tr>
1023
+ <tr><td>二维数据源 <code>DataSource_erwei</code></td><td>✅ 若同名数据集存在,体积只有 1/2.5(<code>REGION</code> 而非 <code>GEOMODEL3D</code>)</td></tr>
1024
+ </tbody>
1025
+ </table>
1026
+ </div>
1027
+
1028
+ <p>体积的 90% 是 <code>geometry.model</code>:每条要素都带着自己那栋楼的 S3M 模型字节(deflate 压缩后约 2.5KB),而我们要的只是几个字段。所以策略是「一次把全部字段捞回来,尽量少发请求」:</p>
1029
+ <ul>
1030
+ <li>响应格式按数据集自动选(<code>format: &#39;auto&#39;</code>)。模型数据集的 <code>model</code> 是 byte[],<code>.fastjson</code> 用 hex 表示只要 2 字符/字节,<code>.json</code> 是十进制数字数组(3~4 字符/字节),所以走 fastjson;二维数据集的几何是坐标 double,hex 反而更胖(实测 3.9KB vs 2.4KB/条),那种就留在 json。判断依据是 <code>features.json</code> 返回的 <code>geometryType</code>,一次几十字节。</li>
1031
+ </ul>
1032
+ <p> fastjson <strong>不是严格 JSON</strong>(<code>0.0D</code>、<code>423L</code>、<code>x&#39;D7A34040…&#39;</code> 都是 Java 字面量),<code>AttributeQuery</code> 会先在字符串上把 <code>x&#39;…&#39;</code> 抹成 <code>null</code> 再 <code>JSON.parse</code>——顺便省掉了整个模型的解析开销;解析失败自动回退 <code>.json</code>。</p>
1033
+ <ul>
1034
+ <li>默认取字段表里除 <code>SmGeometry</code> / <code>SmIndexKey</code> 外的<strong>全部字段</strong>(字段名本身很便宜,模型才是大头),这样换专题字段、看属性卡都命中缓存。</li>
1035
+ <li>需要压到极限时,如果同一份数据还发布了二维数据源,把 <code>datasetName</code> 指过去即可:</li>
1036
+ </ul>
1037
+
1038
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
1039
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
1040
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
1041
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="attributes%3A%20%7B%0A%20%20dataServiceUrl%3A%20'...%2Frest%2Fdata'%2C%0A%20%20datasetName%3A%20'DataSource_erwei%3Acheng'%2C%20%20%20%2F%2F%20%E4%BA%8C%E7%BB%B4%E7%89%88%E6%9C%AC%EF%BC%8C%E5%90%8C%E6%A0%B7%E7%9A%84%E5%B1%9E%E6%80%A7%E3%80%81%E6%B2%A1%E6%9C%89%E6%A8%A1%E5%9E%8B%0A%7D">
1042
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
1043
+ <span class="copy-status">复制</span>
1044
+ </button>
1045
+ </div>
1046
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>attributes: {
1047
+ dataServiceUrl: &#39;.../rest/data&#39;,
1048
+ datasetName: &#39;DataSource_erwei:cheng&#39;, // 二维版本,同样的属性、没有模型
1049
+ }</code></pre>
1050
+ </div>
1051
+
1052
+ <p>实测同一批 100 栋:</p>
1053
+
1054
+ <div class="table-responsive my-6 overflow-x-auto rounded-xl border border-white/10 bg-[#121826]/70 shadow-lg">
1055
+ <table class="w-full text-left text-sm text-slate-300">
1056
+ <thead class="bg-white/5 text-slate-200 font-semibold border-b border-white/10">
1057
+ <tr><th>数据集</th><th>格式</th><th>体积</th></tr>
1058
+ </thead>
1059
+ <tbody class="divide-y divide-white/5">
1060
+ <tr><td><code>DataSource:zhoubian</code>(模型)</td><td>json</td><td>9.2 KB/条</td></tr>
1061
+ <tr><td><code>DataSource:zhoubian</code>(模型)</td><td>fastjson(自动)</td><td>6.3 KB/条</td></tr>
1062
+ <tr><td><code>DataSource:cheng</code>(模型)</td><td>json</td><td>10.8 KB/条</td></tr>
1063
+ <tr><td><code>DataSource:cheng</code>(模型)</td><td>fastjson(自动)</td><td>5.5 KB/条</td></tr>
1064
+ <tr><td><code>DataSource_erwei:cheng</code>(二维)</td><td>json(自动)</td><td>2.1 KB/条</td></tr>
1065
+ </tbody>
1066
+ </table>
1067
+ </div>
1068
+
1069
+ <p><code>AttributeQuery</code> 还提供 <code>format</code>、<code>chunkSize</code>、<code>fields</code> 三个开关,以及 <code>query.stats</code>(<code>{ requests, bytes, ms }</code>)用来观察实际开销。</p>
1070
+ <hr />
1071
+
1072
+ <h2 id="纯-three-js-使用" class="doc-heading group">
1073
+ <span class="heading-anchor-link">#</span>
1074
+ <a href="#纯-three-js-使用" class="heading-text">纯 three.js 使用</a>
1075
+ </h2>
1076
+
1077
+ <p><code>S3MLayer</code> 是给地图用的,但下面的三层都是宿主无关的,可以直接塞进任意 three 场景(<code>demo/three.js</code> 是一个 150 行的完整例子):</p>
1078
+
1079
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
1080
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
1081
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
1082
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="import%20*%20as%20THREE%20from%20'three'%3B%0Aimport%20%7B%20S3MTileset%2C%20createSharedUniforms%2C%20STYLE%2C%20setMaxAnisotropy%20%7D%20from%20's3m-maplibre'%3B%0A%0Aconst%20renderer%20%3D%20new%20THREE.WebGLRenderer(%7B%20antialias%3A%20true%20%7D)%3B%0AsetMaxAnisotropy(renderer.capabilities.getMaxAnisotropy())%3B%20%2F%2F%20%E7%BA%B9%E7%90%86%E5%90%84%E5%90%91%E5%BC%82%E6%80%A7%EF%BC%8C%E8%B6%8A%E6%97%A9%E8%B6%8A%E5%A5%BD%0A%0Aconst%20uniforms%20%3D%20createSharedUniforms()%3B%0Auniforms.uMode.value%20%3D%20STYLE.TEXTURE%3B%20%20%2F%2F%20%E5%88%87%E6%A0%B7%E5%BC%8F%E5%B0%B1%E6%98%AF%E6%94%B9%E8%BF%99%E4%B8%AA%20int%0A%0A%2F%2F%20S3M%20%E9%A1%B6%E7%82%B9%E6%98%AF%E5%B1%80%E9%83%A8%20ENU%EF%BC%88x%20%E4%B8%9C%20%2F%20y%20%E5%8C%97%20%2F%20z%20%E5%A4%A9%EF%BC%89%EF%BC%8Cthree%20%E6%98%AF%20y%20%E6%9C%9D%E4%B8%8A%0Aconst%20root%20%3D%20new%20THREE.Group()%3B%0Aroot.rotation.x%20%3D%20-Math.PI%20%2F%202%3B%0Ascene.add(root)%3B%0A%0Aconst%20tileset%20%3D%20new%20S3MTileset(%7B%0A%20%20configUrl%3A%20'...%2Fjingmo.scp'%2C%0A%20%20maxTiles%3A%2064%2C%0A%20%20uniforms%2C%0A%20%20onTile%3A%20(node)%20%3D%3E%20root.add(node.group)%2C%20%20%2F%2F%20%E6%AF%8F%E4%B8%AA%E7%93%A6%E7%89%87%E4%B8%80%E4%B8%AA%20Group%EF%BC%8C%E5%9D%90%E6%A0%87%E6%98%AF%E7%B1%B3%E5%88%B6%E5%B1%80%E9%83%A8%20ENU%0A%7D)%3B%0Aawait%20tileset.init()%3B%20%20%2F%2F%20%E8%BF%94%E5%9B%9E%E6%97%B6%E9%A6%96%E6%A3%B5%E7%93%A6%E7%89%87%E5%B7%B2%E6%9E%84%E5%BB%BA%E5%AE%8C%E6%88%90%0A%0A%2F%2F%20%E6%AF%8F%E5%B8%A7%E9%A9%B1%E5%8A%A8%20LOD%EF%BC%9A%E7%9B%B8%E6%9C%BA%E4%BD%8D%E7%BD%AE%E8%A6%81%E5%8F%8D%E7%AE%97%E5%9B%9E%20ENU%EF%BC%88world%20x%2Cy%2Cz%20%E2%86%90%20ENU%20x%2C-z%2Cy%EF%BC%89%0Atileset.update(%0A%20%20%5Bcamera.position.x%2C%20-camera.position.z%2C%20camera.position.y%5D%2C%0A%20%20THREE.MathUtils.degToRad(camera.fov)%2C%0A%20%20renderer.domElement.height%0A)%3B">
1083
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
1084
+ <span class="copy-status">复制</span>
1085
+ </button>
1086
+ </div>
1087
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>import * as THREE from &#39;three&#39;;
1088
+ import { S3MTileset, createSharedUniforms, STYLE, setMaxAnisotropy } from &#39;s3m-maplibre&#39;;
1089
+
1090
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
1091
+ setMaxAnisotropy(renderer.capabilities.getMaxAnisotropy()); // 纹理各向异性,越早越好
1092
+
1093
+ const uniforms = createSharedUniforms();
1094
+ uniforms.uMode.value = STYLE.TEXTURE; // 切样式就是改这个 int
1095
+
1096
+ // S3M 顶点是局部 ENU(x 东 / y 北 / z 天),three 是 y 朝上
1097
+ const root = new THREE.Group();
1098
+ root.rotation.x = -Math.PI / 2;
1099
+ scene.add(root);
1100
+
1101
+ const tileset = new S3MTileset({
1102
+ configUrl: &#39;.../jingmo.scp&#39;,
1103
+ maxTiles: 64,
1104
+ uniforms,
1105
+ onTile: (node) =&gt; root.add(node.group), // 每个瓦片一个 Group,坐标是米制局部 ENU
1106
+ });
1107
+ await tileset.init(); // 返回时首棵瓦片已构建完成
1108
+
1109
+ // 每帧驱动 LOD:相机位置要反算回 ENU(world x,y,z ← ENU x,-z,y)
1110
+ tileset.update(
1111
+ [camera.position.x, -camera.position.z, camera.position.y],
1112
+ THREE.MathUtils.degToRad(camera.fov),
1113
+ renderer.domElement.height
1114
+ );</code></pre>
1115
+ </div>
1116
+
1117
+ <p>需要自己补的部分(<code>S3MLayer</code> 不代劳):</p>
1118
+
1119
+ <div class="table-responsive my-6 overflow-x-auto rounded-xl border border-white/10 bg-[#121826]/70 shadow-lg">
1120
+ <table class="w-full text-left text-sm text-slate-300">
1121
+ <thead class="bg-white/5 text-slate-200 font-semibold border-b border-white/10">
1122
+ <tr><th>事项</th><th>说明</th></tr>
1123
+ </thead>
1124
+ <tbody class="divide-y divide-white/5">
1125
+ <tr><td>光照</td><td>渲染用的是 <code>MeshStandardMaterial</code>,自己加 <code>HemisphereLight</code> + <code>DirectionalLight</code></td></tr>
1126
+ <tr><td>多数据集对齐</td><td>每份数据各有锚点,<code>S3MLayer</code> 会把锚点差写进数据集的 <code>Group.position</code>(米),纯 three 需要自己算一次</td></tr>
1127
+ <tr><td>贴地与经纬度</td><td>没有地图就没有墨卡托那一步,只有局部米制坐标</td></tr>
1128
+ <tr><td>拾取 / 描边 / 样式 UI</td><td><code>Picker</code>、等宽棱线、<code>setStyle</code> 都在 <code>S3MLayer</code> 上,纯 three 用法要直接操作 <code>uniforms</code> 和 Mesh</td></tr>
1129
+ <tr><td>瓦片卸载</td><td>超出 <code>maxTiles</code> 时按「最久没进有效范围」淘汰,同帧再比距离</td></tr>
1130
+ </tbody>
1131
+ </table>
1132
+ </div>
1133
+
1134
+ <hr />
1135
+
1136
+ <h2 id="底层-api" class="doc-heading group">
1137
+ <span class="heading-anchor-link">#</span>
1138
+ <a href="#底层-api" class="heading-text">底层 API</a>
1139
+ </h2>
1140
+
1141
+
1142
+ <h3 id="parses3mb" class="doc-heading group">
1143
+ <span class="heading-anchor-link">#</span>
1144
+ <a href="#parses3mb" class="heading-text"><code>parseS3MB</code></a>
1145
+ </h3>
1146
+
1147
+
1148
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
1149
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
1150
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
1151
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="parseS3MB(buffer%3A%20ArrayBuffer%2C%20options%3F)%3A%20Promise%3CS3MParsedTile%3E">
1152
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
1153
+ <span class="copy-status">复制</span>
1154
+ </button>
1155
+ </div>
1156
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>parseS3MB(buffer: ArrayBuffer, options?): Promise&lt;S3MParsedTile&gt;</code></pre>
1157
+ </div>
1158
+
1159
+ <p>纯解析,<strong>不依赖 three</strong>,可在 Node 里跑:</p>
1160
+
1161
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
1162
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
1163
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
1164
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="import%20%7B%20parseS3MB%20%7D%20from%20's3m-maplibre%2Fcore'%3B%0Aimport%20%7B%20inflateSync%20%7D%20from%20'node%3Azlib'%3B%0A%0Aconst%20tile%20%3D%20await%20parseS3MB(buf%2C%20%7B%20inflate%3A%20inflateSync%20%7D)%3B%0Aconsole.log(tile.version%2C%20Object.keys(tile.geoPackage))%3B">
1165
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
1166
+ <span class="copy-status">复制</span>
1167
+ </button>
1168
+ </div>
1169
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>import { parseS3MB } from &#39;s3m-maplibre/core&#39;;
1170
+ import { inflateSync } from &#39;node:zlib&#39;;
1171
+
1172
+ const tile = await parseS3MB(buf, { inflate: inflateSync });
1173
+ console.log(tile.version, Object.keys(tile.geoPackage));</code></pre>
1174
+ </div>
1175
+
1176
+ <p>解析结果可用 <code>bytesRead === bytesTotal</code> 自检——不等说明偏移算错了。</p>
1177
+
1178
+ <h3 id="meshopt-压缩几何" class="doc-heading group">
1179
+ <span class="heading-anchor-link">#</span>
1180
+ <a href="#meshopt-压缩几何" class="heading-text">MeshOpt 压缩几何</a>
1181
+ </h3>
1182
+
1183
+ <p>v3 瓦片默认用 MeshOpt 压几何。解析器内部按需 <code>import</code> 内联的 meshoptimizer wasm 解码器(约 22KB),只有真的遇到 MeshOpt 瓦片才会加载,v1/v2 与未压缩的 v3 不受影响。</p>
1184
+ <p>解码后的顶点属性已经还原成 float,下游无感知:</p>
1185
+
1186
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
1187
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
1188
+ <span class="font-semibold text-cyan-400 tracking-wider">CODE</span>
1189
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="position%20%3D%20minVerticesValue%20%2B%20uint16%20*%20vertCompressConstant%0Atexcoord%20%3D%20minTexCoordValue%5Bi%5D%20%2B%20uint16%20*%20texCoordCompressConstant%5Bi%5D%0Anormal%20%20%20%3D%20octDecode(int16%20%2F%2032767)">
1190
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
1191
+ <span class="copy-status">复制</span>
1192
+ </button>
1193
+ </div>
1194
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>position = minVerticesValue + uint16 * vertCompressConstant
1195
+ texcoord = minTexCoordValue[i] + uint16 * texCoordCompressConstant[i]
1196
+ normal = octDecode(int16 / 32767)</code></pre>
1197
+ </div>
1198
+
1199
+ <p>回归校验(60 个真实瓦片,校验偏移、索引范围、法线归一化、包围盒与 OBB 尺寸):</p>
1200
+
1201
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
1202
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
1203
+ <span class="font-semibold text-cyan-400 tracking-wider">BASH</span>
1204
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="npm%20run%20test%3Ameshopt">
1205
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
1206
+ <span class="copy-status">复制</span>
1207
+ </button>
1208
+ </div>
1209
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>npm run test:meshopt</code></pre>
1210
+ </div>
1211
+
1212
+ <p>需要 WebAssembly;不支持 wasm 的环境会在遇到 MeshOpt 瓦片时抛明确错误。</p>
1213
+
1214
+ <h3 id="s3mtileset" class="doc-heading group">
1215
+ <span class="heading-anchor-link">#</span>
1216
+ <a href="#s3mtileset" class="heading-text"><code>S3MTileset</code></a>
1217
+ </h3>
1218
+
1219
+ <p>单个数据集的瓦片树。不依赖 three,可以用注入的 <code>buildTile</code> 在 Node 里跑通完整链路(URL 解析 → 解析 → LOD 调度),适合做数据体检。</p>
1220
+
1221
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
1222
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
1223
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
1224
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="const%20ts%20%3D%20new%20S3MTileset(%7B%20configUrl%2C%20buildTile%3A%20async%20()%20%3D%3E%20(%7B%20group%3A%20null%2C%20stats%3A%20%7B%7D%20%7D)%20%7D)%3B%0Aawait%20ts.init()%3B%0Ats.update(%5B0%2C%200%2C%20500%5D%2C%200.64%2C%20900)%3B%20%2F%2F%20%E7%9B%B8%E6%9C%BA%E5%9C%A8%E5%B1%80%E9%83%A8%20ENU%20%E7%9A%84%E4%BD%8D%E7%BD%AE">
1225
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
1226
+ <span class="copy-status">复制</span>
1227
+ </button>
1228
+ </div>
1229
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>const ts = new S3MTileset({ configUrl, buildTile: async () =&gt; ({ group: null, stats: {} }) });
1230
+ await ts.init();
1231
+ ts.update([0, 0, 500], 0.64, 900); // 相机在局部 ENU 的位置</code></pre>
1232
+ </div>
1233
+
1234
+
1235
+ <h3 id="单体属性工具" class="doc-heading group">
1236
+ <span class="heading-anchor-link">#</span>
1237
+ <a href="#单体属性工具" class="heading-text">单体属性工具</a>
1238
+ </h3>
1239
+
1240
+
1241
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
1242
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
1243
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
1244
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="resolveFeature(geo%2C%20batchId)%20%20%20%2F%2F%20%E2%86%92%20%7B%20smid%2C%20segments%20%7D%0AmeasureSegments(geometry%2C%20segments)%20%20%2F%2F%20%E2%86%92%20%E5%8C%85%E5%9B%B4%E7%9B%92%20%2B%20%E5%8D%95%E4%BD%93%E4%B8%89%E8%A7%92%E9%9D%A2%E6%95%B0%0AbuildAttributes(%7B%20smid%2C%20box%2C%20triangles%2C%20floorHeight%20%7D)%20%20%2F%2F%20%E2%86%92%20S3MFeatureAttributes">
1245
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
1246
+ <span class="copy-status">复制</span>
1247
+ </button>
1248
+ </div>
1249
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>resolveFeature(geo, batchId) // → { smid, segments }
1250
+ measureSegments(geometry, segments) // → 包围盒 + 单体三角面数
1251
+ buildAttributes({ smid, box, triangles, floorHeight }) // → S3MFeatureAttributes</code></pre>
1252
+ </div>
1253
+
1254
+ <hr />
1255
+
1256
+ <h2 id="性能调优" class="doc-heading group">
1257
+ <span class="heading-anchor-link">#</span>
1258
+ <a href="#性能调优" class="heading-text">性能调优</a>
1259
+ </h2>
1260
+
1261
+
1262
+ <div class="table-responsive my-6 overflow-x-auto rounded-xl border border-white/10 bg-[#121826]/70 shadow-lg">
1263
+ <table class="w-full text-left text-sm text-slate-300">
1264
+ <thead class="bg-white/5 text-slate-200 font-semibold border-b border-white/10">
1265
+ <tr><th>手段</th><th>说明</th></tr>
1266
+ </thead>
1267
+ <tbody class="divide-y divide-white/5">
1268
+ <tr><td><code>maxTiles</code></td><td>每个数据集驻留瓦片上限,默认 64。叠加 5 个数据集时建议 40~48</td></tr>
1269
+ <tr><td><code>maxConcurrent</code></td><td>每数据集并发,默认 4。公开服务建议 2~3</td></tr>
1270
+ <tr><td>关闭 <code>edges</code></td><td>棱线是 CPU 端构建的等宽线几何(每段线段 4 个顶点),内存和 drawcall 都随网格数增长,大场景慎用</td></tr>
1271
+ <tr><td><code>shading: &#39;flat&#39;</code></td><td>与 <code>&#39;smooth&#39;</code> 性能相当,但视觉更干净</td></tr>
1272
+ <tr><td>不透明度 < 1</td><td>会开启混合并关闭深度写入,overdraw 明显上升,仅用于演示</td></tr>
1273
+ <tr><td>批量改单体样式</td><td>同一同步块里的多次 <code>setFeatureStyle</code> 会合并成一次状态纹理重写,见下</td></tr>
1274
+ <tr><td><code>pick</code> 只渲染 1 个像素</td><td>已内置;hover 高亮、框选这类高频拾取不再有整屏开销</td></tr>
1275
+ </tbody>
1276
+ </table>
1277
+ </div>
1278
+
1279
+ <p>实测(上海某区,5 数据集叠加,45 万三角面,1440×900):<strong>85~90 fps</strong>。</p>
1280
+
1281
+ <h3 id="批量改单体样式" class="doc-heading group">
1282
+ <span class="heading-anchor-link">#</span>
1283
+ <a href="#批量改单体样式" class="heading-text">批量改单体样式</a>
1284
+ </h3>
1285
+
1286
+ <p>单体状态(颜色 / 抬高 / 隐藏)写在<strong>每个网格各自一张一维纹理</strong>里,改一个单体就是重写这张纹理。</p>
1287
+ <p>逐个高亮时最贵的就是这里:一块白模网格常有几千个 batch,每改一个单体就全量重算一遍,500 个门店 × 6848 个 batch 就是 340 万次迭代。</p>
1288
+ <p>所以内部做了两层合并:</p>
1289
+ <ul>
1290
+ <li><strong>按 SMID 定位网格</strong>:维护 <code>SMID → 网格</code> 反查表,改一个只重算包含它的那几块;</li>
1291
+ <li><strong>脏标记 + 微任务合并</strong>:同一个同步块里的多次调用只重算一次。微任务一定在下一帧 <code>render()</code> 之前跑完,画面不会看到中间状态。</li>
1292
+ </ul>
1293
+
1294
+ <div class="code-wrapper my-5 rounded-xl border border-white/10 bg-[#121826] overflow-hidden shadow-2xl">
1295
+ <div class="flex items-center justify-between px-4 py-2 border-b border-white/5 bg-[#0e1420] text-xs font-mono text-slate-400 select-none">
1296
+ <span class="font-semibold text-cyan-400 tracking-wider">TS</span>
1297
+ <button class="copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all text-xs" data-code="%2F%2F%20300%20%E6%AC%A1%E8%B0%83%E7%94%A8%20%E2%86%92%201%20%E6%AC%A1%E7%BA%B9%E7%90%86%E9%87%8D%E5%86%99%0Afor%20(const%20smid%20of%20hits)%20layer.setFeatureStyle(smid%2C%20%7B%20color%3A%20'%23ef4444'%20%7D)%3B%0A%0A%2F%2F%20%E9%9C%80%E8%A6%81%E5%90%8C%E6%AD%A5%E6%8B%BF%E5%88%B0%E7%BB%93%E6%9E%9C%EF%BC%88%E7%B4%A7%E6%8E%A5%E7%9D%80%E8%AF%BB%E7%BA%B9%E7%90%86%E3%80%81%E6%88%AA%E5%9B%BE%E3%80%81%E5%AF%BC%E5%87%BA%EF%BC%89%E6%97%B6%E6%89%8B%E5%8A%A8%E5%88%B7%0Alayer.flushOverrides()%3B">
1298
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
1299
+ <span class="copy-status">复制</span>
1300
+ </button>
1301
+ </div>
1302
+ <pre class="p-4 overflow-x-auto text-sm leading-relaxed font-mono text-slate-200"><code>// 300 次调用 → 1 次纹理重写
1303
+ for (const smid of hits) layer.setFeatureStyle(smid, { color: &#39;#ef4444&#39; });
1304
+
1305
+ // 需要同步拿到结果(紧接着读纹理、截图、导出)时手动刷
1306
+ layer.flushOverrides();</code></pre>
1307
+ </div>
1308
+
1309
+ <p><code>filterByField</code> / <code>colorByField</code> / <code>elevateByField</code> / <code>setThematicColors</code> 这类<strong>全局</strong>改动会重算所有网格,但同样被合并成一次。</p>
1310
+ <hr />
1311
+
1312
+ <h2 id="已知限制" class="doc-heading group">
1313
+ <span class="heading-anchor-link">#</span>
1314
+ <a href="#已知限制" class="heading-text">已知限制</a>
1315
+ </h2>
1316
+
1317
+ <ul>
1318
+ <li><strong>Draco 压缩几何</strong>能识别、不能解码,遇到会抛明确错误。接法与 MeshOpt 相同(内联 wasm、按需 <code>import</code>、解码后走同一套 attribute 组装),但必须先拿 Draco 瓦片标定容器结构。MeshOpt 已支持,且是逐字节对过 60 个真实瓦片才做对的。</li>
1319
+ <li><strong>实例化单体</strong>(<code>pickInfo</code> 里给的是 <code>instanceIds</code> 而不是 <code>batchId</code>,白模常见)只做了渲染,没做按单体控制:<code>setFeatureStyle</code> / <code>filterByField</code> / <code>elevateByField</code> / GPU 拾取走的都是 <code>aBatchId</code>,这条路径对实例化几何不生效。要支持得把每个实例单独上色,得改 InstancedMesh 的 instanceColor + 拾取编码。</li>
1320
+ <li><strong>CRN 纹理</strong>已支持,且已在真实服务上验证:内联的 crunch wasm 转回 DXT 后走现成解码,DXT 具体格式看 CRN 头,wasm 约 76KB、只有真遇到 CRN 时才动态加载。<strong>判定看 <code>compressType</code> 而不是 <code>pixelFormat</code></strong>——实测 v3.00 是 <code>ct=26 / pf=13</code>、行标是 <code>ct=50000 / pf=32856</code>,按 <code>pixelFormat</code> 26 / 27 判断会把真实 CRN 整包丢掉(模型变灰模)。判定收敛在 <code>src/core/TextureFormat.ts</code>(<code>resolveTextureKind</code> / <code>TextureCompress</code> / <code>dxtPixelFormatOf</code>,已从包入口导出)。用 <code>npm run probe:crn -- --crn &lt;数据集 config 地址&gt;</code> 可以对任意服务跑「尺寸吻合 / 色数 / 粗糙度 / ASCII 预览」的实测。</li>
1321
+ <li><strong>纹理分包(<code>s3m:TextureSharing</code>)未支持</strong>:这类数据集的 <code>tiles</code> 全是 <code>.s3mblock</code>,不是 <code>.s3mb</code>,加载会失败(例:SuperMap Online <code>3D-CBD/Xiaopin@CBD</code>)。</li>
1322
+ <li><strong>多块 MeshOpt 骨架</strong>(<code>blockCount &gt; 1</code>)暂未对齐:例 <code>3D-WorkSpace_pbr1/pbrmodel</code>,第二块头部按现有布局读会错位,会给出「流尺寸异常」的可定位报错而不是底层 RangeError。</li>
1323
+ <li><strong>切线顶点流</strong>只跳过不解码(渲染没有法线贴图通道),带 TANGENT 的数据不会被判失败。</li>
1324
+ <li><strong>KTX2</strong> 不在 S3M 的 <code>pixelFormat</code> 枚举里,iServer 也不会发;要支持需自行引入 <code>basis_transcoder.wasm</code> 并解析容器,属于「换数据源才需要考虑」的项。</li>
1325
+ <li><strong>骨骼动画</strong>解析了但未驱动。</li>
1326
+ <li><strong>地形遮挡</strong>未实现:MapLibre 的地形深度没有写进 custom layer 的深度缓冲,建筑不会被地形裁掉。</li>
1327
+ <li>跨城市量级的数据集叠加会有 float32 精度问题。</li>
1328
+ </ul>
1329
+ <hr />
1330
+
1331
+ <h2 id="数据与许可" class="doc-heading group">
1332
+ <span class="heading-anchor-link">#</span>
1333
+ <a href="#数据与许可" class="heading-text">数据与许可</a>
1334
+ </h2>
1335
+
1336
+ <ul>
1337
+ <li>本库代码 Apache-2.0。</li>
1338
+ <li><code>src/core/DXTTextureDecode.ts</code> 来自 <a href="https://github.com/supermap/s3m-spec">SuperMap/s3m-spec</a>(Apache-2.0)。</li>
1339
+ <li><code>src/vendor/crunch_decoder.module.js</code> 是 crunch 的 emscripten 构建(Apache-2.0),wasm 内联、仅改动加载方式。</li>
1340
+ <li><code>src/vendor/meshopt_decoder.module.js</code> 是 <a href="https://github.com/zeux/meshoptimizer">meshoptimizer</a> 官方解码器(MIT,© Arseny Kapoulkine),未做修改。</li>
1341
+ <li>S3M 规范文本遵循 CC BY-ND 4.0,不得修改规范文本后再发布。</li>
1342
+ </ul>
1343
+
1344
+ <footer class="mt-16 pt-8 border-t border-white/10 flex flex-col sm:flex-row items-center justify-between text-xs text-slate-500 gap-4">
1345
+ <p>© 2026 s3m-maplibre 项目组 · Apache-2.0 开源协议</p>
1346
+ <div class="flex items-center gap-4">
1347
+ <a href="https://github.com/joo1es/s3m-maplibre" class="hover:text-slate-300">GitHub</a>
1348
+ <span>·</span>
1349
+ <a href="../demo/index.html" class="hover:text-slate-300">在线交互演示</a>
1350
+ </div>
1351
+ </footer>
1352
+ </main>
1353
+
1354
+ <aside class="hidden xl:block w-56 shrink-0 py-8 pl-4 h-[calc(100vh-4rem)] sticky top-16 overflow-y-auto">
1355
+ <div class="text-xs font-semibold text-slate-400 tracking-wider uppercase mb-3 select-none">
1356
+ 本页大纲
1357
+ </div>
1358
+ <ul class="space-y-1 border-l border-white/5 pl-2" id="tocLinks">
1359
+ <li><a href="#目录" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors font-medium text-xs">目录</a></li>
1360
+ <li><a href="#安装" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors font-medium text-xs">安装</a></li>
1361
+ <li><a href="#typescript" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">TypeScript</a></li>
1362
+ <li><a href="#宿主兼容" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">宿主兼容</a></li>
1363
+ <li><a href="#快速开始" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors font-medium text-xs">快速开始</a></li>
1364
+ <li><a href="#核心概念" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors font-medium text-xs">核心概念</a></li>
1365
+ <li><a href="#坐标链路" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">坐标链路</a></li>
1366
+ <li><a href="#锚点" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">锚点</a></li>
1367
+ <li><a href="#瓦片与-lod" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">瓦片与 LOD</a></li>
1368
+ <li><a href="#单体化" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">单体化</a></li>
1369
+ <li><a href="#s3mlayer" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors font-medium text-xs">`S3MLayer`</a></li>
1370
+ <li><a href="#构造函数" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">构造函数</a></li>
1371
+ <li><a href="#数据集管理" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">数据集管理</a></li>
1372
+ <li><a href="#样式" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">样式</a></li>
1373
+ <li><a href="#棱线" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">棱线</a></li>
1374
+ <li><a href="#拾取与空间查询" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">拾取与空间查询</a></li>
1375
+ <li><a href="#其他" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">其他</a></li>
1376
+ <li><a href="#样式-s3mstyle" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors font-medium text-xs">样式(S3MStyle)</a></li>
1377
+ <li><a href="#着色模式" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">着色模式</a></li>
1378
+ <li><a href="#关于-按单体-配色" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">关于「按单体」配色</a></li>
1379
+ <li><a href="#关于-shading" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">关于 `shading`</a></li>
1380
+ <li><a href="#关于光照" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">关于光照</a></li>
1381
+ <li><a href="#拾取与属性" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors font-medium text-xs">拾取与属性</a></li>
1382
+ <li><a href="#投影" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors font-medium text-xs">投影</a></li>
1383
+ <li><a href="#按业务字段着色" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors font-medium text-xs">按业务字段着色</a></li>
1384
+ <li><a href="#过滤-抬高" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">过滤 / 抬高</a></li>
1385
+ <li><a href="#为什么这么重-以及怎么减" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">为什么这么重,以及怎么减</a></li>
1386
+ <li><a href="#纯-three-js-使用" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors font-medium text-xs">纯 three.js 使用</a></li>
1387
+ <li><a href="#底层-api" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors font-medium text-xs">底层 API</a></li>
1388
+ <li><a href="#parses3mb" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">`parseS3MB`</a></li>
1389
+ <li><a href="#meshopt-压缩几何" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">MeshOpt 压缩几何</a></li>
1390
+ <li><a href="#s3mtileset" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">`S3MTileset`</a></li>
1391
+ <li><a href="#单体属性工具" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">单体属性工具</a></li>
1392
+ <li><a href="#性能调优" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors font-medium text-xs">性能调优</a></li>
1393
+ <li><a href="#批量改单体样式" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors ml-3 text-xs">批量改单体样式</a></li>
1394
+ <li><a href="#已知限制" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors font-medium text-xs">已知限制</a></li>
1395
+ <li><a href="#数据与许可" class="toc-link block py-1 text-slate-400 hover:text-cyan-400 transition-colors font-medium text-xs">数据与许可</a></li>
1396
+
1397
+ </ul>
1398
+ </aside>
1399
+ </div>
1400
+
1401
+ <script>
1402
+ document.querySelectorAll('.copy-btn').forEach(btn => {
1403
+ btn.addEventListener('click', async () => {
1404
+ const raw = decodeURIComponent(btn.dataset.code || '');
1405
+ try {
1406
+ await navigator.clipboard.writeText(raw);
1407
+ const status = btn.querySelector('.copy-status');
1408
+ const orig = status.textContent;
1409
+ status.textContent = '已复制!';
1410
+ btn.classList.add('text-cyan-400');
1411
+ setTimeout(() => {
1412
+ status.textContent = orig;
1413
+ btn.classList.remove('text-cyan-400');
1414
+ }, 1800);
1415
+ } catch (err) {
1416
+ console.error('复制失败', err);
1417
+ }
1418
+ });
1419
+ });
1420
+
1421
+ const headings = Array.from(document.querySelectorAll('.doc-heading'));
1422
+ const tocLinks = Array.from(document.querySelectorAll('.toc-link'));
1423
+ const sidebarLinks = Array.from(document.querySelectorAll('.sidebar-link'));
1424
+
1425
+ function onScroll() {
1426
+ const top = window.scrollY + 100;
1427
+ let currentId = '';
1428
+ for (let i = 0; i < headings.length; i++) {
1429
+ if (headings[i].offsetTop <= top) {
1430
+ currentId = headings[i].id;
1431
+ }
1432
+ }
1433
+ if (!currentId && headings.length > 0) currentId = headings[0].id;
1434
+
1435
+ tocLinks.forEach(link => {
1436
+ const active = link.getAttribute('href') === '#' + currentId;
1437
+ link.classList.toggle('active', active);
1438
+ });
1439
+
1440
+ sidebarLinks.forEach(link => {
1441
+ const active = link.getAttribute('href') === '#' + currentId;
1442
+ link.classList.toggle('active', active);
1443
+ });
1444
+ }
1445
+ window.addEventListener('scroll', onScroll, { passive: true });
1446
+ onScroll();
1447
+
1448
+ const searchInput = document.getElementById('searchInput');
1449
+ if (searchInput) {
1450
+ searchInput.addEventListener('input', (e) => {
1451
+ const query = e.target.value.toLowerCase().trim();
1452
+ sidebarLinks.forEach(link => {
1453
+ const text = link.textContent.toLowerCase();
1454
+ link.style.display = (!query || text.includes(query)) ? '' : 'none';
1455
+ });
1456
+ });
1457
+ window.addEventListener('keydown', (e) => {
1458
+ if (e.key === '/' && document.activeElement !== searchInput) {
1459
+ e.preventDefault();
1460
+ searchInput.focus();
1461
+ }
1462
+ });
1463
+ }
1464
+ </script>
1465
+ </body>
1466
+ </html>