threePipeEnhancement.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. /** Three.js adapter for a selected object and its bounded local underground context. */
  2. import * as THREE from 'three'
  3. import { enhancementLabel } from '../domain/pipeEnhancement.js'
  4. import { displayPipeDiameter, PIPE_PALETTE } from '../domain/pipeVisual.js'
  5. import { attachSceneFocus } from './sceneInputFocus.js'
  6. import { addDetailedPipeSegment } from './pipeDetailGeometry.js'
  7. import { attachPointPick } from './pointPick.js'
  8. import { pipeSurfaceMaterial, outlineSelectedPipe } from './pipeSurfaceMaterial.js'
  9. import { sourceDiameterM, pipeSurface } from '../domain/pipePhysical.js'
  10. function defaultRendererFactory({ canvas }) {
  11. return new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true })
  12. }
  13. function project([lon, lat], center, scale) {
  14. const cosLat = Math.cos((center[1] * Math.PI) / 180)
  15. return new THREE.Vector3(
  16. (lon - center[0]) * 111320 * cosLat * scale,
  17. 0,
  18. -(lat - center[1]) * 111320 * scale,
  19. )
  20. }
  21. function disposeObject(root) {
  22. const resources = new Set()
  23. root.traverse((node) => {
  24. if (node.geometry) resources.add(node.geometry)
  25. if (node.material) for (const value of [].concat(node.material)) resources.add(value)
  26. })
  27. for (const resource of resources) resource.dispose()
  28. }
  29. function material(color, opacity = 1) {
  30. return new THREE.MeshStandardMaterial({ color, roughness: 0.62, metalness: 0.18, transparent: opacity < 1, opacity })
  31. }
  32. function buildObject(plan, frame = null) {
  33. const object = plan.object
  34. const root = new THREE.Group()
  35. root.name = `pipe-enhancement:${object.code}`
  36. const center = object.renderKind === 'pipe'
  37. ? [0, 1].map((axis) => {
  38. const [min, max] = object.path.reduce(([lo, hi], point) =>
  39. [Math.min(lo, point[axis]), Math.max(hi, point[axis])], [Infinity, -Infinity])
  40. return (min + max) / 2
  41. })
  42. : object.position || object.path[0]
  43. const scale = 1
  44. const points = object.path.map((point) => project(point, frame?.center || center, scale))
  45. const color = (PIPE_PALETTE[object.colorKey] || PIPE_PALETTE.UNKNOWN).color
  46. if (object.renderKind === 'pipe') {
  47. const diameterM = object.displayDiameterM ?? displayPipeDiameter(object.diameterM ?? sourceDiameterM(object.diameter))
  48. const surface = object.surface || pipeSurface(object.material)
  49. if (diameterM === null) {
  50. const guide = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), new THREE.LineDashedMaterial({ color, dashSize: 1, gapSize: 0.6 }))
  51. guide.name = 'missing-diameter-guide'
  52. guide.computeLineDistances()
  53. root.add(guide)
  54. }
  55. for (let i = 1; diameterM !== null && i < points.length; i += 1) {
  56. if (object.primary === false) {
  57. const delta = new THREE.Vector3().subVectors(points[i], points[i - 1])
  58. const length = delta.length()
  59. if (length < 0.000001) continue
  60. const mesh = new THREE.Mesh(new THREE.CylinderGeometry(diameterM / 2, diameterM / 2, length, 16, 1, true), pipeSurfaceMaterial(color, surface))
  61. mesh.name = 'neighbor-pipe-wall'
  62. mesh.position.copy(points[i - 1]).add(points[i]).multiplyScalar(0.5)
  63. mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize())
  64. root.add(mesh)
  65. } else addDetailedPipeSegment(root, points[i - 1], points[i], color, {
  66. startFlange: i === 1,
  67. endFlange: i === points.length - 1,
  68. radius: diameterM / 2,
  69. surface,
  70. })
  71. }
  72. } else if (object.renderKind === 'manhole') {
  73. const shell = new THREE.Mesh(new THREE.CylinderGeometry(0.85, 0.85, 2.4, 32, 1, true, Math.PI * 0.25, Math.PI * 1.5),
  74. new THREE.MeshStandardMaterial({ color: '#718093', roughness: 0.9, side: THREE.DoubleSide }))
  75. shell.name = 'manhole-shaft'
  76. shell.position.copy(points[0])
  77. shell.position.y = -0.9
  78. root.add(shell)
  79. const base = new THREE.Mesh(new THREE.CylinderGeometry(0.86, 0.86, 0.12, 32), material('#53616f'))
  80. base.position.copy(points[0]); base.position.y = -2.15
  81. root.add(base)
  82. const cover = new THREE.Mesh(new THREE.CylinderGeometry(0.9, 0.9, 0.12, 32), material(color))
  83. cover.name = 'manhole-cover'
  84. cover.position.copy(points[0]); cover.position.y = 0.55
  85. cover.rotation.x = -0.2
  86. root.add(cover)
  87. } else {
  88. const shape = object.renderKind === 'valve'
  89. ? new THREE.CylinderGeometry(0.48, 0.48, 0.16, 24)
  90. : object.renderKind === 'elbow'
  91. ? new THREE.TorusGeometry(0.48, 0.15, 12, 24, Math.PI)
  92. : object.renderKind === 'branch' || object.renderKind === 'cross'
  93. ? new THREE.ConeGeometry(0.55, 0.72, object.renderKind === 'cross' ? 4 : 8)
  94. : new THREE.BoxGeometry(0.72, 0.72, 0.72)
  95. const mesh = new THREE.Mesh(shape, material(color))
  96. mesh.position.copy(points[0])
  97. if (object.renderKind === 'elbow') mesh.rotation.y = Math.PI / 2
  98. root.add(mesh)
  99. }
  100. if (object.primary !== false) outlineSelectedPipe(root)
  101. root.position.y = frame ? ((frame.depthM || 0) - (object.depthM || 0)) * scale : 0
  102. root.userData = { code: object.code, sourceKind: object.sourceKind, primary: object.primary !== false, clipped: object.clipped === true }
  103. return root
  104. }
  105. function buildNeighborhood(plan) {
  106. if ((!plan.objects || plan.objects.length <= 1) && !plan.devices?.length) return buildObject(plan)
  107. const objects = plan.objects || [{ ...plan.object, primary: true, depthM: 0 }]
  108. const path = plan.object.path
  109. const center = [0, 1].map((axis) => {
  110. const [min, max] = path.reduce(([lo, hi], p) => [Math.min(lo, p[axis]), Math.max(hi, p[axis])], [Infinity, -Infinity])
  111. return (min + max) / 2
  112. })
  113. const frame = { center, scale: 1, depthM: objects[0].depthM }
  114. const root = new THREE.Group()
  115. root.name = `pipe-enhancement:${plan.object.code}`
  116. for (const object of objects) root.add(buildObject({ object }, frame))
  117. for (const device of plan.devices || []) {
  118. const group = new THREE.Group()
  119. group.name = `device:${device.code}`
  120. group.userData.device = device
  121. group.position.copy(project(device.position, center, frame.scale))
  122. const radius = device.pipeDisplayRadiusM || 0
  123. const deviceScale = Math.max(3.5, Math.min(7, radius * 2))
  124. group.scale.setScalar(deviceScale)
  125. group.renderOrder = 20
  126. group.position.y = ((frame.depthM || 0) - device.depthM) * frame.scale + Math.max(radius * 1.06, device.mountHeightM || 0) + deviceScale * 0.35
  127. const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.25, 8), material('#b9c5d0'))
  128. stem.position.y = -0.16
  129. stem.renderOrder = 21
  130. stem.material.depthTest = false
  131. stem.material.depthWrite = false
  132. group.add(stem)
  133. const color = (PIPE_PALETTE[device.colorKey] || PIPE_PALETTE.UNKNOWN).color
  134. const shape = device.industry === 'GAS' ? new THREE.BoxGeometry(0.22, 0.32, 0.18)
  135. : device.industry === 'WATER_SUPPLY' ? new THREE.CylinderGeometry(0.15, 0.15, 0.28, 12)
  136. : device.industry === 'MANHOLE' ? new THREE.BoxGeometry(0.3, 0.1, 0.22)
  137. : new THREE.ConeGeometry(0.17, 0.3, 12)
  138. const body = new THREE.Mesh(shape, new THREE.MeshStandardMaterial({ color, metalness: 0.25, roughness: 0.3,
  139. emissive: color, emissiveIntensity: 0.28, depthTest: false, depthWrite: false }))
  140. body.name = 'device-body'
  141. group.add(body)
  142. const indicator = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.07, 0.035), new THREE.MeshBasicMaterial({ color: '#edffff' }))
  143. indicator.position.set(0, 0.05, 0.16)
  144. indicator.material.depthTest = false
  145. indicator.material.depthWrite = false
  146. indicator.renderOrder = 22
  147. group.add(indicator)
  148. root.add(group)
  149. }
  150. return root
  151. }
  152. /**
  153. * Mount a bounded local viewer. The optional rendererFactory is a system-boundary seam
  154. * for offline tests; production uses WebGLRenderer.
  155. */
  156. export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRendererFactory, pixelRatio = 1, onDevicePick = () => {}, onObjectPick = () => {} } = {}) {
  157. if (!host || !plan || plan.visible !== true || !plan.object) {
  158. return { status: 'unavailable', sync: () => {}, destroy: () => {}, getState: () => ({ status: 'unavailable' }) }
  159. }
  160. const scene = new THREE.Scene()
  161. scene.background = new THREE.Color(0x031226)
  162. const camera = new THREE.PerspectiveCamera(36, 1, 0.1, 100)
  163. camera.position.set(0, 3.8, 7.2)
  164. camera.lookAt(0, 0, 0)
  165. scene.add(new THREE.HemisphereLight(0xbde8ff, 0x061127, 2.2))
  166. const key = new THREE.DirectionalLight(0xffffff, 2.4)
  167. key.position.set(3, 6, 4)
  168. scene.add(key)
  169. const fill = new THREE.DirectionalLight(0x89baff, 1.8)
  170. fill.position.set(-4, 2, -3)
  171. scene.add(fill)
  172. const rim = new THREE.DirectionalLight(0xffe2bb, 2)
  173. rim.position.set(1, -1, -4)
  174. scene.add(rim)
  175. let renderer
  176. try {
  177. renderer = rendererFactory({ canvas: host.ownerDocument?.createElement?.('canvas') || null })
  178. renderer.setPixelRatio?.(pixelRatio)
  179. renderer.setSize?.(host.clientWidth || 320, host.clientHeight || 220, false)
  180. if (renderer.domElement && !renderer.domElement.parentNode) host.appendChild(renderer.domElement)
  181. } catch (error) {
  182. return { status: 'failed', reason: String(error?.message || error), sync: () => {}, destroy: () => {}, getState: () => ({ status: 'failed', reason: String(error?.message || error) }) }
  183. }
  184. let root = null
  185. let current = plan
  186. const target = new THREE.Vector3()
  187. let yaw = 0
  188. let pitch = Math.atan2(3.8, 7.2)
  189. let distance = Math.hypot(3.8, 7.2)
  190. let sceneRadius = 1
  191. let drag = null
  192. const canvas = renderer.domElement || host
  193. let destroyed = false
  194. const resize = () => {
  195. if (destroyed) return
  196. const width = host.clientWidth || 320
  197. const height = host.clientHeight || 220
  198. camera.aspect = width / Math.max(1, height)
  199. camera.updateProjectionMatrix()
  200. renderer.setSize?.(width, height, false)
  201. render()
  202. }
  203. const render = () => {
  204. const horizontal = Math.cos(pitch) * distance
  205. camera.position.set(
  206. target.x + Math.sin(yaw) * horizontal,
  207. target.y + Math.sin(pitch) * distance,
  208. target.z + Math.cos(yaw) * horizontal,
  209. )
  210. root?.traverse((node) => {
  211. if (node.name === 'selected-pipe-outline') node.material.uniforms.resolution.value.set(host.clientWidth || 320, host.clientHeight || 220)
  212. })
  213. camera.near = Math.max(0.0001, distance > sceneRadius * 1.05 ? distance - sceneRadius * 1.05 : distance / 1000)
  214. camera.far = Math.max(camera.near + 1, distance + sceneRadius * 2)
  215. camera.updateProjectionMatrix()
  216. camera.lookAt(target)
  217. renderer.render?.(scene, camera)
  218. }
  219. const sync = (next) => {
  220. const preserveView = root && current.object.code === next.object.code && current.object.sourceKind === next.object.sourceKind
  221. current = next
  222. if (root) {
  223. scene.remove(root)
  224. disposeObject(root)
  225. }
  226. root = buildNeighborhood(next)
  227. scene.add(root)
  228. root.updateMatrixWorld(true)
  229. const box = new THREE.Box3().setFromObject(root)
  230. const sphere = box.getBoundingSphere(new THREE.Sphere())
  231. if (!box.isEmpty()) {
  232. sceneRadius = Math.max(0.01, sphere.radius)
  233. if (!preserveView) {
  234. target.copy(sphere.center)
  235. const halfFov = Math.atan(Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * Math.min(1, camera.aspect))
  236. distance = sceneRadius / Math.sin(halfFov) * 1.12
  237. }
  238. camera.near = Math.max(0.0001, sceneRadius / 10000)
  239. camera.far = Math.max(100, sceneRadius * 100)
  240. camera.updateProjectionMatrix()
  241. }
  242. render()
  243. }
  244. const detachFocus = attachSceneFocus(host)
  245. const raycaster = new THREE.Raycaster()
  246. const hitAt = (x, y) => {
  247. if (!root || !canvas.getBoundingClientRect || !Number.isFinite(x) || !Number.isFinite(y)) return null
  248. const rect = canvas.getBoundingClientRect()
  249. if (!rect.width || !rect.height || x < rect.left || x > rect.left + rect.width || y < rect.top || y > rect.top + rect.height) return null
  250. camera.updateMatrixWorld()
  251. root.updateMatrixWorld(true)
  252. raycaster.setFromCamera(new THREE.Vector2((x - rect.left) / rect.width * 2 - 1, 1 - (y - rect.top) / rect.height * 2), camera)
  253. return raycaster.intersectObject(root, true)[0] || null
  254. }
  255. const detachPick = attachPointPick(canvas, ({ x, y }) => {
  256. let node = hitAt(x, y)?.object
  257. while (node) {
  258. if (node.userData.device) { onDevicePick(node.userData.device); return }
  259. if (node.userData.sourceKind) { onObjectPick({ objectKind: node.userData.sourceKind, code: node.userData.code }); return }
  260. node = node.parent
  261. }
  262. })
  263. const onDown = (event) => {
  264. if (drag || (event.button !== 0 && event.button !== 2)) return
  265. const panDistance = Math.max(0.02, Math.min(distance, hitAt(event.clientX, event.clientY)?.distance ?? distance))
  266. drag = { button: event.button, id: event.pointerId, x: event.clientX, y: event.clientY, panDistance }
  267. event.preventDefault?.()
  268. try { canvas.setPointerCapture?.(event.pointerId) } catch { /* 合成事件可能没有可捕获指针。 */ }
  269. }
  270. const onMove = (event) => {
  271. if (!drag || event.pointerId !== drag.id) return
  272. const dx = event.clientX - drag.x
  273. const dy = event.clientY - drag.y
  274. if (drag.button === 0) {
  275. // 将像素位移换算为目标平面距离,保持“抓住场景拖动”的方向和速度。
  276. const unitsPerPixel = 2 * Math.min(distance, drag.panDistance) * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) / (host.clientHeight || 220)
  277. const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion)
  278. const up = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion)
  279. target.addScaledVector(right, -dx * unitsPerPixel).addScaledVector(up, dy * unitsPerPixel)
  280. } else {
  281. yaw -= dx * 0.01
  282. pitch = Math.max(-1.4, Math.min(1.4, pitch + dy * 0.008))
  283. }
  284. drag.x = event.clientX
  285. drag.y = event.clientY
  286. render()
  287. }
  288. const onUp = (event) => {
  289. if (!drag || (event?.pointerId !== undefined && event.pointerId !== drag.id)) return
  290. const id = drag.id
  291. drag = null
  292. try { canvas.releasePointerCapture?.(id) } catch { /* 指针可能已由浏览器释放。 */ }
  293. }
  294. const onLeave = (event) => {
  295. if (!canvas.hasPointerCapture?.(event.pointerId)) onUp(event)
  296. }
  297. const onWheel = (event) => {
  298. event.preventDefault?.()
  299. const hit = hitAt(event.clientX, event.clientY)
  300. const factor = Math.exp(Math.max(-1000, Math.min(1000, event.deltaY)) * 0.001)
  301. const limit = Math.max(sceneRadius * 50, 10)
  302. if (hit) {
  303. const forward = camera.getWorldDirection(new THREE.Vector3())
  304. const nextDistance = Math.max(0.02, Math.min(limit, hit.distance * factor))
  305. const nextPosition = camera.position.clone().addScaledVector(raycaster.ray.direction, hit.distance - nextDistance)
  306. distance = nextDistance
  307. target.copy(nextPosition).addScaledVector(forward, distance)
  308. } else distance = Math.max(0.02, Math.min(limit, distance * factor))
  309. render()
  310. }
  311. canvas.addEventListener?.('pointerdown', onDown)
  312. canvas.addEventListener?.('pointermove', onMove)
  313. canvas.addEventListener?.('pointerup', onUp)
  314. canvas.addEventListener?.('pointercancel', onUp)
  315. canvas.addEventListener?.('lostpointercapture', onUp)
  316. canvas.addEventListener?.('pointerleave', onLeave)
  317. canvas.addEventListener?.('wheel', onWheel, { passive: false })
  318. resize()
  319. sync(plan)
  320. const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(resize) : null
  321. observer?.observe(host)
  322. return {
  323. status: 'ready',
  324. title: enhancementLabel(plan.object.sourceKind),
  325. sync(next) { if (!destroyed && next?.visible && next.object) sync(next) },
  326. resize,
  327. focusDevice(code) {
  328. if (destroyed) return false
  329. const device = root?.getObjectByName(`device:${code}`)
  330. if (!device) return false
  331. const bounds = new THREE.Box3().setFromObject(device).getBoundingSphere(new THREE.Sphere())
  332. target.copy(bounds.center)
  333. distance = Math.max(0.5, bounds.radius / Math.sin(THREE.MathUtils.degToRad(camera.fov / 2)) * 1.3)
  334. render()
  335. return true
  336. },
  337. getState: () => ({ status: destroyed ? 'destroyed' : 'ready', code: current.object.code, title: enhancementLabel(current.object.sourceKind) }),
  338. destroy() {
  339. if (destroyed) return
  340. destroyed = true
  341. onUp()
  342. observer?.disconnect()
  343. detachFocus()
  344. detachPick()
  345. canvas.removeEventListener?.('pointerdown', onDown)
  346. canvas.removeEventListener?.('pointermove', onMove)
  347. canvas.removeEventListener?.('pointerup', onUp)
  348. canvas.removeEventListener?.('pointercancel', onUp)
  349. canvas.removeEventListener?.('lostpointercapture', onUp)
  350. canvas.removeEventListener?.('pointerleave', onLeave)
  351. canvas.removeEventListener?.('wheel', onWheel)
  352. if (root) disposeObject(root)
  353. renderer.dispose?.()
  354. if (renderer.domElement?.parentNode === host) host.removeChild(renderer.domElement)
  355. },
  356. }
  357. }