Browse Source

画卷功能改造

wangfumin 2 months ago
parent
commit
6cdeff9972

+ 741 - 0
src/view/gaPhoto/index.vue

@@ -0,0 +1,741 @@
+<template>
+  <div class="ga-photo">
+    <div class="new-header">
+      <div class="left-title">
+        <el-icon class="back-icon" @click="$emit('back')">
+          <ArrowLeft />
+        </el-icon>
+        <span class="edit-title">编辑</span>
+      </div>
+      <div class="right-title" >
+        <el-button type="primary" @click="$emit('save')">保存</el-button>
+      </div>
+    </div>
+    <!-- 下面的内容区域 -->
+  <div class="content">
+      <div class="left-img-list">
+        <div class="img-grid">
+          <div
+            class="img-item"
+            v-for="item in imgList"
+            :key="item.id"
+            draggable="true"
+            @dragstart="onDragStart(item, $event)"
+          >
+            <img class="thumb" :src="item.imgUrl" alt="image" />
+            <span
+              class="check-btn"
+              :class="{ checked: selectedIds.includes(item.id) }"
+              @click.stop="toggleSelect(item.id)"
+            ></span>
+          </div>
+        </div>
+      </div>
+      <div class="right-canvas-box">
+        <canvas id="photo-canvas" @dragover.prevent @drop="onCanvasDrop" @click="onCanvasClick" @mousemove="onCanvasMouseMove" @mouseleave="onCanvasMouseLeave"></canvas>
+        <!-- 悬浮删除按钮:选中图片后显示在其上方 -->
+        <div
+          v-if="showDeleteBtn"
+          class="canvas-delete-btn"
+          :style="{ left: deleteBtnPos.x + 'px', top: deleteBtnPos.y + 'px' }"
+          @click.stop="deleteSelected"
+        >
+          <el-icon><Delete /></el-icon>
+        </div>
+        <!-- 悬浮边缘线:鼠标靠近页间或两端边缘时显示 -->
+        <div
+          v-if="showInsertBtn"
+          class="insert-edge-line"
+          :style="{ left: insertEdgeLineStyle.left + 'px', top: insertEdgeLineStyle.top + 'px', height: insertEdgeLineStyle.height + 'px' }"
+        ></div>
+        <!-- 插入页加号:鼠标靠近页间或两端边缘时显示 -->
+        <div
+          v-if="showInsertBtn"
+          class="canvas-insert-btn"
+          :style="{ left: insertBtnPos.x + 'px', top: insertBtnPos.y + 'px' }"
+          @click.stop="insertPageAtTarget"
+        >
+          <el-tooltip content="插入页" placement="top">
+            <el-icon><Plus /></el-icon>
+          </el-tooltip>
+        </div>
+        <!-- 页级操作工具条:选中某一页时显示布局选择和删除整页 -->
+        <div
+          v-if="showPageToolbar"
+          class="page-toolbar"
+          :style="{ left: pageToolbarPos.x + 'px', top: pageToolbarPos.y + 'px' }"
+        >
+          <el-dropdown @command="onLayoutCommand">
+            <span class="el-dropdown-link">
+              排版
+            </span>
+            <template #dropdown>
+              <el-dropdown-menu>
+                <el-dropdown-item command="two">一页两张</el-dropdown-item>
+                <el-dropdown-item command="h1">一张横版</el-dropdown-item>
+                <el-dropdown-item command="v1">一张竖版</el-dropdown-item>
+              </el-dropdown-menu>
+            </template>
+          </el-dropdown>
+          <span class="toolbar-divider"></span>
+          <el-button size="small" type="danger" plain @click="deleteCurrentPage">删除当前页</el-button>
+        </div>
+      </div>
+     </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted, onUnmounted } from "vue";
+import * as THREE from "three";
+import { createPhotoControl } from "@/view/gaPhoto/photo/photoControl.js";
+import { useRoute, useRouter } from 'vue-router';
+import { RouteName, router } from "@/router";
+import { ElMessage } from "element-plus";
+import { Delete, ArrowLeft, Plus } from "@element-plus/icons-vue";
+// 使用 gaPhoto 专用的 Scene(包含 #photo-canvas 尺寸适配等)
+import Scene from "@/view/gaPhoto/photo/Scene.js";
+const props = defineProps<{
+  caseId: number;
+}>();
+const route = useRoute();
+let scene = null;
+const vueRouter = useRouter();
+const imgList = [
+    {
+        "id": 429,
+        "caseId": 412,
+        "imgUrl": "https://4dkk.4dage.com/fusion/test/file/797098a37e0c4588ae88f2ec4b1f12df.png",
+        "imgInfo": "111111",
+        "status": 0,
+        "sort": 0,
+        "type": 0,
+        "createTime": "2025-12-01T08:23:42.000+00:00",
+        "updateTime": "2025-12-01T08:23:42.000+00:00",
+        "parentId": null
+    },
+    {
+        "id": 430,
+        "caseId": 412,
+        "imgUrl": "https://4dkk.4dage.com/fusion/test/file/3458e2ad52364c10bce1d37c74f5fcad.jpg",
+        "imgInfo": "ae49f570f600ecf874bcec091ea033a9",
+        "status": 0,
+        "sort": 0,
+        "type": 0,
+        "createTime": "2025-12-01T08:23:42.000+00:00",
+        "updateTime": "2025-12-01T08:23:42.000+00:00",
+        "parentId": null
+    },
+    {
+        "id": 431,
+        "caseId": 412,
+        "imgUrl": "https://4dkk.4dage.com/fusion/test/file/e2d02787524c4fbebcd906b2bd61494b.jpg",
+        "imgInfo": "ffd16e213c0cbe7693acb36030fc50e9",
+        "status": 0,
+        "sort": 0,
+        "type": 0,
+        "createTime": "2025-12-01T08:23:42.000+00:00",
+        "updateTime": "2025-12-01T08:23:42.000+00:00",
+        "parentId": null
+    },
+    {
+        "id": 432,
+        "caseId": 412,
+        "imgUrl": "https://4dkk.4dage.com/fusion/test/file/46400cc493e34529ab69ee8febfeeecb.JPG",
+        "imgInfo": "IMG_9355.PNG - 副本",
+        "status": 0,
+        "sort": 0,
+        "type": 0,
+        "createTime": "2025-12-01T08:23:42.000+00:00",
+        "updateTime": "2025-12-01T08:23:42.000+00:00",
+        "parentId": null
+    },
+    {
+        "id": 433,
+        "caseId": 412,
+        "imgUrl": "https://4dkk.4dage.com/fusion/test/file/0c3c65ece31a4375a232d66f9e19cac9.JPG",
+        "imgInfo": "IMG_9355.PNG",
+        "status": 0,
+        "sort": 0,
+        "type": 0,
+        "createTime": "2025-12-01T08:23:42.000+00:00",
+        "updateTime": "2025-12-01T08:23:42.000+00:00",
+        "parentId": null
+    }
+]
+
+// 生成一个带有文字的占位图片(dataURL),作为空白图片框
+const createPlaceholder = (text: string) => {
+  const canvas = document.createElement("canvas");
+  // 使用较高分辨率,提升在 three 中的清晰度
+  const size = 1024;
+  canvas.width = size;
+  canvas.height = size;
+  const ctx = canvas.getContext("2d")!;
+  // 背景浅灰
+  ctx.fillStyle = "#f2f2f2";
+  ctx.fillRect(0, 0, size, size);
+  // 居中标题文字
+  ctx.fillStyle = "#808080";
+  ctx.font = `${Math.floor(size * 0.08)}px Arial`;
+  ctx.textAlign = "center";
+  ctx.textBaseline = "middle";
+  ctx.fillText(text, size / 2, size / 2);
+  return canvas.toDataURL("image/png");
+};
+
+// paperList:每页一个数组,数组内为图片项(四页,每页两个槽位,共8个)
+// 默认使用占位图和默认说明文字,方便后续删除时进行跨页补位
+const paperList = Array.from({ length: 4 }, (_, pIdx) =>
+  Array.from({ length: 2 }, (_, sIdx) => ({
+    id: pIdx * 2 + sIdx + 1,
+    imgUrl: createPlaceholder("现场照片"),
+    imgInfo: "说明文字",
+  }))
+);
+const renderCanvas = () => {
+  const canvas = document.getElementById("photo-canvas");
+  scene = new Scene(canvas);
+  scene.init();
+  window.scene = scene;
+  // 加载布局(type = 1:横排),将占位数据渲染到画布
+  scene.load(paperList, pageTypes.value);
+};
+onMounted(() => {
+  renderCanvas();
+  // 集成照片控制器
+  photoCtl.value = createPhotoControl(scene, updatePaperImageById, {
+    getPaperList: () => paperList,
+    setSelectedObj: (obj: any) => { selectedObj.value = obj },
+    setSelectedSlotId: (id: number | null) => { selectedSlotId.value = id },
+    clearSelection,
+    updateDeleteBtnPosition,
+    showPageToolbar: (show: boolean) => { showPageToolbar.value = show },
+    highlightPageBorder,
+    clearPageBorder,
+    setSelectedPageIndex: (idx: number | null) => { selectedPageIndex.value = idx },
+    updatePageToolbarPosition,
+  });
+  // 绑定删除快捷键
+  window.addEventListener('keydown', onKeyDown);
+});
+onUnmounted(() => {
+  window.removeEventListener('keydown', onKeyDown);
+});
+
+const emit = defineEmits<{
+  save: [],
+  back: []
+}>()
+
+// 选择逻辑
+const selectedIds = ref<number[]>([])
+const toggleSelect = (id: number) => {
+  const idx = selectedIds.value.indexOf(id)
+  if (idx > -1) {
+    selectedIds.value.splice(idx, 1)
+  } else {
+    selectedIds.value.push(id)
+  }
+}
+
+// 照片控制器集成:对外暴露轻量包装方法,逻辑在 photoControl.js
+const photoCtl = ref<any>(null)
+const onDragStart = (item: any, e: DragEvent) => photoCtl.value?.onDragStart(item, e)
+const onCanvasDrop = (e: DragEvent) => photoCtl.value?.onCanvasDrop(e)
+
+// 替换占位数据:根据 slot id 更新 paperList 中对应项的 imgUrl/imgInfo
+const updatePaperImageById = (id: number, url: string, info?: string) => {
+  for (const page of paperList) {
+    for (const it of page) {
+      if (it.id === id) {
+        it.imgUrl = url
+        if (info) it.imgInfo = info
+        return
+      }
+    }
+  }
+}
+
+// 画布点击选中:先判断是否命中页平面,命中则展示页工具条;否则计算与 imgList 的射线相交并显示删除按钮
+const showDeleteBtn = ref(false)
+const deleteBtnPos = ref({ x: 0, y: 0 })
+const selectedObj = ref<any>(null) // three 对象(ImgLabelBox)
+const selectedSlotId = ref<number | null>(null) // 槽位 id(与 userData 对应)
+// 页选择与工具条
+const selectedPageIndex = ref<number | null>(null)
+const showPageToolbar = ref(false)
+const pageToolbarPos = ref({ x: 0, y: 0 })
+// 每页布局类型:1=两张横版(HorizontalBox),2=一张竖版(VerticalBox),3=一张横版(HorizontalBox,单张)
+const pageTypes = ref<number[]>(Array.from({ length: paperList.length }, () => 1))
+
+const onCanvasClick = (e: MouseEvent) => photoCtl.value?.onCanvasClick(e)
+
+const updateDeleteBtnPosition = () => {
+  if (!selectedObj.value || !scene) return
+  const obj = selectedObj.value
+  const bbox = new THREE.Box3().setFromObject(obj)
+  const center = bbox.getCenter(new THREE.Vector3())
+  const size = bbox.getSize(new THREE.Vector3())
+  const topCenter = center.clone()
+  // Z 轴向上为负值,顶部应取 minZ(center - size/2)并稍微上移
+  topCenter.z = center.z - size.z / 2 - 0.1
+  // 投影到屏幕坐标
+  topCenter.project(scene.orthCamera)
+  const canvasRect = scene.domElement.getBoundingClientRect()
+  const containerEl = document.querySelector('.right-canvas-box') as HTMLElement
+  const containerRect = containerEl?.getBoundingClientRect() || { left: 0, top: 0, width: canvasRect.width, height: canvasRect.height }
+  const px = ((topCenter.x + 1) / 2) * canvasRect.width + (canvasRect.left - containerRect.left)
+  const py = ((1 - (topCenter.y + 1) / 2) * canvasRect.height) + (canvasRect.top - containerRect.top) - 40 // 往上偏移一点
+  deleteBtnPos.value = { x: Math.round(px), y: Math.round(py) }
+  showDeleteBtn.value = true
+}
+
+// 页工具条位置(页包围盒顶部居中)
+const updatePageToolbarPosition = (pageGroup?: any) => {
+  if (!scene) return
+  const obj = pageGroup || (selectedPageIndex.value != null ? scene.boxManager?.model?.children?.[selectedPageIndex.value] : null)
+  if (!obj) return
+  const bbox = new THREE.Box3().setFromObject(obj)
+  const center = bbox.getCenter(new THREE.Vector3())
+  const size = bbox.getSize(new THREE.Vector3())
+  const topCenter = center.clone()
+  topCenter.z = center.z - size.z / 2 - 0.1
+  topCenter.project(scene.orthCamera)
+  const canvasRect = scene.domElement.getBoundingClientRect()
+  const containerEl = document.querySelector('.right-canvas-box') as HTMLElement
+  const containerRect = containerEl?.getBoundingClientRect() || { left: 0, top: 0, width: canvasRect.width, height: canvasRect.height }
+  const px = ((topCenter.x + 1) / 2) * canvasRect.width + (canvasRect.left - containerRect.left)
+  const py = ((1 - (topCenter.y + 1) / 2) * canvasRect.height) + (canvasRect.top - containerRect.top) - 40
+  pageToolbarPos.value = { x: Math.round(px), y: Math.round(py) }
+}
+
+const clearSelection = () => {
+  selectedObj.value = null
+  selectedSlotId.value = null
+  showDeleteBtn.value = false
+  showInsertBtn.value = false
+  // 隐藏所有边框
+  scene?.boxManager?.imgList?.forEach((img: any) => {
+    img.touchLines?.children?.forEach((line: any) => (line.visible = false))
+  })
+}
+
+// 页选中边框控制
+const highlightPageBorder = (index: number | null) => {
+  if (index == null || !scene?.boxManager?.pageEdgeList) return
+  scene.boxManager.pageEdgeList.forEach((group: any, i: number) => {
+    group?.children?.forEach((line: any) => (line.visible = i === index))
+  })
+}
+const clearPageBorder = () => {
+  scene?.boxManager?.pageEdgeList?.forEach((group: any) => {
+    group?.children?.forEach((line: any) => (line.visible = false))
+  })
+}
+
+// 删除:按钮点击或 Delete 键
+const deleteSelected = () => {
+  if (selectedSlotId.value == null) return
+  deleteSlotContentById(selectedSlotId.value)
+  clearSelection()
+}
+
+const onKeyDown = (e: KeyboardEvent) => {
+  if (e.key === 'Delete') {
+    deleteSelected()
+  }
+}
+
+// 页布局切换命令
+const onLayoutCommand = (cmd: string) => {
+  if (selectedPageIndex.value == null) return
+  const p = selectedPageIndex.value
+  if (cmd === 'two') pageTypes.value[p] = 1
+  if (cmd === 'v1') pageTypes.value[p] = 2
+  if (cmd === 'h1') pageTypes.value[p] = 3
+
+  // 根据布局类型调整该页的槽位数量(two:2,h1/v1:1)
+  const targetLen = pageTypes.value[p] === 1 ? 2 : 1
+  const cur = paperList[p]
+  if (cur.length > targetLen) {
+    cur.splice(targetLen) // 截断多余槽位
+  } else if (cur.length < targetLen) {
+    const need = targetLen - cur.length
+    for (let i = 0; i < need; i++) {
+      cur.push({ id: getNewSlotId(), imgUrl: createPlaceholder('现场照片'), imgInfo: '说明文字' })
+    }
+  }
+
+  // 重新渲染(支持每页独立布局)
+  scene.load(paperList, pageTypes.value)
+  updatePageToolbarPosition()
+  highlightPageBorder(selectedPageIndex.value)
+}
+
+// 删除当前页
+const deleteCurrentPage = () => {
+  if (selectedPageIndex.value == null) return
+  const p = selectedPageIndex.value
+  paperList.splice(p, 1)
+  pageTypes.value.splice(p, 1)
+  // 重新编排 id,保证唯一且顺序
+  let idCounter = 1
+  for (let pi = 0; pi < paperList.length; pi++) {
+    for (let si = 0; si < paperList[pi].length; si++) {
+      paperList[pi][si].id = idCounter++
+    }
+  }
+  selectedPageIndex.value = null
+  showPageToolbar.value = false
+  clearPageBorder()
+  scene.load(paperList, pageTypes.value)
+}
+
+// 获取一个新的槽位 id(末尾+1)
+const getNewSlotId = () => {
+  let maxId = 0
+  for (const page of paperList) {
+    for (const it of page) {
+      if (it.id > maxId) maxId = it.id
+    }
+  }
+  return maxId + 1
+}
+
+// 删除并自动补位:从当前槽位开始将后续槽位的内容前移,最后一个置为空白
+const deleteSlotContentById = (slotId: number) => {
+  const perPage = paperList[0]?.length || 2
+  // 找到槽位位置
+  let pageIndex = -1
+  let slotIndex = -1
+  for (let p = 0; p < paperList.length; p++) {
+    for (let s = 0; s < paperList[p].length; s++) {
+      if (paperList[p][s].id === slotId) {
+        pageIndex = p
+        slotIndex = s
+        break
+      }
+    }
+    if (pageIndex !== -1) break
+  }
+  if (pageIndex === -1) return
+
+  // 前移内容
+  const getNext = (p: number, s: number) => {
+    const si = s + 1
+    let np = p, ns = si
+    if (si >= paperList[p].length) {
+      np = p + 1
+      ns = 0
+    }
+    if (np >= paperList.length) return null
+    return paperList[np][ns]
+  }
+
+  for (let p = pageIndex; p < paperList.length; p++) {
+    const startS = p === pageIndex ? slotIndex : 0
+    for (let s = startS; s < paperList[p].length; s++) {
+      const cur = paperList[p][s]
+      const next = getNext(p, s)
+      if (next) {
+        cur.imgUrl = next.imgUrl
+        cur.imgInfo = next.imgInfo
+      } else {
+        // 最后一个置空白
+        cur.imgUrl = createPlaceholder('现场照片')
+        cur.imgInfo = '说明文字'
+        p = paperList.length // 强制跳出外层循环
+        break
+      }
+    }
+  }
+
+  // 重新渲染
+  scene.load(paperList, pageTypes.value)
+}
+
+// ===== 插入页(边缘/页间悬停出现加号) =====
+const showInsertBtn = ref(false)
+const insertBtnPos = ref({ x: 0, y: 0 })
+const insertTargetIndex = ref<number | null>(null)
+const insertEdgeLineStyle = ref({ left: 0, top: 0, height: 0 })
+
+const onCanvasMouseLeave = () => {
+  showInsertBtn.value = false
+  // 隐藏页边缘高亮
+  scene?.boxManager?.pageEdgeList?.forEach((group: any) => {
+    group?.children?.forEach((line: any) => (line.visible = false))
+  })
+}
+
+const onCanvasMouseMove = (e: MouseEvent) => {
+  if (!scene || !scene.orthCamera || !scene.boxManager?.model?.children?.length) return
+
+  const canvas = scene.domElement
+  const rect = canvas.getBoundingClientRect()
+  const ndcX = ((e.clientX - rect.left) / canvas.clientWidth) * 2 - 1
+
+  // 构建可插入的边界集合:左边缘、页间中点、右边缘
+  const pages: any[] = scene.boxManager.model.children || []
+  const edges: { x:number; insertIndex:number; neighborIdx:number }[] = []
+  if (pages.length) {
+    const first = pages[0]
+    edges.push({ x: first.position.x - (first.width ?? 2) / 2, insertIndex: 0, neighborIdx: 0 })
+    for (let i = 0; i < pages.length - 1; i++) {
+      const mid = (pages[i].position.x + pages[i + 1].position.x) / 2
+      edges.push({ x: mid, insertIndex: i + 1, neighborIdx: i })
+    }
+    const last = pages[pages.length - 1]
+    edges.push({ x: last.position.x + (last.width ?? 2) / 2, insertIndex: pages.length, neighborIdx: pages.length - 1 })
+  }
+
+  // 将边界x投影到NDC,挑选与鼠标最近且在阈值内的一个
+  const threshold = 0.03
+  let bestEdge: { x:number; insertIndex:number; neighborIdx:number } | null = null
+  let bestDx = Infinity
+  for (const edge of edges) {
+    const v = new THREE.Vector3(edge.x, 0, 0)
+    v.project(scene.orthCamera)
+    const dx = Math.abs(v.x - ndcX)
+    if (dx < threshold && dx < bestDx) {
+      bestEdge = edge
+      bestDx = dx
+    }
+  }
+
+  if (!bestEdge) {
+    showInsertBtn.value = false
+    return
+  }
+  // 进入插入态,恢复图片UI:隐藏删除按钮、页工具条
+  showDeleteBtn.value = false
+  showPageToolbar.value = false
+
+  // 计算加号的屏幕位置:使用邻近页的顶部中心y/z,替换x为边界x
+  const neighbor = pages[bestEdge.neighborIdx]
+  const bbox = new THREE.Box3().setFromObject(neighbor)
+  const center = bbox.getCenter(new THREE.Vector3())
+  const size = bbox.getSize(new THREE.Vector3())
+  const topCenter = center.clone()
+  topCenter.z = center.z - size.z / 2 - 0.1
+  topCenter.x = bestEdge.x
+  topCenter.project(scene.orthCamera)
+  const containerEl = document.querySelector('.right-canvas-box') as HTMLElement
+  const canvasRect = scene.domElement.getBoundingClientRect()
+  const containerRect = containerEl?.getBoundingClientRect() || { left: 0, top: 0, width: canvasRect.width, height: canvasRect.height }
+  const px = ((topCenter.x + 1) / 2) * canvasRect.width + (canvasRect.left - containerRect.left)
+  insertTargetIndex.value = bestEdge.insertIndex
+  showInsertBtn.value = true
+  // 计算垂直边缘线的屏幕位置与高度(对齐邻近页的顶部、底部)
+  const bottomCenter = center.clone()
+  bottomCenter.z = center.z + size.z / 2 + 0.1
+  bottomCenter.x = bestEdge.x
+  bottomCenter.project(scene.orthCamera)
+  const topPx = ((topCenter.y + 1) / 2) * canvasRect.height + (canvasRect.top - containerRect.top)
+  const bottomPx = ((bottomCenter.y + 1) / 2) * canvasRect.height + (canvasRect.top - containerRect.top)
+  insertEdgeLineStyle.value = { left: Math.round(px), top: Math.round(topPx), height: Math.max(0, Math.round(bottomPx - topPx)) }
+  const plusSize = 32
+  const gap = 8
+  const py = Math.round(topPx - plusSize - gap)
+  insertBtnPos.value = { x: Math.round(px), y: py }
+  // 边缘附近页的边框轻微高亮(左右各一页,首尾仅一页)
+  const leftIdx = Math.max(0, Math.min(pages.length - 1, bestEdge.insertIndex - 1))
+  const rightIdx = Math.max(0, Math.min(pages.length - 1, bestEdge.insertIndex))
+  scene?.boxManager?.pageEdgeList?.forEach((group: any, i: number) => {
+    const show = bestEdge.insertIndex === 0 ? i === rightIdx
+      : bestEdge.insertIndex === pages.length ? i === leftIdx
+      : (i === leftIdx || i === rightIdx)
+    group?.children?.forEach((line: any) => (line.visible = !!show))
+  })
+}
+
+const insertPageAtTarget = () => {
+  if (insertTargetIndex.value == null) return
+  const idx = insertTargetIndex.value
+  const newPage = [
+    { id: 0, imgUrl: createPlaceholder('现场照片'), imgInfo: '说明文字' },
+    { id: 0, imgUrl: createPlaceholder('现场照片'), imgInfo: '说明文字' },
+  ]
+  paperList.splice(idx, 0, newPage)
+  pageTypes.value.splice(idx, 0, 1)
+  // 重新编排 id,保证唯一且顺序
+  let idCounter = 1
+  for (let pi = 0; pi < paperList.length; pi++) {
+    for (let si = 0; si < paperList[pi].length; si++) {
+      paperList[pi][si].id = idCounter++
+    }
+  }
+  scene.load(paperList, pageTypes.value)
+  showInsertBtn.value = false
+}
+
+</script>
+
+<style lang="scss" scoped>
+.new-header{
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 24px 48px 24px;
+  box-sizing: border-box;
+  border-bottom: 1px solid #f5f5f5;
+  .left-title{
+    font-size: 32px;
+    color: #303133;
+    display: flex;
+    align-items: center;
+    .back-icon{
+      cursor: pointer;
+      margin-right: 16px;
+      font-size: 24px;
+      color: #909399;
+      &:hover{
+        color: #606266;
+      }
+    }
+    .edit-title{
+      margin-right: 16px;
+    }
+    .line{
+      margin-left: 16px;
+      color: rgba(0,0,0,0.1);
+    }
+  }
+  .change-btn{
+    margin-right: 26px;
+    .icon-rename{
+      font-size: 24px;
+    }
+  }
+  .right-title{
+    font-size: 32px;
+  }
+}
+.content{
+  height: calc(100vh - 100px);
+  display: flex;
+  align-items: center;
+  .left-img-list{
+    width: 510px;
+    height: 100%;
+    padding: 16px 16px 24px;
+    box-sizing: border-box;
+    overflow-y: auto;
+    background: #ffffff;
+    .img-grid{
+      display: grid;
+      grid-template-columns: repeat(3, 1fr);
+      grid-gap: 16px;
+    }
+    .img-item{
+      position: relative;
+      width: 100%;
+      background: #f7f7f7;
+      border-radius: 6px;
+      overflow: hidden;
+      border: 1px solid #ebeef5;
+    }
+    .thumb{
+      display: block;
+      width: 100%;
+      height: 140px;
+      object-fit: cover;
+    }
+    .check-btn{
+      position: absolute;
+      top: 8px;
+      left: 8px;
+      width: 24px;
+      height: 24px;
+      border-radius: 50%;
+      background: #ffffff;
+      box-shadow: 0 2px 6px rgba(0,0,0,0.15);
+      cursor: pointer;
+    }
+    /* 选中时在中间显示一个“✓” */
+    .check-btn.checked::after{
+      content: "✓";
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      font-size: 18px;
+      color: var(--el-color-primary);
+      line-height: 1;
+    }
+  }
+  .right-canvas-box{
+    position: relative;
+    width: calc(100% - 510px);
+    height: calc(100vh - 208px);
+    background: #f5f5f5;
+    padding: 64px 0 48px 64px;
+    // 让画布占满容器,便于 three 使用实际尺寸渲染
+    #photo-canvas{
+      width: 100%;
+      height: 100%;
+      display: block;
+      background: #ffffff;
+    }
+    .canvas-delete-btn{
+      position: absolute;
+      width: 36px;
+      height: 36px;
+      border-radius: 8px;
+      background: #ffffff;
+      box-shadow: 0 6px 18px rgba(0,0,0,0.12);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      cursor: pointer;
+      z-index: 10;
+      color: #606266;
+    }
+    .page-toolbar{
+      position: absolute;
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      padding: 6px 10px;
+      border-radius: 8px;
+      background: #ffffff;
+      box-shadow: 0 6px 18px rgba(0,0,0,0.12);
+      z-index: 12;
+    }
+    .canvas-insert-btn{
+      position: absolute;
+      width: 32px;
+      height: 32px;
+      border-radius: 16px;
+      background: #ffffff;
+      box-shadow: 0 6px 18px rgba(0,0,0,0.12);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      cursor: pointer;
+      z-index: 12;
+      color: #606266;
+      border: 1px solid #e5e7eb;
+    }
+    .insert-edge-line{
+      position: absolute;
+      width: 2px;
+      background: var(--el-color-primary);
+      box-shadow: 0 0 0 1px rgba(43,122,247,0.12);
+      z-index: 11;
+    }
+    .toolbar-divider{
+      width: 1px;
+      height: 18px;
+      background: #e5e7eb;
+      display: inline-block;
+    }
+    // .canvas-box{
+      //   height: 100%;
+      //   width: 100%;
+      //   background: #fff;
+      // }
+  }
+}
+</style>

+ 969 - 0
src/view/gaPhoto/photo/Player.js

@@ -0,0 +1,969 @@
+import * as THREE from "three";
+
+import FloorplanControls from "../controls/FloorplanControls.js";
+import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
+
+import { TrackballControls } from "three/examples/jsm/controls/TrackballControls.js";
+import Line from "../box/object/Line";
+import LinePoints from "../box/object/LinePoints.js";
+import Marker from "../box/object/marker.js";
+import CircleTextLabel from "../box/object/CircleTextLabel.js";
+import PureTextLabel from "../box/object/PureTextLabel.js";
+import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
+
+const convertScreenToNDC = function (event, domElement) {
+  let x = (event.offsetX / domElement.clientWidth) * 2 - 1;
+  let y = -(event.offsetY / domElement.clientHeight) * 2 + 1;
+  return new THREE.Vector2(x, y);
+};
+export default class Player {
+  constructor(scene) {
+    this.scene = scene;
+    this.orthCamera = scene.orthCamera;
+
+    this.floorplanControls = null;
+    this.raycaster = null;
+
+    this.position = new THREE.Vector3();
+
+    this.pointerdown = new THREE.Vector2();
+    this.pointerup = new THREE.Vector2();
+    this.pointer = new THREE.Vector2();
+    this.markPosition = new THREE.Vector3();
+
+    this.touchImg = null;
+    this.activeEdge = null;
+    this.drawLine = null;
+    this.startObj = null;
+    this.marker = null;
+    this.symbol = null;
+    this.symbolIndex = 0;
+    this.text = null;
+    this.showText = "文本";
+    this.selectItem = null;
+
+    this.drawing = false;
+    this.inited = false;
+    this.renderLines = [];
+    this.renderMarkers = [];
+    this.activeEdges = [];
+    this.renderSymbols = [];
+    this.renderTexts = [];
+
+    this.matLine = null;
+    this.lineColor = 0xe44d54;
+    // 1是画线,2是标方向, 3符号, 4文本
+    this.mode = 0;
+    this.init();
+  }
+
+  setMode(mode) {
+    this.mode = mode;
+
+    if (mode !== 0) {
+      this.reset();
+      this.setEditMode();
+    }
+    // 2方向
+    if (mode === 2) {
+      let pos = new THREE.Vector3(0, 0, -1);
+      pos.unproject(this.orthCamera);
+      pos.y = 5;
+
+      this.marker = new Marker(pos);
+      this.marker.visible = false;
+      this.scene.scene.add(this.marker);
+      this.drawing = true;
+    }
+    //符号
+    if (mode === 3) {
+      let pos = new THREE.Vector3(0, 0, -1);
+      pos.unproject(this.orthCamera);
+      pos.y = 5;
+      const lastIndex = this.getLaSybIndex();
+      this.symbolIndex = lastIndex + 1;
+
+      this.symbol = new CircleTextLabel(this.symbolIndex, pos);
+      this.symbol.visible = false;
+      this.scene.scene.add(this.symbol);
+      console.log("this.symbol", this.symbol);
+      this.drawing = true;
+    }
+
+    if (mode === 4) {
+      let pos = new THREE.Vector3(0, 0, -1);
+      pos.unproject(this.orthCamera);
+      pos.y = 5;
+      this.text = new PureTextLabel(this.showText, pos);
+      this.text.visible = false;
+      this.showText = "文本";
+      this.scene.scene.add(this.text);
+      this.drawing = true;
+    }
+
+    if (mode === 0) {
+      this.setFreeMode();
+    }
+    this.scene.emit("mode", this.mode);
+  }
+  getLaSybIndex() {
+    const maxIndexObject = this.renderSymbols.reduce(
+      (max, current) => {
+        return current.index > max.index ? current : max;
+      },
+      { index: 0, point: [] }
+    );
+    return maxIndexObject.index;
+  }
+
+  setFreeMode() {
+    this.floorplanControls.enablePan = true;
+    this.floorplanControls.mouseButtons = {
+      LEFT: THREE.MOUSE.PAN,
+      MIDDLE: THREE.MOUSE.DOLLY,
+      RIGHT: THREE.MOUSE.PAN,
+    };
+    this.reset();
+  }
+
+  setEditMode() {
+    this.floorplanControls.enablePan = true;
+    this.floorplanControls.mouseButtons = {
+      LEFT: THREE.MOUSE.ROTATE,
+      MIDDLE: THREE.MOUSE.DOLLY,
+      RIGHT: THREE.MOUSE.PAN,
+    };
+  }
+
+  setRotateMode() {
+    this.floorplanControls.enableRotate = true;
+    this.floorplanControls.mouseButtons = {
+      LEFT: THREE.MOUSE.ROTATE,
+      MIDDLE: THREE.MOUSE.DOLLY,
+      RIGHT: THREE.MOUSE.PAN,
+    };
+  }
+  init = () => {
+    // //floorplanControls
+    // this.floorplanControls = new FloorplanControls(
+    //   this.orthCamera,
+    //   this.scene.domElement,
+    //   this
+    // );
+    this.floorplanControls = new OrbitControls(
+      this.orthCamera,
+      this.scene.domElement
+    );
+
+    this.floorplanControls.enablePan = true;
+    // this.floorplanControls.target.set(0, 1, 0);
+    // this.floorplanControls.rotateSpeed = 0.5;
+    // this.floorplanControls.panSpeed = 0.75
+
+    this.floorplanControls.maxDistance = 100;
+    this.floorplanControls.minDistance = 3.5;
+    this.floorplanControls.maxZoom = 500;
+    this.floorplanControls.minZoom = 100;
+
+    // this.floorplanControls.mouseButtons = {
+    //   LEFT: THREE.MOUSE.PAN,
+    //   MIDDLE: THREE.MOUSE.DOLLY,
+    //   RIGHT: THREE.MOUSE.PAN
+    // }
+    this.setMode(0);
+    this.floorplanControls.enableRotate = false;
+    this.raycaster = new THREE.Raycaster();
+    this.onBindEvent();
+    this.inited = true;
+    this.matLine = new LineMaterial({
+      color: this.lineColor,
+      linewidth: 4, // in world units with size attenuation, pixels otherwise
+      dashed: false,
+      alphaToCoverage: true,
+    });
+    this.matLine.resolution = new THREE.Vector2(
+      this.scene.width,
+      this.scene.height
+    );
+  };
+
+  onPointerMove = (e) => {
+    // console.log("intersects", intersects);
+    // if (this.mode === 0) {
+    //   const intersects = this.raycaster.intersectObjects(
+    //     this.scene.scene.children,
+    //     true
+    //   );
+    //   intersects.forEach((i) => {
+    //     if (String(i.object.name).includes("marker")) {
+    //       // console.log("i.object.name", i.object);
+    //       // debugger
+    //     }
+    //   });
+    // }
+    this.pointermove = convertScreenToNDC(e, this.scene.domElement);
+    this.raycaster.setFromCamera(this.pointermove, this.orthCamera);
+
+    if (!this.drawing) return;
+
+    if (this.mode === 1) {
+      let intersectArr = this.scene.boxManager.imgList;
+      // if(this.startObj) {
+      //   let i = intersectArr.indexOf(this.startObj)
+      //   intersectArr.splice(i, 1)
+      // }
+      const intersects = this.raycaster.intersectObjects(intersectArr, false);
+      if (intersects[0] && intersects[0].object !== this.startObj) {
+        this.touchImg = intersects[0];
+        this.setActiveLine(this.touchImg);
+      }
+    }
+    if (this.mode === 2) {
+      if (this.marker) {
+        this.marker.visible = true;
+        let pos = new THREE.Vector3(this.pointermove.x, this.pointermove.y, -1);
+        pos.unproject(this.orthCamera);
+        pos.y = 5;
+        this.marker.position.copy(pos);
+      }
+    }
+
+    if (this.mode === 3) {
+      if (this.symbol) {
+        this.symbol.visible = true;
+        let pos = new THREE.Vector3(this.pointermove.x, this.pointermove.y, -1);
+        pos.unproject(this.orthCamera);
+        pos.y = 5;
+        const clamp = new THREE.Vector3();
+        this.scene.boxManager.obb.clampPoint(pos, clamp);
+        clamp.y = 5;
+        this.symbol.position.copy(clamp);
+      }
+    }
+
+    if (this.mode === 4) {
+      if (this.text) {
+        this.text.visible = true;
+        let pos = new THREE.Vector3(this.pointermove.x, this.pointermove.y, -1);
+        pos.unproject(this.orthCamera);
+        pos.y = 5;
+
+        const clamp = new THREE.Vector3();
+        this.scene.boxManager.obb.clampPoint(pos, clamp);
+        clamp.y = 5;
+        this.text.position.copy(clamp);
+        this.text.userData.pos = clamp.toArray();
+      }
+    }
+  };
+
+  onPointerDown = (e) => {
+    console.log("start draw");
+
+    this.pointerdown = convertScreenToNDC(e, this.scene.domElement);
+
+    if (this.mode === 0) {
+      const intersects = this.raycaster.intersectObjects(
+        this.scene.scene.children,
+        true
+      );
+      intersects.forEach((i) => {
+        if (
+          String(i.object.name).includes("marker") ||
+          String(i.object.name).includes("line") ||
+          String(i.object.name).includes("circle") ||
+          String(i.object.name).includes("pureText")
+        ) {
+          let type;
+          switch (true) {
+            case String(i.object.name).includes("marker"):
+              type = 1;
+              break;
+            case String(i.object.name).includes("line"):
+              type = 2;
+              break;
+            case String(i.object.name).includes("circle"):
+              type = 3;
+              break;
+            case String(i.object.name).includes("pureText"):
+              type = 4;
+              break;
+          }
+
+          this.selectItem = i.object;
+          this.scene.emit("confirmDelete", {
+            id: i.object.uuid,
+            type,
+          });
+        }
+      });
+    }
+
+    if (this.mode === 1) {
+      this.raycaster.setFromCamera(this.pointerdown, this.orthCamera);
+      let intersectArr = this.scene.boxManager.imgList;
+      const intersects = this.raycaster.intersectObjects(intersectArr, false);
+      console.log("intersects", intersects);
+      if (intersects[0]) {
+        this.startObj = intersects[0].object;
+        this.drawing = true;
+      } else {
+        this.startObj = null;
+        this.drawing = false;
+      }
+    }
+
+    if (this.mode === 2) {
+      if (this.marker) {
+        this.raycaster.setFromCamera(this.pointerdown, this.orthCamera);
+        let intersectArr = this.scene.boxManager.imgList;
+        const intersects = this.raycaster.intersectObjects(intersectArr, false);
+
+        if (intersects[0]) {
+          // this.drawing = false;
+          const imageId = intersects[0].object.userData;
+          let lasPos = new THREE.Vector3(
+            this.pointerdown.x,
+            this.pointerdown.y,
+            -1
+          );
+          lasPos.unproject(this.orthCamera);
+          lasPos.y = 5;
+          const marker = new Marker(lasPos, imageId);
+
+          const activeMarkeritem = {
+            id: imageId,
+            point: lasPos.toArray(),
+          };
+          const exist = this.renderMarkers.find((item) => item.id === imageId);
+
+          if (!exist) {
+            this.scene.scene.add(marker);
+            this.renderMarkers.push(activeMarkeritem);
+            this.scene.scene.remove(this.marker);
+            this.marker = null;
+          } else {
+            this.scene.emit("markerExist");
+          }
+
+          console.log("activeMarkeritem", activeMarkeritem);
+          this.setMode(0);
+        }
+      }
+    }
+
+    if (this.mode === 3) {
+      if (this.symbol) {
+        let lasPos = new THREE.Vector3(
+          this.pointerdown.x,
+          this.pointerdown.y,
+          -1
+        );
+        lasPos.unproject(this.orthCamera);
+        lasPos.y = 5;
+
+        const activeSymbolItem = {
+          index: this.symbolIndex,
+          point: lasPos.toArray(),
+        };
+        this.renderSymbols.push(activeSymbolItem);
+        console.log("activeSymbolItem", activeSymbolItem);
+        this.setMode(0);
+      }
+    }
+    if (this.mode === 4) {
+      if (this.text) {
+        let lasPos = new THREE.Vector3(
+          this.pointerdown.x,
+          this.pointerdown.y,
+          -1
+        );
+        this.scene.emit("edit", {
+          type: 4,
+          text: this.showText,
+          id: this.text.uuid,
+          ...this.text.userData,
+        });
+        this.drawing = true;
+        // const activeSymbolItem = {
+        //   id: this.symbolIndex,
+        //   point: lasPos.toArray(),
+        // };
+
+        // this.setMode(0);
+      }
+    }
+  };
+  onPointerUp = (e) => {
+    this.pointerup = convertScreenToNDC(e, this.scene.domElement);
+    // console.log("onPointerUp", this.pointerup);
+    if (this.mode === 1) {
+      this.drawing = false;
+      this.floorplanControls.enabled = true;
+
+      if (this.drawLine) {
+        const points = this.drawLine.userData.points;
+        const dir = this.drawLine.userData.dir;
+        const imageId = this.touchImg.object.userData;
+        const finishLine = new LinePoints(points, this.matLine, dir, imageId);
+        // console.log("startObj", this.startObj);
+        this.scene.scene.add(finishLine);
+        const activeLineItem = {
+          id: finishLine.uuid,
+          imgId: imageId,
+          startId: this.startObj.userData,
+          dir: dir,
+          points: points,
+        };
+        const activeEdgeItem = {
+          id: imageId,
+          startId: this.startObj.userData,
+          dir: [dir],
+        };
+
+        this.renderLines.push(activeLineItem);
+        // console.log("this.touchImg", activeLineItem, points);
+        this.insertActiveEdge(activeEdgeItem);
+        this.scene.scene.remove(this.drawLine);
+        this.drawLine = null;
+        this.startObj = null;
+      }
+    }
+    if (this.mode === 2) {
+      // this.drawing = false;
+    }
+    if (this.mode === 4) {
+      if (this.text) {
+        let pos = new THREE.Vector3(this.pointerup.x, this.pointerup.y, -1);
+        pos.unproject(this.orthCamera);
+        pos.y = 5;
+        const clamp = new THREE.Vector3();
+        this.scene.boxManager.obb.clampPoint(pos, clamp);
+        clamp.y = 5;
+        console.log("pos", pos);
+        console.log("clamp", clamp);
+        this.text.position.copy(clamp);
+        this.text.userData.pos = clamp.toArray();
+        const activeTextItem = this.text.userData;
+        this.drawing = false;
+        console.log("activeTextItem", activeTextItem);
+        this.insertrenderTexts(activeTextItem);
+      }
+    }
+    this.syncDrawData();
+  };
+
+  Listener = {
+    onPointerDown: this.onPointerDown.bind(this),
+    onPointerMove: this.onPointerMove.bind(this),
+    onPointerUp: this.onPointerUp.bind(this),
+  };
+
+  onBindEvent = () => {
+    this.scene.domElement.addEventListener(
+      "pointerdown",
+      this.Listener.onPointerDown
+    );
+    this.scene.domElement.addEventListener(
+      "pointermove",
+      this.Listener.onPointerMove,
+      false
+    );
+    this.scene.domElement.addEventListener(
+      "pointerup",
+      this.Listener.onPointerUp
+    );
+  };
+  unbindEvent = () => {
+    this.scene.domElement.removeEventListener(
+      "pointerdown",
+      this.Listener.onPointerDown
+    );
+    this.scene.domElement.removeEventListener(
+      "pointermove",
+      this.Listener.onPointerMove
+    );
+    this.scene.domElement.removeEventListener(
+      "pointerup",
+      this.Listener.onPointerUp
+    );
+  };
+
+  buildLine = () => {
+    if (this.drawLine) {
+      this.drawLine.removeFromParent();
+    }
+    let s = new THREE.Vector3(this.pointerdown.x, this.pointerdown.y, -1);
+    let e = new THREE.Vector3(this.pointermove.x, this.pointermove.y, -1);
+    s.unproject(this.orthCamera);
+    e.unproject(this.orthCamera);
+    s.y = 5;
+    e.y = 5;
+    const matLine = new LineMaterial({
+      color: this.lineColor,
+      linewidth: 4, // in world units with size attenuation, pixels otherwise
+      dashed: false,
+      alphaToCoverage: true,
+    });
+    matLine.resolution = new THREE.Vector2(this.scene.width, this.scene.height);
+    this.drawLine = new Line(s, e, this.activeEdge, matLine);
+    this.scene.scene.add(this.drawLine);
+  };
+
+  setActiveLine = (obj) => {
+    function getTouchLine(x, y) {
+      // [0 - 1]
+      x -= 0.5;
+      y -= 0.5;
+      // console.log(x, y);
+      if (x >= 0 && y >= 0) {
+        if (x > y) {
+          return 3;
+        } else {
+          return 0;
+        }
+      } else if (x >= 0 && y <= 0) {
+        if (x > Math.abs(y)) {
+          return 3;
+        } else {
+          return 2;
+        }
+      } else if (x <= 0 && y >= 0) {
+        if (Math.abs(x) > y) {
+          return 1;
+        } else {
+          return 0;
+        }
+      } else if (x <= 0 && y <= 0) {
+        if (-x > -y) {
+          return 1;
+        } else {
+          return 2;
+        }
+      }
+    }
+
+    if (this.activeEdge) {
+      this.activeEdge.visible = false;
+      this.activeEdge = null;
+    }
+
+    let num = getTouchLine(obj.uv.x, obj.uv.y);
+    this.activeEdge = obj.object.touchLines.getObjectByName(num);
+    this.activeEdge.visible = true;
+    this.buildLine();
+  };
+
+  insertActiveEdge(item) {
+    const exist = this.activeEdges.find((s) => item.id === s.id);
+    if (exist) {
+      exist.dir = [...new Set([...exist.dir, ...item.dir])];
+    } else {
+      this.activeEdges.push(item);
+    }
+  }
+
+  // insertActiveMarker(item) {
+  //   const exist = this.activeEdges.find((s) => item.id === s.id);
+  //   if (exist) {
+  //     exist.dir = [...new Set([...exist.dir, ...item.dir])];
+  //   } else {
+  //     this.activeEdges.push(item);
+  //   }
+  // }
+
+  insertrenderTexts(item) {
+    const index = this.renderTexts.findIndex((s) => item.id === s.id);
+    if (index > -1) {
+      this.renderTexts[index] = item;
+    } else {
+      this.renderTexts.push(item);
+    }
+  }
+
+  showAllActiveEdges() {
+    if (this.inited) {
+      let imgList = this.scene.boxManager.imgList;
+      if (this.activeEdges.length > 0) {
+        this.activeEdges.forEach((edge) => {
+          const exist = imgList.find((item) => item.userData === edge.id);
+          if (exist) {
+            let others = [0, 1, 2, 3].filter((x) => !edge.dir.includes(x));
+            // console.log("others", others);
+            edge.dir.forEach((dir) => {
+              exist.touchLines.children[dir].visible = true;
+            });
+            // console.log("others", others);
+            others.forEach((dir) => {
+              exist.touchLines.children[dir].visible = false;
+            });
+          }
+        });
+      } else {
+        imgList.forEach((img) => {
+          // console.log("img", img);
+          img.touchLines.children.forEach((line) => {
+            if (line.visible) {
+              line.visible = false;
+            }
+          });
+        });
+      }
+    }
+  }
+  deleteItemByType(type, data) {
+    if (type === 1) {
+      const index = this.renderMarkers.findIndex((mk) => {
+        const p = new THREE.Vector3().fromArray(mk.point);
+        const v = new THREE.Vector3().fromArray(data.point);
+        return p.equals(v);
+      });
+      this.renderMarkers.splice(index, 1);
+    }
+    if (type === 2) {
+      const { imgId, id, dir, points } = data;
+      const index = this.renderLines.findIndex((item) => item.id === id);
+      index > -1 && this.renderLines.splice(index, 1);
+      //线段处理完成
+      const egIndex = this.activeEdges.findIndex((eg) => eg.id === imgId);
+      if (egIndex > -1) {
+        //存在activeEdge 再找renderLines的sibling
+        const cluEgArr = this.renderLines
+          .filter((l) => l.imgId === this.activeEdges[egIndex].id)
+          .reduce((pre, curr) => pre.concat(curr["dir"]), []);
+        const uni_dir = [...new Set(cluEgArr)];
+        console.log("uni_dir", uni_dir);
+        if (uni_dir.length > 0) {
+          this.activeEdges[egIndex].dir = uni_dir;
+        } else {
+          console.log("全空", this.activeEdges[egIndex].id);
+          let imgList = this.scene.boxManager.imgList;
+          const image = imgList.find(
+            (item) => item.userData === this.activeEdges[egIndex].id
+          );
+          image.touchLines.children.forEach((line) => {
+            line.visible = false;
+          });
+          this.activeEdges.splice(egIndex, 1);
+          this.update();
+        }
+      }
+    }
+    if (type === 3) {
+      const index = this.renderSymbols.findIndex((syb) => {
+        const p = new THREE.Vector3().fromArray(syb.point);
+        const v = new THREE.Vector3().fromArray(data);
+        return p.equals(v);
+      });
+      this.renderSymbols.splice(index, 1);
+    }
+    if (type === 4) {
+      const { id } = data;
+      const index = this.renderTexts.findIndex((item) => item.id === id);
+      index > -1 && this.renderTexts.splice(index, 1);
+    }
+  }
+  editing(item) {
+    if (item.type === 4) {
+      if (this.text) {
+        const { pos } = this.text.userData;
+        const newP = new THREE.Vector3().fromArray(pos);
+        this.scene.scene.remove(this.text);
+        this.text = null;
+        this.showText = item.text;
+        console.log("editing", item, newP);
+        // // console.log("this.text", lastPos, newP, item);
+        this.text = new PureTextLabel(
+          item.text,
+          newP,
+          item.fontsize,
+          item.color,
+          item.id
+        );
+        this.scene.scene.add(this.text);
+        const activeTextItem = this.text.userData;
+        console.log("activeTextItem", activeTextItem);
+        this.insertrenderTexts(activeTextItem);
+      }
+    }
+  }
+  getDrawData() {
+    let data;
+    if (this.scene.sceneType === 1) {
+      data = {
+        hor_lines: this.renderLines,
+        hor_activeEdges: this.activeEdges,
+        hor_markers: this.renderMarkers,
+        hor_symbols: this.renderSymbols,
+        hor_texts: this.renderTexts,
+        vir_lines: [],
+        vir_activeEdges: [],
+        vir_markers: [],
+        vir_symbols: [],
+        vir_texts: [],
+      };
+    } else {
+      data = {
+        hor_lines: [],
+        hor_activeEdges: [],
+        hor_markers: [],
+        hor_symbols: [],
+        hor_texts: [],
+        vir_lines: this.renderLines,
+        vir_activeEdges: this.activeEdges,
+        vir_markers: this.renderMarkers,
+        vir_symbols: this.renderSymbols,
+        vir_texts: this.renderTexts,
+      };
+    }
+
+    // console.log("sceneType", this.scene.sceneType);
+    return data;
+  }
+
+  syncDrawData() {
+    const data = this.getDrawData();
+    this.scene.emit("data", data);
+  }
+  load(type, data) {
+    if (type === 1) {
+      console.log("data1", data);
+      const {
+        hor_activeEdges,
+        hor_lines,
+        hor_markers,
+        hor_symbols,
+        hor_texts,
+      } = data;
+      hor_activeEdges && (this.activeEdges = hor_activeEdges);
+      if (hor_lines && Array.isArray(hor_lines)) {
+        this.renderLines = hor_lines;
+        hor_lines.forEach((line) => {
+          const finishLine = new LinePoints(
+            line.points,
+            this.matLine,
+            line.dir,
+            line.imgId,
+            line.id
+          );
+          this.scene.scene.add(finishLine);
+        });
+      }
+      if (hor_markers && Array.isArray(hor_markers)) {
+        this.renderMarkers = hor_markers;
+        hor_markers.forEach((pos) => {
+          console.log("pos");
+          const p = new THREE.Vector3().fromArray(pos.point);
+          const marker = new Marker(p, pos.id);
+          this.scene.scene.add(marker);
+        });
+      }
+
+      if (hor_symbols && Array.isArray(hor_symbols)) {
+        this.renderSymbols = hor_symbols;
+        hor_symbols.forEach((syb) => {
+          console.log("pos");
+          const p = new THREE.Vector3().fromArray(syb.point);
+          const symbol = new CircleTextLabel(syb.index, p);
+          this.scene.scene.add(symbol);
+        });
+      }
+      if (hor_texts && Array.isArray(hor_texts)) {
+        this.renderTexts = hor_texts;
+        hor_texts.forEach((txt) => {
+          console.log("pos");
+          const p = new THREE.Vector3().fromArray(txt.pos);
+          const text = new PureTextLabel(
+            txt.text,
+            p,
+            txt.fontsize,
+            txt.color,
+            txt.id
+          );
+          this.scene.scene.add(text);
+        });
+      }
+    }
+
+    if (type === 2) {
+      const {
+        vir_activeEdges,
+        vir_lines,
+        vir_markers,
+        vir_symbols,
+        vir_texts,
+      } = data;
+      vir_activeEdges && (this.activeEdges = vir_activeEdges);
+      if (vir_lines && Array.isArray(vir_lines)) {
+        this.renderLines = vir_lines;
+        vir_lines.forEach((line) => {
+          const finishLine = new LinePoints(
+            line.points,
+            this.matLine,
+            line.dir,
+            line.imgId,
+            line.id
+          );
+          this.scene.scene.add(finishLine);
+        });
+      }
+      if (vir_markers && Array.isArray(vir_markers)) {
+        this.renderMarkers = vir_markers;
+        vir_markers.forEach((pos) => {
+          const p = new THREE.Vector3().fromArray(pos.point);
+          const marker = new Marker(p);
+          this.scene.scene.add(marker);
+        });
+      }
+      if (vir_symbols && Array.isArray(vir_symbols)) {
+        this.renderSymbols = vir_symbols;
+        vir_symbols.forEach((syb) => {
+          // console.log("pos", syb);
+          const p = new THREE.Vector3().fromArray(syb.point);
+          const symbol = new CircleTextLabel(syb.index, p);
+          this.scene.scene.add(symbol);
+        });
+      }
+      if (vir_texts && Array.isArray(vir_texts)) {
+        this.renderTexts = vir_texts;
+        vir_texts.forEach((txt) => {
+          console.log("pos");
+          const p = new THREE.Vector3().fromArray(txt.pos);
+          const text = new PureTextLabel(
+            txt.text,
+            p,
+            txt.fontsize,
+            txt.color,
+            txt.id
+          );
+          this.scene.scene.add(text);
+        });
+      }
+    }
+    this.syncDrawData();
+  }
+  reset() {
+    if (this.marker) {
+      this.scene.scene.remove(this.marker);
+      this.marker = null;
+    }
+    if (this.drawLine) {
+      this.scene.scene.remove(this.drawLine);
+      this.drawLine = null;
+    }
+    if (this.touchImg) {
+      this.touchImg = null;
+    }
+    if (this.activeEdge) {
+      this.activeEdge = null;
+    }
+    if (this.text) {
+      this.text = null;
+      this.scene.scene.remove(this.text);
+    }
+    this.showText = "文本";
+    this.drawing = false;
+  }
+
+  clear() {
+    this.activeEdges = [];
+    this.renderLines = [];
+    this.renderMarkers = [];
+    this.renderSymbols = [];
+    this.renderTexts = [];
+    this.reset();
+    this.scene.clearDrawScene();
+    this.syncDrawData();
+  }
+
+  //单多张图片删除时要删除相关数据
+  deleteImageDataByIds(ids) {
+    setTimeout(() => {
+      console.warn("单多张图片删除时要删除相关数据", ids);
+      this.clear();
+      this.scene.emit("autoSave");
+      // this.t_deleteImageDataByIds(ids);
+    }, 500);
+  }
+  t_deleteImageDataByIds(ids) {
+    ids.forEach((id) => {
+      const makerIndex = this.renderMarkers.findIndex((item) => item.id === id);
+      if (makerIndex > -1) {
+        this.renderMarkers.splice(makerIndex, 1);
+      }
+      const lines = this.renderLines.filter(
+        (item) => item.imgId === id || item.startId === id
+      );
+
+      lines.forEach((line) => {
+        const edge = this.activeEdges.find(
+          (item) => item.id === line.imgId || item.startId === line.imgId
+        );
+        if (edge) {
+          if (edge.dir.length > 0) {
+            const dirIndex = Array.from(edge.dir).findIndex(
+              (d) => d === line.dir
+            );
+            edge.dir.splice(dirIndex, 1);
+          } else {
+            const indexEdge = this.activeEdges.findIndex(
+              (a) => a.id === edge.id
+            );
+            console.warn("edge没dir", indexEdge);
+            indexEdge > -1 && this.activeEdges.splice(indexEdge, 1);
+          }
+        }
+
+        const lineIndex = this.renderLines.findIndex(
+          (item) => item.imageId === line.imageId
+        );
+        console.log("lineIndex", lineIndex);
+        if (lineIndex > -1) {
+          this.renderLines.splice(lineIndex, 1);
+        }
+      });
+      console.log("lines", lines);
+    });
+
+    setTimeout(() => {
+      this.syncDrawData();
+      this.scene.emit("autoSave");
+    }, 2500);
+  }
+
+  checkDeleteing() {
+    const makers = this.scene.scene.children.filter((obj) =>
+      String(obj.name).includes("marker_")
+    );
+    Array.from(makers).forEach((marker) => {
+      const { imageId, point } = marker.userData;
+      const image = this.scene.boxManager.imgList.find(
+        (item) => item.userData === imageId
+      );
+      if (image) {
+        const nPoint = new THREE.Vector3().fromArray(point);
+        nPoint.project(this.orthCamera);
+        console.log("nPoint", nPoint, point);
+
+        this.raycaster.setFromCamera(nPoint, this.orthCamera);
+        const intersects = this.raycaster.intersectObjects(
+          this.scene.boxManager.imgList,
+          false
+        );
+
+        if (image.userData !== intersects[0].object.userData) {
+          console.log("相交不正确");
+          this.scene.scene.remove(marker);
+        }
+      }
+      // const
+    });
+  }
+
+  update = () => {
+    if (this.floorplanControls.enabled) {
+      this.floorplanControls && this.floorplanControls.update();
+      this.scene.boxManager && this.showAllActiveEdges();
+    }
+  };
+}

+ 122 - 0
src/view/gaPhoto/photo/Scene.js

@@ -0,0 +1,122 @@
+import * as THREE from "three";
+import Stats from "three/examples/jsm/libs/stats.module.js";
+import BoxManager from "./box/BoxManager.js";
+import { Mitt } from "./mitt.js";
+import FloorplanControls from "../../../core/controls/FloorplanControls.js";
+
+const stats = new Stats();
+
+function sleep(ms) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+export default class Scene extends Mitt {
+  constructor(domElement) {
+    super();
+    this.domElement = domElement;
+    this.scene = null;
+    this.renderer = null;
+    this.orthCamera = null;
+    this.camera = null;
+    this.player = null;
+    this.controls = null;
+    this.sceneType = 1;
+    this.width = 0;
+    this.height = 0;
+    this.defaultZoom = 250;
+    this.defaultUseZoom = 250 / window.devicePixelRatio;
+    this.initCamPView = new THREE.Vector3();
+    this.initCamRView = new THREE.Vector3();
+    this.inited = false;
+
+    this.init = () => {
+      this.scene = new THREE.Scene();
+      this.scene.background = new THREE.Color(0xf0f2f5);
+      this.renderer = new THREE.WebGLRenderer({
+        canvas: this.domElement,
+        antialias: true,
+        autoClear: true,
+        preserveDrawingBuffer: true,
+      });
+
+      this.width = this.domElement.clientWidth;
+      this.height = this.domElement.clientHeight;
+      this.renderRes = window.devicePixelRatio;
+      this.defaultUseZoom = this.defaultZoom / window.devicePixelRatio;
+      this.renderer.setSize(this.width, this.height);
+      this.renderer.setPixelRatio(this.renderRes);
+
+      this.camera = new THREE.PerspectiveCamera(70, this.domElement.clientWidth / this.domElement.clientHeight, 0.1, 1000);
+
+      this.orthCamera = new THREE.OrthographicCamera(-this.width / 2, this.width / 2, this.height / 2, -this.height / 2, 0.1, 1000);
+      this.orthCamera.zoom = this.defaultUseZoom;
+      // 影响画线
+      this.orthCamera.position.set(0, 10, 0);
+      this.orthCamera.lookAt(0, 0, 0);
+      this.orthCamera.updateProjectionMatrix();
+
+      // controls: 恢复 core 的拖拽与缩放逻辑,绑定到当前画布
+      this.controls = new FloorplanControls(this.orthCamera, this.domElement);
+      this.controls.enablePan = true;
+      this.controls.enableRotate = false;
+      // 适配缩放范围到当前默认缩放
+      this.controls.maxDistance = 100;
+      this.controls.minDistance = 0.1;
+      this.controls.maxZoom = 500;
+      this.controls.minZoom = 5;
+
+      domElement.parentNode.appendChild(stats.dom);
+      stats.dom.style.pointerEvents = "none";
+      stats.dom.style.left = "15%";
+      stats.dom.style.display = "none";
+
+      this.inited = true;
+      this.load();
+      this.animate();
+    };
+  }
+
+  load = (list, type, data) => {
+    if (!list) return;
+    // console.log("scene: ", list, type, data);
+    //axesHeloer
+    this.clearScene();
+    this.sceneType = type;
+    const axesHelper = new THREE.AxesHelper(1);
+    this.scene.add(axesHelper);
+    this.boxManager = new BoxManager(this);
+    this.boxManager.load(list, type);
+    //light
+    this.loadLight();
+    this.initCamPView.copy(this.orthCamera.position);
+    this.initCamRView.copy(this.orthCamera.rotation);
+  };
+
+  clearScene() {
+    for (var i = this.scene.children.length - 1; i >= 0; i--) {
+      let obj = this.scene.children[i];
+      this.scene.remove(obj);
+    }
+  }
+
+  loadLight = () => {
+    const light = new THREE.AmbientLight(0xffffff, 1.5); // 柔和的白光
+    this.scene.add(light);
+  };
+
+  render = () => {
+    this.renderer.render(this.scene, this.orthCamera);
+  };
+  animate = () => {
+    stats.begin();
+    this.render();
+    stats.end();
+    requestAnimationFrame(this.animate);
+  };
+
+  resetCameraView() {
+    this.orthCamera.zoom = this.defaultUseZoom;
+    this.orthCamera.updateMatrixWorld();
+  }
+
+}

+ 100 - 0
src/view/gaPhoto/photo/box/BoxManager.js

@@ -0,0 +1,100 @@
+import * as THREE from "three";
+import HorizontalBox from "./HorizontalBox";
+import VerticalBox from "./VerticalBox";
+import SimpleLabel from "./object/SimpleLabel";
+
+// import { Line2 } from "three/examples/jsm/lines/Line2.js";
+// import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
+// import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
+
+export default class BoxManager {
+  constructor(scene) {
+    this.scene = scene;
+    this.loadingManager = new THREE.LoadingManager();
+    this.loader = new THREE.TextureLoader(this.loadingManager);
+    this.model = new THREE.Group();
+    this.model.name = "boxManager";
+    this.maps = {};
+    this.obb = new THREE.Box3();
+    this.imgList = [];
+    // 记录每一页的承载平面(用于页级选择/操作)
+    this.pageList = [];
+    // 每页选中高亮边框
+    this.pageEdgeList = [];
+    this.opacity = 1;
+
+    this.onBindEvent();
+  }
+
+  load = (list, type) => {
+    console.log("this.model.name", this.model.name);
+    const total = list.length;
+    list.forEach((item, index) => {
+      const pageType = Array.isArray(type) ? type[index] ?? 1 : type;
+      if (pageType === 1) {
+        //横排
+        console.log("横排");
+        const box = new HorizontalBox(this, item, index, total);
+        this.model.add(box);
+      }
+      if (pageType === 2) {
+        //竖排
+        const box = new VerticalBox(this, item, index, total);
+        // console.log("竖排");
+        this.model.add(box);
+      }
+    });
+    // this.model.position.y += 0.3;
+    // this.model.visible =false;
+    this.scene.scene.add(this.model);
+    this.setArea();
+    // console.log("this.scene.scene", this.scene.scene);
+  };
+
+  onBindEvent = () => {
+    const _this = this;
+    this.loadingManager.onStart = (url, itemsLoaded, itemsTotal) => {
+      // console.log( 'Started loading file: ' + url + '.\nLoaded ' + itemsLoaded + ' of ' + itemsTotal + ' files.' );
+      console.log("loading_manager: loading...");
+ 
+    };
+    this.loadingManager.onLoad = () => {
+      console.log("loading_manager: loading complete!");
+      this.scene.emit("loaded");
+
+    };
+    this.loadingManager.onProgress = function (url, itemsLoaded, itemsTotal) {
+      // console.log( 'Loading file: ' + url + '.\nLoaded ' + itemsLoaded + ' of ' + itemsTotal + ' files.' );
+    };
+    this.loadingManager.onError = function (url) {
+      console.error("loading_manager: error loading " + url);
+    };
+  };
+  setArea() {
+    const object = this.model;
+    const boundingBox = new THREE.Box3().setFromObject(object);
+    // boundingBox.expandByScalar(1.3);
+    const size = boundingBox.getSize(new THREE.Vector3());
+    this.obb = boundingBox;
+    console.log("boundingBox-size", size);
+    // const topLine = [
+    //   -size.x,
+    //   0,
+    //   (size.z + 0.3) / -2,
+    //   size.x + 0.1,
+    //   0,
+    //   (size.z + 0.3) / -2,
+    // ];
+
+    // helper.scale.set(1.2, 1.2, 1.2);
+    // helper.update();
+  }
+  setVisible = (val) => {
+    if (!this.model) return;
+    this.model.visible = val;
+  };
+
+  setOpacity = (val) => {
+    this.material.opacity = val;
+  };
+}

+ 143 - 0
src/view/gaPhoto/photo/box/HorizontalBox.js

@@ -0,0 +1,143 @@
+import * as THREE from "three";
+import TextLabel from "./object/TextLabel";
+import SimpleLabel from "./object/SimpleLabel";
+import ImgLabel from "./object/ImgLabel";
+import TouchEdge from "./object/TouchEdge";
+import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
+
+export default class HorizontalBox extends THREE.Group {
+  constructor(manager, data, index, total) {
+    super();
+    this.manager = manager;
+    this.name = "horizontal_box";
+    this.total = total;
+    this.getStyle();
+    this.load(data, index);
+  }
+  getStyle() {
+    this.width = 2;
+    this.height = (2 * 710) / 500;
+    this.color = 0xffffff;
+  }
+  cover(texture, aspect) {
+    var imageAspect = texture.image.width / texture.image.height;
+
+    if (aspect < imageAspect) {
+      texture.matrix.setUvTransform(0, 0, aspect / imageAspect, 1, 0, 0.5, 0.5);
+    } else {
+      texture.matrix.setUvTransform(0, 0, 1, imageAspect / aspect, 0, 0.5, 0.5);
+    }
+  }
+  load(data, index) {
+    //box
+    const geometry = new THREE.PlaneGeometry(1, 1);
+    geometry.rotateX(-Math.PI / 2);
+
+    const bm = new THREE.MeshBasicMaterial({
+      color: this.color,
+    });
+
+    const box = new THREE.Mesh(geometry, bm);
+    box.scale.set(this.width, 1, this.height);
+
+    this.add(box);
+    // 记录页平面用于页级选择(raycast)
+    box.userData = { pageIndex: index };
+    this.manager.pageList.push(box);
+    // 页面之间贴合无间隔:按页面宽度等距排列,并以总宽居中
+    // centerX = (index - (total - 1) / 2) * width
+    this.position.x = (index - (this.total - 2.2) / 2) * (this.width + 0.005 );
+
+    const matLine = new LineMaterial({
+      color: 0xe44d54,
+      linewidth: 4, // in world units with size attenuation, pixels otherwise
+      dashed: false,
+      alphaToCoverage: true,
+    });
+    matLine.resolution = new THREE.Vector2(
+      this.manager.scene.width,
+      this.manager.scene.height
+    );
+    // 页级选中高亮边框
+    const halfW = this.width / 2;
+    const halfH = this.height / 2;
+    const p = [
+      [-halfW, 0, -halfH, halfW, 0, -halfH],
+      [-halfW, 0, -halfH, -halfW, 0, halfH],
+      [-halfW, 0, halfH, halfW, 0, halfH],
+      [halfW, 0, halfH, halfW, 0, -halfH],
+    ];
+    const pageLines = new TouchEdge(p, matLine);
+    this.add(pageLines);
+    this.manager.pageEdgeList.push(pageLines);
+    //content
+    data.forEach((i, j) => {
+      //img
+      let img;
+      this.manager.loader.load(i.imgUrl, (texture) => {
+        let imgRatio = texture.image.width / texture.image.height;
+        texture.matrixAutoUpdate = false;
+        let planeRatio = 1.5 / 0.85;
+        // let ratio = planeRatio / imgRatio;
+        texture.matrixAutoUpdate = false;
+        if (planeRatio < imgRatio) {
+          texture.matrix.setUvTransform(
+            0,
+            0,
+            planeRatio / imgRatio,
+            1,
+            0,
+            0.5,
+            0.5
+          );
+        } else {
+          texture.matrix.setUvTransform(
+            0,
+            0,
+            1,
+            imgRatio / planeRatio,
+            0,
+            0.5,
+            0.5
+          );
+        }
+        // texture.wrapS = THREE.RepeatWrapping;
+        // texture.wrapS = THREE.RepeatWrapping;
+        // texture.wrapT = THREE.ClampToEdgeWrapping;
+        // texture.repeat.x = ratio;
+        // texture.offset.x = 0.5 * (1 - ratio);
+        // console.log("texture", texture);
+        texture.colorSpace = THREE.SRGBColorSpace;
+
+        img = new ImgLabel(texture, matLine);
+
+        img.userData = i.id;
+        img.position.y += 1;
+        // 根据当前页图片数量动态摆放
+        if (data.length === 1) {
+          // 单张横版:居中偏上
+          img.position.z -= 0.2;
+        } else {
+          if (j === 0) {
+            img.position.z -= 0.8;
+          } else {
+            img.position.z += 0.43;
+          }
+        }
+        this.add(img);
+        this.manager.imgList.push(img);
+        const textlabel = new TextLabel(i.imgInfo, true);
+        this.add(textlabel);
+        textlabel.position.copy(img.position);
+        textlabel.position.z += textlabel.scale.z * 0.5 + 0.1;
+      });
+    });
+    //页脚
+    const f_txt_label = ` 第 ${index + 1} 页  共 ${this.total} 页`;
+    const footlabel = new SimpleLabel(f_txt_label, true);
+    footlabel.renderOrder = 100;
+    footlabel.position.z += 1.26;
+
+    this.add(footlabel);
+  }
+}

+ 96 - 0
src/view/gaPhoto/photo/box/VerticalBox.js

@@ -0,0 +1,96 @@
+import * as THREE from "three";
+import TextLabel from "./object/TextLabel";
+import ImgLabel from "./object/ImgLabel";
+import ImgLabelBox from "./object/ImgLabelBox";
+import SimpleLabel from "./object/SimpleLabel";
+import TouchEdge from "./object/TouchEdge";
+import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
+
+export default class VerticalBox extends THREE.Group {
+  constructor(manager, data, index, total) {
+    super();
+    this.manager = manager;
+    this.total = total;
+    this.name = "vertical_box";
+    this.getStyle();
+    this.load(data, index);
+  }
+  getStyle() {
+    this.width = 2;
+    this.height = (2 * 710) / 500;
+    this.color = 0xffffff;
+  }
+
+  load(data, index) {
+    //box
+    const geometry = new THREE.PlaneGeometry(1, 1);
+    geometry.rotateX(-Math.PI / 2);
+
+    const bm = new THREE.MeshBasicMaterial({
+      color: this.color,
+    });
+
+    const box = new THREE.Mesh(geometry, bm);
+    box.scale.set(this.width, 1, this.height);
+
+    this.add(box);
+    // 记录页平面用于页级选择(raycast)
+    box.userData = { pageIndex: index };
+    this.manager.pageList.push(box);
+    // 页面之间贴合无间隔:按页面宽度等距排列,并以总宽居中
+    this.position.x = (index - (this.total - 1) / 2) * this.width;
+
+    const matLine = new LineMaterial({
+      color: 0xe44d54,
+      linewidth: 4, // in world units with size attenuation, pixels otherwise
+      dashed: false,
+      alphaToCoverage: true,
+    });
+    matLine.resolution = new THREE.Vector2(
+      this.manager.scene.width,
+      this.manager.scene.height
+    );
+    // 页级选中高亮边框
+    const halfW = this.width / 2;
+    const halfH = this.height / 2;
+    const p = [
+      [-halfW, 0, -halfH, halfW, 0, -halfH],
+      [-halfW, 0, -halfH, -halfW, 0, halfH],
+      [-halfW, 0, halfH, halfW, 0, halfH],
+      [halfW, 0, halfH, halfW, 0, -halfH],
+    ];
+    const pageLines = new TouchEdge(p, matLine);
+    this.add(pageLines);
+    this.manager.pageEdgeList.push(pageLines);
+    //content
+    data.forEach((i, j) => {
+      //img
+      let img;
+      this.manager.loader.load(i.imgUrl, (texture) => {
+        let imgRatio = texture.image.width / texture.image.height;
+        let planeRatio = 1.5 / 2;
+        texture.matrixAutoUpdate = false;
+        texture.colorSpace = THREE.SRGBColorSpace;
+        img = new ImgLabelBox(texture, matLine, false);
+
+        img.userData = i.id;
+        img.position.y += 1;
+        img.position.z -= 0.2;
+        this.add(img);
+        this.manager.imgList.push(img);
+        const textlabel = new TextLabel(i.imgInfo, true);
+        this.add(textlabel);
+        textlabel.position.copy(img.position);
+        textlabel.position.z += textlabel.scale.z * 0.5 + 0.7;
+      });
+    });
+
+    //页脚
+    const f_txt_label = ` 第 ${index + 1} 页`;
+    const footlabel = new SimpleLabel(f_txt_label, true);
+    footlabel.renderOrder = 100;
+    footlabel.position.z += 1.26;
+
+    this.add(footlabel);
+  }
+}

+ 66 - 0
src/view/gaPhoto/photo/box/object/CircleTextLabel.js

@@ -0,0 +1,66 @@
+import * as THREE from "three";
+import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
+import { LineSegments2 } from "three/examples/jsm/lines/LineSegments2.js";
+import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
+
+export default class CircleTextLabel extends THREE.Mesh {
+  constructor(text, pos) {
+    let res = 5;
+    let point = new THREE.Vector3().copy(pos);
+    const canvas = document.createElement("canvas");
+    canvas.width = 128 * res;
+    canvas.height = 128 * res;
+    let fontSize = 68 * res;
+    const ctx = canvas.getContext("2d");
+    ctx.font = `800 ${fontSize}px Arial`; // 设置字体大小和类型
+    ctx.textAlign = "center";
+    ctx.textBaseline = "middle";
+    ctx.fillStyle = "#e44d54"; // 设置文字颜色和透明度
+    ctx.fillText(text, canvas.width / 2, canvas.height / 2);
+
+    // 步骤3: 将画布转换为纹理
+    const texture = new THREE.CanvasTexture(canvas);
+
+    // 步骤4: 创建材质并应用纹理
+    const m = new THREE.MeshBasicMaterial({
+      map: texture,
+      transparent: true, // 允许材质透明
+    });
+
+    // const canvas_map = new THREE.Texture(canvas);
+    texture.colorSpace = THREE.SRGBColorSpace;
+    texture.needsUpdate = true;
+    texture.anisotropy = 4;
+
+    const g = new THREE.CircleGeometry(0.08, 128);
+    g.rotateX(-Math.PI / 2);
+
+    super(g, m);
+
+    this.userData = text;
+
+    const edges = new THREE.EdgesGeometry(g);
+
+    const geometry = new LineGeometry();
+    geometry.fromEdgesGeometry(edges);
+
+    const line_m = new LineMaterial({
+      color: 0xe44d54,
+      linewidth: 5,
+      dashed: false,
+      dashScale : 0.1,
+      alphaToCoverage: false,
+    });
+
+    line_m.resolution.set(window.innerWidth, window.innerHeight);
+
+    const line_n = new LineSegments2(geometry, line_m);
+
+    line_n.position.y += 0.5;
+
+    this.add(line_n);
+    this.userData = point.toArray();
+    this.position.copy(point);
+    this.name = "circle_" + text;
+  }
+}

+ 47 - 0
src/view/gaPhoto/photo/box/object/ImgLabel.js

@@ -0,0 +1,47 @@
+import * as THREE from "three";
+import TouchEdge from "./TouchEdge";
+
+export default class ImgLabel extends THREE.Mesh {
+  constructor(texture, matLine, isHorizontal = true) {
+    let width, height, p;
+    if (isHorizontal) {
+      width = 1.5;
+      height = 0.85;
+      p = [
+        [-0.75, 0, -0.425, 0.75, 0, -0.425],
+        [-0.75, 0, -0.425, -0.75, 0, 0.425],
+        [-0.75, 0, 0.425, 0.75, 0, 0.425],
+        [0.75, 0, 0.425, 0.75, 0, -0.425],
+      ];
+    } else {
+      width = 1.5;
+      height = 2;
+      p = [
+        [-0.75, 0, -1, 0.75, 0, -1],
+        [-0.75, 0, -1, -0.75, 0, 1],
+        [-0.75, 0, 1, 0.75, 0, 1],
+        [0.75, 0, 1, 0.75, 0, -1],
+      ];
+    }
+    const g = new THREE.PlaneGeometry(width, height);
+    g.rotateX(-Math.PI / 2);
+
+    const m = new THREE.MeshBasicMaterial({
+      map: texture,
+      transparent: true,
+    });
+    super(g, m);
+
+  
+
+    this.width = width;
+    this.height = height;
+    this.touchLines = new TouchEdge(p, matLine);
+
+    this.touchLines.position.y += 0.5;
+    this.add(this.touchLines);
+    // this.touchLines.children.forEach((child) => (child.visible = true));
+
+    this.name = "imglabel";
+  }
+}

+ 118 - 0
src/view/gaPhoto/photo/box/object/ImgLabelBox.js

@@ -0,0 +1,118 @@
+import * as THREE from "three";
+import { TriangleBlurShader } from "three/addons/shaders/TriangleBlurShader.js";
+import TouchEdge from "./TouchEdge";
+
+function makeTriangleBlurShader(iterations = 10) {
+  // Remove texture, because texture is a reserved word in WebGL 2
+  const { texture, ...uniforms } = TriangleBlurShader.uniforms;
+
+  const TriangleBlurShader2 = {
+    ...TriangleBlurShader,
+
+    name: "TriangleBlurShader2",
+
+    uniforms: {
+      ...uniforms,
+
+      // Replace texture with blurTexture for WebGL 2
+      blurTexture: { value: null },
+    },
+  };
+
+  // Replace texture with blurTexture for WebGL 2
+  TriangleBlurShader2.fragmentShader =
+    TriangleBlurShader2.fragmentShader.replace(
+      "uniform sampler2D texture;",
+      "uniform sampler2D blurTexture;"
+    );
+  TriangleBlurShader2.fragmentShader =
+    TriangleBlurShader2.fragmentShader.replace(
+      "texture2D( texture",
+      "texture2D( blurTexture"
+    );
+
+  // Make iterations configurable.
+  TriangleBlurShader2.fragmentShader =
+    TriangleBlurShader2.fragmentShader.replace(
+      "#define ITERATIONS 10.0",
+      "#define ITERATIONS " + iterations + ".0"
+    );
+
+  console.log("shader:", TriangleBlurShader2.fragmentShader);
+
+  return TriangleBlurShader2;
+}
+
+export default class ImgLabel extends THREE.Mesh {
+  constructor(texture, matLine, isHorizontal = true) {
+    let width, height, p;
+    if (isHorizontal) {
+      width = 1.5;
+      height = 0.85;
+      p = [
+        [-0.75, 0, -0.425, 0.75, 0, -0.425],
+        [-0.75, 0, -0.425, -0.75, 0, 0.425],
+        [-0.75, 0, 0.425, 0.75, 0, 0.425],
+        [0.75, 0, 0.425, 0.75, 0, -0.425],
+      ];
+    } else {
+      width = 1.5;
+      height = 2;
+      p = [
+        [-0.75, 0, -1, 0.75, 0, -1],
+        [-0.75, 0, -1, -0.75, 0, 1],
+        [-0.75, 0, 1, 0.75, 0, 1],
+        [0.75, 0, 1, 0.75, 0, -1],
+      ];
+    }
+    const g = new THREE.PlaneGeometry(width, height);
+    g.rotateX(-Math.PI / 2);
+
+    // const m = new THREE.MeshBasicMaterial({
+    //   map: texture,
+    //   transparent: true,
+    // });
+
+    // const shader = makeTriangleBlurShader(12);
+
+    // const blurMaterial = new THREE.ShaderMaterial({
+    //   vertexShader: shader.vertexShader,
+    //   fragmentShader: shader.fragmentShader,
+    //   uniforms: THREE.UniformsUtils.clone(shader.uniforms),
+    // });
+    // // console.log("blurMaterial", blurMaterial.uniforms);
+    // blurMaterial.uniforms.blurTexture.value = texture;
+    // blurMaterial.uniforms.delta.value = new THREE.Vector2(0.5, 0.9);
+
+    const bg = new THREE.MeshBasicMaterial({
+      color: 0xf2f2f2,
+      transparent: false,
+    });
+
+    super(g, bg);
+
+    let imgRatio = texture.image.width / texture.image.height;
+    const imgHeight = width / imgRatio >= 2 ? 2 : width / imgRatio;
+    const imageG = new THREE.PlaneGeometry(width, imgHeight);
+
+    imageG.rotateX(-Math.PI / 2);
+
+    const im = new THREE.MeshBasicMaterial({
+      map: texture,
+      transparent: true,
+    });
+    const imageMesh = new THREE.Mesh(imageG, im);
+
+    imageMesh.renderOrder = 10;
+    this.add(imageMesh);
+
+    this.width = width;
+    this.height = height;
+    this.touchLines = new TouchEdge(p, matLine);
+
+    this.touchLines.position.y += 0.5;
+    this.add(this.touchLines);
+    // this.touchLines.children.forEach((child) => (child.visible = true));
+    this.name = "imglabel";
+  }
+}

+ 139 - 0
src/view/gaPhoto/photo/box/object/Line.js

@@ -0,0 +1,139 @@
+import * as THREE from "three";
+import { Line2 } from "three/examples/jsm/lines/Line2.js";
+import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
+import gotoPic from "@/assets/image/goto.png";
+const offset = 0.25;
+
+function pointsToArray(arr) {
+  let res = [];
+  arr.forEach((i) => {
+    res = res.concat(i.toArray());
+  });
+  return res;
+}
+let m = new THREE.MeshBasicMaterial({
+  map: new THREE.TextureLoader().load(gotoPic),
+  color: 0x26559b,
+  transparent: true,
+});
+export default class Line extends Line2 {
+  constructor(startPoint, endPoint, endEdge, matLine) {
+    let points;
+
+    let g = new THREE.PlaneGeometry(0.1, 0.1);
+    g.rotateX(-Math.PI / 2);
+    let cross = new THREE.Mesh(g, m);
+
+    if (endEdge.name === 0) {
+      // top
+      let a = startPoint.clone();
+      let b = new THREE.Vector3(
+        startPoint.x,
+        startPoint.y,
+        endEdge.y + endEdge.parent.parent.position.z - offset
+      );
+      let c = new THREE.Vector3(
+        endPoint.x,
+        endPoint.y,
+        endEdge.y + endEdge.parent.parent.position.z - offset
+      );
+      let d = new THREE.Vector3(
+        endPoint.x,
+        endPoint.y,
+        endEdge.y + endEdge.parent.parent.position.z
+      );
+      cross.position.copy(d);
+      cross.rotation.y = -Math.PI / 2;
+      cross.position.z -= 0.02;
+
+      points = pointsToArray([a, b, c, d]);
+    } else if (endEdge.name === 1) {
+      //left
+
+      let a = startPoint.clone();
+      let b = new THREE.Vector3(
+        (startPoint.x + endPoint.x) / 2,
+        startPoint.y,
+        startPoint.z
+      );
+      let c = new THREE.Vector3(
+        (startPoint.x + endPoint.x) / 2,
+        startPoint.y,
+        endPoint.z
+      );
+      let d = new THREE.Vector3(
+        endEdge.x + endEdge.parent.parent.parent.position.x,
+        startPoint.y,
+        endPoint.z
+      );
+      cross.position.copy(d);
+
+      const diff = c.x < d.x;
+      cross.rotation.y = diff ? 0 : -Math.PI;
+      diff ? (cross.position.x -= 0.02) : (cross.position.x += 0.02);
+
+      points = pointsToArray([a, b, c, d]);
+    } else if (endEdge.name === 2) {
+      //bottom
+      let a = startPoint.clone();
+      let b = new THREE.Vector3(
+        startPoint.x,
+        startPoint.y,
+        endEdge.y + endEdge.parent.parent.position.z + offset
+      );
+      let c = new THREE.Vector3(
+        endPoint.x,
+        endPoint.y,
+        endEdge.y + endEdge.parent.parent.position.z + offset
+      );
+      let d = new THREE.Vector3(
+        endPoint.x,
+        endPoint.y,
+        endEdge.y + endEdge.parent.parent.position.z
+      );
+      cross.rotation.y = Math.PI / 2;
+      cross.position.copy(d);
+      cross.position.z += 0.02;
+      points = pointsToArray([a, b, c, d]);
+    } else {
+      //right
+      let a = startPoint.clone();
+      let b = new THREE.Vector3(
+        (startPoint.x + endPoint.x) / 2,
+        startPoint.y,
+        startPoint.z
+      );
+      let c = new THREE.Vector3(
+        (startPoint.x + endPoint.x) / 2,
+        startPoint.y,
+        endPoint.z
+      );
+      let d = new THREE.Vector3(
+        endEdge.x + endEdge.parent.parent.parent.position.x,
+        startPoint.y,
+        endPoint.z
+      );
+      const diff = c.x < d.x;
+      cross.position.copy(d);
+
+      cross.rotation.y = diff ? 0 : Math.PI;
+      diff ? (cross.position.x -= 0.02) : (cross.position.x += 0.02);
+
+      points = pointsToArray([a, b, c, d]);
+    }
+
+    const geometry = new LineGeometry();
+
+    cross.visible = false;
+    geometry.setPositions(points);
+    super(geometry, matLine);
+    this.name = "line_" + this.uuid;
+    this.userData = {
+      dir: endEdge.name,
+      points: points,
+    };
+    this.scale.set(1, 1, 1);
+    this.position.y += 0.5;
+    this.add(cross);
+  }
+}

+ 75 - 0
src/view/gaPhoto/photo/box/object/LinePoints.js

@@ -0,0 +1,75 @@
+import * as THREE from "three";
+import { Line2 } from "three/examples/jsm/lines/Line2.js";
+import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
+import gotoPic from "@/assets/image/goto.png";
+const offset = 0.25;
+
+function pointsToArray(arr) {
+  let res = [];
+  arr.forEach((i) => {
+    res = res.concat(i.toArray());
+  });
+  return res;
+}
+let m = new THREE.MeshBasicMaterial({
+  map: new THREE.TextureLoader().load(gotoPic),
+  color: 0x26559b,
+  transparent: true,
+});
+export default class LinePoints extends Line2 {
+  constructor(points, matLine, dir, imgId, id) {
+    let g = new THREE.PlaneGeometry(0.1, 0.1);
+    g.rotateX(-Math.PI / 2);
+    let cross = new THREE.Mesh(g, m);
+    let a = new THREE.Vector3(points[0], points[1], points[2]);
+    let b = new THREE.Vector3(points[3], points[4], points[5]);
+    let c = new THREE.Vector3(points[6], points[7], points[8]);
+    let d = new THREE.Vector3(points[9], points[10], points[11]);
+    let diff;
+    switch (dir) {
+      case 0:
+        //top
+        cross.rotation.y = -Math.PI / 2;
+        cross.position.copy(d);
+        break;
+      case 1:
+        //left
+        diff = c.x < d.x;
+        cross.rotation.y = diff ? 0 : -Math.PI;
+        diff ? (cross.position.x -= 0.02) : (cross.position.x += 0.02);
+
+        break;
+      case 2:
+        //bottom
+        cross.position.copy(d);
+        cross.rotation.y = Math.PI / 2;
+        cross.position.z += 0.02;
+        break;
+      case 3:
+        //right
+        diff = c.x < d.x;
+        cross.position.copy(d);
+        cross.rotation.y = diff ? 0 : Math.PI;
+        diff ? (cross.position.x -= 0.02) : (cross.position.x += 0.02);
+        break;
+    }
+    const geometry = new LineGeometry();
+    cross.visible = false;
+    // console.log("points", points);
+    geometry.setPositions(points);
+    super(geometry, matLine);
+    this.name = "line_point_" + this.uuid;
+    this.scale.set(1, 1, 1);
+    this.position.y += 0.5;
+    if (id) {
+      this.uuid = id;
+    }
+    this.userData = {
+      id: id || this.uuid,
+      dir: dir,
+      points,
+      imgId: imgId || null,
+    };
+    this.add(cross);
+  }
+}

+ 54 - 0
src/view/gaPhoto/photo/box/object/PureTextLabel copy.js

@@ -0,0 +1,54 @@
+import * as THREE from "three";
+
+export default class PureTextLabel extends THREE.Mesh {
+  constructor(text, point, fontsize = 12, color = "#000000", id) {
+    let res = 2;
+    const width = 168 * res;
+    const height = 50 * res;
+    var canvas = document.createElement("canvas");
+    canvas.width = width;
+    canvas.height = height;
+
+    let fontFamily = "Arial";
+    let fs = fontsize * res;
+    var context = canvas.getContext("2d");
+    context.fillStyle = "transparent";
+    context.rect(0, 0, width, height);
+    context.fill();
+    let fontStyle = "normal " + fs + "px " + fontFamily;
+    // console.log("fontStyle", fontStyle);
+    context.font = fontStyle;
+    context.fillStyle = color;
+    context.textAlign = "center";
+    context.textBaseline = "middle";
+    context.fillText(text, width / 2, height / 2);
+    const canvas_map = new THREE.Texture(canvas);
+    canvas_map.colorSpace = THREE.SRGBColorSpace;
+    canvas_map.needsUpdate = true;
+    canvas_map.anisotropy = 4;
+
+    const g = new THREE.PlaneGeometry(1.5, 0.44);
+    g.rotateX(-Math.PI / 2);
+
+    // const texture = new THREE.CanvasTexture(canvas_map);
+
+    const m = new THREE.MeshBasicMaterial({
+      map: canvas_map,
+      transparent: true, // 允许材质透明
+    });
+    super(g, m);
+    if (id) {
+      this.uuid = id;
+    }
+    const p = new THREE.Vector3().copy(point);
+    this.userData = {
+      id: this.uuid,
+      text: text,
+      color: color,
+      pos: p.toArray(),
+      fontsize: fontsize,
+    };
+    this.position.copy(p);
+    this.name = "pureText_" + text;
+  }
+}

+ 73 - 0
src/view/gaPhoto/photo/box/object/PureTextLabel.js

@@ -0,0 +1,73 @@
+import * as THREE from "three";
+import { getWrapText } from "../../utils/text";
+
+export default class PureTextLabel extends THREE.Mesh {
+  constructor(text, point, fontsize = 12, color = "#000000", id) {
+    const radio = fontsize / 12;
+
+    let containerWidth = 1.5 * radio;
+    let containerHeight = 0.12 * radio;
+    const containerRadio = containerWidth / containerHeight;
+    let res = 2;
+    const width = 168 * res * radio;
+    const height = width / containerRadio;
+    var canvas = document.createElement("canvas");
+    canvas.width = width;
+    canvas.height = height;
+
+    let fontFamily = "Arial";
+    let fs = 12 * res * radio;
+    var context = canvas.getContext("2d");
+
+    const lines = getWrapText(context, text, 140);
+    console.log("lines", lines);
+    containerHeight = containerHeight * lines.length;
+    canvas.height = height * lines.length;
+    context.fillStyle = "transparent";
+    // context.fillStyle = "rgba(255,255,255,0.5)";
+    context.rect(0, 0, width, height * lines.length);
+    context.fill();
+    let fontStyle = "normal " + fs + "px " + fontFamily;
+    // console.log("fontStyle", fontStyle);
+    context.font = fontStyle;
+    context.fillStyle = color;
+    context.textAlign = "center";
+    context.textBaseline = "middle";
+    // context.fillText(text, width / 2, height / 2);
+    lines.forEach((txt, index) => {
+      context.fillText(txt, width / 2, height / 2 + height * index);
+    });
+    const canvas_map = new THREE.Texture(canvas);
+    canvas_map.colorSpace = THREE.SRGBColorSpace;
+    canvas_map.needsUpdate = true;
+    canvas_map.anisotropy = 4;
+
+    const g = new THREE.PlaneGeometry(containerWidth, containerHeight);
+
+    g.rotateX(-Math.PI / 2);
+
+    // const texture = new THREE.CanvasTexture(canvas_map);
+
+    const m = new THREE.MeshBasicMaterial({
+      map: canvas_map,
+      transparent: true, // 允许材质透明
+    });
+
+    super(g, m);
+
+    if (id) {
+      this.uuid = id;
+    }
+    const p = new THREE.Vector3().copy(point);
+    this.userData = {
+      id: this.uuid,
+      text: text,
+      color: color,
+      pos: p.toArray(),
+      fontsize: fontsize,
+    };
+    this.position.copy(p);
+
+    this.name = "pureText_" + text;
+  }
+}

+ 48 - 0
src/view/gaPhoto/photo/box/object/SimpleLabel.js

@@ -0,0 +1,48 @@
+import * as THREE from "three";
+
+export default class SimpleLabel extends THREE.Mesh {
+  constructor(text, outline) {
+    let res = 5;
+    const width = 150 * res;
+    const height = 15 * res;
+    var canvas = document.createElement("canvas");
+    canvas.width = width;
+    canvas.height = height;
+    let fontFamily = "Arial";
+    let fontSize = 5.2 * res;
+    let offsetX = 75 * res;
+    let offsetY = 10 * res;
+    var context = canvas.getContext("2d");
+
+    context.fillStyle = "#ffffff";
+    context.rect(0, 0, width, height);
+    context.fill();
+    context.font = "normal " + fontSize + "px " + fontFamily;
+    context.fillStyle = "#000000";
+    context.textAlign = "center";
+    context.fillText(text, offsetX, offsetY);
+    const canvas_map = new THREE.Texture(canvas);
+    canvas_map.colorSpace = THREE.SRGBColorSpace;
+    canvas_map.needsUpdate = true;
+    canvas_map.anisotropy = 4;
+
+
+    const g = new THREE.PlaneGeometry(1.5, 0.15);
+    g.rotateX(-Math.PI / 2);
+
+    const m = new THREE.MeshBasicMaterial({
+      map: canvas_map,
+    });
+    super(g, m);
+
+    // const edges = new THREE.EdgesGeometry(g);
+    // const line = new THREE.LineSegments(
+    //   edges,
+    //   new THREE.LineBasicMaterial({ color: 0xcccccc })
+    // );
+    // line.position.y += 0.5;
+    // this.add(line);
+
+    this.name = "SimpleLabel_" + text;
+  }
+}

+ 47 - 0
src/view/gaPhoto/photo/box/object/TextLabel.js

@@ -0,0 +1,47 @@
+import * as THREE from "three";
+
+export default class TextLabel extends THREE.Mesh {
+  constructor(text, outline) {
+    let res = 5;
+    const width = 150 * res;
+    const height = 15 * res;
+    var canvas = document.createElement("canvas");
+    canvas.width = width;
+    canvas.height = height;
+    let fontFamily = "Arial";
+    let fontSize = 7 * res;
+    let offsetX = 75 * res;
+    let offsetY = 10 * res;
+    var context = canvas.getContext("2d");
+
+    context.fillStyle = "#ffffff";
+    context.rect(0, 0, width, height);
+    context.fill();
+    context.font = "normal " + fontSize + "px " + fontFamily;
+    context.fillStyle = "#000000";
+    context.textAlign = "center";
+    context.fillText(text, offsetX, offsetY);
+    const canvas_map = new THREE.Texture(canvas);
+    canvas_map.colorSpace = THREE.SRGBColorSpace;
+    canvas_map.needsUpdate = true;
+    canvas_map.anisotropy = 4;
+
+    const g = new THREE.PlaneGeometry(1.5, 0.15);
+    g.rotateX(-Math.PI / 2);
+
+    const m = new THREE.MeshBasicMaterial({
+      map: canvas_map,
+    });
+    super(g, m);
+
+    const edges = new THREE.EdgesGeometry(g);
+    const line = new THREE.LineSegments(
+      edges,
+      new THREE.LineBasicMaterial({ color: 0xcccccc })
+    );
+    line.position.y += 0.5;
+    this.add(line);
+
+    this.name = "textlabel_" + text;
+  }
+}

+ 24 - 0
src/view/gaPhoto/photo/box/object/TouchEdge.js

@@ -0,0 +1,24 @@
+import * as THREE from "three";
+import { Line2 } from "three/examples/jsm/lines/Line2.js";
+import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
+
+export default class TouchEdge extends THREE.Group {
+  constructor(positions, matLine) {
+    super();
+    positions.forEach((i, j) => {
+      //top left bottom right
+      const geometry = new LineGeometry();
+      geometry.setPositions(i);
+
+      const line = new Line2(geometry, matLine);
+      line.scale.set(1, 1, 1);
+      line.position.y += 0.5;
+      line.name = j;
+      line.visible = false;
+      line.x = i[0];
+      line.y = i[2];
+      this.add(line);
+    });
+    // console.log(this);
+  }
+}

+ 31 - 0
src/view/gaPhoto/photo/box/object/marker.js

@@ -0,0 +1,31 @@
+import * as THREE from "three";
+import gotoPic from "@/assets/image/arrow.svg";
+const m = new THREE.MeshBasicMaterial({
+  map: new THREE.TextureLoader().load(gotoPic),
+  color: 0xe44d54,
+  transparent: true,
+});
+
+export default class Marker extends THREE.Mesh {
+  constructor(startPoint, imageId) {
+    const g = new THREE.PlaneGeometry(0.15, 0.15);
+    g.rotateX(-Math.PI / 2);
+    super(g, m);
+    const a = startPoint.clone();
+    this.position.copy(a);
+
+    this.rotation.y = 0;
+    this.position.y = 5;
+    this.position.z -= 0.02;
+    this.userData = {
+      imageId: imageId || null,
+      point: a.toArray(),
+    };
+    this.visible = true;
+    this.scale.set(1, 1, 1);
+    this.position.y += 0.5;
+    this.name = "marker_" + this.uuid;
+    this.renderOrder = 1000;
+    // console.log(this, this.position);
+  }
+}

+ 10 - 0
src/view/gaPhoto/photo/mitt.js

@@ -0,0 +1,10 @@
+import mitt from "mitt";
+export class Mitt {
+  constructor() {
+    const emitter = mitt();
+
+    Object.keys(emitter).forEach((method) => {
+      this[method] = emitter[method];
+    });
+  }
+}

+ 193 - 0
src/view/gaPhoto/photo/photoControl.js

@@ -0,0 +1,193 @@
+import * as THREE from "three";
+import { ElMessage } from "element-plus";
+
+/**
+ * 创建照片拖拽/放置控制器
+ * @param {any} scene - gaPhoto 的 Scene 实例
+ * @param {(id:number, url:string, info?:string)=>void} updatePaperImageById - 更新占位数据的方法
+ */
+export function createPhotoControl(scene, updatePaperImageById, helpers = {}) {
+  const {
+    getPaperList = () => [],
+    setSelectedObj = () => {},
+    setSelectedSlotId = () => {},
+    clearSelection = () => {},
+    updateDeleteBtnPosition = () => {},
+    showPageToolbar = () => {},
+    highlightPageBorder = () => {},
+    clearPageBorder = () => {},
+    setSelectedPageIndex = () => {},
+    updatePageToolbarPosition = () => {},
+  } = helpers;
+  const onDragStart = (item, e) => {
+    try {
+      e.dataTransfer?.setData(
+        "application/json",
+        JSON.stringify({ imgUrl: item.imgUrl, imgInfo: item.imgInfo, id: item.id })
+      );
+    } catch (err) {
+      e.dataTransfer?.setData("text/plain", item.imgUrl);
+    }
+  };
+
+  const onCanvasDrop = (e) => {
+    if (!scene || !scene.orthCamera || !scene.boxManager) return;
+
+    let data = null;
+    try {
+      const jsonStr = e.dataTransfer?.getData("application/json");
+      data = jsonStr ? JSON.parse(jsonStr) : null;
+    } catch (err) {
+      const url = e.dataTransfer?.getData("text/plain");
+      if (url) data = { imgUrl: url };
+    }
+    if (!data || !data.imgUrl) {
+      ElMessage.warning("未获取到拖拽图片数据");
+      return;
+    }
+
+    const canvas = scene.domElement;
+    const rect = canvas.getBoundingClientRect();
+    const x = ((e.clientX - rect.left) / canvas.clientWidth) * 2 - 1;
+    const y = -((e.clientY - rect.top) / canvas.clientHeight) * 2 + 1;
+
+    const ndc = new THREE.Vector2(x, y);
+    const raycaster = new THREE.Raycaster();
+    raycaster.setFromCamera(ndc, scene.orthCamera);
+
+    const intersectArr = scene.boxManager.imgList || [];
+    const intersects = raycaster.intersectObjects(intersectArr, false);
+
+    if (!intersects.length) {
+      ElMessage.info("请将图片拖到占位区域内");
+      return;
+    }
+
+    const hitObj = intersects[0].object;
+    const loader = new THREE.TextureLoader();
+    loader.load(
+      data.imgUrl,
+      (texture) => {
+        texture.colorSpace = THREE.SRGBColorSpace;
+        texture.needsUpdate = true;
+
+        // ImgLabelBox: 图片是第一个子 Mesh(children[0])
+        if (
+          hitObj.children &&
+          hitObj.children.length > 0 &&
+          hitObj.children[0].material &&
+          hitObj.children[0].geometry
+        ) {
+          const imgMesh = hitObj.children[0];
+          const width = hitObj.width || 1.5;
+          const imgRatio = texture.image.width / texture.image.height;
+          const newHeight = Math.min(2, width / imgRatio);
+          const newGeo = new THREE.PlaneGeometry(width, newHeight);
+          newGeo.rotateX(-Math.PI / 2);
+          imgMesh.geometry.dispose?.();
+          imgMesh.geometry = newGeo;
+          imgMesh.material.map = texture;
+          imgMesh.material.needsUpdate = true;
+        } else {
+          // ImgLabel: 用 UV 变换 cover 适配
+          const width = hitObj.width || 1.5;
+          const height = hitObj.height || 0.85;
+          const planeRatio = width / height;
+          const imgRatio = texture.image.width / texture.image.height;
+          texture.matrixAutoUpdate = false;
+          if (planeRatio < imgRatio) {
+            texture.matrix.setUvTransform(0, 0, planeRatio / imgRatio, 1, 0, 0.5, 0.5);
+          } else {
+            texture.matrix.setUvTransform(0, 0, 1, imgRatio / planeRatio, 0, 0.5, 0.5);
+          }
+          hitObj.material.map = texture;
+          hitObj.material.needsUpdate = true;
+        }
+
+        // 同步更新占位数据(替换掉 createPlaceholder 生成的链接)
+        const slotId = hitObj.userData;
+        if (slotId != null) {
+          updatePaperImageById(slotId, data.imgUrl, data.imgInfo);
+        }
+
+        ElMessage.success("已插入图片到占位区域");
+      },
+      undefined,
+      () => {
+        ElMessage.error("图片加载失败");
+      }
+    );
+  };
+
+  // 画布点击选中:优先命中图片,其次命中页;未命中清空
+  const onCanvasClick = (e) => {
+    if (!scene || !scene.orthCamera || !scene.boxManager) return;
+    const canvas = scene.domElement;
+    const rect = canvas.getBoundingClientRect();
+    const x = ((e.clientX - rect.left) / canvas.clientWidth) * 2 - 1;
+    const y = -((e.clientY - rect.top) / canvas.clientHeight) * 2 + 1;
+
+    const ndc = new THREE.Vector2(x, y);
+    const raycaster = new THREE.Raycaster();
+    raycaster.setFromCamera(ndc, scene.orthCamera);
+
+    // 先检测图片命中
+    const intersectArr = scene.boxManager.imgList || [];
+    const intersects = raycaster.intersectObjects(intersectArr, false);
+    if (intersects.length) {
+      const hitObj = intersects[0].object;
+      setSelectedObj(hitObj);
+      setSelectedSlotId(hitObj.userData ?? null);
+
+      // 若为占位图(dataURL 且说明为默认),不选中
+      const isPlaceholderSlot = (id) => {
+        if (id == null) return true;
+        const paperList = getPaperList();
+        for (const page of paperList) {
+          const it = page.find((s) => s.id === id);
+          if (it) {
+            const isDataUrl = typeof it.imgUrl === 'string' && it.imgUrl.startsWith('data:image');
+            const isDefaultInfo = !it.imgInfo || it.imgInfo === '说明文字';
+            return isDataUrl && isDefaultInfo;
+          }
+        }
+        return false;
+      };
+
+      if (isPlaceholderSlot(hitObj.userData ?? null)) {
+        clearSelection();
+        return;
+      }
+
+      // 图片边框高亮
+      scene.boxManager.imgList.forEach((img) => {
+        img.touchLines?.children?.forEach((line) => (line.visible = img === hitObj));
+      });
+      updateDeleteBtnPosition();
+      showPageToolbar(false);
+      clearPageBorder();
+      return;
+    }
+
+    // 未命中图片:检测页平面
+    const pageObjs = scene.boxManager.pageList || [];
+    const pageHits = raycaster.intersectObjects(pageObjs, false);
+    if (pageHits.length) {
+      const pageObj = pageHits[0].object;
+      const pageIndex = pageObj.userData?.pageIndex ?? null;
+      setSelectedPageIndex(pageIndex);
+      updatePageToolbarPosition(pageObj.parent);
+      showPageToolbar(true);
+      highlightPageBorder(pageIndex);
+      clearSelection();
+      return;
+    }
+
+    // 未命中任何对象
+    clearSelection();
+    showPageToolbar(false);
+    clearPageBorder();
+  };
+
+  return { onDragStart, onCanvasDrop, onCanvasClick };
+}

+ 8 - 8
src/view/newFireCase/newFireDetails/index.vue

@@ -1,17 +1,17 @@
 <template>
   <div class="new-fire-details" v-if="allowEnter">
     <!-- 顶部标题栏 -->
-    <headerTop :caseId="caseId" :currentRecord="currentRecord" :editOrShow="editOrShow" :showSave="showSave" @save="saveEditSub" @back="backEditSub" />
-    <editFilePage :caseId="caseId" :currentRecord="currentRecord" :editOrShow="editOrShow" ref="editFilePageRef" />
-    <editInspection :caseId="caseId" :currentRecord="currentRecord" :editOrShow="editOrShow" ref="editInspectionRef" />
+    <!-- <headerTop :caseId="caseId" :currentRecord="currentRecord" :editOrShow="editOrShow" :showSave="showSave" @save="saveEditSub" @back="backEditSub" /> -->
+    <!-- <editFilePage :caseId="caseId" :currentRecord="currentRecord" :editOrShow="editOrShow" ref="editFilePageRef" />
+    <editInspection :caseId="caseId" :currentRecord="currentRecord" :editOrShow="editOrShow" ref="editInspectionRef" /> -->
     <photoEdit :caseId="caseId" :title="pageTitle" ref="photoEditRef" />
     <!-- 查看页 -->
-    <showIndex :caseId="caseId" :currentRecord="currentRecord" :fromRoute="fromRoute" :processingIds="processingIds" :recentAddedItem="recentAddedItem" @playVideo="playVideo" v-if="editOrShow === 'show'" />
+    <!-- <showIndex :caseId="caseId" :currentRecord="currentRecord" :fromRoute="fromRoute" :processingIds="processingIds" :recentAddedItem="recentAddedItem" @playVideo="playVideo" v-if="editOrShow === 'show'" /> -->
     <!-- 编辑页 -->
-    <editIndex :caseId="caseId" :currentRecord="currentRecord" :fromRoute="fromRoute" :processingIds="processingIds" :recentAddedItem="recentAddedItem" @start="startShot" @playVideo="playVideo" v-else />
+    <!-- <editIndex :caseId="caseId" :currentRecord="currentRecord" :fromRoute="fromRoute" :processingIds="processingIds" :recentAddedItem="recentAddedItem" @start="startShot" @playVideo="playVideo" v-else /> -->
   </div>
-  <shot v-if="isShot && allowEnter" @close="closeHandler" @append="appendFragment" @playVideo="playVideo"
-    @updateCover="(cover: string) => $emit('updateCover', cover)" @deleteRecord="$emit('delete')" :record="record" />
+  <!-- <shot v-if="isShot && allowEnter" @close="closeHandler" @append="appendFragment" @playVideo="playVideo"
+    @updateCover="(cover: string) => $emit('updateCover', cover)" @deleteRecord="$emit('delete')" :record="record" /> -->
   <el-dialog
       v-model="previewVisible"
       width="90%"
@@ -58,7 +58,7 @@ import shot from './components/shot.vue';
 import headerTop from './components/headerTop.vue';
 import editFilePage from './editFilePage.vue';
 import editInspection from './editInspection.vue';
-import photoEdit from './photoEdit.vue';
+import photoEdit from '../../gaPhoto/index.vue';
 import {
   createRecordFragment,
   recordFragments,