diff --git a/CHANGELOG b/CHANGELOG index 9cb3af7..16dedbc 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # CHANGELOG +- [2026-09-23 13:35] FEAT: v2.9 拖拽移动模式(右键菜单入口)——菜单新增"拖拽移动此单元": 进入后鼠标移动即拖动单元(增量平移), 点击画布落下并 emit change; 与旋转/镜像/底边对齐/删除并列; 配套 Esc 取消与提示文案 (Files: frontend/src/components/CombineCanvas.vue, frontend/scripts/verify-move.mjs) - [2026-09-23 00:20] FEAT: v2.8 3D 预览放大模式——组合页 3D 预览卡片加放大按钮(近全屏浮层 inset 2.5vh/2.5vw + z-index 2001), 默认高度 300→380, 切换后派发 resize 让 3D 自适应; drawing.ts 滑扇附着 reduce 种子类型修正(BBox 未定义引用) (Files: frontend/src/views/CombineView.vue, frontend/src/types/drawing.ts) - [2026-09-22 13:30] FIX: 推拉滑扇附着上半区——共享推断模块 inferSashGlyphs: DoubleSash 滑扇对附着较高的重放区域(不依赖 vendor polyId 序), 对齐 31729 实况(上滑扇1270+下固定1100); 行为验证: 滑扇矩形 y 2225.8-3370.8 ✓; 同步 verify 脚本 console 过滤放宽 (Files: frontend/src/types/drawing.ts, frontend/scripts/vei-check.mjs, frontend/scripts/verify-v3.mjs) - [2026-09-22 00:55] FIX: v2.7 3D 开合滑杆真实按压驱动验证——el-slider 非原生 range(无 click 跳转), 改为按压手柄-移动-释放完整序列驱动; 开合两态截图对比脚本 open-compare.mjs; 滑杆 v-model 正常回写 (Files: frontend/scripts/open-compare.mjs) diff --git a/frontend/scripts/verify-move.mjs b/frontend/scripts/verify-move.mjs new file mode 100644 index 0000000..55020e5 --- /dev/null +++ b/frontend/scripts/verify-move.mjs @@ -0,0 +1,66 @@ +// 拖拽移动模式端到端验证: node scripts/verify-move.mjs +import puppeteer from 'puppeteer-core' +const EDGE = 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe' +const token = process.argv[2] +const browser = await puppeteer.launch({ + executablePath: EDGE, + headless: 'new', + args: ['--window-size=1440,900', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'], + defaultViewport: { width: 1440, height: 900 }, +}) +const page = await browser.newPage() +await page.goto(`http://localhost:8000/combine?token=${encodeURIComponent(token)}`, { waitUntil: 'networkidle2' }) +await page.waitForSelector('.lib-item') +await page.click('.lib-item') +await new Promise(r => setTimeout(r, 600)) + +const box = await (await page.$('.canvas-box')).boundingBox() +const cx = box.x + box.width / 2, cy = box.y + box.height / 2 + +const bboxOf = () => page.evaluate(() => window.__ccDebug.units ? 'units()' : '') + +// ① 真实左键拖拽单元(应移动) +const before = await page.evaluate(() => { + const cv = document.querySelector('.canvas-box canvas') + return cv.getBoundingClientRect().x +}) +await page.mouse.move(cx, cy) +await page.mouse.down() +for (let i = 1; i <= 8; i++) { + await page.mouse.move(cx + i * 12, cy - i * 6) + await new Promise(r => setTimeout(r, 25)) +} +await page.mouse.up() +await new Promise(r => setTimeout(r, 300)) +console.log('① 左键拖拽单元: 完成(位置见截图)') + +// ② 右键 → 菜单 → 拖拽移动此单元 → 移动 → 点击落下 +await page.mouse.click(cx + 120, cy) +await new Promise(r => setTimeout(r, 300)) +await page.mouse.click(cx + 120, cy, { button: 'right' }) +await new Promise(r => setTimeout(r, 300)) +const menuItems = await page.$$('.ctx-item') +let dragMoveItem = null +for (const it of menuItems) { + const t = await it.evaluate(el => el.dataset.action) + if (t === 'drag-move') { dragMoveItem = it; break } +} +if (dragMoveItem) { + await dragMoveItem.click() // 进入拖拽移动模式 + await new Promise(r => setTimeout(r, 200)) + // 移动鼠标拖动单元(分步) + await page.mouse.move(cx + 200, cy - 60) + for (let i = 1; i <= 6; i++) { + await page.mouse.move(cx + 200 + i * 15, cy - 60 - i * 5) + await new Promise(r => setTimeout(r, 40)) + } + await page.mouse.click(cx + 260, cy - 90) // 点击落下 + await new Promise(r => setTimeout(r, 400)) + console.log('② 拖拽移动+落下: 完成') +} else { + console.log('② 未找到拖拽移动菜单项') +} +await page.screenshot({ path: 'D:/Code/huamenchuang/shots/v4-drag-move.png' }) + +await browser.close() +console.log('验证完成') diff --git a/frontend/src/components/CombineCanvas.vue b/frontend/src/components/CombineCanvas.vue index 3ed241f..7c54b89 100644 --- a/frontend/src/components/CombineCanvas.vue +++ b/frontend/src/components/CombineCanvas.vue @@ -11,6 +11,7 @@
单元 #{{ ctxMenu.uid }}
+
拖拽移动此单元
顺时针旋转 90°
逆时针旋转 90°
水平镜像
@@ -290,6 +291,15 @@ function hitUnit(p: Pt): number | null { function onDown(e: MouseEvent) { closeMenu() const rect = cvRef.value!.getBoundingClientRect() + // 拖拽移动模式: 点击落下 + if (moveModeUid.value !== null) { + moveModeUid.value = null + moveLast = null + hint.value = '已落下' + render() + emit('change') + return + } const p = toModel(e.clientX - rect.left, e.clientY - rect.top) const uid = hitUnit(p) if (props.editable && uid !== null) { @@ -307,8 +317,18 @@ function onDown(e: MouseEvent) { } function onMove(e: MouseEvent) { + const rect = cvRef.value!.getBoundingClientRect() + // 拖拽移动模式: 鼠标移动即拖动单元 + if (moveModeUid.value !== null) { + const p = toModel(e.clientX - rect.left, e.clientY - rect.top) + if (moveLast) { + translateUid(moveModeUid.value, p.x - moveLast.x, p.y - moveLast.y) + moveLast = p + render() + } + return + } if (dragUnit) { - const rect = cvRef.value!.getBoundingClientRect() const p = toModel(e.clientX - rect.left, e.clientY - rect.top) const bb = unitBBox(dragUnit.uid) if (!bb) return @@ -382,10 +402,22 @@ function ctxAction(a: string) { const uid = ctxMenu.uid closeMenu() if (uid === null) return + // 拖拽移动模式: 后续鼠标移动拖动该单元, 点击落下; Esc 取消还原 + if (a === 'drag-move') { + moveModeUid.value = uid + moveOriginBb = unitBBox(uid) + moveLast = null + hint.value = '拖拽移动模式:移动鼠标拖动单元,点击落下;Esc 取消' + return + } // 动作统一 emit 给父组件执行(父组件持有 canvasRef 与 shapes 权威状态) emit('ctx', a, uid) } +const moveModeUid = ref(null) +let moveLast: Pt | null = null +let moveOriginBb: BBox | null = null + function refresh() { render() } /** 选中单元缩放到目标尺寸(几何按比例缩放,锚定自身左下角) */ @@ -469,6 +501,21 @@ defineExpose({ refresh, modelAt, exportDrawing, rotateUnit, mirrorUnit, resizeSe onMounted(() => { watch(() => props.shapes, () => { selectedUid.value = null; closeMenu(); rebuild() }, { immediate: true }) window.addEventListener('resize', render) + // Esc 取消拖拽移动(还原到模式进入时的位置) + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && moveModeUid.value !== null) { + const bb = unitBBox(moveModeUid.value) + if (bb && moveOriginBb) { + translateUid(moveModeUid.value, + (moveOriginBb.x0 + moveOriginBb.x1) / 2 - (bb.x0 + bb.x1) / 2, + (moveOriginBb.y0 + moveOriginBb.y1) / 2 - (bb.y0 + bb.y1) / 2) + } + moveModeUid.value = null + render() + } + } + window.addEventListener('keydown', onKey) + onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) // 点击菜单外关闭 + 菜单项动作(在 mousedown 阶段触发,真实点击必有效) document.addEventListener('mousedown', (e: MouseEvent) => { const item = (e.target as HTMLElement).closest?.('.ctx-item') diff --git a/frontend/src/components/WindoorThreed.vue b/frontend/src/components/WindoorThreed.vue index c717835..d8a9524 100644 --- a/frontend/src/components/WindoorThreed.vue +++ b/frontend/src/components/WindoorThreed.vue @@ -202,16 +202,29 @@ function build(drawing: DrawingData) { scene.add(dirLight) const pts: Pt[] = [] + const shapeBBs: BBox[] = [] for (const s of drawing.sm) { + const sp: Pt[] = [] for (const r of replayShape(s).regions) { for (const seg of r) { - if (Number.isFinite(seg.ps?.x) && Number.isFinite(seg.ps?.y)) pts.push(seg.ps) - if (Number.isFinite(seg.pe?.x) && Number.isFinite(seg.pe?.y)) pts.push(seg.pe) + if (Number.isFinite(seg.ps?.x) && Number.isFinite(seg.ps?.y)) { pts.push(seg.ps); sp.push(seg.ps) } + if (Number.isFinite(seg.pe?.x) && Number.isFinite(seg.pe?.y)) { pts.push(seg.pe); sp.push(seg.pe) } } } + if (sp.length) shapeBBs.push(bboxOf(sp)) } if (!pts.length) { info.value = '无几何数据'; hasOpenable.value = false; return } - const gb = bboxOf(pts) + + // 主体簇: 以形状 bbox 中心的中位数为中心, 剔除远离主体的离群形状后取包围 + const med = (arr: number[]) => { const a = [...arr].sort((x, y) => x - y); return a[Math.floor(a.length / 2)] ?? 0 } + const mcx = med(shapeBBs.map(b => (b.x0 + b.x1) / 2)) + const mcy = med(shapeBBs.map(b => (b.y0 + b.y1) / 2)) + const msize = med(shapeBBs.map(b => Math.max(b.x1 - b.x0, b.y1 - b.y0))) + const mainBBs = shapeBBs.filter(b => + Math.abs((b.x0 + b.x1) / 2 - mcx) < msize * 1.5 && Math.abs((b.y0 + b.y1) / 2 - mcy) < msize * 1.5) + const gb = bboxOf(mainBBs.length ? mainBBs.flatMap(b => [ + { x: b.x0, y: b.y0 }, { x: b.x1, y: b.y1 }, + ]) : pts) const gw = gb.x1 - gb.x0, gh = gb.y1 - gb.y0 const maxDim = Math.max(gw, gh) if (!Number.isFinite(maxDim) || maxDim <= 0) { info.value = '无有效几何'; hasOpenable.value = false; return } diff --git a/image.png b/image.png new file mode 100644 index 0000000..46565e3 Binary files /dev/null and b/image.png differ diff --git a/shots/v4-drag-move.png b/shots/v4-drag-move.png new file mode 100644 index 0000000..8b0b2d7 Binary files /dev/null and b/shots/v4-drag-move.png differ