@eturnity/eturnity_3d 9.10.1 → 9.13.0-google3dTile.0

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@eturnity/eturnity_3d",
3
3
  "private": false,
4
- "version": "9.10.1",
4
+ "version": "9.13.0-google3dTile.0",
5
5
  "files": [
6
6
  "dist",
7
7
  "src"
@@ -16,8 +16,9 @@
16
16
  "merge-remote-master": "node scripts/merge-remote-master.js"
17
17
  },
18
18
  "dependencies": {
19
- "@eturnity/eturnity_maths": "9.10.0",
19
+ "@eturnity/eturnity_maths": "9.13.0",
20
20
  "@originjs/vite-plugin-commonjs": "1.0.3",
21
+ "3d-tiles-renderer": "^0.4.22",
21
22
  "core-js": "3.30.2",
22
23
  "cors": "2.8.5",
23
24
  "earcut": "2.2.4",
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Google 3D Tiles Overlay
3
+ *
4
+ * Loads 3D Tiles from Google's Photorealistic 3D Tiles API and displays them
5
+ * in the Three.js scene. The scene origin is at (centerLat, centerLon) at ground level,
6
+ * with an ENU coordinate system: X = East, Y = North, Z = Up, in meters.
7
+ *
8
+ * Integrates with the existing Overlay system so it can be rendered via renderOnThreeJS
9
+ * and updated every frame via userData.update on the mesh.
10
+ * Extends ThreeDModelOverlay to reuse getProjectedPlanesOnOverlay for roof projection.
11
+ */
12
+
13
+ import ThreeDModelOverlay from './ThreeDModelOverlay'
14
+ import * as THREE from 'three'
15
+ import { TilesRenderer } from '3d-tiles-renderer'
16
+ import {
17
+ TilesFadePlugin,
18
+ GoogleCloudAuthPlugin,
19
+ ReorientationPlugin,
20
+ TileCompressionPlugin,
21
+ } from '3d-tiles-renderer/plugins'
22
+
23
+ /** Set DoubleSide on every mesh material in a loaded 3D Tiles scene */
24
+ function applyDoubleSideToTileScene(scene) {
25
+ if (!scene) return
26
+ scene.traverse((object) => {
27
+ if (!object.isMesh || !object.material) return
28
+ const materials = Array.isArray(object.material)
29
+ ? object.material
30
+ : [object.material]
31
+ for (const mat of materials) {
32
+ if (mat) mat.side = THREE.DoubleSide
33
+ }
34
+ })
35
+ }
36
+
37
+ /**
38
+ * Max screen-space error for tile refinement (3d-tiles-renderer).
39
+ * Lower = finer meshes for tiles inside {@link Google3DTilesOverlay#_tilesCamera}'s frustum.
40
+ * GoogleCloudAuthPlugin sets 20 for broad views; we override for this fixed ortho footprint.
41
+ */
42
+ const TILES_FRUSTUM_ERROR_TARGET = 4
43
+
44
+ /** Same rhythm as ThreeDModelOverlay.animatePlaceholder (sin² on time) */
45
+ function placeholderBounceScale(timeSeconds) {
46
+ const baseRadius = 0.65
47
+ const amplitude = 0.55
48
+ return baseRadius + amplitude * Math.sin(timeSeconds * 0.7 * Math.PI) ** 2
49
+ }
50
+
51
+ export default class Google3DTilesOverlay extends ThreeDModelOverlay {
52
+ constructor(overlay, emit, origin) {
53
+ super(overlay, emit, origin || {}, null)
54
+ this.hasToBeSavedInBE = false
55
+ this.origin = origin || {}
56
+ this.tilesData = null
57
+ this._initPromise = null
58
+ // Center for ReorientationPlugin: use settings or origin (degrees → radians)
59
+ const lat = this.settings?.centerLat ?? this.origin?.lat
60
+ const lon = this.settings?.centerLon ?? this.origin?.lng
61
+ this._centerLatRad =
62
+ lat != null ? lat * (Math.PI / 180) : 35.6586 * (Math.PI / 180)
63
+ this._centerLonRad =
64
+ lon != null ? lon * (Math.PI / 180) : 139.7454 * (Math.PI / 180)
65
+ // Ground elevation in meters from Google Elevation API (used to offset tiles to ground level)
66
+ this._groundElevation = this.settings?.groundElevation ?? null
67
+ // Dedicated camera for tile loading and resolution so the user's camera does not affect which tiles load
68
+ // Orthographic camera looking down (Z up), covering -50 to +50 meters in X and Y
69
+ this._tilesCamera = new THREE.OrthographicCamera(
70
+ -50,
71
+ 50,
72
+ 50,
73
+ -50,
74
+ 0.1,
75
+ 10000
76
+ )
77
+ this._tilesCamera.position.set(0, 0, -100)
78
+ this._tilesCamera.lookAt(0, 0, 0)
79
+ this._tilesCamera.updateMatrixWorld(true)
80
+ const raycaster = new THREE.Raycaster()
81
+ const raycasterOrigin = new THREE.Vector3(0, 0, -100)
82
+ const direction = new THREE.Vector3(0, 0, 1)
83
+ raycaster.far = 1000000
84
+ raycaster.near = 1
85
+ raycaster.set(raycasterOrigin, direction)
86
+ this._raycaster = raycaster
87
+ this.sphereMesh = null
88
+ /** Transparent bouncing sphere at origin; removed once ground align settles */
89
+ this._groundAlignPlaceholderMesh = null
90
+ /** True when raycast tile bbox min.z is within ±10 after stick-to-ground */
91
+ this._tilesGroundAligned = false
92
+ }
93
+ getOverlayMeshForRaycast() {
94
+ return this.tilesData?.tiles.group
95
+ }
96
+ async initialiseModel() {
97
+ this.isReady = true
98
+ return true
99
+ }
100
+
101
+ /**
102
+ * Cast a vertical ray at the origin pointing down and return the intersection height.
103
+ * @returns {number|null} - The z coordinate of the first intersection, or null if none
104
+ */
105
+ raycastAtOrigin() {
106
+ const { tilesWrapper } = this.tilesData || {}
107
+ if (!tilesWrapper) return null
108
+
109
+ tilesWrapper.updateMatrixWorld(true)
110
+
111
+ const intersects = this._raycaster.intersectObject(tilesWrapper, true)
112
+ if (intersects.length > 0) {
113
+ if (intersects[0].point.z < 30) {
114
+ this._tilesGroundAligned = true
115
+ }
116
+ return intersects[0]
117
+ }
118
+ return null
119
+ }
120
+
121
+ _getOrCreateTilesData() {
122
+ if (this.tilesData) return this.tilesData
123
+ const apiKey = process.env.VUE_APP_GOOGLE_MAP_API_KEY
124
+ const tiles = new TilesRenderer()
125
+ tiles.registerPlugin(
126
+ new GoogleCloudAuthPlugin({
127
+ apiToken: apiKey,
128
+ sessionOptions: null,
129
+ autoRefreshToken: true,
130
+ useRecommendedSettings: true,
131
+ })
132
+ )
133
+ tiles.registerPlugin(
134
+ new ReorientationPlugin({
135
+ lat: this._centerLatRad,
136
+ lon: this._centerLonRad,
137
+ })
138
+ )
139
+ tiles.registerPlugin(new TileCompressionPlugin())
140
+ tiles.registerPlugin(new TilesFadePlugin({ maximumFadeOutTiles: 50 }))
141
+
142
+ // After GoogleCloudAuthPlugin.init (errorTarget 20), tighten LOD for the ortho frustum only
143
+ tiles.errorTarget = TILES_FRUSTUM_ERROR_TARGET
144
+ // Prefer loading tiles that intersect _tilesCamera before prefetch / out-of-frustum work
145
+ tiles.optimizedLoadStrategy = true
146
+
147
+ tiles.addEventListener('load-model', ({ scene }) => {
148
+ applyDoubleSideToTileScene(scene)
149
+ })
150
+
151
+ tiles.lruCache.minSize = 400
152
+ tiles.lruCache.maxSize = 800
153
+ tiles.parseQueue.maxJobs = 4
154
+
155
+ const tilesWrapper = new THREE.Group()
156
+ tilesWrapper.name = 'Google3DTilesOverlay'
157
+ tilesWrapper.rotation.x = Math.PI / 2
158
+ tilesWrapper.rotation.y = Math.PI
159
+ tilesWrapper.add(tiles.group)
160
+ tilesWrapper.position.z = -200
161
+ this.tilesData = { tiles, tilesWrapper }
162
+ return this.tilesData
163
+ }
164
+
165
+ _ensureGroundAlignPlaceholder(threeJSComponent) {
166
+ if (this._tilesGroundAligned || !threeJSComponent?.scene) return
167
+ const { scene } = threeJSComponent
168
+ if (this._groundAlignPlaceholderMesh) {
169
+ if (scene.getObjectById(this._groundAlignPlaceholderMesh.id)) return
170
+ this._groundAlignPlaceholderMesh.geometry?.dispose()
171
+ if (this._groundAlignPlaceholderMesh.material?.dispose) {
172
+ this._groundAlignPlaceholderMesh.material.dispose()
173
+ }
174
+ this._groundAlignPlaceholderMesh = null
175
+ }
176
+ const geometry = new THREE.SphereGeometry(10, 32, 32)
177
+ const material = new THREE.MeshBasicMaterial({
178
+ color: 0xffffff,
179
+ transparent: true,
180
+ opacity: 0.2,
181
+ depthWrite: false,
182
+ })
183
+ const mesh = new THREE.Mesh(geometry, material)
184
+ mesh.name = 'Google3DTilesGroundAlignPlaceholder'
185
+ mesh.userData.googleTilesGroundAlignPlaceholder = true
186
+ mesh.position.set(0, 0, 0)
187
+ mesh.scale.setScalar(1)
188
+ scene.add(mesh)
189
+ this._groundAlignPlaceholderMesh = mesh
190
+ this._tickGroundAlignPlaceholderAnimation()
191
+ }
192
+
193
+ _tickGroundAlignPlaceholderAnimation() {
194
+ const mesh = this._groundAlignPlaceholderMesh
195
+ if (!mesh?.material) return
196
+
197
+ if (this.opacity === 0) {
198
+ mesh.visible = false
199
+ mesh.material.opacity = 0
200
+ return
201
+ }
202
+
203
+ mesh.visible = true
204
+ const t = performance.now() * 0.001
205
+ const scale = placeholderBounceScale(t)
206
+ mesh.scale.setScalar(scale)
207
+ // Light Z bob so it reads as a bounce (Z up)
208
+ mesh.position.z = 0.2 * Math.sin(t * 1.4 * Math.PI) ** 2
209
+ mesh.position.x = 0
210
+ mesh.position.y = 0
211
+ mesh.material.opacity = 0.06 + 0.22 * Math.sin(t * 0.85 * Math.PI) ** 2
212
+ }
213
+
214
+ _removeGroundAlignPlaceholder(threeJSComponent) {
215
+ const mesh = this._groundAlignPlaceholderMesh
216
+ if (!mesh) return
217
+ const scene = threeJSComponent?.scene
218
+ if (scene && scene.getObjectById(mesh.id)) {
219
+ scene.remove(mesh)
220
+ } else {
221
+ mesh.parent?.remove(mesh)
222
+ }
223
+ mesh.geometry?.dispose()
224
+ if (mesh.material?.dispose) mesh.material.dispose()
225
+ this._groundAlignPlaceholderMesh = null
226
+ }
227
+
228
+ _syncGroundAlignPlaceholder(threeJSComponent) {
229
+ if (!threeJSComponent?.scene) return
230
+ if (this._tilesGroundAligned) {
231
+ this._removeGroundAlignPlaceholder(threeJSComponent)
232
+ } else {
233
+ this._ensureGroundAlignPlaceholder(threeJSComponent)
234
+ }
235
+ }
236
+
237
+ stickObjectToTheGround() {
238
+ const { tiles, tilesWrapper } = this.tilesData || {}
239
+ if (!tiles || !tilesWrapper) {
240
+ this._tilesGroundAligned = false
241
+ return
242
+ }
243
+
244
+ const raycastIntersect = this.raycastAtOrigin()
245
+ if (!raycastIntersect) {
246
+ this._tilesGroundAligned = false
247
+ return
248
+ }
249
+
250
+ const tileMesh = raycastIntersect.object
251
+ const geom = tileMesh.geometry
252
+ if (!geom) {
253
+ this._tilesGroundAligned = false
254
+ return
255
+ }
256
+ if (!geom.boundingBox) geom.computeBoundingBox()
257
+
258
+ const boundingBox = new THREE.Box3()
259
+ .copy(geom.boundingBox)
260
+ .applyMatrix4(tileMesh.matrixWorld)
261
+ if (
262
+ Math.abs(boundingBox.min.z) > 1 &&
263
+ Math.abs(tilesWrapper.position.z - boundingBox.min.z) < 2000
264
+ ) {
265
+ tilesWrapper.position.z -= boundingBox.min.z
266
+ }
267
+
268
+ tilesWrapper.updateMatrixWorld(true)
269
+ }
270
+ async renderOnThreeJS(threeJSComponent) {
271
+ const tilesData = this._getOrCreateTilesData()
272
+ if (!tilesData) return null
273
+
274
+ const { tiles, tilesWrapper } = tilesData
275
+ const { renderer, scene } = threeJSComponent
276
+ tiles.setCamera(this._tilesCamera)
277
+ tiles.setResolutionFromRenderer(this._tilesCamera, renderer)
278
+
279
+ tilesWrapper.userData.type = 'overlayMeshes'
280
+ tilesWrapper.userData.meshId = this.id
281
+ tilesWrapper.userData.version = this.version
282
+
283
+ this.stickObjectToTheGround()
284
+ this._syncGroundAlignPlaceholder(threeJSComponent)
285
+ if (this._groundAlignPlaceholderMesh) {
286
+ this._tickGroundAlignPlaceholderAnimation()
287
+ }
288
+
289
+ tilesWrapper.userData.update = () => {
290
+ if (!tiles || !renderer) return
291
+ tiles.errorTarget = TILES_FRUSTUM_ERROR_TARGET
292
+ tiles.setCamera(this._tilesCamera)
293
+ tiles.setResolutionFromRenderer(this._tilesCamera, renderer)
294
+ tiles.update()
295
+ this.stickObjectToTheGround()
296
+ this._syncGroundAlignPlaceholder(threeJSComponent)
297
+ if (this._groundAlignPlaceholderMesh) {
298
+ this._tickGroundAlignPlaceholderAnimation()
299
+ }
300
+ }
301
+
302
+ if (tilesWrapper.parent !== scene) {
303
+ if (tilesWrapper.parent) tilesWrapper.parent.remove(tilesWrapper)
304
+ scene.add(tilesWrapper)
305
+ }
306
+
307
+ threeJSComponent.meshes.overlayMeshes[this.id] = tilesWrapper
308
+ this.overlayMesh = tilesWrapper
309
+ return tilesWrapper
310
+ }
311
+
312
+ renderOnPaperJS() {
313
+ // No 2D representation for 3D Tiles overlay
314
+ }
315
+
316
+ serializeSettings() {
317
+ return {
318
+ ...super.serializeSettings(),
319
+ centerLat: this.origin?.lat ?? this.settings?.centerLat,
320
+ centerLon: this.origin?.lng ?? this.settings?.centerLon,
321
+ groundElevation: this._groundElevation,
322
+ }
323
+ }
324
+
325
+ dispose() {
326
+ if (this._groundAlignPlaceholderMesh) {
327
+ this._groundAlignPlaceholderMesh.parent?.remove(
328
+ this._groundAlignPlaceholderMesh
329
+ )
330
+ this._groundAlignPlaceholderMesh.geometry?.dispose()
331
+ if (this._groundAlignPlaceholderMesh.material?.dispose) {
332
+ this._groundAlignPlaceholderMesh.material.dispose()
333
+ }
334
+ this._groundAlignPlaceholderMesh = null
335
+ }
336
+ if (this.tilesData?.tiles) {
337
+ this.tilesData.tiles.dispose()
338
+ }
339
+ this.tilesData = null
340
+ this.overlayMesh = null
341
+ this._initPromise = null
342
+ this._tilesGroundAligned = false
343
+ }
344
+ }
@@ -2,6 +2,7 @@ import Overlay from './Overlay'
2
2
  import GLBOverlay from './GLBOverlay'
3
3
  import ImageOverlay from './ImageOverlay'
4
4
  import YearlySunShadingOverlay from './YearlySunShadingOverlay'
5
+ import Google3DTilesOverlay from './Google3DTilesOverlay'
5
6
 
6
7
  const OverlayFactory = {
7
8
  createOverlay(serializedOverlay, emit, origin = { lat: null, lng: null }) {
@@ -12,6 +13,8 @@ const OverlayFactory = {
12
13
  overlayInstance = new ImageOverlay(serializedOverlay, emit)
13
14
  } else if (serializedOverlay.type == 'yearly_sun_shading') {
14
15
  overlayInstance = new YearlySunShadingOverlay(serializedOverlay, emit)
16
+ } else if (serializedOverlay.type == 'google_3d_tiles') {
17
+ overlayInstance = new Google3DTilesOverlay(serializedOverlay, emit, origin)
15
18
  } else {
16
19
  overlayInstance = new Overlay(serializedOverlay, emit)
17
20
  }
@@ -350,8 +350,24 @@ export default class ThreeDModelOverlay extends Overlay {
350
350
  resolve(imageUrl)
351
351
  })
352
352
  }
353
+ /** Override in subclasses to use a different mesh (e.g. tiles wrapper) or null when not yet ready */
354
+ getOverlayMeshForRaycast() {
355
+ return this.overlayMesh
356
+ }
357
+
358
+ /** Override in subclasses to raycast into child meshes (e.g. for Group-based overlays like 3D Tiles) */
359
+ getRaycastRecursive() {
360
+ return false
361
+ }
362
+
353
363
  getProjectedPlanesOnOverlay(polygons) {
364
+ const mesh = this.getOverlayMeshForRaycast()
365
+ if (!mesh) {
366
+ return polygons.map(() => null)
367
+ }
368
+ mesh.updateMatrixWorld(true)
354
369
  const RayCaster = new THREE.Raycaster()
370
+ const recursive = this.getRaycastRecursive()
355
371
  const cloudsPoints = polygons.map((polygon) => {
356
372
  const outline = polygon.outline
357
373
  const holeOutlines = polygon.holes.map((h) => h.outline)
@@ -368,8 +384,8 @@ export default class ThreeDModelOverlay extends Overlay {
368
384
  let { xMin, xMax, yMin, yMax } = outlineBounds
369
385
  const cloudPoints = []
370
386
  let stepNum = 8
371
- for (let i = 1; i < stepNum; i++) {
372
- for (let j = 1; j < stepNum; j++) {
387
+ for (let i = 1; i < stepNum - 1; i++) {
388
+ for (let j = 1; j < stepNum - 1; j++) {
373
389
  const testPoint = {
374
390
  x: xMin + (i * (xMax - xMin)) / stepNum,
375
391
  y: yMin + (j * (yMax - yMin)) / stepNum,
@@ -384,7 +400,7 @@ export default class ThreeDModelOverlay extends Overlay {
384
400
  new THREE.Vector3(testPoint.x / 1000, testPoint.y / 1000, 100),
385
401
  new THREE.Vector3(0, 0, -1)
386
402
  )
387
- let intersects = RayCaster.intersectObject(this.overlayMesh)
403
+ let intersects = RayCaster.intersectObject(mesh, recursive)
388
404
  if (intersects.length > 0) {
389
405
  cloudPoints.push(intersects[0].point)
390
406
  }
@@ -744,6 +744,23 @@ export default {
744
744
  waitingBall.scale.set(scale, scale, scale)
745
745
  this.render()
746
746
  }
747
+ // Per-frame updates for overlays (e.g. Google 3D Tiles)
748
+ const overlayMeshes = this.meshes?.overlayMeshes
749
+ let overlayUpdated = false
750
+ if (overlayMeshes) {
751
+ Object.values(overlayMeshes).forEach((mesh) => {
752
+ if (
753
+ mesh?.userData?.update &&
754
+ typeof mesh.userData.update === 'function'
755
+ ) {
756
+ mesh.userData.update()
757
+ overlayUpdated = true
758
+ }
759
+ })
760
+ }
761
+ if (overlayUpdated) {
762
+ this.render()
763
+ }
747
764
  this.animationFrameId = requestAnimationFrame(this.animate)
748
765
  },
749
766
  },