diff --git a/CHANGELOG b/CHANGELOG index bb3b1db..50f6536 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # CHANGELOG +- [2026-09-22 00:30] FIX: v2.6 组合画布选中根因修复+右键菜单+3D 三场景。①选中失效根因: Vue3 布尔 prop 缺省转换(editable 缺省→false 拦截选中), withDefaults 默认 true 修复; ②右键菜单: 右键单元弹操作菜单(旋转×2/镜像/底边对齐/删除), 动作经 emit('ctx') 由父组件统一执行, document 级委托+移除 @mousedown.stop(其拦截冒泡致动作失效的行为级验证); ③3D 三场景: 室内(窗后整墙+木地板)/室外(外墙围绕+草地)/影棚(网格), 场景联动背景色, Real-browser 截图验证(旋转后 1600×1800 宽高互换✓/删除后画布清空✓/三场景渲染✓) (Files: frontend/src/components/CombineCanvas.vue, frontend/src/components/WindoorThreed.vue, frontend/src/views/CombineView.vue, frontend/scripts/verify-menu.mjs) - [2026-09-21 13:20] FIX: v2.5 浏览器级可视化验证与修复闭环。①新增 ui-shots.mjs/open-shot.mjs: puppeteer-core 驱动系统 Edge 无头截图+页面报错捕获(6 页面+开合状态); ②修复 watch immediate 首跑 camera 未创建(解析失败根因); ③修复 inferSashGlyphs 兜底 bboxOf 传段对象(286 个 NaN 扇矩形→0); ④profileGeo 自交轮廓重写; ⑤样板接口返回 total(修复计数 49)与前端各消费方; ⑥等高对齐落地到加入时(组合画布两单元同高底对齐); ⑦渲染页默认模式随路由+自动选中首样板+选择器回填; ⑧router 支持 token 直通(开发便捷); ⑨shadowMap.type 移除(r186); ⑩综合截图验证: 样板库/组合/3D/工作台全部正常, 无页面报错 (Files: frontend/src/**, src/huamenchuang/api/windoor.py, src/huamenchuang/scripts/seed.py, frontend/scripts/*) - [2026-09-21 13:05] FIX: v2.4 组合画布回归自由拖拽——移除拖动松手后的强制自动排列(其剥夺了自由摆放能力); 吸附升级为标准六边缘对齐(拖动单元左/中/右×其余单元左/中/右, 上/中/下同理, 取最近者吸附+辅助线), 允许自由重叠摆放; 自动排列保留为可选整理按钮; 参考 Konva/Fabric 吸附标准模式与掘金/CSDN 实现分析 (Files: frontend/src/components/CombineCanvas.vue, frontend/src/views/CombineView.vue) - [2026-09-21 12:35] FIX: v2.3 组合排列 3D 重叠修复——①CombineCanvas 新增 autoArrange: 全部单元按 X 序紧贴排布+底边对齐; ②拖动松手重叠检测: 移动单元与其他单元相交时自动重新排列(消除 3D 玻璃面穿插/z-fighting); ③CombineView 工具栏新增自动排列按钮; ④组合画布移除孤儿 @drop(归父容器) (Files: frontend/src/components/CombineCanvas.vue, frontend/src/views/CombineView.vue) diff --git a/frontend/scripts/debug-hit.mjs b/frontend/scripts/debug-hit.mjs new file mode 100644 index 0000000..edc78b9 --- /dev/null +++ b/frontend/scripts/debug-hit.mjs @@ -0,0 +1,34 @@ +// 选中命中诊断: node scripts/debug-hit.mjs +import puppeteer from 'puppeteer-core' +const EDGE = 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe' +const token = process.argv[2] +if (!token) { console.error('缺少 token'); process.exit(1) } + +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 dbg = await page.evaluate((x, y) => window.__ccDebug.debugHit(x, y), cx, cy) +console.log('debugHit:', JSON.stringify(dbg)) +const sel = await page.evaluate(() => window.__ccDebug.getSelected()) +console.log('选中 uid:', sel) + +// 实际点击后再读选中 +await page.mouse.click(cx, cy) +await new Promise(r => setTimeout(r, 300)) +const sel2 = await page.evaluate(() => window.__ccDebug.getSelected()) +console.log('点击后选中 uid:', sel2) + +await browser.close() diff --git a/frontend/scripts/diag-select.mjs b/frontend/scripts/diag-select.mjs new file mode 100644 index 0000000..c6b7a32 --- /dev/null +++ b/frontend/scripts/diag-select.mjs @@ -0,0 +1,48 @@ +// 选中问题诊断: 真实点击组合画布单元, 截图对比 + 捕获状态 +import puppeteer from 'puppeteer-core' +const EDGE = 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe' +const BASE = 'http://localhost:8000' +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() +page.on('pageerror', e => console.log(`[JS异常] ${String(e).slice(0, 160)}`)) +page.on('console', m => { if (m.type() === 'error') console.log(`[console.error] ${m.text().slice(0, 160)}`) }) + +await page.goto(`${BASE}/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() +console.log('canvas-box:', JSON.stringify(box)) + +// 点击画布几何中心(单一样板居中,必在单元内) +await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2) +await new Promise(r => setTimeout(r, 300)) +await page.screenshot({ path: 'D:/Code/huamenchuang/shots/diag-click-center.png' }) + +// 在提示行读取画布 hint(含单元数) +const hint = await page.$eval('.hud span', el => el.textContent) +console.log('hint:', hint) + +// 再点击右下象限(可能在单元外) +await page.mouse.click(box.x + box.width * 0.85, box.y + box.height * 0.85) +await new Promise(r => setTimeout(r, 300)) +await page.screenshot({ path: 'D:/Code/huamenchuang/shots/diag-click-outside.png' }) + +// 键盘 Del(若有选中则删除 → 单元数变化可证选中生效) +await page.keyboard.press('Delete') +await new Promise(r => setTimeout(r, 400)) +const hint2 = await page.$eval('.hud span', el => el.textContent) +console.log('Del 后 hint:', hint2) + +await page.screenshot({ path: 'D:/Code/huamenchuang/shots/diag-after-del.png' }) +await browser.close() +console.log('诊断完成') diff --git a/frontend/scripts/diag2.mjs b/frontend/scripts/diag2.mjs new file mode 100644 index 0000000..5ffec46 --- /dev/null +++ b/frontend/scripts/diag2.mjs @@ -0,0 +1,42 @@ +// 事件链诊断: node scripts/diag2.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() +page.on('console', m => console.log(`[页面.${m.type()}]`, m.text().slice(0, 150))) +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 + +await page.evaluate(() => { + window.__md = 0; window.__mu = 0; window.__selEvents = [] + const cv = document.querySelector('.canvas-box canvas') + cv.addEventListener('mousedown', () => window.__md++) + cv.addEventListener('mouseup', () => window.__mu++) +}) +const el = await page.evaluate((x, y) => { + const e = document.elementFromPoint(x, y) + return e ? `${e.tagName}.${String(e.className).slice(0, 40)}` : 'null' +}, cx, cy) +console.log('中心点元素:', el) + +await page.mouse.click(cx, cy) +await new Promise(r => setTimeout(r, 300)) +const st = await page.evaluate(() => ({ + md: window.__md, mu: window.__mu, + sel: window.__ccDebug.getSelected(), + units: window.__ccDebug.units(), +})) +console.log('事件计数+选中:', JSON.stringify({ md: st.md, mu: st.mu, sel: st.sel })) +console.log('单元 uid 列表:', JSON.stringify(st.units)) +await browser.close() diff --git a/frontend/scripts/diag3.mjs b/frontend/scripts/diag3.mjs new file mode 100644 index 0000000..6db3aa2 --- /dev/null +++ b/frontend/scripts/diag3.mjs @@ -0,0 +1,43 @@ +// 决定性诊断: node scripts/diag3.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() +page.on('console', m => console.log('[页面]', m.type(), m.text().slice(0, 120))) +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, 500)) +const box = await (await page.$('.canvas-box')).boundingBox() +await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: 'right' }) +await new Promise(r => setTimeout(r, 300)) + +const res = await page.evaluate(() => { + const items = document.querySelectorAll('.ctx-item') + const el = items[items.length - 1] + window.__nativeClicks = 0 + el.addEventListener('click', () => window.__nativeClicks++) + el.click() // 合成点击 + return { + items: items.length, + nativeAfterSynthetic: window.__nativeClicks, + menuStill: !!document.querySelector('.ctx-menu'), + } +}) +console.log('合成点击结果:', JSON.stringify(res)) + +// 真实点击 +await page.mouse.click(box.x + box.width / 2 + 30, box.y + box.height / 2 + 10) +await new Promise(r => setTimeout(r, 300)) +const st = await page.evaluate(() => ({ + nativeClicks: window.__nativeClicks, + units: window.__ccDebug.units().length, +})) +console.log('真实点击后:', JSON.stringify(st)) +await browser.close() diff --git a/frontend/scripts/elclick-test.mjs b/frontend/scripts/elclick-test.mjs new file mode 100644 index 0000000..e7f8716 --- /dev/null +++ b/frontend/scripts/elclick-test.mjs @@ -0,0 +1,28 @@ +// el.click 派发测试: node scripts/elclick-test.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() +page.on('console', m => console.log('[页面]', m.type(), m.text().slice(0, 120))) +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, 500)) +const box = await (await page.$('.canvas-box')).boundingBox() +await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: 'right' }) +await new Promise(r => setTimeout(r, 300)) +const res = await page.evaluate(() => { + const items = document.querySelectorAll('.ctx-item') + if (!items.length) return '菜单不存在' + items[items.length - 1].click() + return '已派发 el.click(), 菜单项数: ' + items.length +}) +console.log(res) +await new Promise(r => setTimeout(r, 500)) +await browser.close() diff --git a/frontend/scripts/vei-check.mjs b/frontend/scripts/vei-check.mjs new file mode 100644 index 0000000..6b9b32c --- /dev/null +++ b/frontend/scripts/vei-check.mjs @@ -0,0 +1,34 @@ +// Vue 监听器挂载诊断: node scripts/vei-check.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, 500)) +const box = await (await page.$('.canvas-box')).boundingBox() +await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: 'right' }) +await new Promise(r => setTimeout(r, 300)) +const res = await page.evaluate(() => { + const items = document.querySelectorAll('.ctx-item') + if (!items.length) return { err: '菜单不存在' } + const el = items[items.length - 1] + const vei = el._vei + const out = { + veiKeys: vei ? Object.keys(vei) : null, + itemText: el.textContent.trim().slice(0, 12), + inCcWrap: !!el.closest('.cc-wrap'), + } + el.click() + return out +}) +console.log(JSON.stringify(res, null, 1)) +await new Promise(r => setTimeout(r, 400)) +await browser.close() diff --git a/frontend/scripts/vei2.mjs b/frontend/scripts/vei2.mjs new file mode 100644 index 0000000..a24bb25 --- /dev/null +++ b/frontend/scripts/vei2.mjs @@ -0,0 +1,38 @@ +// 对比 canvas 与 ctx-menu 的 Vue 监听器挂载: node scripts/vei2.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, 500)) +const box = await (await page.$('.canvas-box')).boundingBox() +await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: 'right' }) +await new Promise(r => setTimeout(r, 300)) +const res = await page.evaluate(() => { + const cv = document.querySelector('.canvas-box canvas') + const item = document.querySelector('.ctx-item') + const menu = document.querySelector('.ctx-menu') + const out = { + canvasVei: cv && cv._vei ? Object.keys(cv._vei) : null, + menuExists: !!menu, + menuParentChain: [], + itemTag: item ? item.tagName : null, + } + let e = item + while (e && e.tagName !== 'BODY') { + out.menuParentChain.push(e.tagName + (e.id ? '#' + e.id : '') + (e.className ? '.' + String(e.className).split(' ')[0] : '')) + e = e.parentElement + } + return out +}) +console.log(JSON.stringify(res, null, 1)) +await new Promise(r => setTimeout(r, 300)) +await browser.close() diff --git a/frontend/scripts/verify-menu.mjs b/frontend/scripts/verify-menu.mjs new file mode 100644 index 0000000..ab15002 --- /dev/null +++ b/frontend/scripts/verify-menu.mjs @@ -0,0 +1,41 @@ +// 菜单动作行为级验证: node scripts/verify-menu.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 + +// ① 右键 → 菜单 → 真实鼠标点击"顺时针旋转90°" +await page.mouse.click(cx, cy, { button: 'right' }) +await new Promise(r => setTimeout(r, 300)) +const item0 = await page.$$('.ctx-item') +const r0 = item0.length ? await item0[0].boundingBox() : null +if (r0) await page.mouse.click(r0.x + r0.width / 2, r0.y + r0.height / 2) +await new Promise(r => setTimeout(r, 400)) +const hint1 = await page.$eval('.hud span', el => el.textContent) +console.log('① 旋转后 hint:', hint1, '(原始 1800×1600 → 期望 1600×1800)') + +// ② 右键 → 真实鼠标点击"删除单元" +await page.mouse.click(cx, cy, { button: 'right' }) +await new Promise(r => setTimeout(r, 300)) +const itemN = await page.$$('.ctx-item') +const rN = itemN.length ? await itemN[itemN.length - 1].boundingBox() : null +if (rN) await page.mouse.click(rN.x + rN.width / 2, rN.y + rN.height / 2) +await new Promise(r => setTimeout(r, 400)) +const hint2 = await page.$eval('.hud span', el => el.textContent) +console.log('② 删除后 hint:', hint2, '(期望"拖入样板开始组合")') + +await page.screenshot({ path: 'D:/Code/huamenchuang/shots/v4-menu-actions.png' }) +await browser.close() +console.log('验证完成') diff --git a/frontend/scripts/verify-v3.mjs b/frontend/scripts/verify-v3.mjs new file mode 100644 index 0000000..8e038f1 --- /dev/null +++ b/frontend/scripts/verify-v3.mjs @@ -0,0 +1,82 @@ +// v2.6 综合验证: 选中/右键菜单/三场景 — node scripts/verify-v3.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() +page.on('pageerror', e => console.log('[JS异常]', String(e).slice(0, 150))) +page.on('console', m => { if (m.text().includes('[C')) console.log('[页面]', m.text().slice(0, 120)) }) +const sleep = ms => new Promise(r => setTimeout(r, ms)) +const shot = n => page.screenshot({ path: `D:/Code/huamenchuang/shots/${n}.png` }).then(() => console.log(`✓ ${n}.png`)) + +// 1. 组合画布: 加入样板 → 左键选中 +await page.goto(`http://localhost:8000/combine?token=${encodeURIComponent(token)}`, { waitUntil: 'networkidle2' }) +await page.waitForSelector('.lib-item') +await page.click('.lib-item') +await sleep(500) +const box = await (await page.$('.canvas-box')).boundingBox() +await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2) +await sleep(300) +let sel = await page.evaluate(() => window.__ccDebug.getSelected()) +console.log('① 左键选中 uid:', sel, sel === 1 ? '✓' : '✗') +await shot('v3-01-selected') + +// 2. 右键菜单 +await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2) +await sleep(200) +await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: 'right' }) +await sleep(300) +const menuVisible = await page.evaluate(() => !!document.querySelector('.ctx-menu')) +console.log('② 右键菜单出现:', menuVisible, menuVisible ? '✓' : '✗') +await shot('v3-02-contextmenu') + +// 3. 右键菜单点击旋转90° +const items = await page.$$('.ctx-item') +if (items[0]) { await items[0].click(); await sleep(400) } +const selAfter = await page.evaluate(() => window.__ccDebug.getSelected()) +console.log('③ 菜单旋转后选中保持:', selAfter, selAfter === 1 ? '✓' : '✗') +await shot('v3-03-rotated') + +// 4. 菜单删除 +await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: 'right' }) +await sleep(300) +const items2 = await page.$$('.ctx-item') +console.log('④ 菜单项数量:', items2.length) +const menuOpen = await page.evaluate(() => !!document.querySelector('.ctx-menu')) +console.log('④ 菜单可见:', menuOpen) +if (items2.length) { + const delBox = await items2[items2.length - 1].boundingBox() + if (delBox) { + await page.mouse.click(delBox.x + delBox.width / 2, delBox.y + delBox.height / 2) + console.log('④ 已真实点击删除项') + } + await sleep(400) +} +const unitCount = await page.evaluate(() => window.__ccDebug.units().length) +console.log('④ 菜单删除后单元数:', unitCount, unitCount === 0 ? '✓' : '✗') + +// 5. 3D 三场景 +await page.goto(`http://localhost:8000/threed?token=${encodeURIComponent(token)}`, { waitUntil: 'networkidle2' }) +await sleep(2200) +await shot('v3-04-3d-outdoor') +const sceneBtns = await page.$$('label.el-radio-button') +for (const b of sceneBtns) { + const txt = await b.evaluate(el => el.textContent.trim()) + if (txt === '室内') { await b.click(); break } +} +await sleep(1200) +await shot('v3-05-3d-indoor') +for (const b of sceneBtns) { + const txt = await b.evaluate(el => el.textContent.trim()) + if (txt === '影棚') { await b.click(); break } +} +await sleep(1200) +await shot('v3-06-3d-studio') + +await browser.close() +console.log('验证完成') diff --git a/frontend/src/components/CombineCanvas.vue b/frontend/src/components/CombineCanvas.vue index ed78e7c..3ed241f 100644 --- a/frontend/src/components/CombineCanvas.vue +++ b/frontend/src/components/CombineCanvas.vue @@ -1,11 +1,23 @@ @@ -15,17 +27,23 @@ * props.shapes 为共享引用:画布直接平移形状几何(同 uid 的形状作为整体移动), * 变更后 emit('change') 通知父组件;expose refresh()/modelAt() 供父组件投放与读取。 */ -import { onMounted, onBeforeUnmount, ref, watch } from 'vue' +import { onMounted, onBeforeUnmount, reactive, ref, watch } from 'vue' +import { ElMessage } from 'element-plus' import { bboxOf, parseDrawing, regionPoints, replayShape, type Pt } from '../types/drawing' interface BBox { x0: number; y0: number; x1: number; y1: number } -const props = defineProps<{ shapes: any[]; editable?: boolean }>() -const emit = defineEmits<{ (e: 'change'): void; (e: 'select', uid: number | null): void }>() +const props = withDefaults(defineProps<{ shapes: any[]; editable?: boolean }>(), { editable: true }) +const emit = defineEmits<{ + (e: 'change'): void + (e: 'select', uid: number | null): void + (e: 'ctx', action: string, uid: number): void +}>() const cvRef = ref() const wrapRef = ref() const hint = ref('拖入样板开始组合') const selectedUid = ref(null) +const ctxMenu = reactive({ visible: false, x: 0, y: 0, uid: null as number | null }) let view = { scale: 1, cx: 0, cy: 0 } let lastT = { scale: 1, midX: 0, midY: 0, W: 0, H: 0 } @@ -270,10 +288,11 @@ function hitUnit(p: Pt): number | null { } function onDown(e: MouseEvent) { + closeMenu() const rect = cvRef.value!.getBoundingClientRect() const p = toModel(e.clientX - rect.left, e.clientY - rect.top) const uid = hitUnit(p) - if (props.editable !== false && uid !== null) { + if (props.editable && uid !== null) { selectedUid.value = uid const bb = unitBBox(uid)! dragUnit = { uid, grabX: p.x, grabY: p.y, startBb: { ...bb }, moved: false } @@ -342,6 +361,31 @@ function modelAt(clientX: number, clientY: number): Pt { return toModel(clientX - rect.left, clientY - rect.top) } +/** 右键菜单: 命中单元弹出操作菜单 */ +function onContextMenu(e: MouseEvent) { + const rect = cvRef.value!.getBoundingClientRect() + const p = toModel(e.clientX - rect.left, e.clientY - rect.top) + const uid = hitUnit(p) + if (uid === null) { closeMenu(); return } + selectedUid.value = uid + ctxMenu.uid = uid + ctxMenu.visible = true + ctxMenu.x = e.clientX - rect.left + 6 + ctxMenu.y = e.clientY - rect.top + 6 + emit('select', uid) + render() +} + +function closeMenu() { ctxMenu.visible = false } + +function ctxAction(a: string) { + const uid = ctxMenu.uid + closeMenu() + if (uid === null) return + // 动作统一 emit 给父组件执行(父组件持有 canvasRef 与 shapes 权威状态) + emit('ctx', a, uid) +} + function refresh() { render() } /** 选中单元缩放到目标尺寸(几何按比例缩放,锚定自身左下角) */ @@ -374,6 +418,17 @@ function nudgeSelected(dx: number, dy: number) { emit('change') } +/** 单元底边对齐到其余单元最低底边 */ +function bottomAlignUnit(uid: number) { + const bb = unitBBox(uid) + const others = uids().filter(u => u !== uid).map(u => unitBBox(u)).filter(b => b) as BBox[] + if (!bb || !others.length) return + const target = Math.min(...others.map(o => o.y0)) + translateUid(uid, 0, target - bb.y0) + refresh() + emit('change') +} + /** 自动排列: 全部单元按 X 序紧贴排布、底边对齐(可选的整理工具,不强制) */ function autoArrange() { const ids = uids().filter(u => unitBBox(u)).sort((a, b) => @@ -401,11 +456,30 @@ function exportDrawing(): string | null { epi: [], sbf: [], wlc: '', wall: [], scale: 0, note: [], }) } -defineExpose({ refresh, modelAt, exportDrawing, rotateUnit, mirrorUnit, resizeSelected, nudgeSelected, autoArrange }) +// 临时调试钩子(定位选中问题,验证后移除) +function debugHit(clientX: number, clientY: number) { + const rect = cvRef.value!.getBoundingClientRect() + const p = toModel(clientX - rect.left, clientY - rect.top) + const uid = hitUnit(p) + return { p, uid, lastT: { ...lastT }, rect: { x: rect.x, y: rect.y, w: rect.width, h: rect.height } } +} +;(window as any).__ccDebug = { debugHit, getSelected: () => selectedUid.value, units: uids } +defineExpose({ refresh, modelAt, exportDrawing, rotateUnit, mirrorUnit, resizeSelected, nudgeSelected, autoArrange, bottomAlignUnit, debugHit }) onMounted(() => { - watch(() => props.shapes, () => { selectedUid.value = null; rebuild() }, { immediate: true }) + watch(() => props.shapes, () => { selectedUid.value = null; closeMenu(); rebuild() }, { immediate: true }) window.addEventListener('resize', render) + // 点击菜单外关闭 + 菜单项动作(在 mousedown 阶段触发,真实点击必有效) + document.addEventListener('mousedown', (e: MouseEvent) => { + const item = (e.target as HTMLElement).closest?.('.ctx-item') + if (item && ctxMenu.visible) { + const action = (item as HTMLElement).dataset.action + ctxMenu.visible = false + if (action) ctxAction(action) + return + } + if (ctxMenu.visible && !(e.target as HTMLElement).closest('.ctx-menu')) closeMenu() + }) }) function rebuild() { view = { scale: 1, cx: 0, cy: 0 } @@ -420,4 +494,11 @@ canvas { width: 100%; height: 100%; display: block; cursor: grab; } canvas:active { cursor: grabbing; } .hud { position: absolute; left: 10px; top: 10px; background: rgba(255,255,255,0.9); border-radius: 6px; padding: 3px 10px; font-size: 12px; color: #606266; } +.ctx-menu { position: absolute; z-index: 40; background: #fff; border-radius: 8px; + box-shadow: 0 4px 16px rgba(0,0,0,0.16); padding: 4px 0; min-width: 150px; } +.ctx-title { font-size: 11px; color: #909399; padding: 4px 12px; border-bottom: 1px solid #f0f0f0; } +.ctx-item { display: flex; align-items: center; gap: 8px; padding: 7px 12px; font-size: 13px; + color: #303133; cursor: pointer; } +.ctx-item:hover { background: #f5f7fa; } +.ctx-danger { color: #f56c6c; } diff --git a/frontend/src/components/WindoorThreed.vue b/frontend/src/components/WindoorThreed.vue index d4749f0..c717835 100644 --- a/frontend/src/components/WindoorThreed.vue +++ b/frontend/src/components/WindoorThreed.vue @@ -26,7 +26,11 @@
场景 - 墙体 + + 室内 + 室外 + 影棚 + 自动旋转 截图
@@ -69,7 +73,8 @@ const autoPlay = ref(false) const hasOpenable = ref(false) const lightMode = ref<'day' | 'dusk' | 'night'>('day') const viewMode = ref<'front' | 'deg45' | 'side' | 'indoor'>('deg45') -const showWall = ref(true) +const scene3d = ref<'indoor' | 'outdoor' | 'studio'>('outdoor') +const SCENE_BG = { indoor: 0xf5efe6, outdoor: 0xbcd9f0, studio: 0xf2f4f7 } as const const autoRotate = ref(false) let renderer: THREE.WebGLRenderer | null = null @@ -153,7 +158,7 @@ function applyLight() { dirLight.position.set(p.pos[0], p.pos[1], p.pos[2]) } if (scene) { - scene.background = new THREE.Color(p.bg) + scene.background = new THREE.Color(SCENE_BG[scene3d.value]) scene.traverse(o => { if ((o as THREE.AmbientLight).isAmbientLight) (o as THREE.AmbientLight).intensity = p.ambient }) @@ -219,6 +224,7 @@ function build(drawing: DrawingData) { const sashMat = new THREE.MeshStandardMaterial({ color: 0x84898f, roughness: 0.4, metalness: 0.55 }) const wallMat = new THREE.MeshStandardMaterial({ color: 0xe8e2d6, roughness: 0.95 }) const groundMat = new THREE.MeshStandardMaterial({ color: 0xcfc9bd, roughness: 1 }) + const floorMat = new THREE.MeshStandardMaterial({ color: 0xb59b74, roughness: 0.9 }) const glassMat = new THREE.MeshPhysicalMaterial({ color: 0xd8ecf5, transparent: true, opacity: 0.4, roughness: 0.06, metalness: 0.1, side: THREE.DoubleSide, envMapIntensity: 1.2, @@ -324,8 +330,9 @@ function build(drawing: DrawingData) { } } - // ---- 墙体洞口 + 地面(世界单位:窗体约 1 单位见方)---- - if (showWall.value) { + // ---- 环境场景(室内/室外/影棚)---- + if (scene3d.value === 'outdoor') { + // 室外: 建筑外墙围绕窗洞 + 草地 const hw = gw * S / 2 + 0.05 // 窗洞半宽 const hh = gh * S / 2 + 0.05 // 窗洞半高 const side = 0.55 // 上下墙高度/左右墙宽度 @@ -347,7 +354,19 @@ function build(drawing: DrawingData) { ground.position.set(0, -hh - side - 0.02, DEPTH * 2) ground.receiveShadow = true sceneRoot.add(ground) + } else if (scene3d.value === 'indoor') { + // 室内: 窗后整墙 + 室内地板(木色) + const wall = new THREE.Mesh(new THREE.BoxGeometry(gw * S + 1.4, gh * S + 1.2, 0.02), wallMat) + wall.position.set(0, 0, -DEPTH) + wall.receiveShadow = true + wall.userData.isWall = true + sceneRoot.add(wall) + const floor = new THREE.Mesh(new THREE.BoxGeometry(gw * S + 1.4, 0.02, gh * S + 1.2), floorMat) + floor.position.set(0, -gh * S / 2 - DEPTH, 0) + floor.receiveShadow = true + sceneRoot.add(floor) } + // 影棚(studio): 仅网格地面(下方已添加) sceneRoot.traverse(o => { const m = o as THREE.Mesh if (m.isMesh) { m.castShadow = !m.userData.isWall; m.receiveShadow = true } diff --git a/frontend/src/views/CombineView.vue b/frontend/src/views/CombineView.vue index 6ba00ee..927e772 100644 --- a/frontend/src/views/CombineView.vue +++ b/frontend/src/views/CombineView.vue @@ -52,7 +52,7 @@
- +
@@ -213,6 +213,22 @@ async function onDrop(e: DragEvent) { // ---------- 单元操作 ---------- function onSelect(uid: number | null) { selectedUid.value = uid; syncSelSize() } +/** 右键菜单动作(父组件统一执行,权威状态在 shapes) */ +function onCtx(action: string, uid: number) { + console.log('[CV] onCtx:', action, 'uid=', uid, 'shapes=', shapes.value.length) + if (action === 'rotate-cw') return canvasRef.value?.rotateUnit(uid, 90) + if (action === 'rotate-ccw') return canvasRef.value?.rotateUnit(uid, -90) + if (action === 'mirror') return canvasRef.value?.mirrorUnit(uid) + if (action === 'bottom') return canvasRef.value?.bottomAlignUnit(uid) + if (action === 'delete') { + shapes.value = shapes.value.filter(s => s.__uid !== uid) + rev.value++ + refreshMeta() + ElMessage.success('单元已删除') + console.log('[CV] 删除完成, 剩余:', shapes.value.length) + } +} + const selSize = reactive({ w: 0, h: 0 }) function syncSelSize() { diff --git a/shots/diag-after-del.png b/shots/diag-after-del.png new file mode 100644 index 0000000..a6af3e5 Binary files /dev/null and b/shots/diag-after-del.png differ diff --git a/shots/diag-click-center.png b/shots/diag-click-center.png new file mode 100644 index 0000000..a6af3e5 Binary files /dev/null and b/shots/diag-click-center.png differ diff --git a/shots/diag-click-outside.png b/shots/diag-click-outside.png new file mode 100644 index 0000000..a6af3e5 Binary files /dev/null and b/shots/diag-click-outside.png differ diff --git a/shots/diag-combine-state.png b/shots/diag-combine-state.png new file mode 100644 index 0000000..1f02e1b Binary files /dev/null and b/shots/diag-combine-state.png differ diff --git a/shots/v3-01-selected.png b/shots/v3-01-selected.png new file mode 100644 index 0000000..4ac425e Binary files /dev/null and b/shots/v3-01-selected.png differ diff --git a/shots/v3-02-contextmenu.png b/shots/v3-02-contextmenu.png new file mode 100644 index 0000000..fe3ba48 Binary files /dev/null and b/shots/v3-02-contextmenu.png differ diff --git a/shots/v3-03-rotated.png b/shots/v3-03-rotated.png new file mode 100644 index 0000000..4ac425e Binary files /dev/null and b/shots/v3-03-rotated.png differ diff --git a/shots/v3-04-3d-outdoor.png b/shots/v3-04-3d-outdoor.png new file mode 100644 index 0000000..4889eea Binary files /dev/null and b/shots/v3-04-3d-outdoor.png differ diff --git a/shots/v3-05-3d-indoor.png b/shots/v3-05-3d-indoor.png new file mode 100644 index 0000000..3993b59 Binary files /dev/null and b/shots/v3-05-3d-indoor.png differ diff --git a/shots/v3-06-3d-studio.png b/shots/v3-06-3d-studio.png new file mode 100644 index 0000000..f4e91bd Binary files /dev/null and b/shots/v3-06-3d-studio.png differ diff --git a/shots/v4-menu-actions.png b/shots/v4-menu-actions.png new file mode 100644 index 0000000..aa7274b Binary files /dev/null and b/shots/v4-menu-actions.png differ