diff --git a/src/services/editor-robot.service.ts b/src/services/editor-robot.service.ts new file mode 100644 index 0000000..766a36e --- /dev/null +++ b/src/services/editor-robot.service.ts @@ -0,0 +1,524 @@ +import type { MapPen, Rect } from '@api/map'; +import type { RobotGroup, RobotInfo, RobotLabel, RobotType } from '@api/robot'; +import type { SceneData } from '@api/scene'; +import sTheme from '@core/theme.service'; +import { CanvasLayer, LockState, type Meta2dStore, s8 } from '@meta2d/core'; +import { useObservable } from '@vueuse/rxjs'; +import { clone, get, isNil, nth, remove, some } from 'lodash-es'; +import { BehaviorSubject, debounceTime } from 'rxjs'; +import { reactive } from 'vue'; + +import cargoIcon from '../assets/icons/png/cargo.png'; +import chargingIcon from '../assets/icons/png/charging.png'; +import notAcceptingOrdersIcon from '../assets/icons/png/notAcceptingOrders.png'; +import colorConfig from './color/color-config.service'; + +interface RobotStatusPosition { + x: number; + y: number; + rotate: number; +} + +export interface EditorRobotContext { + store: Meta2dStore; + data(): any; + setValue(pen: Partial & { id: string }, options: { render: boolean; history: boolean; doEvent: boolean }): void; + updatePen(id: string, pen: Partial, record?: boolean, render?: boolean): void; + addPen(pen: MapPen, history?: boolean, storeData?: boolean, render?: boolean): Promise; + top(pens: MapPen[]): void; + render(): void; + getPenById(id?: string): MapPen | undefined; + getPenRect(pen: MapPen): Rect; + find(target: string): MapPen[]; + inactive(): void; + ensureCorrectLayerOrder(): void; +} + +export class EditorRobotService { + private readonly robotMap = reactive>(new Map()); + private readonly robotGroups$$ = new BehaviorSubject([]); + private readonly robotLabels$$ = new BehaviorSubject([]); + + public readonly robotGroups = useObservable(this.robotGroups$$.pipe(debounceTime(300))); + public readonly robotLabels = useObservable(this.robotLabels$$.pipe(debounceTime(300))); + + constructor(private readonly ctx: EditorRobotContext) {} + + //#region 初始化与基础数据 + public loadInitialData(groups?: RobotGroup[], robots?: RobotInfo[], labels?: RobotLabel[]): void { + this.robotMap.clear(); + robots?.forEach((v) => this.robotMap.set(v.id, v)); + this.robotGroups$$.next(groups ?? []); + this.robotLabels$$.next(labels ?? []); + this.syncRobots(); + this.syncRobotGroups(); + this.syncRobotLabels(); + } + + public get robots(): RobotInfo[] { + return Array.from(this.robotMap.values()); + } + + public hasRobot(id: RobotInfo['id']): boolean { + return this.robotMap.has(id); + } + + public getRobotById(id: RobotInfo['id']): RobotInfo | undefined { + return this.robotMap.get(id); + } + + public updateRobot(id: RobotInfo['id'], value: Partial): void { + const robot = this.getRobotById(id); + if (isNil(robot)) return; + this.robotMap.set(id, { ...robot, ...value }); + if (value.label) { + this.ctx.setValue({ id, text: value.label }, { render: false, history: false, doEvent: false }); + } + this.syncRobots(); + } + + public addRobots(gid: RobotInfo['gid'], robots: RobotInfo[]): void { + const groups = clone(this.robotGroups$$.value); + const group = groups.find((v) => v.id === gid); + if (isNil(group)) throw Error('未找到目标机器人组'); + group.robots ??= []; + robots.forEach((robot) => { + if (this.robotMap.has(robot.id)) return; + this.robotMap.set(robot.id, { ...robot, gid }); + group.robots?.push(robot.id); + }); + this.robotGroups$$.next(groups); + this.syncRobots(); + this.syncRobotGroups(); + } + + public removeRobots(ids: RobotInfo['id'][]): void { + ids?.forEach((id) => this.robotMap.delete(id)); + const groups = clone(this.robotGroups$$.value); + groups.forEach(({ robots }) => remove(robots ?? [], (id) => !this.robotMap.has(id))); + this.robotGroups$$.next(groups); + const labels = clone(this.robotLabels$$.value); + labels.forEach(({ robots }) => remove(robots ?? [], (id) => !this.robotMap.has(id))); + this.robotLabels$$.next(labels); + this.syncRobots(); + this.syncRobotGroups(); + this.syncRobotLabels(); + } + + public updateRobots(ids: RobotInfo['id'][], value: Partial): void { + ids?.forEach((id) => { + const robot = this.robotMap.get(id); + if (isNil(robot)) return; + this.robotMap.set(id, { ...robot, ...value }); + }); + this.syncRobots(); + } + + public createRobotGroup(): void { + const id = s8(); + const label = `RG${id}`; + const groups = clone(this.robotGroups$$.value); + groups.push({ id, label }); + this.robotGroups$$.next(groups); + this.syncRobotGroups(); + } + + public deleteRobotGroup(id: RobotGroup['id']): void { + const groups = clone(this.robotGroups$$.value); + const group = groups.find((v) => v.id === id); + group?.robots?.forEach((rid) => this.robotMap.delete(rid)); + remove(groups, group); + this.robotGroups$$.next(groups); + this.syncRobots(); + this.syncRobotGroups(); + } + + public updateRobotGroupLabel(id: RobotGroup['id'], label: RobotGroup['label']): void { + const groups = this.robotGroups$$.value; + const group = groups.find((v) => v.id === id); + if (isNil(group)) throw Error('未找到目标机器人组'); + if (some(groups, ['label', label])) throw Error('机器人组名称已存在'); + group.label = label; + this.robotGroups$$.next([...groups]); + this.syncRobotGroups(); + } + + public createRobotLabel(): void { + const id = s8(); + const label = `RL${id}`; + const labels = clone(this.robotLabels$$.value); + labels.push({ id, label }); + this.robotLabels$$.next(labels); + this.syncRobotLabels(); + } + + public deleteRobotLabel(id: RobotLabel['id']): void { + const labels = clone(this.robotLabels$$.value); + const labelToDelete = labels.find((v) => v.id === id); + if (!labelToDelete) return; + + const robotsInLabel = labelToDelete.robots; + if (robotsInLabel) { + robotsInLabel.forEach((robotId) => { + const robot = this.getRobotById(robotId); + if (robot?.lid) { + const newLid = robot.lid.filter((lid) => lid !== id); + this.updateRobot(robot.id, { lid: newLid }); + } + }); + } + + remove(labels, (l) => l.id === id); + this.robotLabels$$.next(labels); + this.syncRobotLabels(); + } + + public updateRobotLabel(id: RobotLabel['id'], labelName: RobotLabel['label']): void { + const labels = this.robotLabels$$.value; + const label = labels.find((v) => v.id === id); + if (isNil(label)) throw Error('未找到目标标签'); + if (some(labels, ['label', labelName])) throw Error('机器人标签名称已存在'); + label.label = labelName; + this.robotLabels$$.next([...labels]); + this.syncRobotLabels(); + } + + public addRobotsToLabel(lid: RobotLabel['id'], robots: RobotInfo[]): void { + const labels = clone(this.robotLabels$$.value); + const label = labels.find((v) => v.id === lid); + if (isNil(label)) throw Error('未找到目标标签'); + label.robots ??= []; + + robots.forEach((robot) => { + if (this.robotMap.has(robot.id)) { + const existingRobot = this.robotMap.get(robot.id)!; + existingRobot.lid ??= []; + if (!existingRobot.lid.includes(lid)) { + existingRobot.lid.push(lid); + } + if (!label.robots!.includes(robot.id)) { + label.robots!.push(robot.id); + } + } else { + const newRobot = { ...robot, lid: [lid] }; + this.robotMap.set(robot.id, newRobot); + label.robots?.push(robot.id); + } + }); + + this.robotLabels$$.next(labels); + this.syncRobots(); + this.syncRobotLabels(); + } + + public removeRobotFromLabel(labelId: string, robotId: string): void { + const labels = clone(this.robotLabels$$.value); + const label = labels.find((v) => v.id === labelId); + if (label?.robots) { + remove(label.robots, (id) => id === robotId); + } + this.robotLabels$$.next(labels); + + const robot = this.getRobotById(robotId); + if (robot?.lid) { + const newLid = robot.lid.filter((id) => id !== labelId); + this.updateRobot(robotId, { lid: newLid }); + } + + this.syncRobotLabels(); + this.syncRobots(); + } + + public removeRobotsFromAllLabels(robotIds: string[]): void { + robotIds.forEach((robotId) => { + const robot = this.getRobotById(robotId); + if (robot) { + this.updateRobot(robotId, { lid: [] }); + } + }); + + const labels = clone(this.robotLabels$$.value); + labels.forEach((label) => { + if (label.robots) { + remove(label.robots, (id) => robotIds.includes(id)); + } + }); + this.robotLabels$$.next(labels); + this.syncRobots(); + this.syncRobotLabels(); + } + //#endregion + + //#region 画布绘制 + public async initRobots(): Promise { + await Promise.all( + this.robots.map(async ({ id, label, type }) => { + const pen: MapPen = { + ...this.mapRobotImage(type, true, label), + id, + name: 'robot', + tags: ['robot'], + x: 0, + y: 0, + width: 120, + height: 120, + lineWidth: 1, + robot: { type }, + visible: false, + text: label, + textTop: -18, + whiteSpace: 'nowrap', + ellipsis: false, + locked: LockState.Disable, + }; + await this.ctx.addPen(pen, false, true, true); + this.updateRobotStatusOverlay(id, false); + }), + ); + + this.ctx.ensureCorrectLayerOrder(); + } + + public updateAllRobotImageSizes(): void { + this.robots.forEach(({ id, type, label }) => { + const pen = this.ctx.getPenById(id); + if (pen && pen.robot) { + const imageConfig = this.mapRobotImage(type, pen.robot.active, label); + this.ctx.setValue( + { + id, + iconWidth: imageConfig.iconWidth, + iconHeight: imageConfig.iconHeight, + iconTop: imageConfig.iconTop, + }, + { render: true, history: false, doEvent: false }, + ); + this.updateRobotStatusOverlay(id, true); + } + }); + } + + public updateRobotImage(robotName: string): void { + const robot = this.robots.find((r) => r.label === robotName); + if (!robot) return; + + const pen = this.ctx.getPenById(robot.id); + if (pen && pen.robot) { + const imageConfig = this.mapRobotImage(robot.type, pen.robot.active, robotName); + this.ctx.setValue( + { + id: robot.id, + image: imageConfig.image, + iconWidth: imageConfig.iconWidth, + iconHeight: imageConfig.iconHeight, + iconTop: imageConfig.iconTop, + }, + { render: true, history: false, doEvent: false }, + ); + } + } + + public updateRobotStatusOverlay(id: string, render = false, newPosition?: RobotStatusPosition): void { + const pen = this.ctx.getPenById(id); + if (!pen) return; + + const icon = this.getRobotStatusIcon(pen); + const robotVisible = pen.visible !== false; + + const baseW = (pen as any).iconWidth ?? 42; + const baseH = (pen as any).iconHeight ?? 76; + const oW = Math.max(8, Math.floor(baseW * 0.5)); + const oH = Math.max(8, Math.floor(baseH * 0.5)); + + const rect = this.ctx.getPenRect(pen); + const deg: number = newPosition?.rotate ?? pen.rotate ?? 0; + const theta = (deg * Math.PI) / 180; + const cx = (newPosition?.x ?? rect.x) + rect.width / 2; + const cy = (newPosition?.y ?? rect.y) + rect.height / 2; + const iconTop = (pen as any).iconTop ?? 0; + const localDx = 0; + const localDy = iconTop + 16; + const rotDx = localDx * Math.cos(theta) - localDy * Math.sin(theta); + const rotDy = localDx * Math.sin(theta) + localDy * Math.cos(theta); + const icx = cx + rotDx; + const icy = cy + rotDy; + const ox = icx - oW / 2; + const oy = icy - oH / 2; + + const overlayId = `robot-status-${id}`; + const exist = this.ctx.getPenById(overlayId); + + if (!robotVisible) { + if (exist) { + this.ctx.setValue({ id: overlayId, visible: false }, { render, history: false, doEvent: false }); + } + return; + } + + if (!icon) { + return; + } + + if (exist) { + this.ctx.setValue( + { + id: overlayId, + image: icon, + x: ox, + y: oy, + width: oW, + height: oH, + rotate: deg, + visible: true, + locked: LockState.Disable, + }, + { render: false, history: false, doEvent: false }, + ); + this.ctx.top([exist]); + if (render) this.ctx.render(); + return; + } + + const overlayPen: MapPen = { + id: overlayId, + name: 'image', + tags: ['robot-status'], + x: ox, + y: oy, + width: oW, + height: oH, + image: icon, + rotate: deg, + canvasLayer: CanvasLayer.CanvasImage, + locked: LockState.Disable, + visible: true, + } as any; + this.ctx.addPen(overlayPen, false, false, true); + this.ctx.top([overlayPen]); + if (render) this.ctx.render(); + } + + public mapRobotImage(type: RobotType, active?: boolean, robotName?: string): Required> { + const theme = this.ctx.data().theme; + const useCustomImages = colorConfig.getColor('robot.useCustomImages') === 'true'; + + let image: string; + if (useCustomImages && robotName) { + const customImage = colorConfig.getRobotCustomImage(robotName, active ? 'active' : 'normal'); + if (customImage) { + image = customImage; + } else { + image = import.meta.env.BASE_URL + (active ? `/robot/${type}-active-${theme}.png` : `/robot/${type}-${theme}.png`); + } + } else { + image = import.meta.env.BASE_URL + (active ? `/robot/${type}-active-${theme}.png` : `/robot/${type}-${theme}.png`); + } + + const iconWidth = colorConfig.getColor('robot.imageWidth') ? Number(colorConfig.getColor('robot.imageWidth')) : 42; + const iconHeight = colorConfig.getColor('robot.imageHeight') ? Number(colorConfig.getColor('robot.imageHeight')) : 76; + const iconTop = this.calculatePixelAlignedOffset(-16); + + return { image, iconWidth, iconHeight, iconTop, canvasLayer: CanvasLayer.CanvasImage }; + } + //#endregion + + private getRobotStatusIcon(pen?: MapPen): string | null { + if (!pen) return null; + const r1: any = pen.robot ?? {}; + const r2 = this.getRobotById(pen.id || ''); + const toBool = (v: any) => v === true || v === 1 || v === '1'; + const isCarrying = toBool(r1?.isCarrying ?? r2?.isCarrying); + const canOrder = toBool(r1?.canOrder ?? r2?.canOrder); + const isCharging = toBool(r1?.isCharging ?? r2?.isCharging); + + if (isCarrying) return cargoIcon; + if (!canOrder) return notAcceptingOrdersIcon; + if (isCharging) return chargingIcon; + return null; + } + + private calculatePixelAlignedOffset(baseOffset: number): number { + const ratio = window.devicePixelRatio || 1; + const scaled = baseOffset * ratio; + const snapped = Math.round(scaled); + const logicalOffset = snapped / ratio; + return this.fixPrecision(logicalOffset); + } + + private fixPrecision(value: number): number { + const truncated = Math.floor(value * 1000) / 1000; + return parseFloat(truncated.toFixed(3)); + } + + private syncRobots(): void { + (this.ctx.store.data as SceneData).robots = [...this.robotMap.values()]; + } + + private syncRobotGroups(): void { + (this.ctx.store.data as SceneData).robotGroups = this.robotGroups$$.value; + } + + private syncRobotLabels(): void { + (this.ctx.store.data as SceneData).robotLabels = this.robotLabels$$.value; + } +} + +function getRobotStatus(isWaring?: boolean, isFault?: boolean): 'fault' | 'warning' | 'normal' { + if (isFault) return 'fault'; + if (isWaring) return 'warning'; + return 'normal'; +} + +export function drawRobot(ctx: CanvasRenderingContext2D, pen: MapPen): void { + const theme = sTheme.editor; + const { lineWidth: s = 1 } = pen.calculative ?? {}; + const { x = 0, y = 0, width: w = 0, height: h = 0, rotate: deg = 0 } = pen.calculative?.worldRect ?? {}; + const { active, path, isWaring, isFault } = pen.robot ?? {}; + + if (!active) return; + + const status = getRobotStatus(isWaring, isFault); + + const imageWidth = colorConfig.getColor('robot.imageWidth') ? Number(colorConfig.getColor('robot.imageWidth')) : 60; + const imageHeight = colorConfig.getColor('robot.imageHeight') ? Number(colorConfig.getColor('robot.imageHeight')) : 80; + const imageSize = Math.max(imageWidth, imageHeight); + const robotRadius = (imageSize * 1.2 * s) / 2; + + const ox = x + w / 2; + const oy = y + h / 2; + ctx.save(); + ctx.beginPath(); + ctx.arc(ox, oy, robotRadius, 0, Math.PI * 2); + ctx.fillStyle = + colorConfig.getColor(`robot.fill${status === 'normal' ? 'Normal' : status === 'warning' ? 'Warning' : 'Fault'}`) || + (get(theme, `robot.fill-${status}`) ?? get(theme, 'robot.fill') ?? ''); + ctx.fill(); + ctx.strokeStyle = + colorConfig.getColor(`robot.stroke${status === 'normal' ? 'Normal' : status === 'warning' ? 'Warning' : 'Fault'}`) || + (get(theme, `robot.stroke-${status}`) ?? get(theme, 'robot.stroke') ?? ''); + ctx.stroke(); + if (path?.length) { + ctx.strokeStyle = colorConfig.getColor('robot.line') || (get(theme, 'robot.line') ?? ''); + ctx.lineCap = 'round'; + ctx.lineWidth = s * 4; + ctx.setLineDash([s * 5, s * 10]); + ctx.translate(ox, oy); + ctx.rotate((-deg * Math.PI) / 180); + ctx.beginPath(); + ctx.moveTo(0, 0); + path.forEach((d) => ctx.lineTo(d.x * s, d.y * s)); + ctx.stroke(); + const { x: ex1 = 0, y: ey1 = 0 } = nth(path, -1) ?? {}; + const { x: ex2 = 0, y: ey2 = 0 } = nth(path, -2) ?? {}; + const r = Math.atan2(ey1 - ey2, ex1 - ex2) + Math.PI; + ctx.setLineDash([0]); + ctx.translate(ex1 * s, ey1 * s); + ctx.beginPath(); + ctx.moveTo(Math.cos(r + Math.PI / 4) * s * 10, Math.sin(r + Math.PI / 4) * s * 10); + ctx.lineTo(0, 0); + ctx.lineTo(Math.cos(r - Math.PI / 4) * s * 10, Math.sin(r - Math.PI / 4) * s * 10); + ctx.stroke(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + } + ctx.restore(); +} diff --git a/src/services/editor.service.ts b/src/services/editor.service.ts index 6dc423b..0c5b1c6 100644 --- a/src/services/editor.service.ts +++ b/src/services/editor.service.ts @@ -11,7 +11,7 @@ import { type Point, type Rect, } from '@api/map'; -import type { RobotGroup, RobotInfo, RobotLabel,RobotType } from '@api/robot'; +import type { RobotGroup, RobotInfo, RobotLabel } from '@api/robot'; import type { GroupSceneDetail, SceneData, @@ -23,14 +23,11 @@ import type { import sTheme from '@core/theme.service'; import { CanvasLayer, LockState, Meta2d, type Meta2dStore, type Pen, s8 } from '@meta2d/core'; import { useObservable } from '@vueuse/rxjs'; -import { clone, get, isEmpty, isNil, isString, nth, pick, remove, some } from 'lodash-es'; -import { BehaviorSubject, debounceTime, filter, map, Subject, switchMap } from 'rxjs'; -import { reactive, watch } from 'vue'; +import { clone, get, isEmpty, isNil, isString, pick, } from 'lodash-es'; +import { debounceTime, filter, map, Subject, switchMap } from 'rxjs'; +import type { Ref } from 'vue'; +import { watch } from 'vue'; -// 机器人状态覆盖图标 -import cargoIcon from '../assets/icons/png/cargo.png'; -import chargingIcon from '../assets/icons/png/charging.png'; -import notAcceptingOrdersIcon from '../assets/icons/png/notAcceptingOrders.png'; import { AreaOperationService } from './area-operation.service'; import { BinTaskManagerService } from './bintask-manager.service'; import colorConfig from './color/color-config.service'; @@ -39,6 +36,7 @@ import { drawStorageLocation, drawStorageMore, } from './draw/storage-location-drawer'; +import { drawRobot,EditorRobotService } from './editor-robot.service'; import { LayerManagerService } from './layer-manager.service'; import { createStorageLocationUpdater,StorageLocationService } from './storage-location.service'; import { AutoStorageGenerator } from './utils/auto-storage-generator'; @@ -59,6 +57,7 @@ export class EditorService extends Meta2d { private readonly layerManager: LayerManagerService; /** 库位服务实例 */ private readonly storageLocationService: StorageLocationService; + private readonly robotService: EditorRobotService; /** 区域操作服务实例 */ private readonly areaOperationService!: AreaOperationService; @@ -94,14 +93,13 @@ export class EditorService extends Meta2d { this.open(); this.setState(editable); - this.#loadRobots(robotGroups, robots); - this.#loadLabels(robotLabels); + this.robotService.loadInitialData(robotGroups, robots, robotLabels); await this.#loadScenePoints(points, isImport); this.#loadSceneRoutes(routes, isImport); await this.#loadSceneAreas(areas, isImport); // 确保正确的层级顺序:路线 < 点位 < 机器人 - this.#ensureCorrectLayerOrder(); + this.ensureCorrectLayerOrder(); // 为所有动作点创建库位pen对象 this.createAllStorageLocationPens(); @@ -201,14 +199,8 @@ export class EditorService extends Meta2d { * @param groups 机器人组列表 * @param robots 机器人信息列表 */ - #loadRobots(groups?: RobotGroup[], robots?: RobotInfo[]): void { - this.#robotMap.clear(); - robots?.forEach((v) => this.#robotMap.set(v.id, v)); - this.#robotGroups$$.next(groups ?? []); - } - #loadLabels(labels?: RobotLabel[]): void { - this.#robotLabels$$.next(labels ?? []); - } + + /** * 从场景数据加载点位到画布 * @param points 标准场景点位数据数组 @@ -410,7 +402,7 @@ export class EditorService extends Meta2d { properties, }; if ([MapPointType.充电点, MapPointType.停靠点].includes(type)) { - point.robots = robots?.filter((v) => this.#robotMap.has(v)); + point.robots = robots?.filter((v) => this.robotService.hasRobot(v)); // 若未提供 enabled,则默认启用 point.enabled = (enabled ?? 1) as 0 | 1; } @@ -526,7 +518,7 @@ export class EditorService extends Meta2d { * 确保正确的层级顺序 * 委托给图层管理服务处理 */ - #ensureCorrectLayerOrder(): void { + public ensureCorrectLayerOrder(): void { this.layerManager.ensureCorrectLayerOrder(); } //#endregion @@ -579,184 +571,94 @@ export class EditorService extends Meta2d { ), ); - //#region 机器人组管理 - /** 机器人信息映射表,响应式存储所有机器人数据 */ - readonly #robotMap = reactive>(new Map()); - - /** 获取所有机器人信息数组 */ + //#region 机器人服务 public get robots(): RobotInfo[] { - return Array.from(this.#robotMap.values()); + return this.robotService.robots; + } + + public get robotGroups(): Ref { + return this.robotService.robotGroups; + } + + public get robotLabels(): Ref { + return this.robotService.robotLabels; } public checkRobotById(id: RobotInfo['id']): boolean { - return this.#robotMap.has(id); + return this.robotService.hasRobot(id); } + public getRobotById(id: RobotInfo['id']): RobotInfo | undefined { - return this.#robotMap.get(id); + return this.robotService.getRobotById(id); } public updateRobot(id: RobotInfo['id'], value: Partial): void { - const robot = this.getRobotById(id); - if (isNil(robot)) return; - this.#robotMap.set(id, { ...robot, ...value }); - if (value.label) { - this.setValue({ id, text: value.label }, { render: false, history: false, doEvent: false }); - } - (this.store.data).robots = [...this.#robotMap.values()]; + this.robotService.updateRobot(id, value); } public addRobots(gid: RobotInfo['gid'], robots: RobotInfo[]): void { - const groups = clone(this.#robotGroups$$.value); - const group = groups.find((v) => v.id === gid); - if (isNil(group)) throw Error('未找到目标机器人组'); - group.robots ??= []; - robots.forEach((v) => { - if (this.#robotMap.has(v.id)) return; - this.#robotMap.set(v.id, { ...v, gid }); - group.robots?.push(v.id); - }); - this.#robotGroups$$.next(groups); - (this.store.data).robots = [...this.#robotMap.values()]; - (this.store.data).robotGroups = this.#robotGroups$$.value; + this.robotService.addRobots(gid, robots); } + public removeRobots(ids: RobotInfo['id'][]): void { - ids?.forEach((v) => this.#robotMap.delete(v)); - const groups = clone(this.#robotGroups$$.value); - groups.forEach(({ robots }) => remove(robots ?? [], (v) => !this.#robotMap.has(v))); - this.#robotGroups$$.next(groups); - const labels = clone(this.#robotLabels$$.value); - labels.forEach(({ robots }) => remove(robots ?? [], (v) => !this.#robotMap.has(v))); - this.#robotLabels$$.next(labels); - (this.store.data).robots = [...this.#robotMap.values()]; - (this.store.data).robotGroups = this.#robotGroups$$.value; - (this.store.data).robotLabels = this.#robotLabels$$.value; + this.robotService.removeRobots(ids); } + public updateRobots(ids: RobotInfo['id'][], value: Partial): void { - ids?.forEach((v) => { - const robot = this.#robotMap.get(v); - if (isNil(robot)) return; - this.#robotMap.set(v, { ...robot, ...value }); - }); - (this.store.data).robots = [...this.#robotMap.values()]; + this.robotService.updateRobots(ids, value); } - readonly #robotGroups$$ = new BehaviorSubject([]); - public readonly robotGroups = useObservable(this.#robotGroups$$.pipe(debounceTime(300))); - - readonly #robotLabels$$ = new BehaviorSubject([]); - public readonly robotLabels = useObservable(this.#robotLabels$$.pipe(debounceTime(300))); - public createRobotGroup(): void { - const id = s8(); - const label = `RG${id}`; - const groups = clone(this.#robotGroups$$.value); - groups.push({ id, label }); - this.#robotGroups$$.next(groups); - (this.store.data).robotGroups = this.#robotGroups$$.value; + this.robotService.createRobotGroup(); } + public deleteRobotGroup(id: RobotGroup['id']): void { - const groups = clone(this.#robotGroups$$.value); - const group = groups.find((v) => v.id === id); - group?.robots?.forEach((v) => this.#robotMap.delete(v)); - remove(groups, group); - this.#robotGroups$$.next(groups); - (this.store.data).robots = [...this.#robotMap.values()]; - (this.store.data).robotGroups = this.#robotGroups$$.value; + this.robotService.deleteRobotGroup(id); } + public updateRobotGroupLabel(id: RobotGroup['id'], label: RobotGroup['label']): void { - const groups = this.#robotGroups$$.value; - const group = groups.find((v) => v.id === id); - if (isNil(group)) throw Error('未找到目标机器人组'); - if (some(groups, ['label', label])) throw Error('机器人组名称已经存在'); - group.label = label; - this.#robotGroups$$.next([...groups]); - (this.store.data).robotGroups = this.#robotGroups$$.value; + this.robotService.updateRobotGroupLabel(id, label); } + public createRobotLabel(): void { - const id = s8(); - const label = `RL${id}`; - const labels = clone(this.#robotLabels$$.value); - labels.push({ id, label }); - this.#robotLabels$$.next(labels); - (this.store.data).robotLabels = this.#robotLabels$$.value; + this.robotService.createRobotLabel(); } + public deleteRobotLabel(id: RobotLabel['id']): void { - const labels = clone(this.#robotLabels$$.value); - const labelToDelete = labels.find((v) => v.id === id); - if (!labelToDelete) return; - - // Remove the label id from all robots - const robotsInLabel = labelToDelete.robots; - if (robotsInLabel) { - robotsInLabel.forEach((robotId) => { - const robot = this.getRobotById(robotId); - if (robot?.lid) { - const newLid = robot.lid.filter((lid) => lid !== id); - this.updateRobot(robot.id, { lid: newLid }); - } - }); - } - - remove(labels, (l) => l.id === id); - this.#robotLabels$$.next(labels); - (this.store.data).robotLabels = this.#robotLabels$$.value; + this.robotService.deleteRobotLabel(id); } + public updateRobotLabel(id: RobotLabel['id'], labelName: RobotLabel['label']): void { - const labels = this.#robotLabels$$.value; - const label = labels.find((v) => v.id === id); - if (isNil(label)) throw Error('未找到目标机器人标签'); - if (some(labels, (l) => l.label === labelName && l.id !== id)) throw Error('机器人标签名称已经存在'); - label.label = labelName; - this.#robotLabels$$.next([...labels]); - (this.store.data).robotLabels = this.#robotLabels$$.value; + this.robotService.updateRobotLabel(id, labelName); } + public addRobotsToLabel(lid: RobotLabel['id'], robots: RobotInfo[]): void { - const labels = clone(this.#robotLabels$$.value); - const label = labels.find((v) => v.id === lid); - if (isNil(label)) throw Error('未找到目标机器人标签'); - label.robots ??= []; - robots.forEach((v) => { - const existingRobot = this.#robotMap.get(v.id); - if (existingRobot) { - // If robot is already in scene - if (!existingRobot.lid) { - existingRobot.lid = []; - } - if (!existingRobot.lid.includes(lid)) { - existingRobot.lid.push(lid); - } - if (label.robots && !label.robots.includes(v.id)) { - label.robots.push(v.id); - } - } else { - // If robot is new to the scene - const newRobot = { ...v, lid: [lid] }; - this.#robotMap.set(v.id, newRobot); - if (label.robots) { - label.robots.push(v.id); - } - } - }); - this.#robotLabels$$.next(labels); - (this.store.data).robots = [...this.#robotMap.values()]; - (this.store.data).robotLabels = this.#robotLabels$$.value; + this.robotService.addRobotsToLabel(lid, robots); } + public removeRobotFromLabel(labelId: string, robotId: string): void { - const labels = clone(this.#robotLabels$$.value); - const label = labels.find((v) => v.id === labelId); - if (label?.robots) { - remove(label.robots, (id) => id === robotId); - } - this.#robotLabels$$.next(labels); + this.robotService.removeRobotFromLabel(labelId, robotId); + } - const robot = this.getRobotById(robotId); - if (robot?.lid) { - const newLid = robot.lid.filter((id) => id !== labelId); - this.updateRobot(robotId, { lid: newLid }); - } + public removeRobotsFromAllLabels(robotIds: string[]): void { + this.robotService.removeRobotsFromAllLabels(robotIds); + } - (this.store.data).robotLabels = this.#robotLabels$$.value; - (this.store.data).robots = [...this.#robotMap.values()]; + + public async initRobots(): Promise { + await this.robotService.initRobots(); + } + + public updateAllRobotImageSizes(): void { + this.robotService.updateAllRobotImageSizes(); + } + + public updateRobotImage(robotName: string): void { + this.robotService.updateRobotImage(robotName); + } + + public updateRobotStatusOverlay(id: string, render = false, newPosition?: { x: number; y: number; rotate: number }): void { + this.robotService.updateRobotStatusOverlay(id, render, newPosition); } //#endregion @@ -837,44 +739,7 @@ export class EditorService extends Meta2d { * 优化的像素对齐算法 - 确保在所有缩放比例下都能精确对齐像素边界 * 解决小车和光圈在特定缩放比例下不重合的问题 */ - #calculatePixelAlignedOffset(baseOffset: number): number { - const scale = this.store.data.scale || 1; - const devicePixelRatio = window.devicePixelRatio || 1; - - // 计算实际像素偏移量 - const scaledOffset = baseOffset * scale; - - // 多重对齐策略: - // 1. 设备像素对齐 - 确保在高DPI屏幕上也能对齐 - const deviceAlignedOffset = Math.round(scaledOffset * devicePixelRatio) / devicePixelRatio; - - // 2. 子像素对齐 - 对于常见的缩放比例使用特殊处理 - let finalOffset = deviceAlignedOffset; - - // 针对常见问题缩放比例的特殊处理 - const roundedScale = Math.round(scale * 100) / 100; // 避免浮点数精度问题 - - if (roundedScale <= 0.2) { - // 极小缩放:使用更粗粒度的对齐(0.5像素边界) - finalOffset = Math.round(scaledOffset * 2) / 2; - } else if (roundedScale <= 0.5) { - // 小缩放:使用0.25像素边界对齐 - finalOffset = Math.round(scaledOffset * 4) / 4; - } else if (roundedScale >= 2) { - // 大缩放:使用精确的像素边界对齐 - finalOffset = Math.round(scaledOffset); - } else { - // 标准缩放:使用设备像素对齐结果,但增加额外的精度控制 - const precisionFactor = 8; // 1/8像素精度 - finalOffset = Math.round(scaledOffset * precisionFactor) / precisionFactor; - } - - // 3. 转换回逻辑坐标系并应用精度控制 - const logicalOffset = finalOffset / scale; - - // 4. 使用现有的精度控制方法确保数值稳定性 - return this.#fixPrecision(logicalOffset); - } + /** 画布变化事件流,用于触发响应式数据更新 */ readonly #change$$ = new Subject(); @@ -1022,7 +887,6 @@ export class EditorService extends Meta2d { return createStorageLocationUpdater(this.storageLocationService); } - /** * 为所有动作点创建库位pen对象 * 用于初始化或刷新所有动作点的库位显示 @@ -1080,8 +944,6 @@ export class EditorService extends Meta2d { - - /** * 根据设备ID更新自动门点状态 * @param deviceId 设备ID @@ -1174,281 +1036,6 @@ export class EditorService extends Meta2d { } } - //#region 实时机器人 - public async initRobots(): Promise { - await Promise.all( - this.robots.map(async ({ id, label, type }) => { - const pen: MapPen = { - ...this.#mapRobotImage(type, true, label), // 传递机器人名称(label) - id, - name: 'robot', - tags: ['robot'], - x: 0, - y: 0, - width: 120, - height: 120, - lineWidth: 1, - robot: { type }, - visible: false, - text: label, - textTop: -18, // 调整标签位置,更靠近机器人本体,减少被覆盖的可能性 - whiteSpace: 'nowrap', - ellipsis: false, - locked: LockState.Disable, - }; - await this.addPen(pen, false, true, true); - // 初始化时创建/同步一次状态覆盖图标(默认隐藏或根据当前状态显示) - this.updateRobotStatusOverlay(id, false); - }), - ); - - // 机器人初始化完成后,确保层级正确(机器人应在最顶层) - this.#ensureCorrectLayerOrder(); - - } - - // public refreshRobot(id: RobotInfo['id'], info: Partial): void { - // const pen = this.getPenById(id); - // const { rotate: or, robot } = pen ?? {}; - // if (!robot?.type) return; - // const { x: ox, y: oy } = this.getPenRect(pen!); - // const { x: cx = 37, y: cy = 37, active, angle, path: points, isWaring, isFault } = info; - // const x = cx - 60; - // const y = cy - 60; - // const rotate = angle ?? or; - // const path = - // points?.map((p) => ({ x: p.x - cx, y: p.y - cy })) ?? - // robot.path?.map((p) => ({ x: p.x + ox! - x, y: p.y + oy! - y })); - // const o = { ...robot, ...omitBy({ active, path, isWaring, isFault }, isNil) }; - // if (isNil(active)) { - // this.setValue( - // { id, x, y, rotate, robot: o, visible: true, locked: LockState.None }, - // { render: true, history: false, doEvent: false } - // ); - // } else { - // this.setValue( - // { - // id, - // ...this.#mapRobotImage(robot.type, active), - // x, y, rotate, robot: o, - // visible: true, - // locked: LockState.None - // }, - // { render: true, history: false, doEvent: false }, - // ); - // } - - - - // } - - /** - * 更新所有机器人的图片尺寸 - * 当机器人图片尺寸配置发生变化时调用 - */ - public updateAllRobotImageSizes(): void { - this.robots.forEach(({ id, type, label }) => { - const pen = this.getPenById(id); - if (pen && pen.robot) { - const imageConfig = this.#mapRobotImage(type, pen.robot.active, label); // 传递机器人名称 - this.setValue( - { - id, - iconWidth: imageConfig.iconWidth, - iconHeight: imageConfig.iconHeight, - iconTop: imageConfig.iconTop, - }, - { render: true, history: false, doEvent: false }, - ); - // 同步状态图标尺寸/位置 - this.updateRobotStatusOverlay(id, true); - } - }); - } - - /** - * 更新单个机器人的图片 - * 当机器人自定义图片配置发生变化时调用 - * @param robotName 机器人名称 - */ - public updateRobotImage(robotName: string): void { - const robot = this.robots.find(r => r.label === robotName); - if (!robot) return; - - const pen = this.getPenById(robot.id); - if (pen && pen.robot) { - const imageConfig = this.#mapRobotImage(robot.type, pen.robot.active, robotName); - this.setValue( - { - id: robot.id, - image: imageConfig.image, - iconWidth: imageConfig.iconWidth, - iconHeight: imageConfig.iconHeight, - iconTop: imageConfig.iconTop, - }, - { render: true, history: false, doEvent: false }, - ); - } - } - - - #mapRobotImage( - type: RobotType, - active?: boolean, - robotName?: string, - ): Required> { - const theme = this.data().theme; - - // 检查是否启用自定义图片且有机器人名称 - const useCustomImages = colorConfig.getColor('robot.useCustomImages') === 'true'; - - let image: string; - if (useCustomImages && robotName) { - // 尝试获取自定义图片 - const customImage = colorConfig.getRobotCustomImage(robotName, active ? 'active' : 'normal'); - if (customImage) { - image = customImage; - } else { - // 如果没有自定义图片,回退到默认图片 - image = import.meta.env.BASE_URL + (active ? `/robot/${type}-active-${theme}.png` : `/robot/${type}-${theme}.png`); - } - } else { - // 使用默认图片 - image = import.meta.env.BASE_URL + (active ? `/robot/${type}-active-${theme}.png` : `/robot/${type}-${theme}.png`); - } - - // 使用配置的机器人图片尺寸,如果没有配置则使用默认值 - const iconWidth = colorConfig.getColor('robot.imageWidth') ? Number(colorConfig.getColor('robot.imageWidth')) : 42; - const iconHeight = colorConfig.getColor('robot.imageHeight') - ? Number(colorConfig.getColor('robot.imageHeight')) - : 76; - - // 使用优化的像素对齐算法,确保小车和光圈精确重合 - const iconTop = this.#calculatePixelAlignedOffset(-16); - - return { image, iconWidth, iconHeight, iconTop, canvasLayer: CanvasLayer.CanvasImage }; - } - //#endregion - - /** - * 计算机器人状态覆盖图标(优先级:载货 > 不接单 > 充电) - */ - #getRobotStatusIcon(pen?: MapPen): string | null { - if (!pen) return null; - // 兼容:状态可能存放在 pen.robot 或 机器人信息表中 - const r1: any = pen.robot ?? {}; - const r2 = this.getRobotById(pen.id || ''); - const toBool = (v: any) => v === true || v === 1 || v === '1'; - const isCarrying = toBool(r1?.isCarrying ?? r2?.isCarrying); - const canOrder = toBool(r1?.canOrder ?? r2?.canOrder); - const isCharging = toBool(r1?.isCharging ?? r2?.isCharging); - - if (isCarrying) return cargoIcon; - if (!canOrder) return notAcceptingOrdersIcon; - if (isCharging) return chargingIcon; - return null; - } - - /** - * 更新单个机器人的状态覆盖图标位置/尺寸/显隐 - * @param id 机器人ID - * @param render 是否立即渲染 - * @param newPosition 可选,提供新的位置和角度信息,用于解决异步更新时的延迟问题 - */ - public updateRobotStatusOverlay( - id: string, - render = false, - newPosition?: { x: number; y: number; rotate: number }, - ): void { - const pen = this.getPenById(id); - if (!pen) return; - - const icon = this.#getRobotStatusIcon(pen); - const robotVisible = pen.visible !== false; - - // 计算覆盖图标的尺寸(为机器人图片的一半) - const baseW = (pen as any).iconWidth ?? 42; - const baseH = (pen as any).iconHeight ?? 76; - const oW = Math.max(8, Math.floor(baseW * 0.5)); - const oH = Math.max(8, Math.floor(baseH * 0.5)); - - // 以机器人中心为基准,做一个轻微向下的偏移,并随机器人一起旋转 - const rect = this.getPenRect(pen); - const deg: number = newPosition?.rotate ?? pen.rotate ?? 0; - const theta = (deg * Math.PI) / 180; - // 如果有新位置,使用新位置的 x,y + 容器宽高的一半;否则用旧方法 - const cx = (newPosition?.x ?? rect.x) + rect.width / 2; - const cy = (newPosition?.y ?? rect.y) + rect.height / 2; - const iconTop = (pen as any).iconTop ?? 0; - // 在本地坐标中的偏移(以机器人中心为原点,y向下为正) - const localDx = 0; - const localDy = iconTop + 16; - // 旋转后的偏移 - const rotDx = localDx * Math.cos(theta) - localDy * Math.sin(theta); - const rotDy = localDx * Math.sin(theta) + localDy * Math.cos(theta); - // 覆盖图标中心点 - const icx = cx + rotDx; - const icy = cy + rotDy; - const ox = icx - oW / 2; - const oy = icy - oH / 2; - - const overlayId = `robot-status-${id}`; - const exist = this.getPenById(overlayId); - - // 机器人不可见:隐藏覆盖图标 - if (!robotVisible) { - if (exist) { - this.setValue({ id: overlayId, visible: false }, { render, history: false, doEvent: false }); - } - return; - } - - // 没有状态图标时:如果之前有覆盖图标,则保持现状,不主动隐藏,避免无新数据推送时图标消失 - if (!icon) { - return; - } - - // 如果已存在覆盖图标,更新其属性;否则创建新的 image 图元 - if (exist) { - this.setValue( - { - id: overlayId, - image: icon, - x: ox, - y: oy, - width: oW, - height: oH, - rotate: deg, - visible: true, - locked: LockState.Disable, - }, - { render: false, history: false, doEvent: false }, - ); - // 置顶,保证覆盖在机器人之上 - this.top([exist]); - if (render) this.render(); - return; - } - - const overlayPen: MapPen = { - id: overlayId, - name: 'image', - tags: ['robot-status'], - x: ox, - y: oy, - width: oW, - height: oH, - image: icon, - rotate: deg, - canvasLayer: CanvasLayer.CanvasImage, - locked: LockState.Disable, - visible: true, - } as any; - this.addPen(overlayPen, false, false, true); - this.top([overlayPen]); - if (render) this.render(); - } - //#region 点位 /** 画布上所有点位对象列表,响应式更新 */ public readonly points = useObservable( @@ -1863,6 +1450,7 @@ export class EditorService extends Meta2d { // 初始化库位服务 this.storageLocationService = new StorageLocationService(this, ''); + this.robotService = new EditorRobotService(this); // 初始化自动生成库位工具 this.autoStorageGenerator = new AutoStorageGenerator(this); @@ -1920,7 +1508,7 @@ export class EditorService extends Meta2d { if (!pen.robot?.type) return; // 从pen的text属性获取机器人名称 const robotName = pen.text || ''; - this.canvas.updateValue(pen, this.#mapRobotImage(pen.robot.type, pen.robot.active, robotName)); + this.canvas.updateValue(pen, this.robotService.mapRobotImage(pen.robot.type, pen.robot.active, robotName)); }); this.render(); } @@ -2153,25 +1741,7 @@ export class EditorService extends Meta2d { this.addDrawLineFn('bezier3', lineBezier3); } - public removeRobotsFromAllLabels(robotIds: string[]): void { - robotIds.forEach((robotId) => { - const robot = this.getRobotById(robotId); - if (robot) { - this.updateRobot(robotId, { lid: [] }); - } - }); - - const labels = clone(this.#robotLabels$$.value); - labels.forEach((label) => { - if (label.robots) { - remove(label.robots, (id) => robotIds.includes(id)); - } - }); - this.#robotLabels$$.next(labels); - - (this.store.data).robots = [...this.#robotMap.values()]; - (this.store.data).robotLabels = this.#robotLabels$$.value; - } + } //#region 自定义绘制函数 @@ -2577,75 +2147,6 @@ function drawArea(ctx: CanvasRenderingContext2D, pen: MapPen): void { * - isWaring=true, isFault=false → 告警 * - isWaring=false, isFault=false → 正常 */ -function getRobotStatus(isWaring?: boolean, isFault?: boolean): 'fault' | 'warning' | 'normal' { - // 只要 isFault 为 true,无论 isWaring 是什么,都是故障状态 - if (isFault) return 'fault'; - // 如果 isFault 为 false 但 isWaring 为 true,则是告警状态 - if (isWaring) return 'warning'; - // 两者都为 false 时,为正常状态 - return 'normal'; -} - -/** - * 绘制机器人的自定义函数 - * @param ctx Canvas 2D绘制上下文 - * @param pen 机器人图形对象 - */ -function drawRobot(ctx: CanvasRenderingContext2D, pen: MapPen): void { - const theme = sTheme.editor; - const { lineWidth: s = 1 } = pen.calculative ?? {}; - const { x = 0, y = 0, width: w = 0, height: h = 0, rotate: deg = 0 } = pen.calculative?.worldRect ?? {}; - const { active, path, isWaring, isFault } = pen.robot ?? {}; - - if (!active) return; - - // 根据机器人状态获取颜色 - const status = getRobotStatus(isWaring, isFault); - - // 基于机器人图片大小计算光圈半径,光圈比图片大20%,并随画布缩放 - const imageWidth = colorConfig.getColor('robot.imageWidth') - ? Number(colorConfig.getColor('robot.imageWidth')) : 60; - const imageHeight = colorConfig.getColor('robot.imageHeight') - ? Number(colorConfig.getColor('robot.imageHeight')) : 80; - // 取图片的较大边作为基准,计算圆形光圈的半径 - const imageSize = Math.max(imageWidth, imageHeight); - const robotRadius = (imageSize * 1.2 * s) / 2; // 光圈半径比图片大20%,并随画布缩放 - - const ox = x + w / 2; - const oy = y + h / 2; - ctx.save(); - ctx.beginPath(); - ctx.arc(ox, oy, robotRadius, 0, Math.PI * 2); - ctx.fillStyle = colorConfig.getColor(`robot.fill${status === 'normal' ? 'Normal' : status === 'warning' ? 'Warning' : 'Fault'}`) || (get(theme, `robot.fill-${status}`) ?? (get(theme, 'robot.fill') ?? '')); - ctx.fill(); - ctx.strokeStyle = colorConfig.getColor(`robot.stroke${status === 'normal' ? 'Normal' : status === 'warning' ? 'Warning' : 'Fault'}`) || (get(theme, `robot.stroke-${status}`) ?? (get(theme, 'robot.stroke') ?? '')); - ctx.stroke(); - if (path?.length) { - ctx.strokeStyle = colorConfig.getColor('robot.line') || (get(theme, 'robot.line') ?? ''); - ctx.lineCap = 'round'; - ctx.lineWidth = s * 4; - ctx.setLineDash([s * 5, s * 10]); - ctx.translate(ox, oy); - ctx.rotate((-deg * Math.PI) / 180); - ctx.beginPath(); - ctx.moveTo(0, 0); - path.forEach((d) => ctx.lineTo(d.x * s, d.y * s)); - ctx.stroke(); - const { x: ex1 = 0, y: ey1 = 0 } = nth(path, -1) ?? {}; - const { x: ex2 = 0, y: ey2 = 0 } = nth(path, -2) ?? {}; - const r = Math.atan2(ey1 - ey2, ex1 - ex2) + Math.PI; - ctx.setLineDash([0]); - ctx.translate(ex1 * s, ey1 * s); - ctx.beginPath(); - ctx.moveTo(Math.cos(r + Math.PI / 4) * s * 10, Math.sin(r + Math.PI / 4) * s * 10); - ctx.lineTo(0, 0); - ctx.lineTo(Math.cos(r - Math.PI / 4) * s * 10, Math.sin(r - Math.PI / 4) * s * 10); - ctx.stroke(); - ctx.setTransform(1, 0, 0, 1, 0, 0); - } - ctx.restore(); -} - //#endregion //#region 辅助函数