feat: v2.13 细节尺寸编辑——组合与样板的每一格可精确编辑

- WindoorCanvas: 点击玻璃格选中并输入格W/格H(自动平移边界中梃, 共线多段组移动)
- 选中中梃可输入距边位置精确移动; 中梃拖动改组移动修复多段错位
- CombineView: 编辑细节对话框(复用 WindoorCanvas), 保存原位写回对应单元并驱动 3D 重建
- CombineCanvas: 中梃按各自裁剪 bbox 绘制, 与真实分格一致
- 验证: 格尺寸/梃位/组合写回真实浏览器截图全通过
This commit is contained in:
HHY
2026-09-23 09:18:56 -07:00
parent c780618a17
commit 6e5428032a
12 changed files with 371 additions and 13 deletions
+166 -3
View File
@@ -9,6 +9,16 @@
controls-position="right" style="width:104px" @change="onSizeInput" /></span>
<span class="dim-input">H <el-input-number v-model="sizeH" :min="300" :step="10" size="small"
controls-position="right" style="width:104px" @change="onSizeInput" /></span>
<template v-if="selRegion">
<span class="dim-input">格W <el-input-number v-model="regW" :min="100" :step="10" size="small"
controls-position="right" style="width:92px" @change="onRegionSize" /></span>
<span class="dim-input">格H <el-input-number v-model="regH" :min="100" :step="10" size="small"
controls-position="right" style="width:92px" @change="onRegionSize" /></span>
</template>
<template v-else-if="selected">
<span class="dim-input">梃位 <el-input-number v-model="mulPos" :min="0" :step="10" size="small"
controls-position="right" style="width:92px" @change="onMullionPos" /></span>
</template>
<el-button size="small" :type="tool === 'v' ? 'primary' : ''" @click="arm('v')">+竖梃</el-button>
<el-button size="small" :type="tool === 'h' ? 'primary' : ''" @click="arm('h')">+横梃</el-button>
<el-button size="small" :type="selected ? 'danger' : ''" :disabled="!selected" @click="deleteSelected">删除梃</el-button>
@@ -58,6 +68,111 @@ const sizeH = ref(0)
let resizeDrag: null | { axis: 'w' | 'h' | 'wh'; startW: number; startH: number; startX: number; startY: number } = null
let moveDrag: null | { shapeIdx: number; spIdx: number; startPos: number; startX: number; startY: number; snapshot: string } = null
const selected = ref<{ shapeIdx: number; spIdx: number } | null>(null)
// 细节编辑: 选中的玻璃格(可输入格宽高) ; selRegion 与 selected(梃) 互斥
const selRegion = ref<{ shapeIdx: number; ri: number } | null>(null)
const regW = ref(0)
const regH = ref(0)
const mulPos = ref(0)
/** 选中格 bbox */
function regionBb(shapeIdx: number, ri: number) {
const r = replays[shapeIdx]?.regions[ri]
if (!r) return null
const pts = regionPoints(r)
return pts.length >= 3 ? bboxOf(pts) : null
}
function syncRegionInputs() {
if (!selRegion.value) return
const bb = regionBb(selRegion.value.shapeIdx, selRegion.value.ri)
if (bb) { regW.value = Math.round(bb.x1 - bb.x0); regH.value = Math.round(bb.y1 - bb.y0) }
}
function syncMullionPos() {
if (!selected.value || !dd) return
const sp = dd.sm[selected.value.shapeIdx]?.mm?.sp?.[selected.value.spIdx]
if (!sp) return
const axis = spAxis(sp)
mulPos.value = Math.round(spPos(sp) - (axis === 'v' ? modelBox.x0 : modelBox.y0))
}
/** 共线同位组: 同一物理梃被记录为多条共线分段, 移动须成组 */
function mullionGroup(shapeIdx: number, axis: 'v' | 'h', pos: number) {
return (dd?.sm[shapeIdx]?.mm?.sp ?? []).filter(s => spAxis(s) === axis && Math.abs(spPos(s) - pos) < 0.5)
}
/** 组增量移动(返回实际位移): 限制在组内各段裁剪 bbox 内缩 30mm 的交集 */
function moveGroup(shapeIdx: number, axis: 'v' | 'h', pos: number, delta: number): number {
const group = mullionGroup(shapeIdx, axis, pos)
if (!group.length) return 0
let lo = -Infinity, hi = Infinity
for (const s of group) {
const clip = replays[shapeIdx]?.clips.find(c => c.sp === s)
const bb = clip?.bb ?? modelBox
if (axis === 'v') { lo = Math.max(lo, bb.x0 + 30); hi = Math.min(hi, bb.x1 - 30) }
else { lo = Math.max(lo, bb.y0 + 30); hi = Math.min(hi, bb.y1 - 30) }
}
if (lo > hi) return 0
const applied = Math.min(hi, Math.max(lo, pos + delta)) - pos
for (const s of group) {
if (axis === 'v') s.line.pt.x += applied
else s.line.pt.y += applied
}
return applied
}
/** 格尺寸编辑: 输入目标宽/高, 平移该格的边界中梃(优先右/下边界, 其次左/上) */
function onRegionSize() {
if (!selRegion.value || !dd) return
const { shapeIdx, ri } = selRegion.value
const bb = regionBb(shapeIdx, ri)
if (!bb) return
const sps = dd.sm[shapeIdx]?.mm?.sp ?? []
const curW = bb.x1 - bb.x0, curH = bb.y1 - bb.y0
const targetW = Math.max(100, Math.round(regW.value))
const targetH = Math.max(100, Math.round(regH.value))
let moved = false
if (Math.abs(targetW - curW) >= 1) {
const edge = sps.find(s => spAxis(s) === 'v' && Math.abs(spPos(s) - bb.x1) < 1.5)
?? sps.find(s => spAxis(s) === 'v' && Math.abs(spPos(s) - bb.x0) < 1.5)
if (edge) {
const sign = Math.abs(spPos(edge) - bb.x1) < 1.5 ? 1 : -1
moveGroup(shapeIdx, 'v', spPos(edge), (targetW - curW) * sign)
moved = true
}
}
if (Math.abs(targetH - curH) >= 1) {
const edge = sps.find(s => spAxis(s) === 'h' && Math.abs(spPos(s) - bb.y1) < 1.5)
?? sps.find(s => spAxis(s) === 'h' && Math.abs(spPos(s) - bb.y0) < 1.5)
if (edge) {
const sign = Math.abs(spPos(edge) - bb.y1) < 1.5 ? 1 : -1
moveGroup(shapeIdx, 'h', spPos(edge), (targetH - curH) * sign)
moved = true
}
}
if (!moved) { syncRegionInputs(); ElMessage.warning('该格贴外框, 请先加分格梃再调整'); return }
replayOne(shapeIdx)
computeModelBox()
syncSizeInputs()
syncRegionInputs()
render()
emit('change')
}
/** 梃位精确编辑: 距左边缘(竖梃)/距上边缘(横梃)距离 */
function onMullionPos() {
if (!selected.value || !dd) return
const sp = dd.sm[selected.value.shapeIdx]?.mm?.sp?.[selected.value.spIdx]
if (!sp) return
const axis = spAxis(sp)
const target = mulPos.value + (axis === 'v' ? modelBox.x0 : modelBox.y0)
const applied = moveGroup(selected.value.shapeIdx, axis, spPos(sp), target - spPos(sp))
replayOne(selected.value.shapeIdx)
syncMullionPos()
render()
emit('change')
if (Math.abs(applied - (target - spPos(sp))) > 0.5) ElMessage.warning('超出可移动范围')
}
type Replay = ReturnType<typeof replayShape>
let replays: Replay[] = []
@@ -294,6 +409,25 @@ function drawFrame(
ctx.restore()
}
// 选中玻璃格高亮: 蓝虚线框 + 格尺寸
if (selRegion.value && selRegion.value.shapeIdx === shapeIdx) {
const r = rp.regions[selRegion.value.ri]
if (r) {
const bb = bboxOf(regionPoints(r))
const x0 = tx(bb.x0), y0 = ty(bb.y0), x1 = tx(bb.x1), y1 = ty(bb.y1)
ctx.strokeStyle = '#409eff'
ctx.setLineDash([6, 4])
ctx.lineWidth = 2
ctx.strokeRect(x0, y0, x1 - x0, y1 - y0)
ctx.setLineDash([])
ctx.fillStyle = '#409eff'
ctx.font = 'bold 12px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`${Math.round(bb.x1 - bb.x0)} × ${Math.round(bb.y1 - bb.y0)} mm`, (x0 + x1) / 2, y0 - 8)
ctx.textAlign = 'left'
}
}
// 尺寸标注
if (regions.length) {
const bb = bboxOf(regions.flat())
@@ -355,6 +489,7 @@ function onClick(e: MouseEvent) {
const sx = e.clientX - rect.left, sy = e.clientY - rect.top
// 加梃模式
if (tool.value) {
selRegion.value = null
const click = toModel(sx, sy)
undoStack.push(JSON.stringify(dd))
const axis = tool.value
@@ -386,9 +521,30 @@ function onClick(e: MouseEvent) {
ElMessage.warning('请点击玻璃区域')
return
}
// 选择模式: 点中梃选中/取消
// 选择模式: 中梃优先, 其次玻璃格(细节尺寸编辑)
const m = hitMullion(sx, sy)
selected.value = m
if (m) {
selRegion.value = null
syncMullionPos()
render()
return
}
const p = toModel(sx, sy)
let hit: { shapeIdx: number; ri: number } | null = null
let hitArea = Infinity
for (let si = 0; si < replays.length; si++) {
const regions = replays[si].regions
for (let ri = 0; ri < regions.length; ri++) {
const pts = regionPoints(regions[ri])
if (pts.length < 3 || !pointInPoly(p, pts)) continue
const bb = bboxOf(pts)
const area = (bb.x1 - bb.x0) * (bb.y1 - bb.y0)
if (area < hitArea) { hit = { shapeIdx: si, ri }; hitArea = area }
}
}
selRegion.value = hit
if (hit) syncRegionInputs()
render()
}
@@ -425,6 +581,7 @@ function undo() {
if (!undoStack.length || !dd) return
dd = JSON.parse(undoStack.pop()!)
selected.value = null
selRegion.value = null
rebuildReplays()
computeModelBox()
syncSizeInputs()
@@ -506,8 +663,14 @@ function onMove(e: MouseEvent) {
const delta = axis === 'v' ? (e.clientX - moveDrag.startX) / scale : (e.clientY - moveDrag.startY) / scale
const lo = (axis === 'v' ? bb.x0 : bb.y0) + 30
const hi = (axis === 'v' ? bb.x1 : bb.y1) - 30
const newPos = Math.min(hi, Math.max(lo, moveDrag.startPos + delta))
if (axis === 'v') sp.line.pt.x = newPos; else sp.line.pt.y = newPos
const target = Math.min(hi, Math.max(lo, moveDrag.startPos + delta))
// 组移动: 先归位到 startPos 再统一平移(幂等), 共线多段同步
const cur = spPos(sp)
for (const s of mullionGroup(moveDrag.shapeIdx, axis, cur)) {
if (axis === 'v') s.line.pt.x = moveDrag.startPos
else s.line.pt.y = moveDrag.startPos
}
moveGroup(moveDrag.shapeIdx, axis, moveDrag.startPos, target - moveDrag.startPos)
replayOne(moveDrag.shapeIdx)
render()
}