| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369 |
- /** Three.js adapter for a selected object and its bounded local underground context. */
- import * as THREE from 'three'
- import { enhancementLabel } from '../domain/pipeEnhancement.js'
- import { displayPipeDiameter, PIPE_PALETTE } from '../domain/pipeVisual.js'
- import { attachSceneFocus } from './sceneInputFocus.js'
- import { addDetailedPipeSegment } from './pipeDetailGeometry.js'
- import { attachPointPick } from './pointPick.js'
- import { pipeSurfaceMaterial, outlineSelectedPipe } from './pipeSurfaceMaterial.js'
- import { sourceDiameterM, pipeSurface } from '../domain/pipePhysical.js'
- function defaultRendererFactory({ canvas }) {
- return new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true })
- }
- function project([lon, lat], center, scale) {
- const cosLat = Math.cos((center[1] * Math.PI) / 180)
- return new THREE.Vector3(
- (lon - center[0]) * 111320 * cosLat * scale,
- 0,
- -(lat - center[1]) * 111320 * scale,
- )
- }
- function disposeObject(root) {
- const resources = new Set()
- root.traverse((node) => {
- if (node.geometry) resources.add(node.geometry)
- if (node.material) for (const value of [].concat(node.material)) resources.add(value)
- })
- for (const resource of resources) resource.dispose()
- }
- function material(color, opacity = 1) {
- return new THREE.MeshStandardMaterial({ color, roughness: 0.62, metalness: 0.18, transparent: opacity < 1, opacity })
- }
- function buildObject(plan, frame = null) {
- const object = plan.object
- const root = new THREE.Group()
- root.name = `pipe-enhancement:${object.code}`
- const center = object.renderKind === 'pipe'
- ? [0, 1].map((axis) => {
- const [min, max] = object.path.reduce(([lo, hi], point) =>
- [Math.min(lo, point[axis]), Math.max(hi, point[axis])], [Infinity, -Infinity])
- return (min + max) / 2
- })
- : object.position || object.path[0]
- const scale = 1
- const points = object.path.map((point) => project(point, frame?.center || center, scale))
- const color = (PIPE_PALETTE[object.colorKey] || PIPE_PALETTE.UNKNOWN).color
- if (object.renderKind === 'pipe') {
- const diameterM = object.displayDiameterM ?? displayPipeDiameter(object.diameterM ?? sourceDiameterM(object.diameter))
- const surface = object.surface || pipeSurface(object.material)
- if (diameterM === null) {
- const guide = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), new THREE.LineDashedMaterial({ color, dashSize: 1, gapSize: 0.6 }))
- guide.name = 'missing-diameter-guide'
- guide.computeLineDistances()
- root.add(guide)
- }
- for (let i = 1; diameterM !== null && i < points.length; i += 1) {
- if (object.primary === false) {
- const delta = new THREE.Vector3().subVectors(points[i], points[i - 1])
- const length = delta.length()
- if (length < 0.000001) continue
- const mesh = new THREE.Mesh(new THREE.CylinderGeometry(diameterM / 2, diameterM / 2, length, 16, 1, true), pipeSurfaceMaterial(color, surface))
- mesh.name = 'neighbor-pipe-wall'
- mesh.position.copy(points[i - 1]).add(points[i]).multiplyScalar(0.5)
- mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize())
- root.add(mesh)
- } else addDetailedPipeSegment(root, points[i - 1], points[i], color, {
- startFlange: i === 1,
- endFlange: i === points.length - 1,
- radius: diameterM / 2,
- surface,
- })
- }
- } else if (object.renderKind === 'manhole') {
- 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),
- new THREE.MeshStandardMaterial({ color: '#718093', roughness: 0.9, side: THREE.DoubleSide }))
- shell.name = 'manhole-shaft'
- shell.position.copy(points[0])
- shell.position.y = -0.9
- root.add(shell)
- const base = new THREE.Mesh(new THREE.CylinderGeometry(0.86, 0.86, 0.12, 32), material('#53616f'))
- base.position.copy(points[0]); base.position.y = -2.15
- root.add(base)
- const cover = new THREE.Mesh(new THREE.CylinderGeometry(0.9, 0.9, 0.12, 32), material(color))
- cover.name = 'manhole-cover'
- cover.position.copy(points[0]); cover.position.y = 0.55
- cover.rotation.x = -0.2
- root.add(cover)
- } else {
- const shape = object.renderKind === 'valve'
- ? new THREE.CylinderGeometry(0.48, 0.48, 0.16, 24)
- : object.renderKind === 'elbow'
- ? new THREE.TorusGeometry(0.48, 0.15, 12, 24, Math.PI)
- : object.renderKind === 'branch' || object.renderKind === 'cross'
- ? new THREE.ConeGeometry(0.55, 0.72, object.renderKind === 'cross' ? 4 : 8)
- : new THREE.BoxGeometry(0.72, 0.72, 0.72)
- const mesh = new THREE.Mesh(shape, material(color))
- mesh.position.copy(points[0])
- if (object.renderKind === 'elbow') mesh.rotation.y = Math.PI / 2
- root.add(mesh)
- }
- if (object.primary !== false) outlineSelectedPipe(root)
- root.position.y = frame ? ((frame.depthM || 0) - (object.depthM || 0)) * scale : 0
- root.userData = { code: object.code, sourceKind: object.sourceKind, primary: object.primary !== false, clipped: object.clipped === true }
- return root
- }
- function buildNeighborhood(plan) {
- if ((!plan.objects || plan.objects.length <= 1) && !plan.devices?.length) return buildObject(plan)
- const objects = plan.objects || [{ ...plan.object, primary: true, depthM: 0 }]
- const path = plan.object.path
- const center = [0, 1].map((axis) => {
- const [min, max] = path.reduce(([lo, hi], p) => [Math.min(lo, p[axis]), Math.max(hi, p[axis])], [Infinity, -Infinity])
- return (min + max) / 2
- })
- const frame = { center, scale: 1, depthM: objects[0].depthM }
- const root = new THREE.Group()
- root.name = `pipe-enhancement:${plan.object.code}`
- for (const object of objects) root.add(buildObject({ object }, frame))
- for (const device of plan.devices || []) {
- const group = new THREE.Group()
- group.name = `device:${device.code}`
- group.userData.device = device
- group.position.copy(project(device.position, center, frame.scale))
- const radius = device.pipeDisplayRadiusM || 0
- const deviceScale = Math.max(3.5, Math.min(7, radius * 2))
- group.scale.setScalar(deviceScale)
- group.renderOrder = 20
- group.position.y = ((frame.depthM || 0) - device.depthM) * frame.scale + Math.max(radius * 1.06, device.mountHeightM || 0) + deviceScale * 0.35
- const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.25, 8), material('#b9c5d0'))
- stem.position.y = -0.16
- stem.renderOrder = 21
- stem.material.depthTest = false
- stem.material.depthWrite = false
- group.add(stem)
- const color = (PIPE_PALETTE[device.colorKey] || PIPE_PALETTE.UNKNOWN).color
- const shape = device.industry === 'GAS' ? new THREE.BoxGeometry(0.22, 0.32, 0.18)
- : device.industry === 'WATER_SUPPLY' ? new THREE.CylinderGeometry(0.15, 0.15, 0.28, 12)
- : device.industry === 'MANHOLE' ? new THREE.BoxGeometry(0.3, 0.1, 0.22)
- : new THREE.ConeGeometry(0.17, 0.3, 12)
- const body = new THREE.Mesh(shape, new THREE.MeshStandardMaterial({ color, metalness: 0.25, roughness: 0.3,
- emissive: color, emissiveIntensity: 0.28, depthTest: false, depthWrite: false }))
- body.name = 'device-body'
- group.add(body)
- const indicator = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.07, 0.035), new THREE.MeshBasicMaterial({ color: '#edffff' }))
- indicator.position.set(0, 0.05, 0.16)
- indicator.material.depthTest = false
- indicator.material.depthWrite = false
- indicator.renderOrder = 22
- group.add(indicator)
- root.add(group)
- }
- return root
- }
- /**
- * Mount a bounded local viewer. The optional rendererFactory is a system-boundary seam
- * for offline tests; production uses WebGLRenderer.
- */
- export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRendererFactory, pixelRatio = 1, onDevicePick = () => {}, onObjectPick = () => {} } = {}) {
- if (!host || !plan || plan.visible !== true || !plan.object) {
- return { status: 'unavailable', sync: () => {}, destroy: () => {}, getState: () => ({ status: 'unavailable' }) }
- }
- const scene = new THREE.Scene()
- scene.background = new THREE.Color(0x031226)
- const camera = new THREE.PerspectiveCamera(36, 1, 0.1, 100)
- camera.position.set(0, 3.8, 7.2)
- camera.lookAt(0, 0, 0)
- scene.add(new THREE.HemisphereLight(0xbde8ff, 0x061127, 2.2))
- const key = new THREE.DirectionalLight(0xffffff, 2.4)
- key.position.set(3, 6, 4)
- scene.add(key)
- const fill = new THREE.DirectionalLight(0x89baff, 1.8)
- fill.position.set(-4, 2, -3)
- scene.add(fill)
- const rim = new THREE.DirectionalLight(0xffe2bb, 2)
- rim.position.set(1, -1, -4)
- scene.add(rim)
- let renderer
- try {
- renderer = rendererFactory({ canvas: host.ownerDocument?.createElement?.('canvas') || null })
- renderer.setPixelRatio?.(pixelRatio)
- renderer.setSize?.(host.clientWidth || 320, host.clientHeight || 220, false)
- if (renderer.domElement && !renderer.domElement.parentNode) host.appendChild(renderer.domElement)
- } catch (error) {
- return { status: 'failed', reason: String(error?.message || error), sync: () => {}, destroy: () => {}, getState: () => ({ status: 'failed', reason: String(error?.message || error) }) }
- }
- let root = null
- let current = plan
- const target = new THREE.Vector3()
- let yaw = 0
- let pitch = Math.atan2(3.8, 7.2)
- let distance = Math.hypot(3.8, 7.2)
- let sceneRadius = 1
- let drag = null
- const canvas = renderer.domElement || host
- let destroyed = false
- const resize = () => {
- if (destroyed) return
- const width = host.clientWidth || 320
- const height = host.clientHeight || 220
- camera.aspect = width / Math.max(1, height)
- camera.updateProjectionMatrix()
- renderer.setSize?.(width, height, false)
- render()
- }
- const render = () => {
- const horizontal = Math.cos(pitch) * distance
- camera.position.set(
- target.x + Math.sin(yaw) * horizontal,
- target.y + Math.sin(pitch) * distance,
- target.z + Math.cos(yaw) * horizontal,
- )
- root?.traverse((node) => {
- if (node.name === 'selected-pipe-outline') node.material.uniforms.resolution.value.set(host.clientWidth || 320, host.clientHeight || 220)
- })
- camera.near = Math.max(0.0001, distance > sceneRadius * 1.05 ? distance - sceneRadius * 1.05 : distance / 1000)
- camera.far = Math.max(camera.near + 1, distance + sceneRadius * 2)
- camera.updateProjectionMatrix()
- camera.lookAt(target)
- renderer.render?.(scene, camera)
- }
- const sync = (next) => {
- const preserveView = root && current.object.code === next.object.code && current.object.sourceKind === next.object.sourceKind
- current = next
- if (root) {
- scene.remove(root)
- disposeObject(root)
- }
- root = buildNeighborhood(next)
- scene.add(root)
- root.updateMatrixWorld(true)
- const box = new THREE.Box3().setFromObject(root)
- const sphere = box.getBoundingSphere(new THREE.Sphere())
- if (!box.isEmpty()) {
- sceneRadius = Math.max(0.01, sphere.radius)
- if (!preserveView) {
- target.copy(sphere.center)
- const halfFov = Math.atan(Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * Math.min(1, camera.aspect))
- distance = sceneRadius / Math.sin(halfFov) * 1.12
- }
- camera.near = Math.max(0.0001, sceneRadius / 10000)
- camera.far = Math.max(100, sceneRadius * 100)
- camera.updateProjectionMatrix()
- }
- render()
- }
- const detachFocus = attachSceneFocus(host)
- const raycaster = new THREE.Raycaster()
- const hitAt = (x, y) => {
- if (!root || !canvas.getBoundingClientRect || !Number.isFinite(x) || !Number.isFinite(y)) return null
- const rect = canvas.getBoundingClientRect()
- if (!rect.width || !rect.height || x < rect.left || x > rect.left + rect.width || y < rect.top || y > rect.top + rect.height) return null
- camera.updateMatrixWorld()
- root.updateMatrixWorld(true)
- raycaster.setFromCamera(new THREE.Vector2((x - rect.left) / rect.width * 2 - 1, 1 - (y - rect.top) / rect.height * 2), camera)
- return raycaster.intersectObject(root, true)[0] || null
- }
- const detachPick = attachPointPick(canvas, ({ x, y }) => {
- let node = hitAt(x, y)?.object
- while (node) {
- if (node.userData.device) { onDevicePick(node.userData.device); return }
- if (node.userData.sourceKind) { onObjectPick({ objectKind: node.userData.sourceKind, code: node.userData.code }); return }
- node = node.parent
- }
- })
- const onDown = (event) => {
- if (drag || (event.button !== 0 && event.button !== 2)) return
- const panDistance = Math.max(0.02, Math.min(distance, hitAt(event.clientX, event.clientY)?.distance ?? distance))
- drag = { button: event.button, id: event.pointerId, x: event.clientX, y: event.clientY, panDistance }
- event.preventDefault?.()
- try { canvas.setPointerCapture?.(event.pointerId) } catch { /* 合成事件可能没有可捕获指针。 */ }
- }
- const onMove = (event) => {
- if (!drag || event.pointerId !== drag.id) return
- const dx = event.clientX - drag.x
- const dy = event.clientY - drag.y
- if (drag.button === 0) {
- // 将像素位移换算为目标平面距离,保持“抓住场景拖动”的方向和速度。
- const unitsPerPixel = 2 * Math.min(distance, drag.panDistance) * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) / (host.clientHeight || 220)
- const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion)
- const up = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion)
- target.addScaledVector(right, -dx * unitsPerPixel).addScaledVector(up, dy * unitsPerPixel)
- } else {
- yaw -= dx * 0.01
- pitch = Math.max(-1.4, Math.min(1.4, pitch + dy * 0.008))
- }
- drag.x = event.clientX
- drag.y = event.clientY
- render()
- }
- const onUp = (event) => {
- if (!drag || (event?.pointerId !== undefined && event.pointerId !== drag.id)) return
- const id = drag.id
- drag = null
- try { canvas.releasePointerCapture?.(id) } catch { /* 指针可能已由浏览器释放。 */ }
- }
- const onLeave = (event) => {
- if (!canvas.hasPointerCapture?.(event.pointerId)) onUp(event)
- }
- const onWheel = (event) => {
- event.preventDefault?.()
- const hit = hitAt(event.clientX, event.clientY)
- const factor = Math.exp(Math.max(-1000, Math.min(1000, event.deltaY)) * 0.001)
- const limit = Math.max(sceneRadius * 50, 10)
- if (hit) {
- const forward = camera.getWorldDirection(new THREE.Vector3())
- const nextDistance = Math.max(0.02, Math.min(limit, hit.distance * factor))
- const nextPosition = camera.position.clone().addScaledVector(raycaster.ray.direction, hit.distance - nextDistance)
- distance = nextDistance
- target.copy(nextPosition).addScaledVector(forward, distance)
- } else distance = Math.max(0.02, Math.min(limit, distance * factor))
- render()
- }
- canvas.addEventListener?.('pointerdown', onDown)
- canvas.addEventListener?.('pointermove', onMove)
- canvas.addEventListener?.('pointerup', onUp)
- canvas.addEventListener?.('pointercancel', onUp)
- canvas.addEventListener?.('lostpointercapture', onUp)
- canvas.addEventListener?.('pointerleave', onLeave)
- canvas.addEventListener?.('wheel', onWheel, { passive: false })
- resize()
- sync(plan)
- const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(resize) : null
- observer?.observe(host)
- return {
- status: 'ready',
- title: enhancementLabel(plan.object.sourceKind),
- sync(next) { if (!destroyed && next?.visible && next.object) sync(next) },
- resize,
- focusDevice(code) {
- if (destroyed) return false
- const device = root?.getObjectByName(`device:${code}`)
- if (!device) return false
- const bounds = new THREE.Box3().setFromObject(device).getBoundingSphere(new THREE.Sphere())
- target.copy(bounds.center)
- distance = Math.max(0.5, bounds.radius / Math.sin(THREE.MathUtils.degToRad(camera.fov / 2)) * 1.3)
- render()
- return true
- },
- getState: () => ({ status: destroyed ? 'destroyed' : 'ready', code: current.object.code, title: enhancementLabel(current.object.sourceKind) }),
- destroy() {
- if (destroyed) return
- destroyed = true
- onUp()
- observer?.disconnect()
- detachFocus()
- detachPick()
- canvas.removeEventListener?.('pointerdown', onDown)
- canvas.removeEventListener?.('pointermove', onMove)
- canvas.removeEventListener?.('pointerup', onUp)
- canvas.removeEventListener?.('pointercancel', onUp)
- canvas.removeEventListener?.('lostpointercapture', onUp)
- canvas.removeEventListener?.('pointerleave', onLeave)
- canvas.removeEventListener?.('wheel', onWheel)
- if (root) disposeObject(root)
- renderer.dispose?.()
- if (renderer.domElement?.parentNode === host) host.removeChild(renderer.domElement)
- },
- }
- }
|