|
|
@@ -7,6 +7,49 @@ import { addDetailedPipeSegment } from './pipeDetailGeometry.js'
|
|
|
import { attachPointPick } from './pointPick.js'
|
|
|
import { pipeSurfaceMaterial, outlineSelectedPipe } from './pipeSurfaceMaterial.js'
|
|
|
import { sourceDiameterM, pipeSurface } from '../domain/pipePhysical.js'
|
|
|
+import { planMarker } from '../domain/markerPlan.js'
|
|
|
+import { renderMarker } from './markerAssets.js'
|
|
|
+
|
|
|
+/**
|
|
|
+ * 状态色键兜底表(与 `mapService.toneColors()` 同值、与 `styles/tokens.css` 同值)。
|
|
|
+ * 色值真源仍是 tokens.css;本表只在读不到计算样式时兜底(脱浏览器回归、样式未挂载)。
|
|
|
+ * 刻意不与 mapService 共享常量:那两个文件带有其它在制品改动,本票不触碰它们。
|
|
|
+ */
|
|
|
+const TONE_FALLBACK = Object.freeze({
|
|
|
+ lv1: '#ff3b52',
|
|
|
+ lv2: '#ffb020',
|
|
|
+ lv3: '#01abea',
|
|
|
+ lv4: '#27d17a',
|
|
|
+ neutral: '#5680b0',
|
|
|
+})
|
|
|
+
|
|
|
+/**
|
|
|
+ * 状态色键 → 十六进制。与 `mapService` 的 `toneColors()` 同一口径:
|
|
|
+ * 色值唯一真源是 `styles/tokens.css`,这里从 `.cockpit-shell` 计算样式读,
|
|
|
+ * 脱浏览器(Node 回归)读不到时用 `markerPlan.TONE_FALLBACK` 同值兜底。
|
|
|
+ */
|
|
|
+function toneHex(tone) {
|
|
|
+ const fallback = TONE_FALLBACK[tone] || TONE_FALLBACK.neutral
|
|
|
+ if (typeof document === 'undefined' || !document.querySelector || typeof getComputedStyle !== 'function') return fallback
|
|
|
+ const shell = document.querySelector('.cockpit-shell')
|
|
|
+ if (!shell) return fallback
|
|
|
+ const value = getComputedStyle(shell).getPropertyValue(`--${tone}`)
|
|
|
+ return value && value.trim() ? value.trim() : fallback
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 设备图标的**唯一来源**:复用主图 marker 的合成链路(`planMarker` + `renderMarker`),
|
|
|
+ * 因此地下管线详情视图里的设备图标与一张图上的**同款**,不是另画一套。
|
|
|
+ *
|
|
|
+ * 维护者 2026-10-22:「场景设备图标没有按照要求使用地图中的同款。要保持图标一致性。」
|
|
|
+ * 所以这里不再自绘任何图标;`renderMarker` 的产物(水滴母版 + 行业内层 + 白描边)
|
|
|
+ * 直接作为 Sprite 贴图,尺寸也沿用它的 `draw`(含描边余量)。
|
|
|
+ */
|
|
|
+async function defaultDeviceIcon(device) {
|
|
|
+ const plan = planMarker({ kind: 'monitoring-device', status: device.status, industry: device.industry })
|
|
|
+ const url = await renderMarker(plan, { tintHex: toneHex(plan.tint) })
|
|
|
+ return { url, width: plan.draw.width, height: plan.draw.height }
|
|
|
+}
|
|
|
|
|
|
function defaultRendererFactory({ canvas }) {
|
|
|
return new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true })
|
|
|
@@ -21,45 +64,6 @@ function project([lon, lat], center, scale) {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
-function makeIconTexture(kind = 'meter', glyphOnly = false) {
|
|
|
- const size = 64
|
|
|
- const data = new Uint8Array(size * size * 4)
|
|
|
- const put = (x, y, alpha = 255) => {
|
|
|
- if (x < 0 || y < 0 || x >= size || y >= size) return
|
|
|
- const i = (y * size + x) * 4
|
|
|
- data[i] = data[i + 1] = data[i + 2] = 255
|
|
|
- data[i + 3] = alpha
|
|
|
- }
|
|
|
- if (!glyphOnly) {
|
|
|
- for (let y = 3; y < 61; y++) for (let x = 3; x < 61; x++) {
|
|
|
- const d = Math.hypot(x - 32, y - 32)
|
|
|
- if (d < 29) put(x, y, d > 24 ? 255 : 210)
|
|
|
- }
|
|
|
- for (let y = 14; y <= 49; y++) for (let x = 14; x <= 49; x++) {
|
|
|
- const d = Math.hypot(x - 32, y - 32)
|
|
|
- if (d >= 13 && d <= 16) put(x, y)
|
|
|
- }
|
|
|
- }
|
|
|
- const bars = kind === 'flow' ? [[22, 37, 27], [29, 34, 36], [36, 30, 42], [43, 25, 45]]
|
|
|
- : kind === 'pressure' ? [[22, 36, 43], [29, 31, 43], [36, 26, 43], [43, 21, 43]]
|
|
|
- : [[22, 33, 43], [29, 29, 43], [36, 34, 43], [43, 27, 43]]
|
|
|
- for (const [x, top, bottom] of bars) for (let y = top; y <= bottom; y++) for (let dx = -1; dx <= 1; dx++) put(x + dx, y)
|
|
|
- const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat)
|
|
|
- texture.colorSpace = THREE.SRGBColorSpace
|
|
|
- texture.magFilter = THREE.LinearFilter
|
|
|
- texture.minFilter = THREE.LinearMipmapLinearFilter
|
|
|
- texture.generateMipmaps = true
|
|
|
- texture.needsUpdate = true
|
|
|
- return texture
|
|
|
-}
|
|
|
-
|
|
|
-function metricIconKind(device) {
|
|
|
- const type = `${device.typeName || ''} ${device.name || ''}`
|
|
|
- if (/流量|flow/i.test(type)) return 'flow'
|
|
|
- if (/压力|pressure/i.test(type)) return 'pressure'
|
|
|
- return 'meter'
|
|
|
-}
|
|
|
-
|
|
|
function disposeObject(root) {
|
|
|
const resources = new Set()
|
|
|
root.traverse((node) => {
|
|
|
@@ -188,22 +192,27 @@ function buildNeighborhood(plan) {
|
|
|
emissive: color, emissiveIntensity: 0.28, depthTest: false, depthWrite: false }))
|
|
|
body.name = 'device-body'
|
|
|
group.add(body)
|
|
|
- const iconTexture = makeIconTexture(metricIconKind(device))
|
|
|
- const icon = new THREE.Sprite(new THREE.SpriteMaterial({ map: iconTexture, color, transparent: true,
|
|
|
- depthTest: false, depthWrite: false, sizeAttenuation: true }))
|
|
|
+ // 图标:贴图来自主图同款合成(异步到达后替换);尖端锚点 = Sprite 底部中心。
|
|
|
+ const icon = new THREE.Sprite(new THREE.SpriteMaterial({ transparent: true, depthTest: false, depthWrite: false, sizeAttenuation: true }))
|
|
|
icon.name = 'device-icon'
|
|
|
+ icon.center.set(0.5, 0)
|
|
|
icon.position.set(0, 0.72, 0)
|
|
|
icon.renderOrder = 30
|
|
|
icon.userData.device = device
|
|
|
+ icon.visible = false
|
|
|
group.add(icon)
|
|
|
- const glyphTexture = makeIconTexture(metricIconKind(device), true)
|
|
|
- const iconGlyph = new THREE.Sprite(new THREE.SpriteMaterial({ map: glyphTexture, color: '#ffffff', transparent: true,
|
|
|
- depthTest: false, depthWrite: false, sizeAttenuation: true }))
|
|
|
- iconGlyph.name = 'device-icon-glyph'
|
|
|
- iconGlyph.position.copy(icon.position)
|
|
|
- iconGlyph.renderOrder = 31
|
|
|
- iconGlyph.scale.setScalar(0.68)
|
|
|
- group.add(iconGlyph)
|
|
|
+ // 点击代理:与图标同尺寸的透明平面,专供射线命中。
|
|
|
+ // 不用 Sprite 自身做拾取——Sprite 在嵌套缩放下的 raycast 不稳定(实测同一图标
|
|
|
+ // 最小用例能命中、放进场景就 miss);代理平面只承担命中,视觉仍由 Sprite 提供。
|
|
|
+ const hit = new THREE.Mesh(
|
|
|
+ new THREE.PlaneGeometry(1, 1.2566),
|
|
|
+ new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false, depthTest: false }),
|
|
|
+ )
|
|
|
+ hit.name = 'device-icon-hit'
|
|
|
+ hit.position.copy(icon.position)
|
|
|
+ hit.renderOrder = 29
|
|
|
+ hit.userData.device = device
|
|
|
+ group.add(hit)
|
|
|
root.add(group)
|
|
|
}
|
|
|
return root
|
|
|
@@ -213,7 +222,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 = () => {}, onObjectPick = () => {} } = {}) {
|
|
|
+export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRendererFactory, pixelRatio = 1, onDevicePick = () => {}, onObjectPick = () => {}, iconResolver = defaultDeviceIcon, loadTexture = null } = {}) {
|
|
|
if (!host || !plan || plan.visible !== true || !plan.object) {
|
|
|
return { status: 'unavailable', sync: () => {}, destroy: () => {}, getState: () => ({ status: 'unavailable' }) }
|
|
|
}
|
|
|
@@ -274,13 +283,20 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
|
|
|
if (node.name === 'selected-pipe-outline') node.material.uniforms.resolution.value.set(host.clientWidth || 320, host.clientHeight || 220)
|
|
|
if (node.name === 'device-icon') {
|
|
|
root.updateMatrixWorld(true)
|
|
|
+ // 尺寸 = 主图 marker 的 draw 像素(含描边余量),按视距换算成固定屏显尺寸,
|
|
|
+ // 与主图 billboard 的视觉重量一致;高度按图标自身宽高比。
|
|
|
+ const iconWidth = node.userData.iconWidth || 26.7
|
|
|
+ const aspect = node.userData.iconAspect || 1.2566
|
|
|
const world = 2 * camera.position.distanceTo(node.getWorldPosition(new THREE.Vector3()))
|
|
|
- * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * 48 / (host.clientHeight || 220)
|
|
|
+ * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * iconWidth / (host.clientHeight || 220)
|
|
|
const parentScale = Math.max(0.0001, node.parent.getWorldScale(new THREE.Vector3()).x)
|
|
|
- node.scale.setScalar(world / parentScale)
|
|
|
- } else if (node.name === 'device-icon-glyph') {
|
|
|
- const badge = node.parent.getObjectByName('device-icon')
|
|
|
- node.scale.copy(badge.scale).multiplyScalar(0.68)
|
|
|
+ node.scale.set(world / parentScale, (world * aspect) / parentScale, 1)
|
|
|
+ // 点击代理跟随图标尺寸,并把自身中心抬到图标可见区域中心(图标锚点在底部)。
|
|
|
+ const hit = node.parent.getObjectByName('device-icon-hit')
|
|
|
+ if (hit) {
|
|
|
+ hit.scale.set(node.scale.x, node.scale.y, 1)
|
|
|
+ hit.position.y = node.position.y + node.scale.y / 2
|
|
|
+ }
|
|
|
}
|
|
|
})
|
|
|
camera.near = Math.max(0.0001, distance > sceneRadius * 1.05 ? distance - sceneRadius * 1.05 : distance / 1000)
|
|
|
@@ -289,6 +305,51 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
|
|
|
camera.lookAt(target)
|
|
|
renderer.render?.(scene, camera)
|
|
|
}
|
|
|
+ /**
|
|
|
+ * 设备图标异步装配:贴图来自主图同款合成(`iconResolver`)。
|
|
|
+ * 图标未到达前 Sprite 保持 `visible=false`,避免出现白色占位方块;
|
|
|
+ * 到达后写入 `userData.iconWidth/iconAspect` 供 `render` 按屏显尺寸缩放。
|
|
|
+ * 场景已销毁/换选(`root` 变化)时丢弃迟到结果。
|
|
|
+ */
|
|
|
+ let iconEpoch = 0
|
|
|
+ const textureLoader = new THREE.TextureLoader()
|
|
|
+ /**
|
|
|
+ * 贴图加载 seam:生产用 `THREE.TextureLoader`(需要浏览器 Image),
|
|
|
+ * 脱浏览器回归可注入同步假加载器,从而离线断言「用的是主图同款图标产物」。
|
|
|
+ * 回调签名 `(texture) => void`,失败时调用 `onError()`。
|
|
|
+ */
|
|
|
+ const loadTextureImpl = typeof loadTexture === 'function'
|
|
|
+ ? loadTexture
|
|
|
+ : (url, onLoad, onError) => textureLoader.load(url, onLoad, undefined, onError)
|
|
|
+ const loadDeviceIcons = () => {
|
|
|
+ const epoch = ++iconEpoch
|
|
|
+ const sceneRoot = root
|
|
|
+ if (!sceneRoot) return
|
|
|
+ sceneRoot.traverse((node) => {
|
|
|
+ if (node.name !== 'device-icon' || node.material.map) return
|
|
|
+ const device = node.userData.device
|
|
|
+ if (!device) return
|
|
|
+ Promise.resolve()
|
|
|
+ .then(() => iconResolver(device))
|
|
|
+ .then((icon) => {
|
|
|
+ if (destroyed || epoch !== iconEpoch || sceneRoot !== root || !icon?.url) return
|
|
|
+ node.userData.iconWidth = Number.isFinite(icon.width) ? icon.width : undefined
|
|
|
+ node.userData.iconAspect = Number.isFinite(icon.height) && Number.isFinite(icon.width) && icon.width > 0
|
|
|
+ ? icon.height / icon.width
|
|
|
+ : undefined
|
|
|
+ loadTextureImpl(icon.url, (texture) => {
|
|
|
+ if (destroyed || epoch !== iconEpoch || sceneRoot !== root) { texture.dispose?.(); return }
|
|
|
+ texture.colorSpace = THREE.SRGBColorSpace
|
|
|
+ node.material.map = texture
|
|
|
+ node.material.color.set('#ffffff')
|
|
|
+ node.material.needsUpdate = true
|
|
|
+ node.visible = true
|
|
|
+ render()
|
|
|
+ }, () => { /* 贴图失败:保持不可见,不画自绘替代品。 */ })
|
|
|
+ })
|
|
|
+ .catch(() => { /* 合成失败:保持不可见,不画自绘替代品。 */ })
|
|
|
+ })
|
|
|
+ }
|
|
|
const sync = (next) => {
|
|
|
const preserveView = root && current.object.code === next.object.code && current.object.sourceKind === next.object.sourceKind
|
|
|
current = next
|
|
|
@@ -312,6 +373,7 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
|
|
|
camera.far = Math.max(100, sceneRadius * 100)
|
|
|
camera.updateProjectionMatrix()
|
|
|
}
|
|
|
+ loadDeviceIcons()
|
|
|
render()
|
|
|
}
|
|
|
const detachFocus = attachSceneFocus(host)
|
|
|
@@ -326,8 +388,19 @@ export function mountPipeEnhancement(host, plan, { rendererFactory = defaultRend
|
|
|
return raycaster.intersectObject(root, true)[0] || null
|
|
|
}
|
|
|
const detachPick = attachPointPick(canvas, ({ x, y }) => {
|
|
|
+ // 必须在本次点击坐标上重新投射射线:不能复用 `onDown` 留下的 raycaster 状态,
|
|
|
+ // 否则 pointerdown 未进入画布(或未触发)时拾取会落在上一次的射线上。
|
|
|
+ if (!root || !canvas.getBoundingClientRect) return
|
|
|
+ const rect = canvas.getBoundingClientRect()
|
|
|
+ if (!rect.width || !rect.height) return
|
|
|
+ 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 intersections = raycaster.intersectObject(root, true)
|
|
|
- const hit = intersections.find((item) => item.object.name === 'device-icon' || item.object.name === 'device-icon-glyph') || intersections[0]
|
|
|
+ // 图标点击优先:命中可见图标即派发设备,即使放大后的管壁在它前方
|
|
|
+ // (图标是屏幕可见交互层,用户点的是看得见的东西)。
|
|
|
+ const iconHit = intersections.find((item) => item.object.name === 'device-icon-hit')
|
|
|
+ const hit = iconHit || intersections[0]
|
|
|
let node = hit?.object
|
|
|
while (node) {
|
|
|
if (node.userData.device) { onDevicePick(node.userData.device); return }
|