fix: v2.17 3D 建模对齐样板图——门/纱扇/多扇语义修复

- kind 判定收敛: 仅 Slide=推拉; DoubleSash/DoubleKfcSash=对开门按平开(修门渲染成窗)
- 纱扇 Screen 同格不再单独渲染(消除 179 处双层玻璃叠放)
- 同格多扇沿宽度等分(forceRect): 31337 对开两扇对半+isDoor 合并, 31729 上排 6 滑扇等分
- 2D 图纸补纱扇网格纹理(官方画法)
- 验证: 31337/31729 3D 与官方图结构一致, validate3d 0 无效, ui-shots 6 页无报错
This commit is contained in:
HHY
2026-09-23 11:23:18 -07:00
parent 21760d1887
commit 8cd3442508
10 changed files with 78 additions and 17 deletions
+2
View File
@@ -1,5 +1,7 @@
# CHANGELOG # CHANGELOG
- [2026-09-23 07:40] FIX: v2.17 3D 建模对齐样板图——门/纱扇/多扇语义修复(官方图逐样本实证)。①kind 判定收敛: 仅 Slide=推拉, DoubleSash=双扇对开门(31343)/DoubleKfcSash=内开内倒对门(31337) 按平开——原 DoubleSash 误判推拉致"门渲染成窗户"; ②纱扇(Screen/DoubleScreen 179 处)与玻璃扇同格挂接, 不再单独渲染(消除同格双层玻璃叠放); ③同格多扇沿宽度等分(forceRect 忽略 entry 重复 polyId): 31337 对开两扇对半+isDoor 合并(e.isDoor||sub.isDoor, 修 31337 门被标 false), 31729 上排 6 滑扇等分(原 6 层重叠), 31343 对开门左右各半; ④2D 图纸补纱扇网格纹理(Screen 官方画法); 验证: 31337 对开门 3D 关/开截图与官方图结构一致, 31729 上滑扇+下固定一致, validate3d 254 样板 615 扇 0 无效, ui-shots 6 页无报错 (Files: frontend/src/types/drawing.ts, frontend/src/components/WindoorCanvas.vue, frontend/scripts/verify-door.mjs, shots/door-*.png, shots/verify-31729-3d.png)
- [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 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: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/*)
+38
View File
@@ -0,0 +1,38 @@
// 门类 3D 建模验证: 切换样板 31337(对开门) 截图关/开: node verify-door.mjs <token>
import puppeteer from 'puppeteer-core'
const token = process.argv[2]
const sleep = ms => new Promise(r => setTimeout(r, ms))
const browser = await puppeteer.launch({
executablePath: 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', 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/threed?token=${encodeURIComponent(token)}`, { waitUntil: 'networkidle2' })
await sleep(1500)
// 打开样板下拉并选择 31337
await page.click('.el-select__wrapper, .el-select')
await sleep(600)
const items = await page.$$('.el-select-dropdown__item')
for (const it of items) {
const txt = await it.evaluate(el => el.textContent.trim())
if (txt.includes('31337')) { await it.click(); break }
}
await sleep(2200)
const cv = await (await page.$('.threed-wrap canvas')).boundingBox()
await page.screenshot({ path: 'D:/Code/huamenchuang/shots/door-31337-closed.png', clip: cv })
// 拖滑杆到 55%
const handle = await page.$('.ctrl .el-slider__button')
const hr = await handle.boundingBox()
await page.mouse.move(hr.x + hr.width / 2, hr.y + hr.height / 2)
await page.mouse.down()
const rr = await (await page.$('.ctrl .el-slider__runway')).boundingBox()
for (let i = 1; i <= 10; i++) {
await page.mouse.move(rr.x + rr.width * 0.55, hr.y + hr.height / 2, { steps: 1 })
await sleep(40)
}
await page.mouse.up()
await sleep(1400)
await page.screenshot({ path: 'D:/Code/huamenchuang/shots/door-31337-open.png', clip: cv })
console.log('✓ 31337 对开门 3D 截图完成')
await browser.close()
+19
View File
@@ -522,6 +522,25 @@ function drawFrame(
} }
ctx.textBaseline = 'alphabetic' ctx.textBaseline = 'alphabetic'
// 纱扇(Screen): 官方图纸的网格纹理(不生成开合扇)
for (const sub of (shape.sm ?? []) as SashShape[]) {
if (String(sub.type ?? '') !== 'Screen' || sub.enabled === false) continue
const segs = rp.resolveRef?.(sub.polyId?.idx ?? -1, sub.polyId?.pos)
if (!segs) continue
const bb = bboxOf(regionPoints(segs))
const x0 = tx(bb.x0), y0 = ty(bb.y0), x1 = tx(bb.x1), y1 = ty(bb.y1)
ctx.save()
ctx.beginPath()
ctx.rect(x0, y0, x1 - x0, y1 - y0)
ctx.clip()
ctx.strokeStyle = 'rgba(60,66,72,0.32)'
ctx.lineWidth = 0.7
const step = Math.max(5, (x1 - x0) / 22)
for (let gx = x0; gx < x1; gx += step) { ctx.beginPath(); ctx.moveTo(gx, y0); ctx.lineTo(gx, y1); ctx.stroke() }
for (let gy = y0; gy < y1; gy += step) { ctx.beginPath(); ctx.moveTo(x0, gy); ctx.lineTo(x1, gy); ctx.stroke() }
ctx.restore()
}
// 悬停格淡高亮(可编辑提示) // 悬停格淡高亮(可编辑提示)
if (props.editable && hoverRegion && hoverRegion.shapeIdx === shapeIdx if (props.editable && hoverRegion && hoverRegion.shapeIdx === shapeIdx
&& !(selRegion.value && selRegion.value.shapeIdx === shapeIdx && selRegion.value.ri === hoverRegion.ri)) { && !(selRegion.value && selRegion.value.shapeIdx === shapeIdx && selRegion.value.ri === hoverRegion.ri)) {
+19 -17
View File
@@ -336,7 +336,8 @@ function hmOf(sub: SashShape): { openDirection?: string; openToward?: string } {
/** /**
* 扇样式推断: 有效bd → polyId{idx,pos}挂接区域(原站以区域挂接扇) → 帧bbox按扇数均分兜底。 * 扇样式推断: 有效bd → polyId{idx,pos}挂接区域(原站以区域挂接扇) → 帧bbox按扇数均分兜底。
* kind 由类型决定(Slide/DoubleSash=推拉, 其余=平开); 开合五金取 hm.openDirection/hm.openToward。 * kind 由类型决定(仅 Slide=推拉, DoubleSash/DoubleKfcSash=双扇对开门按平开); 纱扇(Screen)与
* 玻璃扇同格挂接故不单独渲染; 同格多扇沿宽度等分; 开合五金取 hm.openDirection/hm.openToward。
*/ */
export function inferSashGlyphs(shape: FrameShape, replayed?: ReplayResult | Segment[][]): { export function inferSashGlyphs(shape: FrameShape, replayed?: ReplayResult | Segment[][]): {
regions: Segment[][] regions: Segment[][]
@@ -358,8 +359,8 @@ export function inferSashGlyphs(shape: FrameShape, replayed?: ReplayResult | Seg
const ok = [r.x0, r.y0, r.x1, r.y1].every(v => Number.isFinite(v)) const ok = [r.x0, r.y0, r.x1, r.y1].every(v => Number.isFinite(v))
return ok && !bd.hidden && r.x1 - r.x0 > 10 && r.y1 - r.y0 > 10 ? r : null return ok && !bd.hidden && r.x1 - r.x0 > 10 && r.y1 - r.y0 > 10 ? r : null
} }
const kindOf = (sub: SashShape) => (sub.type === 'DoubleSash' || sub.type === 'Slide' // kind 仅 Slide=推拉; DoubleSash/DoubleKfcSash=双扇对开门(KfcSash=内开内倒), 均按平开处理
? 'sliding' as const : 'casement' as const) const kindOf = (sub: SashShape) => (String(sub.type) === 'Slide' ? 'sliding' as const : 'casement' as const)
// polyId{idx,pos} 树寻址; 无树信息时退化为扁平下标 // polyId{idx,pos} 树寻址; 无树信息时退化为扁平下标
const rectOfRef = (ref?: { idx?: number; pos?: number }) => { const rectOfRef = (ref?: { idx?: number; pos?: number }) => {
if (!ref || !Number.isInteger(ref.idx) || (ref.idx as number) < 0) return null if (!ref || !Number.isInteger(ref.idx) || (ref.idx as number) < 0) return null
@@ -370,18 +371,18 @@ export function inferSashGlyphs(shape: FrameShape, replayed?: ReplayResult | Seg
const i = ref.idx as number const i = ref.idx as number
return i < regionBBs.length ? regionBBs[i] : null return i < regionBBs.length ? regionBBs[i] : null
} }
const pushGlyphs = (r: { x0: number; y0: number; x1: number; y1: number }, entries: any[], sub: SashShape) => { const pushGlyphs = (r: { x0: number; y0: number; x1: number; y1: number }, entries: any[], sub: SashShape, forceRect = false) => {
const kind = kindOf(sub) const kind = kindOf(sub)
const hm = hmOf(sub) const hm = hmOf(sub)
for (const e of entries) { for (const e of entries) {
// 子扇自带挂接区域时优先生效(双扇各自挂半区) // 子扇自带挂接区域时优先生效(双扇各自挂半区); 等分模式下强制使用分块
const rect = rectOfRef((e as { polyId?: { idx?: number; pos?: number } }).polyId) ?? r const rect = !forceRect ? (rectOfRef((e as { polyId?: { idx?: number; pos?: number } }).polyId) ?? r) : r
glyphs.push({ glyphs.push({
x0: rect.x0, y0: rect.y0, x1: rect.x1, y1: rect.y1, x0: rect.x0, y0: rect.y0, x1: rect.x1, y1: rect.y1,
od: (e.od ?? sub.od ?? 90) as number, od: (e.od ?? sub.od ?? 90) as number,
openToward: (e.openToward ?? sub.openToward ?? hm.openToward) as string | undefined, openToward: (e.openToward ?? sub.openToward ?? hm.openToward) as string | undefined,
openDirection: (e.openDirection ?? hm.openDirection) as string | undefined, openDirection: (e.openDirection ?? hm.openDirection) as string | undefined,
isDoor: Boolean(e.isDoor ?? sub.isDoor), isDoor: Boolean(e.isDoor || sub.isDoor),
wtf: Boolean(e.wtf ?? sub.wtf ?? sub.withTurningFrame), wtf: Boolean(e.wtf ?? sub.wtf ?? sub.withTurningFrame),
kind, kind,
}) })
@@ -389,21 +390,22 @@ export function inferSashGlyphs(shape: FrameShape, replayed?: ReplayResult | Seg
} }
const unmatched: { sub: SashShape; entries: any[] }[] = [] const unmatched: { sub: SashShape; entries: any[] }[] = []
for (const sub of (shape.sm ?? []) as SashShape[]) { for (const sub of (shape.sm ?? []) as SashShape[]) {
const type = String(sub.type ?? '')
if (type === 'Screen' || type === 'DoubleScreen') continue // 纱扇与玻璃扇同格挂接, 不单独渲染
const raw = (sub.sashes?.length ? sub.sashes : [sub]) as any[] const raw = (sub.sashes?.length ? sub.sashes : [sub]) as any[]
const entries = raw.filter(e => e && e.enabled !== false) const entries = raw.filter(e => e && e.enabled !== false)
if (!entries.length) continue if (!entries.length) continue
// 扇数: 多 entries 依 entries 数; Double*(双扇/对开门)取 2
const n = Math.max(entries.length, type.startsWith('Double') ? 2 : 1)
let rect = sub.bd ? bdRect(sub.bd) : null let rect = sub.bd ? bdRect(sub.bd) : null
if (!rect) rect = rectOfRef(sub.polyId) if (!rect) rect = rectOfRef(sub.polyId)
if (rect) { if (rect) {
if (entries.length === 2 && !raw.some(e => e?.polyId?.idx != null)) { // 同格多扇(对开门/双扇/多滑扇): 沿宽度等分, 每块取对应扇属性
// 双扇共区且无独立挂接: 沿长边对半 for (let k = 0; k < n; k++) {
const w = rect.x1 - rect.x0, h = rect.y1 - rect.y0 const part = n > 1
const halves = w >= h ? { x0: rect.x0 + ((rect.x1 - rect.x0) * k) / n, y0: rect.y0, x1: rect.x0 + ((rect.x1 - rect.x0) * (k + 1)) / n, y1: rect.y1 }
? [{ ...rect, x1: (rect.x0 + rect.x1) / 2 }, { ...rect, x0: (rect.x0 + rect.x1) / 2 }] : rect
: [{ ...rect, y1: (rect.y0 + rect.y1) / 2 }, { ...rect, y0: (rect.y0 + rect.y1) / 2 }] pushGlyphs(part, [entries[Math.min(k, entries.length - 1)]], sub, n > 1)
entries.forEach((e, k) => pushGlyphs(halves[k], [e], sub))
} else {
pushGlyphs(rect, entries, sub)
} }
continue continue
} }
@@ -428,7 +430,7 @@ export function inferSashGlyphs(shape: FrameShape, replayed?: ReplayResult | Seg
od: (e.od ?? u.sub.od ?? 90) as number, od: (e.od ?? u.sub.od ?? 90) as number,
openToward: (e.openToward ?? u.sub.openToward ?? hmOf(u.sub).openToward) as string | undefined, openToward: (e.openToward ?? u.sub.openToward ?? hmOf(u.sub).openToward) as string | undefined,
openDirection: (e.openDirection ?? hmOf(u.sub).openDirection) as string | undefined, openDirection: (e.openDirection ?? hmOf(u.sub).openDirection) as string | undefined,
isDoor: Boolean(e.isDoor ?? u.sub.isDoor), isDoor: Boolean(e.isDoor || u.sub.isDoor),
kind: kindOf(u.sub), kind: kindOf(u.sub),
}) })
i++ i++
Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB