From 6e5428032a52bc6bb80092bfa2739e3c8232af47 Mon Sep 17 00:00:00 2001 From: HHY Date: Wed, 23 Sep 2026 09:18:56 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20v2.13=20=E7=BB=86=E8=8A=82=E5=B0=BA?= =?UTF-8?q?=E5=AF=B8=E7=BC=96=E8=BE=91=E2=80=94=E2=80=94=E7=BB=84=E5=90=88?= =?UTF-8?q?=E4=B8=8E=E6=A0=B7=E6=9D=BF=E7=9A=84=E6=AF=8F=E4=B8=80=E6=A0=BC?= =?UTF-8?q?=E5=8F=AF=E7=B2=BE=E7=A1=AE=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WindoorCanvas: 点击玻璃格选中并输入格W/格H(自动平移边界中梃, 共线多段组移动) - 选中中梃可输入距边位置精确移动; 中梃拖动改组移动修复多段错位 - CombineView: 编辑细节对话框(复用 WindoorCanvas), 保存原位写回对应单元并驱动 3D 重建 - CombineCanvas: 中梃按各自裁剪 bbox 绘制, 与真实分格一致 - 验证: 格尺寸/梃位/组合写回真实浏览器截图全通过 --- CHANGELOG | 2 + frontend/scripts/verify-detail.mjs | 132 +++++++++++++++++ frontend/src/components/CombineCanvas.vue | 17 +-- frontend/src/components/WindoorCanvas.vue | 169 +++++++++++++++++++++- frontend/src/views/CombineView.vue | 62 ++++++++ frontend/src/views/TemplatesView.vue | 2 +- shots/detail-1-editor.png | Bin 0 -> 39760 bytes shots/detail-2-region-selected.png | Bin 0 -> 46186 bytes shots/detail-3-region-resized.png | Bin 0 -> 42288 bytes shots/detail-4-mullion-moved.png | Bin 0 -> 36137 bytes shots/detail-5-combine-dialog.png | Bin 0 -> 118818 bytes shots/detail-6-combine-saved.png | Bin 0 -> 133817 bytes 12 files changed, 371 insertions(+), 13 deletions(-) create mode 100644 frontend/scripts/verify-detail.mjs create mode 100644 shots/detail-1-editor.png create mode 100644 shots/detail-2-region-selected.png create mode 100644 shots/detail-3-region-resized.png create mode 100644 shots/detail-4-mullion-moved.png create mode 100644 shots/detail-5-combine-dialog.png create mode 100644 shots/detail-6-combine-saved.png diff --git a/CHANGELOG b/CHANGELOG index 8c542d7..e471899 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ # CHANGELOG +- [2026-09-23 05:40] FEAT: v2.13 细节尺寸编辑——组合与样板的每一格可精确编辑。①WindoorCanvas: 点击玻璃格选中(蓝虚线高亮+格尺寸标注), HUD 输入格W/格H 自动平移该格边界中梃(优先右/下边界其次左/上, 共线同位多段成组移动+组内各段裁剪 bbox 内缩30mm 交集 clamp); 选中中梃可输入距左/上边缘位置精确移动; 中梃拖动改组移动(修复共线多段拖动错位); ②CombineView: 工具栏新增"编辑细节"对话框(复用 WindoorCanvas 编辑器 editable 模式), 保存后原位写回对应单元形状(__uid/__name 保留, rev 驱动 3D 预览重建); ③CombineCanvas: 中梃改按各自裁剪 bbox 绘制(原横梃画满宽的示意画法与真实分格不符); ④TemplatesView 编辑提示文案更新; 验证: 真实浏览器截图(格选中 663×1207→格H=800 横梃精确下移/梃位 900 移动/组合对话框编辑保存写回后主画布与 3D 同步), vue-tsc 0 错误 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/components/CombineCanvas.vue, frontend/src/views/CombineView.vue, frontend/src/views/TemplatesView.vue, frontend/scripts/verify-detail.mjs, shots/detail-*.png) + - [2026-09-23 05:10] FIX: v2.12 全部 2D 画布上下颠倒修复(组合/编辑/效果图/AI变体)。根因: 四画布(WindoorCanvas/CombineCanvas/RenderCanvas/variantRenderer)的 ty 映射按 y 向上渲染, 而 v2.11 已实证模型坐标 y 向下(屏幕系) → 样板内部布局整体颠倒(31337 亮子被画到底部而 3D 在顶部, 用户报"方向反了"); 统一去翻转: ty 改直接映射+toModel 反演同步+ty(y1)/ty(y0) 顶底序全部归位, WindoorCanvas 同步修正尺寸手柄位置(右下角)/中梃竖向拖动 dy 符号/扇符号与尺寸标注位置; 验证: 组合页 2D 与 3D 方向一致(704 亮子均在上), 设计图纸画布 704 布局=官方平面图(左上亮子+左下扇+右整列), ui-shots 6 页无报错 (Files: frontend/src/components/CombineCanvas.vue, frontend/src/components/WindoorCanvas.vue, frontend/src/components/RenderCanvas.vue, frontend/src/components/variantRenderer.ts, frontend/scripts/verify-2d.mjs, shots/*) - [2026-09-23 04:45] FIX: v2.11 3D 全开/全关状态错误根治——分割树重放语义破译与扇五金解析重建(官方参考图逐样本实证)。①polyId 语义破译: {idx,pos}=(分裂子树节点id,子区域序), 节点id=sp 0基序号(节点0=根), child0=法线指向侧, 同一中梃跨区域记录多条, 分割线锚点=line.pt(所辖梃段中点)作定位兜底; replayShape 重写为树重放+regions/leafRefs/resolveRef 返回; 704(左下平开扇)/31594(7格双纱扇)/31729(上滑扇) 全部引用逐项精确命中官方平面图, 全量 798 扇矩形 100% 有效(validate3d 0 无效几何); ②坐标系修正: 模型坐标 y 向下(屏幕系), 3D 构建翻转 y, 布局上下关系与原图一致; ③扇开合五金解析: kind 由类型判定(Slide/DoubleSash=推拉), 铰链侧=hm.openDirection(left/right/up=上悬), 内外开=hm.openToward, 全开幅=od(90°, 平开扇垂直于墙面), 替换原 cos(od) 猜铰链侧与 32/40° 写死开幅; ④移除"滑扇附着上半区"兜底(v2.7 在错误重放上得出, 实为固定扇被误驱动开合), 2D 编辑器加梃写回 polyId 同步树寻址(leafRefs); ⑤验证: 真实浏览器截图(全关=官方图纸布局/46% 绕左铰链外开/100% 全开垂直)+ui-shots 6 页无报错 (Files: frontend/src/types/drawing.ts, frontend/src/components/WindoorThreed.vue, frontend/src/components/WindoorCanvas.vue, frontend/scripts/validate3d.cjs, frontend/scripts/analyze-split-tree.mjs, frontend/scripts/analyze-split-order.mjs, shots/*) diff --git a/frontend/scripts/verify-detail.mjs b/frontend/scripts/verify-detail.mjs new file mode 100644 index 0000000..442cbea --- /dev/null +++ b/frontend/scripts/verify-detail.mjs @@ -0,0 +1,132 @@ +// 细节编辑验证: ①样板编辑对话框选中玻璃格→格W/H输入→边界梃平移 ②组合页编辑细节对话框→改格→保存写回 +// 用法: node verify-detail.mjs +import puppeteer from 'puppeteer-core' +const EDGE = 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe' +const token = process.argv[2] +const sleep = ms => new Promise(r => setTimeout(r, ms)) +const browser = await puppeteer.launch({ + executablePath: EDGE, headless: 'new', + args: ['--window-size=1440,900', '--enable-unsafe-swiftshader'], + defaultViewport: { width: 1440, height: 900 }, +}) +const page = await browser.newPage() +page.on('pageerror', e => console.log(`[JS异常] ${String(e).slice(0, 200)}`)) +const shots = 'D:/Code/huamenchuang/shots' + +// 在画布内依次尝试点击候选点, 直到 HUD 出现"格W"(选中玻璃格) +async function clickUntilRegion(canvasBox) { + const candidates = [[0.35, 0.30], [0.30, 0.25], [0.40, 0.35], [0.32, 0.22]] + for (const [fx, fy] of candidates) { + await page.mouse.click(canvasBox.x + canvasBox.width * fx, canvasBox.y + canvasBox.height * fy) + await sleep(350) + const ok = await page.evaluate(() => { + const inputs = [...document.querySelectorAll('.hud .dim-input')] + return inputs.some(d => d.textContent.includes('格W')) + }) + if (ok) return true + } + return false +} + +// 设置指定 dim-input 的数值(按标签文本查找) +async function setDim(label, value) { + await page.evaluate((label, value) => { + const wrap = [...document.querySelectorAll('.hud .dim-input, .el-dialog .dim-input')] + .find(d => d.textContent.includes(label)) + if (!wrap) throw new Error('未找到输入: ' + label) + const input = wrap.querySelector('input') + input.value = String(value) + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new Event('change', { bubbles: true })) + }, label, value) + await sleep(500) +} + +const hasDim = label => page.evaluate((label) => { + return [...document.querySelectorAll('.hud .dim-input')].some(d => d.textContent.includes(label)) +}, label) + +// ============ ① 样板库编辑模式: 格尺寸编辑 ============ +await page.goto(`http://localhost:8000/templates?token=${encodeURIComponent(token)}`, { waitUntil: 'networkidle2' }) +await sleep(1500) +await page.click('.sample-card .cap, .card .cap, .cap') +await sleep(1200) +await page.evaluate(() => { + const btn = [...document.querySelectorAll('.el-dialog button')].find(b => b.textContent.includes('编辑分格')) + btn?.click() +}) +await sleep(900) +let cv = await (await page.$('.el-dialog .canvas-box canvas')).boundingBox() +await page.screenshot({ path: `${shots}/detail-1-editor.png`, clip: cv }) + +if (!(await clickUntilRegion(cv))) { console.error('✗ 未能选中玻璃格'); process.exit(1) } +await page.screenshot({ path: `${shots}/detail-2-region-selected.png`, clip: cv }) +const beforeW = await page.evaluate(() => { + const wrap = [...document.querySelectorAll('.hud .dim-input')].find(d => d.textContent.includes('格W')) + return Number(wrap.querySelector('input').value) +}) +await setDim('格H', 800) // 亮子高度调到 800 → 横梃下移 +await page.screenshot({ path: `${shots}/detail-3-region-resized.png`, clip: cv }) + +// 梃位编辑: 点击中央竖梃(画布中线附近) +await page.mouse.click(cv.x + cv.width * 0.50, cv.y + cv.height * 0.55) +await sleep(350) +if (await hasDim('梃位')) { + await setDim('梃位', 900) + await page.screenshot({ path: `${shots}/detail-4-mullion-moved.png`, clip: cv }) + console.log('✓ 梃位编辑完成') +} else { + console.log('△ 未命中中梃(点击位置在格上), 跳过梃位编辑截图') +} +console.log(`✓ 格尺寸编辑完成 (格W ${beforeW} → 调格H=800)`) +await page.close() + +// ============ ② 组合页: 细节编辑对话框 + 保存写回 ============ +const page2 = await browser.newPage() +page2.on('pageerror', e => console.log(`[JS异常2] ${String(e).slice(0, 200)}`)) +await page2.goto(`http://localhost:8000/combine?token=${encodeURIComponent(token)}`, { waitUntil: 'networkidle2' }) +await sleep(1000) +await page2.waitForSelector('.lib-item') +await page2.click('.lib-item') +await sleep(600) +// 点击画布中心选中单元(编辑细节按钮依赖选中态) +const mbox = await (await page2.$('.canvas-box canvas')).boundingBox() +await page2.mouse.click(mbox.x + mbox.width / 2, mbox.y + mbox.height / 2) +await sleep(400) +await page2.evaluate(() => { + const btn = [...document.querySelectorAll('button')].find(b => b.textContent.includes('编辑细节')) + btn?.click() +}) +await sleep(1200) +const dcv = await (await page2.$('.el-dialog canvas')).boundingBox() +if (!(await (async () => { + // clickUntilRegion 绑定的是 page(已关闭), 这里内联同逻辑 + const candidates = [[0.35, 0.30], [0.30, 0.25], [0.40, 0.35], [0.32, 0.22]] + for (const [fx, fy] of candidates) { + await page2.mouse.click(dcv.x + dcv.width * fx, dcv.y + dcv.height * fy) + await sleep(350) + const ok = await page2.evaluate(() => { + const inputs = [...document.querySelectorAll('.hud .dim-input')] + return inputs.some(d => d.textContent.includes('格W')) + }) + if (ok) return true + } + return false +})())) { console.error('✗ 组合细节编辑未能选中玻璃格'); process.exit(1) } +await page2.screenshot({ path: `${shots}/detail-5-combine-dialog.png` }) +await page2.evaluate(() => { + const wrap = [...document.querySelectorAll('.hud .dim-input')].find(d => d.textContent.includes('格H')) + const input = wrap.querySelector('input') + input.value = '900' + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new Event('change', { bubbles: true })) +}) +await sleep(500) +await page2.evaluate(() => { + const btn = [...document.querySelectorAll('.el-dialog__footer button')].find(b => b.textContent.includes('保存到单元')) + btn?.click() +}) +await sleep(900) +await page2.screenshot({ path: `${shots}/detail-6-combine-saved.png` }) +console.log('✓ 组合细节编辑+保存写回完成') +await browser.close() diff --git a/frontend/src/components/CombineCanvas.vue b/frontend/src/components/CombineCanvas.vue index c7ed59b..dae9bb3 100644 --- a/frontend/src/components/CombineCanvas.vue +++ b/frontend/src/components/CombineCanvas.vue @@ -208,7 +208,8 @@ function render() { ctx.lineJoin = 'round' for (const shape of props.shapes) { const sel = selectedUid.value !== null && shape.__uid === selectedUid.value - for (const region of replayShape(shape).regions) { + const rp = replayShape(shape) + for (const region of rp.regions) { const rpts = regionPoints(region) if (rpts.length < 3) continue ctx.beginPath() @@ -221,24 +222,22 @@ function render() { ctx.stroke() } // 外框 - const rs = replayShape(shape).regions.map(regionPoints).filter(p => p.length >= 3) + const rs = rp.regions.map(regionPoints).filter(p => p.length >= 3) if (rs.length) { const bb = bboxOf(rs.flat()) ctx.strokeStyle = sel ? '#409eff' : '#5c6166' ctx.lineWidth = 3 ctx.strokeRect(tx(bb.x0), ty(bb.y0), (bb.x1 - bb.x0) * scale, (bb.y1 - bb.y0) * scale) } - // 中梃(按重放区域裁剪近似:画在其基底 bbox 内) - const rb = rs.length ? bboxOf(rs.flat()) : null - for (const sp of (shape.mm?.sp ?? [])) { - if (!sp.line?.pt || !rb) continue + // 中梃(按各自裁剪 bbox 绘制, 与真实分格一致) + for (const { sp, bb } of rp.clips) { + if (!sp.line?.pt) continue const n = sp.line.norm ?? { x: 1, y: 0 } - const pos = Math.abs(n.x) >= Math.abs(n.y) ? sp.line.pt.x : sp.line.pt.y ctx.strokeStyle = sel ? '#66aaff' : '#7c828a' ctx.lineWidth = 2 ctx.beginPath() - if (Math.abs(n.x) >= Math.abs(n.y)) { ctx.moveTo(tx(pos), ty(rb.y0)); ctx.lineTo(tx(pos), ty(rb.y1)) } - else { ctx.moveTo(tx(rb.x0), ty(pos)); ctx.lineTo(tx(rb.x1), ty(pos)) } + if (Math.abs(n.x) >= Math.abs(n.y)) { ctx.moveTo(tx(sp.line.pt.x), ty(bb.y0)); ctx.lineTo(tx(sp.line.pt.x), ty(bb.y1)) } + else { ctx.moveTo(tx(bb.x0), ty(sp.line.pt.y)); ctx.lineTo(tx(bb.x1), ty(sp.line.pt.y)) } ctx.stroke() } } diff --git a/frontend/src/components/WindoorCanvas.vue b/frontend/src/components/WindoorCanvas.vue index ab8b2f7..a35326f 100644 --- a/frontend/src/components/WindoorCanvas.vue +++ b/frontend/src/components/WindoorCanvas.vue @@ -9,6 +9,16 @@ controls-position="right" style="width:104px" @change="onSizeInput" /> H + + +竖梃 +横梃 删除梃 @@ -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 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() } diff --git a/frontend/src/views/CombineView.vue b/frontend/src/views/CombineView.vue index 98379fe..e93cb2b 100644 --- a/frontend/src/views/CombineView.vue +++ b/frontend/src/views/CombineView.vue @@ -34,6 +34,9 @@ ⇋ 镜像 + + 编辑细节 +