HydraulicDiagram3D.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. <script setup lang="ts">
  2. import { onMounted, onUnmounted, ref } from 'vue'
  3. import * as THREE from 'three'
  4. import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
  5. import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'
  6. const containerRef = ref<HTMLDivElement>()
  7. let scene: THREE.Scene
  8. let camera: THREE.PerspectiveCamera
  9. let renderer: THREE.WebGLRenderer
  10. let labelRenderer: CSS2DRenderer
  11. let controls: OrbitControls
  12. let animationId: number
  13. let flowDots: THREE.Mesh[] = []
  14. interface DeviceNode {
  15. id: string; label: string; x: number; z: number; value: string; color: number
  16. }
  17. const nodes: DeviceNode[] = [
  18. { id: 'quShou', label: '渠首', x: -300, z: -70, value: '引水枢纽', color: 0x4dd0e1 },
  19. { id: 'tiaoXing', label: '条形沉砂池', x: -150, z: -70, value: '12.8万m³', color: 0x4dd0e1 },
  20. { id: 'gate1', label: '退水闸', x: 0, z: -70, value: '退水', color: 0x4dd0e1 },
  21. { id: 'chen2', label: '2#沉砂池', x: 150, z: -70, value: '38团二期', color: 0x4dd0e1 },
  22. { id: 'chen1', label: '1#沉砂池', x: 300, z: -70, value: '38团一期', color: 0x4dd0e1 },
  23. { id: 'river', label: '莫勒切河', x: -150, z: 70, value: '来水', color: 0x4fc3f7 },
  24. { id: 'fenShui', label: '节制分水闸', x: 0, z: 70, value: '分水', color: 0x4dd0e1 },
  25. ]
  26. const nodeMap = new Map(nodes.map(n => [n.id, n]))
  27. const pipes = [
  28. // 线路1:渠首 → 条形沉砂池 → 退水闸 → 2#沉砂池
  29. { from: 'quShou', to: 'tiaoXing' },
  30. { from: 'tiaoXing', to: 'gate1' },
  31. { from: 'gate1', to: 'chen2' },
  32. // 线路2:莫勒切河 → 节制分水闸 → 2#沉砂池 + 1#沉砂池
  33. { from: 'river', to: 'fenShui' },
  34. { from: 'fenShui', to: 'chen2' },
  35. { from: 'fenShui', to: 'chen1' },
  36. // 2#沉砂池 → 1#沉砂池
  37. { from: 'chen2', to: 'chen1' },
  38. ]
  39. // 流动管线 Shader
  40. const pipeVertexShader = `
  41. attribute float aDistance;
  42. uniform float uTime;
  43. varying float vDist;
  44. void main() {
  45. vDist = aDistance + uTime * 0.15;
  46. vec4 mvPos = modelViewMatrix * vec4(position, 1.0);
  47. gl_Position = projectionMatrix * mvPos;
  48. }
  49. `
  50. const pipeFragmentShader = `
  51. uniform vec3 uColor;
  52. uniform float uOpacity;
  53. varying float vDist;
  54. void main() {
  55. // 流动条纹:沿管线方向亮暗交替
  56. float flow = sin((vDist - 0.5) * 6.283 * 3.0) * 0.5 + 0.5;
  57. flow = pow(flow, 2.0);
  58. float glow = 0.3 + 0.7 * flow;
  59. gl_FragColor = vec4(uColor * glow, uOpacity * (0.5 + 0.5 * flow));
  60. }
  61. `
  62. /** 创建一条带流动效果的管线(Tube 网格,支持线宽) */
  63. function createFlowLine(pts: THREE.Vector3[]) {
  64. // 用 CatmullRom 但 tension=0 保持直线
  65. const curve = new THREE.CatmullRomCurve3(pts, false, 'catmullrom', 0)
  66. const tubeGeo = new THREE.TubeGeometry(curve, 64, 1.8, 8, false)
  67. // 重新计算顶点距离属性
  68. const posAttr = tubeGeo.attributes.position
  69. const vertCount = posAttr.count
  70. const distances = new Float32Array(vertCount)
  71. let totalLen = 0
  72. // 按 TubeGeometry 的环结构估算距离
  73. for (let i = 0; i < vertCount; i++) {
  74. const x = posAttr.getX(i), y = posAttr.getY(i), z = posAttr.getZ(i)
  75. if (i > 0) {
  76. totalLen += Math.sqrt(
  77. (x - posAttr.getX(i-1))**2 + (y - posAttr.getY(i-1))**2 + (z - posAttr.getZ(i-1))**2
  78. )
  79. }
  80. distances[i] = totalLen
  81. }
  82. const maxLen = distances[vertCount - 1] || 1
  83. for (let i = 0; i < vertCount; i++) distances[i] /= maxLen
  84. tubeGeo.setAttribute('aDistance', new THREE.Float32BufferAttribute(distances, 1))
  85. const mat = new THREE.ShaderMaterial({
  86. uniforms: {
  87. uTime: { value: 0 },
  88. uColor: { value: new THREE.Color(0x4dd0e1) },
  89. uOpacity: { value: 0.9 },
  90. },
  91. vertexShader: pipeVertexShader,
  92. fragmentShader: pipeFragmentShader,
  93. transparent: true,
  94. blending: THREE.AdditiveBlending,
  95. depthWrite: false,
  96. })
  97. const mesh = new THREE.Mesh(tubeGeo, mat);
  98. (mesh as any)._pipeMat = mat
  99. return mesh
  100. }
  101. // 存储所有管线材质
  102. const pipeMats: THREE.ShaderMaterial[] = []
  103. /** 构建折线路径点 */
  104. function buildPoints(a: DeviceNode, b: DeviceNode, from: string, to: string): THREE.Vector3[] {
  105. // 分水闸 → 1#沉砂池:先右后下
  106. if (from === 'fenShui' && to === 'chen1') {
  107. return [
  108. new THREE.Vector3(a.x, 14, a.z),
  109. new THREE.Vector3(b.x, 14, a.z),
  110. new THREE.Vector3(b.x, 14, b.z),
  111. ]
  112. }
  113. // 同行/同列:直线
  114. if (a.z === b.z || a.x === b.x) {
  115. return [
  116. new THREE.Vector3(a.x, 14, a.z),
  117. new THREE.Vector3(b.x, 14, b.z),
  118. ]
  119. }
  120. // 标准 L 折线:先水平再垂直
  121. return [
  122. new THREE.Vector3(a.x, 14, a.z),
  123. new THREE.Vector3(b.x, 14, a.z),
  124. new THREE.Vector3(b.x, 14, b.z),
  125. ]
  126. }
  127. function initScene() {
  128. const el = containerRef.value
  129. if (!el) { console.warn('[H3D] no container'); return }
  130. const w = el.clientWidth, h = el.clientHeight
  131. console.log('[H3D] init', w, h)
  132. if (w === 0 || h === 0) return
  133. scene = new THREE.Scene()
  134. scene.background = new THREE.Color(0x0a1a2e)
  135. camera = new THREE.PerspectiveCamera(40, w / h, 1, 2000)
  136. camera.position.set(300, 350, 450)
  137. renderer = new THREE.WebGLRenderer({ antialias: true })
  138. renderer.setSize(w, h)
  139. renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
  140. el.appendChild(renderer.domElement)
  141. labelRenderer = new CSS2DRenderer()
  142. labelRenderer.setSize(w, h)
  143. labelRenderer.domElement.style.position = 'absolute'
  144. labelRenderer.domElement.style.top = '0'
  145. labelRenderer.domElement.style.left = '0'
  146. labelRenderer.domElement.style.pointerEvents = 'none'
  147. el.appendChild(labelRenderer.domElement)
  148. controls = new OrbitControls(camera, renderer.domElement)
  149. controls.enableDamping = true
  150. controls.dampingFactor = 0.1
  151. controls.target.set(0, 0, 0)
  152. controls.update()
  153. // 灯光
  154. scene.add(new THREE.AmbientLight(0x334466, 0.6))
  155. const dl = new THREE.DirectionalLight(0xffffcc, 1.2)
  156. dl.position.set(200, 500, 300)
  157. scene.add(dl)
  158. const fl = new THREE.DirectionalLight(0x4488ff, 0.4)
  159. fl.position.set(-200, 100, -200)
  160. scene.add(fl)
  161. // 地面网格
  162. scene.add(new THREE.GridHelper(600, 20, 0x1a3a5c, 0x0d2a4a))
  163. // 创建设备节点
  164. nodes.forEach(n => {
  165. const g = new THREE.Group()
  166. // 底座
  167. const base = new THREE.Mesh(
  168. new THREE.BoxGeometry(50, 6, 50),
  169. new THREE.MeshStandardMaterial({ color: 0x1a3050, roughness: 0.6, metalness: 0.3 })
  170. )
  171. base.position.y = 3; base.receiveShadow = true; base.castShadow = true
  172. g.add(base)
  173. // 主体
  174. const body = new THREE.Mesh(
  175. new THREE.BoxGeometry(36, 24, 36),
  176. new THREE.MeshStandardMaterial({ color: n.color, emissive: 0x0088ff, emissiveIntensity: 0.15, roughness: 0.3, metalness: 0.5 })
  177. )
  178. body.position.y = 18; body.castShadow = true
  179. g.add(body)
  180. // 顶条
  181. const rim = new THREE.Mesh(
  182. new THREE.BoxGeometry(40, 2, 40),
  183. new THREE.MeshStandardMaterial({ color: n.color, emissive: 0x0088ff, emissiveIntensity: 0.6 })
  184. )
  185. rim.position.y = 31
  186. g.add(rim)
  187. g.position.set(n.x, 0, n.z)
  188. scene.add(g)
  189. })
  190. // 管道(Shader 流动线)
  191. pipes.forEach(p => {
  192. const a = nodeMap.get(p.from)!, b = nodeMap.get(p.to)!
  193. const pts = buildPoints(a, b, p.from, p.to)
  194. const line = createFlowLine(pts)
  195. scene.add(line)
  196. pipeMats.push((line as any)._pipeMat)
  197. // 流动球 - 沿折线运动
  198. const dot = new THREE.Mesh(
  199. new THREE.SphereGeometry(4, 8, 8),
  200. new THREE.MeshStandardMaterial({ color: 0x4dd0e1, emissive: 0x4dd0e1, emissiveIntensity: 2 })
  201. )
  202. dot.userData = { pts, t: Math.random(), speed: 0.3 + Math.random() * 0.2 }
  203. scene.add(dot)
  204. flowDots.push(dot)
  205. })
  206. // CSS2D 标签
  207. nodes.forEach(n => {
  208. const div = document.createElement('div')
  209. div.style.cssText = 'text-align:center;pointer-events:none'
  210. const t = document.createElement('div')
  211. t.style.cssText = 'color:#fff;font:bold 13px "Microsoft YaHei",sans-serif;text-shadow:0 0 12px rgba(0,0,0,0.95)'
  212. t.textContent = n.label
  213. const v = document.createElement('div')
  214. v.style.cssText = 'color:#4fc3f7;font:11px "Microsoft YaHei",sans-serif;text-shadow:0 0 8px rgba(0,0,0,0.9)'
  215. v.textContent = n.value
  216. div.appendChild(t)
  217. div.appendChild(v)
  218. const label = new CSS2DObject(div)
  219. label.position.set(n.x, 42, n.z)
  220. scene.add(label)
  221. })
  222. console.log('[H3D] 初始化完成, 节点:', nodes.length, '管道:', pipes.length)
  223. window.addEventListener('resize', onResize)
  224. animate()
  225. }
  226. function animate() {
  227. animationId = requestAnimationFrame(animate)
  228. const time = performance.now() * 0.001
  229. pipeMats.forEach(mat => { mat.uniforms.uTime.value = time })
  230. flowDots.forEach(dot => {
  231. const { pts, t, speed } = dot.userData
  232. const nt = (t + 0.004 * speed) % 1
  233. dot.userData.t = nt
  234. // 沿折线分段插值
  235. const segCount = pts.length - 1
  236. const seg = nt * segCount
  237. const idx = Math.min(Math.floor(seg), segCount - 1)
  238. const frac = seg - idx
  239. const a = pts[idx], b = pts[idx + 1]
  240. dot.position.set(
  241. a.x + (b.x - a.x) * frac,
  242. (a.y + (b.y - a.y) * frac) + 0.5,
  243. a.z + (b.z - a.z) * frac,
  244. )
  245. })
  246. controls.update()
  247. renderer.render(scene, camera)
  248. labelRenderer.render(scene, camera)
  249. }
  250. function onResize() {
  251. if (!containerRef.value) return
  252. const w = containerRef.value.clientWidth, h = containerRef.value.clientHeight
  253. camera.aspect = w / h; camera.updateProjectionMatrix()
  254. renderer.setSize(w, h); labelRenderer.setSize(w, h)
  255. }
  256. function disposeScene() {
  257. window.removeEventListener('resize', onResize)
  258. cancelAnimationFrame(animationId)
  259. controls?.dispose()
  260. renderer?.dispose()
  261. if (labelRenderer) labelRenderer.domElement.remove()
  262. pipeMats.length = 0
  263. flowDots = []
  264. }
  265. onMounted(() => { try { initScene() } catch (e) { console.error('[H3D] error:', e) } })
  266. onUnmounted(() => disposeScene())
  267. </script>
  268. <template>
  269. <div ref="containerRef" class="diagram-container">
  270. <div class="header">
  271. <span class="title">38团水利工程组态图</span>
  272. <span class="hint">拖拽旋转 · 滚轮缩放</span>
  273. </div>
  274. </div>
  275. </template>
  276. <style scoped>
  277. .diagram-container {
  278. width: 100%; height: 100%;
  279. position: relative; overflow: hidden;
  280. background: #0a1a2e;
  281. }
  282. .header {
  283. position: absolute; top: 16px; left: 24px; z-index: 10;
  284. display: flex; align-items: center; gap: 20px;
  285. }
  286. .title {
  287. font: bold 18px "Microsoft YaHei", sans-serif;
  288. color: #4dd0e1;
  289. text-shadow: 0 0 20px rgba(77,208,225,0.3);
  290. }
  291. .hint {
  292. color: rgba(255,255,255,0.35);
  293. font: 12px "Microsoft YaHei", sans-serif;
  294. }
  295. </style>