|
@@ -8,10 +8,19 @@ import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
|
|
|
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'
|
|
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'
|
|
|
|
|
|
|
|
import type { CameraInfo, PartMeta, PartStatus } from '../types'
|
|
import type { CameraInfo, PartMeta, PartStatus } from '../types'
|
|
|
|
|
+import { createAirFlow, type AirFlowHandle } from './AirFlow'
|
|
|
|
|
|
|
|
const HIGHLIGHT = new THREE.Color(0x4da3ff)
|
|
const HIGHLIGHT = new THREE.Color(0x4da3ff)
|
|
|
const REF_MEDIAN_Z = -22.46
|
|
const REF_MEDIAN_Z = -22.46
|
|
|
|
|
|
|
|
|
|
+/** 按模型名指定要做“内部体积气流 + 玻璃壳”的部件标签。 */
|
|
|
|
|
+const FLOW_PART_LABELS: Record<string, string[]> = {
|
|
|
|
|
+ '压缩机2326-3000-one.glb': ['Cylinder_4'],
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 内部气流闪烁:显示/隐藏各 100ms。 */
|
|
|
|
|
+const FLOW_BLINK_MS = 100
|
|
|
|
|
+
|
|
|
interface PartHandle {
|
|
interface PartHandle {
|
|
|
id: string
|
|
id: string
|
|
|
label: string
|
|
label: string
|
|
@@ -75,6 +84,10 @@ export class Viewer {
|
|
|
private parts: PartHandle[] = []
|
|
private parts: PartHandle[] = []
|
|
|
private byId = new Map<string, PartHandle>()
|
|
private byId = new Map<string, PartHandle>()
|
|
|
private instancedMeshes: THREE.InstancedMesh[] = []
|
|
private instancedMeshes: THREE.InstancedMesh[] = []
|
|
|
|
|
+ private flowShells = new Map<string, THREE.Mesh>()
|
|
|
|
|
+ private flowEffects: AirFlowHandle[] = []
|
|
|
|
|
+ private flowBlinkOn = true
|
|
|
|
|
+ private flowBlinkTimer: ReturnType<typeof setInterval> | undefined
|
|
|
private instMaterial = new THREE.MeshStandardMaterial({
|
|
private instMaterial = new THREE.MeshStandardMaterial({
|
|
|
color: 0xffffff, metalness: 0.2, roughness: 0.7, side: THREE.DoubleSide,
|
|
color: 0xffffff, metalness: 0.2, roughness: 0.7, side: THREE.DoubleSide,
|
|
|
})
|
|
})
|
|
@@ -161,6 +174,10 @@ export class Viewer {
|
|
|
requestAnimationFrame(loop)
|
|
requestAnimationFrame(loop)
|
|
|
if (this.transition) this.stepTransition()
|
|
if (this.transition) this.stepTransition()
|
|
|
else this.controls.update()
|
|
else this.controls.update()
|
|
|
|
|
+ if (this.flowEffects.length) {
|
|
|
|
|
+ const elapsed = performance.now() / 1000
|
|
|
|
|
+ this.flowEffects.forEach((effect) => effect.update(elapsed))
|
|
|
|
|
+ }
|
|
|
this.composer.render()
|
|
this.composer.render()
|
|
|
}
|
|
}
|
|
|
loop()
|
|
loop()
|
|
@@ -211,6 +228,7 @@ export class Viewer {
|
|
|
this.cb.onProgress?.(`正在合并 ${meshCount} 个部件…`)
|
|
this.cb.onProgress?.(`正在合并 ${meshCount} 个部件…`)
|
|
|
group.visible = false
|
|
group.visible = false
|
|
|
this.collectPartsInstanced(group)
|
|
this.collectPartsInstanced(group)
|
|
|
|
|
+ this.setupAirFlows(name)
|
|
|
this.cb.onParts?.(this.parts.map((p) => p.meta))
|
|
this.cb.onParts?.(this.parts.map((p) => p.meta))
|
|
|
this.cb.onProgress?.('')
|
|
this.cb.onProgress?.('')
|
|
|
}
|
|
}
|
|
@@ -229,6 +247,14 @@ export class Viewer {
|
|
|
this.scene.remove(this.grid)
|
|
this.scene.remove(this.grid)
|
|
|
this.grid = null
|
|
this.grid = null
|
|
|
}
|
|
}
|
|
|
|
|
+ this.flowShells.forEach((shell) => {
|
|
|
|
|
+ this.scene.remove(shell)
|
|
|
|
|
+ ;(shell.material as THREE.Material).dispose()
|
|
|
|
|
+ })
|
|
|
|
|
+ this.flowShells.clear()
|
|
|
|
|
+ this.stopFlowBlink()
|
|
|
|
|
+ this.flowEffects.forEach((effect) => effect.dispose())
|
|
|
|
|
+ this.flowEffects = []
|
|
|
this.parts = []
|
|
this.parts = []
|
|
|
this.byId.clear()
|
|
this.byId.clear()
|
|
|
this.selectedIds.clear()
|
|
this.selectedIds.clear()
|
|
@@ -330,6 +356,88 @@ export class Viewer {
|
|
|
return handle.obj ? handle.obj.matrixWorld : null
|
|
return handle.obj ? handle.obj.matrixWorld : null
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /* ---------------- 内部气流(玻璃壳 + 体积流) ---------------- */
|
|
|
|
|
+ private setupAirFlows(modelName: string): void {
|
|
|
|
|
+ const labels = FLOW_PART_LABELS[modelName]
|
|
|
|
|
+ if (!labels) return
|
|
|
|
|
+ labels.forEach((label) => {
|
|
|
|
|
+ const handle = this.parts.find((p) => p.label === label)
|
|
|
|
|
+ if (!handle?.inst || handle.instanceId === undefined) return
|
|
|
|
|
+ const inst = handle.inst
|
|
|
|
|
+ const geometry = inst.geometry
|
|
|
|
|
+ if (!geometry.boundingBox) geometry.computeBoundingBox()
|
|
|
|
|
+ const box = geometry.boundingBox!
|
|
|
|
|
+ const size = box.getSize(new THREE.Vector3())
|
|
|
|
|
+ const center = box.getCenter(new THREE.Vector3())
|
|
|
|
|
+ const dims: [number, number, number] = [size.x, size.y, size.z]
|
|
|
|
|
+
|
|
|
|
|
+ const matrix = new THREE.Matrix4()
|
|
|
|
|
+ inst.getMatrixAt(handle.instanceId, matrix)
|
|
|
|
|
+
|
|
|
|
|
+ // 隐藏原实例,改用独立玻璃壳渲染(InstancedMesh 材质/透明度为共享,无法只透明一个)
|
|
|
|
|
+ const hidden = matrix.clone()
|
|
|
|
|
+ hidden.scale(new THREE.Vector3(0, 0, 0))
|
|
|
|
|
+ inst.setMatrixAt(handle.instanceId, hidden)
|
|
|
|
|
+ inst.instanceMatrix.needsUpdate = true
|
|
|
|
|
+
|
|
|
|
|
+ const glass = new THREE.MeshStandardMaterial({
|
|
|
|
|
+ color: new THREE.Color(handle.colorHex ?? handle.defaults[0]),
|
|
|
|
|
+ transparent: true,
|
|
|
|
|
+ opacity: 0.8,
|
|
|
|
|
+ metalness: 0.2,
|
|
|
|
|
+ roughness: 0.05,
|
|
|
|
|
+ side: THREE.DoubleSide,
|
|
|
|
|
+ depthWrite: false,
|
|
|
|
|
+ envMapIntensity: 1.2,
|
|
|
|
|
+ })
|
|
|
|
|
+ const shell = new THREE.Mesh(geometry, glass)
|
|
|
|
|
+ shell.matrixAutoUpdate = false
|
|
|
|
|
+ shell.matrix.copy(matrix)
|
|
|
|
|
+ shell.renderOrder = 1
|
|
|
|
|
+ shell.userData.partId = handle.id
|
|
|
|
|
+ this.scene.add(shell)
|
|
|
|
|
+ shell.updateMatrixWorld(true)
|
|
|
|
|
+ this.flowShells.set(handle.id, shell)
|
|
|
|
|
+
|
|
|
|
|
+ // 沿最长轴布置内部体积流(几何局部坐标系,随壳矩阵缩放)
|
|
|
|
|
+ let axis: 0 | 1 | 2 = 0
|
|
|
|
|
+ if (dims[1] >= dims[axis]) axis = 1
|
|
|
|
|
+ if (dims[2] >= dims[axis]) axis = 2
|
|
|
|
|
+ const length = dims[axis]
|
|
|
|
|
+ const others = dims.filter((_, i) => i !== axis)
|
|
|
|
|
+ const radius = Math.min(...others) / 2
|
|
|
|
|
+
|
|
|
|
|
+ const effect = createAirFlow({
|
|
|
|
|
+ center,
|
|
|
|
|
+ axis,
|
|
|
|
|
+ length: length * 0.95,
|
|
|
|
|
+ radius: Math.max(radius * 0.85, 1e-4),
|
|
|
|
|
+ color: 0xffffff,
|
|
|
|
|
+ speed: 2.0,
|
|
|
|
|
+ })
|
|
|
|
|
+ shell.add(effect.group)
|
|
|
|
|
+ this.flowEffects.push(effect)
|
|
|
|
|
+ })
|
|
|
|
|
+ if (this.flowEffects.length) this.startFlowBlink()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private startFlowBlink(): void {
|
|
|
|
|
+ this.stopFlowBlink()
|
|
|
|
|
+ this.flowBlinkOn = true
|
|
|
|
|
+ this.flowBlinkTimer = setInterval(() => {
|
|
|
|
|
+ this.flowBlinkOn = !this.flowBlinkOn
|
|
|
|
|
+ this.flowEffects.forEach((effect) => { effect.group.visible = this.flowBlinkOn })
|
|
|
|
|
+ }, FLOW_BLINK_MS)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private stopFlowBlink(): void {
|
|
|
|
|
+ if (this.flowBlinkTimer) {
|
|
|
|
|
+ clearInterval(this.flowBlinkTimer)
|
|
|
|
|
+ this.flowBlinkTimer = undefined
|
|
|
|
|
+ }
|
|
|
|
|
+ this.flowBlinkOn = true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/* ---------------- 取景 / 视角 ---------------- */
|
|
/* ---------------- 取景 / 视角 ---------------- */
|
|
|
fitAll = (zoom = 1.2): void => {
|
|
fitAll = (zoom = 1.2): void => {
|
|
|
if (!this.modelGroup) return
|
|
if (!this.modelGroup) return
|
|
@@ -511,10 +619,14 @@ export class Viewer {
|
|
|
this.pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1
|
|
this.pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1
|
|
|
this.pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1
|
|
this.pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1
|
|
|
this.raycaster.setFromCamera(this.pointer, this.camera)
|
|
this.raycaster.setFromCamera(this.pointer, this.camera)
|
|
|
- if (!this.instancedMeshes.length) return null
|
|
|
|
|
- const hits = this.raycaster.intersectObjects(this.instancedMeshes, false)
|
|
|
|
|
|
|
+ const targets: THREE.Object3D[] = [...this.instancedMeshes, ...this.flowShells.values()]
|
|
|
|
|
+ if (!targets.length) return null
|
|
|
|
|
+ const hits = this.raycaster.intersectObjects(targets, false)
|
|
|
if (!hits.length) return null
|
|
if (!hits.length) return null
|
|
|
- return this.idFromInstance(hits[0].object as THREE.InstancedMesh, hits[0].instanceId ?? 0)
|
|
|
|
|
|
|
+ const hit = hits[0]
|
|
|
|
|
+ const partId = (hit.object.userData as { partId?: string }).partId
|
|
|
|
|
+ if (partId) return partId
|
|
|
|
|
+ return this.idFromInstance(hit.object as THREE.InstancedMesh, hit.instanceId ?? 0)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private idFromInstance(inst: THREE.InstancedMesh, instanceId: number): string | null {
|
|
private idFromInstance(inst: THREE.InstancedMesh, instanceId: number): string | null {
|
|
@@ -604,6 +716,12 @@ export class Viewer {
|
|
|
if (anyM.color) anyM.color.set(base)
|
|
if (anyM.color) anyM.color.set(base)
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
|
|
+ const shell = this.flowShells.get(id)
|
|
|
|
|
+ if (shell) {
|
|
|
|
|
+ const c = new THREE.Color(base)
|
|
|
|
|
+ if (this.selectedIds.has(id)) c.lerp(HIGHLIGHT, 0.45)
|
|
|
|
|
+ ;(shell.material as THREE.MeshStandardMaterial).color.copy(c)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
applyDefaultColor(id: string): string {
|
|
applyDefaultColor(id: string): string {
|
|
@@ -614,6 +732,9 @@ export class Viewer {
|
|
|
dispose(): void {
|
|
dispose(): void {
|
|
|
this.animating = false
|
|
this.animating = false
|
|
|
this.stopFaultBlink()
|
|
this.stopFaultBlink()
|
|
|
|
|
+ this.stopFlowBlink()
|
|
|
|
|
+ this.flowEffects.forEach((effect) => effect.dispose())
|
|
|
|
|
+ this.flowEffects = []
|
|
|
window.removeEventListener('resize', this.updateAspect)
|
|
window.removeEventListener('resize', this.updateAspect)
|
|
|
const el = this.renderer.domElement
|
|
const el = this.renderer.domElement
|
|
|
el.removeEventListener('pointermove', this.onPointerMove)
|
|
el.removeEventListener('pointermove', this.onPointerMove)
|