fix: v2.16 样板库画布每块可编辑+模型框污染根治
- 预览对话框打开即编辑态, 报价/下单入口保留 - 格尺寸贴外框方向联动等比缩放整窗, 任意格任意方向均可编辑 - 根因修复: computeModelBox 误含扇 bd 退化点致 W/H 与梃位/缩放参考边全错 - 悬停格淡高亮 + pointer 光标; HUD 标签改格宽/格高 - 验证: 格尺寸/梃位/贴边缩放/组合写回四项截图全过, ui-shots 6 页无报错
@@ -1,5 +1,7 @@
|
||||
# CHANGELOG
|
||||
|
||||
- [2026-09-23 07:00] FIX: v2.16 样板库画布每块可编辑+模型框污染根治。①TemplatesView 预览对话框打开即编辑态(去掉"编辑分格/尺寸"门槛, 报价/下单入口保留); ②格尺寸调整死角修复: 格两边均贴外框时联动等比缩放整窗(原直接拒绝→"只能编辑外框"的体感根源), 任意格任意方向均可编辑; ③根因修复: computeModelBox 误将扇 bd 退化点(如 x=24.4)计入模型框→W/H 显示 2231 而非 1800、梃位/缩放参考边全被污染, 改为仅由基底多边形决定; ④悬停格淡高亮+pointer 光标(可发现性), HUD 标签改"格宽/格高"; 验证: 真实浏览器四项全过(格尺寸 663→格高800 边界梃平移/梃位900 精确移动/贴边格格高1200→整窗 1800×1200 等比/组合写回), ui-shots 6 页无报错 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/views/TemplatesView.vue, frontend/scripts/verify-detail.mjs, shots/*)
|
||||
|
||||
- [2026-09-23 06:30] FEAT: v2.15 图纸封面图一致性收尾——官方 PNG 剩余元素补齐。①分段尺寸标注: 按重放区域分割点生成蓝色分段(每格宽/高)+总尺寸双排标注(端部刻度, 左侧文字旋转), 替换原仅总宽高文本; ②玻璃配置第二行: fl.spet.sp(如 5+12+5)画于编号下方(704 无配置不画, 与官方一致); ③中梃与外框交点白色连接件方块; 修复 spIdx 遍历变量; 验证: 704 画布与官方封面图逐元素一致(分段 663/1137/393/1207+总 1800/1600), ui-shots 6 页无报错 (Files: frontend/src/components/WindoorCanvas.vue, shots/*)
|
||||
|
||||
- [2026-09-23 06:05] FEAT: v2.14 2D 图纸样式对齐源站设计图(官方 PNG 逐元素比对)。WindoorCanvas 绘制层重写: ①玻璃 teal 渐变(官方配色)+深色细描边; ②型材立体三段线(深轮廓+紫灰主体+白高光, 宽度=60mm 料宽映射限幅), 外框/中梃/扇料框统一; ③四角白色角码; ④开向符号对齐官方: 平开=虚线大三角(apex 按 hm.openDirection 指铰链侧, wtf 同样画三角), 推拉=水平滑行箭头(成对反向); ⑤把手(铰链对面侧白条)+锁点小方块; ⑥玻璃编号标签(frame/sash 的 mm.fls[].ser.text 经 polyId 树寻址定位到格中心); ⑦尺寸标注改官方蓝色带端部刻度; CombineCanvas 玻璃/型材配色同步; 验证: 设计图纸 704 画布与官方平面图逐元素一致, ui-shots 6 页无报错 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/components/CombineCanvas.vue, shots/*)
|
||||
|
||||
@@ -13,7 +13,7 @@ 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"(选中玻璃格)
|
||||
// 在画布内依次尝试点击候选点, 直到 HUD 出现"格宽"(选中玻璃格)
|
||||
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) {
|
||||
@@ -21,7 +21,7 @@ async function clickUntilRegion(canvasBox) {
|
||||
await sleep(350)
|
||||
const ok = await page.evaluate(() => {
|
||||
const inputs = [...document.querySelectorAll('.hud .dim-input')]
|
||||
return inputs.some(d => d.textContent.includes('格W'))
|
||||
return inputs.some(d => d.textContent.includes('格宽'))
|
||||
})
|
||||
if (ok) return true
|
||||
}
|
||||
@@ -51,25 +51,21 @@ await page.goto(`http://localhost:8000/templates?token=${encodeURIComponent(toke
|
||||
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'))
|
||||
const wrap = [...document.querySelectorAll('.hud .dim-input')].find(d => d.textContent.includes('格宽'))
|
||||
return Number(wrap.querySelector('input').value)
|
||||
})
|
||||
await setDim('格H', 800) // 亮子高度调到 800 → 横梃下移
|
||||
await setDim('格高', 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 page.mouse.click(cv.x + cv.width * 0.433, cv.y + cv.height * 0.55)
|
||||
await sleep(350)
|
||||
if (await hasDim('梃位')) {
|
||||
await setDim('梃位', 900)
|
||||
@@ -78,7 +74,23 @@ if (await hasDim('梃位')) {
|
||||
} else {
|
||||
console.log('△ 未命中中梃(点击位置在格上), 跳过梃位编辑截图')
|
||||
}
|
||||
console.log(`✓ 格尺寸编辑完成 (格W ${beforeW} → 调格H=800)`)
|
||||
// 贴边格等比缩放: 选中右侧整列格(高贴外框) → 改格高 → 整窗等比缩放
|
||||
await page.mouse.click(cv.x + cv.width * 0.684, cv.y + cv.height * 0.5)
|
||||
await sleep(350)
|
||||
if (await hasDim('格宽')) {
|
||||
await setDim('格高', 1200)
|
||||
const wh = await page.evaluate(() => {
|
||||
const wraps = [...document.querySelectorAll('.hud .dim-input')]
|
||||
const w = wraps.find(d => d.textContent.trim().startsWith('W'))
|
||||
const h = wraps.find(d => d.textContent.trim().startsWith('H'))
|
||||
return { w: w?.querySelector('input')?.value, h: h?.querySelector('input')?.value }
|
||||
})
|
||||
await page.screenshot({ path: `${shots}/detail-4b-edge-scale.png`, clip: cv })
|
||||
console.log(`✓ 贴边格等比缩放: 整窗 ${wh.w} × ${wh.h} (期望 1800 × 1200)`)
|
||||
} else {
|
||||
console.log('△ 未选中贴边格, 跳过等比缩放验证')
|
||||
}
|
||||
console.log(`✓ 格尺寸编辑完成 (格W ${beforeW} → 调格高=800)`)
|
||||
await page.close()
|
||||
|
||||
// ============ ② 组合页: 细节编辑对话框 + 保存写回 ============
|
||||
@@ -107,7 +119,7 @@ if (!(await (async () => {
|
||||
await sleep(350)
|
||||
const ok = await page2.evaluate(() => {
|
||||
const inputs = [...document.querySelectorAll('.hud .dim-input')]
|
||||
return inputs.some(d => d.textContent.includes('格W'))
|
||||
return inputs.some(d => d.textContent.includes('格宽'))
|
||||
})
|
||||
if (ok) return true
|
||||
}
|
||||
@@ -115,7 +127,7 @@ if (!(await (async () => {
|
||||
})())) { 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 wrap = [...document.querySelectorAll('.hud .dim-input')].find(d => d.textContent.includes('格高'))
|
||||
const input = wrap.querySelector('input')
|
||||
input.value = '900'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
<span class="dim-input">H <el-input-number v-model="sizeH" :min="300" :step="10" size="small"
|
||||
controls-position="right" style="width:104px" @change="onSizeInput" /></span>
|
||||
<template v-if="selRegion">
|
||||
<span class="dim-input">格W <el-input-number v-model="regW" :min="100" :step="10" size="small"
|
||||
<span class="dim-input">格宽 <el-input-number v-model="regW" :min="100" :step="10" size="small"
|
||||
controls-position="right" style="width:92px" @change="onRegionSize" /></span>
|
||||
<span class="dim-input">格H <el-input-number v-model="regH" :min="100" :step="10" size="small"
|
||||
<span class="dim-input">格高 <el-input-number v-model="regH" :min="100" :step="10" size="small"
|
||||
controls-position="right" style="width:92px" @change="onRegionSize" /></span>
|
||||
</template>
|
||||
<template v-else-if="selected">
|
||||
@@ -70,6 +70,7 @@ let moveDrag: null | { shapeIdx: number; spIdx: number; startPos: number; startX
|
||||
const selected = ref<{ shapeIdx: number; spIdx: number } | null>(null)
|
||||
// 细节编辑: 选中的玻璃格(可输入格宽高) ; selRegion 与 selected(梃) 互斥
|
||||
const selRegion = ref<{ shapeIdx: number; ri: number } | null>(null)
|
||||
let hoverRegion: { shapeIdx: number; ri: number } | null = null
|
||||
const regW = ref(0)
|
||||
const regH = ref(0)
|
||||
const mulPos = ref(0)
|
||||
@@ -121,36 +122,56 @@ function moveGroup(shapeIdx: number, axis: 'v' | 'h', pos: number, delta: number
|
||||
return applied
|
||||
}
|
||||
|
||||
/** 格尺寸编辑: 输入目标宽/高, 平移该格的边界中梃(优先右/下边界, 其次左/上) */
|
||||
/** 找格在该方向的可动边界梃: 优先 pos1(右/下)侧, 其次 pos0(左/上)侧; 返回 {pos, sign} 或 null */
|
||||
function findRegionEdge(sps: SplitLine[], axis: 'v' | 'h', bb: { x0: number; y0: number; x1: number; y1: number }) {
|
||||
const pos1 = axis === 'v' ? bb.x1 : bb.y1
|
||||
const e1 = sps.find(s => spAxis(s) === axis && Math.abs(spPos(s) - pos1) < 1.5)
|
||||
if (e1) return { pos: spPos(e1), sign: 1 }
|
||||
const pos0 = axis === 'v' ? bb.x0 : bb.y0
|
||||
const e0 = sps.find(s => spAxis(s) === axis && Math.abs(spPos(s) - pos0) < 1.5)
|
||||
if (e0) return { pos: spPos(e0), sign: -1 }
|
||||
return null
|
||||
}
|
||||
|
||||
/** 格尺寸编辑: 输入目标宽/高 → 平移边界中梃; 格两边都贴外框时联动等比缩放整窗 */
|
||||
function onRegionSize() {
|
||||
if (!selRegion.value || !dd) return
|
||||
undoStack.push(JSON.stringify(dd))
|
||||
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)
|
||||
let changed = false
|
||||
// 宽
|
||||
let bb = regionBb(shapeIdx, ri)
|
||||
if (!bb) { undoStack.pop(); return }
|
||||
let cur = bb.x1 - bb.x0
|
||||
if (Math.abs(targetW - cur) >= 1) {
|
||||
const edge = findRegionEdge(sps, 'v', bb)
|
||||
if (edge) {
|
||||
const sign = Math.abs(spPos(edge) - bb.x1) < 1.5 ? 1 : -1
|
||||
moveGroup(shapeIdx, 'v', spPos(edge), (targetW - curW) * sign)
|
||||
moved = true
|
||||
moveGroup(shapeIdx, 'v', edge.pos, (targetW - cur) * edge.sign)
|
||||
changed = true
|
||||
} else if (Math.abs(bb.x0 - modelBox.x0) < 1.5 && Math.abs(bb.x1 - modelBox.x1) < 1.5) {
|
||||
// 格宽=整窗宽(两边界均为外框): 等比缩放整窗
|
||||
resizeTo(Math.round((modelBox.x1 - modelBox.x0) * targetW / cur), Math.round(modelBox.y1 - modelBox.y0))
|
||||
changed = 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)
|
||||
// 高(宽调整可能已缩放整窗, 重新取格 bbox)
|
||||
bb = regionBb(shapeIdx, ri)
|
||||
if (!bb) { undoStack.pop(); return }
|
||||
cur = bb.y1 - bb.y0
|
||||
if (Math.abs(targetH - cur) >= 1) {
|
||||
const edge = findRegionEdge(sps, 'h', bb)
|
||||
if (edge) {
|
||||
const sign = Math.abs(spPos(edge) - bb.y1) < 1.5 ? 1 : -1
|
||||
moveGroup(shapeIdx, 'h', spPos(edge), (targetH - curH) * sign)
|
||||
moved = true
|
||||
moveGroup(shapeIdx, 'h', edge.pos, (targetH - cur) * edge.sign)
|
||||
changed = true
|
||||
} else if (Math.abs(bb.y0 - modelBox.y0) < 1.5 && Math.abs(bb.y1 - modelBox.y1) < 1.5) {
|
||||
resizeTo(Math.round(modelBox.x1 - modelBox.x0), Math.round((modelBox.y1 - modelBox.y0) * targetH / cur))
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (!moved) { syncRegionInputs(); ElMessage.warning('该格贴外框, 请先加分格梃再调整'); return }
|
||||
if (!changed) { undoStack.pop(); syncRegionInputs(); return }
|
||||
replayOne(shapeIdx)
|
||||
computeModelBox()
|
||||
syncSizeInputs()
|
||||
@@ -501,6 +522,19 @@ function drawFrame(
|
||||
}
|
||||
ctx.textBaseline = 'alphabetic'
|
||||
|
||||
// 悬停格淡高亮(可编辑提示)
|
||||
if (props.editable && hoverRegion && hoverRegion.shapeIdx === shapeIdx
|
||||
&& !(selRegion.value && selRegion.value.shapeIdx === shapeIdx && selRegion.value.ri === hoverRegion.ri)) {
|
||||
const r = rp.regions[hoverRegion.ri]
|
||||
if (r) {
|
||||
ctx.beginPath()
|
||||
regionPoints(r).forEach((p, i) => (i ? ctx.lineTo(tx(p.x), ty(p.y)) : ctx.moveTo(tx(p.x), ty(p.y))))
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = 'rgba(64,158,255,0.14)'
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
// 6) 选中玻璃格高亮: 蓝虚线框 + 格尺寸
|
||||
if (selRegion.value && selRegion.value.shapeIdx === shapeIdx) {
|
||||
const r = rp.regions[selRegion.value.ri]
|
||||
@@ -751,12 +785,10 @@ function computeModelBox() {
|
||||
if (!dd) return
|
||||
const pts: Pt[] = []
|
||||
for (const s of dd.sm) {
|
||||
// 外框只由基底多边形决定; 扇 bd 序列化常退化为单点, 计入会把模型框拉大
|
||||
for (const r of (s.polygon?.polygon ?? [])) {
|
||||
for (const seg of r) { pts.push(seg.ps); pts.push(seg.pe) }
|
||||
}
|
||||
for (const sub of (s.sm ?? []) as SashShape[]) {
|
||||
if (sub.bd) { pts.push(sub.bd.from, sub.bd.to) }
|
||||
}
|
||||
}
|
||||
if (!pts.length) { modelBox = { x0: 0, y0: 0, x1: 1000, y1: 1000 }; return }
|
||||
modelBox = bboxOf(pts)
|
||||
@@ -827,12 +859,30 @@ function onMove(e: MouseEvent) {
|
||||
}
|
||||
return
|
||||
}
|
||||
// 光标提示
|
||||
// 光标提示 + 悬停格高亮
|
||||
if (props.editable && !tool.value && rect) {
|
||||
const sx = e.clientX - rect.left, sy = e.clientY - rect.top
|
||||
const hit = hitResizeHandle(sx, sy)
|
||||
const m = hit ? null : hitMullion(sx, sy)
|
||||
let hover: { shapeIdx: number; ri: number } | null = null
|
||||
if (!hit && !m) {
|
||||
const p = toModel(sx, sy)
|
||||
let best = 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 b = bboxOf(pts)
|
||||
const area = (b.x1 - b.x0) * (b.y1 - b.y0)
|
||||
if (area < best) { best = area; hover = { shapeIdx: si, ri } }
|
||||
}
|
||||
}
|
||||
}
|
||||
cvRef.value!.style.cursor = hit === 'wh' ? 'nwse-resize' : hit === 'w' ? 'ew-resize' : hit === 'h' ? 'ns-resize'
|
||||
: hitMullion(sx, sy) ? 'move' : 'grab'
|
||||
: m ? 'move' : hover ? 'pointer' : 'grab'
|
||||
const changed = hover?.shapeIdx !== hoverRegion?.shapeIdx || hover?.ri !== hoverRegion?.ri
|
||||
if (changed) { hoverRegion = hover; render() }
|
||||
}
|
||||
if (!drag) return
|
||||
view.cx += e.clientX - drag.x
|
||||
|
||||
@@ -39,14 +39,16 @@
|
||||
<span>尺寸限制: {{ current.low_width }}-{{ current.high_width }} × {{ current.low_height }}-{{ current.high_height }} mm</span>
|
||||
<span>引用次数: {{ current.times }}</span>
|
||||
<el-button type="success" size="small" @click="quoteRef.open({ template_id: current.id })">算料报价</el-button>
|
||||
<el-button size="small" @click="editing = true">编辑分格/尺寸</el-button>
|
||||
<el-button size="small" @click="editing = true">编辑样板</el-button>
|
||||
<el-button type="primary" size="small" @click="useInOrder">用此样板下单</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="tip">改 W/H 或拖右下角手柄缩放;点击玻璃格输入格宽高;点击中梃输入位置或拖动;+竖梃/+横梃点击分割</span>
|
||||
<span class="tip">点击玻璃格输入格宽高;点中梃输入位置或拖动;改 W/H 缩放整体;+竖梃/+横梃分割</span>
|
||||
<span class="live-price" v-if="liveTotal !== null">当前报价 ¥{{ liveTotal }}</span>
|
||||
<el-button type="success" size="small" @click="quoteRef.open({ template_id: current.id })">算料报价</el-button>
|
||||
<el-button size="small" @click="cancelEdit">取消</el-button>
|
||||
<el-button type="primary" size="small" :disabled="!dirty" @click="saveAsTemplate">另存为我的样板</el-button>
|
||||
<el-button size="small" @click="useInOrder">用此样板下单</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,7 +110,8 @@ async function load() {
|
||||
|
||||
async function preview(t: any) {
|
||||
current.value = await api<any>('get', `/api/windoor/templates/${t.id}`)
|
||||
editing.value = false
|
||||
// 点击进入即编辑: 每一块(玻璃格/中梃)都可直接编辑
|
||||
editing.value = true
|
||||
dirty.value = false
|
||||
liveTotal.value = null
|
||||
previewVisible.value = true
|
||||
|
||||
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 242 KiB |