/** 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) }, } }