|
|
@@ -1,7 +1,7 @@
|
|
|
/** 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 { PIPE_PALETTE } from '../domain/pipeVisual.js'
|
|
|
+import { displayPipeDiameter, PIPE_PALETTE } from '../domain/pipeVisual.js'
|
|
|
import { attachSceneFocus } from './sceneInputFocus.js'
|
|
|
import { addDetailedPipeSegment } from './pipeDetailGeometry.js'
|
|
|
import { attachPointPick } from './pointPick.js'
|
|
|
@@ -50,7 +50,7 @@ function buildObject(plan, frame = null) {
|
|
|
const color = (PIPE_PALETTE[object.colorKey] || PIPE_PALETTE.UNKNOWN).color
|
|
|
|
|
|
if (object.renderKind === 'pipe') {
|
|
|
- const diameterM = object.diameterM ?? sourceDiameterM(object.diameter)
|
|
|
+ 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 }))
|
|
|
@@ -112,7 +112,7 @@ function buildObject(plan, frame = null) {
|
|
|
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 = [...objects.flatMap((object) => object.path), ...(plan.devices || []).map((device) => device.position)]
|
|
|
+ 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
|
|
|
@@ -126,7 +126,10 @@ function buildNeighborhood(plan) {
|
|
|
group.name = `device:${device.code}`
|
|
|
group.userData.device = device
|
|
|
group.position.copy(project(device.position, center, frame.scale))
|
|
|
- group.position.y = ((frame.depthM || 0) - device.depthM) * frame.scale + 0.35
|
|
|
+ const radius = device.pipeDisplayRadiusM || 0
|
|
|
+ const deviceScale = Math.max(2, Math.min(6, radius * 2))
|
|
|
+ group.scale.setScalar(deviceScale)
|
|
|
+ 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
|
|
|
group.add(stem)
|
|
|
@@ -135,12 +138,15 @@ function buildNeighborhood(plan) {
|
|
|
: 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)
|
|
|
- group.add(new THREE.Mesh(shape, material(color)))
|
|
|
+ const body = new THREE.Mesh(shape, new THREE.MeshStandardMaterial({ color, metalness: 0.25, roughness: 0.3,
|
|
|
+ emissive: color, emissiveIntensity: 0.28 }))
|
|
|
+ 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)
|
|
|
+ group.add(indicator)
|
|
|
root.add(group)
|
|
|
}
|
|
|
- root.updateMatrixWorld(true)
|
|
|
- const box = new THREE.Box3().setFromObject(root)
|
|
|
- root.position.sub(box.getCenter(new THREE.Vector3()))
|
|
|
return root
|
|
|
}
|
|
|
|
|
|
@@ -148,7 +154,7 @@ function buildNeighborhood(plan) {
|
|
|
* 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 = () => {} } = {}) {
|
|
|
+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' }) }
|
|
|
}
|
|
|
@@ -162,6 +168,12 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
|
|
|
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 {
|
|
|
@@ -209,6 +221,7 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
|
|
|
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)
|
|
|
@@ -221,9 +234,11 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
|
|
|
const sphere = box.getBoundingSphere(new THREE.Sphere())
|
|
|
if (!box.isEmpty()) {
|
|
|
sceneRadius = Math.max(0.01, sphere.radius)
|
|
|
- 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
|
|
|
+ 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()
|
|
|
@@ -232,21 +247,27 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
|
|
|
}
|
|
|
const detachFocus = attachSceneFocus(host)
|
|
|
const raycaster = new THREE.Raycaster()
|
|
|
- const detachPick = attachPointPick(canvas, ({ x, y }) => {
|
|
|
- if (!root || !canvas.getBoundingClientRect) return
|
|
|
+ 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) return
|
|
|
+ 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)
|
|
|
- const hit = raycaster.intersectObject(root, true)[0]
|
|
|
- let node = hit?.object
|
|
|
- while (node && !node.userData.device) node = node.parent
|
|
|
- if (node?.userData.device) onDevicePick(node.userData.device)
|
|
|
+ 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
|
|
|
- drag = { button: event.button, id: event.pointerId, x: event.clientX, y: event.clientY }
|
|
|
+ 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 { /* 合成事件可能没有可捕获指针。 */ }
|
|
|
}
|
|
|
@@ -256,7 +277,7 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
|
|
|
const dy = event.clientY - drag.y
|
|
|
if (drag.button === 0) {
|
|
|
// 将像素位移换算为目标平面距离,保持“抓住场景拖动”的方向和速度。
|
|
|
- const unitsPerPixel = 2 * distance * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) / (host.clientHeight || 220)
|
|
|
+ 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)
|
|
|
@@ -279,7 +300,16 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
|
|
|
}
|
|
|
const onWheel = (event) => {
|
|
|
event.preventDefault?.()
|
|
|
- distance = Math.max(sceneRadius * 0.05, Math.min(sceneRadius * 50, distance * Math.exp(Math.max(-1000, Math.min(1000, event.deltaY)) * 0.001)))
|
|
|
+ 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)
|
|
|
@@ -299,6 +329,16 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
|
|
|
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
|