s3m-three 0.4.16 → 0.4.18

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.
Files changed (47) hide show
  1. package/dist/browser/s3m-three.iife.ext-three.js +41 -23
  2. package/dist/browser/s3m-three.iife.js +41 -23
  3. package/dist/core/AttributeQuery.d.ts +13 -0
  4. package/dist/core/AttributeQuery.d.ts.map +1 -1
  5. package/dist/core/AttributeQuery.js +20 -3
  6. package/dist/core/AttributeQuery.js.map +1 -1
  7. package/dist/core/AuthFetch.d.ts +52 -0
  8. package/dist/core/AuthFetch.d.ts.map +1 -0
  9. package/dist/core/AuthFetch.js +44 -0
  10. package/dist/core/AuthFetch.js.map +1 -0
  11. package/dist/core/types.d.ts +8 -0
  12. package/dist/core/types.d.ts.map +1 -1
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/render/DatasetManager.d.ts +7 -1
  18. package/dist/render/DatasetManager.d.ts.map +1 -1
  19. package/dist/render/DatasetManager.js +2 -1
  20. package/dist/render/DatasetManager.js.map +1 -1
  21. package/dist/render/HostRenderer.d.ts +105 -0
  22. package/dist/render/HostRenderer.d.ts.map +1 -0
  23. package/dist/render/HostRenderer.js +170 -0
  24. package/dist/render/HostRenderer.js.map +1 -0
  25. package/dist/render/S3MViewer.d.ts +28 -8
  26. package/dist/render/S3MViewer.d.ts.map +1 -1
  27. package/dist/render/S3MViewer.js +101 -42
  28. package/dist/render/S3MViewer.js.map +1 -1
  29. package/dist/render/StyleMaterial.d.ts +6 -4
  30. package/dist/render/StyleMaterial.d.ts.map +1 -1
  31. package/dist/render/StyleMaterial.js +48 -3
  32. package/dist/render/StyleMaterial.js.map +1 -1
  33. package/dist/tileset/S3MTileset.d.ts +8 -0
  34. package/dist/tileset/S3MTileset.d.ts.map +1 -1
  35. package/dist/tileset/S3MTileset.js +23 -5
  36. package/dist/tileset/S3MTileset.js.map +1 -1
  37. package/docs/API.md +40 -22
  38. package/package.json +1 -1
  39. package/src/core/AttributeQuery.ts +26 -3
  40. package/src/core/AuthFetch.ts +82 -0
  41. package/src/core/types.ts +8 -0
  42. package/src/index.ts +4 -0
  43. package/src/render/DatasetManager.ts +9 -1
  44. package/src/render/HostRenderer.ts +222 -0
  45. package/src/render/S3MViewer.ts +112 -39
  46. package/src/render/StyleMaterial.ts +59 -3
  47. package/src/tileset/S3MTileset.ts +28 -5
package/docs/API.md CHANGED
@@ -13,7 +13,7 @@
13
13
  - [样式(S3MStyle)](#样式s3mstyle)
14
14
  - [拾取与属性](#拾取与属性)
15
15
  - [投影](#投影)
16
- - [按业务字段着色](#按业务字段着色)
16
+ - [业务字段:锚点在库内,对应关系在外部](#业务字段锚点在库内对应关系在外部)
17
17
  - [动画与过渡](#动画与过渡)
18
18
  - [纯 three.js 使用](#纯-threejs-使用)
19
19
  - [底层 API](#底层-api)
@@ -176,7 +176,7 @@ new S3MViewer(options?: S3MViewerOptions)
176
176
  | `maxImageCache` | `number` | `128` | 纹理缓存条数上限。一条 = 一张已解码的瓦片纹理,1024² RGBA 约 4MB,128 条即约 512MB 显存;低端设备 32~48。LRU,降下来下次淘汰时释放 |
177
177
  | `materialQuality` | `'pbr' \| 'lambert'` | `'pbr'` | 光照模型。`lambert` 用 `MeshLambertMaterial` 替换 PBR,去掉 GGX 与环境球谐,片元约省一半;白模 / 纯色 / 按高度着色几乎无差。模块级全局,只影响之后创建的材质 |
178
178
  | `buildTile` | `BuildTileFn \| null` | — | 自定义瓦片构建器(测试 / 服务端渲染时注入) |
179
- | `attributes` | `AttributeQueryOptions` | — | 业务属性查询配置,配了才能 `colorByField()`,见[按业务字段着色](#按业务字段着色) |
179
+ | `attributes` | `AttributeQueryOptions` | — | 可选的 iServer 数据服务配置,配了才能用 `colorByField()` 等便利方法;不配就自己取数 + `setFeaturesStyle()`,见[业务字段](#业务字段锚点在库内对应关系在外部) |
180
180
  | `onRequestRepaint` | `() => void` | — | 宿主请求重画的信号:纯 three 宿主接自己的渲染循环 |
181
181
  | `getBearing` | `() => number` | — | 地图朝向(弧度),让灯光跟着视角走;不传则灯光方向固定 |
182
182
  | `onStats` | `(s: Stats) => void` | — | 统计更新 |
@@ -334,7 +334,7 @@ getFilter(): S3MFilter | null
334
334
  clearFilter(): void
335
335
  ```
336
336
 
337
- 详见[按业务字段着色](#按业务字段着色)。
337
+ 详见[业务字段](#业务字段锚点在库内对应关系在外部)。
338
338
 
339
339
  ### 3D 物理地表与基坑
340
340
 
@@ -536,7 +536,7 @@ interface PickResult {
536
536
  }
537
537
  ```
538
538
 
539
- > **注意**:`height` / `floors` / `bboxArea` 这些是**从几何算出来的**,不是业务属性表。OSGB 转 S3M 的数据通常不带属性表,`smid` 是与 iServer 侧数据对齐的唯一标识,业务字段需要你自己按 `smid` 去外部关联。
539
+ > **注意**:`height` / `floors` / `bboxArea` 这些是**从几何算出来的**,不是业务属性表。OSGB 转 S3M 的数据通常不带属性表,`smid` 是与 iServer 侧数据对齐的唯一标识,业务字段需要你自己按 `smid` 去外部关联 —— 库不内置这个对应关系,见[业务字段](#业务字段锚点在库内对应关系在外部)。
540
540
 
541
541
  需要接业务属性时,用 `onPick` 拿到 `smid` 后自行查询:
542
542
 
@@ -572,9 +572,31 @@ V = P⁻¹ × MVP
572
572
 
573
573
  ---
574
574
 
575
- ## 按业务字段着色
575
+ ## 业务字段:锚点在库内,对应关系在外部
576
576
 
577
- S3M 瓦片里**只有单体 ID**(SMID)和顶点区间,没有任何业务字段;业务属性在 iServer 的**数据服务**里(`data-xxx/rest/data`),和三维服务(`3D-xxx/rest/realspace`)是两个服务。所以按字段着色是「三维 + 数据」的跨服务查询,需要单独配一个数据源。
577
+ S3M 瓦片里**只有单体 ID(SMID)和顶点区间,没有任何业务字段**。所以"哪个字段代表这栋楼、这栋楼在业务系统里是谁"这件事,库不替你做——鉴权、主子表、多数据源、字段命名每家都不一样,一旦内置就得跟着业务改。
578
+
579
+ 库只提供三个锚点:
580
+
581
+ | 能力 | API |
582
+ |---|---|
583
+ | 拿到单体 ID | `onPick` 回调里的 `feature.smid` |
584
+ | 单个 / 批量落样式 | `setFeatureStyle(smid, style)` / `setFeaturesStyle(smids, style)` |
585
+ | 批量上色 | `setThematicColors(Map<smid, color>)` |
586
+
587
+ 推荐写法是**外部取数 → 自己算 → 落样式**:
588
+
589
+ ```ts
590
+ const smids = viewer.getLoadedSmids(); // 当前加载进来的单体
591
+ const rows = await myOwnApi(smids); // 你自己的后端、自己的鉴权
592
+ const colors = new Map<number, string>();
593
+ for (const [smid, row] of rows) colors.set(smid, pickColor(row.用途));
594
+ viewer.setThematicColors(colors); // 一次写纹理,不重建几何
595
+ ```
596
+
597
+ ### 内置的 iServer 便利版(可选)
598
+
599
+ 如果业务属性**刚好**发布在同一个 iServer 的**数据服务**里(`data-xxx/rest/data`,和三维服务 `3D-xxx/rest/realspace` 是两个服务),且单体 ID 列就是 `SmID`、没有额外鉴权,可以直接用内置的三个方法,省掉自己写查询:
578
600
 
579
601
  ```ts
580
602
  const viewer = new S3MViewer({
@@ -586,29 +608,25 @@ const viewer = new S3MViewer({
586
608
  },
587
609
  });
588
610
 
589
- // 分类字段:同一取值同色
590
- const result = await viewer.colorByField({ field: 'KEYID', type: 'category' });
591
-
592
- // 数值字段:连续色带 / 按阈值分级
593
- await viewer.colorByField({ field: 'SmMaxZ', type: 'ramp' });
594
- await viewer.colorByField({ field: 'SmMaxZ', type: 'breaks', breaks: [10, 30, 60] });
595
-
596
- result.legend; // [{ label, color, count }] 直接拿去画图例
597
- result.matched; // 真正上了色的单体数
611
+ await viewer.colorByField({ field: 'SmMaxZ', type: 'ramp' }); // 数值列:连续色带
612
+ await viewer.colorByField({ field: 'KEYID', type: 'category' }); // 字符串列:同值同色
613
+ await viewer.filterByField({ field: '用途', values: ['住宅'] }); // 只显示命中
614
+ await viewer.elevateByField({ field: 'SmMaxZ', scale: 0.5 }); // 按字段抬高
598
615
  ```
599
616
 
600
- 几个要点:
617
+ 它的能力边界很明确,超出就别硬套,自己写:
601
618
 
602
- - **默认范围是「当前已加载的单体」**(`getLoadedSmids()`),不是整个数据集。实测每条要素的响应约 5KB(模型数据集每条都挂着 `geometry.model`,见下面的「为什么这么重」),全量几千栋会变成几十 MB,所以只查加载进来的那部分,按 200 个一批分片;
603
- - 查询结果按 SMID 缓存**整行**(`AttributeQuery`),换字段只是重算颜色、重写一张一维纹理,不重建几何、不重编译着色器,也不再发一次请求;
619
+ - **只认 iServer 数据服务**,换后端没有口子;
620
+ - **键固定是 `SmID`**(可用 `idField` 改名,但语义仍是"属性表里有一列等于 smid");
621
+ - 三个方法**各自查各自的**,共享的只是 `AttributeQuery` 内部那份整行缓存;
622
+ - 默认范围是**当前已加载的单体**(`getLoadedSmids()`),不是整个数据集。实测每条要素约 5KB(模型数据集每条都挂着 `geometry.model`),全量几千栋会变成几十 MB,所以按 200 个一批分片;
623
+ - 查询结果按 SMID 缓存**整行**,换字段只重算颜色、重写一维纹理,不重建几何、不重编译着色器,也不再发请求;
604
624
  - 手动 `setFeatureStyle()` 的颜色优先级高于专题色;
605
- - `transform` 用来归并取值:`KEYID` / `ENTITYID` 这种基本一栋一个值的字段直接上分类色,着出来就是「随机色」,要么改用 `ramp` / `breaks`,要么用 `transform` 把编码截到能分组的层级,比如 `(v) => v.slice(0, 6)`;
606
- - `breaks` 不给就用色带档数等分;`colors` 不给就用内置 12 色板。
625
+ - `transform` 用来归并取值:`KEYID` / `ENTITYID` 这种基本一栋一个值的字段直接上分类色会变成"随机色",要么改用 `ramp` / `breaks`,要么用 `transform` 把编码截到能分组的层级。
607
626
 
608
- 不想接数据服务时,也可以:
627
+ 不接数据服务也能自己算颜色(比如按几何派生量:高度 / 面积 / 楼层):
609
628
 
610
629
  ```ts
611
- // 自己算好颜色(比如按几何派生量:高度 / 面积 / 楼层)
612
630
  viewer.setThematicColors(new Map([[423, '#ef4444'], [424, '#22c55e']]));
613
631
  viewer.clearThematic();
614
632
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s3m-three",
3
- "version": "0.4.16",
3
+ "version": "0.4.18",
4
4
  "description": "SuperMap S3M 三维模型格式的 three.js 渲染内核:浏览器内直接解析 .s3mb 瓦片,无需转 3D Tiles,支持 LOD 调度、白膜/按高度/按单体/动态扫描着色、单体级 GPU 拾取。宿主无关,可单独用于任意 three 场景。",
5
5
  "type": "module",
6
6
  "repository": {
@@ -23,6 +23,8 @@
23
23
  * 这样换专题字段时全部命中缓存,不再发请求。
24
24
  */
25
25
 
26
+ import { resolveAuthRequest, type AuthenticateFn } from './AuthFetch.js';
27
+
26
28
  const DEFAULT_ID_FIELD = 'SmID';
27
29
  const DEFAULT_CHUNK_SIZE = 200;
28
30
  /** 几何/索引字段取回来没用,还特别占带宽 */
@@ -44,6 +46,11 @@ export interface AttributeQueryOptions {
44
46
  format?: 'auto' | 'fastjson' | 'json';
45
47
  /** 便于测试时替换,默认用全局 fetch */
46
48
  fetchImpl?: typeof fetch;
49
+ /**
50
+ * 鉴权适配函数(不填则用图层级的 `authenticate`)。请求发出前过一遍,
51
+ * 可改写 URL(加 `?token=`)或附加请求头。见 `AuthenticateFn`。
52
+ */
53
+ authenticate?: AuthenticateFn;
47
54
  }
48
55
 
49
56
  export interface AttributeField {
@@ -93,6 +100,7 @@ export class AttributeQuery {
93
100
  private readonly _chunkSize: number;
94
101
  private readonly _formatOption: 'auto' | 'fastjson' | 'json';
95
102
  private readonly _fetch: typeof fetch;
103
+ private readonly _authenticate: AuthenticateFn | undefined;
96
104
  private readonly _cache = new Map<number, AttributeRow>();
97
105
  private _schema: AttributeField[] | null = null;
98
106
  private _resolvedFields: string[] | null = null;
@@ -113,6 +121,7 @@ export class AttributeQuery {
113
121
  this._chunkSize = Math.max(1, options.chunkSize ?? DEFAULT_CHUNK_SIZE);
114
122
  this._formatOption = options.format ?? 'auto';
115
123
  this._fetch = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
124
+ this._authenticate = options.authenticate;
116
125
  }
117
126
 
118
127
  /** 数据集的 REST 路径:.../datasources/name/{ds}/datasets/name/{name} */
@@ -120,6 +129,20 @@ export class AttributeQuery {
120
129
  return `${this.dataServiceUrl}/datasources/name/${encodeURIComponent(this.datasourceName)}/datasets/name/${encodeURIComponent(this.datasetName)}`;
121
130
  }
122
131
 
132
+ /**
133
+ * 带鉴权的请求:先过 `authenticate` 钩子改写 URL / 追加 header,再走本查询的
134
+ * 传输层(`fetchImpl` 或全局 fetch,自定义传输不被绕过)。未配置鉴权时与旧版
135
+ * 行为逐字一致(参数原样透传)。
136
+ */
137
+ private async _request(url: string, init?: RequestInit): Promise<Response> {
138
+ const resolved = await resolveAuthRequest(url, this._authenticate, 'attributes');
139
+ const hookHeaders = resolved.headers && Object.keys(resolved.headers).length > 0 ? resolved.headers : null;
140
+ if (!hookHeaders) return init === undefined ? this._fetch(resolved.url) : this._fetch(resolved.url, init);
141
+ // 钩子头在前,init 头在后:调用方显式给的头(如 Content-Type)不被覆盖
142
+ const headers = { ...hookHeaders, ...init?.headers } as Record<string, string>;
143
+ return this._fetch(resolved.url, { ...init, headers });
144
+ }
145
+
123
146
  /** 累计的请求代价(不清零,用来观察整体开销) */
124
147
  get stats(): AttributeQueryStats {
125
148
  return { ...this._stats };
@@ -128,7 +151,7 @@ export class AttributeQuery {
128
151
  /** 字段表(顺手也用来判断「这个数据集到底有没有属性表」) */
129
152
  async loadSchema(): Promise<AttributeField[]> {
130
153
  if (this._schema) return this._schema;
131
- const res = await this._fetch(`${this.datasetPath}/fields.json`);
154
+ const res = await this._request(`${this.datasetPath}/fields.json`);
132
155
  this._stats.requests++;
133
156
  if (!res.ok) throw new Error(`[s3m] 读取字段表失败 ${res.status}:${this.datasetPath}/fields.json`);
134
157
  const json = (await res.json()) as { fieldNames?: string[] };
@@ -197,7 +220,7 @@ export class AttributeQuery {
197
220
  if (this._format) return this._format;
198
221
  if (this._formatOption !== 'auto') return (this._format = this._formatOption);
199
222
  try {
200
- const res = await this._fetch(`${this.datasetPath}/features.json?fromIndex=0&toIndex=1`);
223
+ const res = await this._request(`${this.datasetPath}/features.json?fromIndex=0&toIndex=1`);
201
224
  this._stats.requests++;
202
225
  const json = (await res.json()) as { geometryType?: string };
203
226
  const isModel = String(json.geometryType ?? '').toUpperCase().includes('MODEL');
@@ -249,7 +272,7 @@ export class AttributeQuery {
249
272
 
250
273
  private async _post(body: unknown, format: 'fastjson' | 'json'): Promise<string> {
251
274
  const t0 = performance.now();
252
- const res = await this._fetch(`${this.dataServiceUrl}/featureResults.${format}?returnContent=true`, {
275
+ const res = await this._request(`${this.dataServiceUrl}/featureResults.${format}?returnContent=true`, {
253
276
  method: 'POST',
254
277
  headers: { 'Content-Type': 'application/json' },
255
278
  body: JSON.stringify(body),
@@ -0,0 +1,82 @@
1
+ /**
2
+ * 鉴权适配(Authenticate Hook)
3
+ *
4
+ * S3M 服务经常挂在带鉴权的网关后面:有的要求 URL 带 `?token=...`,有的要求
5
+ * 请求头带 `Authorization` / `X-Token`,还有的 token 会过期需要定期换。内核在
6
+ * 主线程发出的全部请求(数据集配置 scp、瓦片 .s3mb/.s3mblock、子图集 .texblock、
7
+ * 棱线缓存、iServer 属性查询)都从这一个钩子过一遍,由使用方统一改写。
8
+ *
9
+ * 设计约束(对应 AGENTS.md 不变式①):不传 `authenticate` 时,请求路径与旧版
10
+ * 逐字一致——`authFetch` 直接短路到裸 `fetch(url)` / `fetch(url, init)`,连 init
11
+ * 对象都不新建。
12
+ *
13
+ * 注意:鉴权只作用于**发到网络上的请求**,不参与内核的缓存键(瓦片 URL 带 `#N`
14
+ * 片段与 nodes/pending 等内部 Map 的键都是改写前的原 URL),token 轮换不会导致
15
+ * 重复加载同一块瓦片。重试时每次重新过钩子,token 刷新后重试天然带上新值。
16
+ */
17
+
18
+ /** 请求类别,钩子按类别区分处理(比如属性查询走另一个网关) */
19
+ export type AuthRequestKind = 'config' | 'tile' | 'texblock' | 'creases' | 'attributes';
20
+
21
+ /** 改写后的请求:实际请求的 URL + 需要附加的请求头 */
22
+ export interface AuthResolvedRequest {
23
+ /** 实际发到网络上的 URL(可追加 `?token=...` 等鉴权参数) */
24
+ url: string;
25
+ /** 需要附加的请求头(如 `Authorization` / `X-Token`) */
26
+ headers?: Record<string, string>;
27
+ }
28
+
29
+ /**
30
+ * 鉴权适配函数。每个请求发出前调用一次:
31
+ *
32
+ * - 返回 `string`:只改写 URL(最简形式,如追加 `?token=`);
33
+ * - 返回 `{ url, headers }`:改写 URL 并附加请求头;
34
+ * - 返回 `Promise`:支持异步(如先向认证服务换 token)。
35
+ *
36
+ * ```js
37
+ * new S3MLayer({
38
+ * configUrl: 'https://gw.example.com/data/a.scp',
39
+ * authenticate: (url, kind) => ({
40
+ * url: `${url}${url.includes('?') ? '&' : '?'}token=${getToken()}`,
41
+ * headers: { 'X-Client': 's3m' },
42
+ * }),
43
+ * });
44
+ * ```
45
+ */
46
+ export type AuthenticateFn = (
47
+ url: string,
48
+ kind: AuthRequestKind
49
+ ) => string | AuthResolvedRequest | Promise<string | AuthResolvedRequest>;
50
+
51
+ /** 把钩子返回值归一成 `{ url, headers }`;返回值异常时兜底用原 URL */
52
+ export async function resolveAuthRequest(
53
+ url: string,
54
+ auth: AuthenticateFn | undefined,
55
+ kind: AuthRequestKind
56
+ ): Promise<AuthResolvedRequest> {
57
+ if (!auth) return { url };
58
+ const out = await auth(url, kind);
59
+ if (typeof out === 'string') return { url: out || url };
60
+ if (!out || typeof out !== 'object') return { url };
61
+ return { url: out.url || url, headers: out.headers };
62
+ }
63
+
64
+ /**
65
+ * 带鉴权的 fetch:先过钩子改写 URL / 追加 header,再发请求。
66
+ * `init.headers`(调用方自带的头)优先级高于钩子返回的 headers。
67
+ * `auth` 未配置时与裸 `fetch` 完全等价(参数原样透传,不新建 init)。
68
+ */
69
+ export async function authFetch(
70
+ url: string,
71
+ auth: AuthenticateFn | undefined,
72
+ kind: AuthRequestKind,
73
+ init?: RequestInit
74
+ ): Promise<Response> {
75
+ if (!auth) return init === undefined ? fetch(url) : fetch(url, init);
76
+ const resolved = await resolveAuthRequest(url, auth, kind);
77
+ const hookHeaders = resolved.headers && Object.keys(resolved.headers).length > 0 ? resolved.headers : null;
78
+ if (!hookHeaders) return init === undefined ? fetch(resolved.url) : fetch(resolved.url, init);
79
+ // 钩子头在前,init 头在后:调用方显式给的头不被覆盖
80
+ const headers = { ...hookHeaders, ...init?.headers } as Record<string, string>;
81
+ return fetch(resolved.url, { ...init, headers });
82
+ }
package/src/core/types.ts CHANGED
@@ -222,6 +222,14 @@ export interface Stats {
222
222
  vertices: number;
223
223
  triangles: number;
224
224
  datasets: number;
225
+ /** 上一帧 draw call 数(renderer.info.render.calls,无渲染器时为 0) */
226
+ drawCalls: number;
227
+ /** 已编译着色程序数(renderer.info.programs.length) */
228
+ programs: number;
229
+ /** 显存里驻留的纹理数(renderer.info.memory.textures) */
230
+ textures: number;
231
+ /** 显存里驻留的几何数(renderer.info.memory.geometries) */
232
+ geometries: number;
225
233
  }
226
234
 
227
235
  /* ------------------------------------------------------------------ 单体属性 */
package/src/index.ts CHANGED
@@ -21,6 +21,8 @@ export type {
21
21
  AttributeField,
22
22
  AttributeRow,
23
23
  } from './core/AttributeQuery.js';
24
+ export type { AuthRequestKind, AuthResolvedRequest, AuthenticateFn } from './core/AuthFetch.js';
25
+ export { resolveAuthRequest, authFetch } from './core/AuthFetch.js';
24
26
 
25
27
  // —— 公共类型
26
28
  export type {
@@ -65,6 +67,8 @@ export type {
65
67
  S3MViewerOptions,
66
68
  PickResult,
67
69
  } from './render/S3MViewer.js';
70
+ export { HostRenderer } from './render/HostRenderer.js';
71
+ export type { HostRendererOptions, HostFrame } from './render/HostRenderer.js';
68
72
  export {
69
73
  DEFAULT_STYLE,
70
74
  STYLE,
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import * as THREE from 'three';
11
11
  import { S3MTileset, usableGeoBounds } from '../tileset/S3MTileset.js';
12
+ import type { AuthenticateFn } from '../core/AuthFetch.js';
12
13
  import type { Anchor } from '../core/types.js';
13
14
  import { worldFromLngLat, type GeoAdapter } from './GeoAdapter.js';
14
15
 
@@ -43,6 +44,11 @@ export interface S3MDatasetOptions {
43
44
  * - true / false:强制开 / 关(例如地形、绿地、道路只显示材质、不描边)
44
45
  */
45
46
  enableEdges?: boolean;
47
+ /**
48
+ * 本数据集专属的鉴权适配函数(不填则用图层级的 `authenticate`)。
49
+ * 适合多数据集走不同网关 / 不同 token 的场景。见 `AuthenticateFn`。
50
+ */
51
+ authenticate?: AuthenticateFn;
46
52
  }
47
53
 
48
54
  export interface S3MDatasetEntry {
@@ -170,7 +176,8 @@ export class DatasetManager {
170
176
  maxConcurrent: number,
171
177
  callbacks: DatasetManagerCallbacks,
172
178
  maxTriangles: number = Infinity,
173
- maxImageCache: number = 128
179
+ maxImageCache: number = 128,
180
+ authenticate?: AuthenticateFn
174
181
  ): Promise<S3MDatasetEntry> {
175
182
  const existing = this.datasets.get(ds.id);
176
183
  if (existing) return existing;
@@ -205,6 +212,7 @@ export class DatasetManager {
205
212
  lodRangeScale: ds.lodRangeScale ?? defaultLodRangeScale,
206
213
  buildTile: buildTile ?? null,
207
214
  useWorker: ds.useWorker,
215
+ authenticate: ds.authenticate ?? authenticate,
208
216
  uniforms: uniforms,
209
217
  onTile: (node) => {
210
218
  callbacks.onTile(node, entry);
@@ -0,0 +1,222 @@
1
+ /**
2
+ * 宿主无关渲染底座(HostRenderer)
3
+ *
4
+ * 把「宿主协议 ↔ s3m-three 渲染内核」的**宿主无关**部分抽出来,作为所有地图适配层
5
+ * (MapLibre / Mapbox / 未来的 ArcGIS / Cesium)的共同基类。它只做三件事:
6
+ *
7
+ * 1. 持有并构造 `S3MViewer`(渲染内核,宿主无关);
8
+ * 2. 提供每帧驱动(把宿主给的 world→clip 矩阵喂给内核,并按需请求下一帧)、
9
+ * 生命周期(mount / restore / unmount)、投影模式状态与近平面缩放状态;
10
+ * 3. 把「宿主能力」做成两个抽象钩子 `requestRepaint()` / `getBearing()`,
11
+ * 由具体适配器实现(maplibre → `triggerRepaint()`,arcgis → `requestRender()`)。
12
+ *
13
+ * 凡是依赖具体地图 API 的东西(CustomLayerInterface 形态、从宿主 render 参数里抽矩阵、
14
+ * 事件绑定、上下文恢复重加图层、经纬度→墨卡托的 WorldCoordinateHelper 提取)都**不在这里**——
15
+ * 那是各适配层自己的事。这样「绕开墨卡托」也成为可能:非墨卡托宿主(ECEF / 局部米制)只要
16
+ * 实现 `requestRepaint` 并提供返回自己坐标系的 GeoAdapter,内核就完全不碰墨卡托。
17
+ *
18
+ * 设计约束(对应 AGENTS.md 不变式①):本类不引入任何默认行为变更;maplibre 适配器继承它后,
19
+ * 默认路径与改动前逐字一致。
20
+ */
21
+ import { S3MViewer } from './S3MViewer.js';
22
+ import type { S3MViewerOptions, PickResult } from './S3MViewer.js';
23
+ import type { ProjectionMode } from './OrthoProjection.js';
24
+ import type { TransitionOptions } from '../anim/Animator.js';
25
+ import type { Stats } from '../core/types.js';
26
+ import type { S3MDatasetEntry } from './DatasetManager.js';
27
+
28
+ /** HostRenderer 构造选项:渲染内核选项 + 三个适配器层专属开关 */
29
+ export interface HostRendererOptions extends S3MViewerOptions {
30
+ /** 图层 / 适配器 id,默认 's3m' */
31
+ id?: string;
32
+ /** 投影模式,默认 'perspective'(跟随宿主) */
33
+ projection?: ProjectionMode;
34
+ /** 近裁剪面缩放系数(0 < scale < 1,默认 1 = 不改) */
35
+ nearPlaneScale?: number;
36
+ }
37
+
38
+ /** 每帧喂给内核的矩阵输入(宿主无关;适配器负责从宿主 render 参数里抽出来) */
39
+ export interface HostFrame {
40
+ /** world→clip 矩阵(宿主坐标系下) */
41
+ mvp: ArrayLike<number>;
42
+ /** 相机在场景 ENU 中的位置(米,相对场景锚点) */
43
+ cameraEnu: [number, number, number];
44
+ /** 垂直视场角(弧度),用于 LOD 屏幕误差 */
45
+ fovRad: number;
46
+ /** 绘图缓冲高度(像素),LOD 判定用 */
47
+ canvasHeight: number;
48
+ }
49
+
50
+ /**
51
+ * 宿主无关渲染底座。具体地图适配器(如 S3MLayer)继承它,只补宿主专属胶水。
52
+ */
53
+ export abstract class HostRenderer {
54
+ readonly id: string;
55
+ readonly options: HostRendererOptions;
56
+ readonly pickable: boolean;
57
+
58
+ /** 公开回调,可被使用方运行时重新赋值 */
59
+ onStats: (stats: Stats) => void;
60
+ onReady: (entry: S3MDatasetEntry) => void;
61
+ onPick: (result: PickResult | null) => void;
62
+ onError: (error: unknown) => void;
63
+
64
+ /** 渲染内核(宿主无关) */
65
+ readonly viewer: S3MViewer;
66
+
67
+ /** 当前投影模式 */
68
+ protected _projection: ProjectionMode = 'perspective';
69
+ /** 正交插值比例(0 = 纯透视,1 = 纯正交);过渡期间由 animator 平滑推进 */
70
+ protected _projectionT = 0;
71
+ /** 正交不可用只告警一次 */
72
+ protected _orthoWarned = false;
73
+ /** 近裁剪面缩放系数,1 = 不改(默认) */
74
+ protected _nearPlaneScale = 1;
75
+
76
+ constructor(options: HostRendererOptions = {}) {
77
+ this.id = options.id ?? 's3m';
78
+ this.options = options;
79
+ this.pickable = options.pickable !== false;
80
+ this._projection = options.projection === 'orthographic' ? 'orthographic' : 'perspective';
81
+ this._nearPlaneScale = HostRenderer.normalizeNearPlaneScale(options.nearPlaneScale);
82
+ this._projectionT = this._projection === 'orthographic' ? 1 : 0;
83
+
84
+ this.onStats = options.onStats ?? (() => {});
85
+ this.onReady = options.onReady ?? (() => {});
86
+ this.onPick = options.onPick ?? (() => {});
87
+ this.onError = options.onError ?? ((e) => console.warn('[s3m]', e));
88
+
89
+ // 把宿主能力做成钩子,内核不碰任何地图 API
90
+ const { id: _id, projection: _projection, nearPlaneScale: _nearPlaneScale, ...viewerOptions } = options;
91
+ this.viewer = new S3MViewer({
92
+ ...viewerOptions,
93
+ onRequestRepaint: () => this.requestRepaint(),
94
+ getBearing: () => this.getBearing(),
95
+ onStats: (s) => this.onStats(s),
96
+ onReady: (e) => this.onReady(e),
97
+ onPick: (r) => this.onPick(r),
98
+ onError: (e) => this.onError(e),
99
+ });
100
+ }
101
+
102
+ /**
103
+ * 宿主能力的钩子:请求下一帧重绘。
104
+ * maplibre → `map.triggerRepaint()`;arcgis → `externalRenderers.requestRender(view)`。
105
+ */
106
+ protected abstract requestRepaint(): void;
107
+
108
+ /** 宿主方位角(度);宿主无关默认 0(灯光不跟随视角) */
109
+ protected getBearing(): number {
110
+ return 0;
111
+ }
112
+
113
+ /* ----------------------------------------------------------------- 生命周期 */
114
+
115
+ /** 挂载渲染内核到宿主的 canvas + 共享 GL 上下文(上下文恢复时原地换新 renderer) */
116
+ protected mountViewer(canvas: HTMLCanvasElement, gl: WebGLRenderingContext | WebGL2RenderingContext): void {
117
+ if (this.viewer.renderer) {
118
+ // 上下文恢复路径:onAdd 被再次触发(map.addLayer),原地换新 renderer,
119
+ // 保留已加载的场景与瓦片,不重建、不重拉。
120
+ this.viewer.restoreContext(gl);
121
+ } else {
122
+ this.viewer.mount(canvas, gl);
123
+ }
124
+ }
125
+
126
+ /** 卸载渲染内核(保留已加载的瓦片,下次 mount 可复用) */
127
+ protected unmountViewer(): void {
128
+ this.viewer.unmount();
129
+ }
130
+
131
+ /* ----------------------------------------------------------------- 每帧驱动 */
132
+
133
+ /**
134
+ * 把宿主抽好的矩阵喂给内核,并按需请求下一帧。
135
+ * 返回内核是否需要继续驱动(动态着色 / 瓦片加载中 / LOD 变化)。
136
+ */
137
+ protected driveFrame(frame: HostFrame): boolean {
138
+ const needsNext = this.viewer.renderFrame(frame);
139
+ if (needsNext) this.requestRepaint();
140
+ return needsNext;
141
+ }
142
+
143
+ /* ----------------------------------------------------------------- 投影模式 */
144
+
145
+ /**
146
+ * 切换投影模式,可带平滑过渡。
147
+ * 正交只影响本层:底图瓦片、地形、DOM Marker / 弹窗仍走宿主的透视投影。
148
+ * 拾取、选中描边、按单体着色/隐藏/抬高不受影响。
149
+ */
150
+ setProjection(mode: ProjectionMode, transition?: TransitionOptions): void {
151
+ const next = mode === 'orthographic' ? 'orthographic' : 'perspective';
152
+ const targetT = next === 'orthographic' ? 1 : 0;
153
+ const duration = transition?.duration ?? 0;
154
+ const wantAnim = duration > 0 || !!transition?.spring;
155
+
156
+ this._projection = next;
157
+ this._orthoWarned = false;
158
+
159
+ if (!wantAnim) {
160
+ if (this.viewer.animator.has('projection.t')) this.viewer.animator.cancel('projection.t');
161
+ this._projectionT = targetT;
162
+ this.requestRepaint();
163
+ return;
164
+ }
165
+
166
+ this._ensureProjectionChannel();
167
+ this.viewer.animator.to('projection.t', targetT, {
168
+ ...transition,
169
+ duration,
170
+ easing: transition?.easing ?? 'easeInOutCubic',
171
+ onComplete: () => {
172
+ this._projectionT = targetT;
173
+ this.requestRepaint();
174
+ },
175
+ });
176
+ this.requestRepaint();
177
+ }
178
+
179
+ /** 当前投影模式 */
180
+ get projection(): ProjectionMode {
181
+ return this._projection;
182
+ }
183
+
184
+ /** 当前正交插值比例(0 = 纯透视,1 = 纯正交) */
185
+ get projectionT(): number {
186
+ return this._projectionT;
187
+ }
188
+
189
+ /** 正交插值参数通道:由 animator 平滑推进,写回时请求重绘 */
190
+ protected _ensureProjectionChannel(): void {
191
+ if (this.viewer.animator.has('projection.t')) return;
192
+ this.viewer.animator.register('projection.t', {
193
+ dim: 1,
194
+ read: (out) => {
195
+ out[0] = this._projectionT;
196
+ },
197
+ write: (v) => {
198
+ this._projectionT = v[0];
199
+ this.requestRepaint();
200
+ },
201
+ });
202
+ }
203
+
204
+ /* ----------------------------------------------------------------- 近平面缩放 */
205
+
206
+ /** 动态调整近裁剪面缩放(0 < scale < 1)。传 1 / 0 / 非法值 = 恢复宿主原始 near */
207
+ setNearPlaneScale(scale: number): void {
208
+ this._nearPlaneScale = HostRenderer.normalizeNearPlaneScale(scale);
209
+ this.requestRepaint();
210
+ }
211
+
212
+ /** 当前近裁剪面缩放系数(1 = 不改,用宿主的默认值) */
213
+ get nearPlaneScale(): number {
214
+ return this._nearPlaneScale;
215
+ }
216
+
217
+ /** 近裁剪缩放系数的合法化:非数字 / <=0 / >=1 一律视为「不改」 */
218
+ static normalizeNearPlaneScale(v: number | undefined): number {
219
+ if (typeof v !== 'number' || !Number.isFinite(v)) return 1;
220
+ return v > 0 && v < 1 ? v : 1;
221
+ }
222
+ }