Pārlūkot izejas kodu

viewer:Cylinder_4 内部体积气流(玻璃壳 + 白色流动,100ms 闪烁)

- 新增 three/AirFlow.ts:simplex 噪声体积流 shader
- Viewer.ts:按模型+标签把实例拆为半透明玻璃壳(opacity 0.8)并挂气流,沿最长轴流动,支持可见性闪烁
- 点选/上色/故障闪烁路由到玻璃壳
18922397810 2 nedēļas atpakaļ
vecāks
revīzija
e553353547
2 mainītis faili ar 281 papildinājumiem un 3 dzēšanām
  1. 157 0
      viewer/frontend/src/three/AirFlow.ts
  2. 124 3
      viewer/frontend/src/three/Viewer.ts

+ 157 - 0
viewer/frontend/src/three/AirFlow.ts

@@ -0,0 +1,157 @@
+import * as THREE from 'three'
+
+export interface AirFlowOptions {
+  center: THREE.Vector3
+  /** 部件长轴在几何局部坐标系中的下标:0=X、1=Y、2=Z。 */
+  axis: 0 | 1 | 2
+  length: number
+  radius: number
+  color?: number
+  speed?: number
+  opacity?: number
+}
+
+export interface AirFlowHandle {
+  group: THREE.Group
+  update: (elapsed: number) => void
+  dispose: () => void
+}
+
+const VERT = /* glsl */ `
+varying vec3 vPos;
+void main() {
+  vPos = position;
+  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+}
+`
+
+const FRAG = /* glsl */ `
+uniform float uTime;
+uniform vec3 uColor;
+uniform float uRadius;
+uniform float uLen;
+uniform float uSpeed;
+uniform float uOpacity;
+varying vec3 vPos;
+
+vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
+vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
+vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); }
+vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
+
+float snoise(vec3 v) {
+  const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0);
+  const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
+  vec3 i  = floor(v + dot(v, C.yyy));
+  vec3 x0 = v - i + dot(i, C.xxx);
+  vec3 g = step(x0.yzx, x0.xyz);
+  vec3 l = 1.0 - g;
+  vec3 i1 = min(g.xyz, l.zxy);
+  vec3 i2 = max(g.xyz, l.zxy);
+  vec3 x1 = x0 - i1 + C.xxx;
+  vec3 x2 = x0 - i2 + C.yyy;
+  vec3 x3 = x0 - D.yyy;
+  i = mod289(i);
+  vec4 p = permute(permute(permute(
+             i.z + vec4(0.0, i1.z, i2.z, 1.0))
+           + i.y + vec4(0.0, i1.y, i2.y, 1.0))
+           + i.x + vec4(0.0, i1.x, i2.x, 1.0));
+  float n_ = 0.142857142857;
+  vec3 ns = n_ * D.wyz - D.xzx;
+  vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
+  vec4 x_ = floor(j * ns.z);
+  vec4 y_ = floor(j - 7.0 * x_);
+  vec4 x = x_ * ns.x + ns.yyyy;
+  vec4 y = y_ * ns.x + ns.yyyy;
+  vec4 h = 1.0 - abs(x) - abs(y);
+  vec4 b0 = vec4(x.xy, y.xy);
+  vec4 b1 = vec4(x.zw, y.zw);
+  vec4 s0 = floor(b0) * 2.0 + 1.0;
+  vec4 s1 = floor(b1) * 2.0 + 1.0;
+  vec4 sh = -step(h, vec4(0.0));
+  vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
+  vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
+  vec3 p0 = vec3(a0.xy, h.x);
+  vec3 p1 = vec3(a0.zw, h.y);
+  vec3 p2 = vec3(a1.xy, h.z);
+  vec3 p3 = vec3(a1.zw, h.w);
+  vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));
+  p0 *= norm.x;
+  p1 *= norm.y;
+  p2 *= norm.z;
+  p3 *= norm.w;
+  vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
+  m = m * m;
+  return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
+}
+
+void main() {
+  float inv = 2.0 / max(uRadius, 1e-4);
+  vec3 q = vPos * inv;
+  q.y *= 0.35;
+  q.y += uTime * uSpeed * 4.0;
+
+  float n1 = snoise(q * 1.3);
+  float n2 = snoise(q * 3.0 + vec3(3.1, 0.0, 1.7));
+  float n = n1 * 0.65 + n2 * 0.35;
+  n = n * 0.5 + 0.5;
+
+  float density = smoothstep(0.30, 0.9, n);
+
+  float along = vPos.y / max(uLen, 1e-4) + 0.5;
+  float ends = smoothstep(0.0, 0.12, along) * smoothstep(1.0, 0.88, along);
+
+  float a = density * ends * uOpacity;
+  if (a < 0.01) discard;
+
+  vec3 col = mix(uColor, vec3(1.0), pow(n, 3.0) * 0.35);
+  gl_FragColor = vec4(col, a);
+}
+`
+
+export function createAirFlow(options: AirFlowOptions): AirFlowHandle {
+  const {
+    center, axis, length, radius,
+    color = 0xcfeaff,
+    speed = 1.0,
+    opacity = 0.9,
+  } = options
+
+  const group = new THREE.Group()
+  group.position.copy(center)
+  if (axis === 0) group.rotation.z = -Math.PI / 2
+  else if (axis === 2) group.rotation.x = Math.PI / 2
+
+  const geometry = new THREE.CylinderGeometry(radius, radius, length, 48, 1, true)
+  const material = new THREE.ShaderMaterial({
+    uniforms: {
+      uTime: { value: 0 },
+      uColor: { value: new THREE.Color(color) },
+      uRadius: { value: radius },
+      uLen: { value: length },
+      uSpeed: { value: speed },
+      uOpacity: { value: opacity },
+    },
+    vertexShader: VERT,
+    fragmentShader: FRAG,
+    transparent: true,
+    depthWrite: false,
+    side: THREE.DoubleSide,
+    blending: THREE.NormalBlending,
+  })
+
+  const mesh = new THREE.Mesh(geometry, material)
+  mesh.renderOrder = 3
+  group.add(mesh)
+
+  return {
+    group,
+    update(elapsed: number): void {
+      material.uniforms.uTime.value = elapsed
+    },
+    dispose(): void {
+      geometry.dispose()
+      material.dispose()
+    },
+  }
+}

+ 124 - 3
viewer/frontend/src/three/Viewer.ts

@@ -8,10 +8,19 @@ import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
 import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'
 
 import type { CameraInfo, PartMeta, PartStatus } from '../types'
+import { createAirFlow, type AirFlowHandle } from './AirFlow'
 
 const HIGHLIGHT = new THREE.Color(0x4da3ff)
 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 {
   id: string
   label: string
@@ -75,6 +84,10 @@ export class Viewer {
   private parts: PartHandle[] = []
   private byId = new Map<string, PartHandle>()
   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({
     color: 0xffffff, metalness: 0.2, roughness: 0.7, side: THREE.DoubleSide,
   })
@@ -161,6 +174,10 @@ export class Viewer {
       requestAnimationFrame(loop)
       if (this.transition) this.stepTransition()
       else this.controls.update()
+      if (this.flowEffects.length) {
+        const elapsed = performance.now() / 1000
+        this.flowEffects.forEach((effect) => effect.update(elapsed))
+      }
       this.composer.render()
     }
     loop()
@@ -211,6 +228,7 @@ export class Viewer {
     this.cb.onProgress?.(`正在合并 ${meshCount} 个部件…`)
     group.visible = false
     this.collectPartsInstanced(group)
+    this.setupAirFlows(name)
     this.cb.onParts?.(this.parts.map((p) => p.meta))
     this.cb.onProgress?.('')
   }
@@ -229,6 +247,14 @@ export class Viewer {
       this.scene.remove(this.grid)
       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.byId.clear()
     this.selectedIds.clear()
@@ -330,6 +356,88 @@ export class Viewer {
     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 => {
     if (!this.modelGroup) return
@@ -511,10 +619,14 @@ export class Viewer {
     this.pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1
     this.pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1
     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
-    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 {
@@ -604,6 +716,12 @@ export class Viewer {
         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 {
@@ -614,6 +732,9 @@ export class Viewer {
   dispose(): void {
     this.animating = false
     this.stopFaultBlink()
+    this.stopFlowBlink()
+    this.flowEffects.forEach((effect) => effect.dispose())
+    this.flowEffects = []
     window.removeEventListener('resize', this.updateAspect)
     const el = this.renderer.domElement
     el.removeEventListener('pointermove', this.onPointerMove)