fix: v2.6 组合画布选中根因修复+右键菜单+3D 三场景
- 选中失效根因: Vue3 布尔 prop 缺省转换(editable 缺省→false)
withDefaults 默认 true 修复, 左键选中/右键选中/菜单动作全链路打通
- 右键菜单: 右键单元弹操作菜单(顺/逆旋转/镜像/底边对齐/删除)
动作 emit('ctx') 由父组件统一执行, document 级委托
- 3D 三场景: 室内(窗后墙+木地板)/室外(外墙+草地)/影棚(网格)
场景联动背景色, 真实浏览器截图验证三场景渲染
- 菜单动作行为级验证: 旋转后 1600x1800 宽高互换✓ 删除清空✓
@@ -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)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// 选中命中诊断: node scripts/debug-hit.mjs <token>
|
||||
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()
|
||||
@@ -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('诊断完成')
|
||||
@@ -0,0 +1,42 @@
|
||||
// 事件链诊断: node scripts/diag2.mjs <token>
|
||||
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()
|
||||
@@ -0,0 +1,43 @@
|
||||
// 决定性诊断: node scripts/diag3.mjs <token>
|
||||
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()
|
||||
@@ -0,0 +1,28 @@
|
||||
// el.click 派发测试: node scripts/elclick-test.mjs <token>
|
||||
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()
|
||||
@@ -0,0 +1,34 @@
|
||||
// Vue 监听器挂载诊断: node scripts/vei-check.mjs <token>
|
||||
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()
|
||||
@@ -0,0 +1,38 @@
|
||||
// 对比 canvas 与 ctx-menu 的 Vue 监听器挂载: node scripts/vei2.mjs <token>
|
||||
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()
|
||||
@@ -0,0 +1,41 @@
|
||||
// 菜单动作行为级验证: node scripts/verify-menu.mjs <token>
|
||||
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('验证完成')
|
||||
@@ -0,0 +1,82 @@
|
||||
// v2.6 综合验证: 选中/右键菜单/三场景 — node scripts/verify-v3.mjs <token>
|
||||
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('验证完成')
|
||||
@@ -1,11 +1,23 @@
|
||||
<template>
|
||||
<div class="cc-wrap" ref="wrapRef">
|
||||
<canvas ref="cvRef" @wheel.prevent="onWheel" @mousedown="onDown" @mousemove="onMove"
|
||||
@mouseup="onUp" @mouseleave="onUp" @dblclick="reset" />
|
||||
@mouseup="onUp" @mouseleave="onUp" @dblclick="reset"
|
||||
@contextmenu.prevent="onContextMenu" />
|
||||
<div class="hud">
|
||||
<span>{{ hint }}</span>
|
||||
<el-button size="small" text @click="reset">重置视图</el-button>
|
||||
</div>
|
||||
<!-- 右键操作菜单 -->
|
||||
<transition name="fade">
|
||||
<div v-if="ctxMenu.visible" class="ctx-menu" :style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px' }">
|
||||
<div class="ctx-title">单元 #{{ ctxMenu.uid }}</div>
|
||||
<div class="ctx-item" data-action="rotate-cw"><el-icon><RefreshRight /></el-icon>顺时针旋转 90°</div>
|
||||
<div class="ctx-item" data-action="rotate-ccw"><el-icon><RefreshLeft /></el-icon>逆时针旋转 90°</div>
|
||||
<div class="ctx-item" data-action="mirror"><el-icon><Switch /></el-icon>水平镜像</div>
|
||||
<div class="ctx-item" data-action="bottom"><el-icon><Bottom /></el-icon>底边对齐</div>
|
||||
<div class="ctx-item ctx-danger" data-action="delete"><el-icon><Delete /></el-icon>删除单元</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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<HTMLCanvasElement>()
|
||||
const wrapRef = ref<HTMLDivElement>()
|
||||
const hint = ref('拖入样板开始组合')
|
||||
const selectedUid = ref<number | null>(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; }
|
||||
</style>
|
||||
|
||||
@@ -26,7 +26,11 @@
|
||||
</div>
|
||||
<div class="p-row">
|
||||
<span class="p-label">场景</span>
|
||||
<el-checkbox v-model="showWall" size="small" @change="rebuild">墙体</el-checkbox>
|
||||
<el-radio-group v-model="scene3d" size="small" @change="rebuild">
|
||||
<el-radio-button value="indoor">室内</el-radio-button>
|
||||
<el-radio-button value="outdoor">室外</el-radio-button>
|
||||
<el-radio-button value="studio">影棚</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-checkbox v-model="autoRotate" size="small">自动旋转</el-checkbox>
|
||||
<el-button size="small" text type="primary" @click="screenshot">截图</el-button>
|
||||
</div>
|
||||
@@ -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 }
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<div class="canvas-box" @dragover.prevent @drop="onDrop" style="position:relative">
|
||||
<CombineCanvas ref="canvasRef" :shapes="shapes" @select="onSelect" @change="onCanvasChange" />
|
||||
<CombineCanvas ref="canvasRef" :shapes="shapes" @select="onSelect" @change="onCanvasChange" @ctx="onCtx" />
|
||||
</div>
|
||||
<div class="save-bar">
|
||||
<el-input v-model="saveName" placeholder="组合样板名称(如:三扇组合窗)" style="flex:1" />
|
||||
@@ -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() {
|
||||
|
||||
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 583 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 102 KiB |