## 物流世界 一个物流仓库, 就是一个世界 他有自己的项目定义, 楼层, 围墙, 柱子, 其他数据, 个性化脚本等等 每次对建模文件打开的时候, 因为性能问题, 一次只会读取一个楼层, 或者一个水平横截面. 因此, 一个 floor 就是对应一个 THREE.Scene ### 物流世界数据示例 ```ts export default { project_uuid: 'example1', Tool: { Group: [], // 分组 GlobalVariables: [], // 全局变量 UserCommand: [], // 全局脚本 Dashboard: [], // 监控面板 DataTable: [], // 地图自带的数据 Trigger: [ // 触发器 { name: 'OnOpen', fn: '' }, // 打开 { name: 'OnReset', fn: '' }, // 仿真重置 { name: 'OnStart', fn: '' }, // 开始仿真 { name: 'OnStop', fn: '' } // 停止仿真 ], gridHelper: { // 网格辅助线 axesEnabled: true, // 是否显示中心轴 axesSize: 50, // 中心轴长度 axesDivisions: 2, // 中心轴分割数 axesColor: 0x000000, // 中心轴颜色 axesOpacity: 1, // 中心轴透明度 gridEnabled: true, // 是否显示网格 gridSize: 1000, // 网格大小 gridDivisions: 1000, // 网格分割数 gridColor: 0x999999, // 网格颜色 gridOpacity: 0.8, // 网格透明度 snapEnabled: true, // 是否启用吸附 snapDistance: 0.25 // 吸附距离 } }, items: [ { catalogCode: 'f1', t: 'floor', // 楼层 items: [ { id: 'p1', // 物体ID, 唯一标识, 需保证唯一, three.js 中的 uuid t: 'measure', // 物体类型, measure表示测量, 需交给 itemType.name == 'measure' 的组件处理 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 中 label: '测量1', // 标签名称, 显示用 color: '#ff0000', // 颜色, 显示用. 十六进制颜色值, three.js 中的材质颜色 center: ['p2'], // 用于 a='ln' 的测量线段, 关联的点对象(uuid) in: [], // 物流入方向关联的对象(id) out: [] // 物流出方向关联的对象(id) } }, { id: 'p2', t: 'measure', tf: [[-9.0, 0, 3], [0, 0, 0], [0.25, 0.1, 0.25]], dt: { color: '#ff0000', label: '测量2', center: ['p1', 'p3', 'p4'] } }, { id: 'p3', t: 'measure', tf: [[-5.0, 0, 3], [0, 0, 0], [0.25, 0.1, 0.25]], dt: { label: '测量3', center: ['p2'] } }, { id: 'p4', t: 'measure', tf: [[-9.0, 0, 8], [0, 0, 0], [0.25, 0.1, 0.25]], dt: { label: '测量4', center: ['p2'] } } ] } ], elevator: [], // 电梯 wall: [], // 墙体 pillar: [], // 柱子 catalog: [ // 目录 { label: '仓库楼层', // 目录分组名 items: [ { catalogCode: '-f1', label: '地下室 (-f1)' }, // 目录项 { catalogCode: 'f1', label: '一楼 (f1)' }, { catalogCode: 'f2', label: '二楼 (f2)' }, { catalogCode: 'OUT', label: '外场 (OUT)' }, { catalogCode: 'fe', label: '楼层电梯 (fe)' } ] }, { label: '密集库区域', items: [ { catalogCode: 'm1', label: 'M1 (m1)' }, { catalogCode: 'm2', label: 'M2 (m2)' }, { catalogCode: 'm3', label: 'M3 (m3)' }, { catalogCode: 'm4', label: 'M4 (m4)' }, { catalogCode: 'me', label: '提升机 (me)' } ] }, { label: '多穿库A', items: [ { catalogCode: 'd1', label: 'D1 (d1)' }, { catalogCode: 'd2', label: 'D2 (d2)' }, { catalogCode: 'd3', label: 'D3 (d3)' }, { catalogCode: 'd4', label: 'D4 (d4)' }, { catalogCode: 'de1', label: '提升机 (de1)' } ] }, { label: '多穿库B', items: [ { catalogCode: 'e1', label: 'E1 (e1)' }, { catalogCode: 'e2', label: 'E2 (e2)' }, { catalogCode: 'e3', label: 'E3 (e3)' }, { catalogCode: 'e4', label: 'E4 (e4)' }, { catalogCode: 'ee1', label: '提升机 (ee1)' } ] } ] } ``` ### 物流世界模型 ```ts /** * 物流世界模型 */ export default class WorldModel { /** * 世界模型双向绑定的状态数据 */ state: WorldModelState = reactive({ isOpened: false, // 是否已打开世界模型 catalogCode: '', // 当前楼层的目录代码 isDraft: false, // 是否是草稿数据, 如果是草稿数据, 则不需要再从服务器加载数据 stateManagerId: '', // 当前楼层的状态管理器id, 一般是 项目ID+目录项ID catalog: [] as Catalog // 世界模型目录 }) /** * 初始化世界模型 */ init() /** * 读取世界地图数据目录 */ loadCatalog(data: any) /** * 当楼层发生改变时调用此方法, 将事件派发出去 */ onCatalogCodeChanged(catalogCode: string) /** * 从服务器获取当前目录楼层的所有数据 */ async getCatalogData(catalogCode: string): Promise> } ``` ### 状态管理器 StateManager ```ts /** 数据状态管理器 他能够对数据进行载入, 保存草稿, 载入草稿, 数据增删改查,撤销、重做等操作 最终他会将数据的变化同步到 EntityManager 中 主要功能包括: 1. 管理场景数据的读取、保存, 以及临时保存、临时读取等功能 2. 管理撤销、重做功能 3. 交互控制组件通过 update() 方法修改数据 this.viewport.stateManager.update(({ getEntity, putEntity, deleteEntity, addEntity }) => { const entity = getEntity(id) // 获取实体 entity.abc = 123 putEntity(entity) // 提交修改 deleteEntity(id) // 删除实体 addEntity(newEntity) // 添加新实体 }) 4. 内部如果进行了撤销、还原等操作,会通过 syncDataState() 方法将 vdata 数据与 EntityManager 进行同步 5. 注意,如果正在读取中,需要设置 isLoading = true,外部需要等待加载完成后再进行操作 主要难点: - 单张地图数据量可能超过 10000 个对象, 需要高效的管理数据状态 */ export default class StateManager { /** * 唯一场景标识符, 用于做临时存储的 key */ readonly id: string /** * 实体对象, 用于同步当前场景的状态 */ readonly entityManager: EntityManager /** * 是否发生了变化,通知外部是否需要保存数据 */ readonly isChanged = ref(false) /** * 是否正在加载数据,通知外部是否需要等待加载完成 */ readonly isLoading = ref(false) readonly storeKey: string /** * 当前场景数据 */ vdata: VData /** * 使用循环缓冲区存储历史记录 */ private historySteps: HistoryStep[] = [] private historyIndex = -1 private maxHistorySteps = 20 // 变化追踪器 private readonly changeTracker: DataDiff = { added: [], removed: [], updated: [] } /** * 数据快照(用于差异计算) */ private lastStateDict = new Map() /** * @param id 唯一场景标识符, 用于做临时存储的 key * @param viewport 视口对象 * @param maxHistorySteps 最大回撤步数 默认为 20 */ constructor(id: string, viewport: Viewport, maxHistorySteps = 20) /** * 将当前数据 与 entityManager 进行同步, 对比出不同的部分,分别进行更新 * - 调用 entityManager.beginEntityUpdate() 开始更新 * - 调用 entityManager.createOrUpdateEntity(vdataItem) 添加场景中新的实体 * - 调用 entityManager.deleteEntity(id) 删除场景中的实体 * - 调用 entityManager.endEntityUpdate() 结束更新场景 */ syncDataState(diff: DataDiff): void /** * 从外部加载数据 */ async load(items: VDataItem[]) /** * 保存数据到外部 */ async save(): Promise /** * 撤销 */ undo() /** * 重做 */ redo() /** * 使用 updater 函数更新状态, 并同步转换为实体和渲染 */ update(updaterFn: (updater: StateUpdater) => void): void /** * 保存到本地存储 浏览器indexDb(防止数据丢失) */ async saveToLocalstore() /** * 从本地存储还原数据 */ async loadFromLocalstore() /** * 删除本地存储 */ async removeLocalstore() } // 状态更新器接口 interface StateUpdater { getEntity(id: string): ItemJson putEntity(entity: ItemJson): void // 修改已有实体 deleteEntity(id: string): boolean // 删除实体 addEntity(entity: ItemJson): void // 添加新实体 } ``` ### 单元点数据 ItemJson ```ts /** * 物体单元(点) */ export interface ItemJson { /** * 对应 three.js 中的 uuid, 物体ID, 唯一标识, 需保证唯一, 有方法可以进行快速的 O(1) 查找 */ id?: string /** * 物体名称, 显示用, 最后初始化到 three.js 的 name 中, 可以不设置, 可以不唯一, 但他的查找速度是 O(N) */ name?: string /** * "点"的物体单元类型, 最终对应到 measure / conveyor / task 等不同的单元处理逻辑中 */ t: string /** * 变换矩阵, 3x3矩阵, 采用Y轴向上为正, X轴向右, Z轴向前的右手坐标系 */ tf: [ /** * 平移向量 position, 三维坐标 */ [number, number, number], /** * 旋转向量 rotation, 表示绕Y轴旋转的角度, 单位为度。对应 three.js 应进行"角度"转"弧度"的换算 */ [number, number, number], /** * 缩放向量 scale, 三维缩放比例 */ [number, number, number], ] /** * 用户数据, 可自定义, 一般用在 three.js 的 userData 中 */ dt: { /** * 标签名称, 显示用, 最后初始化到 three.js 的 userData.label 中, 最终应该如何渲染, 每个单元类型有自己的逻辑, 取决于物流单元类型t的 renderer 逻辑 */ label?: string /** * 颜色, 最后初始化到 three.js 的 userData.color 中, 最终颜色应该如何渲染, 每个单元类型有自己的逻辑, 取决于物流单元类型t的 renderer 逻辑 */ color?: string /** * S连线(又称逻辑连线), 与其他点之间的无方向性关联, 关系的起点需要在他的 dt.center[] 数组中添加目标点的id, 关系的终点需要在他的 dt.center[] 数组中添加起点的 id */ center?: string[] /** * A连线(又称物体流动线)的输入, 关系的终点需要在 dt.in[] 数组中添加起点的 id */ in?: string[] /** * A连线(又称物体流动线)的输出, 关系的起点需要在 dt.out[] 数组中添加目标点的 id */ out?: string[] /** * 是否可以被选中, 默认 true */ selectable?: boolean /** * 是否受保护, 不可在图形编辑器中拖拽, 默认 false */ protected?: boolean /** * 在 ThreeJs 中, 这个点应当属于哪个父元素 */ parentId?: string /** * 其他自定义数据, 可以存储任何数据 */ [key: string]: any }, } ``` ## 场景和视窗 ### 场景 SceneHelp ```ts /** * 场景对象 * 通常是某个楼层的所有物品, 对应一个 Three.Scene, 每个场景可能会有多个不同的 Viewport 对他进行观察 */ export default class SceneHelp { scene: THREE.Scene } ``` ### 视窗 Viewport ```ts /** * 视窗对象 * 所有状态管理器,场景,控制器,摄像机,实体管理器, 都在这里可以取到 */ export default class Viewport { sceneHelp: SceneHelp viewerDom: HTMLElement camera: THREE.OrthographicCamera renderer: THREE.WebGLRenderer statsControls: Stats controls: OrbitControls raycaster: THREE.Raycaster animationFrameId: any = null selectManager = new SelectManager() mouseMoveManager = new MouseMoveManager() dragManager = new DragManager() labelManager = new LabelManager() entityManager = new EntityManager() itemFindManager = new ItemFindManager() interactionManager = new InteractionManager() // 状态管理器 stateManager: StateManager // 对象实例管理器 moduleName -> InstanceMeshManager meshManager: Map = new Map() // 线段实例管理器 moduleName -> LineSegmentManager lineSegmentManagerMap: Map = new Map() constructor(sceneHelp: SceneHelp, viewerDom: HTMLElement) } ``` ### 实体管理器 EntityManager ```ts /** 实体管理器 缓存所有 数据(ItemJson)和他们的关系, 以及渲染对象 THREE.Object3D 数据更新的流程是: 1. 状态管理器 StateManager 发出数据被修改的请求, 这些请求通常只有 ItemJson 数据 2. 实体管理器 (EntityManager) 需要计算所有相关的点和线的变化. 在 endUpdate 时需要根据 center / in / out 关系, 计算出: 点的创建 / 点的更新 / 点的删除 / 线的创建 / 线的更新 / 线的删除 3. 通知对应的渲染器 (Renderer) 进行点和线的创建 / 更新 / 删除 */ export class EntityManager { viewport: Viewport // 所有数据点的实体 private readonly entities = new Map() // 关系索引 private readonly relationIndex = new Map() // 所有 THREEJS "点"对象, 检索值是"点实体"的 id, 值是 THREE.Object3D 数组 private readonly objects = new Map() // 所有 THREEJS "线"对象, 检索值是"线实体"的 id, 取值方式是 {type}${startId}${endId}, 值是 THREE.Object3D 数组 private readonly lines = new Map() // 差量渲染器 private readonly diffRenderer = new Map() // 线差量记录 private readonly lineDiffs = { create: new Map(), update: new Map(), delete: new Map() } constructor(viewport: Viewport) /** * 批量更新开始 */ beginEntityUpdate(): void /** * 创建或更新一个实体, 这个点的 center[] / in[] / out[] 关联的点, 可能都要对应进行关联 */ createOrUpdateEntity(entity: ItemJson, option: EntityCudOption = {}): void /** * 删除实体, 与这个点有关的所有线都要删除, 与这个点有关系的点,都要对应的 center[] / in[] / out[] 都要断绝关系 */ deleteEntity(id: string, option: EntityCudOption = {}): void /** * 批量更新结束, 结束后会触发视窗的渲染 * 这个方法最重要的是进行连线逻辑的处理 * - 如果进行了添加, 那么这个点的 center[] / in[] / out[] 关联的点, 都要对应进行关联 * - 如果进行了删除, 与这个点有关的所有线都要删除, 与这个点有关系的点,都要对应的 center[] / in[] / out[] 都要断绝关系 * - 如果进行了更新, 如果改了颜色/位置, 则需要在UI上进行对应修改,如果改了关系,需要与关联的节点批量调整 * 将影响到的所有数据, 都变成一个修改集合, 统一调用对应单元类型渲染器(BaseRenderer)的 createPoint / deletePoint / updatePoint / createLine / updateLine / deleteLine 方法 */ endEntityUpdate(): void /** * 更新关系关系网, 计算出差值, 可以临时放在 diffRenderer 中, 等待 commitUpdate 时统一处理 */ private updateRelations(entity: ItemJson): void private updateReverseRelations(id: string, relatedIds: string[] | undefined, relationType: 'center' | 'in' | 'out'): void /** * 删除关系关系网, 计算出差值, 可以临时放在 diffRenderer 中, 等待 commitUpdate 时统一处理 */ private removeRelations(id: string): void } /** * 关系详情 */ export class Relation { center = new Set() input = new Set() output = new Set() add(type: LinkType, id: string): boolean delete(type: LinkType, id: string): boolean } ``` ### 交互管理器 InteractionManager ```ts export default class InteractionManager { private viewport: Viewport; /** * 当前激活的交互工具 */ private currentTool: BaseInteraction | null = null constructor(viewport: Viewport) { this.viewport = viewport; } } ``` ## 物流单元封装 物流单元组件封装, 每个组件类别都会有 - 渲染器 renderer - 交互控制器 interaction - 实体定义 entity - 属性元数据 meta 比如测量组件 Measure, 他属于最基础的线类型物流单元. ### 基础渲染器 base.renderer ```ts /** 基本渲染器基类 定义了点 / 线如何渲染到 Three.js 场景中. 后期考虑调用 InstancePool 渲染 */ public export default BaseRenderer { /** * 每次 beginUpdate 时记录临时 viewport, endUpdate 之后要马上删除, 因为 BaseRenderer 全局只有一个实例, 而 viewport 有多个 */ tempViewport?: Viewport = undefined /** * 开始更新 * @param viewport 当前视口 */ beginRendererUpdate(viewport: Viewport): void { this.tempViewport = viewport } /** * 创建一个最基本的点对象, 不用管 item 的 name / id / 位置 / 转换 / 大小 和 userData, 除非有明确定义 */ abstract createPointBasic(item: ItemJson, option?: RendererCudOption): THREE.Object3D[] /** * 创建测量线 */ abstract createLineBasic(start: ItemJson, end: ItemJson, type: LinkType): THREE.Object3D[] /** * 将对象添加到当前视口的场景中 */ appendToScene(...objects: THREE.Object3D[]): void /** * 创建一个点 * @param item 点的定义 * @param option 渲染选项 */ createPoint(item: ItemJson, option?: RendererCudOption): void /** * 删除一个点 * @param id 点的唯一标识 * @param option 渲染选项 */ deletePoint(id: string, option?: RendererCudOption): void /** * 更新一个点 * @param item 点的定义 * @param option 渲染选项 */ updatePoint(item: ItemJson, option?: RendererCudOption): void /** * 创建一根线 * @param start 起点 * @param end 终点 * @param type 线的类型 * @param option 渲染选项 */ createLine(start: ItemJson, end: ItemJson, type: LinkType, option?: RendererCudOption): void /** * 更新一根线 * @param start 起点 * @param end 终点 * @param type 线的类型 * @param option 渲染选项 */ updateLine(start: ItemJson, end: ItemJson, type: LinkType, option?: RendererCudOption): void /** * 删除一根线 * @param start 起点 * @param end 终点 * @param option 渲染选项 */ deleteLine(start: ItemJson, end: ItemJson, option?: RendererCudOption): void /** * 结束更新 */ endRendererUpdate(): void } ``` ### 测量尺渲染器 measure.renderer ```ts /** * 辅助测量工具渲染器 */ export default class MeasureRenderer extends BaseRenderer { /** * 当前测绘内容组, 所有测量点、线、标签都在这个组中. 但不包括临时点、线 */ group: THREE.Group static GROUP_NAME = 'measure_group' static LABEL_NAME = 'measure_label' static POINT_NAME = 'measure_point' static LINE_NAME = 'measure_line' pointMaterial = new THREE.MeshBasicMaterial({ color: 0x303133, transparent: true, opacity: 0.9 }) lineMaterial = new LineMaterial({ color: 0xE63C17, // 主颜色 linewidth: 2, // 实际可用的线宽 vertexColors: true, // 启用顶点颜色 dashed: false, alphaToCoverage: true }) createLineBasic(start: ItemJson, end: ItemJson, type: LinkType): THREE.Object3D[] { const geom = new LineGeometry() const obj = new Line2(geom, this.lineMaterial) obj.frustumCulled = false obj.name = MeasureRenderer.LINE_NAME obj.uuid = getLineId(start.id, end.id, type) return [obj] } createPointBasic(item: ItemJson, option?: RendererCudOption): THREE.Object3D[] { const tt = new THREE.BoxGeometry(1, 1, 1) const obj = new THREE.Mesh(tt, this.pointMaterial) obj.name = MeasureRenderer.POINT_NAME obj.uuid = item.id return [obj] } appendToScene(...objects: THREE.Object3D[]) { if (!this.group) { this.group = new THREE.Group() this.group.name = MeasureRenderer.GROUP_NAME this.tempViewport?.scene.add(this.group) } this.group.add(...objects) } } ``` ### 实例池 InstancePool ```ts { 不知道怎么做 } ``` ### 基础交互控制器 base.interaction ```ts /** * 交互控制器基类 * 定义了在建模编辑器里面物流单元, 如何响应鼠标, 键盘的操作. * 每个物流单元类型, 全局只有一个实例 */ public default class BaseInteraction { // 初始化 init(viewport: Viewport) // 测量工具开始, 监听 Three.Renderer.domElement 的鼠标事件, 有可能用户会指定以某个点为起点, 也有可能第一个点由用户点击而来 start(startPoint?: THREE.Object3D) // 用户鼠标移动时, 判断是否存在起点, 有起点就要实时构建临时线, 临时线的中间创建一个 Label 显示线的长度. 每次鼠标移动都要进行重新调整 onMousemove(e: MouseEvent) // 用户如果点左键, 就调用 MeasureRenderer.createPoint 创建点, 如果点击右键就调用 Stop 退出工具 onMouseup(e: MouseEvent) // 退出这个工具的点击, 停止对 Three.Renderer.domElement 的监听 stop() // 用户在设计器拽动某个点时触发. 这时调整的都是 "虚点" 和 "虚线" dragPointStart(point: THREE.Object3D) // 用户在设计器拖拽完毕后触发, 他会触发 MeasureRenderer.updatePoint 事件 dragPointComplete() // 在用户开始测量工具 start / 或拖拽 dragPointStart 过程中,会创建临时点和临时线, 辅助用户 // 临时点在一次交互中只会有一个 createOrUpdateTempPoint(e: MouseEvent) // 临时线在一次交互中, 可能会有多个, 取决于调整的点 有多少关联点, 都要画出虚线和label createOrUpdateTempLine(label: string, pointStart: THREE.Object3D, pointEnd: THREE.Object3D) // 清空所有临时点和线 clearTemps() } ``` ### 测量尺 交互控制器 measure.interaction ```ts public default class MeasureInteraction extends BaseInteraction { // 用户在设计器拽动某个点时触发. 这时调整的都是 "虚点" 和 "虚线" dragPointStart(point: THREE.Object3D) // 用户在设计器拖拽完毕后触发, 他会触发 MeasureRenderer.updatePoint 事件 dragPointComplete() } ``` ### 属性元数据 meta ```ts /** * 他定义了数据如何呈现在属性面板, 编辑器如果修改, 配合 Entity 该如何进行 */ export default { // "点"属性面板 point: { // 基础面板 basic: [ { field: 'uuid', editor: 'UUID', label: 'uuid', readonly: true }, { field: 'name', editor: 'TextInput', label: '名称' }, { field: 'dt.label', editor: 'TextInput', label: '标签' }, { editor: 'TransformEditor' }, { field: 'dt.color', editor: 'Color', label: '颜色' }, { editor: '-' }, { field: 'tf', editor: 'InOutCenterEditor' }, { field: 'dt.selectable', editor: 'Switch', label: '可选中' }, { field: 'dt.protected', editor: 'Switch', label: '受保护' }, { field: 'visible', editor: 'Switch', label: '可见' } ] }, // "线"属性面板 line: { ...xxx } } ``` ### 实体定义 entity ```ts /** * 基本"点"属性操作代理实体 * 这个对象不易大量存在, 只有在绑定控制面板, 高级属性对话框, 或操作 Modeltree, 或脚本控制的时候才被实例化 * 他提供操作的抽象代理, 最终可能调用 DataStateManager 进行数据的保存 */ export default class BaseItemEntity { setItem(itemJson: ItemJson) setObject(point: THREE.Object3D) } /** * 基本"线"属性操作代理实体 * 这个对象不易大量存在, 只有在绑定控制面板, 高级属性对话框, 或操作 Modeltree, 或脚本控制的时候提供Proxy操作代理 */ export default class BaseLineEntity { setItem(start: ItemJson, end: ItemJson) setObjects(line: THREE.Object3D[]) } /** * "线"属性 操作代理实体 */ export class MeasurePoint extends BaseItemEntity { ... 各种 get / set / method } /** * "线"属性 操作代理实体 */ export class MeasureLine extends BaseLineEntity { ... 各种 get / set / method } ``` 所有的模型操作都遵循如流程 StateManager -> EntityManager -> xxx.renderer -> InstancePool 辅助类的操作则是通过交互控制器 interaction 完成临时辅助对象的创建, 辅助线 / 临时单元 是不会被持久化的 点之间的关系不会非常复杂, 通常是比较稀疏的, 可能一个点最多有6个连线, 绝大部分点 只有1~2个关系连线.