瀏覽代碼

3d:相机平滑飞行(600ms, easeOutBack回弹);故障场景位置记入 fault_part_map.csv;场景位置卡片新增「贴合故障」记录按钮

18922397810 2 周之前
父節點
當前提交
01a2decda0

+ 18 - 1
3d/backend/routers/fault_map.py

@@ -1,14 +1,31 @@
-"""故障 → 3D 部件对照表。"""
+"""故障 → 3D 部件对照表,以及故障场景位置的记录。"""
 
 from typing import Any
 
 from fastapi import APIRouter
+from pydantic import BaseModel, Field
 
 from services import fault_map_service
 
 router = APIRouter()
 
 
+class FaultSceneInput(BaseModel):
+    model: str = Field(min_length=1)
+    alarm_type: str = Field(min_length=1)
+    device_point: str = ""
+    camera: list[float] = Field(min_length=3, max_length=3)
+    target: list[float] = Field(min_length=3, max_length=3)
+
+
 @router.get("/api/fault-part-map")
 def list_fault_part_map() -> list[dict[str, Any]]:
     return fault_map_service.list_rows()
+
+
+@router.post("/api/fault-part-map/scene")
+def set_fault_scene(payload: FaultSceneInput) -> dict[str, Any]:
+    changed = fault_map_service.set_scene_position(
+        payload.model, payload.alarm_type, payload.device_point, payload.camera, payload.target,
+    )
+    return {"ok": True, "updated": changed}

+ 77 - 13
3d/backend/services/fault_map_service.py

@@ -1,8 +1,9 @@
 """故障 → 3D 部件对照表(data/fault_part_map.csv 的读写)。
 
-列:model, alarm_type, device_point, device_part, part
+列:model, alarm_type, device_point, device_part, part, camera_x/y/z, target_x/y/z
 - device_point / device_part 支持多值,用 ``|`` 分隔(未来一个故障多个点)。
 - part 为前端部件标签(createUniqueName 约定:如 ``三维立体_1687``);一个部件一行。
+- camera_/target_ 为该故障的“场景位置”(参考系坐标,空=未记录)。
 """
 
 import csv
@@ -11,9 +12,12 @@ from typing import Any
 
 from config import DATA_DIR, FAULT_MAP_CSV
 
-FIELDS = ["model", "alarm_type", "device_point", "device_part", "part"]
+FIELDS = [
+    "model", "alarm_type", "device_point", "device_part", "part",
+    "camera_x", "camera_y", "camera_z", "target_x", "target_y", "target_z",
+]
 
-# 初始种子(一个部件一行)。
+# 初始种子(一个部件一行,场景位置留空)。
 SEED: list[list[str]] = [
     ["压缩机2326-3000-one.glb", "润滑油压力故障", "YSJ_5", "润滑油压力", "三维立体_1687"],
     ["压缩机2326-3000-one.glb", "振动故障", "YSJ_11", "链轮端振动", "三维立体_3815"],
@@ -36,20 +40,80 @@ def _ensure_file() -> None:
     with FAULT_MAP_CSV.open("w", encoding="utf-8", newline="") as handle:
         writer = csv.writer(handle)
         writer.writerow(FIELDS)
-        writer.writerows(SEED)
+        for row in SEED:
+            writer.writerow(row + [""] * 6)
 
 
-def list_rows() -> list[dict[str, Any]]:
+def _read() -> list[dict[str, str]]:
     _ensure_file()
     with FAULT_MAP_CSV.open("r", encoding="utf-8", newline="") as handle:
         return [
-            {
-                "model": row["model"].strip(),
-                "alarm_type": row["alarm_type"].strip(),
-                "device_point": row["device_point"].strip(),
-                "device_part": row["device_part"].strip(),
-                "part": row["part"].strip(),
-            }
+            {key: (row.get(key) or "").strip() for key in FIELDS}
             for row in csv.DictReader(handle)
-            if row.get("part")
         ]
+
+
+def _write(rows: list[dict[str, str]]) -> None:
+    DATA_DIR.mkdir(parents=True, exist_ok=True)
+    tmp = FAULT_MAP_CSV.parent / (FAULT_MAP_CSV.name + ".tmp")
+    with tmp.open("w", encoding="utf-8", newline="") as handle:
+        writer = csv.writer(handle)
+        writer.writerow(FIELDS)
+        for row in rows:
+            writer.writerow([row.get(key, "") for key in FIELDS])
+    os.replace(tmp, FAULT_MAP_CSV)
+
+
+def _split(value: str) -> list[str]:
+    return [item.strip() for item in str(value or "").split("|") if item.strip()]
+
+
+def _vector(row: dict[str, str], x: str, y: str, z: str) -> list[float] | None:
+    try:
+        return [float(row[x]), float(row[y]), float(row[z])]
+    except (ValueError, KeyError):
+        return None
+
+
+def _to_api(row: dict[str, str]) -> dict[str, Any]:
+    return {
+        "model": row["model"],
+        "alarm_type": row["alarm_type"],
+        "device_point": row["device_point"],
+        "device_part": row["device_part"],
+        "part": row["part"],
+        "camera": _vector(row, "camera_x", "camera_y", "camera_z"),
+        "target": _vector(row, "target_x", "target_y", "target_z"),
+    }
+
+
+def list_rows() -> list[dict[str, Any]]:
+    return [_to_api(row) for row in _read() if row.get("part")]
+
+
+def set_scene_position(
+    model: str,
+    alarm_type: str,
+    device_point: str,
+    camera: list[float],
+    target: list[float],
+) -> int:
+    rows = _read()
+    wanted = set(_split(device_point))
+    changed = 0
+    for row in rows:
+        if row["model"] != model or row["alarm_type"] != alarm_type:
+            continue
+        row_points = set(_split(row["device_point"]))
+        if wanted and not (wanted & row_points):
+            continue
+        row["camera_x"], row["camera_y"], row["camera_z"] = (
+            str(camera[0]), str(camera[1]), str(camera[2]),
+        )
+        row["target_x"], row["target_y"], row["target_z"] = (
+            str(target[0]), str(target[1]), str(target[2]),
+        )
+        changed += 1
+    if changed:
+        _write(rows)
+    return changed

+ 12 - 12
3d/data/fault_part_map.csv

@@ -1,12 +1,12 @@
-model,alarm_type,device_point,device_part,part
-压缩机2326-3000-one.glb,润滑油压力故障,YSJ_5,润滑油压力,三维立体_1687
-压缩机2326-3000-one.glb,振动故障,YSJ_11,链轮端振动,三维立体_3815
-压缩机2326-3000-one.glb,振动故障,YSJ_11,链轮端振动,三维立体_3814
-压缩机2326-3000-one.glb,振动故障,YSJ_10,联轴器端振动,三维立体_101
-压缩机2326-3000-one.glb,振动故障,YSJ_10,联轴器端振动,三维立体_97
-压缩机2326-3000-one.glb,振动故障,YSJ_10,联轴器端振动,三维立体_98
-压缩机2326-3000-one.glb,振动故障,YSJ_10,联轴器端振动,三维立体_130
-压缩机2326-3000-one.glb,振动故障,YSJ_10,联轴器端振动,三维立体_135
-压缩机2326-3000-one.glb,振动故障,YSJ_10,联轴器端振动,三维立体_134
-压缩机2326-3000-one.glb,振动故障,YSJ_10,联轴器端振动,三维立体_138
-压缩机2326-3000-one.glb,振动故障,YSJ_10,联轴器端振动,三维立体_3813
+model,alarm_type,device_point,device_part,part,camera_x,camera_y,camera_z,target_x,target_y,target_z
+压缩机2326-3000-one.glb,润滑油压力故障,YSJ_5,润滑油压力,三维立体_1687,15.622124515950691,10.055330415077645,-29.29204404569884,5.340199340854499,-4.950376594417323,-19.851266531021484
+压缩机2326-3000-one.glb,振动故障,YSJ_11,链轮端振动,三维立体_3815,0.25809478439485556,11.793764506994755,-31.111424360845284,5.673677584450476,-3.4169107554599476,-18.186356722823906
+压缩机2326-3000-one.glb,振动故障,YSJ_11,链轮端振动,三维立体_3814,0.25809478439485556,11.793764506994755,-31.111424360845284,5.673677584450476,-3.4169107554599476,-18.186356722823906
+压缩机2326-3000-one.glb,振动故障,YSJ_10,联轴器端振动,三维立体_101,10.373214218486305,11.691416913467895,-27.19015100738594,5.67366449505803,-3.41697932158619,-18.18643192942387
+压缩机2326-3000-one.glb,振动故障,YSJ_10,联轴器端振动,三维立体_97,10.373214218486305,11.691416913467895,-27.19015100738594,5.67366449505803,-3.41697932158619,-18.18643192942387
+压缩机2326-3000-one.glb,振动故障,YSJ_10,联轴器端振动,三维立体_98,10.373214218486305,11.691416913467895,-27.19015100738594,5.67366449505803,-3.41697932158619,-18.18643192942387
+压缩机2326-3000-one.glb,振动故障,YSJ_10,联轴器端振动,三维立体_130,10.373214218486305,11.691416913467895,-27.19015100738594,5.67366449505803,-3.41697932158619,-18.18643192942387
+压缩机2326-3000-one.glb,振动故障,YSJ_10,联轴器端振动,三维立体_135,10.373214218486305,11.691416913467895,-27.19015100738594,5.67366449505803,-3.41697932158619,-18.18643192942387
+压缩机2326-3000-one.glb,振动故障,YSJ_10,联轴器端振动,三维立体_134,10.373214218486305,11.691416913467895,-27.19015100738594,5.67366449505803,-3.41697932158619,-18.18643192942387
+压缩机2326-3000-one.glb,振动故障,YSJ_10,联轴器端振动,三维立体_138,10.373214218486305,11.691416913467895,-27.19015100738594,5.67366449505803,-3.41697932158619,-18.18643192942387
+压缩机2326-3000-one.glb,振动故障,YSJ_10,联轴器端振动,三维立体_3813,10.373214218486305,11.691416913467895,-27.19015100738594,5.67366449505803,-3.41697932158619,-18.18643192942387

+ 12 - 0
3d/frontend/src/api/faultMap.ts

@@ -5,3 +5,15 @@ export async function fetchFaultPartMap(): Promise<FaultMapRow[]> {
   const res = await http.get<FaultMapRow[]>('/api/fault-part-map')
   return res.data
 }
+
+export interface FaultScenePayload {
+  model: string
+  alarm_type: string
+  device_point: string
+  camera: [number, number, number]
+  target: [number, number, number]
+}
+
+export async function saveFaultScene(payload: FaultScenePayload): Promise<void> {
+  await http.post('/api/fault-part-map/scene', payload)
+}

+ 48 - 1
3d/frontend/src/components/ScenePositionCard.vue

@@ -2,14 +2,25 @@
 import { message } from 'ant-design-vue'
 import { computed, ref } from 'vue'
 
+import { saveFaultScene } from '../api/faultMap'
+import { getViewer } from '../three/instance'
+import { useAlarmsStore } from '../stores/alarms'
+import { useFaultPartsStore } from '../stores/faultParts'
+import { useModelStore } from '../stores/model'
 import { useScenesStore } from '../stores/scenes'
 import { useViewerStore } from '../stores/viewer'
 
 const viewerStore = useViewerStore()
 const scenesStore = useScenesStore()
+const alarmsStore = useAlarmsStore()
+const modelStore = useModelStore()
+const faultPartsStore = useFaultPartsStore()
 
 const addOpen = ref(false)
 const addName = ref('')
+const fitting = ref(false)
+
+const canFit = computed(() => Boolean(alarmsStore.selected))
 
 const lines = computed(() => {
   const info = viewerStore.camInfo
@@ -37,13 +48,44 @@ async function confirmAdd(): Promise<void> {
   await scenesStore.add(name)
   addOpen.value = false
 }
+
+/** 将当前相机位置记录为“选中故障”的场景位置(写入 fault_part_map.csv)。 */
+async function fitFault(): Promise<void> {
+  const alarm = alarmsStore.selected
+  const state = getViewer()?.getCameraState()
+  if (!alarm || !state) {
+    message.warning('请先选择一个故障')
+    return
+  }
+  fitting.value = true
+  try {
+    await saveFaultScene({
+      model: modelStore.current,
+      alarm_type: alarm.alarm_type,
+      device_point: alarm.device_point,
+      camera: state.camera,
+      target: state.target,
+    })
+    await faultPartsStore.reload()
+    message.success('已贴合故障')
+  } catch (error) {
+    message.error('贴合失败:' + (error as Error).message)
+  } finally {
+    fitting.value = false
+  }
+}
 </script>
 
 <template>
   <section class="glass card scene-card">
     <div class="card-head">
       <h3 class="card-title">场景位置</h3>
-      <a-button type="link" size="small" class="add-btn" @click="openAdd">添加位置</a-button>
+      <div class="card-actions">
+        <a-button type="link" size="small" @click="openAdd">添加位置</a-button>
+        <a-button type="link" size="small" :disabled="!canFit" :loading="fitting" @click="fitFault">
+          贴合故障
+        </a-button>
+      </div>
     </div>
     <div v-if="lines.length" class="cam-info">
       <div v-for="(line, i) in lines" :key="i">{{ line }}</div>
@@ -74,6 +116,11 @@ async function confirmAdd(): Promise<void> {
   letter-spacing: 0.5px;
   margin: 0;
 }
+.card-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
 .scene-card :deep(.ant-btn) {
   font-size: 12px;
 }

+ 1 - 1
3d/frontend/src/composables/useThreeViewer.ts

@@ -29,7 +29,7 @@ export function useThreeViewer(container: Ref<HTMLElement | null>): void {
       viewer.prepareViews()
       viewer.fitAll(1.2)
       await scenesStore.load()
-      if (scenesStore.items[0]) scenesStore.apply(scenesStore.items[0])
+      if (scenesStore.items[0]) scenesStore.apply(scenesStore.items[0], false)
       await faultPartsStore.ensureLoaded()
       faultPartsStore.apply()
       modelStore.loading = false

+ 14 - 2
3d/frontend/src/stores/faultParts.ts

@@ -15,6 +15,10 @@ export const useFaultPartsStore = defineStore('faultParts', () => {
 
   async function ensureLoaded(): Promise<void> {
     if (loaded.value) return
+    await reload()
+  }
+
+  async function reload(): Promise<void> {
     rows.value = await fetchFaultPartMap()
     loaded.value = true
   }
@@ -45,15 +49,23 @@ export const useFaultPartsStore = defineStore('faultParts', () => {
     const byLabel = new Map(partsStore.parts.map((part) => [part.label, part.id]))
     const hex = levelColor(alarm.alarm_level)
     const entries: Record<string, string> = {}
+    let sceneCamera: [number, number, number] | null = null
+    let sceneTarget: [number, number, number] | null = null
     rows.value.forEach((row) => {
       if (row.model !== model || row.alarm_type !== alarm.alarm_type) return
       const rowPoints = splitPoints(row.device_point)
       if (rowPoints.length && !rowPoints.some((point) => alarmPoints.includes(point))) return
       const id = byLabel.get(row.part)
       if (id) entries[id] = hex
+      if (!sceneCamera && row.camera && row.target) {
+        sceneCamera = row.camera
+        sceneTarget = row.target
+      }
     })
-    getViewer()?.setFaultColors(entries)
+    const viewer = getViewer()
+    viewer?.setFaultColors(entries)
+    if (sceneCamera && sceneTarget) viewer?.flyToCamera(sceneCamera, sceneTarget)
   }
 
-  return { rows, loaded, ensureLoaded, apply, clear }
+  return { rows, loaded, ensureLoaded, reload, apply, clear }
 })

+ 5 - 2
3d/frontend/src/stores/scenes.ts

@@ -21,8 +21,11 @@ export const useScenesStore = defineStore('scenes', () => {
     }
   }
 
-  function apply(scene: ScenePosition): void {
-    getViewer()?.applyCamera(scene.camera, scene.target)
+  function apply(scene: ScenePosition, animate = true): void {
+    const viewer = getViewer()
+    if (!viewer) return
+    if (animate) viewer.flyToCamera(scene.camera, scene.target)
+    else viewer.applyCamera(scene.camera, scene.target)
   }
 
   async function add(name: string): Promise<void> {

+ 51 - 1
3d/frontend/src/three/Viewer.ts

@@ -43,6 +43,12 @@ function eachMaterial(obj: THREE.Mesh, fn: (m: THREE.Material) => void): void {
   else if (obj.material) fn(obj.material)
 }
 
+function easeOutBack(x: number): number {
+  const c1 = 1.70158
+  const c3 = c1 + 1
+  return 1 + c3 * (x - 1) ** 3 + c1 * (x - 1) ** 2
+}
+
 export class Viewer {
   private container: HTMLElement
   private cb: ViewerCallbacks
@@ -79,6 +85,14 @@ export class Viewer {
   private hoveredId: string | null = null
   private lastPick = 0
   private downXY: [number, number] | null = null
+  private transition: {
+    positionFrom: THREE.Vector3
+    positionTo: THREE.Vector3
+    targetFrom: THREE.Vector3
+    targetTo: THREE.Vector3
+    start: number
+    duration: number
+  } | null = null
 
   constructor(container: HTMLElement, callbacks: ViewerCallbacks = {}) {
     this.container = container
@@ -143,7 +157,8 @@ export class Viewer {
     const loop = (): void => {
       if (!this.animating) return
       requestAnimationFrame(loop)
-      this.controls.update()
+      if (this.transition) this.stepTransition()
+      else this.controls.update()
       this.composer.render()
     }
     loop()
@@ -218,6 +233,7 @@ export class Viewer {
     this.faultColors.clear()
     this.prevSelected = new Set()
     this.hoveredId = null
+    this.transition = null
   }
 
   private countMeshNodes(group: THREE.Object3D): number {
@@ -375,6 +391,40 @@ export class Viewer {
     this.emitCamInfo()
   }
 
+  /** 平滑飞到“参考系”相机/目标(easeOutBack 轻微回弹)。 */
+  flyToCamera(
+    camera: [number, number, number],
+    target: [number, number, number],
+    duration = 600,
+  ): void {
+    const dz = this.modelMedianZ - REF_MEDIAN_Z
+    this.transition = {
+      positionFrom: this.camera.position.clone(),
+      positionTo: new THREE.Vector3(camera[0], camera[1], camera[2] + dz),
+      targetFrom: this.controls.target.clone(),
+      targetTo: new THREE.Vector3(target[0], target[1], target[2] + dz),
+      start: performance.now(),
+      duration,
+    }
+    this.controls.enabled = false
+  }
+
+  private stepTransition(): void {
+    const tr = this.transition
+    if (!tr) return
+    const progress = Math.min(1, (performance.now() - tr.start) / tr.duration)
+    const eased = easeOutBack(progress)
+    this.camera.position.lerpVectors(tr.positionFrom, tr.positionTo, eased)
+    this.controls.target.lerpVectors(tr.targetFrom, tr.targetTo, eased)
+    this.camera.lookAt(this.controls.target)
+    this.emitCamInfo()
+    if (progress >= 1) {
+      this.transition = null
+      this.controls.enabled = true
+      this.controls.update()
+    }
+  }
+
   /** 返回“参考系”下的当前相机/目标(Z 去掉 dz)。 */
   getCameraState(): { camera: [number, number, number]; target: [number, number, number] } {
     const dz = this.modelMedianZ - REF_MEDIAN_Z

+ 2 - 0
3d/frontend/src/types/index.ts

@@ -33,6 +33,8 @@ export interface FaultMapRow {
   device_point: string
   device_part: string
   part: string
+  camera: [number, number, number] | null
+  target: [number, number, number] | null
 }
 
 export interface ChartSeries {