فهرست منبع

3d 重构:FastAPI 服务端 + Ant Design Vue 客户端(Vue3+Vite+TS),拆分 tools/docs,移除旧原生客户端与 server.py

18922397810 2 هفته پیش
والد
کامیت
21c53436fc
60فایلهای تغییر یافته به همراه4070 افزوده شده و 64453 حذف شده
  1. 2 0
      .gitignore
  2. 79 0
      3d/README.md
  3. 10 0
      3d/backend/config.py
  4. 42 0
      3d/backend/main.py
  5. 3 0
      3d/backend/requirements.txt
  6. 0 0
      3d/backend/routers/__init__.py
  7. 21 0
      3d/backend/routers/files.py
  8. 22 0
      3d/backend/routers/state.py
  9. 29 0
      3d/backend/run.py
  10. 0 0
      3d/backend/services/__init__.py
  11. 35 0
      3d/backend/services/model_service.py
  12. 27 0
      3d/backend/services/state_service.py
  13. 3 2
      3d/建模与GLB转换指南.md
  14. 12 0
      3d/frontend/index.html
  15. 2065 0
      3d/frontend/package-lock.json
  16. 25 0
      3d/frontend/package.json
  17. 38 0
      3d/frontend/src/App.vue
  18. 4 0
      3d/frontend/src/api/http.ts
  19. 10 0
      3d/frontend/src/api/models.ts
  20. 11 0
      3d/frontend/src/api/state.ts
  21. 24 0
      3d/frontend/src/components/DataPanel.vue
  22. 142 0
      3d/frontend/src/components/PartQueryModal.vue
  23. 119 0
      3d/frontend/src/components/PartStatusCard.vue
  24. 52 0
      3d/frontend/src/components/ScenePositionCard.vue
  25. 89 0
      3d/frontend/src/components/TopBar.vue
  26. 84 0
      3d/frontend/src/components/ViewerCanvas.vue
  27. 70 0
      3d/frontend/src/composables/useThreeViewer.ts
  28. 7 0
      3d/frontend/src/env.d.ts
  29. 9 0
      3d/frontend/src/main.ts
  30. 20 0
      3d/frontend/src/stores/model.ts
  31. 169 0
      3d/frontend/src/stores/parts.ts
  32. 40 0
      3d/frontend/src/stores/viewer.ts
  33. 73 0
      3d/frontend/src/styles/index.css
  34. 625 0
      3d/frontend/src/three/Viewer.ts
  35. 11 0
      3d/frontend/src/three/instance.ts
  36. 40 0
      3d/frontend/src/types/index.ts
  37. 21 0
      3d/frontend/tsconfig.json
  38. 18 0
      3d/frontend/vite.config.ts
  39. 0 216
      3d/server.py
  40. 0 289
      3d/static/index.html
  41. 0 1417
      3d/static/js/jsm/controls/OrbitControls.js
  42. 0 828
      3d/static/js/jsm/controls/TrackballControls.js
  43. 0 148
      3d/static/js/jsm/environments/RoomEnvironment.js
  44. 0 4663
      3d/static/js/jsm/loaders/GLTFLoader.js
  45. 0 231
      3d/static/js/jsm/postprocessing/EffectComposer.js
  46. 0 104
      3d/static/js/jsm/postprocessing/MaskPass.js
  47. 0 654
      3d/static/js/jsm/postprocessing/OutlinePass.js
  48. 0 95
      3d/static/js/jsm/postprocessing/OutputPass.js
  49. 0 95
      3d/static/js/jsm/postprocessing/Pass.js
  50. 0 99
      3d/static/js/jsm/postprocessing/RenderPass.js
  51. 0 77
      3d/static/js/jsm/postprocessing/ShaderPass.js
  52. 0 45
      3d/static/js/jsm/shaders/CopyShader.js
  53. 0 81
      3d/static/js/jsm/shaders/OutputShader.js
  54. 0 1375
      3d/static/js/jsm/utils/BufferGeometryUtils.js
  55. 0 990
      3d/static/js/main.js
  56. 0 53044
      3d/static/js/three.module.js
  57. 0 0
      3d/tools/remove_nodes.py
  58. 0 0
      3d/tools/scale_part.py
  59. 0 0
      3d/tools/split_glb.py
  60. 19 0
      README.md

+ 2 - 0
.gitignore

@@ -5,6 +5,8 @@ __MACOSX/
 .venv/
 frontend/node_modules/
 frontend/dist/
+3d/frontend/node_modules/
+3d/frontend/dist/
 .env
 .env.*
 !.env.example

+ 79 - 0
3d/README.md

@@ -0,0 +1,79 @@
+# 压缩机 3D 部件查看与配色
+
+Python(FastAPI)服务端 + Ant Design Vue(Vue3 + Vite + TypeScript)客户端。
+加载 `models/*.glb`,按部件单独选中与着色,配色状态持久化到 `data/color_state.json`。
+
+## 目录结构
+
+```
+3d/
+├─ backend/            # FastAPI 服务端(扁平结构,无 app/ 层)
+│  ├─ main.py          # 应用入口:/api、/models、前端托管与 SPA 回退
+│  ├─ config.py        # 路径配置
+│  ├─ routers/         # files.py(模型)、state.py(配色状态)
+│  ├─ services/        # model_service、state_service
+│  ├─ run.py           # 启动脚本
+│  └─ requirements.txt
+├─ frontend/           # Ant Design Vue 客户端
+│  └─ src/
+│     ├─ api/          # 接口封装(axios)
+│     ├─ stores/       # pinia:model / parts / viewer
+│     ├─ three/        # 纯 three.js 逻辑(Viewer.ts 等)
+│     ├─ composables/  # useThreeViewer(Vue 与 three 的桥接)
+│     ├─ components/   # TopBar / ViewerCanvas / DataPanel / 卡片 / 查询弹窗
+│     ├─ types/        # 类型定义
+│     └─ styles/
+├─ tools/              # CAD/GLB 处理脚本(split_glb / remove_nodes / scale_part)
+├─ models/             # *.glb 模型
+├─ data/               # color_state.json
+└─ docs/               # 建模与GLB转换指南.md
+```
+
+## 依赖
+
+```bash
+python3 -m pip install -r 3d/backend/requirements.txt
+cd 3d/frontend && npm install
+```
+
+## 开发模式(前后端分离,热更新)
+
+```bash
+# 终端 1:服务端
+python3 3d/backend/run.py --reload         # http://127.0.0.1:8000
+
+# 终端 2:客户端
+cd 3d/frontend
+npm run dev                                # http://127.0.0.1:5173
+```
+
+Vite 已把 `/api`、`/models` 代理到 `http://127.0.0.1:8000`(见 `vite.config.ts`)。
+
+## 生产模式(单端口)
+
+```bash
+cd 3d/frontend && npm run build            # 产物:3d/frontend/dist
+python3 3d/backend/run.py --host 0.0.0.0 --port 8000
+```
+
+FastAPI 托管 `frontend/dist`,非 `/api`、`/models` 的路径回退 `index.html`。
+
+## 接口
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| GET | `/api/models` | 可用模型名列表 |
+| GET | `/api/state?model=x` | 读取某模型的配色状态 |
+| POST | `/api/state?model=x` | 保存配色状态(body 为 `{部件id: {color, status}}`) |
+| GET | `/models/<file>` | GLB / glTF 模型文件 |
+
+## CAD 工具
+
+```bash
+# 按名字删除节点(世界变换烘焙,支持嵌套)
+python3 3d/tools/remove_nodes.py --input a.glb --output b.glb --name 三维立体
+# 按查看器标签缩放零件
+python3 3d/tools/scale_part.py --input a.glb --label 区域_143 --width 3 --inplace
+# 按平面拆分模块
+python3 3d/tools/split_glb.py --input a.glb --outdir 3d/models --prefix a --axis y --planes 16 32
+```

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

@@ -0,0 +1,10 @@
+"""3D 模块服务端配置:路径与端口。"""
+
+from pathlib import Path
+
+BASE_DIR = Path(__file__).resolve().parent          # 3d/backend
+MODULE_DIR = BASE_DIR.parent                        # 3d
+MODELS_DIR = MODULE_DIR / "models"
+DATA_DIR = MODULE_DIR / "data"
+DATA_FILE = DATA_DIR / "color_state.json"
+FRONTEND_DIST = MODULE_DIR / "frontend" / "dist"

+ 42 - 0
3d/backend/main.py

@@ -0,0 +1,42 @@
+"""压缩机 3D 部件查看服务(FastAPI)。
+
+接口:
+    GET  /api/models          可用模型列表
+    GET  /api/state?model=x   读取配色状态
+    POST /api/state?model=x   保存配色状态
+    GET  /models/<file>       GLB / glTF 模型文件
+    GET  /*                   前端构建产物(frontend/dist),SPA 回退 index.html
+"""
+
+from fastapi import FastAPI, HTTPException
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import FileResponse
+
+from config import FRONTEND_DIST
+from routers import files, state
+
+app = FastAPI(title="压缩机 3D 部件查看服务", version="0.1.0")
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["*"],
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+app.include_router(files.router)
+app.include_router(state.router)
+
+
+@app.get("/{full_path:path}")
+def serve_frontend(full_path: str) -> FileResponse:
+    if full_path.startswith(("api/", "models/")):
+        raise HTTPException(status_code=404, detail="Not Found")
+    resolved_dist = FRONTEND_DIST.resolve()
+    if full_path:
+        candidate = (FRONTEND_DIST / full_path).resolve()
+        if resolved_dist in candidate.parents and candidate.is_file():
+            return FileResponse(candidate)
+    index = FRONTEND_DIST / "index.html"
+    if index.is_file():
+        return FileResponse(index)
+    raise HTTPException(status_code=404, detail="前端未构建:请先 cd 3d/frontend && npm run build")

+ 3 - 0
3d/backend/requirements.txt

@@ -0,0 +1,3 @@
+fastapi>=0.115,<1
+uvicorn[standard]>=0.30,<1
+pydantic>=2.0,<3

+ 0 - 0
3d/backend/routers/__init__.py


+ 21 - 0
3d/backend/routers/files.py

@@ -0,0 +1,21 @@
+"""模型列表与模型文件下载。"""
+
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import FileResponse
+
+from services import model_service
+
+router = APIRouter()
+
+
+@router.get("/api/models")
+def list_models() -> list[str]:
+    return model_service.list_models()
+
+
+@router.get("/models/{name}")
+def get_model(name: str) -> FileResponse:
+    path = model_service.resolve_model(name)
+    if path is None:
+        raise HTTPException(status_code=404, detail="模型不存在")
+    return FileResponse(path, media_type=model_service.media_type(path))

+ 22 - 0
3d/backend/routers/state.py

@@ -0,0 +1,22 @@
+"""每个模型的配色状态读写。"""
+
+from typing import Any
+
+from fastapi import APIRouter, HTTPException, Query
+
+from services import state_service
+
+router = APIRouter()
+
+
+@router.get("/api/state")
+def get_state(model: str = Query(...)) -> dict[str, Any]:
+    return state_service.load_model_state(model)
+
+
+@router.post("/api/state")
+def save_state(payload: dict[str, Any], model: str = Query(...)) -> dict[str, Any]:
+    if not model:
+        raise HTTPException(status_code=400, detail="缺少 model 参数")
+    state_service.save_model_state(model, payload)
+    return {"ok": True, "model": model, "count": len(payload)}

+ 29 - 0
3d/backend/run.py

@@ -0,0 +1,29 @@
+#!/usr/bin/env python3
+"""启动 3D 服务端。
+
+用法:
+    python3 3d/backend/run.py                 # 默认 127.0.0.1:8000
+    python3 3d/backend/run.py --port 9100 --reload
+"""
+
+import argparse
+import sys
+from pathlib import Path
+
+import uvicorn
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="压缩机 3D 部件查看服务")
+    parser.add_argument("--host", default="127.0.0.1")
+    parser.add_argument("--port", type=int, default=8000)
+    parser.add_argument("--reload", action="store_true")
+    args = parser.parse_args()
+
+    uvicorn.run("main:app", host=args.host, port=args.port, reload=args.reload)
+
+
+if __name__ == "__main__":
+    main()

+ 0 - 0
3d/backend/services/__init__.py


+ 35 - 0
3d/backend/services/model_service.py

@@ -0,0 +1,35 @@
+"""模型文件:列举与安全解析。"""
+
+from pathlib import Path
+
+from config import MODELS_DIR
+
+ALLOWED_SUFFIXES = {".glb", ".gltf"}
+MEDIA_TYPES = {".glb": "model/gltf-binary", ".gltf": "model/gltf+json"}
+
+
+def list_models() -> list[str]:
+    if not MODELS_DIR.is_dir():
+        return []
+    names = [
+        path.name
+        for path in MODELS_DIR.iterdir()
+        if path.is_file() and path.suffix.lower() in ALLOWED_SUFFIXES
+    ]
+    return sorted(names)
+
+
+def resolve_model(name: str) -> Path | None:
+    """只允许 models 目录下的直接子文件,防路径穿越。"""
+    if not name or name != Path(name).name:
+        return None
+    candidate = (MODELS_DIR / name).resolve()
+    if candidate.parent != MODELS_DIR.resolve():
+        return None
+    if candidate.is_file() and candidate.suffix.lower() in ALLOWED_SUFFIXES:
+        return candidate
+    return None
+
+
+def media_type(path: Path) -> str:
+    return MEDIA_TYPES.get(path.suffix.lower(), "application/octet-stream")

+ 27 - 0
3d/backend/services/state_service.py

@@ -0,0 +1,27 @@
+"""配色状态持久化:data/color_state.json(原子写)。"""
+
+import json
+import os
+from typing import Any
+
+from config import DATA_DIR, DATA_FILE
+
+
+def load_all() -> dict[str, Any]:
+    try:
+        return json.loads(DATA_FILE.read_text(encoding="utf-8"))
+    except (OSError, json.JSONDecodeError):
+        return {}
+
+
+def load_model_state(model: str) -> dict[str, Any]:
+    return load_all().get(model, {})
+
+
+def save_model_state(model: str, state: dict[str, Any]) -> None:
+    DATA_DIR.mkdir(parents=True, exist_ok=True)
+    all_state = load_all()
+    all_state[model] = state
+    tmp = DATA_FILE.with_suffix(DATA_FILE.suffix + ".tmp")
+    tmp.write_text(json.dumps(all_state, ensure_ascii=False, indent=2), encoding="utf-8")
+    os.replace(tmp, DATA_FILE)

+ 3 - 2
3d/建模与GLB转换指南.md

@@ -1,7 +1,8 @@
 # 3D 机械图建模与 GLB 转换注意事项
 
 > 面向本程序(网页 3D 查看 + 每部件独立着色)的建模与导出规范。
-> 演示程序:`3d/`,运行 `python3 3d/server.py`,模型放 `3d/models/*.glb`。
+> 演示程序:`3d/`(FastAPI 服务端 + Ant Design Vue 客户端),
+> 运行 `python3 3d/backend/run.py`,模型放 `3d/models/*.glb`。详见 `3d/README.md`。
 
 ---
 
@@ -89,7 +90,7 @@ CAD Exchanger SDK / Datakit CrossManager:一条命令 STEP→GLB,保留部
 
 ## 六、交付自检清单
 
-打开网页(`python3 3d/server.py`)后逐项确认:
+打开网页(`python3 3d/backend/run.py`,默认 http://127.0.0.1:8000)后逐项确认:
 
 - [ ] 顶部下拉能选到你的模型
 - [ ] 部件列表数量、名称与你 CAD 里的零件对应

+ 12 - 0
3d/frontend/index.html

@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>3D 部件查看与配色</title>
+</head>
+<body>
+  <div id="app"></div>
+  <script type="module" src="/src/main.ts"></script>
+</body>
+</html>

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 2065 - 0
3d/frontend/package-lock.json


+ 25 - 0
3d/frontend/package.json

@@ -0,0 +1,25 @@
+{
+  "name": "compressor-3d-viewer",
+  "private": true,
+  "version": "0.1.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vue-tsc --noEmit && vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "ant-design-vue": "^4.2.6",
+    "axios": "^1.7.9",
+    "pinia": "^2.3.0",
+    "three": "^0.186.0",
+    "vue": "^3.5.13"
+  },
+  "devDependencies": {
+    "@types/three": "^0.186.0",
+    "@vitejs/plugin-vue": "^5.2.1",
+    "typescript": "^5.7.3",
+    "vite": "^6.1.0",
+    "vue-tsc": "^2.2.0"
+  }
+}

+ 38 - 0
3d/frontend/src/App.vue

@@ -0,0 +1,38 @@
+<script setup lang="ts">
+import { theme } from 'ant-design-vue'
+import zhCN from 'ant-design-vue/es/locale/zh_CN'
+import { onMounted } from 'vue'
+
+import DataPanel from './components/DataPanel.vue'
+import PartQueryModal from './components/PartQueryModal.vue'
+import PartStatusCard from './components/PartStatusCard.vue'
+import ScenePositionCard from './components/ScenePositionCard.vue'
+import TopBar from './components/TopBar.vue'
+import ViewerCanvas from './components/ViewerCanvas.vue'
+import { useModelStore } from './stores/model'
+
+const modelStore = useModelStore()
+
+onMounted(() => {
+  modelStore.loadModelList()
+})
+</script>
+
+<template>
+  <a-config-provider
+    :locale="zhCN"
+    :theme="{ algorithm: theme.darkAlgorithm, token: { colorPrimary: '#4da3ff' } }"
+  >
+    <div class="app">
+      <TopBar />
+      <ViewerCanvas>
+        <DataPanel />
+        <div class="ext-panel">
+          <PartStatusCard />
+          <ScenePositionCard />
+        </div>
+      </ViewerCanvas>
+      <PartQueryModal />
+    </div>
+  </a-config-provider>
+</template>

+ 4 - 0
3d/frontend/src/api/http.ts

@@ -0,0 +1,4 @@
+import axios from 'axios'
+
+/** 同源请求:开发由 Vite 代理到后端,生产由 FastAPI 统一托管。 */
+export const http = axios.create({ baseURL: '', timeout: 20000 })

+ 10 - 0
3d/frontend/src/api/models.ts

@@ -0,0 +1,10 @@
+import { http } from './http'
+
+export async function fetchModels(): Promise<string[]> {
+  const res = await http.get<string[]>('/api/models')
+  return res.data
+}
+
+export function modelUrl(name: string): string {
+  return `/models/${encodeURIComponent(name)}`
+}

+ 11 - 0
3d/frontend/src/api/state.ts

@@ -0,0 +1,11 @@
+import { http } from './http'
+import type { SavedState } from '../types'
+
+export async function fetchState(model: string): Promise<SavedState> {
+  const res = await http.get<SavedState>('/api/state', { params: { model } })
+  return res.data
+}
+
+export async function saveState(model: string, state: SavedState): Promise<void> {
+  await http.post('/api/state', state, { params: { model } })
+}

+ 24 - 0
3d/frontend/src/components/DataPanel.vue

@@ -0,0 +1,24 @@
+<template>
+  <section class="glass card data-panel">
+    <h3 class="card-title">数据区域</h3>
+    <div class="placeholder">内容待定…</div>
+  </section>
+</template>
+
+<style scoped>
+.data-panel {
+  position: absolute;
+  left: 20px;
+  top: 20px;
+  bottom: 20px;
+  width: 300px;
+  padding: 12px 14px;
+  display: flex;
+  flex-direction: column;
+}
+.placeholder {
+  margin-top: 10px;
+  color: #8b93a1;
+  font-size: 12px;
+}
+</style>

+ 142 - 0
3d/frontend/src/components/PartQueryModal.vue

@@ -0,0 +1,142 @@
+<script setup lang="ts">
+import { theme } from 'ant-design-vue'
+import type { TableColumnType } from 'ant-design-vue'
+import { computed, h } from 'vue'
+
+import { usePartsStore } from '../stores/parts'
+import { PRESETS, type PartMeta } from '../types'
+
+const partsStore = usePartsStore()
+const MAX_ROWS = 800
+
+const filtered = computed<PartMeta[]>(() => {
+  const q = partsStore.search.trim().toLowerCase()
+  const matched = q
+    ? partsStore.parts.filter(
+        (p) => p.label.toLowerCase().includes(q) || p.name.toLowerCase().includes(q),
+      )
+    : partsStore.parts
+  return matched.slice(0, MAX_ROWS)
+})
+
+const note = computed(() => {
+  const q = partsStore.search.trim().toLowerCase()
+  const total = q
+    ? partsStore.parts.filter(
+        (p) => p.label.toLowerCase().includes(q) || p.name.toLowerCase().includes(q),
+      ).length
+    : partsStore.parts.length
+  if (total > MAX_ROWS) return `匹配 ${total} 个,仅显示前 ${MAX_ROWS} 个,请用搜索缩小范围`
+  if (total === 0) return '没有匹配的部件'
+  return ''
+})
+
+const columns: TableColumnType<PartMeta>[] = [
+  { title: '名称', dataIndex: 'label', key: 'label', ellipsis: true },
+  { title: '顶点数', dataIndex: 'vtx', key: 'vtx', width: 84, customRender: ({ record }) => record.vtx.toLocaleString() },
+  { title: '三角面', dataIndex: 'tri', key: 'tri', width: 84, customRender: ({ record }) => record.tri.toLocaleString() },
+  { title: '尺寸 (x×y×z)', dataIndex: 'size', key: 'size', width: 160 },
+  {
+    title: '颜色',
+    key: 'color',
+    width: 110,
+    customRender: ({ record }) => {
+      const hex = record.colorHex || record.defaults[0]
+      return h('span', { class: 'color-cell' }, [
+        h('i', { class: 'swatch', style: { background: hex } }),
+        h('span', hex),
+      ])
+    },
+  },
+  {
+    title: '状态',
+    key: 'status',
+    width: 70,
+    customRender: ({ record }) => {
+      const preset = PRESETS.find((s) => s.key === record.status)
+      if (!preset) return '—'
+      return h('span', { class: 'status-tag', style: { background: preset.color } }, preset.label)
+    },
+  },
+]
+
+const customRow = (record: PartMeta) => ({
+  onClick: () => partsStore.selectOnly(record.id),
+})
+const rowClassName = (record: PartMeta): string =>
+  partsStore.selected.has(record.id) ? 'row-selected' : ''
+</script>
+
+<template>
+  <a-modal
+    :open="partsStore.queryOpen"
+    wrap-class-name="part-query-modal"
+    :footer="null"
+    width="880px"
+    title="查询部件"
+    @cancel="partsStore.queryOpen = false"
+  >
+    <a-config-provider :theme="{ algorithm: theme.defaultAlgorithm }">
+      <div class="query-body">
+        <a-input
+          v-model:value="partsStore.search"
+          allow-clear
+          placeholder="搜索部件名称…"
+          class="search"
+        />
+        <a-table
+          :columns="columns"
+          :data-source="filtered"
+          :pagination="false"
+          :custom-row="customRow"
+          :row-class-name="rowClassName"
+          row-key="id"
+          size="small"
+          :scroll="{ y: 420 }"
+        >
+          <template #emptyText>
+            <span style="color: #8b93a1">没有匹配的部件</span>
+          </template>
+        </a-table>
+        <div v-if="note" class="note">{{ note }}</div>
+      </div>
+    </a-config-provider>
+  </a-modal>
+</template>
+
+<style scoped>
+.query-body {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+.note {
+  text-align: center;
+  color: #8b93a1;
+  font-size: 12px;
+}
+:deep(.row-selected) > td {
+  background: #e3efff !important;
+}
+:deep(.ant-table-row) {
+  cursor: pointer;
+}
+:deep(.color-cell) {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+:deep(.swatch) {
+  width: 13px;
+  height: 13px;
+  border-radius: 4px;
+  display: inline-block;
+  border: 1px solid rgba(0, 0, 0, 0.25);
+}
+:deep(.status-tag) {
+  color: #fff;
+  border-radius: 4px;
+  padding: 1px 7px;
+  font-size: 10px;
+}
+</style>

+ 119 - 0
3d/frontend/src/components/PartStatusCard.vue

@@ -0,0 +1,119 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+
+import { usePartsStore } from '../stores/parts'
+import { PRESETS, type PartMeta } from '../types'
+
+const partsStore = usePartsStore()
+
+const single = computed<PartMeta | null>(() =>
+  partsStore.selectedParts.length === 1 ? partsStore.selectedParts[0] : null,
+)
+
+const statusLabel = computed(() => {
+  const p = single.value
+  if (!p) return ''
+  return PRESETS.find((s) => s.key === p.status)?.label ?? '未设置'
+})
+</script>
+
+<template>
+  <section class="glass card status-card">
+    <div class="card-head">
+      <h3 class="card-title">部件状态控制</h3>
+      <a-button type="link" size="small" @click="partsStore.resetAll()">全部恢复</a-button>
+    </div>
+
+    <div class="preset-row">
+      <a-button
+        v-for="p in PRESETS"
+        :key="p.key"
+        size="small"
+        class="preset-btn"
+        @click="partsStore.applyPreset(p.color, p.key)"
+      >
+        <span class="dot" :style="{ background: p.color }" />{{ p.label }}
+      </a-button>
+      <a-button size="small" class="preset-btn" @click="partsStore.resetSelected()">恢复</a-button>
+    </div>
+
+    <div class="detail">
+      <template v-if="partsStore.selectedCount === 0">
+        <div class="dim">未选中部件。点击模型中的部件,或点下方「打开查询部件…」选择。</div>
+      </template>
+      <template v-else-if="partsStore.selectedCount > 1">
+        <div class="line">
+          已选中 {{ partsStore.selectedCount }} 个部件:{{
+            partsStore.selectedParts.slice(0, 6).map((p) => p.label).join('、')
+          }}{{ partsStore.selectedCount > 6 ? '…' : '' }}
+        </div>
+      </template>
+      <template v-else-if="single">
+        <div class="line">名称:{{ single.label }}</div>
+        <div class="line">顶点数:{{ single.vtx.toLocaleString() }} 三角面:{{ single.tri.toLocaleString() }}</div>
+        <div class="line">尺寸:{{ single.size }} 颜色:{{ single.colorHex || single.defaults[0] }} 状态:{{ statusLabel }}</div>
+      </template>
+    </div>
+
+    <div class="card-foot">
+      <a-button type="link" size="small" @click="partsStore.queryOpen = true">打开查询部件…</a-button>
+    </div>
+  </section>
+</template>
+
+<style scoped>
+.status-card {
+  pointer-events: auto;
+  padding: 12px 14px;
+}
+.card-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 8px;
+}
+.card-title {
+  font-size: 12px;
+  color: #8b93a1;
+  font-weight: 600;
+  letter-spacing: 0.5px;
+  margin: 0;
+}
+.preset-row {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 6px;
+}
+.preset-btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 5px;
+  padding: 0 4px;
+}
+.dot {
+  width: 10px;
+  height: 10px;
+  border-radius: 3px;
+  display: inline-block;
+}
+.detail {
+  margin-top: 10px;
+  font-size: 12px;
+  line-height: 1.7;
+  color: #d6dbe2;
+}
+.line {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.dim {
+  color: #8b93a1;
+}
+.card-foot {
+  margin-top: 8px;
+  padding-top: 6px;
+  border-top: 1px solid #333a45;
+}
+</style>

+ 52 - 0
3d/frontend/src/components/ScenePositionCard.vue

@@ -0,0 +1,52 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+
+import { useViewerStore } from '../stores/viewer'
+
+const viewerStore = useViewerStore()
+
+const lines = computed(() => {
+  const info = viewerStore.camInfo
+  if (!info) return []
+  const [cx, cy, cz] = info.camera
+  const [tx, ty, tz] = info.target
+  return [
+    `方位角 Az ${info.az.toFixed(1)}°   俯仰角 El ${info.el.toFixed(1)}°   距离 ${info.dist.toFixed(1)}`,
+    `相机 (${cx.toFixed(1)}, ${cy.toFixed(1)}, ${cz.toFixed(1)})`,
+    `目标 (${tx.toFixed(1)}, ${ty.toFixed(1)}, ${tz.toFixed(1)})`,
+  ]
+})
+</script>
+
+<template>
+  <section class="glass card scene-card">
+    <h3 class="card-title">场景位置</h3>
+    <div v-if="lines.length" class="cam-info">
+      <div v-for="(line, i) in lines" :key="i">{{ line }}</div>
+    </div>
+    <div v-else class="cam-info dim">—</div>
+  </section>
+</template>
+
+<style scoped>
+.scene-card {
+  pointer-events: auto;
+  padding: 12px 14px;
+}
+.card-title {
+  font-size: 12px;
+  color: #8b93a1;
+  font-weight: 600;
+  letter-spacing: 0.5px;
+  margin: 0 0 8px;
+}
+.cam-info {
+  color: #8b93a1;
+  font-size: 12px;
+  line-height: 1.6;
+  font-variant-numeric: tabular-nums;
+}
+.dim {
+  opacity: 0.6;
+}
+</style>

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

@@ -0,0 +1,89 @@
+<script setup lang="ts">
+import { VIEW_PRESETS } from '../three/Viewer'
+import { useModelStore } from '../stores/model'
+import { usePartsStore } from '../stores/parts'
+import { useViewerStore } from '../stores/viewer'
+
+const modelStore = useModelStore()
+const partsStore = usePartsStore()
+const viewerStore = useViewerStore()
+
+function onModelChange(value: unknown): void {
+  modelStore.current = String(value)
+}
+</script>
+
+<template>
+  <header class="topbar">
+    <h1 class="title">3D 部件查看与配色</h1>
+    <div class="field">
+      <span class="label">模型</span>
+      <a-select
+        :value="modelStore.current || undefined"
+        :options="modelStore.models.map((m) => ({ value: m, label: m }))"
+        size="small"
+        style="width: 240px"
+        @change="onModelChange"
+      />
+    </div>
+    <span class="count">共 {{ partsStore.parts.length }} 个部件{{ modelStore.largeMode ? ' · 大模型模式' : '' }}</span>
+    <div class="spacer" />
+    <a-tag v-if="partsStore.cycleText" color="blue">{{ partsStore.cycleText }}</a-tag>
+    <a-button-group>
+      <a-button
+        v-for="(v, i) in VIEW_PRESETS"
+        :key="v.label"
+        size="small"
+        :type="viewerStore.activeView === i ? 'primary' : 'default'"
+        @click="viewerStore.activateView(i)"
+      >
+        {{ v.label }}
+      </a-button>
+    </a-button-group>
+    <a-button size="small" @click="viewerStore.toggleFreeMode()">
+      {{ viewerStore.freeMode ? '轨道旋转' : '自由旋转' }}
+    </a-button>
+    <span class="save-status">{{ partsStore.saveStatus }}</span>
+  </header>
+</template>
+
+<style scoped>
+.topbar {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  height: 48px;
+  padding: 0 16px;
+  background: #1d2128;
+  border-bottom: 1px solid #333a45;
+  color: #d6dbe2;
+  z-index: 20;
+  position: relative;
+}
+.title {
+  font-size: 15px;
+  font-weight: 600;
+  margin: 0;
+}
+.field {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+.field .label {
+  color: #8b93a1;
+}
+.count {
+  color: #8b93a1;
+  font-size: 12px;
+}
+.spacer {
+  flex: 1;
+}
+.save-status {
+  color: #8b93a1;
+  font-size: 12px;
+  min-width: 96px;
+  text-align: right;
+}
+</style>

+ 84 - 0
3d/frontend/src/components/ViewerCanvas.vue

@@ -0,0 +1,84 @@
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+
+import { useThreeViewer } from '../composables/useThreeViewer'
+import { useModelStore } from '../stores/model'
+import { useViewerStore } from '../stores/viewer'
+
+const container = ref<HTMLElement | null>(null)
+const modelStore = useModelStore()
+const viewerStore = useViewerStore()
+
+useThreeViewer(container)
+
+const tooltipStyle = computed(() => ({
+  left: `${viewerStore.tooltip.x + 14}px`,
+  top: `${viewerStore.tooltip.y + 14}px`,
+}))
+
+function onMove(e: MouseEvent): void {
+  const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
+  viewerStore.moveTooltip(e.clientX - rect.left, e.clientY - rect.top)
+}
+</script>
+
+<template>
+  <div class="stage" @mousemove="onMove">
+    <div ref="container" class="canvas-host" />
+    <div v-if="modelStore.loadingText" class="loading-overlay">{{ modelStore.loadingText }}</div>
+    <div v-if="viewerStore.tooltip.visible" class="tooltip" :style="tooltipStyle">
+      {{ viewerStore.hoveredLabel }}
+    </div>
+    <div class="hint">左键拖动=旋转 · 滚轮=缩放 · 右键拖动=平移 · 点击部件=选中</div>
+    <slot />
+  </div>
+</template>
+
+<style scoped>
+.stage {
+  position: relative;
+  flex: 1;
+  min-width: 0;
+  min-height: 0;
+  background: #0f1115;
+  overflow: hidden;
+}
+.canvas-host {
+  position: absolute;
+  inset: 0;
+}
+.loading-overlay {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #8b93a1;
+  font-size: 14px;
+  z-index: 5;
+}
+.tooltip {
+  position: absolute;
+  pointer-events: none;
+  background: rgba(0, 0, 0, 0.75);
+  color: #fff;
+  padding: 4px 8px;
+  border-radius: 5px;
+  font-size: 12px;
+  white-space: nowrap;
+  z-index: 20;
+}
+.hint {
+  position: absolute;
+  left: 50%;
+  transform: translateX(-50%);
+  bottom: 12px;
+  color: #8b93a1;
+  font-size: 12px;
+  background: rgba(0, 0, 0, 0.45);
+  padding: 5px 10px;
+  border-radius: 6px;
+  pointer-events: none;
+  z-index: 4;
+}
+</style>

+ 70 - 0
3d/frontend/src/composables/useThreeViewer.ts

@@ -0,0 +1,70 @@
+import { onBeforeUnmount, onMounted, watch, type Ref } from 'vue'
+
+import { modelUrl } from '../api/models'
+import { setViewer } from '../three/instance'
+import { Viewer } from '../three/Viewer'
+import { useModelStore } from '../stores/model'
+import { usePartsStore } from '../stores/parts'
+import { useViewerStore } from '../stores/viewer'
+
+export function useThreeViewer(container: Ref<HTMLElement | null>): void {
+  const modelStore = useModelStore()
+  const partsStore = usePartsStore()
+  const viewerStore = useViewerStore()
+  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))
+      viewer.prepareViews()
+      viewer.fitAll(1.2)
+      viewer.activateView(0)
+      viewerStore.activeView = 0
+      await partsStore.applySavedState(name)
+      if (/^压缩机2326/.test(name) && /模块2/.test(name)) {
+        const target = partsStore.parts.find(
+          (p) => p.label === '三维立体_1687' || p.name === '三维立体_1687',
+        )
+        if (target) partsStore.startCycle(target.id)
+      }
+      modelStore.loading = false
+      modelStore.loadingText = ''
+    } catch (error) {
+      modelStore.loadingText = '模型加载失败:' + (error as Error).message
+      setTimeout(() => {
+        modelStore.loading = false
+        modelStore.loadingText = ''
+      }, 4000)
+    }
+  }
+
+  onMounted(() => {
+    if (!container.value) return
+    viewer = new Viewer(container.value, {
+      onProgress: (text) => { modelStore.loadingText = text },
+      onParts: (list, info) => {
+        partsStore.setParts(list)
+        modelStore.meshCount = info.meshCount
+        modelStore.largeMode = info.largeMode
+      },
+      onHover: (id, label) => { viewerStore.setHover(id, label) },
+      onPick: (id, additive) => { if (id) partsStore.toggle(id, additive) },
+      onCamInfo: (info) => { viewerStore.setCamInfo(info) },
+    })
+    setViewer(viewer)
+    if (modelStore.current) load(modelStore.current)
+  })
+
+  watch(() => modelStore.current, (name) => { load(name) })
+
+  onBeforeUnmount(() => {
+    viewer?.dispose()
+    setViewer(null)
+    viewer = null
+  })
+}

+ 7 - 0
3d/frontend/src/env.d.ts

@@ -0,0 +1,7 @@
+/// <reference types="vite/client" />
+
+declare module '*.vue' {
+  import type { DefineComponent } from 'vue'
+  const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
+  export default component
+}

+ 9 - 0
3d/frontend/src/main.ts

@@ -0,0 +1,9 @@
+import Antd from 'ant-design-vue'
+import 'ant-design-vue/dist/reset.css'
+import { createPinia } from 'pinia'
+import { createApp } from 'vue'
+
+import App from './App.vue'
+import './styles/index.css'
+
+createApp(App).use(createPinia()).use(Antd).mount('#app')

+ 20 - 0
3d/frontend/src/stores/model.ts

@@ -0,0 +1,20 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+
+import { fetchModels } from '../api/models'
+
+export const useModelStore = defineStore('model', () => {
+  const models = ref<string[]>([])
+  const current = ref('')
+  const loading = ref(false)
+  const loadingText = ref('')
+  const meshCount = ref(0)
+  const largeMode = ref(false)
+
+  async function loadModelList(): Promise<void> {
+    models.value = await fetchModels()
+    if (!current.value && models.value.length) current.value = models.value[0]
+  }
+
+  return { models, current, loading, loadingText, meshCount, largeMode, loadModelList }
+})

+ 169 - 0
3d/frontend/src/stores/parts.ts

@@ -0,0 +1,169 @@
+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
+}
+
+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)
+
+  function byId(id: string): PartMeta | undefined {
+    return parts.value.find((p) => p.id === id)
+  }
+
+  function setParts(list: PartMeta[]): void {
+    parts.value = list
+    selected.value = new Set()
+  }
+
+  function syncViewerSelection(): void {
+    getViewer()?.setSelectedIds([...selected.value])
+  }
+
+  function selectOnly(id: string | null): void {
+    selected.value = new Set(id ? [id] : [])
+    syncViewerSelection()
+  }
+
+  function toggle(id: string, additive: boolean): void {
+    if (!additive) { selectOnly(id); return }
+    const next = new Set(selected.value)
+    if (next.has(id)) next.delete(id)
+    else next.add(id)
+    selected.value = next
+    syncViewerSelection()
+  }
+
+  function clearSelection(): void {
+    selected.value = new Set()
+    syncViewerSelection()
+  }
+
+  function setColor(id: string, hex: string, status: PartStatus | null): void {
+    const part = byId(id)
+    if (!part) return
+    part.colorHex = hex
+    part.status = status
+    getViewer()?.setPartColor(id, hex, status)
+  }
+
+  function resetColor(id: string): void {
+    const part = byId(id)
+    if (!part) return
+    part.colorHex = null
+    part.status = null
+    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 }
+    selected.value.forEach((id) => setColor(id, hex, status))
+    scheduleSave()
+  }
+
+  function resetSelected(): void {
+    if (!selected.value.size) { toast('请先选择一个部件'); 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,
+    selectedParts, selectedCount,
+    byId, setParts, selectOnly, toggle, clearSelection,
+    setColor, resetColor, applyPreset, resetSelected, resetAll,
+    applySavedState, startCycle, stopCycle,
+  }
+})

+ 40 - 0
3d/frontend/src/stores/viewer.ts

@@ -0,0 +1,40 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+
+import { getViewer } from '../three/instance'
+import type { CameraInfo } from '../types'
+
+export const useViewerStore = defineStore('viewer', () => {
+  const camInfo = ref<CameraInfo | null>(null)
+  const activeView = ref(0)
+  const freeMode = ref(false)
+  const hoveredId = ref<string | null>(null)
+  const hoveredLabel = ref<string | null>(null)
+  const tooltip = ref({ x: 0, y: 0, visible: false })
+
+  function setCamInfo(info: CameraInfo): void {
+    camInfo.value = info
+  }
+  function setHover(id: string | null, label: string | null): void {
+    hoveredId.value = id
+    hoveredLabel.value = label
+    tooltip.value.visible = Boolean(id)
+  }
+  function moveTooltip(x: number, y: number): void {
+    tooltip.value.x = x
+    tooltip.value.y = y
+  }
+  function activateView(index: number): void {
+    activeView.value = index
+    getViewer()?.activateView(index)
+  }
+  function toggleFreeMode(): void {
+    freeMode.value = !freeMode.value
+    getViewer()?.setFreeMode(freeMode.value)
+  }
+
+  return {
+    camInfo, activeView, freeMode, hoveredId, hoveredLabel, tooltip,
+    setCamInfo, setHover, moveTooltip, activateView, toggleFreeMode,
+  }
+})

+ 73 - 0
3d/frontend/src/styles/index.css

@@ -0,0 +1,73 @@
+* {
+  box-sizing: border-box;
+}
+html,
+body,
+#app {
+  height: 100%;
+  margin: 0;
+}
+body {
+  font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
+  background: #14161a;
+  color: #d6dbe2;
+  overflow: hidden;
+  font-size: 13px;
+}
+.app {
+  display: flex;
+  flex-direction: column;
+  height: 100vh;
+}
+
+/* 悬浮卡片(黑底 70% 透明、圆角 10px) */
+.glass {
+  background: rgba(0, 0, 0, 0.7);
+  border: 1px solid #333a45;
+  border-radius: 10px;
+  backdrop-filter: blur(6px);
+  -webkit-backdrop-filter: blur(6px);
+}
+
+/* 右侧扩展区域 */
+.ext-panel {
+  position: absolute;
+  right: 20px;
+  top: 20px;
+  bottom: 20px;
+  width: 300px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  overflow-y: auto;
+  pointer-events: none;
+  z-index: 8;
+}
+.ext-panel > * {
+  pointer-events: auto;
+  flex: 0 0 auto;
+}
+
+/* 查询部件弹窗:白底黑字 */
+.part-query-modal .ant-modal-content {
+  background: #fff;
+  color: #1a1a1a;
+}
+.part-query-modal .ant-modal-header {
+  background: #fff;
+  border-bottom: 1px solid #e7e9ec;
+  margin-bottom: 12px;
+}
+.part-query-modal .ant-modal-title {
+  color: #111;
+  font-weight: 600;
+}
+.part-query-modal .ant-modal-close {
+  color: #888;
+}
+.part-query-modal .ant-modal-close:hover {
+  color: #222;
+}
+.part-query-modal .ant-modal-body {
+  color: #1a1a1a;
+}

+ 625 - 0
3d/frontend/src/three/Viewer.ts

@@ -0,0 +1,625 @@
+import * as THREE from 'three'
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
+import { TrackballControls } from 'three/addons/controls/TrackballControls.js'
+import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
+import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'
+import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'
+import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
+import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js'
+import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'
+
+import type { CameraInfo, PartMeta, PartStatus } from '../types'
+
+const LARGE_THRESHOLD = 1500
+const HIGHLIGHT = new THREE.Color(0x4da3ff)
+const REF_MEDIAN_Z = -22.46
+
+export interface ViewPreset {
+  label: string
+  camera?: [number, number, number]
+  target?: [number, number, number]
+  axis?: [number, number, number]
+}
+
+export const VIEW_PRESETS: ViewPreset[] = [
+  { label: '俯视', camera: [2.0, 22.0, -3.2], target: [2.0, 0.0, -22.0] },
+  { label: '角度A', camera: [5.4, 16.7, -6.3], target: [9.4, 0.0, -22.5] },
+  { label: '短边', axis: [1, 0, 0] },
+]
+
+interface PartHandle {
+  id: string
+  label: string
+  name: string
+  defaults: string[]
+  obj?: THREE.Mesh
+  inst?: THREE.InstancedMesh
+  instanceId?: number
+  status: PartStatus | null
+  colorHex: string | null
+  meta: PartMeta
+}
+
+interface ViewerCallbacks {
+  onParts?: (parts: PartMeta[], info: { meshCount: number; largeMode: boolean; medianZ: number }) => void
+  onProgress?: (text: string) => void
+  onHover?: (id: string | null, label: string | null) => void
+  onPick?: (id: string | null, additive: boolean) => void
+  onCamInfo?: (info: CameraInfo) => void
+  onLog?: (...args: unknown[]) => void
+}
+
+function toHex(color: THREE.ColorRepresentation): string {
+  return '#' + new THREE.Color(color).getHexString()
+}
+
+function eachMaterial(obj: THREE.Mesh, fn: (m: THREE.Material) => void): void {
+  if (Array.isArray(obj.material)) obj.material.forEach(fn)
+  else if (obj.material) fn(obj.material)
+}
+
+export class Viewer {
+  private container: HTMLElement
+  private cb: ViewerCallbacks
+  private renderer!: THREE.WebGLRenderer
+  private scene!: THREE.Scene
+  private camera!: THREE.PerspectiveCamera
+  private orbit!: OrbitControls
+  private trackball: TrackballControls | null = null
+  private controls!: OrbitControls | TrackballControls
+  private freeMode = false
+  private composer!: EffectComposer
+  private outline!: OutlinePass
+  private grid: THREE.GridHelper | null = null
+  private loader = new GLTFLoader()
+  private raycaster = new THREE.Raycaster()
+  private pointer = new THREE.Vector2()
+  private animating = false
+
+  private modelGroup: THREE.Group | null = null
+  private modelCenter = new THREE.Vector3()
+  private modelSize = new THREE.Vector3()
+  private modelDist = 0
+  private modelMedianZ = REF_MEDIAN_Z
+
+  private parts: PartHandle[] = []
+  private byId = new Map<string, PartHandle>()
+  private largeMode = false
+  private instancedMeshes: THREE.InstancedMesh[] = []
+  private instMaterial = new THREE.MeshStandardMaterial({
+    color: 0xffffff, metalness: 0.2, roughness: 0.7, side: THREE.DoubleSide,
+  })
+
+  private selectedIds = new Set<string>()
+  private prevSelected = new Set<string>()
+  private hoveredId: string | null = null
+  private lastPick = 0
+  private downXY: [number, number] | null = null
+
+  constructor(container: HTMLElement, callbacks: ViewerCallbacks = {}) {
+    this.container = container
+    this.cb = callbacks
+    this.init()
+  }
+
+  /* ---------------- 初始化 ---------------- */
+  private init(): void {
+    const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true })
+    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
+    renderer.setSize(this.container.clientWidth, this.container.clientHeight)
+    renderer.toneMapping = THREE.ACESFilmicToneMapping
+    renderer.toneMappingExposure = 1.05
+    renderer.domElement.style.display = 'block'
+    this.container.insertBefore(renderer.domElement, this.container.firstChild)
+    this.renderer = renderer
+
+    const scene = new THREE.Scene()
+    scene.background = new THREE.Color(0x0f1115)
+    this.scene = scene
+
+    const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 2000)
+    this.camera = camera
+
+    this.orbit = new OrbitControls(camera, renderer.domElement)
+    this.orbit.enableDamping = true
+    this.orbit.dampingFactor = 0.08
+    this.controls = this.orbit
+
+    const pmrem = new THREE.PMREMGenerator(renderer)
+    scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture
+    scene.add(new THREE.HemisphereLight(0xffffff, 0x20242c, 0.9))
+    const dl = new THREE.DirectionalLight(0xffffff, 1.6)
+    dl.position.set(6, 12, 8)
+    scene.add(dl)
+    const dl2 = new THREE.DirectionalLight(0xffffff, 0.5)
+    dl2.position.set(-8, -4, -6)
+    scene.add(dl2)
+
+    const composer = new EffectComposer(renderer)
+    composer.addPass(new RenderPass(scene, camera))
+    const outline = new OutlinePass(
+      new THREE.Vector2(this.container.clientWidth, this.container.clientHeight), scene, camera,
+    )
+    outline.edgeStrength = 4
+    outline.edgeGlow = 0.2
+    outline.edgeThickness = 1.2
+    outline.visibleEdgeColor = new THREE.Color(0x4da3ff)
+    outline.hiddenEdgeColor = new THREE.Color(0x000000)
+    composer.addPass(outline)
+    composer.addPass(new OutputPass())
+    this.composer = composer
+    this.outline = outline
+
+    this.bindEvents()
+    this.updateAspect()
+    this.animate()
+  }
+
+  private bindEvents(): void {
+    const el = this.renderer.domElement
+    el.addEventListener('pointermove', this.onPointerMove)
+    el.addEventListener('pointerdown', this.onPointerDown)
+    el.addEventListener('pointerup', this.onPointerUp)
+    window.addEventListener('resize', this.updateAspect)
+    this.orbit.addEventListener('change', this.emitCamInfo)
+  }
+
+  private animate = (): void => {
+    this.animating = true
+    const loop = (): void => {
+      if (!this.animating) return
+      requestAnimationFrame(loop)
+      this.controls.update()
+      this.composer.render()
+    }
+    loop()
+  }
+
+  /* ---------------- 尺寸 / 相机信息 ---------------- */
+  updateAspect = (): void => {
+    const w = this.container.clientWidth
+    const h = this.container.clientHeight
+    if (!w || !h) return
+    this.camera.aspect = w / h
+    this.camera.updateProjectionMatrix()
+    this.renderer.setSize(w, h)
+    this.composer.setSize(w, h)
+    this.outline.setSize(w, h)
+    this.trackball?.handleResize()
+  }
+
+  private get modelMedianZValue(): number {
+    return this.modelMedianZ
+  }
+
+  private emitCamInfo = (): void => {
+    const off = this.camera.position.clone().sub(this.controls.target)
+    if (off.lengthSq() === 0) return
+    const sph = new THREE.Spherical().setFromVector3(off)
+    const az = ((THREE.MathUtils.radToDeg(sph.theta) % 360) + 360) % 360
+    const el = 90 - THREE.MathUtils.radToDeg(sph.phi)
+    const p = this.camera.position
+    const t = this.controls.target
+    this.cb.onCamInfo?.({
+      az, el, dist: sph.radius,
+      camera: [p.x, p.y, p.z],
+      target: [t.x, t.y, t.z],
+    })
+  }
+
+  /* ---------------- 模型加载 ---------------- */
+  async loadModel(name: string, url: string): Promise<void> {
+    this.cb.onProgress?.('模型加载中…')
+    const usePreset = /^压缩机2326/.test(name)
+    const gltf = await this.loader.loadAsync(url)
+    this.cb.onProgress?.('正在构建部件…')
+
+    this.clearModel()
+    const group = gltf.scene
+    if (usePreset) group.rotation.x = -Math.PI / 2
+    this.modelGroup = group
+    this.scene.add(group)
+
+    const meshCount = this.countMeshNodes(group)
+    this.largeMode = meshCount > LARGE_THRESHOLD
+    if (this.largeMode) {
+      this.cb.onProgress?.(`大模型模式:正在合并 ${meshCount} 个部件…`)
+      group.visible = false
+      this.collectPartsInstanced(group)
+    } else {
+      this.collectParts(group)
+    }
+    this.cb.onParts?.(this.parts.map((p) => p.meta), {
+      meshCount, largeMode: this.largeMode, medianZ: this.modelMedianZValue,
+    })
+    this.cb.onProgress?.('')
+  }
+
+  private clearModel(): void {
+    if (this.modelGroup) {
+      this.scene.remove(this.modelGroup)
+      this.modelGroup = null
+    }
+    this.instancedMeshes.forEach((m) => {
+      this.scene.remove(m)
+      m.dispose()
+    })
+    this.instancedMeshes = []
+    if (this.grid) {
+      this.scene.remove(this.grid)
+      this.grid = null
+    }
+    this.parts = []
+    this.byId.clear()
+    this.selectedIds.clear()
+    this.prevSelected = new Set()
+    this.largeMode = false
+    this.outline.selectedObjects = []
+    this.hoveredId = null
+  }
+
+  private countMeshNodes(group: THREE.Object3D): number {
+    let n = 0
+    group.traverse((o) => { if ((o as THREE.Mesh).isMesh && (o as THREE.Mesh).geometry) n++ })
+    return n
+  }
+
+  private ensureOwnMaterial(obj: THREE.Mesh): void {
+    if (Array.isArray(obj.material)) obj.material = obj.material.map((m) => m.clone())
+    else if (obj.material) obj.material = obj.material.clone()
+    eachMaterial(obj, (m) => { m.needsUpdate = true })
+  }
+
+  private collectParts(group: THREE.Group): void {
+    group.updateMatrixWorld(true)
+    const seen = new Map<string, number>()
+    group.traverse((obj) => {
+      const mesh = obj as THREE.Mesh
+      if (!mesh.isMesh || !mesh.geometry) return
+      this.ensureOwnMaterial(mesh)
+      const base = mesh.name && mesh.name.trim() ? mesh.name.trim() : '部件'
+      const n = (seen.get(base) || 0) + 1
+      seen.set(base, n)
+      const defaults: string[] = []
+      eachMaterial(mesh, (m) => {
+        const anyM = m as THREE.MeshStandardMaterial
+        const hex = toHex(anyM.color || 0xcccccc)
+        defaults.push(hex)
+        anyM.color.set(hex)
+      })
+      const id = `${base}#${n}`
+      const handle: PartHandle = {
+        id, label: n > 1 ? `${base} [${n}]` : base, name: base, defaults,
+        obj: mesh, status: null, colorHex: null, meta: null as unknown as PartMeta,
+      }
+      handle.meta = this.buildMeta(handle)
+      this.parts.push(handle)
+      this.byId.set(id, handle)
+    })
+    this.sortParts()
+  }
+
+  private collectPartsInstanced(group: THREE.Group): void {
+    group.updateMatrixWorld(true)
+    const byGeom = new Map<string, { geometry: THREE.BufferGeometry; entries: { matrix: THREE.Matrix4; handle: PartHandle }[] }>()
+    const seen = new Map<string, number>()
+    group.traverse((obj) => {
+      const mesh = obj as THREE.Mesh
+      if (!mesh.isMesh || !mesh.geometry) return
+      const geometry = mesh.geometry
+      if (!geometry.attributes.normal) geometry.computeVertexNormals()
+      if (!geometry.boundingSphere) geometry.computeBoundingSphere()
+      let rec = byGeom.get(geometry.uuid)
+      if (!rec) { rec = { geometry, entries: [] }; byGeom.set(geometry.uuid, rec) }
+
+      const base = mesh.name && mesh.name.trim() ? mesh.name.trim() : '部件'
+      const n = (seen.get(base) || 0) + 1
+      seen.set(base, n)
+      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],
+        status: null, colorHex: null, meta: null as unknown as PartMeta,
+      }
+      rec.entries.push({ matrix: mesh.matrixWorld.clone(), handle })
+      this.parts.push(handle)
+      this.byId.set(id, handle)
+    })
+
+    byGeom.forEach((rec) => {
+      const inst = new THREE.InstancedMesh(rec.geometry, this.instMaterial, rec.entries.length)
+      inst.instanceMatrix.setUsage(THREE.DynamicDrawUsage)
+      rec.entries.forEach((entry, i) => {
+        inst.setMatrixAt(i, entry.matrix)
+        inst.setColorAt(i, new THREE.Color(entry.handle.defaults[0]))
+        entry.handle.inst = inst
+        entry.handle.instanceId = i
+        entry.handle.meta = this.buildMeta(entry.handle)
+      })
+      inst.instanceMatrix.needsUpdate = true
+      if (inst.instanceColor) inst.instanceColor.needsUpdate = true
+      this.scene.add(inst)
+      this.instancedMeshes.push(inst)
+    })
+    this.sortParts()
+  }
+
+  private sortParts(): void {
+    this.parts.sort((a, b) => a.label.localeCompare(b.label, 'zh'))
+  }
+
+  private buildMeta(handle: PartHandle): PartMeta {
+    const geometry = this.partGeometry(handle)
+    let vtx = 0
+    let tri = 0
+    let size = '—'
+    if (geometry) {
+      if (geometry.attributes.position) vtx = geometry.attributes.position.count
+      tri = geometry.index ? Math.floor(geometry.index.count / 3) : Math.floor(vtx / 3)
+      if (!geometry.boundingBox) geometry.computeBoundingBox()
+      const matrix = this.partMatrix(handle)
+      if (geometry.boundingBox && matrix) {
+        const box = geometry.boundingBox.clone().applyMatrix4(matrix)
+        const d = box.getSize(new THREE.Vector3())
+        size = `${d.x.toFixed(1)}×${d.y.toFixed(1)}×${d.z.toFixed(1)}`
+      }
+    }
+    return {
+      id: handle.id, label: handle.label, name: handle.name, defaults: handle.defaults,
+      vtx, tri, size, status: handle.status, colorHex: handle.colorHex,
+    }
+  }
+
+  private partGeometry(handle: PartHandle): THREE.BufferGeometry | null {
+    if (handle.inst) return handle.inst.geometry
+    return handle.obj ? handle.obj.geometry : null
+  }
+
+  private partMatrix(handle: PartHandle): THREE.Matrix4 | null {
+    if (handle.inst && handle.instanceId !== undefined) {
+      const m = new THREE.Matrix4()
+      handle.inst.getMatrixAt(handle.instanceId, m)
+      return m
+    }
+    return handle.obj ? handle.obj.matrixWorld : null
+  }
+
+  /* ---------------- 取景 / 视角 ---------------- */
+  fitAll = (zoom = 1.2): void => {
+    if (!this.modelGroup) return
+    const box = new THREE.Box3().setFromObject(this.modelGroup)
+    if (box.isEmpty()) return
+    const center = box.getCenter(new THREE.Vector3())
+    const size = box.getSize(new THREE.Vector3())
+    const maxDim = Math.max(size.x, size.y, size.z)
+    const fovRad = THREE.MathUtils.degToRad(this.camera.fov)
+    const fitDist = (maxDim / (2 * Math.tan(fovRad / 2))) * 1.35
+    this.modelCenter.copy(center)
+    this.modelSize.copy(size)
+    this.modelDist = fitDist / zoom
+    this.camera.near = maxDim / 100
+    this.camera.far = maxDim * 50
+    const dir = new THREE.Vector3(0.7, 0.55, 1).normalize()
+    this.camera.position.copy(center).add(dir.multiplyScalar(this.modelDist))
+    this.camera.lookAt(center)
+    this.controls.target.copy(center)
+    this.controls.update()
+    this.updateAspect()
+    if (this.grid) this.scene.remove(this.grid)
+    const helper = new THREE.GridHelper(maxDim * 2.2, 20, 0x3a424f, 0x232831)
+    helper.position.set(center.x, box.min.y, center.z)
+    ;(helper.material as THREE.Material).transparent = true
+    ;(helper.material as THREE.Material).opacity = 0.5
+    this.scene.add(helper)
+    this.grid = helper
+    this.emitCamInfo()
+  }
+
+  prepareViews(): void {
+    if (!this.modelGroup) return
+    this.modelGroup.updateMatrixWorld(true)
+    this.modelMedianZ = this.computeMedian(this.modelGroup, 2)
+  }
+
+  private computeMedian(group: THREE.Object3D, axis = 2): number {
+    const vals: number[] = []
+    group.traverse((o) => {
+      const mesh = o as THREE.Mesh
+      if (!mesh.isMesh || !mesh.geometry) return
+      if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
+      const c = new THREE.Vector3()
+      mesh.geometry.boundingBox!.getCenter(c).applyMatrix4(mesh.matrixWorld)
+      vals.push(c.getComponent(axis))
+    })
+    if (!vals.length) return 0
+    vals.sort((a, b) => a - b)
+    return vals[Math.floor(vals.length / 2)]
+  }
+
+  activateView(index: number): void {
+    const view = VIEW_PRESETS[index]
+    if (!view || !this.modelDist) return
+    let target: THREE.Vector3
+    let cam: THREE.Vector3
+    if (view.axis) {
+      const dir = new THREE.Vector3(...view.axis).normalize()
+      const ai = view.axis.findIndex((x) => x !== 0)
+      const cross = Math.max(...[0, 1, 2].filter((i) => i !== ai).map((i) => this.modelSize.getComponent(i)))
+      const fovRad = THREE.MathUtils.degToRad(this.camera.fov)
+      const crossFit = (cross / (2 * Math.tan(fovRad / 2))) * 1.2
+      const halfAxis = this.modelSize.getComponent(ai) / 2
+      const dist = halfAxis + crossFit
+      target = this.modelCenter.clone()
+      cam = this.modelCenter.clone().addScaledVector(dir, dist)
+    } else {
+      const dz = this.modelMedianZ - REF_MEDIAN_Z
+      target = new THREE.Vector3(view.target![0], view.target![1], view.target![2] + dz)
+      cam = new THREE.Vector3(view.camera![0], view.camera![1], view.camera![2] + dz)
+    }
+    this.camera.position.copy(cam)
+    this.camera.lookAt(target)
+    this.controls.target.copy(target)
+    this.controls.update()
+    this.emitCamInfo()
+  }
+
+  moveForward(sign: number): void {
+    const dir = new THREE.Vector3().subVectors(this.controls.target, this.camera.position).normalize()
+    const step = (this.modelDist || 10) * 0.08
+    this.camera.position.addScaledVector(dir, sign * step)
+    this.controls.target.addScaledVector(dir, sign * step)
+    this.controls.update()
+  }
+
+  setFreeMode(on: boolean): void {
+    this.freeMode = on
+    if (on) {
+      if (!this.trackball) {
+        this.trackball = new TrackballControls(this.camera, this.renderer.domElement)
+        this.trackball.rotateSpeed = 3.0
+        this.trackball.zoomSpeed = 1.2
+        this.trackball.panSpeed = 0.8
+        this.trackball.staticMoving = false
+        this.trackball.dynamicDampingFactor = 0.15
+        this.trackball.enabled = false
+        this.trackball.addEventListener('change', this.emitCamInfo)
+      }
+      this.trackball.target.copy(this.controls.target)
+      this.trackball.enabled = true
+      this.orbit.enabled = false
+      this.controls = this.trackball
+    } else {
+      if (this.trackball) this.trackball.enabled = false
+      this.camera.up.set(0, 1, 0)
+      this.orbit.target.copy(this.controls.target)
+      this.orbit.enabled = true
+      this.controls = this.orbit
+    }
+    this.controls.update()
+    this.emitCamInfo()
+  }
+
+  get isFreeMode(): boolean {
+    return this.freeMode
+  }
+
+  /* ---------------- 拾取 / 悬停 ---------------- */
+  private onPointerMove = (e: PointerEvent): void => {
+    const now = performance.now()
+    if (now - this.lastPick < 60) return
+    this.lastPick = now
+    const id = this.pickId(e.clientX, e.clientY)
+    if (id !== this.hoveredId) {
+      this.hoveredId = id
+      const handle = id ? this.byId.get(id) : null
+      this.cb.onHover?.(id, handle ? handle.label : null)
+    }
+  }
+
+  private onPointerDown = (e: PointerEvent): void => {
+    this.downXY = [e.clientX, e.clientY]
+  }
+
+  private onPointerUp = (e: PointerEvent): void => {
+    if (!this.downXY) return
+    const dist = Math.hypot(e.clientX - this.downXY[0], e.clientY - this.downXY[1])
+    this.downXY = null
+    if (dist > 5) return
+    const id = this.pickId(e.clientX, e.clientY)
+    const additive = (e.ctrlKey || e.metaKey) && id !== null
+    this.cb.onPick?.(id, additive)
+  }
+
+  private pickId(clientX: number, clientY: number): string | null {
+    const rect = this.renderer.domElement.getBoundingClientRect()
+    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.largeMode) {
+      if (!this.instancedMeshes.length) return null
+      const hits = this.raycaster.intersectObjects(this.instancedMeshes, false)
+      if (!hits.length) return null
+      return this.idFromInstance(hits[0].object as THREE.InstancedMesh, hits[0].instanceId ?? 0)
+    }
+    const meshes = this.parts.filter((p) => p.obj).map((p) => p.obj!)
+    const hits = this.raycaster.intersectObjects(meshes, false)
+    if (!hits.length) return null
+    const obj = hits[0].object as THREE.Mesh
+    const found = this.parts.find((p) => p.obj === obj)
+    return found ? found.id : null
+  }
+
+  private idFromInstance(inst: THREE.InstancedMesh, instanceId: number): string | null {
+    const found = this.parts.find((p) => p.inst === inst && p.instanceId === instanceId)
+    return found ? found.id : null
+  }
+
+  /* ---------------- 选择 / 上色 ---------------- */
+  setSelectedIds(ids: string[]): void {
+    this.selectedIds = new Set(ids)
+    if (this.largeMode) {
+      this.outline.selectedObjects = []
+      const changed = new Set<string>()
+      this.selectedIds.forEach((id) => { if (!this.prevSelected.has(id)) changed.add(id) })
+      this.prevSelected.forEach((id) => { if (!this.selectedIds.has(id)) changed.add(id) })
+      changed.forEach((id) => this.applyDisplay(id))
+      this.prevSelected = new Set(this.selectedIds)
+    } else {
+      this.outline.selectedObjects = ids
+        .map((id) => this.byId.get(id)?.obj)
+        .filter((o): o is THREE.Mesh => Boolean(o))
+    }
+  }
+
+  setPartColor(id: string, hex: string, status: PartStatus | null): void {
+    const handle = this.byId.get(id)
+    if (!handle) return
+    handle.colorHex = hex
+    handle.status = status
+    handle.meta.colorHex = hex
+    handle.meta.status = status
+    this.applyDisplay(id)
+  }
+
+  resetPart(id: string): void {
+    const handle = this.byId.get(id)
+    if (!handle) return
+    handle.colorHex = null
+    handle.status = null
+    handle.meta.colorHex = null
+    handle.meta.status = null
+    this.applyDisplay(id)
+  }
+
+  private applyDisplay(id: string): void {
+    const handle = this.byId.get(id)
+    if (!handle) return
+    const base = 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)
+      handle.inst.setColorAt(handle.instanceId, c)
+      if (handle.inst.instanceColor) handle.inst.instanceColor.needsUpdate = true
+    } else if (handle.obj) {
+      eachMaterial(handle.obj, (m) => {
+        const anyM = m as THREE.MeshStandardMaterial
+        if (anyM.color) anyM.color.set(base)
+      })
+    }
+  }
+
+  applyDefaultColor(id: string): string {
+    const handle = this.byId.get(id)
+    return handle ? handle.defaults[0] : '#ffffff'
+  }
+
+  dispose(): void {
+    this.animating = false
+    window.removeEventListener('resize', this.updateAspect)
+    const el = this.renderer.domElement
+    el.removeEventListener('pointermove', this.onPointerMove)
+    el.removeEventListener('pointerdown', this.onPointerDown)
+    el.removeEventListener('pointerup', this.onPointerUp)
+    this.renderer.dispose()
+  }
+}

+ 11 - 0
3d/frontend/src/three/instance.ts

@@ -0,0 +1,11 @@
+import type { Viewer } from './Viewer'
+
+let current: Viewer | null = null
+
+export function setViewer(viewer: Viewer | null): void {
+  current = viewer
+}
+
+export function getViewer(): Viewer | null {
+  return current
+}

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

@@ -0,0 +1,40 @@
+export type PartStatus = 'ok' | 'warn' | 'fault'
+
+export interface PartMeta {
+  id: string
+  label: string
+  name: string
+  defaults: string[]
+  vtx: number
+  tri: number
+  size: string
+  status: PartStatus | null
+  colorHex: string | null
+}
+
+export interface CameraInfo {
+  az: number
+  el: number
+  dist: number
+  camera: [number, number, number]
+  target: [number, number, number]
+}
+
+export interface SavedPartState {
+  color: string
+  status: PartStatus | null
+}
+
+export type SavedState = Record<string, SavedPartState>
+
+export interface PresetDef {
+  key: PartStatus
+  label: string
+  color: string
+}
+
+export const PRESETS: PresetDef[] = [
+  { key: 'ok', label: '正常', color: '#43A047' },
+  { key: 'warn', label: '预警', color: '#F9A825' },
+  { key: 'fault', label: '故障', color: '#E53935' },
+]

+ 21 - 0
3d/frontend/tsconfig.json

@@ -0,0 +1,21 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "useDefineForClassFields": true,
+    "module": "ESNext",
+    "lib": ["ES2020", "DOM", "DOM.Iterable"],
+    "skipLibCheck": true,
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noEmit": true,
+    "jsx": "preserve",
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true,
+    "types": ["vite/client"]
+  },
+  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
+}

+ 18 - 0
3d/frontend/vite.config.ts

@@ -0,0 +1,18 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+export default defineConfig({
+  plugins: [vue()],
+  server: {
+    port: 5173,
+    allowedHosts: ['2frps.roadbyway.com'],
+    proxy: {
+      '/api': 'http://127.0.0.1:8000',
+      '/models': 'http://127.0.0.1:8000',
+    },
+  },
+  build: {
+    outDir: 'dist',
+    chunkSizeWarningLimit: 2000,
+  },
+})

+ 0 - 216
3d/server.py

@@ -1,216 +0,0 @@
-#!/usr/bin/env python3
-"""3D 部件查看与配色 - 本地演示服务器 (纯标准库实现, 无需安装依赖)
-
-用法:
-    python3 server.py              # 默认端口 9000, 浏览器打开 http://127.0.0.1:9000
-    python3 server.py --port 9100
-
-功能:
-    GET  /                    首页 (index.html)
-    GET  /js/... /css/...     前端静态资源
-    GET  /models/<file>       GLB/glTF 模型文件
-    GET  /api/models          返回 models 目录下可用的模型列表 (JSON)
-    GET  /api/state?model=xx  读取某个模型的配色状态 (JSON)
-    POST /api/state?model=xx  保存配色状态到 data/color_state.json
-"""
-
-import argparse
-import json
-import mimetypes
-import os
-import re
-import urllib.parse
-from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
-
-BASE = os.path.dirname(os.path.abspath(__file__))
-STATIC_DIR = os.path.join(BASE, "static")
-MODELS_DIR = os.path.join(BASE, "models")
-DATA_DIR = os.path.join(BASE, "data")
-DATA_FILE = os.path.join(DATA_DIR, "color_state.json")
-
-EXTRA_TYPES = {
-    ".glb": "model/gltf-binary",
-    ".gltf": "model/gltf+json",
-    ".js": "text/javascript; charset=utf-8",
-    ".json": "application/json; charset=utf-8",
-    ".css": "text/css; charset=utf-8",
-    ".html": "text/html; charset=utf-8",
-}
-
-# 允许的扩展名 -> 相对子目录
-ROUTES = {
-    "/models": ("models", {".glb", ".gltf"}),
-    "/js": ("static/js", {".js", ".mjs"}),
-    "/css": ("static/css", {".css"}),
-    "/images": ("static/images", {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}),
-}
-
-SAFE_NAME = re.compile(r"^[\w\-. ]+$")
-
-
-class Handler(SimpleHTTPRequestHandler):
-    protocol_version = "HTTP/1.1"
-
-    # ---- 工具 ----
-    def _guess_type(self, path):
-        ext = os.path.splitext(path)[1].lower()
-        if ext in EXTRA_TYPES:
-            return EXTRA_TYPES[ext]
-        return mimetypes.guess_type(path)[0] or "application/octet-stream"
-
-    def _send_bytes(self, code, body: bytes, ctype="application/octet-stream"):
-        self.send_response(code)
-        self.send_header("Content-Type", ctype)
-        self.send_header("Content-Length", str(len(body)))
-        self.send_header("Cache-Control", "no-store")
-        self.end_headers()
-        self.wfile.write(body)
-
-    def _send_json(self, obj, code=200):
-        self._send_bytes(code, json.dumps(obj, ensure_ascii=False).encode("utf-8"),
-                         "application/json; charset=utf-8")
-
-    def _send_file(self, full_path, ctype=None):
-        try:
-            with open(full_path, "rb") as f:
-                body = f.read()
-        except OSError:
-            self.send_error(404, "Not Found")
-            return
-        self._send_bytes(200, body, ctype or self._guess_type(full_path))
-
-    def _safe_join(self, root, rel):
-        """防路径穿越"""
-        full = os.path.abspath(os.path.join(root, rel))
-        if not full.startswith(os.path.abspath(root) + os.sep) and full != os.path.abspath(root):
-            return None
-        return full
-
-    def _resolve_static(self, path):
-        for prefix, (sub, exts) in ROUTES.items():
-            if path == prefix or path.startswith(prefix + "/"):
-                rel = path[len(prefix):].lstrip("/")
-                root = os.path.join(BASE, sub)
-                full = self._safe_join(root, rel) if rel else root
-                if full and os.path.isfile(full):
-                    ext = os.path.splitext(full)[1].lower()
-                    return full if ext in exts else None
-                if full and os.path.isdir(full):
-                    idx = os.path.join(full, "index.html")
-                    if os.path.isfile(idx):
-                        return idx
-                return None
-        return None
-
-    # ---- 数据读写 ----
-    def _load_state(self):
-        try:
-            with open(DATA_FILE, "r", encoding="utf-8") as f:
-                return json.load(f)
-        except (OSError, json.JSONDecodeError):
-            return {}
-
-    def _save_state(self, state):
-        os.makedirs(DATA_DIR, exist_ok=True)
-        tmp = DATA_FILE + ".tmp"
-        with open(tmp, "w", encoding="utf-8") as f:
-            json.dump(state, f, ensure_ascii=False, indent=2)
-        os.replace(tmp, DATA_FILE)
-
-    # ---- handlers ----
-    def do_GET(self):
-        parsed = urllib.parse.urlparse(self.path)
-        path = urllib.parse.unquote(parsed.path)
-        query = urllib.parse.parse_qs(parsed.query)
-
-        if path == "/api/models":
-            try:
-                names = [f for f in os.listdir(MODELS_DIR)
-                         if os.path.isfile(os.path.join(MODELS_DIR, f))
-                         and SAFE_NAME.match(f)
-                         and f.lower().endswith((".glb", ".gltf"))]
-            except OSError:
-                names = []
-            self._send_json(sorted(names))
-            return
-
-        if path == "/api/state":
-            model = (query.get("model") or [""])[0]
-            state = self._load_state()
-            self._send_json(state.get(model, {}))
-            return
-
-        if path == "/" or path == "":
-            self._send_file(os.path.join(STATIC_DIR, "index.html"))
-            return
-
-        static_file = self._resolve_static(path)
-        if static_file:
-            self._send_file(static_file)
-            return
-
-        if path.startswith("/models/"):
-            rel = path[len("/models/"):]
-            if SAFE_NAME.match(rel):
-                full = self._safe_join(MODELS_DIR, rel)
-                if full and os.path.isfile(full):
-                    self._send_file(full)
-                    return
-        self.send_error(404, "Not Found")
-
-    def do_POST(self):
-        parsed = urllib.parse.urlparse(self.path)
-        path = urllib.parse.unquote(parsed.path)
-        query = urllib.parse.parse_qs(parsed.query)
-
-        if path == "/api/state":
-            try:
-                length = int(self.headers.get("Content-Length") or 0)
-                body = self.rfile.read(length) if length else b"{}"
-                data = json.loads(body.decode("utf-8"))
-            except (ValueError, json.JSONDecodeError):
-                self.send_error(400, "Bad JSON")
-                return
-            model = (query.get("model") or [""])[0]
-            if not model:
-                self.send_error(400, "缺少 model 参数")
-                return
-            if not isinstance(data, dict):
-                self.send_error(400, "body 应为 JSON 对象")
-                return
-            state = self._load_state()
-            state[model] = data
-            self._save_state(state)
-            self._send_json({"ok": True, "model": model, "count": len(data)})
-            return
-
-        self.send_error(405, "Method Not Allowed")
-
-    # ---- 日志 ----
-    def log_message(self, fmt, *args):
-        print(f"[server] {self.address_string()} - {fmt % args}")
-
-
-def main():
-    ap = argparse.ArgumentParser(description="3D 部件查看与配色本地服务器")
-    ap.add_argument("--host", default="127.0.0.1")
-    ap.add_argument("--port", type=int, default=9000)
-    args = ap.parse_args()
-
-    httpd = ThreadingHTTPServer((args.host, args.port), Handler)
-    url = f"http://{args.host}:{args.port}"
-    print("=" * 56)
-    print("  3D 部件查看与配色 演示服务器已启动")
-    print(f"  请用浏览器打开: {url}")
-    print(f"  模型目录: {MODELS_DIR}")
-    print(f"  配色数据: {DATA_FILE}")
-    print("  按 Ctrl+C 停止")
-    print("=" * 56)
-    try:
-        httpd.serve_forever()
-    except KeyboardInterrupt:
-        print("\n已停止")
-
-
-if __name__ == "__main__":
-    main()

+ 0 - 289
3d/static/index.html

@@ -1,289 +0,0 @@
-<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-<meta charset="UTF-8">
-<meta name="viewport" content="width=device-width, initial-scale=1.0">
-<title>3D 部件查看与配色</title>
-<style>
-  :root {
-    --bg: #14161a;
-    --panel: #1d2128;
-    --panel-2: #242a33;
-    --border: #333a45;
-    --text: #d6dbe2;
-    --text-dim: #8b93a1;
-    --accent: #4da3ff;
-  }
-  * { box-sizing: border-box; margin: 0; padding: 0; }
-  html, body { height: 100%; }
-  body {
-    font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
-    background: var(--bg); color: var(--text);
-    overflow: hidden; font-size: 13px;
-  }
-  #app { display: flex; flex-direction: column; height: 100vh; }
-
-  /* ---------- top bar (固定 60px) ---------- */
-  header {
-    display: flex; align-items: center; gap: 14px;
-    height: 48px; padding: 0 16px; background: var(--panel);
-    border-bottom: 1px solid var(--border); flex: 0 0 auto;
-  }
-  header h1 { font-size: 15px; font-weight: 600; }
-  .spacer { flex: 1; }
-  .tool label { color: var(--text-dim); margin-right: 6px; }
-  select {
-    background: var(--panel-2); color: var(--text);
-    border: 1px solid var(--border); border-radius: 6px;
-    padding: 4px 8px; font-size: 13px;
-  }
-  #saveStatus { color: var(--text-dim); font-size: 12px; }
-
-  /* ---------- view angle buttons ---------- */
-  .view-group { display: flex; gap: 6px; align-items: center; }
-  .view-btn {
-    background: var(--panel-2); border: 1px solid var(--border); color: var(--text);
-    border-radius: 6px; padding: 4px 10px; font-size: 12px; cursor: pointer;
-  }
-  .view-btn:hover { border-color: var(--accent); color: #fff; }
-  .view-btn.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
-
-  /* ---------- 时序切换进度 ---------- */
-  #cycleProgress {
-    display: none; background: #2b5fa8; color: #fff; border-radius: 999px;
-    padding: 3px 12px; font-size: 12px; font-weight: 600; letter-spacing: .3px;
-    white-space: nowrap;
-  }
-
-  /* ---------- main area ---------- */
-  main { flex: 1; display: flex; min-height: 0; }
-  #viewport { flex: 1; position: relative; min-width: 0; background: #0f1115; }
-  #viewport canvas { display: block; }
-
-  /* ---------- 悬浮卡片(黑底 30% 透明) ---------- */
-  .glass {
-    background: rgba(0, 0, 0, .7);
-    border: 1px solid var(--border);
-    border-radius: 10px;
-    backdrop-filter: blur(6px);
-    -webkit-backdrop-filter: blur(6px);
-  }
-  #dataPanel {
-    position: absolute; left: 20px; top: 20px; bottom: 20px; width: 300px;
-    z-index: 8; display: flex; flex-direction: column; overflow: hidden;
-    padding: 12px 14px;
-  }
-  #dataPanel h3, .card h3 {
-    font-size: 12px; color: var(--text-dim); font-weight: 600; letter-spacing: .5px;
-  }
-  #dataPanel .placeholder {
-    margin-top: 10px; color: var(--text-dim); font-size: 12px; opacity: .8;
-  }
-  #extPanel {
-    position: absolute; right: 20px; top: 20px; bottom: 20px; width: 300px;
-    z-index: 8; display: flex; flex-direction: column; gap: 12px;
-    overflow-y: auto; pointer-events: none;
-  }
-  .card {
-    pointer-events: auto;
-    padding: 12px 14px; flex: 0 0 auto;
-  }
-  .card-head {
-    display: flex; align-items: center; justify-content: space-between;
-    margin-bottom: 10px;
-  }
-  .text-btn {
-    background: none; border: none; color: var(--accent);
-    font-size: 12px; cursor: pointer; padding: 2px 6px; border-radius: 6px;
-  }
-  .text-btn:hover { background: rgba(77,163,255,.15); }
-
-  /* ---------- 部件状态控制 ---------- */
-  .preset-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
-  .preset-btn {
-    display: inline-flex; align-items: center; justify-content: center; gap: 6px;
-    background: var(--panel-2); border: 1px solid var(--border);
-    color: var(--text); border-radius: 6px; padding: 6px 4px;
-    font-size: 12px; cursor: pointer; white-space: nowrap;
-  }
-  .preset-btn:hover { border-color: var(--accent); }
-  .preset-btn .dot { width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto; }
-  .part-detail { margin-top: 10px; font-size: 12px; line-height: 1.7; color: var(--text); }
-  .part-detail .dline { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-  .part-detail .dim { color: var(--text-dim); white-space: normal; }
-  .card-foot { margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border); }
-
-  /* ---------- 场景位置 ---------- */
-  #camInfo {
-    color: var(--text-dim); font-size: 12px; line-height: 1.6;
-    white-space: pre; font-variant-numeric: tabular-nums;
-  }
-
-  /* ---------- 视口浮层 ---------- */
-  #hint {
-    position: absolute; left: 50%; transform: translateX(-50%); bottom: 12px;
-    color: var(--text-dim); font-size: 12px;
-    background: rgba(0,0,0,.45); padding: 5px 10px; border-radius: 6px; pointer-events: none;
-    z-index: 4;
-  }
-  #tooltip {
-    position: absolute; display: none; pointer-events: none;
-    background: rgba(0,0,0,.75); color: #fff; padding: 4px 8px;
-    border-radius: 5px; font-size: 12px; white-space: nowrap; z-index: 20;
-  }
-  #loading {
-    position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
-    color: var(--text-dim); font-size: 14px; z-index: 5;
-  }
-
-  /* ---------- 部件查询弹窗(白底黑字) ---------- */
-  #partModal {
-    position: fixed; inset: 0; z-index: 50; display: none;
-    background: rgba(0,0,0,.55); align-items: center; justify-content: center;
-  }
-  #partModal.show { display: flex; }
-  .modal {
-    width: min(880px, 92vw); max-height: 84vh; background: #fff; color: #1a1a1a;
-    border-radius: 12px; display: flex; flex-direction: column; overflow: hidden;
-    box-shadow: 0 24px 70px rgba(0,0,0,.55);
-  }
-  .modal-head {
-    display: flex; align-items: center; justify-content: space-between;
-    padding: 12px 16px; border-bottom: 1px solid #e7e9ec;
-  }
-  .modal-head h3 { font-size: 14px; color: #111; font-weight: 600; }
-  .modal-close {
-    background: none; border: none; font-size: 22px; line-height: 1;
-    cursor: pointer; color: #888; padding: 0 4px;
-  }
-  .modal-close:hover { color: #222; }
-  .modal .search { margin: 12px 16px 8px; position: relative; }
-  .modal .search svg { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); }
-  .modal .search input {
-    width: 100%; background: #f3f4f6; border: 1px solid #d9dce1; color: #111;
-    border-radius: 8px; padding: 7px 10px 7px 30px; font-size: 13px;
-  }
-  .modal .search input:focus { outline: none; border-color: var(--accent); background: #fff; }
-
-  .part-table { flex: 1; display: flex; flex-direction: column; min-height: 0; padding: 0 8px 8px; }
-  .part-row {
-    display: grid;
-    grid-template-columns: 1fr 74px 74px 156px 104px 64px;
-    gap: 8px; align-items: center; padding: 6px 8px;
-    border-radius: 6px; cursor: pointer; border-bottom: 1px solid #f1f2f4;
-    color: #222; font-size: 12px;
-  }
-  .part-row.head {
-    color: #6b7280; font-weight: 600; cursor: default; background: #fafbfc;
-    position: sticky; top: 0; z-index: 1;
-  }
-  .part-row:not(.head):hover { background: #f2f7ff; }
-  .part-row.selected { background: #e3efff; }
-  .part-row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-  .part-row .vtx, .part-row .tri, .part-row .size {
-    font-variant-numeric: tabular-nums; color: #555;
-  }
-  .part-row .color { display: flex; align-items: center; gap: 5px; color: #555; }
-  .part-row .swatch {
-    width: 13px; height: 13px; border-radius: 4px; flex: 0 0 auto;
-    border: 1px solid rgba(0,0,0,.25); display: inline-block;
-  }
-  .part-row .tag {
-    font-size: 10px; border-radius: 4px; padding: 1px 7px; color: #fff; white-space: nowrap;
-  }
-  .part-row .status { color: #888; }
-  #partList { flex: 1; overflow-y: auto; }
-  #partList::-webkit-scrollbar { width: 8px; }
-  #partList::-webkit-scrollbar-thumb { background: #cfd4db; border-radius: 4px; }
-  #partListNote { display: none; padding: 12px; text-align: center; color: #8b93a1; font-size: 12px; }
-</style>
-</head>
-<body>
-<div id="app">
-  <header>
-    <h1>3D 部件查看与配色</h1>
-    <div class="tool">
-      <label>模型</label>
-      <select id="modelSelect"></select>
-    </div>
-    <span id="partCount" class="tool" style="color:var(--text-dim)"></span>
-    <div class="spacer"></div>
-    <span id="cycleProgress"></span>
-    <div class="view-group" id="viewBtns"></div>
-    <button class="view-btn" id="rotModeBtn" title="切换 轨道旋转 / 自由旋转">自由旋转</button>
-    <span id="saveStatus"></span>
-  </header>
-
-  <main>
-    <div id="viewport">
-      <div id="loading">模型加载中…</div>
-      <div id="tooltip"></div>
-      <div id="hint">左键拖动=旋转 · 滚轮=缩放 · 右键拖动=平移 · 点击部件=选中</div>
-
-      <!-- 左:数据区域(占位,固定撑满视口高度) -->
-      <div id="dataPanel" class="glass">
-        <h3>数据区域</h3>
-        <div class="placeholder">内容待定…</div>
-      </div>
-
-      <!-- 右:扩展区域(高度撑满,内容随卡片) -->
-      <div id="extPanel">
-        <section class="card glass">
-          <div class="card-head">
-            <h3>部件状态控制</h3>
-            <button class="text-btn" id="resetAll" title="全部恢复默认颜色">全部恢复</button>
-          </div>
-          <div class="preset-row" id="presetRow"></div>
-          <div class="part-detail" id="partDetail"></div>
-          <div class="card-foot">
-            <button class="text-btn" id="openParts">打开查询部件…</button>
-          </div>
-        </section>
-
-        <section class="card glass">
-          <div class="card-head"><h3>场景位置</h3></div>
-          <div id="camInfo"></div>
-        </section>
-      </div>
-    </div>
-  </main>
-</div>
-
-<!-- 部件查询弹窗 -->
-<div id="partModal">
-  <div class="modal">
-    <div class="modal-head">
-      <h3>查询部件</h3>
-      <button class="modal-close" id="closeParts" title="关闭">×</button>
-    </div>
-    <div class="search">
-      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#8b93a1" stroke-width="2">
-        <circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.5" y2="16.5"/>
-      </svg>
-      <input type="text" id="search" placeholder="搜索部件名称…">
-    </div>
-    <div class="part-table">
-      <div class="part-row head">
-        <span class="name">名称</span>
-        <span class="vtx">顶点数</span>
-        <span class="tri">三角面</span>
-        <span class="size">尺寸 (x×y×z)</span>
-        <span class="color">颜色</span>
-        <span class="status">状态</span>
-      </div>
-      <div id="partList"></div>
-    </div>
-    <div id="partListNote"></div>
-  </div>
-</div>
-
-<script type="importmap">
-{
-  "imports": {
-    "three": "/js/three.module.js"
-  }
-}
-</script>
-<script type="module" src="/js/main.js"></script>
-</body>
-</html>

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 1417
3d/static/js/jsm/controls/OrbitControls.js


+ 0 - 828
3d/static/js/jsm/controls/TrackballControls.js

@@ -1,828 +0,0 @@
-import {
-	EventDispatcher,
-	MathUtils,
-	MOUSE,
-	Quaternion,
-	Vector2,
-	Vector3
-} from 'three';
-
-const _changeEvent = { type: 'change' };
-const _startEvent = { type: 'start' };
-const _endEvent = { type: 'end' };
-
-class TrackballControls extends EventDispatcher {
-
-	constructor( object, domElement ) {
-
-		super();
-
-		const scope = this;
-		const STATE = { NONE: - 1, ROTATE: 0, ZOOM: 1, PAN: 2, TOUCH_ROTATE: 3, TOUCH_ZOOM_PAN: 4 };
-
-		this.object = object;
-		this.domElement = domElement;
-		this.domElement.style.touchAction = 'none'; // disable touch scroll
-
-		// API
-
-		this.enabled = true;
-
-		this.screen = { left: 0, top: 0, width: 0, height: 0 };
-
-		this.rotateSpeed = 1.0;
-		this.zoomSpeed = 1.2;
-		this.panSpeed = 0.3;
-
-		this.noRotate = false;
-		this.noZoom = false;
-		this.noPan = false;
-
-		this.staticMoving = false;
-		this.dynamicDampingFactor = 0.2;
-
-		this.minDistance = 0;
-		this.maxDistance = Infinity;
-
-		this.minZoom = 0;
-		this.maxZoom = Infinity;
-
-		this.keys = [ 'KeyA' /*A*/, 'KeyS' /*S*/, 'KeyD' /*D*/ ];
-
-		this.mouseButtons = { LEFT: MOUSE.ROTATE, MIDDLE: MOUSE.DOLLY, RIGHT: MOUSE.PAN };
-
-		// internals
-
-		this.target = new Vector3();
-
-		const EPS = 0.000001;
-
-		const lastPosition = new Vector3();
-		let lastZoom = 1;
-
-		let _state = STATE.NONE,
-			_keyState = STATE.NONE,
-
-			_touchZoomDistanceStart = 0,
-			_touchZoomDistanceEnd = 0,
-
-			_lastAngle = 0;
-
-		const _eye = new Vector3(),
-
-			_movePrev = new Vector2(),
-			_moveCurr = new Vector2(),
-
-			_lastAxis = new Vector3(),
-
-			_zoomStart = new Vector2(),
-			_zoomEnd = new Vector2(),
-
-			_panStart = new Vector2(),
-			_panEnd = new Vector2(),
-
-			_pointers = [],
-			_pointerPositions = {};
-
-		// for reset
-
-		this.target0 = this.target.clone();
-		this.position0 = this.object.position.clone();
-		this.up0 = this.object.up.clone();
-		this.zoom0 = this.object.zoom;
-
-		// methods
-
-		this.handleResize = function () {
-
-			const box = scope.domElement.getBoundingClientRect();
-			// adjustments come from similar code in the jquery offset() function
-			const d = scope.domElement.ownerDocument.documentElement;
-			scope.screen.left = box.left + window.pageXOffset - d.clientLeft;
-			scope.screen.top = box.top + window.pageYOffset - d.clientTop;
-			scope.screen.width = box.width;
-			scope.screen.height = box.height;
-
-		};
-
-		const getMouseOnScreen = ( function () {
-
-			const vector = new Vector2();
-
-			return function getMouseOnScreen( pageX, pageY ) {
-
-				vector.set(
-					( pageX - scope.screen.left ) / scope.screen.width,
-					( pageY - scope.screen.top ) / scope.screen.height
-				);
-
-				return vector;
-
-			};
-
-		}() );
-
-		const getMouseOnCircle = ( function () {
-
-			const vector = new Vector2();
-
-			return function getMouseOnCircle( pageX, pageY ) {
-
-				vector.set(
-					( ( pageX - scope.screen.width * 0.5 - scope.screen.left ) / ( scope.screen.width * 0.5 ) ),
-					( ( scope.screen.height + 2 * ( scope.screen.top - pageY ) ) / scope.screen.width ) // screen.width intentional
-				);
-
-				return vector;
-
-			};
-
-		}() );
-
-		this.rotateCamera = ( function () {
-
-			const axis = new Vector3(),
-				quaternion = new Quaternion(),
-				eyeDirection = new Vector3(),
-				objectUpDirection = new Vector3(),
-				objectSidewaysDirection = new Vector3(),
-				moveDirection = new Vector3();
-
-			return function rotateCamera() {
-
-				moveDirection.set( _moveCurr.x - _movePrev.x, _moveCurr.y - _movePrev.y, 0 );
-				let angle = moveDirection.length();
-
-				if ( angle ) {
-
-					_eye.copy( scope.object.position ).sub( scope.target );
-
-					eyeDirection.copy( _eye ).normalize();
-					objectUpDirection.copy( scope.object.up ).normalize();
-					objectSidewaysDirection.crossVectors( objectUpDirection, eyeDirection ).normalize();
-
-					objectUpDirection.setLength( _moveCurr.y - _movePrev.y );
-					objectSidewaysDirection.setLength( _moveCurr.x - _movePrev.x );
-
-					moveDirection.copy( objectUpDirection.add( objectSidewaysDirection ) );
-
-					axis.crossVectors( moveDirection, _eye ).normalize();
-
-					angle *= scope.rotateSpeed;
-					quaternion.setFromAxisAngle( axis, angle );
-
-					_eye.applyQuaternion( quaternion );
-					scope.object.up.applyQuaternion( quaternion );
-
-					_lastAxis.copy( axis );
-					_lastAngle = angle;
-
-				} else if ( ! scope.staticMoving && _lastAngle ) {
-
-					_lastAngle *= Math.sqrt( 1.0 - scope.dynamicDampingFactor );
-					_eye.copy( scope.object.position ).sub( scope.target );
-					quaternion.setFromAxisAngle( _lastAxis, _lastAngle );
-					_eye.applyQuaternion( quaternion );
-					scope.object.up.applyQuaternion( quaternion );
-
-				}
-
-				_movePrev.copy( _moveCurr );
-
-			};
-
-		}() );
-
-
-		this.zoomCamera = function () {
-
-			let factor;
-
-			if ( _state === STATE.TOUCH_ZOOM_PAN ) {
-
-				factor = _touchZoomDistanceStart / _touchZoomDistanceEnd;
-				_touchZoomDistanceStart = _touchZoomDistanceEnd;
-
-				if ( scope.object.isPerspectiveCamera ) {
-
-					_eye.multiplyScalar( factor );
-
-				} else if ( scope.object.isOrthographicCamera ) {
-
-					scope.object.zoom = MathUtils.clamp( scope.object.zoom / factor, scope.minZoom, scope.maxZoom );
-
-					if ( lastZoom !== scope.object.zoom ) {
-
-						scope.object.updateProjectionMatrix();
-
-					}
-
-				} else {
-
-					console.warn( 'THREE.TrackballControls: Unsupported camera type' );
-
-				}
-
-			} else {
-
-				factor = 1.0 + ( _zoomEnd.y - _zoomStart.y ) * scope.zoomSpeed;
-
-				if ( factor !== 1.0 && factor > 0.0 ) {
-
-					if ( scope.object.isPerspectiveCamera ) {
-
-						_eye.multiplyScalar( factor );
-
-					} else if ( scope.object.isOrthographicCamera ) {
-
-						scope.object.zoom = MathUtils.clamp( scope.object.zoom / factor, scope.minZoom, scope.maxZoom );
-
-						if ( lastZoom !== scope.object.zoom ) {
-
-							scope.object.updateProjectionMatrix();
-
-						}
-
-					} else {
-
-						console.warn( 'THREE.TrackballControls: Unsupported camera type' );
-
-					}
-
-				}
-
-				if ( scope.staticMoving ) {
-
-					_zoomStart.copy( _zoomEnd );
-
-				} else {
-
-					_zoomStart.y += ( _zoomEnd.y - _zoomStart.y ) * this.dynamicDampingFactor;
-
-				}
-
-			}
-
-		};
-
-		this.panCamera = ( function () {
-
-			const mouseChange = new Vector2(),
-				objectUp = new Vector3(),
-				pan = new Vector3();
-
-			return function panCamera() {
-
-				mouseChange.copy( _panEnd ).sub( _panStart );
-
-				if ( mouseChange.lengthSq() ) {
-
-					if ( scope.object.isOrthographicCamera ) {
-
-						const scale_x = ( scope.object.right - scope.object.left ) / scope.object.zoom / scope.domElement.clientWidth;
-						const scale_y = ( scope.object.top - scope.object.bottom ) / scope.object.zoom / scope.domElement.clientWidth;
-
-						mouseChange.x *= scale_x;
-						mouseChange.y *= scale_y;
-
-					}
-
-					mouseChange.multiplyScalar( _eye.length() * scope.panSpeed );
-
-					pan.copy( _eye ).cross( scope.object.up ).setLength( mouseChange.x );
-					pan.add( objectUp.copy( scope.object.up ).setLength( mouseChange.y ) );
-
-					scope.object.position.add( pan );
-					scope.target.add( pan );
-
-					if ( scope.staticMoving ) {
-
-						_panStart.copy( _panEnd );
-
-					} else {
-
-						_panStart.add( mouseChange.subVectors( _panEnd, _panStart ).multiplyScalar( scope.dynamicDampingFactor ) );
-
-					}
-
-				}
-
-			};
-
-		}() );
-
-		this.checkDistances = function () {
-
-			if ( ! scope.noZoom || ! scope.noPan ) {
-
-				if ( _eye.lengthSq() > scope.maxDistance * scope.maxDistance ) {
-
-					scope.object.position.addVectors( scope.target, _eye.setLength( scope.maxDistance ) );
-					_zoomStart.copy( _zoomEnd );
-
-				}
-
-				if ( _eye.lengthSq() < scope.minDistance * scope.minDistance ) {
-
-					scope.object.position.addVectors( scope.target, _eye.setLength( scope.minDistance ) );
-					_zoomStart.copy( _zoomEnd );
-
-				}
-
-			}
-
-		};
-
-		this.update = function () {
-
-			_eye.subVectors( scope.object.position, scope.target );
-
-			if ( ! scope.noRotate ) {
-
-				scope.rotateCamera();
-
-			}
-
-			if ( ! scope.noZoom ) {
-
-				scope.zoomCamera();
-
-			}
-
-			if ( ! scope.noPan ) {
-
-				scope.panCamera();
-
-			}
-
-			scope.object.position.addVectors( scope.target, _eye );
-
-			if ( scope.object.isPerspectiveCamera ) {
-
-				scope.checkDistances();
-
-				scope.object.lookAt( scope.target );
-
-				if ( lastPosition.distanceToSquared( scope.object.position ) > EPS ) {
-
-					scope.dispatchEvent( _changeEvent );
-
-					lastPosition.copy( scope.object.position );
-
-				}
-
-			} else if ( scope.object.isOrthographicCamera ) {
-
-				scope.object.lookAt( scope.target );
-
-				if ( lastPosition.distanceToSquared( scope.object.position ) > EPS || lastZoom !== scope.object.zoom ) {
-
-					scope.dispatchEvent( _changeEvent );
-
-					lastPosition.copy( scope.object.position );
-					lastZoom = scope.object.zoom;
-
-				}
-
-			} else {
-
-				console.warn( 'THREE.TrackballControls: Unsupported camera type' );
-
-			}
-
-		};
-
-		this.reset = function () {
-
-			_state = STATE.NONE;
-			_keyState = STATE.NONE;
-
-			scope.target.copy( scope.target0 );
-			scope.object.position.copy( scope.position0 );
-			scope.object.up.copy( scope.up0 );
-			scope.object.zoom = scope.zoom0;
-
-			scope.object.updateProjectionMatrix();
-
-			_eye.subVectors( scope.object.position, scope.target );
-
-			scope.object.lookAt( scope.target );
-
-			scope.dispatchEvent( _changeEvent );
-
-			lastPosition.copy( scope.object.position );
-			lastZoom = scope.object.zoom;
-
-		};
-
-		// listeners
-
-		function onPointerDown( event ) {
-
-			if ( scope.enabled === false ) return;
-
-			if ( _pointers.length === 0 ) {
-
-				scope.domElement.setPointerCapture( event.pointerId );
-
-				scope.domElement.addEventListener( 'pointermove', onPointerMove );
-				scope.domElement.addEventListener( 'pointerup', onPointerUp );
-
-			}
-
-			//
-
-			addPointer( event );
-
-			if ( event.pointerType === 'touch' ) {
-
-				onTouchStart( event );
-
-			} else {
-
-				onMouseDown( event );
-
-			}
-
-		}
-
-		function onPointerMove( event ) {
-
-			if ( scope.enabled === false ) return;
-
-			if ( event.pointerType === 'touch' ) {
-
-				onTouchMove( event );
-
-			} else {
-
-				onMouseMove( event );
-
-			}
-
-		}
-
-		function onPointerUp( event ) {
-
-			if ( scope.enabled === false ) return;
-
-			if ( event.pointerType === 'touch' ) {
-
-				onTouchEnd( event );
-
-			} else {
-
-				onMouseUp();
-
-			}
-
-			//
-
-			removePointer( event );
-
-			if ( _pointers.length === 0 ) {
-
-				scope.domElement.releasePointerCapture( event.pointerId );
-
-				scope.domElement.removeEventListener( 'pointermove', onPointerMove );
-				scope.domElement.removeEventListener( 'pointerup', onPointerUp );
-
-			}
-
-
-		}
-
-		function onPointerCancel( event ) {
-
-			removePointer( event );
-
-		}
-
-		function keydown( event ) {
-
-			if ( scope.enabled === false ) return;
-
-			window.removeEventListener( 'keydown', keydown );
-
-			if ( _keyState !== STATE.NONE ) {
-
-				return;
-
-			} else if ( event.code === scope.keys[ STATE.ROTATE ] && ! scope.noRotate ) {
-
-				_keyState = STATE.ROTATE;
-
-			} else if ( event.code === scope.keys[ STATE.ZOOM ] && ! scope.noZoom ) {
-
-				_keyState = STATE.ZOOM;
-
-			} else if ( event.code === scope.keys[ STATE.PAN ] && ! scope.noPan ) {
-
-				_keyState = STATE.PAN;
-
-			}
-
-		}
-
-		function keyup() {
-
-			if ( scope.enabled === false ) return;
-
-			_keyState = STATE.NONE;
-
-			window.addEventListener( 'keydown', keydown );
-
-		}
-
-		function onMouseDown( event ) {
-
-			if ( _state === STATE.NONE ) {
-
-				switch ( event.button ) {
-
-					case scope.mouseButtons.LEFT:
-						_state = STATE.ROTATE;
-						break;
-
-					case scope.mouseButtons.MIDDLE:
-						_state = STATE.ZOOM;
-						break;
-
-					case scope.mouseButtons.RIGHT:
-						_state = STATE.PAN;
-						break;
-
-				}
-
-			}
-
-			const state = ( _keyState !== STATE.NONE ) ? _keyState : _state;
-
-			if ( state === STATE.ROTATE && ! scope.noRotate ) {
-
-				_moveCurr.copy( getMouseOnCircle( event.pageX, event.pageY ) );
-				_movePrev.copy( _moveCurr );
-
-			} else if ( state === STATE.ZOOM && ! scope.noZoom ) {
-
-				_zoomStart.copy( getMouseOnScreen( event.pageX, event.pageY ) );
-				_zoomEnd.copy( _zoomStart );
-
-			} else if ( state === STATE.PAN && ! scope.noPan ) {
-
-				_panStart.copy( getMouseOnScreen( event.pageX, event.pageY ) );
-				_panEnd.copy( _panStart );
-
-			}
-
-			scope.dispatchEvent( _startEvent );
-
-		}
-
-		function onMouseMove( event ) {
-
-			const state = ( _keyState !== STATE.NONE ) ? _keyState : _state;
-
-			if ( state === STATE.ROTATE && ! scope.noRotate ) {
-
-				_movePrev.copy( _moveCurr );
-				_moveCurr.copy( getMouseOnCircle( event.pageX, event.pageY ) );
-
-			} else if ( state === STATE.ZOOM && ! scope.noZoom ) {
-
-				_zoomEnd.copy( getMouseOnScreen( event.pageX, event.pageY ) );
-
-			} else if ( state === STATE.PAN && ! scope.noPan ) {
-
-				_panEnd.copy( getMouseOnScreen( event.pageX, event.pageY ) );
-
-			}
-
-		}
-
-		function onMouseUp() {
-
-			_state = STATE.NONE;
-
-			scope.dispatchEvent( _endEvent );
-
-		}
-
-		function onMouseWheel( event ) {
-
-			if ( scope.enabled === false ) return;
-
-			if ( scope.noZoom === true ) return;
-
-			event.preventDefault();
-
-			switch ( event.deltaMode ) {
-
-				case 2:
-					// Zoom in pages
-					_zoomStart.y -= event.deltaY * 0.025;
-					break;
-
-				case 1:
-					// Zoom in lines
-					_zoomStart.y -= event.deltaY * 0.01;
-					break;
-
-				default:
-					// undefined, 0, assume pixels
-					_zoomStart.y -= event.deltaY * 0.00025;
-					break;
-
-			}
-
-			scope.dispatchEvent( _startEvent );
-			scope.dispatchEvent( _endEvent );
-
-		}
-
-		function onTouchStart( event ) {
-
-			trackPointer( event );
-
-			switch ( _pointers.length ) {
-
-				case 1:
-					_state = STATE.TOUCH_ROTATE;
-					_moveCurr.copy( getMouseOnCircle( _pointers[ 0 ].pageX, _pointers[ 0 ].pageY ) );
-					_movePrev.copy( _moveCurr );
-					break;
-
-				default: // 2 or more
-					_state = STATE.TOUCH_ZOOM_PAN;
-					const dx = _pointers[ 0 ].pageX - _pointers[ 1 ].pageX;
-					const dy = _pointers[ 0 ].pageY - _pointers[ 1 ].pageY;
-					_touchZoomDistanceEnd = _touchZoomDistanceStart = Math.sqrt( dx * dx + dy * dy );
-
-					const x = ( _pointers[ 0 ].pageX + _pointers[ 1 ].pageX ) / 2;
-					const y = ( _pointers[ 0 ].pageY + _pointers[ 1 ].pageY ) / 2;
-					_panStart.copy( getMouseOnScreen( x, y ) );
-					_panEnd.copy( _panStart );
-					break;
-
-			}
-
-			scope.dispatchEvent( _startEvent );
-
-		}
-
-		function onTouchMove( event ) {
-
-			trackPointer( event );
-
-			switch ( _pointers.length ) {
-
-				case 1:
-					_movePrev.copy( _moveCurr );
-					_moveCurr.copy( getMouseOnCircle( event.pageX, event.pageY ) );
-					break;
-
-				default: // 2 or more
-
-					const position = getSecondPointerPosition( event );
-
-					const dx = event.pageX - position.x;
-					const dy = event.pageY - position.y;
-					_touchZoomDistanceEnd = Math.sqrt( dx * dx + dy * dy );
-
-					const x = ( event.pageX + position.x ) / 2;
-					const y = ( event.pageY + position.y ) / 2;
-					_panEnd.copy( getMouseOnScreen( x, y ) );
-					break;
-
-			}
-
-		}
-
-		function onTouchEnd( event ) {
-
-			switch ( _pointers.length ) {
-
-				case 0:
-					_state = STATE.NONE;
-					break;
-
-				case 1:
-					_state = STATE.TOUCH_ROTATE;
-					_moveCurr.copy( getMouseOnCircle( event.pageX, event.pageY ) );
-					_movePrev.copy( _moveCurr );
-					break;
-
-				case 2:
-					_state = STATE.TOUCH_ZOOM_PAN;
-
-					for ( let i = 0; i < _pointers.length; i ++ ) {
-
-						if ( _pointers[ i ].pointerId !== event.pointerId ) {
-
-							const position = _pointerPositions[ _pointers[ i ].pointerId ];
-							_moveCurr.copy( getMouseOnCircle( position.x, position.y ) );
-							_movePrev.copy( _moveCurr );
-							break;
-
-						}
-
-					}
-
-					break;
-
-			}
-
-			scope.dispatchEvent( _endEvent );
-
-		}
-
-		function contextmenu( event ) {
-
-			if ( scope.enabled === false ) return;
-
-			event.preventDefault();
-
-		}
-
-		function addPointer( event ) {
-
-			_pointers.push( event );
-
-		}
-
-		function removePointer( event ) {
-
-			delete _pointerPositions[ event.pointerId ];
-
-			for ( let i = 0; i < _pointers.length; i ++ ) {
-
-				if ( _pointers[ i ].pointerId == event.pointerId ) {
-
-					_pointers.splice( i, 1 );
-					return;
-
-				}
-
-			}
-
-		}
-
-		function trackPointer( event ) {
-
-			let position = _pointerPositions[ event.pointerId ];
-
-			if ( position === undefined ) {
-
-				position = new Vector2();
-				_pointerPositions[ event.pointerId ] = position;
-
-			}
-
-			position.set( event.pageX, event.pageY );
-
-		}
-
-		function getSecondPointerPosition( event ) {
-
-			const pointer = ( event.pointerId === _pointers[ 0 ].pointerId ) ? _pointers[ 1 ] : _pointers[ 0 ];
-
-			return _pointerPositions[ pointer.pointerId ];
-
-		}
-
-		this.dispose = function () {
-
-			scope.domElement.removeEventListener( 'contextmenu', contextmenu );
-
-			scope.domElement.removeEventListener( 'pointerdown', onPointerDown );
-			scope.domElement.removeEventListener( 'pointercancel', onPointerCancel );
-			scope.domElement.removeEventListener( 'wheel', onMouseWheel );
-
-			scope.domElement.removeEventListener( 'pointermove', onPointerMove );
-			scope.domElement.removeEventListener( 'pointerup', onPointerUp );
-
-			window.removeEventListener( 'keydown', keydown );
-			window.removeEventListener( 'keyup', keyup );
-
-		};
-
-		this.domElement.addEventListener( 'contextmenu', contextmenu );
-
-		this.domElement.addEventListener( 'pointerdown', onPointerDown );
-		this.domElement.addEventListener( 'pointercancel', onPointerCancel );
-		this.domElement.addEventListener( 'wheel', onMouseWheel, { passive: false } );
-
-
-		window.addEventListener( 'keydown', keydown );
-		window.addEventListener( 'keyup', keyup );
-
-		this.handleResize();
-
-		// force an update at start
-		this.update();
-
-	}
-
-}
-
-export { TrackballControls };

+ 0 - 148
3d/static/js/jsm/environments/RoomEnvironment.js

@@ -1,148 +0,0 @@
-/**
- * https://github.com/google/model-viewer/blob/master/packages/model-viewer/src/three-components/EnvironmentScene.ts
- */
-
-import {
- 	BackSide,
- 	BoxGeometry,
- 	Mesh,
-	MeshBasicMaterial,
- 	MeshStandardMaterial,
- 	PointLight,
- 	Scene,
-} from 'three';
-
-class RoomEnvironment extends Scene {
-
-	constructor( renderer = null ) {
-
-		super();
-
-		const geometry = new BoxGeometry();
-		geometry.deleteAttribute( 'uv' );
-
-		const roomMaterial = new MeshStandardMaterial( { side: BackSide } );
-		const boxMaterial = new MeshStandardMaterial();
-
-		let intensity = 5;
-
-		if ( renderer !== null && renderer._useLegacyLights === false ) intensity = 900;
-
-		const mainLight = new PointLight( 0xffffff, intensity, 28, 2 );
-		mainLight.position.set( 0.418, 16.199, 0.300 );
-		this.add( mainLight );
-
-		const room = new Mesh( geometry, roomMaterial );
-		room.position.set( - 0.757, 13.219, 0.717 );
-		room.scale.set( 31.713, 28.305, 28.591 );
-		this.add( room );
-
-		const box1 = new Mesh( geometry, boxMaterial );
-		box1.position.set( - 10.906, 2.009, 1.846 );
-		box1.rotation.set( 0, - 0.195, 0 );
-		box1.scale.set( 2.328, 7.905, 4.651 );
-		this.add( box1 );
-
-		const box2 = new Mesh( geometry, boxMaterial );
-		box2.position.set( - 5.607, - 0.754, - 0.758 );
-		box2.rotation.set( 0, 0.994, 0 );
-		box2.scale.set( 1.970, 1.534, 3.955 );
-		this.add( box2 );
-
-		const box3 = new Mesh( geometry, boxMaterial );
-		box3.position.set( 6.167, 0.857, 7.803 );
-		box3.rotation.set( 0, 0.561, 0 );
-		box3.scale.set( 3.927, 6.285, 3.687 );
-		this.add( box3 );
-
-		const box4 = new Mesh( geometry, boxMaterial );
-		box4.position.set( - 2.017, 0.018, 6.124 );
-		box4.rotation.set( 0, 0.333, 0 );
-		box4.scale.set( 2.002, 4.566, 2.064 );
-		this.add( box4 );
-
-		const box5 = new Mesh( geometry, boxMaterial );
-		box5.position.set( 2.291, - 0.756, - 2.621 );
-		box5.rotation.set( 0, - 0.286, 0 );
-		box5.scale.set( 1.546, 1.552, 1.496 );
-		this.add( box5 );
-
-		const box6 = new Mesh( geometry, boxMaterial );
-		box6.position.set( - 2.193, - 0.369, - 5.547 );
-		box6.rotation.set( 0, 0.516, 0 );
-		box6.scale.set( 3.875, 3.487, 2.986 );
-		this.add( box6 );
-
-
-		// -x right
-		const light1 = new Mesh( geometry, createAreaLightMaterial( 50 ) );
-		light1.position.set( - 16.116, 14.37, 8.208 );
-		light1.scale.set( 0.1, 2.428, 2.739 );
-		this.add( light1 );
-
-		// -x left
-		const light2 = new Mesh( geometry, createAreaLightMaterial( 50 ) );
-		light2.position.set( - 16.109, 18.021, - 8.207 );
-		light2.scale.set( 0.1, 2.425, 2.751 );
-		this.add( light2 );
-
-		// +x
-		const light3 = new Mesh( geometry, createAreaLightMaterial( 17 ) );
-		light3.position.set( 14.904, 12.198, - 1.832 );
-		light3.scale.set( 0.15, 4.265, 6.331 );
-		this.add( light3 );
-
-		// +z
-		const light4 = new Mesh( geometry, createAreaLightMaterial( 43 ) );
-		light4.position.set( - 0.462, 8.89, 14.520 );
-		light4.scale.set( 4.38, 5.441, 0.088 );
-		this.add( light4 );
-
-		// -z
-		const light5 = new Mesh( geometry, createAreaLightMaterial( 20 ) );
-		light5.position.set( 3.235, 11.486, - 12.541 );
-		light5.scale.set( 2.5, 2.0, 0.1 );
-		this.add( light5 );
-
-		// +y
-		const light6 = new Mesh( geometry, createAreaLightMaterial( 100 ) );
-		light6.position.set( 0.0, 20.0, 0.0 );
-		light6.scale.set( 1.0, 0.1, 1.0 );
-		this.add( light6 );
-
-	}
-
-	dispose() {
-
-		const resources = new Set();
-
-		this.traverse( ( object ) => {
-
-			if ( object.isMesh ) {
-
-				resources.add( object.geometry );
-				resources.add( object.material );
-
-			}
-
-		} );
-
-		for ( const resource of resources ) {
-
-			resource.dispose();
-
-		}
-
-	}
-
-}
-
-function createAreaLightMaterial( intensity ) {
-
-	const material = new MeshBasicMaterial();
-	material.color.setScalar( intensity );
-	return material;
-
-}
-
-export { RoomEnvironment };

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 4663
3d/static/js/jsm/loaders/GLTFLoader.js


+ 0 - 231
3d/static/js/jsm/postprocessing/EffectComposer.js

@@ -1,231 +0,0 @@
-import {
-	Clock,
-	HalfFloatType,
-	NoBlending,
-	Vector2,
-	WebGLRenderTarget
-} from 'three';
-import { CopyShader } from '../shaders/CopyShader.js';
-import { ShaderPass } from './ShaderPass.js';
-import { MaskPass } from './MaskPass.js';
-import { ClearMaskPass } from './MaskPass.js';
-
-class EffectComposer {
-
-	constructor( renderer, renderTarget ) {
-
-		this.renderer = renderer;
-
-		this._pixelRatio = renderer.getPixelRatio();
-
-		if ( renderTarget === undefined ) {
-
-			const size = renderer.getSize( new Vector2() );
-			this._width = size.width;
-			this._height = size.height;
-
-			renderTarget = new WebGLRenderTarget( this._width * this._pixelRatio, this._height * this._pixelRatio, { type: HalfFloatType } );
-			renderTarget.texture.name = 'EffectComposer.rt1';
-
-		} else {
-
-			this._width = renderTarget.width;
-			this._height = renderTarget.height;
-
-		}
-
-		this.renderTarget1 = renderTarget;
-		this.renderTarget2 = renderTarget.clone();
-		this.renderTarget2.texture.name = 'EffectComposer.rt2';
-
-		this.writeBuffer = this.renderTarget1;
-		this.readBuffer = this.renderTarget2;
-
-		this.renderToScreen = true;
-
-		this.passes = [];
-
-		this.copyPass = new ShaderPass( CopyShader );
-		this.copyPass.material.blending = NoBlending;
-
-		this.clock = new Clock();
-
-	}
-
-	swapBuffers() {
-
-		const tmp = this.readBuffer;
-		this.readBuffer = this.writeBuffer;
-		this.writeBuffer = tmp;
-
-	}
-
-	addPass( pass ) {
-
-		this.passes.push( pass );
-		pass.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio );
-
-	}
-
-	insertPass( pass, index ) {
-
-		this.passes.splice( index, 0, pass );
-		pass.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio );
-
-	}
-
-	removePass( pass ) {
-
-		const index = this.passes.indexOf( pass );
-
-		if ( index !== - 1 ) {
-
-			this.passes.splice( index, 1 );
-
-		}
-
-	}
-
-	isLastEnabledPass( passIndex ) {
-
-		for ( let i = passIndex + 1; i < this.passes.length; i ++ ) {
-
-			if ( this.passes[ i ].enabled ) {
-
-				return false;
-
-			}
-
-		}
-
-		return true;
-
-	}
-
-	render( deltaTime ) {
-
-		// deltaTime value is in seconds
-
-		if ( deltaTime === undefined ) {
-
-			deltaTime = this.clock.getDelta();
-
-		}
-
-		const currentRenderTarget = this.renderer.getRenderTarget();
-
-		let maskActive = false;
-
-		for ( let i = 0, il = this.passes.length; i < il; i ++ ) {
-
-			const pass = this.passes[ i ];
-
-			if ( pass.enabled === false ) continue;
-
-			pass.renderToScreen = ( this.renderToScreen && this.isLastEnabledPass( i ) );
-			pass.render( this.renderer, this.writeBuffer, this.readBuffer, deltaTime, maskActive );
-
-			if ( pass.needsSwap ) {
-
-				if ( maskActive ) {
-
-					const context = this.renderer.getContext();
-					const stencil = this.renderer.state.buffers.stencil;
-
-					//context.stencilFunc( context.NOTEQUAL, 1, 0xffffffff );
-					stencil.setFunc( context.NOTEQUAL, 1, 0xffffffff );
-
-					this.copyPass.render( this.renderer, this.writeBuffer, this.readBuffer, deltaTime );
-
-					//context.stencilFunc( context.EQUAL, 1, 0xffffffff );
-					stencil.setFunc( context.EQUAL, 1, 0xffffffff );
-
-				}
-
-				this.swapBuffers();
-
-			}
-
-			if ( MaskPass !== undefined ) {
-
-				if ( pass instanceof MaskPass ) {
-
-					maskActive = true;
-
-				} else if ( pass instanceof ClearMaskPass ) {
-
-					maskActive = false;
-
-				}
-
-			}
-
-		}
-
-		this.renderer.setRenderTarget( currentRenderTarget );
-
-	}
-
-	reset( renderTarget ) {
-
-		if ( renderTarget === undefined ) {
-
-			const size = this.renderer.getSize( new Vector2() );
-			this._pixelRatio = this.renderer.getPixelRatio();
-			this._width = size.width;
-			this._height = size.height;
-
-			renderTarget = this.renderTarget1.clone();
-			renderTarget.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio );
-
-		}
-
-		this.renderTarget1.dispose();
-		this.renderTarget2.dispose();
-		this.renderTarget1 = renderTarget;
-		this.renderTarget2 = renderTarget.clone();
-
-		this.writeBuffer = this.renderTarget1;
-		this.readBuffer = this.renderTarget2;
-
-	}
-
-	setSize( width, height ) {
-
-		this._width = width;
-		this._height = height;
-
-		const effectiveWidth = this._width * this._pixelRatio;
-		const effectiveHeight = this._height * this._pixelRatio;
-
-		this.renderTarget1.setSize( effectiveWidth, effectiveHeight );
-		this.renderTarget2.setSize( effectiveWidth, effectiveHeight );
-
-		for ( let i = 0; i < this.passes.length; i ++ ) {
-
-			this.passes[ i ].setSize( effectiveWidth, effectiveHeight );
-
-		}
-
-	}
-
-	setPixelRatio( pixelRatio ) {
-
-		this._pixelRatio = pixelRatio;
-
-		this.setSize( this._width, this._height );
-
-	}
-
-	dispose() {
-
-		this.renderTarget1.dispose();
-		this.renderTarget2.dispose();
-
-		this.copyPass.dispose();
-
-	}
-
-}
-
-export { EffectComposer };

+ 0 - 104
3d/static/js/jsm/postprocessing/MaskPass.js

@@ -1,104 +0,0 @@
-import { Pass } from './Pass.js';
-
-class MaskPass extends Pass {
-
-	constructor( scene, camera ) {
-
-		super();
-
-		this.scene = scene;
-		this.camera = camera;
-
-		this.clear = true;
-		this.needsSwap = false;
-
-		this.inverse = false;
-
-	}
-
-	render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) {
-
-		const context = renderer.getContext();
-		const state = renderer.state;
-
-		// don't update color or depth
-
-		state.buffers.color.setMask( false );
-		state.buffers.depth.setMask( false );
-
-		// lock buffers
-
-		state.buffers.color.setLocked( true );
-		state.buffers.depth.setLocked( true );
-
-		// set up stencil
-
-		let writeValue, clearValue;
-
-		if ( this.inverse ) {
-
-			writeValue = 0;
-			clearValue = 1;
-
-		} else {
-
-			writeValue = 1;
-			clearValue = 0;
-
-		}
-
-		state.buffers.stencil.setTest( true );
-		state.buffers.stencil.setOp( context.REPLACE, context.REPLACE, context.REPLACE );
-		state.buffers.stencil.setFunc( context.ALWAYS, writeValue, 0xffffffff );
-		state.buffers.stencil.setClear( clearValue );
-		state.buffers.stencil.setLocked( true );
-
-		// draw into the stencil buffer
-
-		renderer.setRenderTarget( readBuffer );
-		if ( this.clear ) renderer.clear();
-		renderer.render( this.scene, this.camera );
-
-		renderer.setRenderTarget( writeBuffer );
-		if ( this.clear ) renderer.clear();
-		renderer.render( this.scene, this.camera );
-
-		// unlock color and depth buffer and make them writable for subsequent rendering/clearing
-
-		state.buffers.color.setLocked( false );
-		state.buffers.depth.setLocked( false );
-
-		state.buffers.color.setMask( true );
-		state.buffers.depth.setMask( true );
-
-		// only render where stencil is set to 1
-
-		state.buffers.stencil.setLocked( false );
-		state.buffers.stencil.setFunc( context.EQUAL, 1, 0xffffffff ); // draw if == 1
-		state.buffers.stencil.setOp( context.KEEP, context.KEEP, context.KEEP );
-		state.buffers.stencil.setLocked( true );
-
-	}
-
-}
-
-class ClearMaskPass extends Pass {
-
-	constructor() {
-
-		super();
-
-		this.needsSwap = false;
-
-	}
-
-	render( renderer /*, writeBuffer, readBuffer, deltaTime, maskActive */ ) {
-
-		renderer.state.buffers.stencil.setLocked( false );
-		renderer.state.buffers.stencil.setTest( false );
-
-	}
-
-}
-
-export { MaskPass, ClearMaskPass };

+ 0 - 654
3d/static/js/jsm/postprocessing/OutlinePass.js

@@ -1,654 +0,0 @@
-import {
-	AdditiveBlending,
-	Color,
-	DoubleSide,
-	HalfFloatType,
-	Matrix4,
-	MeshDepthMaterial,
-	NoBlending,
-	RGBADepthPacking,
-	ShaderMaterial,
-	UniformsUtils,
-	Vector2,
-	Vector3,
-	WebGLRenderTarget
-} from 'three';
-import { Pass, FullScreenQuad } from './Pass.js';
-import { CopyShader } from '../shaders/CopyShader.js';
-
-class OutlinePass extends Pass {
-
-	constructor( resolution, scene, camera, selectedObjects ) {
-
-		super();
-
-		this.renderScene = scene;
-		this.renderCamera = camera;
-		this.selectedObjects = selectedObjects !== undefined ? selectedObjects : [];
-		this.visibleEdgeColor = new Color( 1, 1, 1 );
-		this.hiddenEdgeColor = new Color( 0.1, 0.04, 0.02 );
-		this.edgeGlow = 0.0;
-		this.usePatternTexture = false;
-		this.edgeThickness = 1.0;
-		this.edgeStrength = 3.0;
-		this.downSampleRatio = 2;
-		this.pulsePeriod = 0;
-
-		this._visibilityCache = new Map();
-
-
-		this.resolution = ( resolution !== undefined ) ? new Vector2( resolution.x, resolution.y ) : new Vector2( 256, 256 );
-
-		const resx = Math.round( this.resolution.x / this.downSampleRatio );
-		const resy = Math.round( this.resolution.y / this.downSampleRatio );
-
-		this.renderTargetMaskBuffer = new WebGLRenderTarget( this.resolution.x, this.resolution.y );
-		this.renderTargetMaskBuffer.texture.name = 'OutlinePass.mask';
-		this.renderTargetMaskBuffer.texture.generateMipmaps = false;
-
-		this.depthMaterial = new MeshDepthMaterial();
-		this.depthMaterial.side = DoubleSide;
-		this.depthMaterial.depthPacking = RGBADepthPacking;
-		this.depthMaterial.blending = NoBlending;
-
-		this.prepareMaskMaterial = this.getPrepareMaskMaterial();
-		this.prepareMaskMaterial.side = DoubleSide;
-		this.prepareMaskMaterial.fragmentShader = replaceDepthToViewZ( this.prepareMaskMaterial.fragmentShader, this.renderCamera );
-
-		this.renderTargetDepthBuffer = new WebGLRenderTarget( this.resolution.x, this.resolution.y, { type: HalfFloatType } );
-		this.renderTargetDepthBuffer.texture.name = 'OutlinePass.depth';
-		this.renderTargetDepthBuffer.texture.generateMipmaps = false;
-
-		this.renderTargetMaskDownSampleBuffer = new WebGLRenderTarget( resx, resy, { type: HalfFloatType } );
-		this.renderTargetMaskDownSampleBuffer.texture.name = 'OutlinePass.depthDownSample';
-		this.renderTargetMaskDownSampleBuffer.texture.generateMipmaps = false;
-
-		this.renderTargetBlurBuffer1 = new WebGLRenderTarget( resx, resy, { type: HalfFloatType } );
-		this.renderTargetBlurBuffer1.texture.name = 'OutlinePass.blur1';
-		this.renderTargetBlurBuffer1.texture.generateMipmaps = false;
-		this.renderTargetBlurBuffer2 = new WebGLRenderTarget( Math.round( resx / 2 ), Math.round( resy / 2 ), { type: HalfFloatType } );
-		this.renderTargetBlurBuffer2.texture.name = 'OutlinePass.blur2';
-		this.renderTargetBlurBuffer2.texture.generateMipmaps = false;
-
-		this.edgeDetectionMaterial = this.getEdgeDetectionMaterial();
-		this.renderTargetEdgeBuffer1 = new WebGLRenderTarget( resx, resy, { type: HalfFloatType } );
-		this.renderTargetEdgeBuffer1.texture.name = 'OutlinePass.edge1';
-		this.renderTargetEdgeBuffer1.texture.generateMipmaps = false;
-		this.renderTargetEdgeBuffer2 = new WebGLRenderTarget( Math.round( resx / 2 ), Math.round( resy / 2 ), { type: HalfFloatType } );
-		this.renderTargetEdgeBuffer2.texture.name = 'OutlinePass.edge2';
-		this.renderTargetEdgeBuffer2.texture.generateMipmaps = false;
-
-		const MAX_EDGE_THICKNESS = 4;
-		const MAX_EDGE_GLOW = 4;
-
-		this.separableBlurMaterial1 = this.getSeperableBlurMaterial( MAX_EDGE_THICKNESS );
-		this.separableBlurMaterial1.uniforms[ 'texSize' ].value.set( resx, resy );
-		this.separableBlurMaterial1.uniforms[ 'kernelRadius' ].value = 1;
-		this.separableBlurMaterial2 = this.getSeperableBlurMaterial( MAX_EDGE_GLOW );
-		this.separableBlurMaterial2.uniforms[ 'texSize' ].value.set( Math.round( resx / 2 ), Math.round( resy / 2 ) );
-		this.separableBlurMaterial2.uniforms[ 'kernelRadius' ].value = MAX_EDGE_GLOW;
-
-		// Overlay material
-		this.overlayMaterial = this.getOverlayMaterial();
-
-		// copy material
-
-		const copyShader = CopyShader;
-
-		this.copyUniforms = UniformsUtils.clone( copyShader.uniforms );
-
-		this.materialCopy = new ShaderMaterial( {
-			uniforms: this.copyUniforms,
-			vertexShader: copyShader.vertexShader,
-			fragmentShader: copyShader.fragmentShader,
-			blending: NoBlending,
-			depthTest: false,
-			depthWrite: false
-		} );
-
-		this.enabled = true;
-		this.needsSwap = false;
-
-		this._oldClearColor = new Color();
-		this.oldClearAlpha = 1;
-
-		this.fsQuad = new FullScreenQuad( null );
-
-		this.tempPulseColor1 = new Color();
-		this.tempPulseColor2 = new Color();
-		this.textureMatrix = new Matrix4();
-
-		function replaceDepthToViewZ( string, camera ) {
-
-			const type = camera.isPerspectiveCamera ? 'perspective' : 'orthographic';
-
-			return string.replace( /DEPTH_TO_VIEW_Z/g, type + 'DepthToViewZ' );
-
-		}
-
-	}
-
-	dispose() {
-
-		this.renderTargetMaskBuffer.dispose();
-		this.renderTargetDepthBuffer.dispose();
-		this.renderTargetMaskDownSampleBuffer.dispose();
-		this.renderTargetBlurBuffer1.dispose();
-		this.renderTargetBlurBuffer2.dispose();
-		this.renderTargetEdgeBuffer1.dispose();
-		this.renderTargetEdgeBuffer2.dispose();
-
-		this.depthMaterial.dispose();
-		this.prepareMaskMaterial.dispose();
-		this.edgeDetectionMaterial.dispose();
-		this.separableBlurMaterial1.dispose();
-		this.separableBlurMaterial2.dispose();
-		this.overlayMaterial.dispose();
-		this.materialCopy.dispose();
-
-		this.fsQuad.dispose();
-
-	}
-
-	setSize( width, height ) {
-
-		this.renderTargetMaskBuffer.setSize( width, height );
-		this.renderTargetDepthBuffer.setSize( width, height );
-
-		let resx = Math.round( width / this.downSampleRatio );
-		let resy = Math.round( height / this.downSampleRatio );
-		this.renderTargetMaskDownSampleBuffer.setSize( resx, resy );
-		this.renderTargetBlurBuffer1.setSize( resx, resy );
-		this.renderTargetEdgeBuffer1.setSize( resx, resy );
-		this.separableBlurMaterial1.uniforms[ 'texSize' ].value.set( resx, resy );
-
-		resx = Math.round( resx / 2 );
-		resy = Math.round( resy / 2 );
-
-		this.renderTargetBlurBuffer2.setSize( resx, resy );
-		this.renderTargetEdgeBuffer2.setSize( resx, resy );
-
-		this.separableBlurMaterial2.uniforms[ 'texSize' ].value.set( resx, resy );
-
-	}
-
-	changeVisibilityOfSelectedObjects( bVisible ) {
-
-		const cache = this._visibilityCache;
-
-		function gatherSelectedMeshesCallBack( object ) {
-
-			if ( object.isMesh ) {
-
-				if ( bVisible === true ) {
-
-					object.visible = cache.get( object );
-
-				} else {
-
-					cache.set( object, object.visible );
-					object.visible = bVisible;
-
-				}
-
-			}
-
-		}
-
-		for ( let i = 0; i < this.selectedObjects.length; i ++ ) {
-
-			const selectedObject = this.selectedObjects[ i ];
-			selectedObject.traverse( gatherSelectedMeshesCallBack );
-
-		}
-
-	}
-
-	changeVisibilityOfNonSelectedObjects( bVisible ) {
-
-		const cache = this._visibilityCache;
-		const selectedMeshes = [];
-
-		function gatherSelectedMeshesCallBack( object ) {
-
-			if ( object.isMesh ) selectedMeshes.push( object );
-
-		}
-
-		for ( let i = 0; i < this.selectedObjects.length; i ++ ) {
-
-			const selectedObject = this.selectedObjects[ i ];
-			selectedObject.traverse( gatherSelectedMeshesCallBack );
-
-		}
-
-		function VisibilityChangeCallBack( object ) {
-
-			if ( object.isMesh || object.isSprite ) {
-
-				// only meshes and sprites are supported by OutlinePass
-
-				let bFound = false;
-
-				for ( let i = 0; i < selectedMeshes.length; i ++ ) {
-
-					const selectedObjectId = selectedMeshes[ i ].id;
-
-					if ( selectedObjectId === object.id ) {
-
-						bFound = true;
-						break;
-
-					}
-
-				}
-
-				if ( bFound === false ) {
-
-					const visibility = object.visible;
-
-					if ( bVisible === false || cache.get( object ) === true ) {
-
-						object.visible = bVisible;
-
-					}
-
-					cache.set( object, visibility );
-
-				}
-
-			} else if ( object.isPoints || object.isLine ) {
-
-				// the visibilty of points and lines is always set to false in order to
-				// not affect the outline computation
-
-				if ( bVisible === true ) {
-
-					object.visible = cache.get( object ); // restore
-
-				} else {
-
-					cache.set( object, object.visible );
-					object.visible = bVisible;
-
-				}
-
-			}
-
-		}
-
-		this.renderScene.traverse( VisibilityChangeCallBack );
-
-	}
-
-	updateTextureMatrix() {
-
-		this.textureMatrix.set( 0.5, 0.0, 0.0, 0.5,
-			0.0, 0.5, 0.0, 0.5,
-			0.0, 0.0, 0.5, 0.5,
-			0.0, 0.0, 0.0, 1.0 );
-		this.textureMatrix.multiply( this.renderCamera.projectionMatrix );
-		this.textureMatrix.multiply( this.renderCamera.matrixWorldInverse );
-
-	}
-
-	render( renderer, writeBuffer, readBuffer, deltaTime, maskActive ) {
-
-		if ( this.selectedObjects.length > 0 ) {
-
-			renderer.getClearColor( this._oldClearColor );
-			this.oldClearAlpha = renderer.getClearAlpha();
-			const oldAutoClear = renderer.autoClear;
-
-			renderer.autoClear = false;
-
-			if ( maskActive ) renderer.state.buffers.stencil.setTest( false );
-
-			renderer.setClearColor( 0xffffff, 1 );
-
-			// Make selected objects invisible
-			this.changeVisibilityOfSelectedObjects( false );
-
-			const currentBackground = this.renderScene.background;
-			this.renderScene.background = null;
-
-			// 1. Draw Non Selected objects in the depth buffer
-			this.renderScene.overrideMaterial = this.depthMaterial;
-			renderer.setRenderTarget( this.renderTargetDepthBuffer );
-			renderer.clear();
-			renderer.render( this.renderScene, this.renderCamera );
-
-			// Make selected objects visible
-			this.changeVisibilityOfSelectedObjects( true );
-			this._visibilityCache.clear();
-
-			// Update Texture Matrix for Depth compare
-			this.updateTextureMatrix();
-
-			// Make non selected objects invisible, and draw only the selected objects, by comparing the depth buffer of non selected objects
-			this.changeVisibilityOfNonSelectedObjects( false );
-			this.renderScene.overrideMaterial = this.prepareMaskMaterial;
-			this.prepareMaskMaterial.uniforms[ 'cameraNearFar' ].value.set( this.renderCamera.near, this.renderCamera.far );
-			this.prepareMaskMaterial.uniforms[ 'depthTexture' ].value = this.renderTargetDepthBuffer.texture;
-			this.prepareMaskMaterial.uniforms[ 'textureMatrix' ].value = this.textureMatrix;
-			renderer.setRenderTarget( this.renderTargetMaskBuffer );
-			renderer.clear();
-			renderer.render( this.renderScene, this.renderCamera );
-			this.renderScene.overrideMaterial = null;
-			this.changeVisibilityOfNonSelectedObjects( true );
-			this._visibilityCache.clear();
-
-			this.renderScene.background = currentBackground;
-
-			// 2. Downsample to Half resolution
-			this.fsQuad.material = this.materialCopy;
-			this.copyUniforms[ 'tDiffuse' ].value = this.renderTargetMaskBuffer.texture;
-			renderer.setRenderTarget( this.renderTargetMaskDownSampleBuffer );
-			renderer.clear();
-			this.fsQuad.render( renderer );
-
-			this.tempPulseColor1.copy( this.visibleEdgeColor );
-			this.tempPulseColor2.copy( this.hiddenEdgeColor );
-
-			if ( this.pulsePeriod > 0 ) {
-
-				const scalar = ( 1 + 0.25 ) / 2 + Math.cos( performance.now() * 0.01 / this.pulsePeriod ) * ( 1.0 - 0.25 ) / 2;
-				this.tempPulseColor1.multiplyScalar( scalar );
-				this.tempPulseColor2.multiplyScalar( scalar );
-
-			}
-
-			// 3. Apply Edge Detection Pass
-			this.fsQuad.material = this.edgeDetectionMaterial;
-			this.edgeDetectionMaterial.uniforms[ 'maskTexture' ].value = this.renderTargetMaskDownSampleBuffer.texture;
-			this.edgeDetectionMaterial.uniforms[ 'texSize' ].value.set( this.renderTargetMaskDownSampleBuffer.width, this.renderTargetMaskDownSampleBuffer.height );
-			this.edgeDetectionMaterial.uniforms[ 'visibleEdgeColor' ].value = this.tempPulseColor1;
-			this.edgeDetectionMaterial.uniforms[ 'hiddenEdgeColor' ].value = this.tempPulseColor2;
-			renderer.setRenderTarget( this.renderTargetEdgeBuffer1 );
-			renderer.clear();
-			this.fsQuad.render( renderer );
-
-			// 4. Apply Blur on Half res
-			this.fsQuad.material = this.separableBlurMaterial1;
-			this.separableBlurMaterial1.uniforms[ 'colorTexture' ].value = this.renderTargetEdgeBuffer1.texture;
-			this.separableBlurMaterial1.uniforms[ 'direction' ].value = OutlinePass.BlurDirectionX;
-			this.separableBlurMaterial1.uniforms[ 'kernelRadius' ].value = this.edgeThickness;
-			renderer.setRenderTarget( this.renderTargetBlurBuffer1 );
-			renderer.clear();
-			this.fsQuad.render( renderer );
-			this.separableBlurMaterial1.uniforms[ 'colorTexture' ].value = this.renderTargetBlurBuffer1.texture;
-			this.separableBlurMaterial1.uniforms[ 'direction' ].value = OutlinePass.BlurDirectionY;
-			renderer.setRenderTarget( this.renderTargetEdgeBuffer1 );
-			renderer.clear();
-			this.fsQuad.render( renderer );
-
-			// Apply Blur on quarter res
-			this.fsQuad.material = this.separableBlurMaterial2;
-			this.separableBlurMaterial2.uniforms[ 'colorTexture' ].value = this.renderTargetEdgeBuffer1.texture;
-			this.separableBlurMaterial2.uniforms[ 'direction' ].value = OutlinePass.BlurDirectionX;
-			renderer.setRenderTarget( this.renderTargetBlurBuffer2 );
-			renderer.clear();
-			this.fsQuad.render( renderer );
-			this.separableBlurMaterial2.uniforms[ 'colorTexture' ].value = this.renderTargetBlurBuffer2.texture;
-			this.separableBlurMaterial2.uniforms[ 'direction' ].value = OutlinePass.BlurDirectionY;
-			renderer.setRenderTarget( this.renderTargetEdgeBuffer2 );
-			renderer.clear();
-			this.fsQuad.render( renderer );
-
-			// Blend it additively over the input texture
-			this.fsQuad.material = this.overlayMaterial;
-			this.overlayMaterial.uniforms[ 'maskTexture' ].value = this.renderTargetMaskBuffer.texture;
-			this.overlayMaterial.uniforms[ 'edgeTexture1' ].value = this.renderTargetEdgeBuffer1.texture;
-			this.overlayMaterial.uniforms[ 'edgeTexture2' ].value = this.renderTargetEdgeBuffer2.texture;
-			this.overlayMaterial.uniforms[ 'patternTexture' ].value = this.patternTexture;
-			this.overlayMaterial.uniforms[ 'edgeStrength' ].value = this.edgeStrength;
-			this.overlayMaterial.uniforms[ 'edgeGlow' ].value = this.edgeGlow;
-			this.overlayMaterial.uniforms[ 'usePatternTexture' ].value = this.usePatternTexture;
-
-
-			if ( maskActive ) renderer.state.buffers.stencil.setTest( true );
-
-			renderer.setRenderTarget( readBuffer );
-			this.fsQuad.render( renderer );
-
-			renderer.setClearColor( this._oldClearColor, this.oldClearAlpha );
-			renderer.autoClear = oldAutoClear;
-
-		}
-
-		if ( this.renderToScreen ) {
-
-			this.fsQuad.material = this.materialCopy;
-			this.copyUniforms[ 'tDiffuse' ].value = readBuffer.texture;
-			renderer.setRenderTarget( null );
-			this.fsQuad.render( renderer );
-
-		}
-
-	}
-
-	getPrepareMaskMaterial() {
-
-		return new ShaderMaterial( {
-
-			uniforms: {
-				'depthTexture': { value: null },
-				'cameraNearFar': { value: new Vector2( 0.5, 0.5 ) },
-				'textureMatrix': { value: null }
-			},
-
-			vertexShader:
-				`#include <morphtarget_pars_vertex>
-				#include <skinning_pars_vertex>
-
-				varying vec4 projTexCoord;
-				varying vec4 vPosition;
-				uniform mat4 textureMatrix;
-
-				void main() {
-
-					#include <skinbase_vertex>
-					#include <begin_vertex>
-					#include <morphtarget_vertex>
-					#include <skinning_vertex>
-					#include <project_vertex>
-
-					vPosition = mvPosition;
-
-					vec4 worldPosition = vec4( transformed, 1.0 );
-
-					#ifdef USE_INSTANCING
-
-						worldPosition = instanceMatrix * worldPosition;
-
-					#endif
-					
-					worldPosition = modelMatrix * worldPosition;
-
-					projTexCoord = textureMatrix * worldPosition;
-
-				}`,
-
-			fragmentShader:
-				`#include <packing>
-				varying vec4 vPosition;
-				varying vec4 projTexCoord;
-				uniform sampler2D depthTexture;
-				uniform vec2 cameraNearFar;
-
-				void main() {
-
-					float depth = unpackRGBAToDepth(texture2DProj( depthTexture, projTexCoord ));
-					float viewZ = - DEPTH_TO_VIEW_Z( depth, cameraNearFar.x, cameraNearFar.y );
-					float depthTest = (-vPosition.z > viewZ) ? 1.0 : 0.0;
-					gl_FragColor = vec4(0.0, depthTest, 1.0, 1.0);
-
-				}`
-
-		} );
-
-	}
-
-	getEdgeDetectionMaterial() {
-
-		return new ShaderMaterial( {
-
-			uniforms: {
-				'maskTexture': { value: null },
-				'texSize': { value: new Vector2( 0.5, 0.5 ) },
-				'visibleEdgeColor': { value: new Vector3( 1.0, 1.0, 1.0 ) },
-				'hiddenEdgeColor': { value: new Vector3( 1.0, 1.0, 1.0 ) },
-			},
-
-			vertexShader:
-				`varying vec2 vUv;
-
-				void main() {
-					vUv = uv;
-					gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
-				}`,
-
-			fragmentShader:
-				`varying vec2 vUv;
-
-				uniform sampler2D maskTexture;
-				uniform vec2 texSize;
-				uniform vec3 visibleEdgeColor;
-				uniform vec3 hiddenEdgeColor;
-
-				void main() {
-					vec2 invSize = 1.0 / texSize;
-					vec4 uvOffset = vec4(1.0, 0.0, 0.0, 1.0) * vec4(invSize, invSize);
-					vec4 c1 = texture2D( maskTexture, vUv + uvOffset.xy);
-					vec4 c2 = texture2D( maskTexture, vUv - uvOffset.xy);
-					vec4 c3 = texture2D( maskTexture, vUv + uvOffset.yw);
-					vec4 c4 = texture2D( maskTexture, vUv - uvOffset.yw);
-					float diff1 = (c1.r - c2.r)*0.5;
-					float diff2 = (c3.r - c4.r)*0.5;
-					float d = length( vec2(diff1, diff2) );
-					float a1 = min(c1.g, c2.g);
-					float a2 = min(c3.g, c4.g);
-					float visibilityFactor = min(a1, a2);
-					vec3 edgeColor = 1.0 - visibilityFactor > 0.001 ? visibleEdgeColor : hiddenEdgeColor;
-					gl_FragColor = vec4(edgeColor, 1.0) * vec4(d);
-				}`
-		} );
-
-	}
-
-	getSeperableBlurMaterial( maxRadius ) {
-
-		return new ShaderMaterial( {
-
-			defines: {
-				'MAX_RADIUS': maxRadius,
-			},
-
-			uniforms: {
-				'colorTexture': { value: null },
-				'texSize': { value: new Vector2( 0.5, 0.5 ) },
-				'direction': { value: new Vector2( 0.5, 0.5 ) },
-				'kernelRadius': { value: 1.0 }
-			},
-
-			vertexShader:
-				`varying vec2 vUv;
-
-				void main() {
-					vUv = uv;
-					gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
-				}`,
-
-			fragmentShader:
-				`#include <common>
-				varying vec2 vUv;
-				uniform sampler2D colorTexture;
-				uniform vec2 texSize;
-				uniform vec2 direction;
-				uniform float kernelRadius;
-
-				float gaussianPdf(in float x, in float sigma) {
-					return 0.39894 * exp( -0.5 * x * x/( sigma * sigma))/sigma;
-				}
-
-				void main() {
-					vec2 invSize = 1.0 / texSize;
-					float sigma = kernelRadius/2.0;
-					float weightSum = gaussianPdf(0.0, sigma);
-					vec4 diffuseSum = texture2D( colorTexture, vUv) * weightSum;
-					vec2 delta = direction * invSize * kernelRadius/float(MAX_RADIUS);
-					vec2 uvOffset = delta;
-					for( int i = 1; i <= MAX_RADIUS; i ++ ) {
-						float x = kernelRadius * float(i) / float(MAX_RADIUS);
-						float w = gaussianPdf(x, sigma);
-						vec4 sample1 = texture2D( colorTexture, vUv + uvOffset);
-						vec4 sample2 = texture2D( colorTexture, vUv - uvOffset);
-						diffuseSum += ((sample1 + sample2) * w);
-						weightSum += (2.0 * w);
-						uvOffset += delta;
-					}
-					gl_FragColor = diffuseSum/weightSum;
-				}`
-		} );
-
-	}
-
-	getOverlayMaterial() {
-
-		return new ShaderMaterial( {
-
-			uniforms: {
-				'maskTexture': { value: null },
-				'edgeTexture1': { value: null },
-				'edgeTexture2': { value: null },
-				'patternTexture': { value: null },
-				'edgeStrength': { value: 1.0 },
-				'edgeGlow': { value: 1.0 },
-				'usePatternTexture': { value: 0.0 }
-			},
-
-			vertexShader:
-				`varying vec2 vUv;
-
-				void main() {
-					vUv = uv;
-					gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
-				}`,
-
-			fragmentShader:
-				`varying vec2 vUv;
-
-				uniform sampler2D maskTexture;
-				uniform sampler2D edgeTexture1;
-				uniform sampler2D edgeTexture2;
-				uniform sampler2D patternTexture;
-				uniform float edgeStrength;
-				uniform float edgeGlow;
-				uniform bool usePatternTexture;
-
-				void main() {
-					vec4 edgeValue1 = texture2D(edgeTexture1, vUv);
-					vec4 edgeValue2 = texture2D(edgeTexture2, vUv);
-					vec4 maskColor = texture2D(maskTexture, vUv);
-					vec4 patternColor = texture2D(patternTexture, 6.0 * vUv);
-					float visibilityFactor = 1.0 - maskColor.g > 0.0 ? 1.0 : 0.5;
-					vec4 edgeValue = edgeValue1 + edgeValue2 * edgeGlow;
-					vec4 finalColor = edgeStrength * maskColor.r * edgeValue;
-					if(usePatternTexture)
-						finalColor += + visibilityFactor * (1.0 - maskColor.r) * (1.0 - patternColor.r);
-					gl_FragColor = finalColor;
-				}`,
-			blending: AdditiveBlending,
-			depthTest: false,
-			depthWrite: false,
-			transparent: true
-		} );
-
-	}
-
-}
-
-OutlinePass.BlurDirectionX = new Vector2( 1.0, 0.0 );
-OutlinePass.BlurDirectionY = new Vector2( 0.0, 1.0 );
-
-export { OutlinePass };

+ 0 - 95
3d/static/js/jsm/postprocessing/OutputPass.js

@@ -1,95 +0,0 @@
-import {
-	ColorManagement,
-	RawShaderMaterial,
-	UniformsUtils,
-	LinearToneMapping,
-	ReinhardToneMapping,
-	CineonToneMapping,
-	AgXToneMapping,
-	ACESFilmicToneMapping,
-	SRGBTransfer
-} from 'three';
-import { Pass, FullScreenQuad } from './Pass.js';
-import { OutputShader } from '../shaders/OutputShader.js';
-
-class OutputPass extends Pass {
-
-	constructor() {
-
-		super();
-
-		//
-
-		const shader = OutputShader;
-
-		this.uniforms = UniformsUtils.clone( shader.uniforms );
-
-		this.material = new RawShaderMaterial( {
-			name: shader.name,
-			uniforms: this.uniforms,
-			vertexShader: shader.vertexShader,
-			fragmentShader: shader.fragmentShader
-		} );
-
-		this.fsQuad = new FullScreenQuad( this.material );
-
-		// internal cache
-
-		this._outputColorSpace = null;
-		this._toneMapping = null;
-
-	}
-
-	render( renderer, writeBuffer, readBuffer/*, deltaTime, maskActive */ ) {
-
-		this.uniforms[ 'tDiffuse' ].value = readBuffer.texture;
-		this.uniforms[ 'toneMappingExposure' ].value = renderer.toneMappingExposure;
-
-		// rebuild defines if required
-
-		if ( this._outputColorSpace !== renderer.outputColorSpace || this._toneMapping !== renderer.toneMapping ) {
-
-			this._outputColorSpace = renderer.outputColorSpace;
-			this._toneMapping = renderer.toneMapping;
-
-			this.material.defines = {};
-
-			if ( ColorManagement.getTransfer( this._outputColorSpace ) === SRGBTransfer ) this.material.defines.SRGB_TRANSFER = '';
-
-			if ( this._toneMapping === LinearToneMapping ) this.material.defines.LINEAR_TONE_MAPPING = '';
-			else if ( this._toneMapping === ReinhardToneMapping ) this.material.defines.REINHARD_TONE_MAPPING = '';
-			else if ( this._toneMapping === CineonToneMapping ) this.material.defines.CINEON_TONE_MAPPING = '';
-			else if ( this._toneMapping === ACESFilmicToneMapping ) this.material.defines.ACES_FILMIC_TONE_MAPPING = '';
-			else if ( this._toneMapping === AgXToneMapping ) this.material.defines.AGX_TONE_MAPPING = '';
-
-			this.material.needsUpdate = true;
-
-		}
-
-		//
-
-		if ( this.renderToScreen === true ) {
-
-			renderer.setRenderTarget( null );
-			this.fsQuad.render( renderer );
-
-		} else {
-
-			renderer.setRenderTarget( writeBuffer );
-			if ( this.clear ) renderer.clear( renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil );
-			this.fsQuad.render( renderer );
-
-		}
-
-	}
-
-	dispose() {
-
-		this.material.dispose();
-		this.fsQuad.dispose();
-
-	}
-
-}
-
-export { OutputPass };

+ 0 - 95
3d/static/js/jsm/postprocessing/Pass.js

@@ -1,95 +0,0 @@
-import {
-	BufferGeometry,
-	Float32BufferAttribute,
-	OrthographicCamera,
-	Mesh
-} from 'three';
-
-class Pass {
-
-	constructor() {
-
-		this.isPass = true;
-
-		// if set to true, the pass is processed by the composer
-		this.enabled = true;
-
-		// if set to true, the pass indicates to swap read and write buffer after rendering
-		this.needsSwap = true;
-
-		// if set to true, the pass clears its buffer before rendering
-		this.clear = false;
-
-		// if set to true, the result of the pass is rendered to screen. This is set automatically by EffectComposer.
-		this.renderToScreen = false;
-
-	}
-
-	setSize( /* width, height */ ) {}
-
-	render( /* renderer, writeBuffer, readBuffer, deltaTime, maskActive */ ) {
-
-		console.error( 'THREE.Pass: .render() must be implemented in derived pass.' );
-
-	}
-
-	dispose() {}
-
-}
-
-// Helper for passes that need to fill the viewport with a single quad.
-
-const _camera = new OrthographicCamera( - 1, 1, 1, - 1, 0, 1 );
-
-// https://github.com/mrdoob/three.js/pull/21358
-
-class FullscreenTriangleGeometry extends BufferGeometry {
-
-	constructor() {
-
-		super();
-
-		this.setAttribute( 'position', new Float32BufferAttribute( [ - 1, 3, 0, - 1, - 1, 0, 3, - 1, 0 ], 3 ) );
-		this.setAttribute( 'uv', new Float32BufferAttribute( [ 0, 2, 0, 0, 2, 0 ], 2 ) );
-
-	}
-
-}
-
-const _geometry = new FullscreenTriangleGeometry();
-
-class FullScreenQuad {
-
-	constructor( material ) {
-
-		this._mesh = new Mesh( _geometry, material );
-
-	}
-
-	dispose() {
-
-		this._mesh.geometry.dispose();
-
-	}
-
-	render( renderer ) {
-
-		renderer.render( this._mesh, _camera );
-
-	}
-
-	get material() {
-
-		return this._mesh.material;
-
-	}
-
-	set material( value ) {
-
-		this._mesh.material = value;
-
-	}
-
-}
-
-export { Pass, FullScreenQuad };

+ 0 - 99
3d/static/js/jsm/postprocessing/RenderPass.js

@@ -1,99 +0,0 @@
-import {
-	Color
-} from 'three';
-import { Pass } from './Pass.js';
-
-class RenderPass extends Pass {
-
-	constructor( scene, camera, overrideMaterial = null, clearColor = null, clearAlpha = null ) {
-
-		super();
-
-		this.scene = scene;
-		this.camera = camera;
-
-		this.overrideMaterial = overrideMaterial;
-
-		this.clearColor = clearColor;
-		this.clearAlpha = clearAlpha;
-
-		this.clear = true;
-		this.clearDepth = false;
-		this.needsSwap = false;
-		this._oldClearColor = new Color();
-
-	}
-
-	render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) {
-
-		const oldAutoClear = renderer.autoClear;
-		renderer.autoClear = false;
-
-		let oldClearAlpha, oldOverrideMaterial;
-
-		if ( this.overrideMaterial !== null ) {
-
-			oldOverrideMaterial = this.scene.overrideMaterial;
-
-			this.scene.overrideMaterial = this.overrideMaterial;
-
-		}
-
-		if ( this.clearColor !== null ) {
-
-			renderer.getClearColor( this._oldClearColor );
-			renderer.setClearColor( this.clearColor );
-
-		}
-
-		if ( this.clearAlpha !== null ) {
-
-			oldClearAlpha = renderer.getClearAlpha();
-			renderer.setClearAlpha( this.clearAlpha );
-
-		}
-
-		if ( this.clearDepth == true ) {
-
-			renderer.clearDepth();
-
-		}
-
-		renderer.setRenderTarget( this.renderToScreen ? null : readBuffer );
-
-		if ( this.clear === true ) {
-
-			// TODO: Avoid using autoClear properties, see https://github.com/mrdoob/three.js/pull/15571#issuecomment-465669600
-			renderer.clear( renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil );
-
-		}
-
-		renderer.render( this.scene, this.camera );
-
-		// restore
-
-		if ( this.clearColor !== null ) {
-
-			renderer.setClearColor( this._oldClearColor );
-
-		}
-
-		if ( this.clearAlpha !== null ) {
-
-			renderer.setClearAlpha( oldClearAlpha );
-
-		}
-
-		if ( this.overrideMaterial !== null ) {
-
-			this.scene.overrideMaterial = oldOverrideMaterial;
-
-		}
-
-		renderer.autoClear = oldAutoClear;
-
-	}
-
-}
-
-export { RenderPass };

+ 0 - 77
3d/static/js/jsm/postprocessing/ShaderPass.js

@@ -1,77 +0,0 @@
-import {
-	ShaderMaterial,
-	UniformsUtils
-} from 'three';
-import { Pass, FullScreenQuad } from './Pass.js';
-
-class ShaderPass extends Pass {
-
-	constructor( shader, textureID ) {
-
-		super();
-
-		this.textureID = ( textureID !== undefined ) ? textureID : 'tDiffuse';
-
-		if ( shader instanceof ShaderMaterial ) {
-
-			this.uniforms = shader.uniforms;
-
-			this.material = shader;
-
-		} else if ( shader ) {
-
-			this.uniforms = UniformsUtils.clone( shader.uniforms );
-
-			this.material = new ShaderMaterial( {
-
-				name: ( shader.name !== undefined ) ? shader.name : 'unspecified',
-				defines: Object.assign( {}, shader.defines ),
-				uniforms: this.uniforms,
-				vertexShader: shader.vertexShader,
-				fragmentShader: shader.fragmentShader
-
-			} );
-
-		}
-
-		this.fsQuad = new FullScreenQuad( this.material );
-
-	}
-
-	render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) {
-
-		if ( this.uniforms[ this.textureID ] ) {
-
-			this.uniforms[ this.textureID ].value = readBuffer.texture;
-
-		}
-
-		this.fsQuad.material = this.material;
-
-		if ( this.renderToScreen ) {
-
-			renderer.setRenderTarget( null );
-			this.fsQuad.render( renderer );
-
-		} else {
-
-			renderer.setRenderTarget( writeBuffer );
-			// TODO: Avoid using autoClear properties, see https://github.com/mrdoob/three.js/pull/15571#issuecomment-465669600
-			if ( this.clear ) renderer.clear( renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil );
-			this.fsQuad.render( renderer );
-
-		}
-
-	}
-
-	dispose() {
-
-		this.material.dispose();
-
-		this.fsQuad.dispose();
-
-	}
-
-}
-
-export { ShaderPass };

+ 0 - 45
3d/static/js/jsm/shaders/CopyShader.js

@@ -1,45 +0,0 @@
-/**
- * Full-screen textured quad shader
- */
-
-const CopyShader = {
-
-	name: 'CopyShader',
-
-	uniforms: {
-
-		'tDiffuse': { value: null },
-		'opacity': { value: 1.0 }
-
-	},
-
-	vertexShader: /* glsl */`
-
-		varying vec2 vUv;
-
-		void main() {
-
-			vUv = uv;
-			gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
-
-		}`,
-
-	fragmentShader: /* glsl */`
-
-		uniform float opacity;
-
-		uniform sampler2D tDiffuse;
-
-		varying vec2 vUv;
-
-		void main() {
-
-			vec4 texel = texture2D( tDiffuse, vUv );
-			gl_FragColor = opacity * texel;
-
-
-		}`
-
-};
-
-export { CopyShader };

+ 0 - 81
3d/static/js/jsm/shaders/OutputShader.js

@@ -1,81 +0,0 @@
-const OutputShader = {
-
-	name: 'OutputShader',
-
-	uniforms: {
-
-		'tDiffuse': { value: null },
-		'toneMappingExposure': { value: 1 }
-
-	},
-
-	vertexShader: /* glsl */`
-		precision highp float;
-
-		uniform mat4 modelViewMatrix;
-		uniform mat4 projectionMatrix;
-
-		attribute vec3 position;
-		attribute vec2 uv;
-
-		varying vec2 vUv;
-
-		void main() {
-
-			vUv = uv;
-			gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
-
-		}`,
-
-	fragmentShader: /* glsl */`
-	
-		precision highp float;
-
-		uniform sampler2D tDiffuse;
-
-		#include <tonemapping_pars_fragment>
-		#include <colorspace_pars_fragment>
-
-		varying vec2 vUv;
-
-		void main() {
-
-			gl_FragColor = texture2D( tDiffuse, vUv );
-
-			// tone mapping
-
-			#ifdef LINEAR_TONE_MAPPING
-
-				gl_FragColor.rgb = LinearToneMapping( gl_FragColor.rgb );
-
-			#elif defined( REINHARD_TONE_MAPPING )
-
-				gl_FragColor.rgb = ReinhardToneMapping( gl_FragColor.rgb );
-
-			#elif defined( CINEON_TONE_MAPPING )
-
-				gl_FragColor.rgb = OptimizedCineonToneMapping( gl_FragColor.rgb );
-
-			#elif defined( ACES_FILMIC_TONE_MAPPING )
-
-				gl_FragColor.rgb = ACESFilmicToneMapping( gl_FragColor.rgb );
-
-			#elif defined( AGX_TONE_MAPPING )
-
-				gl_FragColor.rgb = AgXToneMapping( gl_FragColor.rgb );
-
-			#endif
-
-			// color space
-
-			#ifdef SRGB_TRANSFER
-
-				gl_FragColor = sRGBTransferOETF( gl_FragColor );
-
-			#endif
-
-		}`
-
-};
-
-export { OutputShader };

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 1375
3d/static/js/jsm/utils/BufferGeometryUtils.js


+ 0 - 990
3d/static/js/main.js

@@ -1,990 +0,0 @@
-import * as THREE from 'three';
-import { OrbitControls } from '/js/jsm/controls/OrbitControls.js';
-import { TrackballControls } from '/js/jsm/controls/TrackballControls.js';
-import { GLTFLoader } from '/js/jsm/loaders/GLTFLoader.js';
-import { RoomEnvironment } from '/js/jsm/environments/RoomEnvironment.js';
-import { EffectComposer } from '/js/jsm/postprocessing/EffectComposer.js';
-import { RenderPass } from '/js/jsm/postprocessing/RenderPass.js';
-import { OutlinePass } from '/js/jsm/postprocessing/OutlinePass.js';
-import { OutputPass } from '/js/jsm/postprocessing/OutputPass.js';
-
-/* ------------------------------------------------------------------ */
-/* 状态预设:名称 / 颜色 / 是否恢复默认色                              */
-/* ------------------------------------------------------------------ */
-const PRESETS = [
-  { key: 'ok',    label: '正常', color: '#43A047' },
-  { key: 'warn',  label: '预警', color: '#F9A825' },
-  { key: 'fault', label: '故障', color: '#E53935' },
-];
-
-/* ------------------------------------------------------------------ */
-/* DOM                                                                 */
-/* ------------------------------------------------------------------ */
-const $ = (id) => document.getElementById(id);
-const viewport = $('viewport');
-const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
-renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
-renderer.setSize(viewport.clientWidth, viewport.clientHeight);
-renderer.toneMapping = THREE.ACESFilmicToneMapping;
-renderer.toneMappingExposure = 1.05;
-viewport.insertBefore(renderer.domElement, viewport.firstChild);
-
-const scene = new THREE.Scene();
-scene.background = new THREE.Color(0x0f1115);
-
-const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 2000);
-const orbitControls = new OrbitControls(camera, renderer.domElement);
-orbitControls.enableDamping = true;
-orbitControls.dampingFactor = 0.08;
-let controls = orbitControls;   // 当前生效的控制器(轨道 / 自由)
-let trackball = null;
-let freeMode = false;
-
-/* 环境反射(让金属/粗糙PBR材质可见),房间环境由 PMREM 生成 */
-const pmrem = new THREE.PMREMGenerator(renderer);
-scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
-scene.add(new THREE.HemisphereLight(0xffffff, 0x20242c, 0.9));
-{
-  const dl = new THREE.DirectionalLight(0xffffff, 1.6);
-  dl.position.set(6, 12, 8);
-  scene.add(dl);
-  const dl2 = new THREE.DirectionalLight(0xffffff, 0.5);
-  dl2.position.set(-8, -4, -6);
-  scene.add(dl2);
-}
-
-/* 后处理:描边高亮选中部件 */
-const composer = new EffectComposer(renderer);
-composer.addPass(new RenderPass(scene, camera));
-const outline = new OutlinePass(
-  new THREE.Vector2(viewport.clientWidth, viewport.clientHeight), scene, camera
-);
-outline.edgeStrength = 4;
-outline.edgeGlow = 0.2;
-outline.edgeThickness = 1.2;
-outline.visibleEdgeColor = new THREE.Color(0x4da3ff);
-outline.hiddenEdgeColor = new THREE.Color(0x000000);
-composer.addPass(outline);
-composer.addPass(new OutputPass());
-
-/* ------------------------------------------------------------------ */
-/* 状态变量                                                            */
-/* ------------------------------------------------------------------ */
-const parts = [];            // { id, label, obj|inst+instanceId, status, colorHex, defaults:[hex,...], name }
-const selected = new Set();  // 存放 part 引用(面板与场景共用)
-let modelName = null;
-let modelGroup = null;
-let grid = null;
-let hoveredPart = null;
-let saveTimer = null;
-const modelCenter = new THREE.Vector3();  // 当前模型包围盒中心
-const modelSize = new THREE.Vector3();    // 当前模型包围盒尺寸
-let modelDist = 0;                        // 当前取景距离(含 120% 缩放)
-
-/* 大模型模式:部件数超过阈值时,用 InstancedMesh 批量渲染 */
-const LARGE_THRESHOLD = 1500;
-let largeMode = false;
-let instancedMeshes = [];    // 场景中的 InstancedMesh 集合
-let prevSelected = new Set(); // 上一次选中集合(用于增量更新高亮)
-const HIGHLIGHT = new THREE.Color(0x4da3ff);
-const instMaterial = new THREE.MeshStandardMaterial({
-  color: 0xffffff, metalness: 0.2, roughness: 0.7, side: THREE.DoubleSide,
-});
-
-const loader = new GLTFLoader();
-const raycaster = new THREE.Raycaster();
-const pointer = new THREE.Vector2();
-
-/* ------------------------------------------------------------------ */
-/* 工具函数                                                            */
-/* ------------------------------------------------------------------ */
-function eachMaterial(obj, fn) {
-  if (!obj) return;
-  if (Array.isArray(obj.material)) obj.material.forEach(fn);
-  else fn(obj.material);
-}
-function toHex(c) {
-  return '#' + new THREE.Color(c).getHexString();
-}
-function isValidModelFile(name) {
-  return /\.(glb|gltf)$/i.test(name);
-}
-
-/* ------------------------------------------------------------------ */
-/* 部件管理:为每个 Mesh 建立独立材质 + 稳定的部件ID                    */
-/* ------------------------------------------------------------------ */
-function ensureOwnMaterial(obj) {
-  if (Array.isArray(obj.material)) obj.material = obj.material.map((m) => m.clone());
-  else if (obj.material) obj.material = obj.material.clone();
-  eachMaterial(obj, (m) => { m.needsUpdate = true; });
-}
-function collectParts(group) {
-  parts.length = 0;
-  const seenCount = new Map();
-  group.traverse((obj) => {
-    if (!obj.isMesh || !obj.geometry) return;
-    ensureOwnMaterial(obj);
-    const base = (obj.name && obj.name.trim()) ? obj.name.trim() : '部件';
-    const n = (seenCount.get(base) || 0) + 1;
-    seenCount.set(base, n);
-    const label = n > 1 ? `${base} [${n}]` : base;
-    const id = `${base}#${n}`;
-    const defaults = [];
-    eachMaterial(obj, (m) => {
-      const hex = toHex(m.color || 0xcccccc);
-      defaults.push(hex);
-      m.color.set(hex);
-    });
-    const part = { id, label, obj, name: base, status: null, colorHex: null, defaults };
-    obj.userData.part = part;
-    parts.push(part);
-  });
-  parts.sort((a, b) => a.label.localeCompare(b.label, 'zh'));
-  return parts;
-}
-
-/* 大模型模式:按"唯一几何"归并,用 InstancedMesh 渲染(实例复用几何,
-   绘制次数从 N 降到唯一几何数;每个实例可独立着色与拾取) */
-function countMeshNodes(group) {
-  let n = 0;
-  group.traverse((o) => { if (o.isMesh && o.geometry) n++; });
-  return n;
-}
-function collectPartsInstanced(group) {
-  parts.length = 0;
-  instancedMeshes = [];
-  group.updateMatrixWorld(true);
-  const byGeom = new Map();          // geometry.uuid -> { geometry, entries:[{matrix, part}] }
-  const seenCount = new Map();
-  group.traverse((obj) => {
-    if (!obj.isMesh || !obj.geometry) return;
-    const g = obj.geometry;
-    if (!g.attributes.normal) g.computeVertexNormals();
-    if (!g.boundingSphere) g.computeBoundingSphere();
-    let rec = byGeom.get(g.uuid);
-    if (!rec) { rec = { geometry: g, entries: [] }; byGeom.set(g.uuid, rec); }
-
-    const base = (obj.name && obj.name.trim()) ? obj.name.trim() : '部件';
-    const n = (seenCount.get(base) || 0) + 1;
-    seenCount.set(base, n);
-    const label = n > 1 ? `${base} [${n}]` : base;
-    const id = `${base}#${n}`;
-    const defaultHex = toHex((obj.material && obj.material.color) ? obj.material.color : 0xcccccc);
-    const part = {
-      id, label, name: base, status: null, colorHex: null,
-      defaults: [defaultHex], obj: null, inst: null, instanceId: 0,
-    };
-    rec.entries.push({ matrix: obj.matrixWorld.clone(), part });
-    parts.push(part);
-  });
-
-  byGeom.forEach((rec) => {
-    const inst = new THREE.InstancedMesh(rec.geometry, instMaterial, rec.entries.length);
-    inst.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
-    const map = new Array(rec.entries.length);
-    rec.entries.forEach((e, i) => {
-      inst.setMatrixAt(i, e.matrix);
-      inst.setColorAt(i, new THREE.Color(e.part.defaults[0]));
-      e.part.inst = inst;
-      e.part.instanceId = i;
-      map[i] = e.part;
-    });
-    inst.instanceMatrix.needsUpdate = true;
-    if (inst.instanceColor) inst.instanceColor.needsUpdate = true;
-    inst.userData.instPartMap = map;
-    scene.add(inst);
-    instancedMeshes.push(inst);
-  });
-
-  parts.sort((a, b) => a.label.localeCompare(b.label, 'zh'));
-  return parts;
-}
-function clearRendered() {
-  instancedMeshes.forEach((m) => {
-    scene.remove(m);
-    m.dispose();
-  });
-  instancedMeshes = [];
-}
-
-/* ------------------------------------------------------------------ */
-/* 部件属性:顶点数 / 三角面 / 世界尺寸(懒计算并缓存)                  */
-/* ------------------------------------------------------------------ */
-function partGeometry(part) {
-  if (part.inst) return part.inst.geometry;
-  return part.obj ? part.obj.geometry : null;
-}
-function partMatrix(part) {
-  if (part.inst) {
-    const m = new THREE.Matrix4();
-    part.inst.getMatrixAt(part.instanceId, m);
-    return m;
-  }
-  return part.obj ? part.obj.matrixWorld : null;
-}
-function partStats(part) {
-  if (part._stats) return part._stats;
-  let vtx = 0, tri = 0, size = '—';
-  const g = partGeometry(part);
-  if (g) {
-    if (g.attributes && g.attributes.position) vtx = g.attributes.position.count;
-    tri = g.index ? Math.floor(g.index.count / 3) : Math.floor(vtx / 3);
-    if (!g.boundingBox) g.computeBoundingBox();
-    const m = partMatrix(part);
-    if (g.boundingBox && m) {
-      const box = g.boundingBox.clone().applyMatrix4(m);
-      const d = box.getSize(new THREE.Vector3());
-      size = `${d.x.toFixed(1)}×${d.y.toFixed(1)}×${d.z.toFixed(1)}`;
-    }
-  }
-  part._stats = { vtx, tri, size };
-  return part._stats;
-}
-
-/* ------------------------------------------------------------------ */
-/* 上色(兼容普通模式与大模型模式)                                     */
-/* ------------------------------------------------------------------ */
-function applyPartDisplay(part) {
-  const base = part.colorHex || part.defaults[0];
-  if (part.inst) {
-    const c = new THREE.Color(base);
-    if (selected.has(part)) c.lerp(HIGHLIGHT, 0.45);   // 大模型模式用颜色提亮表示选中
-    part.inst.setColorAt(part.instanceId, c);
-    if (part.inst.instanceColor) part.inst.instanceColor.needsUpdate = true;
-  } else if (part.obj) {
-    eachMaterial(part.obj, (m) => { if (m.color) m.color.set(base); });
-  }
-}
-function setPartColor(part, hex, status) {
-  part.colorHex = toHex(new THREE.Color(hex));
-  part.status = status || null;
-  applyPartDisplay(part);
-}
-function resetPartColor(part) {
-  part.colorHex = null;
-  part.status = null;
-  applyPartDisplay(part);
-}
-function refreshSelectionColors() {
-  if (!largeMode) return;
-  const changed = new Set();
-  selected.forEach((p) => { if (!prevSelected.has(p)) changed.add(p); });
-  prevSelected.forEach((p) => { if (!selected.has(p)) changed.add(p); });
-  changed.forEach(applyPartDisplay);
-  prevSelected = new Set(selected);
-}
-function resetAllParts() {
-  parts.forEach((p) => resetPartColor(p));
-  refreshRows();
-  renderPartDetail();
-  scheduleSave();
-}
-function applyToSelection(hex, status) {
-  if (!selected.size) { toast('请先选择一个部件'); return; }
-  selected.forEach((p) => setPartColor(p, hex, status));
-  refreshRows();
-  renderPartDetail();
-  scheduleSave();
-}
-function resetSelected() {
-  if (!selected.size) { toast('请先选择一个部件'); return; }
-  selected.forEach((p) => resetPartColor(p));
-  refreshRows();
-  renderPartDetail();
-  scheduleSave();
-}
-
-/* ------------------------------------------------------------------ */
-/* 时序着色演示:本色 → 预警 → 故障,每 1 秒切换                        */
-/* ------------------------------------------------------------------ */
-const CYCLE_STATES = [
-  { label: '本色', apply: (p) => resetPartColor(p) },
-  { label: '预警', apply: (p) => setPartColor(p, '#F9A825', 'warn') },
-  { label: '故障', apply: (p) => setPartColor(p, '#E53935', 'fault') },
-];
-let cycleTimer = null;
-let cyclePart = null;
-let cycleStep = 0;
-
-/* 只更新该部件对应的一行,避免每秒重建整个列表 */
-function updatePartRow(part) {
-  const list = $('partList');
-  for (const row of list.children) {
-    if (!row.dataset || row.dataset.id !== part.id) continue;
-    const sw = row.querySelector('.swatch');
-    if (sw) sw.style.background = part.colorHex || part.defaults[0];
-    let tag = row.querySelector('.tag');
-    const preset = part.status ? PRESETS.find((s) => s.key === part.status) : null;
-    if (preset) {
-      if (!tag) { tag = document.createElement('span'); tag.className = 'tag'; row.appendChild(tag); }
-      tag.textContent = preset.label;
-      tag.style.background = preset.color;
-    } else if (tag) {
-      tag.remove();
-    }
-    break;
-  }
-}
-
-function stopCycle() {
-  if (cycleTimer) { clearInterval(cycleTimer); cycleTimer = null; }
-  if (cyclePart) { resetPartColor(cyclePart); updatePartRow(cyclePart); }
-  cyclePart = null;
-  cycleStep = 0;
-  const el = $('cycleProgress');
-  if (el) { el.style.display = 'none'; el.textContent = ''; }
-}
-
-function startCycle(part) {
-  stopCycle();
-  if (!part) return;
-  cyclePart = part;
-  cycleStep = 0;
-  const el = $('cycleProgress');
-  if (el) el.style.display = 'inline-block';
-  const tick = () => {
-    const s = CYCLE_STATES[cycleStep];
-    s.apply(cyclePart);
-    updatePartRow(cyclePart);
-    if (el) el.textContent = `${cycleStep + 1}/3 ${s.label}`;
-    log(`时序切换 ${cycleStep + 1}/3 ${s.label}`);
-    cycleStep = (cycleStep + 1) % CYCLE_STATES.length;
-  };
-  tick();                                   // 立即进入 1/3
-  cycleTimer = setInterval(tick, 1000);     // 每 1 秒切换
-}
-
-/* ------------------------------------------------------------------ */
-/* 选择管理                                                            */
-/* ------------------------------------------------------------------ */
-function clearSelection() {
-  selected.clear();
-  updateSelectionUI();
-}
-function selectOnly(part) {
-  selected.clear();
-  if (part) selected.add(part);
-  updateSelectionUI();
-}
-function toggleSelect(part, additive) {
-  if (!additive) { selectOnly(part); return; }
-  if (selected.has(part)) selected.delete(part); else selected.add(part);
-  updateSelectionUI();
-}
-function updateSelectionUI() {
-  refreshRows();
-  renderPartDetail();
-  if (largeMode) {
-    outline.selectedObjects = [];
-    refreshSelectionColors();
-  } else {
-    outline.selectedObjects = [...selected].map((p) => p.obj);
-  }
-}
-function renderPartDetail() {
-  const el = $('partDetail');
-  if (!el) return;
-  el.innerHTML = '';
-  const add = (text, cls) => {
-    const d = document.createElement('div');
-    d.className = 'dline' + (cls ? ' ' + cls : '');
-    d.textContent = text;
-    el.appendChild(d);
-  };
-  const n = selected.size;
-  if (n === 0) {
-    add('未选中部件。点击模型中的部件,或点下方「打开查询部件…」选择。', 'dim');
-    return;
-  }
-  if (n > 1) {
-    const names = [...selected].slice(0, 6).map((p) => p.label).join('、');
-    add(`已选中 ${n} 个部件:${names}${n > 6 ? '…' : ''}`);
-    return;
-  }
-  const p = selected.values().next().value;
-  const st = partStats(p);
-  const color = p.colorHex || p.defaults[0];
-  const preset = PRESETS.find((s) => s.key === p.status);
-  add(`名称:${p.label}`);
-  add(`顶点数:${st.vtx.toLocaleString()} 三角面:${st.tri.toLocaleString()}`);
-  add(`尺寸:${st.size} 颜色:${color} 状态:${preset ? preset.label : '未设置'}`);
-}
-
-/* ------------------------------------------------------------------ */
-/* 部件列表面板                                                        */
-/* ------------------------------------------------------------------ */
-const MAX_LIST_ROWS = 800;
-function refreshRows() {
-  const list = $('partList');
-  if (!list) return;
-  const q = ($('search').value || '').trim().toLowerCase();
-  const frag = document.createDocumentFragment();
-  const matched = q
-    ? parts.filter((p) => p.label.toLowerCase().includes(q) || p.name.toLowerCase().includes(q))
-    : parts;
-  const shown = matched.slice(0, MAX_LIST_ROWS);
-  shown.forEach((p) => {
-    const st = partStats(p);
-    const row = document.createElement('div');
-    row.className = 'part-row' + (selected.has(p) ? ' selected' : '');
-    row.dataset.id = p.id;
-
-    const name = document.createElement('span');
-    name.className = 'name';
-    name.textContent = p.label;
-    name.title = p.label;
-
-    const vtx = document.createElement('span');
-    vtx.className = 'vtx';
-    vtx.textContent = st.vtx.toLocaleString();
-
-    const tri = document.createElement('span');
-    tri.className = 'tri';
-    tri.textContent = st.tri.toLocaleString();
-
-    const size = document.createElement('span');
-    size.className = 'size';
-    size.textContent = st.size;
-
-    const color = document.createElement('span');
-    color.className = 'color';
-    const swatch = document.createElement('i');
-    swatch.className = 'swatch';
-    swatch.style.background = p.colorHex || p.defaults[0];
-    const hex = document.createElement('span');
-    hex.textContent = p.colorHex || p.defaults[0];
-    color.appendChild(swatch);
-    color.appendChild(hex);
-
-    const status = document.createElement('span');
-    status.className = 'status';
-    const preset = PRESETS.find((s) => s.key === p.status);
-    if (preset) {
-      const tag = document.createElement('span');
-      tag.className = 'tag';
-      tag.textContent = preset.label;
-      tag.style.background = preset.color;
-      status.appendChild(tag);
-    } else {
-      status.textContent = '—';
-    }
-
-    row.appendChild(name);
-    row.appendChild(vtx);
-    row.appendChild(tri);
-    row.appendChild(size);
-    row.appendChild(color);
-    row.appendChild(status);
-
-    row.addEventListener('click', () => selectOnly(p));
-    row.addEventListener('mouseenter', () => setHover(p, true));
-    row.addEventListener('mouseleave', () => setHover(p, false));
-    frag.appendChild(row);
-  });
-  list.innerHTML = '';
-  list.appendChild(frag);
-  $('partCount').textContent = `共 ${parts.length} 个部件${largeMode ? ' · 大模型模式' : ''}`;
-  const note = $('partListNote');
-  if (note) {
-    if (matched.length > MAX_LIST_ROWS) {
-      note.style.display = 'block';
-      note.textContent = `匹配 ${matched.length} 个,仅显示前 ${MAX_LIST_ROWS} 个,请用搜索缩小范围`;
-    } else if (!matched.length) {
-      note.style.display = 'block';
-      note.textContent = '没有匹配的部件';
-    } else {
-      note.style.display = 'none';
-    }
-  }
-}
-
-/* ------------------------------------------------------------------ */
-/* 悬停提示                                                            */
-/* ------------------------------------------------------------------ */
-function setHover(part, on) {
-  if (on && part !== hoveredPart) {
-    hoveredPart = part;
-    renderer.domElement.style.cursor = 'pointer';
-    const tip = $('tooltip');
-    tip.textContent = part.label;
-    tip.style.display = 'block';
-  } else if (!on && part === hoveredPart) {
-    hoveredPart = null;
-    renderer.domElement.style.cursor = 'default';
-    $('tooltip').style.display = 'none';
-  }
-}
-viewport.addEventListener('mousemove', (e) => {
-  const r = renderer.domElement.getBoundingClientRect();
-  const tip = $('tooltip');
-  if (hoveredPart) {
-    tip.style.left = (e.clientX - r.left + 14) + 'px';
-    tip.style.top = (e.clientY - r.top + 14) + 'px';
-  }
-});
-
-/* 场景拾取(悬停 + 点选) */
-function pickPart(clientX, clientY) {
-  const rect = renderer.domElement.getBoundingClientRect();
-  pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
-  pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
-  raycaster.setFromCamera(pointer, camera);
-  if (largeMode) {
-    if (!instancedMeshes.length) return null;
-    const hits = raycaster.intersectObjects(instancedMeshes, false);
-    if (!hits.length) return null;
-    const h = hits[0];
-    return h.object.userData.instPartMap[h.instanceId] || null;
-  }
-  const meshes = parts.map((p) => p.obj);
-  const hits = raycaster.intersectObjects(meshes, false);
-  return hits.length ? hits[0].object.userData.part : null;
-}
-
-let lastPick = 0;
-renderer.domElement.addEventListener('pointermove', (e) => {
-  const now = performance.now();
-  if (now - lastPick < 60) return;   // 大模型下限制拾取频率,避免卡顿
-  lastPick = now;
-  const p = pickPart(e.clientX, e.clientY);
-  setHover(p, true);
-  setHover(hoveredPart && hoveredPart !== p ? hoveredPart : null, false);
-});
-
-/* 区分“点击”与“拖动旋转”:位移小于阈值才算点击 */
-let downXY = null;
-renderer.domElement.addEventListener('pointerdown', (e) => {
-  downXY = [e.clientX, e.clientY];
-});
-renderer.domElement.addEventListener('pointerup', (e) => {
-  if (!downXY) return;
-  const dist = Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]);
-  downXY = null;
-  if (dist > 5) return;
-  const p = pickPart(e.clientX, e.clientY);
-  toggleSelect(p, (e.ctrlKey || e.metaKey) && p !== null);
-});
-
-/* ------------------------------------------------------------------ */
-/* 相机取景                                                            */
-/* ------------------------------------------------------------------ */
-function fitCamera(group, zoom = 1.2) {
-  const box = new THREE.Box3().setFromObject(group);
-  if (box.isEmpty()) return;
-  const center = box.getCenter(new THREE.Vector3());
-  const size = box.getSize(new THREE.Vector3());
-  const maxDim = Math.max(size.x, size.y, size.z);
-  const fovRad = THREE.MathUtils.degToRad(camera.fov);
-  const fitDist = (maxDim / (2 * Math.tan(fovRad / 2))) * 1.35;
-  modelCenter.copy(center);
-  modelSize.copy(size);
-  modelDist = fitDist / zoom;               // zoom=1.2 -> 比标准取景放大到 120%
-  camera.near = maxDim / 100;
-  camera.far = maxDim * 50;
-  const dir = new THREE.Vector3(0.7, 0.55, 1).normalize();
-  camera.position.copy(center).add(dir.multiplyScalar(modelDist));
-  camera.lookAt(center);
-  controls.target.copy(center);
-  controls.update();
-  updateCameraAspect();
-  if (grid) scene.remove(grid);
-  const g = new THREE.GridHelper(maxDim * 2.2, 20, 0x3a424f, 0x232831);
-  g.position.set(center.x, box.min.y, center.z);
-  g.material.transparent = true;
-  g.material.opacity = 0.5;
-  scene.add(g);
-  grid = g;
-  updateCamInfo();
-}
-
-/* 应用一个视角:
-   - 固定坐标视角:X/Y 固定,Z 按"零件中位位置"随模型平移(模块沿 Z 排列);
-   - 轴向视角(axis):相机放在模型中心沿该轴方向、按横截面大小取距离,正对该轴端面 */
-function applyView(v) {
-  if (!modelDist) return;
-  let target, cam;
-  if (v.axis) {
-    const dir = new THREE.Vector3(...v.axis).normalize();
-    const ai = v.axis.findIndex((x) => x !== 0);
-    const cross = Math.max(...[0, 1, 2].filter((i) => i !== ai).map((i) => modelSize.getComponent(i)));
-    const fovRad = THREE.MathUtils.degToRad(camera.fov);
-    const crossFit = (cross / (2 * Math.tan(fovRad / 2))) * 1.2;
-    const halfAxis = modelSize.getComponent(ai) / 2;
-    const dist = halfAxis + crossFit;     // 保证相机在该轴端面之外
-    target = modelCenter.clone();
-    cam = modelCenter.clone().addScaledVector(dir, dist);
-  } else {
-    const dz = modelMedianZ - REF_MEDIAN_Z;
-    target = new THREE.Vector3(v.target[0], v.target[1], v.target[2] + dz);
-    cam = new THREE.Vector3(v.camera[0], v.camera[1], v.camera[2] + dz);
-  }
-  camera.position.copy(cam);
-  camera.lookAt(target);
-  controls.target.copy(target);
-  controls.update();
-  updateCamInfo();
-}
-
-/* 相机沿视线方向前后移动(保持朝向),用于"走近/走远"查看 */
-function moveForward(sign) {
-  const dir = new THREE.Vector3().subVectors(controls.target, camera.position).normalize();
-  const step = (modelDist || 10) * 0.08;
-  camera.position.addScaledVector(dir, sign * step);
-  controls.target.addScaledVector(dir, sign * step);
-  controls.update();
-}
-window.addEventListener('keydown', (e) => {
-  const t = e.target;
-  if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
-  if (e.key === 'ArrowUp' || e.key === 'w' || e.key === 'W') { moveForward(1); e.preventDefault(); }
-  else if (e.key === 'ArrowDown' || e.key === 's' || e.key === 'S') { moveForward(-1); e.preventDefault(); }
-});
-
-let viewButtonEls = [];
-function activateView(i) {
-  applyView(VIEWS[i]);
-  viewButtonEls.forEach((b, j) => b.classList.toggle('active', j === i));
-}
-
-/* 实时显示当前相机角度参数,便于调好角度后固化到按钮 */
-function updateCamInfo() {
-  const el = $('camInfo');
-  if (!el) return;
-  const off = camera.position.clone().sub(controls.target);
-  if (off.lengthSq() === 0) { el.textContent = ''; return; }
-  const sph = new THREE.Spherical().setFromVector3(off);
-  const az = ((THREE.MathUtils.radToDeg(sph.theta) % 360) + 360) % 360;
-  const elev = 90 - THREE.MathUtils.radToDeg(sph.phi);
-  const p = camera.position, t = controls.target;
-  el.textContent =
-    `方位角 Az ${az.toFixed(1)}°   俯仰角 El ${elev.toFixed(1)}°   距离 ${sph.radius.toFixed(1)}\n` +
-    `相机 (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})\n` +
-    `目标 (${t.x.toFixed(1)}, ${t.y.toFixed(1)}, ${t.z.toFixed(1)})`;
-}
-controls.addEventListener('change', updateCamInfo);
-
-/* 旋转模式:轨道(OrbitControls,绕上轴) / 自由(TrackballControls,可任意翻滚) */
-function ensureTrackball() {
-  if (trackball) return trackball;
-  trackball = new TrackballControls(camera, renderer.domElement);
-  trackball.rotateSpeed = 3.0;
-  trackball.zoomSpeed = 1.2;
-  trackball.panSpeed = 0.8;
-  trackball.staticMoving = false;
-  trackball.dynamicDampingFactor = 0.15;
-  trackball.enabled = false;
-  trackball.addEventListener('change', updateCamInfo);
-  return trackball;
-}
-function setFreeMode(on) {
-  freeMode = on;
-  if (on) {
-    ensureTrackball();
-    trackball.target.copy(controls.target);
-    trackball.enabled = true;
-    orbitControls.enabled = false;
-    controls = trackball;
-  } else {
-    if (trackball) trackball.enabled = false;
-    camera.up.set(0, 1, 0);            // 回到轨道模式时恢复默认上轴
-    orbitControls.target.copy(controls.target);
-    orbitControls.enabled = true;
-    controls = orbitControls;
-  }
-  controls.update();
-  updateCamInfo();
-}
-
-/* 视角预设:模型已把 CAD 的 Z 朝上转成 Y 朝上(rotation.x = -90°),
-   坐标均为新坐标系 (x, z, -y)_旧。加载时按当前模型的零件中位位置只平移 Z
-   (三个模块沿 Z 排列,X/Y 不变),从而三个模块都能套用同一视角。
-   俯视:   相机 (2.0, 22.0, -3.2) -> 目标 (2.0, 0, -22.0)
-   角度A:  相机 (5.4, 16.7, -6.3) -> 目标 (9.4, 0, -22.5)
-   短边:   沿长轴(X)方向正对短边,相机/距离自动按模型横截面计算 */
-const REF_MEDIAN_Z = -22.46;         // 参考模型零件 Z 中位数(新坐标系)
-let modelMedianZ = REF_MEDIAN_Z;     // 当前模型零件 Z 中位数(加载后更新)
-const VIEWS = [
-  { label: '俯视',  camera: [2.0, 22.0, -3.2],  target: [2.0, 0.0, -22.0] },
-  { label: '角度A', camera: [5.4, 16.7, -6.3],  target: [9.4, 0.0, -22.5] },
-  { label: '短边',  axis: [1, 0, 0] },
-];
-
-/* 零件包围盒中心在指定轴上的中位数:对地板等少数大件不敏感,能稳定代表压缩机位置 */
-function computeMedian(group, axis = 2) {
-  const vals = [];
-  group.traverse((o) => {
-    if (!o.isMesh || !o.geometry) return;
-    if (!o.geometry.boundingBox) o.geometry.computeBoundingBox();
-    const c = new THREE.Vector3();
-    o.geometry.boundingBox.getCenter(c).applyMatrix4(o.matrixWorld);
-    vals.push(c.getComponent(axis));
-  });
-  if (!vals.length) return 0;
-  vals.sort((a, b) => a - b);
-  return vals[Math.floor(vals.length / 2)];
-}
-
-function updateCameraAspect() {
-  const w = viewport.clientWidth, h = viewport.clientHeight;
-  if (!w || !h) return;
-  camera.aspect = w / h;
-  camera.updateProjectionMatrix();
-  renderer.setSize(w, h);
-  composer.setSize(w, h);
-  outline.setSize(w, h);
-  if (trackball) trackball.handleResize();
-}
-window.addEventListener('resize', updateCameraAspect);
-
-/* ------------------------------------------------------------------ */
-/* 模型加载                                                            */
-/* ------------------------------------------------------------------ */
-async function loadModel(name) {
-  if (!isValidModelFile(name)) return;
-  stopCycle();
-  clearSelection();
-  clearRendered();
-  parts.length = 0;
-  prevSelected = new Set();
-  if (modelGroup) scene.remove(modelGroup);
-  $('loading').style.display = 'flex';
-  $('loading').textContent = '模型加载中…';
-  $('partList').innerHTML = '';
-  modelName = name;
-  try {
-    const gltf = await new Promise((resolve, reject) => {
-      loader.load(
-        '/models/' + encodeURIComponent(name),
-        resolve,
-        (ev) => {
-          if (ev.lengthComputable) {
-            const mb = (n) => (n / 1048576).toFixed(1);
-            $('loading').textContent =
-              `模型加载中… ${mb(ev.loaded)} / ${mb(ev.total)} MB (${Math.round(ev.loaded / ev.total * 100)}%)`;
-          } else {
-            $('loading').textContent = `模型加载中… ${(ev.loaded / 1048576).toFixed(1)} MB`;
-          }
-        },
-        reject
-      );
-    });
-    $('loading').textContent = '正在构建部件…';
-    modelGroup = gltf.scene;
-    /* 压缩机模型是 CAD 的 Z 轴朝上,转到 three.js 的 Y 轴朝上,让底板水平 */
-    const usePreset = /^压缩机2326/.test(name);
-    if (usePreset) modelGroup.rotation.x = -Math.PI / 2;
-    log('模型解析完成');
-    let t0 = performance.now();
-    const meshCount = countMeshNodes(modelGroup);
-    largeMode = meshCount > LARGE_THRESHOLD;
-    log('网格节点数', meshCount, `(${(performance.now() - t0).toFixed(0)}ms)`);
-    if (largeMode) {
-      $('loading').textContent = `大模型模式:正在合并 ${meshCount} 个部件…`;
-      scene.add(modelGroup);
-      modelGroup.visible = false;      // 原始网格不渲染,改用实例化批量渲染
-      t0 = performance.now();
-      collectPartsInstanced(modelGroup);
-      log('实例化构建完成', parts.length, '部件', instancedMeshes.length, '个批次',
-          `(${(performance.now() - t0).toFixed(0)}ms)`);
-    } else {
-      scene.add(modelGroup);
-      collectParts(modelGroup);
-    }
-    await applySavedState();
-    t0 = performance.now();
-    fitCamera(modelGroup, 1.2);
-    log('取景完成', `(${(performance.now() - t0).toFixed(0)}ms)`);
-    /* 视角预设只对"压缩机2326"开头的模型生效 */
-    $('viewBtns').style.display = usePreset ? 'flex' : 'none';
-    if (usePreset) {
-      modelGroup.updateMatrixWorld(true);
-      modelMedianZ = computeMedian(modelGroup, 2);
-      log('零件Z中位数', modelMedianZ.toFixed(2));
-      activateView(0);   // 加载/切换模型后默认回到"俯视"
-    } else {
-      viewButtonEls.forEach((b) => b.classList.remove('active'));
-    }
-    refreshRows();
-    log('全部就绪');
-    /* 压缩机…模块2:对指定部件做时序着色演示 */
-    if (/^压缩机2326/.test(name) && /模块2/.test(name)) {
-      const target = parts.find((p) => p.label === '三维立体_1687' || p.name === '三维立体_1687');
-      if (target) startCycle(target);
-      else toast('未找到部件 三维立体_1687');
-    }
-    if (DEBUG) {
-      setTimeout(() => {
-        const r = renderer.domElement.getBoundingClientRect();
-        const p = pickPart(r.left + r.width / 2, r.top + r.height / 2);
-        log('中心拾取测试:', p ? p.label : '未命中');
-        log(`模型中心 (${modelCenter.x.toFixed(2)}, ${modelCenter.y.toFixed(2)}, ${modelCenter.z.toFixed(2)})`);
-        if (usePreset) {
-          VIEWS.forEach((v, i) => {
-            activateView(i);
-            const p2 = camera.position, t2 = controls.target;
-            log(`视角「${v.label}」相机 (${p2.x.toFixed(1)}, ${p2.y.toFixed(1)}, ${p2.z.toFixed(1)}) ` +
-                `目标 (${t2.x.toFixed(1)}, ${t2.y.toFixed(1)}, ${t2.z.toFixed(1)})`);
-          });
-          activateView(0);
-        }
-      }, 800);
-    }
-    $('loading').style.display = 'none';
-  } catch (err) {
-    console.error(err);
-    $('loading').innerHTML = '模型加载失败:' + err.message;
-    setTimeout(() => { $('loading').style.display = 'none'; }, 4000);
-  }
-}
-
-/* ------------------------------------------------------------------ */
-/* 配色状态持久化:GET/POST /api/state?model=xx                        */
-/* ------------------------------------------------------------------ */
-async function applySavedState() {
-  try {
-    const res = await fetch('/api/state?model=' + encodeURIComponent(modelName));
-    const state = await res.json();
-    parts.forEach((p) => {
-      const s = state[p.id];
-      if (s && s.color) setPartColor(p, s.color, s.status || null);
-    });
-  } catch (e) { console.warn('读取配色失败', e); }
-}
-function scheduleSave() {
-  clearTimeout(saveTimer);
-  $('saveStatus').textContent = '保存中…';
-  saveTimer = setTimeout(saveState, 400);
-}
-async function saveState() {
-  if (!modelName) return;
-  const state = {};
-  parts.forEach((p) => {
-    if (p.colorHex) state[p.id] = { color: p.colorHex, status: p.status };
-  });
-  try {
-    const res = await fetch('/api/state?model=' + encodeURIComponent(modelName), {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify(state),
-    });
-    if (res.ok) $('saveStatus').textContent = '✓ 已保存 ' + new Date().toLocaleTimeString('zh-CN', { hour12: false });
-    else $('saveStatus').textContent = '保存失败';
-  } catch (e) {
-    $('saveStatus').textContent = '保存失败';
-  }
-}
-
-/* ------------------------------------------------------------------ */
-/* 简易提示                                                            */
-/* ------------------------------------------------------------------ */
-let toastTimer = null;
-function toast(msg) {
-  const t = $('saveStatus');
-  t.textContent = msg;
-  clearTimeout(toastTimer);
-  toastTimer = setTimeout(() => { t.textContent = ''; }, 2500);
-}
-
-/* ------------------------------------------------------------------ */
-/* 模型下拉                                                            */
-/* ------------------------------------------------------------------ */
-async function loadModelList() {
-  try {
-    const res = await fetch('/api/models');
-    const names = await res.json();
-    const sel = $('modelSelect');
-    sel.innerHTML = '';
-    (names.filter(isValidModelFile)).forEach((n) => {
-      const o = document.createElement('option');
-      o.value = n; o.textContent = n;
-      sel.appendChild(o);
-    });
-    sel.addEventListener('change', () => loadModel(sel.value));
-    const wanted = new URLSearchParams(location.search).get('model');
-    if (wanted && names.includes(wanted)) sel.value = wanted;
-    if (sel.options.length) await loadModel(sel.value);
-    else $('loading').innerHTML = 'models 目录中没有可用的 .glb 模型';
-  } catch (e) { console.error(e); }
-}
-
-/* ------------------------------------------------------------------ */
-/* UI 事件绑定                                                         */
-/* ------------------------------------------------------------------ */
-function initUI() {
-  /* 部件状态控制:正常 / 预警 / 故障 / 恢复 */
-  const presetRow = $('presetRow');
-  PRESETS.forEach((s) => {
-    const btn = document.createElement('button');
-    btn.className = 'preset-btn';
-    btn.innerHTML = `<span class="dot" style="background:${s.color}"></span>${s.label}`;
-    btn.title = `将选中部件设为「${s.label}」`;
-    btn.addEventListener('click', () => applyToSelection(s.color, s.key));
-    presetRow.appendChild(btn);
-  });
-  const resetBtn = document.createElement('button');
-  resetBtn.className = 'preset-btn';
-  resetBtn.textContent = '恢复';
-  resetBtn.title = '恢复选中部件的默认颜色';
-  resetBtn.addEventListener('click', resetSelected);
-  presetRow.appendChild(resetBtn);
-
-  $('resetAll').addEventListener('click', resetAllParts);
-
-  /* 部件查询弹窗 */
-  const modal = $('partModal');
-  const openModal = () => {
-    modal.classList.add('show');
-    refreshRows();
-    const s = $('search');
-    if (s) { s.value = ''; s.focus(); }
-  };
-  const closeModal = () => modal.classList.remove('show');
-  $('openParts').addEventListener('click', openModal);
-  $('closeParts').addEventListener('click', closeModal);
-  modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
-  window.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
-  $('search').addEventListener('input', refreshRows);
-
-  /* 视角按钮 */
-  const viewBtns = $('viewBtns');
-  viewButtonEls = [];
-  VIEWS.forEach((v, i) => {
-    const b = document.createElement('button');
-    b.className = 'view-btn';
-    b.textContent = v.label;
-    b.title = v.axis
-      ? `正对 ${v.axis.join('')} 轴端面`
-      : `相机 (${v.camera.join(', ')}) → 目标 (${v.target.join(', ')})`;
-    b.addEventListener('click', () => activateView(i));
-    viewBtns.appendChild(b);
-    viewButtonEls.push(b);
-  });
-
-  /* 轨道 / 自由 旋转切换 */
-  const rotBtn = $('rotModeBtn');
-  rotBtn.addEventListener('click', () => {
-    setFreeMode(!freeMode);
-    rotBtn.textContent = freeMode ? '轨道旋转' : '自由旋转';
-    rotBtn.classList.toggle('active', freeMode);
-  });
-}
-
-/* ------------------------------------------------------------------ */
-/* 动画循环                                                            */
-/* ------------------------------------------------------------------ */
-const QS = new URLSearchParams(location.search);
-const DEBUG = QS.has('debug');
-const NO_RENDER = QS.has('norender');
-function log(...a) { if (DEBUG) console.log('[3d]', ...a); }
-
-function animate() {
-  requestAnimationFrame(animate);
-  controls.update();
-  composer.render();
-}
-if (!NO_RENDER) animate();
-
-/* ------------------------------------------------------------------ */
-initUI();
-loadModelList();
-updateCameraAspect();

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 53044
3d/static/js/three.module.js


3d/remove_nodes.py → 3d/tools/remove_nodes.py


3d/scale_part.py → 3d/tools/scale_part.py


3d/split_glb.py → 3d/tools/split_glb.py


+ 19 - 0
README.md

@@ -50,6 +50,25 @@ npm run dev
 npm run build
 ```
 
+## 3D 部件查看模块
+
+`3d/` 为独立的 3D 部件查看与配色模块(FastAPI 服务端 + Ant Design Vue 客户端):
+
+```bash
+python3 -m pip install -r 3d/backend/requirements.txt
+cd 3d/frontend && npm install
+
+# 开发:服务端 + 客户端(Vite 代理 /api、/models 到 8000)
+python3 3d/backend/run.py --reload
+cd 3d/frontend && npm run dev            # http://127.0.0.1:5173
+
+# 生产:构建后由 FastAPI 单端口托管
+cd 3d/frontend && npm run build
+python3 3d/backend/run.py                # http://127.0.0.1:8000
+```
+
+详见 `3d/README.md`。
+
 ## 主要接口
 
 - `POST /api/login` —— 登录,返回 token(账号/密码见下)