From 150e85cc7e88ba379999ef41c6a2aec2e3adc704 Mon Sep 17 00:00:00 2001 From: luoyifan Date: Fri, 30 May 2025 22:06:35 +0800 Subject: [PATCH] =?UTF-8?q?=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/designer/StateManager.ts | 399 +++++++++++++++++++++++++++++++++++++++++++ src/designer/Viewport.ts | 20 ++- src/runtime/System.ts | 5 +- src/types/Types.d.ts | 54 +++++- src/utils/webutils.ts | 67 ++++++++ 5 files changed, 541 insertions(+), 4 deletions(-) create mode 100644 src/designer/StateManager.ts diff --git a/src/designer/StateManager.ts b/src/designer/StateManager.ts new file mode 100644 index 0000000..7be560e --- /dev/null +++ b/src/designer/StateManager.ts @@ -0,0 +1,399 @@ +import _ from 'lodash' +import localforage from 'localforage' +import type Viewport from '@/designer/Viewport.ts' +import { markRaw, reactive, ref } from 'vue' +import type { VData, VDataItem } from '@/types/Types' + +/** + * THREE.js 场景状态管理器. + * 从数据结构还原 threejs 画布的一种结构 + * 1. 管理场景数据的读取、保存, 以及临时保存、临时读取等功能 + * 2. 管理撤销、重做功能 + * 3. 用户侧会通过如下步骤修改数据 + * - 1. 调用 beginUserWrite 开始修改数据 + * - 2. 直接修改 vdata 数据 + * - 3. 调用 endUserWrite 完成数据修改 + * 4. 内部如果进行了撤销、还原等操作,会通过 syncDataState() 方法将 vdata 数据与 viewport 进行同步 + * 5. syncDataState 方法会对比 vdata 与 viewport + * - 分别进行添加、删除、更新等操作 + * 6. 注意,如果正在读取中,需要设置 isLoading = true,外部需要等待加载完成后再进行操作 + * 状态管理器,管理内部数据结构的读取、保存、临时保存、临时读取、撤销、重做等功能 + * 当数据结构发生变化时,只对发生变化的对象进行更新 + * + * // 初始化 + * const stateManager = new StateManager('scene-1', viewport) + * + * // 加载大数据 + * await stateManager.load(largeDataSet) // 5万+ items + * + * // 修改数据 + * stateManager.beginUserWrite() + * + * // 直接修改状态(实际项目应通过封装方法) + * stateManager.vdata.items.push(newItem) + * stateManager.vdata.items[0].name = 'updated' + * stateManager.vdata.items = stateManager.vdata.items.filter(i => i.id !== 'remove-id') + * + * stateManager.endUserWrite() // 自动计算差异并保存 + * + * // 撤销操作 + * stateManager.undo() + * + * // 保存到云端 + * const data = await stateManager.save() + * + */ +export default class StateManager { + /** + * 是否发生了变化,通知外部是否需要保存数据 + */ + isChanged = ref(false) + + /** + * 是否正在加载数据,通知外部是否需要等待加载完成 + */ + isLoading = ref(false) + + /** + * 当前场景数据 + */ + vdata: VData = { items: [], isChanged: false } + + /** + * 唯一场景标识符, 用于做临时存储的 key + */ + id: string + + /** + * 视口对象, 用于获取、同步当前场景的状态 + */ + viewport: Viewport + + /** + * 使用循环缓冲区存储历史记录 + */ + private historySteps: HistoryStep[] = [] + private historyIndex = -1 + private readonly maxHistorySteps = 20 + private readonly historyBufferSize: number + + // 变化追踪器 + private changeTracker: DataDiff = { + added: [], + removed: [], + updated: [] + } + + /** + * 数据快照(用于差异计算) + */ + private lastSnapshot: Map = new Map() + + /** + * @param id 唯一场景标识符, 用于做临时存储的 key + * @param viewport 视口对象, 用于获取、同步当前场景的状态 + * @param bufferSize 历史记录缓冲区大小,默认为 50 + */ + constructor(id: string, viewport: Viewport, bufferSize = 50) { + this.id = id + this.viewport = markRaw(viewport) + this.historyBufferSize = bufferSize + // 初始化固定大小的历史缓冲区 + this.historySteps = new Array(this.maxHistorySteps) + } + + /** + * 开始用户操作(创建数据快照) + */ + beginUserWrite() { + // 创建当前状态快照(非深拷贝) + this.lastSnapshot = new Map( + this.vdata.items.map(item => [item.id, item]) + ) + this.changeTracker = { added: [], removed: [], updated: [] } + } + + /** + * 结束用户操作(计算差异并保存) + */ + endUserWrite() { + this.calculateDiff() + this.saveStep() + this.syncDataState() + this.isChanged.value = true + } + + + /** + * 计算当前状态与快照的差异 + */ + private calculateDiff() { + const currentMap = new Map( + this.vdata.items.map(item => [item.id, item]) + ) + + // 检测删除的项目 + for (const [id] of this.lastSnapshot) { + if (!currentMap.has(id)) { + this.changeTracker.removed.push(id) + } + } + + // 检测新增和更新的项目 + for (const [id, currentItem] of currentMap) { + const lastItem = this.lastSnapshot.get(id) + + if (!lastItem) { + this.changeTracker.added.push(currentItem) + } else if (!_.isEqual(lastItem, currentItem)) { + this.changeTracker.updated.push(currentItem) + } + } + + // 清除空变更 + if (this.changeTracker.added.length === 0) delete this.changeTracker.added + if (this.changeTracker.removed.length === 0) delete this.changeTracker.removed + if (this.changeTracker.updated.length === 0) delete this.changeTracker.updated + } + + + /** + * 保存差异到历史记录 + */ + private saveStep() { + // 跳过空变更 + if ( + (!this.changeTracker.added || this.changeTracker.added.length === 0) && + (!this.changeTracker.removed || this.changeTracker.removed.length === 0) && + (!this.changeTracker.updated || this.changeTracker.updated.length === 0) + ) { + return + } + + // 使用循环缓冲区存储历史记录 + const nextIndex = (this.historyIndex + 1) % this.maxHistorySteps + this.historySteps[nextIndex] = { + diff: _.cloneDeep(this.changeTracker), + timestamp: Date.now() + } + + this.historyIndex = nextIndex + this.saveToLocalstore() + } + + + /** + * 将当前数据 与 viewport 进行同步, 对比出不同的部分,分别进行更新 + * - 调用 viewport.beginSync() 开始更新场景 + * - 调用 viewport.syncOnRemove(id) 删除场景中不存在的对象 + * - 调用 viewport.syncOnAppend(vdataItem) 添加场景中新的对象 + * - 调用 viewport.syncOnUpdate(id) 更新场景中已存在的对象 + * - 调用 viewport.endSync() 结束更新场景 + */ + syncDataState() { + // 没有变化时跳过同步 + if ( + (!this.changeTracker.added || this.changeTracker.added.length === 0) && + (!this.changeTracker.removed || this.changeTracker.removed.length === 0) && + (!this.changeTracker.updated || this.changeTracker.updated.length === 0) + ) { + return + } + + this.viewport.beginSync() + + // 处理删除 + if (this.changeTracker.removed) { + for (const id of this.changeTracker.removed) { + this.viewport.syncOnRemove(id) + } + } + + // 处理新增 + if (this.changeTracker.added) { + for (const item of this.changeTracker.added) { + this.viewport.syncOnAppend(item) + } + } + + // 处理更新 + if (this.changeTracker.updated) { + for (const item of this.changeTracker.updated) { + this.viewport.syncOnUpdate(item) + } + } + + this.viewport.endSync() + } + + + /** + * 从外部加载数据 + */ + async load(items: VDataItem[]) { + this.isLoading.value = true + + try { + // 直接替换数组引用(避免响应式开销) + this.vdata.items = items + + // 初始状态作为第一步 + this.beginUserWrite() + this.endUserWrite() + + this.isChanged.value = false + } finally { + this.isLoading.value = false + } + } + + /** + * 保存数据到外部 + */ + async save(): Promise { + return _.cloneDeep(this.vdata) + } + + /** + * 撤销 + */ + undo() { + if (!this.undoEnabled()) return + + const step = this.historySteps[this.historyIndex] + if (!step) return + + this.applyReverseDiff(step.diff) + this.historyIndex = (this.historyIndex - 1 + this.maxHistorySteps) % this.maxHistorySteps + this.isChanged.value = true + this.syncDataState() + } + + /** + * 重做 + */ + redo() { + if (!this.redoEnabled()) return + + this.historyIndex = (this.historyIndex + 1) % this.maxHistorySteps + const step = this.historySteps[this.historyIndex] + if (!step) return + + this.applyDiff(step.diff) + this.isChanged.value = true + this.syncDataState() + } + + + /** + * 应用正向差异 + */ + private applyDiff(diff: DataDiff) { + // 处理删除 + if (diff.removed) { + this.vdata.items = this.vdata.items.filter(item => !diff.removed.includes(item.id)) + } + + // 处理新增 + if (diff.added) { + this.vdata.items.push(...diff.added) + } + + // 处理更新 + if (diff.updated) { + const updateMap = new Map(diff.updated.map(item => [item.id, item])) + this.vdata.items = this.vdata.items.map(item => + updateMap.has(item.id) ? updateMap.get(item.id)! : item + ) + } + } + + /** + * 应用反向差异(用于撤销) + */ + private applyReverseDiff(diff: DataDiff) { + // 反向处理:删除 → 添加 + if (diff.removed) { + // 从历史快照恢复被删除的项目 + const restoredItems = diff.removed + .map(id => this.lastSnapshot.get(id)) + .filter(Boolean) as VDataItem[] + + this.vdata.items.push(...restoredItems) + } + + // 反向处理:添加 → 删除 + if (diff.added) { + const addedIds = new Set(diff.added.map(item => item.id)) + this.vdata.items = this.vdata.items.filter(item => !addedIds.has(item.id)) + } + + // 反向处理:更新 → 恢复旧值 + if (diff.updated) { + const restoreMap = new Map( + diff.updated + .map(item => [item.id, this.lastSnapshot.get(item.id)]) + .filter(([, item]) => !!item) as [string, VDataItem][] + ) + + this.vdata.items = this.vdata.items.map(item => + restoreMap.has(item.id) ? restoreMap.get(item.id)! : item + ) + } + } + + /** + * 保存到本地存储(防止数据丢失) + */ + async saveToLocalstore() { + // 只保存变化部分和关键元数据 + const saveData = { + diff: this.changeTracker, + timestamp: Date.now(), + itemsCount: this.vdata.items.length + } + + await localforage.setItem(`scene-tmp-${this.id}`, saveData) + } + + /** + * 从本地存储加载数据 + */ + async loadFromLocalstore() { + const saved: any = await localforage.getItem(`scene-tmp-${this.id}`) + if (saved && saved.diff) { + this.applyDiff(saved.diff) + this.isChanged.value = true + } + } + + undoEnabled() { + return this.historyIndex >= 0 && this.historySteps[this.historyIndex] !== undefined + } + + redoEnabled() { + const nextIndex = (this.historyIndex + 1) % this.maxHistorySteps + return this.historySteps[nextIndex] !== undefined + } + + /** + * 删除本地存储 + */ + async removeLocalstore() { + await localforage.removeItem(`scene-tmp-${this.id}`) + } +} + + +// 差异类型定义 +interface DataDiff { + added: VDataItem[] + removed: string[] + updated: VDataItem[] +} + +// 历史记录项 +interface HistoryStep { + diff: DataDiff + timestamp: number +} \ No newline at end of file diff --git a/src/designer/Viewport.ts b/src/designer/Viewport.ts index 1bb402a..c38e0bd 100644 --- a/src/designer/Viewport.ts +++ b/src/designer/Viewport.ts @@ -16,6 +16,7 @@ import type Toolbox from '@/model/itemType/Toolbox.ts' import { calcPositionUseSnap } from '@/model/ModelUtils.ts' import SelectInspect from '@/designer/model2DEditor/tools/SelectInspect.ts' import MouseMoveInspect from '@/designer/model2DEditor/tools/MouseMoveInspect.ts' +import type { CursorMode, VDataItem } from '@/types/Types' /** * 编辑器对象 @@ -32,7 +33,7 @@ export default class Viewport { controls: OrbitControls worldModel: WorldModel raycaster: THREE.Raycaster - dragControl: EsDragControls + dragControl: any // EsDragControls animationFrameId: any = null //搭配 state.cursorMode = xxx 之后, currentTool.start(第一个参数) 使用 @@ -44,6 +45,23 @@ export default class Viewport { ] toolbox: Record = {} + objectMap: Map = new Map() + + beginSync() { + } + + syncOnRemove(id: string) { + } + + syncOnUpdate(item: VDataItem) { + } + + syncOnAppend(item: VDataItem) { + } + + endSync() { + } + /** * 监听窗口大小变化 */ diff --git a/src/runtime/System.ts b/src/runtime/System.ts index 7c37cf3..29289c9 100644 --- a/src/runtime/System.ts +++ b/src/runtime/System.ts @@ -6,7 +6,7 @@ import hotkeys from 'hotkeys-js' import { defineComponent, h, markRaw, nextTick, reactive, toRaw, unref, type App, createApp, type Component } from 'vue' import { ElMessage, ElMessageBox, ElNotification } from 'element-plus' import { QuestionFilled } from '@element-plus/icons-vue' -import { renderIcon } from '@/utils/webutils.ts' +import { decompressUUID, renderIcon, createShortUUID } from '@/utils/webutils.ts' import type { showDialogOption } from '@/SystemOption' import ShowDialogWrap from '@/components/ShowDialogWrap.vue' import LoadingDialog from '@/components/LoadingDialog.vue' @@ -38,6 +38,9 @@ export default class System { */ rootElementList: { cmp: Component, props: any }[] = reactive([]) + createUUID = createShortUUID + decompressUUID = decompressUUID + constructor(app: App) { this.app = app window['_'] = _ diff --git a/src/types/Types.d.ts b/src/types/Types.d.ts index 98ab9dd..3bcd896 100644 --- a/src/types/Types.d.ts +++ b/src/types/Types.d.ts @@ -1,4 +1,4 @@ -type CursorMode = +export type CursorMode = 'normal' | 'ALink' | 'SLink' @@ -17,4 +17,54 @@ type PointType = | 'pointMarker2' | 'pointMarker3' | 'pointMarker4' - | 'pointMarker5' \ No newline at end of file + | 'pointMarker5' + + +export export interface VData { + /** + * 场景数据 + */ + items: VDataItem[] + + /** + * 是否发生了变化,通知外部是否需要保存数据 + */ + isChanged: boolean +} + +export interface VDataItem { + /** + * { + * id: 'p1', // 物体ID, 唯一标识, 需保证唯一, three.js 中的 uuid + * t: 'measure', // 物体类型, measure表示测量, 需交给 itemType.name == 'measure' 的组件处理 + * l: '测量1', // 标签名称, 显示用 + * c: '#ff0000', // 颜色, 显示用. 十六进制颜色值, three.js 中的材质颜色 + * tf: [ // 变换矩阵, 3x3矩阵, 采用Y轴向上为正, X轴向右, Z轴向前的右手坐标系 + * [-9.0, 0, -1.0], // 平移向量 position + * [0, 0, 0], // 旋转向量 rotation, 表示绕Y轴旋转的角度, 单位为度。对应 three.js 应进行"角度"转"弧度"的换算 + * [0.25, 0.1, 0.25] // 缩放向量 scale + * ], + * dt: { // 用户数据, 可自定义, 一般用在 three.js 的 userData 中 + * center: ['p2'], // 用于 a='ln' 的测量线段, 关联的点对象(uuid) + * in: [], // 物流入方向关联的对象(uuid) + * out: [] // 物流出方向关联的对象(uuid) + * ... // 其他自定义数据 + * } + * } + */ + id: string + t: string + l: string + c: string + tf: [ + [number, number, number], + [number, number, number], + [number, number, number], + ] + dt: { + center?: string[] // 用于 a='ln' 的测量线段, 关联的点对象(uuid) + in?: string[] // 物流入方向关联的对象(uuid) + out?: string[] // 物流出方向关联的对象(uuid) + [key: string]: any // 其他自定义数据 + } +} \ No newline at end of file diff --git a/src/utils/webutils.ts b/src/utils/webutils.ts index b386812..771b276 100644 --- a/src/utils/webutils.ts +++ b/src/utils/webutils.ts @@ -4,6 +4,7 @@ import * as AntdIcon from '@vicons/antd' import { ElIcon } from 'element-plus' import * as FaIcon from '@vicons/fa' import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import * as THREE from 'three' /** * 从 0x409EFF 数字变为字符串 '#409EFF' @@ -21,6 +22,72 @@ export function getColorFromCode(value: number): string { } /** + * 生成一个短的 UUID 字符串 + */ +export function createShortUUID() { + // 生成一个标准的 UUID + return compressUUID(THREE.MathUtils.generateUUID()) +} + +/** + * 压缩 UUID 为短字符串 + */ +export function compressUUID(uuid) { + // 移除连字符并转换为 ArrayBuffer + const raw = uuid.replace(/-/g, '') + const buf = new Uint8Array(16) + + for (let i = 0; i < 32; i += 2) { + buf[i / 2] = parseInt(raw.substr(i, 2), 16) + } + + // 将字节数组转换为 Base64 字符串 + const base64 = btoa(String.fromCharCode.apply(null, buf)) + + // 去掉 Base64 中的填充字符 '=' 并替换 '/' 为 '_', '+' 为 '-' 以便 URL 安全 + return base64 + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') +} + +/** + * 解压缩 UUID + */ +export function decompressUUID(shortUuid: string) { + // 补全 Base64 填充字符 + let padded = shortUuid + padded = padded.replace(/-/g, '+').replace(/_/g, '/') + while (padded.length % 4 !== 0) { + padded += '=' + } + + // 解码 Base64 + const binStr = atob(padded) + const buf = new Uint8Array(binStr.length) + + for (let i = 0; i < binStr.length; i++) { + buf[i] = binStr.charCodeAt(i) + } + + // 转换为标准 UUID 格式 + const hex = [] + for (let i = 0; i < 16; i++) { + hex.push((buf[i] >> 4).toString(16)) + hex.push((buf[i] & 0x0f).toString(16)) + } + + const raw = hex.join('') + return ( + raw.substr(0, 8) + '-' + + raw.substr(8, 4) + '-' + + raw.substr(12, 4) + '-' + + raw.substr(16, 4) + '-' + + raw.substr(20, 12) + ) +} + +/** * 从 '#409EFF' 数字变为数字 0x409EFF */ export function getColorFromString(value: string): number {