s3m-three 0.4.27 → 0.4.28

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s3m-three",
3
- "version": "0.4.27",
3
+ "version": "0.4.28",
4
4
  "description": "SuperMap S3M 三维模型格式的 three.js 渲染内核:浏览器内直接解析 .s3mb 瓦片,无需转 3D Tiles,支持 LOD 调度、白膜/按高度/按单体/动态扫描着色、单体级 GPU 拾取。宿主无关,可单独用于任意 three 场景。",
5
5
  "type": "module",
6
6
  "repository": {
@@ -62,6 +62,7 @@
62
62
  */
63
63
  import { BinaryReader } from './BinaryReader.js';
64
64
  import type {
65
+ Geode,
65
66
  GeoPackageEntry,
66
67
  GroupNode,
67
68
  IndexPackage,
@@ -124,7 +125,13 @@ interface XmlMaterial {
124
125
  addressMode?: { u: number; v: number };
125
126
  }
126
127
 
127
- interface XmlLod {
128
+ export interface XmlGeode {
129
+ geoName: string;
130
+ matrix: number[];
131
+ }
132
+
133
+ export interface XmlLod {
134
+ geodes?: XmlGeode[];
128
135
  geoNames: string[];
129
136
  matrix: number[];
130
137
  childTile: string;
@@ -158,7 +165,7 @@ function rangeModeOf(text: string | null): number {
158
165
  return 1;
159
166
  }
160
167
 
161
- function parseXmlLods(xml: string): XmlLod[] {
168
+ export function parseXmlLods(xml: string): XmlLod[] {
162
169
  const blocks = [...xml.matchAll(/<(?:[A-Za-z0-9_-]+:)?PagedLOD[^>]*>([\s\S]*?)<\/(?:[A-Za-z0-9_-]+:)?PagedLOD>/gi)];
163
170
  const out: XmlLod[] = [];
164
171
  for (const b of blocks) {
@@ -180,6 +187,39 @@ function parseXmlLods(xml: string): XmlLod[] {
180
187
  radius: num(tag(sphere, 'radius')),
181
188
  });
182
189
  }
190
+ // 兜底:S3M 1.x 叶子瓦片没有 <PagedLOD>,<PageLods> 内部直接是 <Geode>
191
+ if (out.length === 0) {
192
+ const pageLodsBlock = /<(?:[A-Za-z0-9_-]+:)?PageLods[^>]*>([\s\S]*?)<\/(?:[A-Za-z0-9_-]+:)?PageLods>/i.exec(xml);
193
+ const scope = pageLodsBlock ? pageLodsBlock[1] : xml;
194
+ const geodeBlocks = [...scope.matchAll(/<(?:[A-Za-z0-9_-]+:)?Geode[^>]*>([\s\S]*?)<\/(?:[A-Za-z0-9_-]+:)?Geode>/gi)];
195
+ if (geodeBlocks.length > 0) {
196
+ const geodes: XmlGeode[] = [];
197
+ for (const gb of geodeBlocks) {
198
+ const gs = gb[1];
199
+ const gName = tag(gs, "GeoName") ?? "";
200
+ const matStr = tag(gs, "GeoDeModMatrix");
201
+ const mat = (matStr ?? "1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1")
202
+ .split(",")
203
+ .map((x) => parseFloat(x.trim()))
204
+ .filter((x) => Number.isFinite(x));
205
+ geodes.push({
206
+ geoName: gName,
207
+ matrix: mat.length === 16 ? mat : [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
208
+ });
209
+ }
210
+ out.push({
211
+ geoNames: geodes.map((g) => g.geoName).filter(Boolean),
212
+ matrix: geodes[0]?.matrix ?? [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
213
+ geodes,
214
+ childTile: "",
215
+ rangeMode: rangeModeOf(null), // 1 = 屏幕像素
216
+ rangeValue: 0,
217
+ center: { x: 0, y: 0, z: 0 },
218
+ radius: 0,
219
+ });
220
+ }
221
+ }
222
+
183
223
  return out;
184
224
  }
185
225
 
@@ -645,6 +685,108 @@ function readTextures(br: BinaryReader, texSize: number): Record<string, S3MText
645
685
  let warnedV1Attrs = false;
646
686
  let warnedQuantBox = false;
647
687
 
688
+ function computeLodBoundingSphere(
689
+ geodes: { matrix: number[]; skeletonNames: string[] }[],
690
+ meshes: MeshRecord[]
691
+ ): { center: { x: number; y: number; z: number }; radius: number } | null {
692
+ const meshMap = new Map(meshes.map((m) => [`SuperMapCache#${m.geoName}`, m]));
693
+ let minX = Infinity, maxX = -Infinity;
694
+ let minY = Infinity, maxY = -Infinity;
695
+ let minZ = Infinity, maxZ = -Infinity;
696
+ let totalPoints = 0;
697
+
698
+ for (const geode of geodes) {
699
+ const mat = geode.matrix;
700
+ const isIdentity =
701
+ !mat ||
702
+ (mat[0] === 1 && mat[1] === 0 && mat[2] === 0 && mat[3] === 0 &&
703
+ mat[4] === 0 && mat[5] === 1 && mat[6] === 0 && mat[7] === 0 &&
704
+ mat[8] === 0 && mat[9] === 0 && mat[10] === 1 && mat[11] === 0 &&
705
+ mat[12] === 0 && mat[13] === 0 && mat[14] === 0 && mat[15] === 1);
706
+
707
+ for (const name of geode.skeletonNames) {
708
+ const mesh = meshMap.get(name);
709
+ if (!mesh || !mesh.position) continue;
710
+ const pos = mesh.position;
711
+ totalPoints += pos.length / 3;
712
+
713
+ if (isIdentity) {
714
+ for (let i = 0; i < pos.length; i += 3) {
715
+ const x = pos[i]!, y = pos[i + 1]!, z = pos[i + 2]!;
716
+ if (x < minX) minX = x; if (x > maxX) maxX = x;
717
+ if (y < minY) minY = y; if (y > maxY) maxY = y;
718
+ if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
719
+ }
720
+ } else {
721
+ const m0 = mat[0]!, m1 = mat[1]!, m2 = mat[2]!;
722
+ const m4 = mat[4]!, m5 = mat[5]!, m6 = mat[6]!;
723
+ const m8 = mat[8]!, m9 = mat[9]!, m10 = mat[10]!;
724
+ const m12 = mat[12]!, m13 = mat[13]!, m14 = mat[14]!;
725
+ for (let i = 0; i < pos.length; i += 3) {
726
+ const x = pos[i]!, y = pos[i + 1]!, z = pos[i + 2]!;
727
+ const tx = m0 * x + m4 * y + m8 * z + m12;
728
+ const ty = m1 * x + m5 * y + m9 * z + m13;
729
+ const tz = m2 * x + m6 * y + m10 * z + m14;
730
+ if (tx < minX) minX = tx; if (tx > maxX) maxX = tx;
731
+ if (ty < minY) minY = ty; if (ty > maxY) maxY = ty;
732
+ if (tz < minZ) minZ = tz; if (tz > maxZ) maxZ = tz;
733
+ }
734
+ }
735
+ }
736
+ }
737
+
738
+ if (totalPoints === 0 || !Number.isFinite(minX)) return null;
739
+
740
+ const cx = (minX + maxX) * 0.5;
741
+ const cy = (minY + maxY) * 0.5;
742
+ const cz = (minZ + maxZ) * 0.5;
743
+
744
+ let maxR2 = 0;
745
+ for (const geode of geodes) {
746
+ const mat = geode.matrix;
747
+ const isIdentity =
748
+ !mat ||
749
+ (mat[0] === 1 && mat[1] === 0 && mat[2] === 0 && mat[3] === 0 &&
750
+ mat[4] === 0 && mat[5] === 1 && mat[6] === 0 && mat[7] === 0 &&
751
+ mat[8] === 0 && mat[9] === 0 && mat[10] === 1 && mat[11] === 0 &&
752
+ mat[12] === 0 && mat[13] === 0 && mat[14] === 0 && mat[15] === 1);
753
+
754
+ for (const name of geode.skeletonNames) {
755
+ const mesh = meshMap.get(name);
756
+ if (!mesh || !mesh.position) continue;
757
+ const pos = mesh.position;
758
+
759
+ if (isIdentity) {
760
+ for (let i = 0; i < pos.length; i += 3) {
761
+ const dx = pos[i]! - cx, dy = pos[i + 1]! - cy, dz = pos[i + 2]! - cz;
762
+ const r2 = dx * dx + dy * dy + dz * dz;
763
+ if (r2 > maxR2) maxR2 = r2;
764
+ }
765
+ } else {
766
+ const m0 = mat[0]!, m1 = mat[1]!, m2 = mat[2]!;
767
+ const m4 = mat[4]!, m5 = mat[5]!, m6 = mat[6]!;
768
+ const m8 = mat[8]!, m9 = mat[9]!, m10 = mat[10]!;
769
+ const m12 = mat[12]!, m13 = mat[13]!, m14 = mat[14]!;
770
+ for (let i = 0; i < pos.length; i += 3) {
771
+ const x = pos[i]!, y = pos[i + 1]!, z = pos[i + 2]!;
772
+ const tx = m0 * x + m4 * y + m8 * z + m12;
773
+ const ty = m1 * x + m5 * y + m9 * z + m13;
774
+ const tz = m2 * x + m6 * y + m10 * z + m14;
775
+ const dx = tx - cx, dy = ty - cy, dz = tz - cz;
776
+ const r2 = dx * dx + dy * dy + dz * dz;
777
+ if (r2 > maxR2) maxR2 = r2;
778
+ }
779
+ }
780
+ }
781
+ }
782
+
783
+ const radius = Math.sqrt(maxR2);
784
+ return {
785
+ center: { x: cx, y: cy, z: cz },
786
+ radius: radius > 0 ? radius : 0.001,
787
+ };
788
+ }
789
+
648
790
  export async function parseS3M1(arrayBuffer: ArrayBuffer, options: ParseS3M1Options = {}): Promise<ParsedTile> {
649
791
  const inflate = options.inflate ?? defaultInflate;
650
792
  const r = new BinaryReader(arrayBuffer);
@@ -770,17 +912,45 @@ export async function parseS3M1(arrayBuffer: ArrayBuffer, options: ParseS3M1Opti
770
912
  const xmlLods = parseXmlLods(xml);
771
913
  const pageLods: PageLOD[] = [];
772
914
  xmlLods.forEach((lod, i) => {
773
- const named = lod.geoNames.map((n) => keyOf.get(n)).filter((k): k is string => !!k);
774
- // 名字对不上时退回位置匹配,并让上层有机会发现(宁可少一个 LOD 也不要挂错球)
775
- const fallback = meshes[i] ? [`SuperMapCache#${meshes[i].geoName}`] : [];
776
- const skeletonNames = named.length ? [...new Set(named)] : fallback;
777
- if (!skeletonNames.length) return;
915
+ let geodes: Geode[];
916
+ if (lod.geodes && lod.geodes.length > 0) {
917
+ geodes = lod.geodes
918
+ .map((g, gi) => {
919
+ const key = keyOf.get(g.geoName);
920
+ const fallback = meshes[gi] ? [`SuperMapCache#${meshes[gi].geoName}`] : [];
921
+ const skeletonNames = key ? [key] : fallback;
922
+ return {
923
+ matrix: g.matrix,
924
+ skeletonNames,
925
+ };
926
+ })
927
+ .filter((g) => g.skeletonNames.length > 0);
928
+ } else {
929
+ const named = lod.geoNames.map((n) => keyOf.get(n)).filter((k): k is string => !!k);
930
+ // 名字对不上时退回位置匹配,并让上层有机会发现(宁可少一个 LOD 也不要挂错球)
931
+ const fallback = meshes[i] ? [`SuperMapCache#${meshes[i].geoName}`] : [];
932
+ const skeletonNames = named.length ? [...new Set(named)] : fallback;
933
+ geodes = skeletonNames.length ? [{ matrix: lod.matrix, skeletonNames }] : [];
934
+ }
935
+ if (!geodes.length) return;
936
+
937
+ let center = { ...lod.center };
938
+ let radius = lod.radius;
939
+ // 缺包围球(如叶子瓦片)时,从网格顶点实时计算真实几何包围球
940
+ if (radius <= 0) {
941
+ const computed = computeLodBoundingSphere(geodes, meshes);
942
+ if (computed) {
943
+ center = computed.center;
944
+ radius = computed.radius;
945
+ }
946
+ }
947
+
778
948
  pageLods.push({
779
949
  rangeValue: lod.rangeValue,
780
950
  rangeMode: lod.rangeMode,
781
- boundingSphere: { center: { ...lod.center }, radius: lod.radius },
951
+ boundingSphere: { center, radius },
782
952
  childTile: lod.childTile,
783
- geodes: [{ matrix: lod.matrix, skeletonNames }],
953
+ geodes,
784
954
  });
785
955
  });
786
956
  const groupNode: GroupNode = { pageLods };
@@ -1737,16 +1737,29 @@ export class S3MViewer {
1737
1737
 
1738
1738
  private _registerTile(node: TileNode, entry: S3MDatasetEntry): void {
1739
1739
  // 必须在 add / updateMatrixWorld 之前设置:后者会拿当前 matrix 算 matrixWorld 并缓存
1740
- if (this._perTilePlacement && node.sphere) {
1741
- this._applyTilePlacement(node.group, node.sphere.center, entry);
1742
- } else {
1740
+ //
1741
+ // ★ 触发条件**不能**是「有没有包围球」。S3M 1.x 的叶子瓦片 XML 是
1742
+ // `<PageLods><Geode>`(外面没有 `<PagedLOD>` 包装),`parseXmlLods` 解析出来
1743
+ // pageLods 为空 → sphere 为 null。而 `entry.root` 上挂着 align 算出来的整体偏移
1744
+ // (索菲亚教堂是 heightRange.min = 119.84),那个偏移是靠 placement 里的
1745
+ // `- datasetOffset` 抵消的。少摆一块,这块就带着偏移显示 —— 实测 L23 整层
1746
+ // 因此沉到地下 120 m,且 `localZ - worldZ` 恒等于偏移量。
1747
+ // 缺球时用几何包围盒中心兜底;连几何都没有就退回旧口径。
1748
+ const center = node.sphere?.center ?? this._localCenterOf(node.group);
1749
+ if (this._perTilePlacement && center) {
1750
+ this._applyTilePlacement(node.group, center, entry);
1751
+ }
1752
+ // 上面没摆成(关掉本功能 / 缺锚点 / 几何为空)时 matrixAutoUpdate 仍是 true,走旧口径
1753
+ if (node.group.matrixAutoUpdate) {
1743
1754
  const sceneAnchor = this.sceneAnchor;
1744
1755
  if (sceneAnchor) {
1745
1756
  const sEast = sceneAnchor.meterInMercatorEast ?? sceneAnchor.meterInMercator;
1746
1757
  const sNorth = sceneAnchor.meterInMercatorNorth ?? sceneAnchor.meterInMercator;
1747
1758
  const sUp = sceneAnchor.meterInMercatorUp ?? sEast;
1748
- const center = node.sphere ? ([node.sphere.center.x, node.sphere.center.y, node.sphere.center.z] as const) : ([0, 0, 0] as const);
1749
- this._updateTileUniforms(node.group, center, [1.0 / sEast, 1.0 / sNorth, 1.0 / sUp]);
1759
+ const c = node.sphere
1760
+ ? ([node.sphere.center.x, node.sphere.center.y, node.sphere.center.z] as const)
1761
+ : ([0, 0, 0] as const);
1762
+ this._updateTileUniforms(node.group, c, [1.0 / sEast, 1.0 / sNorth, 1.0 / sUp]);
1750
1763
  }
1751
1764
  }
1752
1765
  entry.root.add(node.group);
@@ -1820,6 +1833,22 @@ export class S3MViewer {
1820
1833
  }
1821
1834
  }
1822
1835
 
1836
+ /**
1837
+ * 瓦片几何在**数据集局部 ENU** 里的包围盒中心,给没有包围球的瓦片兜底。
1838
+ *
1839
+ * 调用时机固定在 `entry.root.add()` 之前:此时 group 还没挂到 root 上、matrix 还是
1840
+ * 单位阵,`setFromObject` 得到的就是顶点自己的坐标系,正好是摆放要的枢轴。
1841
+ * 挂上 root 之后再算会带上 root 的 offset,枢轴就偏了。
1842
+ */
1843
+ private _localCenterOf(group: THREE.Object3D): { x: number; y: number; z: number } | null {
1844
+ if (!group.children.length) return null;
1845
+ const box = new THREE.Box3().setFromObject(group);
1846
+ if (box.isEmpty()) return null;
1847
+ const c = box.getCenter(new THREE.Vector3());
1848
+ if (!Number.isFinite(c.x) || !Number.isFinite(c.y) || !Number.isFinite(c.z)) return null;
1849
+ return { x: c.x, y: c.y, z: c.z };
1850
+ }
1851
+
1823
1852
  /**
1824
1853
  * 把一块瓦片摆到「用它自己中心纬度算出的尺度」上(`perTilePlacement` 开启时才走)。
1825
1854
  *
@@ -951,6 +951,15 @@ export class S3MTileset {
951
951
  victim = node;
952
952
  continue;
953
953
  }
954
+ // 可见性优先于时间戳:没有包围球的瓦片算不出距离,`lastSeen` 天然比别的瓦片旧,
955
+ // 只看时间戳会把「正在显示」的那块先踢掉(开洞 → 回补 → 再踢 = 闪)。
956
+ // 只有不可见的候选分不出来时,才轮到 lastSeen / distance 决定。
957
+ const nodeVisible = node.group.visible ? 1 : 0;
958
+ const victimVisible = victim.group.visible ? 1 : 0;
959
+ if (nodeVisible !== victimVisible) {
960
+ if (nodeVisible < victimVisible) victim = node;
961
+ continue;
962
+ }
954
963
  const a = node.lastSeen ?? -1;
955
964
  const b = victim.lastSeen ?? -1;
956
965
  if (a < b || (a === b && (node.distance ?? 0) > (victim.distance ?? 0))) victim = node;
@@ -1513,6 +1522,11 @@ export class S3MTileset {
1513
1522
 
1514
1523
  for (const node of this.nodes.values()) {
1515
1524
  if (!node.sphere) {
1525
+ // 没有包围球(S3M 1.x 叶子瓦片:XML 是 `<PageLods><Geode>`,没有 PagedLOD)时
1526
+ // 算不出距离,但**仍要记「最近用过」**:`lastSeen` 恒为 undefined 会让它在
1527
+ // `_pickVictim` 里永远排最前(`?? -1`),硬上限一触发 `allowUncovered`,
1528
+ // 第一块被踢掉的就是正在显示的那块 —— 画面立刻一个洞,下一帧又加载回来。
1529
+ node.lastSeen = this._frame;
1516
1530
  node.needRefine = false;
1517
1531
  continue;
1518
1532
  }