加固地图编辑拾取循环,避免多路 pick 竞争与选中回环。

统一 stop/restart pick 代际控制,并小幅整理编辑工具栏交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
黄兆尉
2026-08-26 17:50:37 +08:00
co-authored by Cursor
parent c9e835aaf7
commit 3968f5dc1f
5 changed files with 348 additions and 354 deletions
@@ -96,7 +96,7 @@
<section v-if="positionFields.length > 0" class="prop-card prop-card--geom">
<header class="prop-card-header">
<span class="prop-card-title">位置 / 几何</span>
<span class="prop-card-flag" :class="{ 'is-synced': syncFlash }" :title="syncFlash ? '已同步到画布' : '改动会立即同步到 SimpleLite 画布'">
<span class="prop-card-flag" :class="{ 'is-synced': syncFlash }" :title="syncFlash ? '已同步到画布' : '改动会立即同步到 Simple3 画布'">
<span class="prop-card-flag-dot" />
<span class="prop-card-flag-text">{{ syncFlash ? '已同步' : '同步到画布' }}</span>
</span>
@@ -261,7 +261,7 @@
<el-tab-pane label="类型默认" name="defaults">
<div class="defaults-pane">
<el-alert type="info" :closable="false" class="defaults-alert">
视口样式与 SimpleLite「视口样式」对话框同步,修改后即时重绘画布;下方「新建默认」仅影响下次创建对象。
视口样式与 Simple3「视口样式」对话框同步,修改后即时重绘画布;下方「新建默认」仅影响下次创建对象。
</el-alert>
<div v-if="!selectionKind" class="empty-hint defaults-empty">
@@ -78,8 +78,8 @@ const groups: ToolGroup[] = [
{ id: 'draw.sites.continuous', label: '连续站点', glyph: '⁂', tip: '切换工具或右键退出' },
{ id: 'draw.track.polyline', label: '直线路径', glyph: '─', tip: '依序点击两端站点' },
{ id: 'draw.tracks.continuous', label: '连续直线', glyph: '⇉', tip: '连续点两端站点建直线;右键退出' },
{ id: 'draw.track.bezier', label: '贝塞尔路径', glyph: '', tip: '点两端站点 → 输入中间控制点个数 → 连续点选' },
{ id: 'draw.track.arc', label: '弧形路径', glyph: '◜', tip: '点两端站点 → 输入半径(≥ |AB|/2' },
{ id: 'draw.track.bezier', label: '贝塞尔路径', glyph: '', tip: '点两端站点 → 输入中间点个数 → 按邻边自动生成' },
{ id: 'draw.track.arc', label: '弧形路径', glyph: '◜', tip: '点两端站点,按邻边自动生成圆弧' },
{ id: 'draw.decor.text', label: '装饰文本', glyph: 'T', tip: '输入内容后在画布单击落点' },
{ id: 'draw.image', label: '插入图片', glyph: '🖼', tip: '选择图片后在画布单击落点' }
]
@@ -10,13 +10,10 @@
</el-button>
<el-dropdown trigger="click" @command="onCmd">
<el-button text class="topbar-btn"></el-button>
<el-button text class="topbar-btn"></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="file.import.dwg"> .dwg/.dxf</el-dropdown-item>
<el-dropdown-item command="file.import.geojson">导入 .geojson</el-dropdown-item>
<el-dropdown-item command="file.upload.image" divided>导入图片</el-dropdown-item>
<el-dropdown-item command="file.export.png" divided>导出 PNG</el-dropdown-item>
<el-dropdown-item command="file.export.png"> PNG</el-dropdown-item>
<el-dropdown-item command="file.export.svg">导出 SVG</el-dropdown-item>
<el-dropdown-item command="file.export.json">导出 JSON</el-dropdown-item>
</el-dropdown-menu>
@@ -18,7 +18,7 @@
<button class="proj-icon-btn" title="刷新" @click="refresh">
<span class="proj-icon">⟳</span>
</button>
<button class="proj-icon-btn" title="SimpleLite 主目录" @click="goCwd">
<button class="proj-icon-btn" title="Simple3 主目录" @click="goCwd">
<span class="proj-icon">⌂</span>
</button>
<el-input
@@ -52,7 +52,7 @@
<el-scrollbar class="proj-side-scroll">
<button class="proj-side-btn proj-side-cwd" @click="goCwd">
<span class="proj-side-icon">🏠</span>
<span class="proj-side-text">SimpleLite 主目录</span>
<span class="proj-side-text">Simple3 主目录</span>
</button>
<div v-if="parent" class="proj-side-section">
<button class="proj-side-btn" @click="goUp">
@@ -167,7 +167,7 @@ const props = defineProps<{
modelValue: boolean
/** 'load' 用于「加载文件」对话框;'save' 用于「另存为」对话框(可选中 .json 或新建文件名)。 */
mode: 'load' | 'save'
/** 上次记住的路径,用于打开时初始定位。空字符串则用 SimpleLite CWD。 */
/** 上次记住的路径,用于打开时初始定位。空字符串则用 Simple3 CWD。 */
initialPath?: string
}>()
@@ -108,7 +108,7 @@
图层显示切换对话框列出所有已知图层每行一个 visible/selectable 开关 +
设为当前默认层按钮本面板与右侧 EditPropertyPanel 的图层卡片共享同一份
layerRows操作后双向同步defaults.site/track.layer 决定下次创建对象的落点
备注画布渲染本身受 SimpleLite LayerToShow 控制要让画布也跟着隐藏
备注画布渲染本身受 Simple3 LayerToShow 控制要让画布也跟着隐藏
还需后端 REST API当前 toggle 只影响前端面板筛选
-->
<el-dialog v-model="layerVisibilityDialogOpen" title="切换显示图层" width="520px">
@@ -244,7 +244,7 @@ const primaryFields = ref<Array<{ key: string; value: string }>>([])
const primaryMethods = ref<ReflectionMethod[]>([])
const primaryStatus = ref<Array<{ key: string; value: string }>>([])
// 默认图层?SimpleLite/Prop.layerName 默认值一致('g');
// 默认图层?Simple3/Prop.layerName 默认值一致('g');
// 顶栏「图层」菜单提供「新建图?/ 切换显示图层」入口,新建后会自动设为
// defaults.site.layer / defaults.track.layer,下一?Add-Site / Add-Track 自动落到新层?
const defaults = reactive({
@@ -282,13 +282,13 @@ const layerRows = ref<LayerRow[]>([
{ name: 'g', visible: true, selectable: true, color: '#cccccc' }
])
// 「过滤」菜单状态:对齐 SimpleLite 桌面?WorkspaceBottomBar 的「选择 / 对齐 / 显示内容」三组开关?
// 「过滤」菜单状态:对齐 Simple3 桌面?WorkspaceBottomBar 的「选择 / 对齐 / 显示内容」三组开关?
// 三组语义?
// selectFilter.* 画布命中后允许进?selection 的对象类型,?SimpleUI.SelectFilter{Sites,Tracks,Cars,Decor} 对齐?
// alignSnap.* 绘制工具吸附时考虑的目标类型,?SimpleUI.AlignSnap{Sites,Cars,Tracks} 对齐?
// showViewport.* 显示内容过滤,与 SimpleUI.ShowViewport{SceneLabels,ScenePrimitives,Cars} 对齐?
//
// 当前阶段仅在 Vue 前端生效(影?selection 接收 / 属性面板筛选);画布本身的可见性由 SimpleLite
// 当前阶段仅在 Vue 前端生效(影?selection 接收 / 属性面板筛选);画布本身的可见性由 Simple3
// ?LayerToShow / ?ShowViewport* 控制,待后端 REST API 打通后再做双向同步?
const viewFilter = reactive<ViewFilterState>({
selectFilter: { sites: true, tracks: true, cars: true, decor: true },
@@ -369,27 +369,16 @@ function pruneSelectionByFilter() {
}
// 「切换显示图层」对话框。开?visible 直接?layerRows[i],与右侧 EditPropertyPanel 共享 state?
// 注:画布渲染本身?SimpleLite ?LayerToShow 控制,目前后端没有暴露写 API?
// 注:画布渲染本身?Simple3 ?LayerToShow 控制,目前后端没有暴露写 API?
// 这里 toggle 仅控制前端面板的可见性筛选(同步到画布需另加后端 API,待 Fody 修好再做)?
const layerVisibilityDialogOpen = ref(false)
// 用于「绘制路径」工具的两次拾取状态机:第一次记?siteA,第二次创建路径
const drawTrackState = ref<{ siteA: number | null }>({ siteA: null })
// 贝塞?/ NURBS 创建第二步:siteA + siteB 拾取完后,会进入 N 次「中间控制点」连续拾取阶段?
// 这一阶段直接 await mapEditApi.pick(),与 startPickLoop 那条主循环并存会出现两条
// pick 监听竞争同一?SSE pick-result。inFollowupPick=true 期间?
// - startPickLoop 已被 handleTrackPick 主动 break,不会再拉新 pick?
// - onCanvasPickiframe ?workspace.pick postMessage 旁路)短路返回,避免?
// 再走一次工具状态机重复落对象?
const inFollowupPick = ref(false)
const streamConnected = ref(false)
// 选中同步防回环:保存"本地刚发出去"的选中 key?
// 后端广播 selection-detail 时如果与?key 等价,跳?selection.set,避?
// watch(selection) ?reflectionApi.setSelection ?SSE selection-detail ?onSelectionDetail
// ?selection.set ??watch 的死循环?
// SSE 与本地选中同一份时跳过 selection.set,避免重复拉属性
const lastSyncedSelectionKey = ref<string>('')
function makeSelectionKey(list: Array<{ kind: string; id: number }>): string {
@@ -398,6 +387,9 @@ function makeSelectionKey(list: Array<{ kind: string; id: number }>): string {
// 拾取循环开关:切换到拾取工具时拉起,切走时落下?
const pickLoopActive = ref(false)
let pickLoopGen = 0
let pickLoopTask: Promise<void> | null = null
let pickAbort: AbortController | null = null
const pickToolIds: EditToolId[] = [
'draw.site',
'draw.sites.continuous',
@@ -421,69 +413,97 @@ function pickNeedsSiteSnap(tool: EditToolId): boolean {
)
}
async function startPickLoop() {
if (pickLoopActive.value) return
pickLoopActive.value = true
// 工具栏交互约定:
// - 单次工具(draw.site / draw.track.*):完成一次后退出循环,但保持工具高亮(可再点工具续画)?
// - 连续工具:保持选中,右键取消为止?
// - 不再自动切到「移动」(该工具已移除);避免绘制中偶发跳工具?
while (pickLoopActive.value) {
let result: { x: number; y: number; siteId: number } | null = null
const toolBeforePick = editTool.activeTool.value
try {
result = await mapEditApi.pick({ snap: pickNeedsSiteSnap(toolBeforePick) })
} catch {
break
}
if (!pickLoopActive.value) break
const tool = editTool.activeTool.value
if (!pickToolIds.includes(tool)) break
async function stopPickLoop() {
pickLoopGen++
pickLoopActive.value = false
pickAbort?.abort()
pickAbort = null
try {
await mapEditApi.cancelPick()
} catch {
/* 没有进行中的拾取,或内核尚未提供 cancel */
}
if (pickLoopTask) {
try { await pickLoopTask } catch { /* ignore */ }
pickLoopTask = null
}
}
if (tool === 'draw.site') {
await runCreateSite(result.x, result.y)
ElMessage.success(`已添加站点 (${result.x.toFixed(0)}, ${result.y.toFixed(0)})`)
break
} else if (tool === 'draw.sites.continuous') {
await runCreateSite(result.x, result.y)
} else if (
tool === 'draw.track.polyline'
|| tool === 'draw.tracks.continuous'
|| tool === 'draw.track.bezier'
|| tool === 'draw.track.arc'
|| tool === 'draw.track.nurbs'
) {
const before = drawTrackState.value.siteA
await handleTrackPick(result.siteId)
if (before != null && drawTrackState.value.siteA == null) {
const label = tool === 'draw.track.bezier' ? '贝塞尔路径'
: tool === 'draw.track.arc' ? '弧形路径'
: tool === 'draw.track.nurbs' ? 'NURBS 路径'
: '直线路径'
ElMessage.success(`已添加${label}`)
if (tool !== 'draw.tracks.continuous') break
}
} else if (tool === 'draw.decor.text') {
const content = pendingTextContent.value || 'Label'
pendingTextContent.value = null
await runCreateText(result.x, result.y, content)
ElMessage.success(`已添加文本「${content}`)
break
} else if (tool === 'draw.image') {
const path = pendingImagePath.value
if (!path) {
ElMessage.warning('请先选择要插入的图片')
async function startPickLoop() {
await stopPickLoop()
const myGen = pickLoopGen
if (!pickToolIds.includes(editTool.activeTool.value)) return
if (pickLoopGen !== myGen) return
pickAbort = new AbortController()
const signal = pickAbort.signal
pickLoopActive.value = true
const task = runPickLoop(myGen, signal)
pickLoopTask = task
void task.finally(() => {
if (pickLoopTask === task) pickLoopTask = null
})
}
async function runPickLoop(myGen: number, signal: AbortSignal) {
try {
while (pickLoopGen === myGen) {
let result: { x: number; y: number; siteId: number } | null = null
const toolBeforePick = editTool.activeTool.value
try {
result = await mapEditApi.pick({ snap: pickNeedsSiteSnap(toolBeforePick), signal })
} catch {
break
}
if (pickLoopGen !== myGen || !result) break
const tool = editTool.activeTool.value
if (!pickToolIds.includes(tool)) break
if (tool === 'draw.site') {
await runCreateSite(result.x, result.y)
ElMessage.success(`已添加站点 (${result.x.toFixed(0)}, ${result.y.toFixed(0)})`)
break
} else if (tool === 'draw.sites.continuous') {
await runCreateSite(result.x, result.y)
} else if (
tool === 'draw.track.polyline'
|| tool === 'draw.tracks.continuous'
|| tool === 'draw.track.bezier'
|| tool === 'draw.track.arc'
|| tool === 'draw.track.nurbs'
) {
const created = await handleTrackPick(result.siteId)
if (created) {
const label = tool === 'draw.track.bezier' ? '贝塞尔路径'
: tool === 'draw.track.arc' ? '弧形路径'
: tool === 'draw.track.nurbs' ? 'NURBS 路径'
: '直线路径'
ElMessage.success(`已添加${label}`)
if (tool !== 'draw.tracks.continuous') break
}
} else if (tool === 'draw.decor.text') {
const content = pendingTextContent.value || 'Label'
pendingTextContent.value = null
await runCreateText(result.x, result.y, content)
ElMessage.success(`已添加文本「${content}`)
break
} else if (tool === 'draw.image') {
const path = pendingImagePath.value
if (!path) {
ElMessage.warning('请先选择要插入的图片')
break
}
pendingImagePath.value = null
await runCreateImage(result.x, result.y, path)
ElMessage.success('已插入图片')
break
} else {
break
}
pendingImagePath.value = null
await runCreateImage(result.x, result.y, path)
ElMessage.success('已插入图片')
break
} else {
break
}
} finally {
if (pickLoopGen === myGen) pickLoopActive.value = false
}
pickLoopActive.value = false
}
const snapAny = computed(() => editTool.snap.value.grid || editTool.snap.value.object || editTool.snap.value.endpoint || editTool.snap.value.midpoint)
@@ -504,8 +524,6 @@ async function onTopBarCmd(cmd: string) {
case 'edit.invert': ElMessage.info('反选功能后续实现'); break
case 'project.save': await saveMapAndLeave(); break
case 'ai.open': await openAiDialog(); break
case 'file.upload.image': triggerUpload('image'); break
case 'file.upload.model': triggerUpload('model'); break
case 'view.fitAll': ElMessage.info('请在 3D 视口右键菜单使用「适配全部」'); break
case 'view.fitSelection': ElMessage.info('请在 3D 视口右键菜单使用「适配选中」'); break
case 'snap.toGrid': editTool.toggleSnap('grid'); break
@@ -532,7 +550,7 @@ async function onTopBarCmd(cmd: string) {
* 切换某一?viewFilter 开关并落地副作用:
* - 立即更新本地 reactive 状态,保证菜单勾选和右侧属性面板立即响应;
* - selectFilter 改变后立即从 selection 里剔除被禁的类型?
* - 同步?`POST /map-edit/view-filter` 把变化写?SimpleLite 后端?
* - 同步?`POST /map-edit/view-filter` 把变化写?Simple3 后端?
* selectFilter ?SimpleUI.SelectFilter* + RebuildWorkspaceSelectablePatterns(影响画布拾?+ hover?
* alignSnap ?SimpleUI.AlignSnap*(影响绘制工具吸附)
* showViewport ?SimpleUI.ShowViewport*(影响画布渲染:取消勾选后对应对象消失?
@@ -540,7 +558,7 @@ async function onTopBarCmd(cmd: string) {
* - 三组都会写一?localStorage 记住偏好?
*
* E2 修复:后端请求失败时回滚本地状?+ localStorage,让菜单勾选与"实际生效的过?保持一?—?
* 否则用户看到菜单?ON、画布表现是 OFF,会以为 SimpleLite 配置错乱。回滚后再弹一条带"重试"按钮的提示?
* 否则用户看到菜单?ON、画布表现是 OFF,会以为 Simple3 配置错乱。回滚后再弹一条带"重试"按钮的提示?
*/
async function toggleViewFilter<G extends keyof ViewFilterState>(
group: G,
@@ -570,7 +588,7 @@ async function toggleViewFilter<G extends keyof ViewFilterState>(
persistViewFilter()
if (group === 'selectFilter') pruneSelectionByFilter()
ElMessage({
message: '过滤设置写入后端失败,已回滚本地状态;请检查 SimpleLite 是否连通后重试。',
message: '过滤设置写入后端失败,已回滚本地状态;请检查 Simple3 是否连通后重试。',
type: 'warning',
duration: 5000,
showClose: true
@@ -609,7 +627,7 @@ async function applySelectOnlyMode(mode: SelectOnlyMode) {
persistViewFilter()
pruneSelectionByFilter()
ElMessage({
message: '选择过滤写入后端失败,已回滚;请检查 SimpleLite 是否连通后重试。',
message: '选择过滤写入后端失败,已回滚;请检查 Simple3 是否连通后重试。',
type: 'warning',
duration: 5000,
showClose: true
@@ -624,119 +642,6 @@ function trackKindLabel(kind: 'track' | 'bezier' | 'arc' | 'nurbs'): string {
: '直线路径'
}
/**
* 画布绘制弧线时收集半径并拼出 typeInfo?
*
* 半径校验:必??|AB|/2(小于则圆心不存在或重合,无法形成弧)。默认值用 ceil(|AB|/2) 即半圆?
*/
async function collectArcTypeInfoByCoord(
a: number,
b: number
): Promise<{ typeInfo: string; radius: number } | null | 'cancelled'> {
const sa = await fetchSiteXY(a)
const sb = await fetchSiteXY(b)
if (!sa || !sb) {
ElMessage.warning(`无法拉到 site #${a} / #${b} 的 x,y,将不带 typeInfo 创建 track(圆心需手动设置)`)
return null
}
const ab = Math.hypot(sa.x - sb.x, sa.y - sb.y)
const minR = ab / 2
let radius: number
try {
const v = await ElMessageBox.prompt(
`请输入半径 (mm),必须 >= ${minR.toFixed(1)}(等于即半圆)`,
'添加弧形路径',
{ inputValue: String(Math.ceil(minR)) }
)
radius = Number(v.value)
} catch { return 'cancelled' }
if (!Number.isFinite(radius) || radius < minR) {
ElMessage.warning(`半径必须 >= ${minR.toFixed(1)} mm`)
return 'cancelled'
}
return {
typeInfo: buildArcTypeInfo(sa, sb, radius),
radius
}
}
/**
* 创建完一?bezier / arc / nurbs 路径后给用户一句简短反馈?
* 现在所有几何参数(贝塞?NURBS 的控制点、弧线的圆心+半径)都在创建前就由前端
* 通过 typeInfo 字段一并落盘,画布选中?SimpleSceneRenderer.DrawTrackEditHandles
* 能直接画出控制点 / 控制折线 / 圆心,无需用户再走属性面板「设置圆心」「输入控制点」补救?
*/
function notifyTrackCreatedDefaults(kind: 'track' | 'bezier' | 'arc' | 'nurbs') {
if (kind === 'bezier' || kind === 'nurbs') {
ElMessage.success(
`${kind === 'bezier' ? '贝塞尔' : 'NURBS'}路径已创建。选中后可在画布看到控制点(白色端点 + 黄色中间点)和控制折线,可在属性面板拖拽 / 输入坐标 / 增删控制点继续调整。`
)
} else if (kind === 'arc') {
ElMessage.success('弧形路径已创建。选中后可在画布看到圆心;属性面板「设置圆心、半径」「设定相邻切线」可继续微调。')
}
}
// ──────────────────────────────────────────────────────────────────────────
// typeInfo 几何工具:把贝塞?/ NURBS / 弧线的几何序列化?SimpleLite 后端
// `Track.typeInfo`[FieldMember])可识别的字符串。后?ApplyExtraFields 会通过
// reflection ?typeInfo 写到刚刚 new 出的 UIBezierTrack/UICircularArcTrack/UINurbsTrack
// 上,画布渲染?TrackTypeInfoGeometry.HydrateTypedTrackFields / TrySamplePolyline
// 即按此字符串还原 BezierCurve.ControlPoints / Arc 几何,从而:
// - 第一次渲染就显示正确曲线(不?EnsureControlPoints ?[A, midpoint, B] 兜底直线?
// - 选中?DrawTrackEditHandles 立刻画出控制?+ 灰色控制折线 + 圆心十字
//
// 格式(来?TrackTypeInfoGeometry & SimpleComposer ?UpdateInfo):
// 弧线 : "1,{cx},{cy},{radius},{angleStartDeg},{angleEndDeg}"
// 贝塞?: "2,{count},{x0},{y0},{x1},{y1},...,{x_{count-1}},{y_{count-1}}"
// NURBS : "3,{count},{x0},{y0},...,{x_{count-1}},{y_{count-1}}"
// 坐标都是毫米,与 Site.x/y 量纲一致?
// ──────────────────────────────────────────────────────────────────────────
interface XYPoint { x: number; y: number }
/** 拉取站点真实坐标用于?typeInfo(后?createTrack 不会回填 site 坐标,前端必须自己取)?*/
async function fetchSiteXY(id: number): Promise<XYPoint | null> {
try {
const b = await reflectionApi.getBundle('site', id)
const x = Number(b.fields?.x ?? NaN)
const y = Number(b.fields?.y ?? NaN)
if (!Number.isFinite(x) || !Number.isFinite(y)) return null
return { x, y }
} catch { return null }
}
/** 贝塞?/ NURBS 共享控制折线序列化(kindFlag=2 贝塞?/ 3 NURBS)。allPoints = [siteA, ...中间, siteB]?*/
function buildCurveTypeInfo(allPoints: XYPoint[], kindFlag: 2 | 3): string {
const parts: string[] = [String(kindFlag), String(allPoints.length)]
for (const p of allPoints) {
parts.push(p.x.toFixed(3))
parts.push(p.y.toFixed(3))
}
return parts.join(',')
}
/**
* 弧线 typeInfo:圆心在 AB 中垂线上、距 AB 中点 d = sqrt(R² - |AB|²/4)?
* 调用前应已校?radius >= |AB|/2。side='left' 时圆心在 AB 法线左侧(默认)?
*/
function buildArcTypeInfo(sa: XYPoint, sb: XYPoint, radius: number, side: 'left' | 'right' = 'left'): string {
const ax = sb.x - sa.x
const ay = sb.y - sa.y
const len = Math.hypot(ax, ay)
const halfAb = len / 2
const dSq = radius * radius - halfAb * halfAb
const d = Math.sqrt(Math.max(0, dSq))
const px = len > 0 ? -ay / len : 0
const py = len > 0 ? ax / len : 0
const sign = side === 'left' ? 1 : -1
const cx = (sa.x + sb.x) / 2 + sign * d * px
const cy = (sa.y + sb.y) / 2 + sign * d * py
const angleStart = Math.atan2(sa.y - cy, sa.x - cx) * 180 / Math.PI
const angleEnd = Math.atan2(sb.y - cy, sb.x - cx) * 180 / Math.PI
return ['1', cx.toFixed(3), cy.toFixed(3), radius.toFixed(3), angleStart.toFixed(3), angleEnd.toFixed(3)].join(',')
}
// 把指定图层设为下次创建对象的默认落点。也作为 dialog 中「设为默认」按钮回调?
function setDefaultLayer(name: string) {
if (!name) return
@@ -789,11 +694,8 @@ async function onSelectTool(id: EditToolId) {
if (id !== 'draw.decor.text') pendingTextContent.value = null
if (id !== 'draw.image') pendingImagePath.value = null
// 切换工具会终止上一轮拾取循环(SimpleLite 端的 GetPoint 会被下一?cancel 截断?
pickLoopActive.value = false
// 一些工具是一次?action,点完直接执行:
if (id === 'select.clear') {
await stopPickLoop()
selection.clear()
await reflectionApi.clearSelection().catch(() => {})
return
@@ -808,19 +710,21 @@ async function onSelectTool(id: EditToolId) {
pendingTextContent.value = (value ?? '').trim() || 'Label'
} catch {
editTool.setTool('select.single')
await stopPickLoop()
return
}
startPickLoop()
await startPickLoop()
return
}
if (id === 'draw.image') {
// 先选文件;上传成功后再进入画布点选落点(?onFilePicked?
await stopPickLoop()
triggerUpload('image')
return
}
if (pickToolIds.includes(id)) {
// 切到需要画布拾取的工具:异步起一?pick 循环;用户每点一次就反射?create?
startPickLoop()
await startPickLoop()
} else {
await stopPickLoop()
}
if (id.startsWith('align.')) {
await applyAlignment(id.replace('align.', '') as AlignMode)
@@ -858,11 +762,6 @@ async function onCanvasPick(p: { x: number; y: number }) {
// 同一次点击会被创建两个站点?
if (pickLoopActive.value && pickToolIds.includes(id)) return
// 贝塞?/ NURBS 收集中间控制点期间,handleTrackPick 已直?await mapEditApi.pick()?
// 这里再走一次状态机会把同一次点击当成新一轮的 siteA。短路即可,pick-result 已被
// collectAndCreateBezierLike 消费?
if (inFollowupPick.value) return
if (id === 'draw.site' || id === 'draw.sites.continuous') {
await runCreateSite(p.x, p.y)
return
@@ -885,68 +784,129 @@ async function onCanvasPick(p: { x: number; y: number }) {
}
}
function onCanvasSelect(names: string[]) {
// names 形如 ["UISite-12", "UITrack-7", "UIBezierTrack-3", "UICircularArcTrack-5", "Car-2", ...]
// 直接?selection.set,由 parseSelectionNames 在内部按 viewFilter.selectFilter 过滤掉被禁类型?
selection.set(parseSelectionNames(names))
function applySelectionItems(items: SelectionItem[]) {
const key = makeSelectionKey(items)
if (key === lastSyncedSelectionKey.value) return
lastSyncedSelectionKey.value = key
selection.set(items)
}
function isMapEditorKind(kind: string | null | undefined): kind is ReflectionKind {
return kind === 'site' || kind === 'track' || kind === 'car' || kind === 'special'
}
let selectionBroadcastSeq = 0
async function resolveEmptySelectionBroadcast(seq: number) {
try {
const sel = await reflectionApi.getSelection()
if (seq !== selectionBroadcastSeq) return
if (isMapEditorKind(sel?.kind) && sel.id) {
applySelectionItems([{
kind: sel.kind,
id: sel.id,
name: sel.name || `${sel.kind}-${sel.id}`,
typeName: sel.typeName || sel.kind
}])
return
}
} catch {
if (seq !== selectionBroadcastSeq) return
}
if (seq !== selectionBroadcastSeq) return
applySelectionItems([])
}
function onCanvasSelect(names: string[]) {
const items = parseSelectionNames(names)
if (items.length === 0) return
applySelectionItems(items)
}
/** 画布/SSE 对象名 → SelectionItem;曲线段、句柄、字形/贴图归并到父对象。 */
function parseOneSelectionName(n: string): SelectionItem | null {
const handle = /^CurveHandle-(\d+)_/.exec(n)
if (handle) {
return {
kind: 'track',
id: Number(handle[1]),
name: n,
typeName: 'UIBezierTrack'
}
}
const text = /^TxtGlyph-(\d+)$/.exec(n) ?? /^UIText-glyph-(\d+)$/.exec(n)
if (text) {
return { kind: 'special', id: Number(text[1]), name: n, typeName: 'UIText' }
}
const image = /^ImgTex-(\d+)$/.exec(n)
?? /^UIImage-(?:tex|rgba)-(\d+)$/.exec(n)
if (image) {
return { kind: 'special', id: Number(image[1]), name: n, typeName: 'UIImage' }
}
const siteLabel = /^SiteSelLabel-(\d+)$/.exec(n)
if (siteLabel) {
return { kind: 'site', id: Number(siteLabel[1]), name: n, typeName: 'UISite' }
}
const m = /^UI([A-Za-z]+)-(\d+)(?:_seg\d+)?$/.exec(n)
?? /^([A-Za-z]+)-(\d+)(?:_seg\d+)?$/.exec(n)
if (!m) return null
const rawType = m[1]!
const id = Number(m[2])
const tn = rawType.toLowerCase()
let kind: ReflectionKind
if (tn === 'site') kind = 'site'
else if (tn === 'car' || tn === 'vehicle') kind = 'car'
else if (tn.includes('track')) kind = 'track'
else kind = 'special'
const typeName = n.startsWith('UI') ? `UI${rawType}` : rawType
return { kind, id, name: n, typeName }
}
/**
* ?SimpleLite ?prop name 解析?SelectionItem?
* 之前?onCanvasSelect 用了 "(?:Track)?" 的非贪婪正则,会?
* "UIBezierTrack-3" 拆成 typeName="Bezier" ?kind 错判?"special"?
* 这里改成统一逻辑:贪婪吃完字母前缀,再?typeName 内容判定 kind?
*
* 解析后会?viewFilter.selectFilter 过滤——和 SimpleLite 桌面端「选择」菜单语义一致:
* 关闭某类型则该类型不会被选中(即使画布层面物理上点中了它)?
*/
function parseSelectionNames(names: readonly string[]): SelectionItem[] {
const items: SelectionItem[] = []
const seen = new Set<string>()
const sf = viewFilter.selectFilter
for (const n of names) {
const m = /^UI([A-Za-z]+)-(\d+)$/.exec(n) ?? /^([A-Za-z]+)-(\d+)$/.exec(n)
if (!m) continue
const rawType = m[1]!
const id = Number(m[2])
const tn = rawType.toLowerCase()
let kind: ReflectionKind
if (tn === 'site') kind = 'site'
else if (tn === 'car' || tn === 'vehicle') kind = 'car'
else if (tn.includes('track')) kind = 'track'
else kind = 'special'
const parsed = parseOneSelectionName(n)
if (!parsed) continue
const dedupe = `${parsed.kind}:${parsed.id}`
if (seen.has(dedupe)) continue
seen.add(dedupe)
if (kind === 'site' && !sf.sites) continue
if (kind === 'track' && !sf.tracks) continue
if (kind === 'car' && !sf.cars) continue
if (kind === 'special' && !sf.decor) continue
if (parsed.kind === 'site' && !sf.sites) continue
if (parsed.kind === 'track' && !sf.tracks) continue
if (parsed.kind === 'car' && !sf.cars) continue
if (parsed.kind === 'special' && !sf.decor) continue
if (parsed.kind === 'track' && !trackPassesSelectMode(parsed.typeName)) continue
const typeName = n.startsWith('UI') ? `UI${rawType}` : rawType
if (kind === 'track' && !trackPassesSelectMode(typeName)) continue
items.push({
kind,
id,
name: n,
typeName
})
items.push(parsed)
}
return items
}
async function handleTrackPick(siteId: number) {
async function handleTrackPick(siteId: number): Promise<boolean> {
if (siteId <= 0) {
ElMessage.warning('请先拾取一个站点')
return
return false
}
if (drawTrackState.value.siteA == null) {
drawTrackState.value.siteA = siteId
ElMessage.success(`已选起点站 #${siteId},请再拾取终点站`)
return
return false
}
const a = drawTrackState.value.siteA
const b = siteId
drawTrackState.value.siteA = null
if (a === b) { ElMessage.warning('起止站点不能相同'); return }
if (a === b) {
drawTrackState.value.siteA = null
ElMessage.warning('起止站点不能相同')
return false
}
const kind: 'track' | 'bezier' | 'arc' | 'nurbs' =
editTool.activeTool.value === 'draw.track.bezier' ? 'bezier'
@@ -954,100 +914,152 @@ async function handleTrackPick(siteId: number) {
: editTool.activeTool.value === 'draw.track.nurbs' ? 'nurbs'
: 'track' // polyline / tracks.continuous 都走直线 UITrack
// 收集 typeInfo(贝塞尔/NURBS:弹个数后在画布上连?pick;弧线:弹半径自动算圆心)?
// 用户取消或参数非??整个创建动作终止,不?createTrack(避免落一条没有几何的曲线)?
let typeInfo: string | undefined
let extraLabel = ''
let midCount = 2
if (kind === 'bezier' || kind === 'nurbs') {
const r = await collectCurveTypeInfoByPick(a, b, kind)
if (r === 'cancelled') return
if (r != null) {
typeInfo = r.typeInfo
extraLabel = `(控制点 ${r.midCount}`
}
} else if (kind === 'arc') {
const r = await collectArcTypeInfoByCoord(a, b)
if (r === 'cancelled') return
if (r != null) {
typeInfo = r.typeInfo
extraLabel = `(半径 ${r.radius.toFixed(0)}`
const n = await promptCurveMidCount(kind)
if (n === 'cancelled') {
drawTrackState.value.siteA = null
return false
}
midCount = n
}
drawTrackState.value.siteA = null
let lastId = -1
await history.run({
label: `新建${trackKindLabel(kind)} (${a}->${b})${extraLabel}`,
label: `新建${trackKindLabel(kind)} (${a}->${b})`,
apply: async () => {
const payload: Record<string, unknown> = { siteA: a, siteB: b, layer: defaults.track.layer }
if (typeInfo) payload.typeInfo = typeInfo
const r = await mapEditApi.createTrack(kind, payload as { siteA: number; siteB: number })
const r = await mapEditApi.createTrack(kind, { siteA: a, siteB: b, layer: defaults.track.layer })
lastId = r.id
if ((kind === 'bezier' || kind === 'nurbs') && midCount !== 2) {
await applyCurveMidCount(lastId, kind, midCount, a, b)
}
},
revert: async () => {
if (lastId > 0) await mapEditApi.deleteObject('track', lastId)
}
})
notifyTrackCreatedDefaults(kind)
return true
}
/**
* 「画布拾取」场景下的贝塞尔 / NURBS typeInfo 收集?
* 1. 弹「中间控制点个数」对话框(默?2)?
* 2. 进入 inFollowupPick=true,连?N ?await mapEditApi.pick()——画布任意位置点击都算(不限站点)?
* 用户右键中途取??mapEditApi.pick() 抛错 ?整个收集中止?
* 3. 收集完拼 typeInfo?
*
* 注意:startPickLoop 主循环在 await handleTrackPick 时不会再?mapEditApi.pick()?
* 所以这里的 pick() 不会与主循环竞争同一?SSE pick-result?
*/
async function collectCurveTypeInfoByPick(
a: number,
b: number,
kind: 'bezier' | 'nurbs'
): Promise<{ typeInfo: string; midCount: number } | null | 'cancelled'> {
const sa = await fetchSiteXY(a)
const sb = await fetchSiteXY(b)
if (!sa || !sb) {
ElMessage.warning(`无法拉到 site #${a} / #${b} 的 x,y,将不带 typeInfo 创建 track(控制点会用兜底直线)`)
return null
}
const label = trackKindLabel(kind)
let midCount: number
async function promptCurveMidCount(kind: 'bezier' | 'nurbs'): Promise<number | 'cancelled'> {
try {
const v = await ElMessageBox.prompt(
'请输入中间控制点个数(不含起止站点;默认 2,输入 0 则不带控制点直接连接两站点',
`创建${label}`,
'请输入中间控制点个数(不含起止站点;默认 2,按邻边切向自动生成,无需在画布上点选',
`创建${trackKindLabel(kind)}`,
{ inputValue: '2', inputPattern: /^\d+$/, inputErrorMessage: '请输入非负整数' }
)
midCount = Math.max(0, Math.floor(Number(v.value)))
} catch { return 'cancelled' }
if (midCount === 0) {
return { typeInfo: buildCurveTypeInfo([sa, sb], kind === 'bezier' ? 2 : 3), midCount: 0 }
return Math.max(0, Math.floor(Number(v.value)))
} catch {
return 'cancelled'
}
}
ElMessage.info(`请在画布上依次点选 ${midCount} 个中间控制点(任意位置;右键取消创建)`)
inFollowupPick.value = true
const midPts: XYPoint[] = []
try {
for (let i = 0; i < midCount; i++) {
let r: { x: number; y: number; siteId: number }
try {
r = await mapEditApi.pick()
} catch {
ElMessage.info('已取消创建')
return 'cancelled'
}
midPts.push({ x: r.x, y: r.y })
ElMessage.success(`已记录第 ${i + 1}/${midCount} 个控制点 (${r.x.toFixed(0)}, ${r.y.toFixed(0)})`)
interface XYPoint { x: number; y: number }
function parseCurveTypeInfo(ti: string): { flag: 2 | 3; points: XYPoint[] } | null {
const parts = ti.split(',').map((s) => s.trim())
if (parts.length < 4) return null
const flag = Number(parts[0])
const count = Number(parts[1])
if ((flag !== 2 && flag !== 3) || !Number.isFinite(count) || count < 2) return null
const points: XYPoint[] = []
for (let i = 0; i < count; i++) {
const x = Number(parts[2 + i * 2])
const y = Number(parts[3 + i * 2])
if (!Number.isFinite(x) || !Number.isFinite(y)) return null
points.push({ x, y })
}
return { flag, points }
}
function formatCurveTypeInfo(flag: 2 | 3, points: XYPoint[]): string {
const parts: string[] = [String(flag), String(points.length)]
for (const p of points) {
parts.push(p.x.toFixed(3), p.y.toFixed(3))
}
return parts.join(',')
}
function deCasteljau(points: XYPoint[], t: number): XYPoint {
const buf = points.map((p) => ({ x: p.x, y: p.y }))
for (let k = 1; k < buf.length; k++) {
for (let i = 0; i < buf.length - k; i++) {
buf[i].x = (1 - t) * buf[i].x + t * buf[i + 1].x
buf[i].y = (1 - t) * buf[i].y + t * buf[i + 1].y
}
} finally {
inFollowupPick.value = false
}
return {
typeInfo: buildCurveTypeInfo([sa, ...midPts, sb], kind === 'bezier' ? 2 : 3),
midCount
return buf[0]!
}
function elevateBezier(pts: XYPoint[]): XYPoint[] {
const n = pts.length - 1
const out: XYPoint[] = [{ ...pts[0]! }]
for (let i = 1; i <= n; i++) {
const a = i / (n + 1)
out.push({
x: a * pts[i - 1]!.x + (1 - a) * pts[i]!.x,
y: a * pts[i - 1]!.y + (1 - a) * pts[i]!.y
})
}
out.push({ ...pts[n]! })
return out
}
function reshapeCurvePoints(pts: XYPoint[], midCount: number): XYPoint[] {
if (pts.length < 2) return pts
const a = pts[0]!
const b = pts[pts.length - 1]!
if (midCount <= 0) return [a, b]
if (midCount === 1) {
const mid = deCasteljau(pts, 0.5)
return [a, { x: 2 * mid.x - (a.x + b.x) / 2, y: 2 * mid.y - (a.y + b.y) / 2 }, b]
}
let cur = pts.length >= 4
? pts
: [
a,
{ x: a.x + (b.x - a.x) / 3, y: a.y + (b.y - a.y) / 3 },
{ x: a.x + (2 * (b.x - a.x)) / 3, y: a.y + (2 * (b.y - a.y)) / 3 },
b
]
const targetLen = midCount + 2
while (cur.length < targetLen) cur = elevateBezier(cur)
return cur
}
async function applyCurveMidCount(
trackId: number,
kind: 'bezier' | 'nurbs',
midCount: number,
siteA: number,
siteB: number
): Promise<void> {
const flag: 2 | 3 = kind === 'nurbs' ? 3 : 2
let pts: XYPoint[] | null = null
try {
const bundle = await reflectionApi.getBundle('track', trackId)
pts = parseCurveTypeInfo(bundle.fields?.typeInfo ?? '')?.points ?? null
} catch { /* 用两端站点坐标兜底 */ }
if (!pts || pts.length < 2) {
const sa = await fetchSiteXY(siteA)
const sb = await fetchSiteXY(siteB)
if (!sa || !sb) return
pts = [sa, sb]
}
const next = reshapeCurvePoints(pts, midCount)
await reflectionApi.setField('track', trackId, 'typeInfo', formatCurveTypeInfo(flag, next))
}
async function fetchSiteXY(id: number): Promise<XYPoint | null> {
try {
const b = await reflectionApi.getBundle('site', id)
const x = Number(b.fields?.x ?? NaN)
const y = Number(b.fields?.y ?? NaN)
if (!Number.isFinite(x) || !Number.isFinite(y)) return null
return { x, y }
} catch { return null }
}
// ──────────────────────────────────────────────────────────────────────────
@@ -1954,10 +1966,9 @@ async function onFilePicked(ev: Event) {
if (fileUploadKind.value === 'image') {
pendingImagePath.value = r.savedPath
editTool.setTool('draw.image')
pickLoopActive.value = false
startPickLoop()
await startPickLoop()
} else {
ElMessage.info('插入模型功能暂缓(SimpleLite 侧未完整实现)')
ElMessage.info('插入模型功能暂缓(Simple3 侧未完整实现)')
}
} catch (err) {
ElMessage.error(`上传失败:${(err as Error).message}`)
@@ -1993,21 +2004,14 @@ const stream = useMapEditStream({
onObjectBatchChanged: () => refreshAfterMutation(),
onViewportStyleUpdated: () => { void loadViewportStyle() },
onSelectionDetail: (e) => {
// ?SimpleLite ?canvas 内的选中实时同步?Vue 端,让右侧属性面板自动刷新?
//
// 重要:仅?e.names 是数组时才覆?selection?
// 历史教训:早期某些后端路径(ReflectionApiController.SetSelection / ClearSelection?
// 广播?selection-detail 缺少 names 字段,e.names ?undefined?
// 若此时直?`e.names ?? []` 会把刚选中的对象误清空,进一步触?watch ?primary ?null
// ?右侧属性面板瞬时显?"未选中对象"。所以这里非数组直接忽略,保留当?selection?
if (!Array.isArray(e.names)) return
const incoming = parseSelectionNames(e.names)
const incomingKey = makeSelectionKey(incoming)
// 防回环:广播回来的就是本地刚推上去的同一份,跳过避免死循?
// watch(selection) ?reflectionApi.setSelection ?后端广播 selection-detail ?这里?
if (incomingKey === lastSyncedSelectionKey.value) return
lastSyncedSelectionKey.value = incomingKey
selection.set(incoming)
const seq = ++selectionBroadcastSeq
if (incoming.length > 0) {
applySelectionItems(incoming)
return
}
void resolveEmptySelectionBroadcast(seq)
}
})
@@ -2019,14 +2023,7 @@ watch(() => stream.connected.value, (v) => (streamConnected.value = v), { immedi
watch(() => makeSelectionKey(selection.items.value), (key) => {
refreshPrimary()
// 记录"本地刚发??key ?onSelectionDetail 用,无论单?多选都先记?
// setSelection ?fire-and-forget,但后端广播一定晚于本调用,因?last
// SyncedSelectionKey 在广播到达时已就绪)?
lastSyncedSelectionKey.value = key
// 把后端选中状态同步到 SimpleLite(单选)
if (selection.items.value.length === 1) {
reflectionApi.setSelection(selection.items.value[0]!.kind, selection.items.value[0]!.id).catch(() => {})
}
})
// ──────────────────────────────────────────────────────────────────────────
@@ -2105,7 +2102,7 @@ function onWorkspaceShortcutEvent(ev: Event) {
/**
* 从后端拉当前?viewFilter 真实状态,把本?reactive 覆盖?
* SimpleLite ?source of truth:避免「前端勾着、SimpleLite 真实开关却没改」的偏差
* Simple3 ?source of truth:避免「前端勾着、Simple3 真实开关却没改」的偏差
* (比如另一?tab 已经把过滤打开 / 关掉,本 tab 进入时应当反映那次状态)?
* 失败时静默回退到本?localStorage 缓存(loadViewFilter 已先调过)?
*/