You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

535 lines
27 KiB

  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { readFileSync } from 'node:fs'
  4. import { createRequire } from 'node:module'
  5. import * as historyModule from '../../unreal_tran_web/src/features/guide-legacy/demo-src/scene-history.js'
  6. import { chooseSceneSelection } from '../../unreal_tran_web/src/features/guide-legacy/demo-src/scene-selection.js'
  7. import { findDuplicateSceneBindingIds } from '../../unreal_tran_web/src/features/guide-legacy/demo-src/scene-validation.js'
  8. import * as rendering from '../../unreal_tran_web/src/features/guide-legacy/demo-src/scene-rendering-profile.js'
  9. import * as environment from '../../unreal_tran_web/src/features/guide-legacy/demo-src/scene-environment.js'
  10. import { SceneSelectionHighlight } from '../../unreal_tran_web/src/features/guide-legacy/demo-src/scene-selection-highlight.js'
  11. const requireWeb = createRequire(new URL('../../unreal_tran_web/package.json', import.meta.url))
  12. const requirePrototype = createRequire(new URL('../../unreal_tran/package.json', import.meta.url))
  13. const THREE = requireWeb('three')
  14. const { Window } = requirePrototype('happy-dom')
  15. const { SceneHistory } = historyModule
  16. const source = readFileSync(new URL('../../unreal_tran_web/src/features/guide-legacy/demo-src/scene-editor.js', import.meta.url), 'utf8')
  17. // Execute the shipped class methods with real Three.js objects and a DOM. Only
  18. // WebGL construction / external GLB transport are replaced in these Node tests.
  19. const body = source.slice(source.indexOf('const STORAGE_KEY'), source.indexOf('/** 当前挂载的实例'))
  20. function fixture() {
  21. const window = new Window()
  22. const document = window.document
  23. const bindings = {
  24. THREE, window, document, ...historyModule, chooseSceneSelection,
  25. ...rendering, ...environment, findDuplicateSceneBindingIds,
  26. modelUrl: value => value, listPublishedModelAssets: () => [],
  27. refreshSceneModelBounds: root => { root.updateMatrixWorld(true); return new THREE.Box3().setFromObject(root) },
  28. parseSceneScale: (value, current) => Number(value) > 0 ? Number(value) : current,
  29. formatSceneScale: value => String(value),
  30. applyModelEditorOverrides: () => ({}),
  31. isBridgeErectorVehicleReference: () => false,
  32. createGarageEnclosureController: () => ({}),
  33. createGarageEnclosureControls: () => ({ attach() {} }),
  34. assetStore: {}, setTimeout, clearTimeout,
  35. }
  36. const { WorkshopSceneEditor, SCENE_PRESETS } = new Function(...Object.keys(bindings), `${body}; return {WorkshopSceneEditor, SCENE_PRESETS}`)(...Object.values(bindings))
  37. const editor = Object.create(WorkshopSceneEditor.prototype)
  38. const root = document.createElement('div')
  39. document.body.append(root)
  40. Object.assign(editor, {
  41. root, context: { remoteControlled: true }, currentPreset: SCENE_PRESETS.find(p => p.id === 'workshop-1'),
  42. readySettled: true, readOnly: false, canPublish: true, pendingLoads: 0, loadGeneration: 0,
  43. objects: [], selected: null, assetSerial: 0, snap: true, dirty: false, changeRevision: 0,
  44. history: new SceneHistory(), historyInitialized: false, historyBusy: false,
  45. scene: new THREE.Scene(), camera: new THREE.PerspectiveCamera(),
  46. contentRoot: new THREE.Group(), environmentRoot: new THREE.Group(),
  47. controls: { target: new THREE.Vector3(), update() {} },
  48. transform: { attach() {}, detach() {}, setMode() {} },
  49. renderer: { toneMappingExposure: 1 },
  50. ambient: new THREE.HemisphereLight(), sun: new THREE.DirectionalLight(),
  51. grid: { visible: true }, selectionHighlight: { setObject() {}, clear() {} },
  52. assetStore: { hasReferenceSync: () => true },
  53. garageEnclosure: { setMode() {}, unregisterTree() {} },
  54. messages: [], toast(message) { this.messages.push(message) },
  55. })
  56. editor.renderShell()
  57. editor.cacheElements()
  58. return { editor, window, document, WorkshopSceneEditor, SCENE_PRESETS }
  59. }
  60. function keyboard(target, key, options = {}) {
  61. return { target, key, ctrlKey: false, metaKey: false, altKey: false, shiftKey: false,
  62. ...options, prevented: false, preventDefault() { this.prevented = true } }
  63. }
  64. test('scene granular update permission enables editing without the retired shared code', async () => {
  65. const f = fixture()
  66. class WithoutWebGL extends f.WorkshopSceneEditor {
  67. renderShell() {} cacheElements() {} applyAccessMode() {} initThree() {} bindEvents() {} animate() {} renderAssets() {}
  68. async loadPreset() {}
  69. }
  70. const editor = new WithoutWebGL(f.editor.root, { storage: f.window.localStorage, can: code => code === 'content.scene.update' })
  71. await editor.ready
  72. assert.equal(editor.readOnly, false)
  73. assert.equal(editor.canPublish, false)
  74. })
  75. test('typing undo/redo stays inside text controls and inactive cached editors ignore global shortcuts', () => {
  76. const { editor, document } = fixture()
  77. const input = document.createElement('input')
  78. const calls = []
  79. editor.undo = () => calls.push('undo'); editor.redo = () => calls.push('redo')
  80. editor.deleteSelected = () => calls.push('delete'); editor.setTransform = mode => calls.push(mode)
  81. for (const key of ['z', 'y']) editor.handleKeyboard(keyboard(input, key, { ctrlKey: true }))
  82. assert.deepEqual(calls, [])
  83. editor.saveProject = () => calls.push('save')
  84. const save = keyboard(input, 's', { ctrlKey: true })
  85. editor.handleKeyboard(save)
  86. assert.deepEqual(calls, ['save'])
  87. assert.equal(save.prevented, true)
  88. calls.length = 0
  89. editor.paused = true
  90. for (const key of ['Delete', 'w', 'e', 'r', 'z']) editor.handleKeyboard(keyboard(document.body, key, { ctrlKey: key === 'z' }))
  91. assert.deepEqual(calls, [])
  92. })
  93. test('preview locks mutation controls and Delete while Escape restores editing', () => {
  94. const { editor, document } = fixture()
  95. let deletes = 0
  96. editor.deleteSelected = () => deletes++
  97. editor.togglePreview()
  98. editor.handleKeyboard(keyboard(document.body, 'Delete'))
  99. assert.equal(deletes, 0)
  100. assert.equal(editor.writeUnavailable(), true)
  101. assert.equal(editor.root.querySelector('[data-action="open"]').disabled, true)
  102. editor.handleKeyboard(keyboard(document.body, 'Escape'))
  103. assert.equal(editor.previewing, false)
  104. assert.equal(editor.writeUnavailable(), false)
  105. })
  106. test('readonly templates have disabled affordances and cannot replace the scene', () => {
  107. const { editor } = fixture()
  108. editor.readOnly = true
  109. editor.renderPresets()
  110. editor.applyAccessMode()
  111. const card = editor.root.querySelector('[data-preset-id="terrain-1"]')
  112. let changed = false
  113. editor.loadPreset = () => { changed = true }
  114. editor.handleClick({ target: card })
  115. assert.equal(changed, false)
  116. assert.equal(card.disabled, true)
  117. })
  118. test('top view returns to the previous perspective through the visible perspective button', () => {
  119. const { editor } = fixture()
  120. editor.camera.position.set(12, 9, 18)
  121. editor.controls.target.set(2, 1, 3)
  122. editor.setTopView()
  123. editor.setTopView()
  124. editor.handleClick({ target: editor.root.querySelector('[data-camera="perspective"]') })
  125. assert.deepEqual(editor.camera.position.toArray(), [12, 9, 18])
  126. assert.deepEqual(editor.controls.target.toArray(), [2, 1, 3])
  127. })
  128. test('explicitly cleared bindings and display name survive serialized restore', () => {
  129. const { editor } = fixture()
  130. const object = new THREE.Group()
  131. object.userData = { displayName: 'old name', deviceId: 'OLD-DEVICE', modelId: 'OLD-MODEL' }
  132. editor.applySerializedObject(object, { name: '', deviceId: '', modelId: '' })
  133. assert.equal(object.userData.displayName, '')
  134. assert.equal(object.userData.deviceId, '')
  135. assert.equal(object.userData.modelId, '')
  136. })
  137. test('binding table reports the actual semantic value without claiming missing bindings are complete', () => {
  138. const { editor } = fixture()
  139. const object = new THREE.Group()
  140. object.userData = { displayName: '区域', deviceId: '', modelId: '', semantic: '安全区域' }
  141. editor.objects = [object]
  142. editor.renderBottom('binding')
  143. assert.match(editor.bottomContent.textContent, /安全区域/)
  144. assert.doesNotMatch(editor.bottomContent.textContent, /已绑定/)
  145. editor.renderBottom('logs')
  146. assert.doesNotMatch(editor.bottomContent.textContent, /本地存储/)
  147. })
  148. test('real Three ray hits on hidden descendants are excluded before selecting a root or part', () => {
  149. const root = new THREE.Group()
  150. root.userData.assetRoot = true
  151. const hidden = new THREE.Group()
  152. hidden.visible = false
  153. const mesh = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial())
  154. hidden.add(mesh); root.add(hidden)
  155. root.updateMatrixWorld(true)
  156. const ray = new THREE.Raycaster(new THREE.Vector3(0, 0, 3), new THREE.Vector3(0, 0, -1))
  157. const hits = ray.intersectObjects([root], true)
  158. assert.ok(hits.length > 0, 'Three returns intersections even when an ancestor is hidden')
  159. assert.equal(chooseSceneSelection(hits, [root]) === null, true)
  160. hidden.visible = true
  161. assert.equal(chooseSceneSelection(hits, [root]), root)
  162. mesh.userData.sceneDeleted = true
  163. assert.equal(chooseSceneSelection(hits, [root]) === null, true)
  164. })
  165. test('deleting a root disposes its geometry and materials after clearing selection', () => {
  166. const { editor } = fixture()
  167. const mesh = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial())
  168. mesh.userData = { sceneId: 'mesh', displayName: 'mesh' }
  169. editor.objects = [mesh]; editor.contentRoot.add(mesh); editor.selected = mesh
  170. let geometry = 0, material = 0
  171. mesh.geometry.addEventListener('dispose', () => geometry++)
  172. mesh.material.addEventListener('dispose', () => material++)
  173. editor.deleteSelected()
  174. assert.equal(editor.objects.length, 0)
  175. assert.equal(geometry, 1)
  176. assert.equal(material, 1)
  177. })
  178. test('loading and history restoration preserve an authored camera rather than auto-focusing a vehicle', async () => {
  179. const { editor } = fixture()
  180. const project = { presetId: 'workshop-1', objects: [], rendering: { camera: { position: [7, 8, 9], target: [1, 2, 3] } } }
  181. editor.context.readDocument = () => project
  182. editor.focusPublishedVehicle = () => editor.camera.position.set(99, 99, 99)
  183. await editor.loadPreset('workshop-1', { initial: true })
  184. assert.deepEqual(editor.camera.position.toArray(), [7, 8, 9])
  185. editor.camera.position.set(11, 11, 11)
  186. await editor.restoreHistoryState({ project, selectedSceneId: '', dirty: false })
  187. assert.deepEqual(editor.camera.position.toArray(), [7, 8, 9])
  188. })
  189. test('save B then undo A stays dirty, and redo B returns to the actual saved baseline', async () => {
  190. const { editor } = fixture()
  191. const object = editor.addAsset('equipment', new THREE.Vector3(), true)
  192. editor.historyInitialized = true
  193. editor.history.reset(editor.captureHistoryState())
  194. object.position.x = 4
  195. editor.markDirty('move to B')
  196. const writes = []
  197. editor.context.saveDocument = async doc => { writes.push(structuredClone(doc)) }
  198. await editor.saveProject()
  199. assert.equal(editor.dirty, false)
  200. await editor.undo()
  201. assert.equal(object.position.x, 0)
  202. assert.equal(editor.dirty, true, 'A is no longer the server version after B was saved')
  203. assert.equal(editor.root.querySelector('#se-save-state').textContent, '未保存')
  204. await editor.redo()
  205. assert.equal(object.position.x, 4)
  206. assert.equal(editor.dirty, false)
  207. assert.equal(writes.length, 1, 'history navigation never saves by itself')
  208. })
  209. test('new resource bindings do not reuse a saved serial after earlier objects were deleted', () => {
  210. const { editor } = fixture()
  211. editor.restoreObject({ type: 'equipment', name: 'kept', deviceId: 'EQ-002', modelId: 'MODEL-EQUIPMENT-02' })
  212. const added = editor.addAsset('equipment', new THREE.Vector3(), true)
  213. assert.notEqual(added.userData.deviceId, 'EQ-002')
  214. assert.notEqual(added.userData.modelId, 'MODEL-EQUIPMENT-02')
  215. })
  216. test('formal new delegates to the host even for create-only sessions and leaves this document intact', async () => {
  217. const { editor } = fixture()
  218. editor.readOnly = true; editor.canCreate = true
  219. let created = 0
  220. editor.context.onNew = async () => { created++ }
  221. editor.loadPreset = () => { throw new Error('must not reset the current document') }
  222. editor.applyAccessMode()
  223. assert.equal(editor.root.querySelector('[data-action="new"]').disabled, false)
  224. await editor.newProject()
  225. assert.equal(created, 1)
  226. editor.pendingLoads = 1
  227. await editor.newProject()
  228. assert.equal(created, 1)
  229. })
  230. test('cancelled async template replacement retains the existing scene and history', async () => {
  231. const { editor } = fixture()
  232. editor.dirty = true
  233. let prompts = 0, clears = 0
  234. editor.context.confirmDiscard = async () => { prompts++; return false }
  235. editor.clearContent = () => { clears++ }
  236. await editor.loadPreset('terrain-1')
  237. assert.equal(prompts, 1)
  238. assert.equal(clears, 0)
  239. assert.equal(editor.currentPreset.id, 'workshop-1')
  240. })
  241. test('a failed replacement GLB keeps the previous live scene rather than clearing an unsaved draft', async () => {
  242. const { editor } = fixture()
  243. const kept = editor.addAsset('equipment', new THREE.Vector3(3, 0, 1), true)
  244. editor.dirty = true
  245. editor.context.confirmDiscard = async () => true
  246. editor.context.readDocument = () => null
  247. editor.placeAsset = async () => { throw new Error('fixture model unavailable') }
  248. try { await editor.loadPreset('terrain-1') } catch {}
  249. assert.equal(editor.currentPreset.id, 'workshop-1')
  250. assert.equal(editor.objects.length, 1)
  251. assert.equal(editor.objects[0] === kept, true)
  252. assert.equal(kept.parent === editor.contentRoot, true)
  253. assert.equal(editor.dirty, true)
  254. })
  255. test('failed undo restoration retains live objects and keeps the undo target available for retry', async () => {
  256. const { editor } = fixture()
  257. const kept = editor.addAsset('equipment', new THREE.Vector3(), true)
  258. editor.historyInitialized = true
  259. const earlier = editor.captureHistoryState()
  260. earlier.project.objects.push({ sceneId: 'missing', type: 'imported', name: 'missing.glb' })
  261. editor.history.reset(earlier)
  262. editor.markDirty('delete missing asset')
  263. editor.restoreImportedObject = async () => { throw new Error('fixture model unavailable') }
  264. await editor.undo()
  265. assert.equal(editor.objects[0] === kept, true)
  266. assert.equal(editor.history.canUndo, true)
  267. assert.equal(editor.history.canRedo, false)
  268. })
  269. test('validation text and imported object IDs cannot create executable DOM markup', () => {
  270. const { editor } = fixture()
  271. editor.verifyScene = () => [{ ok: false, label: 'duplicate', detail: '<img src=x onerror="void 0">' }]
  272. editor.renderBottom('validation')
  273. assert.equal(editor.bottomContent.querySelector('img') === null, true)
  274. assert.match(editor.bottomContent.textContent, /<img/)
  275. editor.objects = [{ userData: { sceneId: 'a" onclick="void 0', displayName: 'safe' } }]
  276. editor.renderBottom('objects')
  277. assert.equal(editor.bottomContent.querySelector('[onclick]') === null, true)
  278. })
  279. test('selection highlight cleanup restores source materials and releases its own GPU helper', () => {
  280. const scene = new THREE.Scene()
  281. const material = new THREE.MeshStandardMaterial()
  282. const mesh = new THREE.Mesh(new THREE.BoxGeometry(), material)
  283. const highlight = new SceneSelectionHighlight(scene)
  284. highlight.setObject(mesh)
  285. assert.notEqual(mesh.material, material)
  286. let geometry = 0, helperMaterial = 0
  287. highlight.helper.geometry.addEventListener('dispose', () => geometry++)
  288. highlight.helper.material.addEventListener('dispose', () => helperMaterial++)
  289. assert.equal(typeof highlight.dispose, 'function')
  290. highlight.dispose()
  291. assert.equal(mesh.material, material)
  292. assert.equal(highlight.helper.parent, null)
  293. assert.equal(geometry, 1)
  294. assert.equal(helperMaterial, 1)
  295. })
  296. test('replacement failure waits for other started loads before restoring the original scene', async () => {
  297. const { editor } = fixture()
  298. editor.context.remoteControlled = false
  299. const kept = editor.addAsset('equipment', new THREE.Vector3(), true)
  300. let completeLate
  301. const late = new Promise(resolve => { completeLate = resolve })
  302. editor.context.readDocument = () => ({ presetId: 'terrain-1', objects: [{ fail: true }, { fail: false }] })
  303. editor.restoreObject = async item => {
  304. if (item.fail) throw new Error('fixture failed asset')
  305. await late
  306. return editor.addAsset('workbench', new THREE.Vector3(), true)
  307. }
  308. let finished = false
  309. const load = editor.loadPreset('terrain-1').then(() => { finished = true })
  310. await Promise.resolve(); await Promise.resolve(); await Promise.resolve()
  311. assert.equal(finished, false)
  312. assert.equal(editor.writeUnavailable(), true)
  313. completeLate()
  314. await load
  315. assert.equal(editor.objects.length, 1)
  316. assert.equal(editor.objects[0] === kept, true)
  317. assert.equal(editor.pendingLoads, 0)
  318. })
  319. test('activity log reflects actual session events and keeps API error details transient', () => {
  320. const { editor, WorkshopSceneEditor } = fixture()
  321. WorkshopSceneEditor.prototype.toast.call(editor, '场景工程已保存到服务端', 'success')
  322. WorkshopSceneEditor.prototype.toast.call(editor, '接口失败详情', 'warn')
  323. editor.renderBottom('logs')
  324. assert.match(editor.bottomContent.textContent, /场景工程已保存到服务端/)
  325. assert.match(editor.bottomContent.textContent, /操作未完成/)
  326. assert.doesNotMatch(editor.bottomContent.textContent, /接口失败详情/)
  327. assert.equal(editor.operationLog.length, 2)
  328. assert.ok(editor.operationLog.every(entry => Number.isFinite(Date.parse(entry.at))))
  329. })
  330. test('formal template cards use fresh seeds even when returning to the original project preset', async () => {
  331. const { editor } = fixture()
  332. let reads = 0
  333. editor.context.readDocument = () => { reads++; return { objects: [] } }
  334. editor.context.confirmDiscard = async () => true
  335. await editor.loadPreset('workshop-2')
  336. assert.equal(editor.objects.length, 3)
  337. await editor.loadPreset('workshop-1')
  338. assert.equal(editor.objects.length, 0)
  339. assert.equal(reads, 0)
  340. })
  341. test('a pending pre-save recovery timer cannot overwrite the host checkpoint during a slow save', async () => {
  342. const { editor } = fixture()
  343. let checkpoints = 0
  344. editor.context.onDocumentChanged = () => { checkpoints++ }
  345. editor.markDirty('edit before save')
  346. clearTimeout(editor.recoveryTimer)
  347. editor.recoveryTimer = setTimeout(() => editor.context.onDocumentChanged(editor.serialize()), 15)
  348. editor.context.saveDocument = async () => { await new Promise(resolve => setTimeout(resolve, 60)) }
  349. await editor.saveProject()
  350. assert.equal(editor.dirty, false)
  351. assert.equal(checkpoints, 0)
  352. assert.equal(editor.recoveryTimer, 0)
  353. })
  354. test('verify opens live validation and whitespace binding edits immediately invalidate the active tab', () => {
  355. const { editor } = fixture()
  356. const object = editor.addAsset('equipment', new THREE.Vector3(), true)
  357. editor.selectObject(object)
  358. editor.verifyScene(true)
  359. assert.equal(editor.root.querySelector('[data-bottom-tab="validation"]').classList.contains('active'), true)
  360. const device = editor.root.querySelector('[data-inspector-field="deviceId"]')
  361. device.value = ' '
  362. editor.handleInput({ target: device })
  363. assert.equal(editor.verifyScene(false).find(item => item.label === '对象设备编号完整').ok, false)
  364. assert.ok(editor.bottomContent.querySelector('.warn'))
  365. editor.renderBottom('binding')
  366. const name = editor.root.querySelector('[data-inspector-field="name"]')
  367. name.value = 'Live edited name'
  368. editor.handleInput({ target: name })
  369. assert.match(editor.bottomContent.textContent, /Live edited name/)
  370. assert.equal(editor.root.querySelector('[data-bottom-tab="binding"]').classList.contains('active'), true)
  371. })
  372. test('model source navigation locates the published owner and falls back without source permission', () => {
  373. const { editor } = fixture()
  374. const root = editor.addAsset('equipment', new THREE.Vector3(), true)
  375. root.userData.targetProjectId = '157'
  376. const part = new THREE.Group()
  377. part.userData = { modelPartTarget: true, sceneOwnerId: root.userData.sceneId }
  378. editor.selected = part
  379. const requests = []
  380. editor.context.navigate = (...args) => requests.push(args)
  381. const button = editor.root.querySelector('[data-action="open-model-editor"]')
  382. editor.handleClick({ target: button })
  383. assert.deepEqual(requests.pop(), ['editor', '?edit=157'])
  384. editor.context.can = () => false
  385. editor.handleClick({ target: button })
  386. assert.deepEqual(requests.pop(), ['editor', ''])
  387. })
  388. test('gizmo scaling across its pivot follows the same strictly positive scale contract as inspector edits', () => {
  389. const { editor } = fixture()
  390. const model = editor.addAsset('equipment', new THREE.Vector3(), true)
  391. editor.selectObject(model)
  392. editor.transform.mode = 'scale'
  393. model.scale.set(-1.25, 0, 0.034)
  394. editor.handleTransformChange()
  395. assert.deepEqual(model.scale.toArray(), [0.000001, 0.000001, 0.034])
  396. assert.equal(editor.root.querySelector('[data-inspector-field="sx"]').value, '0.000001')
  397. assert.equal(editor.dirty, true)
  398. assert.equal(editor.transformChanged, true)
  399. })
  400. test('default history caps at 60 snapshots, stops at either edge and discards redo only after a new branch', () => {
  401. const history = new SceneHistory()
  402. assert.equal(history.undo(), null)
  403. assert.equal(history.redo(), null)
  404. history.reset({ revision: 0 })
  405. for (let revision = 1; revision <= 75; revision++) history.push({ revision }, `edit ${revision}`)
  406. assert.equal(history.past.length, 60)
  407. let oldest
  408. for (let i = 0; i < 59; i++) oldest = history.undo()
  409. assert.equal(oldest.state.revision, 16)
  410. assert.equal(history.undo(), null)
  411. assert.equal(history.canUndo, false)
  412. for (let i = 0; i < 59; i++) assert.ok(history.redo())
  413. assert.equal(history.redo(), null)
  414. assert.equal(history.past.at(-1).state.revision, 75)
  415. const previous = history.undo()
  416. previous.state.revision = -1
  417. assert.equal(history.past.at(-1).state.revision, 74)
  418. assert.equal(history.push({ revision: 74 }), false)
  419. assert.equal(history.canRedo, true)
  420. history.push({ revision: 100 }, 'new branch')
  421. assert.equal(history.canRedo, false)
  422. assert.equal(history.redo(), null)
  423. })
  424. const truckPartDefinitions = [{ interactionId: 'interaction-source-wheel', editorId: 'wheel-editor-id', nodeName: 'Wheel' }]
  425. function addTruckInstance(editor, id) {
  426. const root = new THREE.Group()
  427. root.userData = { sceneId: id, assetType: 'imported', modelEditorAssetId: 'truck-published', displayName: id }
  428. const part = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshStandardMaterial())
  429. part.name = 'Wheel'; part.userData = { interactionId: 'interaction-source-wheel', editorId: 'wheel-editor-id' }
  430. root.add(part); editor.contentRoot.add(root); editor.objects.push(root)
  431. return { root, part }
  432. }
  433. test('two published instances have unique tree part IDs and editing the second persists independently after reopen', () => {
  434. const { editor } = fixture()
  435. const first = addTruckInstance(editor, 'TRUCK-A'), second = addTruckInstance(editor, 'TRUCK-B')
  436. editor.registerModelPartTargets(first.root, truckPartDefinitions)
  437. editor.registerModelPartTargets(second.root, truckPartDefinitions)
  438. assert.equal(first.part.userData.sceneId, 'scene-part:TRUCK-A:interaction-source-wheel')
  439. assert.equal(second.part.userData.sceneId, 'scene-part:TRUCK-B:interaction-source-wheel')
  440. assert.equal(second.part.userData.interactionId, first.part.userData.interactionId)
  441. assert.equal(second.part.userData.editorId, first.part.userData.editorId)
  442. editor.renderTree()
  443. const secondRow = editor.root.querySelector(`[data-object-id="${second.part.userData.sceneId}"]`)
  444. editor.handleClick({ target: secondRow })
  445. assert.equal(editor.selected === second.part, true)
  446. const input = editor.root.querySelector('[data-inspector-field="x"]')
  447. input.value = '3.75'; editor.handleInput({ target: input }); editor.handleChange({ target: input })
  448. assert.equal(second.part.position.x, 3.75)
  449. assert.equal(first.part.position.x, 0)
  450. const saved = [first, second].map(instance => editor.serializeObject(instance.root))
  451. const reopened = fixture().editor
  452. reopened.publishedModelAsset = () => ({ parts: truckPartDefinitions })
  453. for (const item of saved) reopened.applySerializedObject(addTruckInstance(reopened, item.sceneId).root, item)
  454. assert.equal(reopened.findSceneSelectionById(first.part.userData.sceneId).position.x, 0)
  455. assert.equal(reopened.findSceneSelectionById(second.part.userData.sceneId).position.x, 3.75)
  456. assert.deepEqual(reopened.objects.map(root => reopened.captureModelPartTargets(root)), saved.map(item => item.modelPartTargets))
  457. })
  458. test('legacy duplicate instance IDs migrate only later owners in document order despite reversed GLB completion', async () => {
  459. const makeSaved = (id, x) => ({ sceneId: id, type: 'imported', name: id, modelEditorAssetId: 'truck-published',
  460. modelPartTargets: [{ sceneId: 'legacy-wheel', interactionId: 'interaction-source-wheel', sourceInteractionId: 'interaction-source-wheel', editorId: 'wheel-editor-id', sourceNodeName: 'Wheel' }],
  461. partOverrides: { 'interaction-source-wheel': { sceneId: 'legacy-wheel', sourceInteractionId: 'interaction-source-wheel', position: [x, 0, 0] } } })
  462. const original = [makeSaved('TRUCK-A', 1), makeSaved('TRUCK-B', 2)]
  463. const { editor } = fixture()
  464. editor.publishedModelAsset = () => ({ parts: truckPartDefinitions })
  465. editor.restoreObject = async item => {
  466. await new Promise(resolve => setTimeout(resolve, item.sceneId === 'TRUCK-A' ? 20 : 1))
  467. const instance = addTruckInstance(editor, item.sceneId)
  468. editor.applySerializedObject(instance.root, item)
  469. return instance.root
  470. }
  471. await editor.restoreSceneObjects(original, editor.loadGeneration)
  472. assert.equal(editor.findSceneSelectionById('legacy-wheel').userData.sceneOwnerId, 'TRUCK-A')
  473. assert.equal(editor.findSceneSelectionById('scene-part:TRUCK-B:interaction-source-wheel').position.x, 2)
  474. assert.equal(original[1].modelPartTargets[0].sceneId, 'legacy-wheel', 'the source document is not modified in place')
  475. const persisted = editor.objects.map(root => editor.serializeObject(root))
  476. assert.deepEqual(editor.normalizeModelPartSceneIds(persisted), persisted, 'migration is stable after saving in completion order')
  477. const single = fixture().editor
  478. assert.deepEqual(single.normalizeModelPartSceneIds([original[0]]), [original[0]], 'legacy single-instance references remain unchanged')
  479. })
  480. test('successful parallel restores retain document order after delayed GLBs and split-object batches finish out of order', async () => {
  481. const { editor } = fixture()
  482. const kept = editor.addAsset('equipment', new THREE.Vector3(), true)
  483. const completed = []
  484. editor.restoreObject = async item => {
  485. await new Promise(resolve => setTimeout(resolve, item.delay))
  486. completed.push(item.id)
  487. const roots = item.parts.map(id => {
  488. const root = new THREE.Group(); root.userData = { sceneId: id, assetType: 'imported' }
  489. editor.contentRoot.add(root); editor.objects.push(root); return root
  490. })
  491. return item.parts.length === 1 ? roots[0] : roots
  492. }
  493. await editor.restoreSceneObjects([
  494. { id: 'slow-first', parts: ['TRUCK-A'], delay: 30 },
  495. { id: 'fast-second', parts: ['TRUCK-B'], delay: 1 },
  496. { id: 'split-third', parts: ['SPLIT-1', 'SPLIT-2'], delay: 10 },
  497. ], editor.loadGeneration)
  498. assert.deepEqual(completed, ['fast-second', 'split-third', 'slow-first'])
  499. assert.deepEqual(editor.objects.map(root => root.userData.sceneId), [kept.userData.sceneId, 'TRUCK-A', 'TRUCK-B', 'SPLIT-1', 'SPLIT-2'])
  500. assert.deepEqual(editor.serialize().objects.filter(item => !environment.isSceneEnvironmentSnapshotObject(item)).map(item => item.sceneId).filter(Boolean), editor.objects.map(root => root.userData.sceneId))
  501. })