t35-tileset-loading.mjs 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. import assert from 'node:assert/strict'
  2. import { createTilesetLoadingController } from '../../../src/modules/cockpit/services/tilesetLoading.js'
  3. function event() {
  4. const listeners = new Set()
  5. return { addEventListener(fn) { listeners.add(fn); return () => listeners.delete(fn) },
  6. emit(...args) { for (const fn of listeners) fn(...args) }, get count() { return listeners.size } }
  7. }
  8. function tile() {
  9. return { show: true, tilesLoaded: false, destroyed: false,
  10. loadProgress: event(), tileVisible: event(), tileFailed: event(),
  11. isDestroyed() { return this.destroyed } }
  12. }
  13. function deferred() {
  14. let resolve, reject
  15. const promise = new Promise((a, b) => { resolve = a; reject = b })
  16. return { promise, resolve, reject }
  17. }
  18. function fixture(plans, overrides = {}) {
  19. let clock = 0, nextId = 0
  20. const timers = new Map()
  21. const state = { started: [], added: [], removed: [], instances: [], failed: [],
  22. pose: { position: [0, 0, 0], direction: [1, 0, 0], up: [0, 0, 1] }, distances: {}, signals: [] }
  23. const controller = createTilesetLoadingController({ plans, instances: state.instances, failed: state.failed,
  24. prepareBounds: async (plan, signal) => { state.signals.push(signal); return { url: plan.url } },
  25. readCamera: () => state.pose, distanceTo: (bounds) => state.distances[bounds.url] ?? 0,
  26. load: async (plan) => { const instance = tile(); state.started.push({ url: plan.url, instance }); return instance },
  27. add: (instance) => state.added.push(instance),
  28. remove: (instance) => { assert.equal(instance.destroyed, false, 'double destruction');
  29. instance.destroyed = true; state.removed.push(instance) },
  30. now: () => clock,
  31. setTimer: (fn, delay) => { const id = ++nextId; timers.set(id, { at: clock + delay, fn }); return id },
  32. clearTimer: (id) => timers.delete(id),
  33. ...overrides,
  34. })
  35. const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve() }
  36. async function advance(ms) {
  37. const until = clock + ms
  38. for (let count = 0; count < 10000; count++) {
  39. const next = [...timers].sort((a, b) => a[1].at - b[1].at)[0]
  40. if (!next || next[1].at > until) break
  41. clock = next[1].at
  42. timers.delete(next[0])
  43. next[1].fn()
  44. await flush()
  45. }
  46. clock = until
  47. await flush()
  48. }
  49. return { ...state, state, controller, advance, flush, timers }
  50. }
  51. const plans = [{ url: 'a', eager: false }, { url: 'b', eager: false }]
  52. // Regression: metadata-ready must NOT release the slot while the first GLB is processing.
  53. {
  54. const f = fixture(plans)
  55. await f.controller.prepare()
  56. await f.advance(600)
  57. assert.deepEqual(f.started.map((x) => x.url), ['a'])
  58. const a = f.started[0].instance
  59. a.loadProgress.emit(0, 4)
  60. f.controller.onRender()
  61. await f.advance(2000)
  62. assert.equal(f.started.length, 1, 'second stream overlapped first GLB processing')
  63. a.tilesLoaded = true
  64. a.loadProgress.emit(0, 0)
  65. a.tileVisible.emit({ content: { trianglesLength: 12 } })
  66. await f.advance(800)
  67. assert.deepEqual(f.started.map((x) => x.url), ['a', 'b'])
  68. f.controller.detach()
  69. }
  70. // Camera rotation, small cumulative moves, and idle delay all matter; altitude is not a switch.
  71. {
  72. const f = fixture(plans)
  73. await f.controller.prepare()
  74. await f.advance(400)
  75. f.state.pose.direction = [0, 1, 0]
  76. f.controller.refresh()
  77. await f.advance(400)
  78. assert.equal(f.started.length, 0, 'rotation was ignored by the idle gate')
  79. f.state.pose.position = [0.1, 0, 0]
  80. f.controller.refresh()
  81. f.state.pose.position = [0.3, 0, 0]
  82. f.controller.refresh()
  83. await f.advance(400)
  84. assert.equal(f.started.length, 0, 'small cumulative moves bypassed the idle gate')
  85. await f.advance(200)
  86. assert.equal(f.started.length, 1)
  87. f.controller.detach()
  88. }
  89. // Coarse fallback stays visible until actual triangles are shown; empty root anchor is not enough.
  90. {
  91. const f = fixture([{ url: 'far', role: 'far', eager: true }, ...plans])
  92. await f.controller.prepare()
  93. const far = f.started[0].instance
  94. far.tilesLoaded = true
  95. f.controller.onRender()
  96. await f.advance(1000)
  97. const fine = f.started[1].instance
  98. f.controller.refresh()
  99. assert.equal(far.show, true, 'JSON initialization hid the fallback')
  100. fine.tilesLoaded = true
  101. fine.tileVisible.emit({ content: { trianglesLength: 0 } })
  102. f.controller.onRender()
  103. f.controller.refresh()
  104. assert.equal(far.show, true, 'empty root anchor hid the fallback')
  105. fine.tileVisible.emit({ content: { trianglesLength: 10 } })
  106. f.controller.onRender()
  107. f.controller.refresh()
  108. assert.equal(far.show, false)
  109. f.controller.onRender()
  110. f.controller.refresh()
  111. assert.equal(far.show, true, 'previously visible content must not hide the fallback after leaving the frustum')
  112. f.controller.detach()
  113. }
  114. // An older, ready tileset may stream again on camera movement. That work blocks a new initial stream.
  115. {
  116. const f = fixture(plans)
  117. await f.controller.prepare()
  118. await f.advance(600)
  119. const a = f.started[0].instance
  120. a.tilesLoaded = true
  121. f.controller.onRender()
  122. await f.advance(400)
  123. a.loadProgress.emit(1, 0)
  124. await f.advance(600)
  125. assert.equal(f.started.length, 1)
  126. a.loadProgress.emit(0, 0)
  127. await f.advance(600)
  128. assert.equal(f.started.length, 2)
  129. f.controller.detach()
  130. }
  131. // Failed initialization must be recorded once, not retry recursively forever.
  132. {
  133. const f = fixture(plans, { load: async () => { throw new Error('bad content') } })
  134. await f.controller.prepare()
  135. await f.advance(5000)
  136. assert.equal(f.failed.length, 2)
  137. assert.deepEqual(f.controller.snapshot().map((x) => x.phase), ['failed', 'failed'])
  138. f.controller.detach()
  139. }
  140. // Distance unload calls the owner once and removes the shared reference.
  141. {
  142. const f = fixture([plans[0]])
  143. await f.controller.prepare()
  144. await f.advance(600)
  145. f.state.distances.a = 50000
  146. f.controller.refresh()
  147. f.controller.refresh()
  148. assert.equal(f.removed.length, 1)
  149. assert.equal(f.instances.length, 0)
  150. f.controller.detach()
  151. }
  152. // Late async initialization after disposal must be destroyed, not resurrected in the scene.
  153. {
  154. const pending = deferred()
  155. const f = fixture([plans[0]], { load: () => pending.promise })
  156. await f.controller.prepare()
  157. await f.advance(600)
  158. f.controller.detach()
  159. const instance = tile()
  160. pending.resolve(instance)
  161. await f.flush()
  162. assert.equal(f.added.length, 0)
  163. assert.equal(f.removed.length, 1)
  164. assert.ok(f.signals.every((signal) => signal.aborted))
  165. assert.equal(f.timers.size, 0)
  166. }
  167. // Detach cleans every event subscription and timer.
  168. {
  169. const f = fixture([plans[0]])
  170. await f.controller.prepare()
  171. await f.advance(600)
  172. const a = f.started[0].instance
  173. f.controller.detach()
  174. assert.equal(a.loadProgress.count + a.tileFailed.count + a.tileVisible.count, 0)
  175. assert.equal(f.timers.size, 0)
  176. }
  177. // All initialization failures are terminal; metadata failure cannot silently enter the pool.
  178. {
  179. const f = fixture(plans, { prepareBounds: async () => { throw new Error('missing bounds') } })
  180. await f.controller.prepare()
  181. await f.advance(5000)
  182. assert.equal(f.started.length, 0)
  183. assert.equal(f.failed.length, 2)
  184. f.controller.detach()
  185. }
  186. // Eager entries share the content gate too; empty/offscreen roots can release after a real frame.
  187. {
  188. const f = fixture([{ url: 'a', eager: true }, { url: 'b', eager: true }])
  189. await f.controller.prepare()
  190. const a = f.started[0].instance
  191. a.tilesLoaded = true
  192. await f.advance(1000)
  193. assert.equal(f.started.length, 1, 'an unrendered eager entry released the content slot')
  194. f.controller.onRender()
  195. await f.advance(600)
  196. assert.equal(f.started.length, 2)
  197. f.controller.detach()
  198. f.controller.detach()
  199. }
  200. // A tile failure releases the stream after pending work drains, without retrying the batch.
  201. {
  202. const f = fixture(plans)
  203. await f.controller.prepare()
  204. await f.advance(600)
  205. const a = f.started[0].instance
  206. a.tileFailed.emit({ url: 'a.glb', message: 'decode failure' })
  207. f.controller.onRender()
  208. await f.advance(600)
  209. assert.equal(f.started.length, 2)
  210. assert.equal(f.failed.length, 1)
  211. assert.equal(f.controller.snapshot()[0].phase, 'failed')
  212. f.controller.detach()
  213. }
  214. // Hidden coarse layer at the global camera must not hold the content gate forever.
  215. {
  216. const f = fixture([{ url: 'far', role: 'far', eager: true }, plans[0]])
  217. f.state.distances.far = 2000000
  218. await f.controller.prepare()
  219. const far = f.started[0].instance
  220. assert.equal(far.show, false)
  221. assert.equal(far.tilesLoaded, false)
  222. f.controller.onRender()
  223. await f.advance(1000)
  224. assert.deepEqual(f.started.map((item) => item.url), ['far', 'a'], 'hidden coarse layer blocked the queue')
  225. f.controller.detach()
  226. }
  227. console.log('T35 tileset loading: 12 scenarios passed')