Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

187 linhas
9.0 KiB

  1. import assert from 'node:assert/strict'
  2. import { readFile } from 'node:fs/promises'
  3. import { createRequire } from 'node:module'
  4. import { pathToFileURL } from 'node:url'
  5. import test from 'node:test'
  6. import ts from 'typescript'
  7. const requireWeb = createRequire(new URL('../../unreal_tran_web/package.json', import.meta.url))
  8. const threeUrl = pathToFileURL(requireWeb.resolve('three')).href
  9. const THREE = await import(threeUrl)
  10. const webSource = new URL('../../unreal_tran_web/src/features/', import.meta.url)
  11. const placementSource = await readFile(new URL('scene-legacy/modelPlacement.ts', webSource), 'utf8')
  12. const placementJavaScript = ts.transpileModule(placementSource, {
  13. compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
  14. }).outputText.replace(/from 'three'/g, `from '${threeUrl}'`)
  15. const placementUrl = `data:text/javascript;base64,${Buffer.from(placementJavaScript).toString('base64')}`
  16. const { refreshSceneModelBounds } = await import(placementUrl)
  17. // Exercise the actual Three functions without constructing the browser-only
  18. // editor UI or opening its storage. Metadata normalization is irrelevant to
  19. // these geometry checks and remains an identity adapter in this harness.
  20. async function renderingFunctions(relativePath) {
  21. const source = await readFile(new URL(relativePath, webSource), 'utf8')
  22. const ast = ts.createSourceFile(relativePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
  23. const functionNames = new Set(['fitObject', 'applyPublishedSceneTransform', 'buildTrainingContentSnapshot', 'resolveTrainingEnvironmentUrl'])
  24. const functions = ast.statements
  25. .filter(node => ts.isFunctionDeclaration(node) && functionNames.has(node.name?.text))
  26. .map(node => node.getText(ast).replace(/^export\s+/, ''))
  27. assert.equal(functions.length, functionNames.size)
  28. const editor = ast.statements.find(node => ts.isClassDeclaration(node) && node.name?.text === 'TrainingSceneEditor')
  29. const highlight = editor.members.find(node => node.name?.getText(ast) === 'highlightTarget')
  30. const setProject = editor.members.find(node => node.name?.getText(ast) === 'setProject')
  31. const body = `
  32. import * as THREE from '${threeUrl}';
  33. import { refreshSceneModelBounds } from '${placementUrl}';
  34. const normalizeTrainingModelPartTargets = item => item.modelPartTargets || [];
  35. const normalizeTrainingPartOverrides = item => item || {};
  36. const normalizeSceneRendering = item => item || {};
  37. ${functions.join('\n')}
  38. class TrainingSceneEditor { ${highlight.getText(ast)} ${setProject.getText(ast)} }
  39. export { fitObject, applyPublishedSceneTransform, TrainingSceneEditor };
  40. `
  41. return import(`data:text/javascript;base64,${Buffer.from(body).toString('base64')}`)
  42. }
  43. function skinFixture() {
  44. const root = new THREE.Group()
  45. const bone = new THREE.Bone()
  46. const geometry = new THREE.BoxGeometry(2, 4, 10)
  47. geometry.translate(0, 2, 0)
  48. const count = geometry.getAttribute('position').count
  49. const indices = new Uint16Array(count * 4)
  50. const weights = new Float32Array(count * 4)
  51. for (let i = 0; i < count; i += 1) weights[i * 4] = 1
  52. geometry.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(indices, 4))
  53. geometry.setAttribute('skinWeight', new THREE.Float32BufferAttribute(weights, 4))
  54. const mesh = new THREE.SkinnedMesh(geometry, new THREE.MeshBasicMaterial())
  55. root.add(bone, mesh)
  56. root.updateMatrixWorld(true)
  57. mesh.bind(new THREE.Skeleton([bone]))
  58. root.scale.setScalar(0.1)
  59. root.position.set(1, 2, 3)
  60. bone.position.y = 5
  61. return { root, bone, mesh }
  62. }
  63. function close(actual, expected) {
  64. assert.ok(Math.abs(actual - expected) < 1e-7, `${actual} != ${expected}`)
  65. }
  66. function assertSkinFitsCachedBounds(mesh) {
  67. const vertex = new THREE.Vector3()
  68. const worldBox = new THREE.Box3().setFromObject(mesh).expandByScalar(1e-7)
  69. for (let i = 0; i < mesh.geometry.getAttribute('position').count; i += 1) {
  70. mesh.getVertexPosition(i, vertex)
  71. assert.ok(vertex.distanceTo(mesh.boundingSphere.center) <= mesh.boundingSphere.radius + 1e-7)
  72. assert.ok(worldBox.containsPoint(vertex.applyMatrix4(mesh.matrixWorld)))
  73. }
  74. }
  75. for (const runtime of ['guide-legacy/demo-src', 'virtual-training-legacy/upstream']) {
  76. const { fitObject, applyPublishedSceneTransform, TrainingSceneEditor } = await renderingFunctions(`${runtime}/training-scene-editor.js`)
  77. test(`${runtime}: fitting an authored skinned model uses its current pose and real proportions`, () => {
  78. const { root, mesh } = skinFixture()
  79. new THREE.Box3().setFromObject(root) // stale cached GLTF bounds
  80. fitObject(root, { maxSize: 6, center: [0, 0, 0] })
  81. const box = new THREE.Box3().setFromObject(root)
  82. const size = box.getSize(new THREE.Vector3())
  83. close(size.x, 1.2)
  84. close(size.y, 2.4)
  85. close(size.z, 6)
  86. close(box.min.y, 0)
  87. close(root.scale.x, 0.6)
  88. assertSkinFitsCachedBounds(mesh)
  89. })
  90. test(`${runtime}: published scene restore preserves saved scale and refreshes skin culling bounds`, () => {
  91. const { root, bone, mesh } = skinFixture()
  92. refreshSceneModelBounds(root)
  93. const item = {
  94. position: [-7.5, 1.06, -0.45], rotation: [0.1, 0.4, -0.2],
  95. scale: [0.0361637593257, 0.06, 0.1], visible: true,
  96. }
  97. bone.position.set(1, 17, 3)
  98. applyPublishedSceneTransform(root, item)
  99. assert.deepEqual(root.position.toArray(), item.position)
  100. assert.deepEqual(root.rotation.toArray().slice(0, 3), item.rotation)
  101. assert.deepEqual(root.scale.toArray(), item.scale)
  102. assert.deepEqual(bone.position.toArray(), [1, 17, 3])
  103. assertSkinFitsCachedBounds(mesh)
  104. })
  105. test(`${runtime}: selecting and clearing a skinned training target keeps bounds consistent`, () => {
  106. const { root, mesh } = skinFixture()
  107. refreshSceneModelBounds(root)
  108. const context = {
  109. targetMaterials: new Map([['fox', []]]), targetObjects: new Map([['fox', root]]),
  110. targetScales: new Map([['fox', root.scale.clone()]]), publishedSceneRoot: root,
  111. }
  112. TrainingSceneEditor.prototype.highlightTarget.call(context, 'fox')
  113. close(root.scale.x, 0.104)
  114. assertSkinFitsCachedBounds(mesh)
  115. TrainingSceneEditor.prototype.highlightTarget.call(context, '')
  116. close(root.scale.x, 0.1)
  117. assertSkinFitsCachedBounds(mesh)
  118. })
  119. test(`${runtime}: ground datum follows the final authored root scale`, () => {
  120. const { root } = skinFixture()
  121. root.position.set(0, 0, 0)
  122. fitObject(root, { maxSize: 6, center: [0, 0, 0], groundLevelY: -1.5 })
  123. close(root.position.y - 1.5 * root.scale.y, 0)
  124. })
  125. if (runtime === 'guide-legacy/demo-src') {
  126. test('API-backed scene bind reports a failed load and permits a same-snapshot retry', async () => {
  127. const failure = new Error('controlled asset unavailable')
  128. let calls = 0
  129. const context = {
  130. environmentUrl: '', environmentModel: null,
  131. workshop: { visible: false }, machine: { visible: false },
  132. publishedSceneSignature: '', publishedSceneObjects: [],
  133. applyRendering() {}, async loadEnvironment() {},
  134. async loadPublishedSceneSnapshot() {
  135. calls += 1
  136. if (calls === 1) return { count: 0, objects: [], error: failure }
  137. this.publishedSceneObjects = [new THREE.Group()]
  138. return { count: 1, objects: this.publishedSceneObjects }
  139. },
  140. }
  141. const project = { scenario: { publishedSceneId: '160', sceneResource: { snapshot: { objects: [{ sceneId: 'fox' }] } } } }
  142. const failed = await TrainingSceneEditor.prototype.setProject.call(context, project)
  143. assert.equal(failed.error, failure)
  144. assert.equal(failed.snapshotLoaded, false)
  145. const recovered = await TrainingSceneEditor.prototype.setProject.call(context, project)
  146. assert.equal(calls, 2)
  147. assert.equal(recovered.error, undefined)
  148. assert.equal(recovered.snapshotLoaded, true)
  149. assert.equal(recovered.objectCount, 1)
  150. })
  151. }
  152. }
  153. test('cached published skins retain independent bones after each scene instance is transformed', async () => {
  154. const { loadCachedPublishedModel, clearTrainingModelCache } = await import(new URL('guide-legacy/demo-src/training-model-cache.js', webSource))
  155. clearTrainingModelCache()
  156. const fixture = skinFixture()
  157. const canonical = { scene: fixture.root, scenes: [fixture.root], animations: [] }
  158. const loader = { loadAsync: async () => canonical }
  159. const first = await loadCachedPublishedModel('fixture://fox.glb', loader)
  160. const second = await loadCachedPublishedModel('fixture://fox.glb', loader)
  161. let firstSkin, secondSkin
  162. first.scene.traverse(node => { if (node.isSkinnedMesh) firstSkin = node })
  163. second.scene.traverse(node => { if (node.isSkinnedMesh) secondSkin = node })
  164. assert.notEqual(firstSkin.skeleton.bones[0], secondSkin.skeleton.bones[0])
  165. assert.notEqual(firstSkin.skeleton.bones[0], fixture.bone)
  166. firstSkin.skeleton.bones[0].position.y = 17
  167. first.scene.scale.setScalar(0.0361637593257)
  168. refreshSceneModelBounds(first.scene)
  169. refreshSceneModelBounds(second.scene)
  170. close(secondSkin.skeleton.bones[0].position.y, 5)
  171. assertSkinFitsCachedBounds(firstSkin)
  172. assertSkinFitsCachedBounds(secondSkin)
  173. clearTrainingModelCache()
  174. })