@@ -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?
// - onCanvasPick( iframe ?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,22 +413,49 @@ function pickNeedsSiteSnap(tool: EditToolId): boolean {
)
}
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
}
}
async function startPickLoop ( ) {
if ( pickLoopActive . value ) return
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
// 工具栏交互约定:
// - 单次工具(draw.site / draw.track.*):完成一次后退出循环,但保持工具高亮(可再点工具续画)?
// - 连续工具:保持选中,右键取消为止?
// - 不再自动切到「移动」(该工具已移除);避免绘制中偶发跳工具?
while ( pickLoopActive . value ) {
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 ) } )
result = await mapEditApi . pick ( { snap : pickNeedsSiteSnap ( toolBeforePick ) , signal } )
} catch {
break
}
if ( ! pickLoopActive . value ) break
if ( pickLoopGen !== myGen || ! result ) break
const tool = editTool . activeTool . value
if ( ! pickToolIds . includes ( tool ) ) break
@@ -453,9 +472,8 @@ async function startPickLoop() {
|| 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 created = await handleTrackPick ( result . siteId )
if ( created ) {
const label = tool === 'draw.track.bezier' ? '贝塞尔路径'
: tool === 'draw.track.arc' ? '弧形路径'
: tool === 'draw.track.nurbs' ? 'NURBS 路径'
@@ -483,7 +501,9 @@ async function startPickLoop() {
break
}
}
pickLoopActive . value = false
} finally {
if ( pickLoopGen === myGen ) 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,27 +784,77 @@ 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 )
}
/**
* ?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 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
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 ( )
@@ -915,38 +864,49 @@ function parseSelectionNames(names: readonly string[]): SelectionItem[] {
else if ( tn . includes ( 'track' ) ) kind = 'track'
else kind = 'special'
if ( kind === 'site' && ! sf . sites ) continue
if ( kind === 'track' && ! sf . tracks ) continue
if ( kind === 'car' && ! sf . cars ) continue
if ( kind === 'special' && ! sf . decor ) continue
const typeName = n . startsWith ( 'UI' ) ? ` UI ${ rawType } ` : rawType
if ( kind === 'track' && ! trackPassesSelectMode ( typeName ) ) continue
return { kind , id , name : n , typeName }
}
items . push ( {
kind ,
id ,
name : n ,
typeName
} )
function parseSelectionNames ( names : readonly string [ ] ) : SelectionItem [ ] {
const items : SelectionItem [ ] = [ ]
const seen = new Set < string > ( )
const sf = viewFilter . selectFilter
for ( const n of names ) {
const parsed = parseOneSelectionName ( n )
if ( ! parsed ) continue
const dedupe = ` ${ parsed . kind } : ${ parsed . id } `
if ( seen . has ( dedupe ) ) continue
seen . add ( dedupe )
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
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
if ( a === b ) {
drawTrackState . value . siteA = null
if ( a === b ) { ElMessage . warning ( '起止站点不能相同' ) ; return }
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 }
}
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 ( )
return Math . max ( 0 , Math . floor ( Number ( v . value ) ) )
} 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 ) } ) ` )
}
} finally {
inFollowupPick . value = false
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 {
typeInfo : buildCurveTypeInfo ( [ sa , ... midPts , sb ] , kind === 'bezier' ? 2 : 3 ) ,
midCount
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
}
}
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 已先调过)?
*/