Sfoglia il codice sorgente

3d:故障↔3D部件对照表(CSV+接口)与选中故障按等级着色;部件命名改createUniqueName;移除时序演示与配色持久化

18922397810 2 settimane fa
parent
commit
afe7465315

+ 1 - 0
3d/backend/config.py

@@ -9,6 +9,7 @@ MODELS_DIR = MODULE_DIR / "models"
 DATA_DIR = MODULE_DIR / "data"
 DATA_FILE = DATA_DIR / "color_state.json"
 SCENE_CSV = DATA_DIR / "scene_positions.csv"
+FAULT_MAP_CSV = DATA_DIR / "fault_part_map.csv"
 FRONTEND_DIST = MODULE_DIR / "frontend" / "dist"
 
 # 数据库凭据沿用项目根目录的 数据库.md(与 backend/app/config.py 同源,不复制到代码里)。

+ 2 - 1
3d/backend/main.py

@@ -13,7 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware
 from fastapi.responses import FileResponse
 
 from config import FRONTEND_DIST
-from routers import alarms, files, scenes, series, state
+from routers import alarms, fault_map, files, scenes, series, state
 
 app = FastAPI(title="压缩机 3D 部件查看服务", version="0.1.0")
 app.add_middleware(
@@ -24,6 +24,7 @@ app.add_middleware(
 )
 
 app.include_router(alarms.router)
+app.include_router(fault_map.router)
 app.include_router(files.router)
 app.include_router(scenes.router)
 app.include_router(series.router)

+ 14 - 0
3d/backend/routers/fault_map.py

@@ -0,0 +1,14 @@
+"""故障 → 3D 部件对照表。"""
+
+from typing import Any
+
+from fastapi import APIRouter
+
+from services import fault_map_service
+
+router = APIRouter()
+
+
+@router.get("/api/fault-part-map")
+def list_fault_part_map() -> list[dict[str, Any]]:
+    return fault_map_service.list_rows()

+ 55 - 0
3d/backend/services/fault_map_service.py

@@ -0,0 +1,55 @@
+"""故障 → 3D 部件对照表(data/fault_part_map.csv 的读写)。
+
+列:model, alarm_type, device_point, device_part, part
+- device_point / device_part 支持多值,用 ``|`` 分隔(未来一个故障多个点)。
+- part 为前端部件标签(createUniqueName 约定:如 ``三维立体_1687``);一个部件一行。
+"""
+
+import csv
+import os
+from typing import Any
+
+from config import DATA_DIR, FAULT_MAP_CSV
+
+FIELDS = ["model", "alarm_type", "device_point", "device_part", "part"]
+
+# 初始种子(一个部件一行)。
+SEED: list[list[str]] = [
+    ["压缩机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"],
+]
+
+
+def _ensure_file() -> None:
+    if FAULT_MAP_CSV.exists():
+        return
+    DATA_DIR.mkdir(parents=True, exist_ok=True)
+    with FAULT_MAP_CSV.open("w", encoding="utf-8", newline="") as handle:
+        writer = csv.writer(handle)
+        writer.writerow(FIELDS)
+        writer.writerows(SEED)
+
+
+def list_rows() -> list[dict[str, Any]]:
+    _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(),
+            }
+            for row in csv.DictReader(handle)
+            if row.get("part")
+        ]

+ 2 - 1
3d/data/color_state.json

@@ -2,5 +2,6 @@
   "2CylinderEngine.glb": {},
   "压缩机2326-3000.glb": {},
   "压缩机2326-3000_模块2.glb": {},
-  "压缩机2326-3000_删墙.glb": {}
+  "压缩机2326-3000_删墙.glb": {},
+  "压缩机2326-3000-one.glb": {}
 }

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

@@ -0,0 +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

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

@@ -0,0 +1,7 @@
+import { http } from './http'
+import type { FaultMapRow } from '../types'
+
+export async function fetchFaultPartMap(): Promise<FaultMapRow[]> {
+  const res = await http.get<FaultMapRow[]>('/api/fault-part-map')
+  return res.data
+}

+ 0 - 8
3d/frontend/src/components/TopBar.vue

@@ -3,13 +3,11 @@ import { Modal, message } from 'ant-design-vue'
 import { ref } from 'vue'
 
 import { useModelStore } from '../stores/model'
-import { usePartsStore } from '../stores/parts'
 import { useScenesStore } from '../stores/scenes'
 import { useViewerStore } from '../stores/viewer'
 import type { ScenePosition } from '../types'
 
 const modelStore = useModelStore()
-const partsStore = usePartsStore()
 const viewerStore = useViewerStore()
 const scenesStore = useScenesStore()
 
@@ -73,7 +71,6 @@ function removeFromRename(): void {
       />
     </div>
     <div class="spacer" />
-    <a-tag v-if="partsStore.cycleText" color="blue">{{ partsStore.cycleText }}</a-tag>
     <div class="scene-chips">
       <div
         v-for="scene in scenesStore.items"
@@ -89,7 +86,6 @@ function removeFromRename(): void {
     <a-button size="small" class="rot-btn" @click="viewerStore.toggleFreeMode()">
       {{ viewerStore.freeMode ? '轨道旋转' : '自由旋转' }}
     </a-button>
-    <span v-if="partsStore.saveStatus" class="save-status">{{ partsStore.saveStatus }}</span>
     <a-checkbox v-model:checked="viewerStore.sceneVisible">场景位置</a-checkbox>
 
     <a-modal v-model:open="renameOpen" title="场景位置" :footer="null">
@@ -178,10 +174,6 @@ function removeFromRename(): void {
 .rename-spacer {
   flex: 1;
 }
-.save-status {
-  color: #8b93a1;
-  font-size: 12px;
-}
 /* 与场景位置圆角卡片统一样式 */
 .rot-btn {
   display: inline-flex !important;

+ 7 - 8
3d/frontend/src/composables/useThreeViewer.ts

@@ -3,6 +3,8 @@ import { onBeforeUnmount, onMounted, watch, type Ref } from 'vue'
 import { modelUrl } from '../api/models'
 import { setViewer } from '../three/instance'
 import { Viewer } from '../three/Viewer'
+import { useAlarmsStore } from '../stores/alarms'
+import { useFaultPartsStore } from '../stores/faultParts'
 import { useModelStore } from '../stores/model'
 import { usePartsStore } from '../stores/parts'
 import { useScenesStore } from '../stores/scenes'
@@ -13,13 +15,14 @@ export function useThreeViewer(container: Ref<HTMLElement | null>): void {
   const partsStore = usePartsStore()
   const viewerStore = useViewerStore()
   const scenesStore = useScenesStore()
+  const alarmsStore = useAlarmsStore()
+  const faultPartsStore = useFaultPartsStore()
   let viewer: Viewer | null = null
 
   async function load(name: string): Promise<void> {
     if (!viewer || !name) return
     modelStore.loading = true
     modelStore.loadingText = '模型加载中…'
-    partsStore.stopCycle()
     partsStore.clearSelection()
     try {
       await viewer.loadModel(name, modelUrl(name))
@@ -27,13 +30,8 @@ export function useThreeViewer(container: Ref<HTMLElement | null>): void {
       viewer.fitAll(1.2)
       await scenesStore.load()
       if (scenesStore.items[0]) scenesStore.apply(scenesStore.items[0])
-      await partsStore.applySavedState(name)
-      if (/^压缩机2326/.test(name) && /one/.test(name)) {
-        const target = partsStore.parts.find(
-          (p) => p.label === '三维立体_1687' || p.name === '三维立体_1687',
-        )
-        if (target) partsStore.startCycle(target.id)
-      }
+      await faultPartsStore.ensureLoaded()
+      faultPartsStore.apply()
       modelStore.loading = false
       modelStore.loadingText = ''
     } catch (error) {
@@ -59,6 +57,7 @@ export function useThreeViewer(container: Ref<HTMLElement | null>): void {
   })
 
   watch(() => modelStore.current, (name) => { load(name) })
+  watch(() => alarmsStore.selected?.id, () => { faultPartsStore.apply() })
 
   onBeforeUnmount(() => {
     viewer?.dispose()

+ 59 - 0
3d/frontend/src/stores/faultParts.ts

@@ -0,0 +1,59 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+
+import { fetchFaultPartMap } from '../api/faultMap'
+import { getViewer } from '../three/instance'
+import type { FaultMapRow } from '../types'
+import { levelColor } from '../utils/level'
+import { useAlarmsStore } from './alarms'
+import { useModelStore } from './model'
+import { usePartsStore } from './parts'
+
+export const useFaultPartsStore = defineStore('faultParts', () => {
+  const rows = ref<FaultMapRow[]>([])
+  const loaded = ref(false)
+
+  async function ensureLoaded(): Promise<void> {
+    if (loaded.value) return
+    rows.value = await fetchFaultPartMap()
+    loaded.value = true
+  }
+
+  function splitPoints(value: string): string[] {
+    return String(value || '')
+      .split(/[|,]/)
+      .map((item) => item.trim())
+      .filter(Boolean)
+  }
+
+  function clear(): void {
+    getViewer()?.clearFaultColors()
+  }
+
+  /** 依据当前选中告警与当前模型,给命中的 3D 部件上故障等级色(临时、不持久化)。 */
+  function apply(): void {
+    const alarms = useAlarmsStore()
+    const partsStore = usePartsStore()
+    const modelStore = useModelStore()
+    const alarm = alarms.selected
+    if (!alarm || !loaded.value) {
+      clear()
+      return
+    }
+    const model = modelStore.current
+    const alarmPoints = splitPoints(alarm.device_point)
+    const byLabel = new Map(partsStore.parts.map((part) => [part.label, part.id]))
+    const hex = levelColor(alarm.alarm_level)
+    const entries: Record<string, string> = {}
+    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
+    })
+    getViewer()?.setFaultColors(entries)
+  }
+
+  return { rows, loaded, ensureLoaded, apply, clear }
+})

+ 4 - 89
3d/frontend/src/stores/parts.ts

@@ -1,29 +1,14 @@
 import { defineStore } from 'pinia'
 import { computed, ref } from 'vue'
 
-import { fetchState, saveState } from '../api/state'
 import { getViewer } from '../three/instance'
-import type { PartMeta, PartStatus, SavedState } from '../types'
-
-interface CycleStep {
-  label: string
-  apply: (id: string) => void
-}
+import type { PartMeta, PartStatus } from '../types'
 
 export const usePartsStore = defineStore('parts', () => {
   const parts = ref<PartMeta[]>([])
   const selected = ref<Set<string>>(new Set())
   const queryOpen = ref(false)
   const search = ref('')
-  const saveStatus = ref('')
-  const cycleText = ref('')
-
-  let currentModel = ''
-  let saveTimer: ReturnType<typeof setTimeout> | undefined
-  let toastTimer: ReturnType<typeof setTimeout> | undefined
-  let cycleTimer: ReturnType<typeof setInterval> | undefined
-  let cyclePartId: string | null = null
-  let cycleStep = 0
 
   const selectedParts = computed(() => parts.value.filter((p) => selected.value.has(p.id)))
   const selectedCount = computed(() => selected.value.size)
@@ -76,94 +61,24 @@ export const usePartsStore = defineStore('parts', () => {
     getViewer()?.resetPart(id)
   }
 
-  function toast(msg: string): void {
-    saveStatus.value = msg
-    clearTimeout(toastTimer)
-    toastTimer = setTimeout(() => { saveStatus.value = '' }, 2500)
-  }
-
   function applyPreset(hex: string, status: PartStatus | null): void {
-    if (!selected.value.size) { toast('请先选择一个部件'); return }
+    if (!selected.value.size) return
     selected.value.forEach((id) => setColor(id, hex, status))
-    scheduleSave()
   }
 
   function resetSelected(): void {
-    if (!selected.value.size) { toast('请先选择一个部件'); return }
+    if (!selected.value.size) return
     selected.value.forEach((id) => resetColor(id))
-    scheduleSave()
   }
 
   function resetAll(): void {
-    stopCycle()
     parts.value.forEach((p) => resetColor(p.id))
-    scheduleSave()
-  }
-
-  /* ---------------- 持久化 ---------------- */
-  function scheduleSave(): void {
-    clearTimeout(saveTimer)
-    saveStatus.value = '保存中…'
-    saveTimer = setTimeout(doSave, 400)
-  }
-
-  async function doSave(): Promise<void> {
-    if (!currentModel) return
-    const state: SavedState = {}
-    parts.value.forEach((p) => {
-      if (p.colorHex) state[p.id] = { color: p.colorHex, status: p.status }
-    })
-    try {
-      await saveState(currentModel, state)
-      saveStatus.value = '✓ 已保存 ' + new Date().toLocaleTimeString('zh-CN', { hour12: false })
-    } catch {
-      saveStatus.value = '保存失败'
-    }
-  }
-
-  async function applySavedState(model: string): Promise<void> {
-    currentModel = model
-    const state = await fetchState(model)
-    parts.value.forEach((p) => {
-      const saved = state[p.id]
-      if (saved && saved.color) setColor(p.id, saved.color, saved.status ?? null)
-    })
-  }
-
-  /* ---------------- 时序着色演示 ---------------- */
-  const cycleSteps: CycleStep[] = [
-    { label: '本色', apply: (id) => resetColor(id) },
-    { label: '预警', apply: (id) => setColor(id, '#F9A825', 'warn') },
-    { label: '故障', apply: (id) => setColor(id, '#E53935', 'fault') },
-  ]
-
-  function startCycle(id: string): void {
-    stopCycle()
-    cyclePartId = id
-    cycleStep = 0
-    const tick = (): void => {
-      const step = cycleSteps[cycleStep]
-      step.apply(id)
-      cycleText.value = `${cycleStep + 1}/3 ${step.label}`
-      cycleStep = (cycleStep + 1) % cycleSteps.length
-    }
-    tick()
-    cycleTimer = setInterval(tick, 1000)
-  }
-
-  function stopCycle(): void {
-    if (cycleTimer) { clearInterval(cycleTimer); cycleTimer = undefined }
-    if (cyclePartId) resetColor(cyclePartId)
-    cyclePartId = null
-    cycleStep = 0
-    cycleText.value = ''
   }
 
   return {
-    parts, selected, queryOpen, search, saveStatus, cycleText,
+    parts, selected, queryOpen, search,
     selectedParts, selectedCount,
     byId, setParts, selectOnly, toggle, clearSelection,
     setColor, resetColor, applyPreset, resetSelected, resetAll,
-    applySavedState, startCycle, stopCycle,
   }
 })

+ 18 - 2
3d/frontend/src/three/Viewer.ts

@@ -74,6 +74,7 @@ export class Viewer {
   })
 
   private selectedIds = new Set<string>()
+  private faultColors = new Map<string, string>()
   private prevSelected = new Set<string>()
   private hoveredId: string | null = null
   private lastPick = 0
@@ -214,6 +215,7 @@ export class Viewer {
     this.parts = []
     this.byId.clear()
     this.selectedIds.clear()
+    this.faultColors.clear()
     this.prevSelected = new Set()
     this.hoveredId = null
   }
@@ -243,7 +245,7 @@ export class Viewer {
       const id = `${base}#${n}`
       const defaultHex = toHex((mesh.material as THREE.MeshStandardMaterial)?.color ?? 0xcccccc)
       const handle: PartHandle = {
-        id, label: n > 1 ? `${base} [${n}]` : base, name: base, defaults: [defaultHex],
+        id, label: n > 1 ? `${base}_${n - 1}` : base, name: base, defaults: [defaultHex],
         status: null, colorHex: null, meta: null as unknown as PartMeta,
       }
       rec.entries.push({ matrix: mesh.matrixWorld.clone(), handle })
@@ -497,10 +499,24 @@ export class Viewer {
     this.applyDisplay(id)
   }
 
+  /** 故障高亮:临时显示层(不写入部件自身配色、不持久化)。 */
+  setFaultColors(entries: Record<string, string>): void {
+    const changed = new Set<string>([...this.faultColors.keys(), ...Object.keys(entries)])
+    this.faultColors = new Map(Object.entries(entries))
+    changed.forEach((id) => this.applyDisplay(id))
+  }
+
+  clearFaultColors(): void {
+    if (!this.faultColors.size) return
+    const ids = [...this.faultColors.keys()]
+    this.faultColors.clear()
+    ids.forEach((id) => this.applyDisplay(id))
+  }
+
   private applyDisplay(id: string): void {
     const handle = this.byId.get(id)
     if (!handle) return
-    const base = handle.colorHex || handle.defaults[0]
+    const base = this.faultColors.get(id) ?? handle.colorHex ?? handle.defaults[0]
     if (handle.inst && handle.instanceId !== undefined) {
       const c = new THREE.Color(base)
       if (this.selectedIds.has(id)) c.lerp(HIGHLIGHT, 0.45)

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

@@ -27,6 +27,14 @@ export interface SavedPartState {
 
 export type SavedState = Record<string, SavedPartState>
 
+export interface FaultMapRow {
+  model: string
+  alarm_type: string
+  device_point: string
+  device_part: string
+  part: string
+}
+
 export interface ChartSeries {
   name: string
   data: [number, number | null][]