diff --git a/CHANGELOG b/CHANGELOG index d62e90b..572556a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # CHANGELOG +- [2026-10-05 07:30] TEST: v2.40 全量 254 套模板视觉校验——v2.39 消费路径全量回归 0 缺陷。新增两个常驻校验资产: ①frontend/scripts/verify-sash-panels.mjs(结构级: 渲染器同路径全量跑 重放/扇推断/锚点匹配/inferSashPanels, A~E 五级异常分类+子格覆盖度断言, 实测 254 文件/409 形状/570 扇/726 子格(裙板 150/格条 58) 0 异常) ②frontend/scripts/patrol-render-all.mjs(渲染级: 无头浏览器逐套切换样板真实渲染, 收集 JS 异常+画布非透明像素活性, 实测 254/254 OK 0 空白 0 异常); 6 套代表性样板视觉抽验通过(704 平开窗/31637 推拉门格条裙板/31343 双扇门裙板/31353 三扇推拉16条扇内分割/31593 平开窗/31603 四扇推拉)——v2.39 为渲染器/工具层共性修复, 全量样板自动受益, 无需逐套修改 (Files: frontend/scripts/verify-sash-panels.mjs, frontend/scripts/patrol-render-all.mjs) - [2026-10-05 07:14] FEAT: v2.39 渲染视觉与图纸对齐——源站原生扇内结构全量消费(扇内分割/裙板/格条/编号)。数据层诊断: 704(mm.sp=2 三区域+A1 平开扇)/31637(Frame 无分割+Slide 双推拉扇, 扇自带 mm.sp 裙板分割+mm.fls 玻璃/Panel 挂接+mm.dbs.bars decorationChinese 格条, hc=2 主格+gp=80 回纹)数据本身完整, 自研渲染器只消费顶层 mm.sp 未下钻扇内。新增 types/drawing.ts inferSashPanels(扇矩形构造临时 shape 复用 replayShape 全语义重放扇内分割, fls 按 polyId{idx,pos} 树寻址挂玻璃/裙板(type=Panel 判定), dbs.bars 格条解析 tp/gp/hc); 三渲染器消费: WindoorCanvas(2D 图纸: 裙板实色+格条内框/主格/四角回纹+玻璃编号 A1~A4)+RenderCanvas(效果图: 同语义)+WindoorThreed(3D: 裙板实体板+格条条体; 修复单 region 多扇时第二扇被 used 丢弃(改 glyph 自身矩形+共享区域保留静态底玻璃)); 扇形状按 sp 锚点归属匹配(修 sashes 数组序与视觉序交叉); WindoorCanvas 挂载加 ResizeObserver+零尺寸保护(修 el-dialog 内首帧 0 尺寸空白, 与 v2.38 3D 同型); 视觉验收: 704 渲染(开向三角/把手/编号)与 31637 四视图(双推拉+中式格条+裙板)均与官方参考图对齐; 回归: 254 套数据校验 0 失败+24 页巡检 0 异常+冒烟 60/60 (Files: frontend/src/types/drawing.ts, frontend/src/components/WindoorCanvas.vue, frontend/src/components/RenderCanvas.vue, frontend/src/components/WindoorThreed.vue) - [2026-10-05 06:23] FIX: v2.38 效果图页切 Tab 后 3D 展示空白修复。根因: WindoorThreed 在 /rendering 页以 v-show=false(display:none) 挂载, 容器 clientWidth=0 时 Three.js 以 0 尺寸初始化(camera.aspect=0/0=NaN), 页内切 Tab 显示后仅 window resize 事件无法触发重设尺寸→画布永远空白(直接访问 /threed 路由正常, 故 24 页巡检未覆盖); 修复: onResize 加零尺寸保护(避免 NaN 投影矩阵)+ResizeObserver 监听容器(显示瞬间尺寸 0→正常自动恢复)+初始 setSize 兜底 ||1; 无头复验 /rendering 切 Tab 场景渲染正常; 巡检 24 页 0 异常 (Files: frontend/src/components/WindoorThreed.vue) - [2026-10-05 06:11] FIX: v2.37 效果图渲染歪斜线修复——Canvas stroke 落错路径。根因: 玻璃多边形 fill 后叠加高光四边形(beginPath 新路径), 其后 ctx.stroke() 描的是最后构建的高光斜四边形而非玻璃多边形, 型材线全部变成斜线; 同型缺陷 3 处一并修复: RenderCanvas.vue(效果图渲染, stroke 移至高光前)+variantRenderer.ts renderAI(AI 炫图本地渲染, restore 后重建玻璃多边形路径再 stroke)+photoCompose.ts(照片式 AI 合成, 同 RenderCanvas 改法); 排查方法: 无头浏览器 hook moveTo/lineTo/beginPath 还原绘制序列, 证实矩形区域路径全部轴对齐、歪线恰为 3 个高光平行四边形; 高光斜带本为设计效果(clip 玻璃内)保留不变; 巡检 24 页 0 异常 (Files: frontend/src/components/RenderCanvas.vue, frontend/src/components/variantRenderer.ts, frontend/src/components/photoCompose.ts) diff --git a/frontend/scripts/patrol-render-all.mjs b/frontend/scripts/patrol-render-all.mjs new file mode 100644 index 0000000..a246ac0 --- /dev/null +++ b/frontend/scripts/patrol-render-all.mjs @@ -0,0 +1,71 @@ +// 全量样板渲染巡检: 逐套在效果图渲染页切换样板, 收集 JS 异常 + 画布活性(非空白像素占比) +// 用法: node scripts/patrol-render-all.mjs [--shot=12] +import puppeteer from 'puppeteer-core' +import fs from 'fs' + +const token = process.argv[2] +const shotN = Number((process.argv.find(a => a.startsWith('--shot=')) ?? '--shot=12').split('=')[1]) || 12 +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'], + defaultViewport: { width: 1440, height: 900 }, +}) +const page = await browser.newPage() +const errs = [] +page.on('pageerror', e => errs.push(String(e).slice(0, 200))) +page.on('console', m => { if (m.type() === 'error' && !m.text().includes('favicon')) errs.push('[console] ' + m.text().slice(0, 150)) }) + +await page.goto(`http://localhost:8000/rendering?token=${encodeURIComponent(token)}`, { waitUntil: 'networkidle2' }) +await new Promise(r => setTimeout(r, 1500)) + +// 取全部样板名 +const names = await page.evaluate(async () => { + const token = localStorage.getItem('hmc_token') + const res = await fetch('/api/windoor/templates?page=1&page_size=254', { headers: { Authorization: `Bearer ${token}` } }) + const d = (await res.json()).data + const rows = Array.isArray(d) ? d : d.data + return rows.map(r => r.name) +}) +console.log('样板总数:', names.length) + +const results = [] +for (let i = 0; i < names.length; i++) { + const name = names[i] + errs.length = 0 + try { + // 打开下拉 → 输入过滤 → 选中 + await page.evaluate(() => document.querySelector('.el-select')?.querySelector('.el-select__wrapper, .el-input__inner')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))) + await new Promise(r => setTimeout(r, 350)) + await page.keyboard.type(name, { delay: 5 }) + await new Promise(r => setTimeout(r, 550)) + const picked = await page.evaluate(nm => { + const items = [...document.querySelectorAll('.el-select-dropdown__item')] + const el = items.find(x => x.textContent.trim() === nm) + if (!el) return false + el.click() + return true + }, name) + if (!picked) { results.push([name, 'SKIP', '下拉未命中']); await page.keyboard.press('Escape'); continue } + await new Promise(r => setTimeout(r, 1000)) + // 画布活性: 非透明像素占比 + const alive = await page.evaluate(() => { + const cv = document.querySelector('.render-canvas') + if (!cv || !cv.width) return -1 + const ctx = cv.getContext('2d') + const d = ctx.getImageData(0, 0, cv.width, cv.height).data + let n = 0 + for (let p = 3; p < d.length; p += 40) if (d[p] > 0) n++ + return n / (d.length / 40) + }) + results.push([name, errs.length ? 'ERR' : (alive > 0.02 ? 'OK' : 'BLANK'), + errs.length ? errs[0] : `alive=${alive.toFixed(3)}`]) + } catch (e) { + results.push([name, 'ERR', String(e).slice(0, 120)]) + } + if ((i + 1) % 40 === 0) console.log(`进度 ${i + 1}/${names.length}`) +} +const bad = results.filter(r => r[1] !== 'OK' && r[1] !== 'SKIP') +console.log(`\n结果: OK=${results.filter(r => r[1] === 'OK').length} BLANK=${results.filter(r => r[1] === 'BLANK').length} ERR=${results.filter(r => r[1] === 'ERR').length} SKIP=${results.filter(r => r[1] === 'SKIP').length}`) +for (const r of bad) console.log(' ' + r.join(' | ')) +fs.writeFileSync('scripts/.render-patrol.json', JSON.stringify(results)) +await browser.close() diff --git a/frontend/scripts/verify-sash-panels.mjs b/frontend/scripts/verify-sash-panels.mjs new file mode 100644 index 0000000..3a13803 --- /dev/null +++ b/frontend/scripts/verify-sash-panels.mjs @@ -0,0 +1,114 @@ +// 全量样板"渲染器消费路径"校验(与 WindoorCanvas/RenderCanvas/WindoorThreed 同逻辑): +// ① 链路异常(A级: 会导致渲染中断) ② 扇形状未匹配/锚点错位(B级) ③ 扇内子格异常(C级) +// ④ 源站格条解析(D级) ⑤ sashes 序与视觉序交叉信息(E级) +// 用法: npx esbuild src/types/drawing.ts --bundle --format=esm --outfile=scripts/.drawing-bundle.mjs && node scripts/verify-sash-panels.mjs +import fs from 'fs' +import path from 'path' +import { parseDrawing, replayShape, inferSashGlyphs, inferSashPanels, bboxOf, regionPoints } from './.drawing-bundle.mjs' + +const DIR = 'D:/Code/huamenchuang/data/windoor-drawings' +const issues = [] // {file, level, msg} +let stats = { files: 0, shapes: 0, glyphs: 0, panels: 0, panelBoards: 0, decos: 0, matched: 0, fallback: 0 } + +const collectSash = (s, out) => { + if (!['Frame', 'Slide'].includes(String(s.type))) out.push(s) + for (const x of s.sm ?? []) collectSash(x, out) + for (const x of s.sashes ?? []) collectSash(x, out) +} + +for (const f of fs.readdirSync(DIR).filter(x => x.endsWith('.json')).sort()) { + stats.files++ + const file = path.basename(f, '.json') + let row, raw + try { + row = JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8')) + raw = typeof row.drawing_data === 'string' ? JSON.parse(row.drawing_data) : row.drawing_data + if (raw && typeof raw === 'object' && typeof raw.drawing_data === 'string') raw = JSON.parse(raw.drawing_data) + else if (raw && typeof raw === 'object' && raw.drawing_data && typeof raw.drawing_data === 'object') raw = raw.drawing_data + } catch (e) { + issues.push({ file, level: 'A', msg: 'JSON 解析失败: ' + e.message }) + continue + } + let dd + try { + dd = parseDrawing(raw) + } catch (e) { + issues.push({ file, level: 'A', msg: 'parseDrawing 失败: ' + e.message }) + continue + } + for (const shape of dd.sm ?? []) { + stats.shapes++ + let rp, glyphs + try { + rp = replayShape(shape) + glyphs = inferSashGlyphs(shape, rp).sashes + } catch (e) { + issues.push({ file, level: 'A', msg: `重放/扇推断异常: ${e.message}` }) + continue + } + const sashShapes = [] + try { collectSash(shape, sashShapes) } catch (e) { issues.push({ file, level: 'A', msg: '形状收集异常: ' + e.message }); continue } + + glyphs.forEach((g, gi) => { + stats.glyphs++ + const gRect = { x0: g.x0, y0: g.y0, x1: g.x1, y1: g.y1 } + if (![gRect.x0, gRect.y0, gRect.x1, gRect.y1].every(Number.isFinite)) { + issues.push({ file, level: 'B', msg: `扇${gi} 矩形含非有限值` }) + return + } + // 锚点归属匹配(与渲染器同) + let matched = null, byAnchor = false + for (const sh of sashShapes) { + const sps = sh.mm?.sp ?? [] + if (sps.some(sp => { + const pt = sp.line?.pt + return pt && pt.x >= gRect.x0 && pt.x <= gRect.x1 && pt.y >= gRect.y0 && pt.y <= gRect.y1 + })) { matched = sh; byAnchor = true; break } + } + if (!matched && sashShapes.length) { matched = sashShapes[gi % sashShapes.length]; stats.fallback++ } + if (!matched) { + if ((shape.mm?.sp ?? []).length || (shape.sm ?? []).length) issues.push({ file, level: 'B', msg: `扇${gi} 无可匹配扇形状` }) + return + } + if (byAnchor) stats.matched++ + let panels + try { + panels = inferSashPanels(matched, gRect) + } catch (e) { + issues.push({ file, level: 'C', msg: `扇${gi}(${matched.type}) inferSashPanels 异常: ${e.message}` }) + return + } + if (!panels.length) { issues.push({ file, level: 'C', msg: `扇${gi} 零子格` }); return } + for (const [pi, p] of panels.entries()) { + stats.panels++ + if (![p.x0, p.y0, p.x1, p.y1].every(Number.isFinite) || p.x1 - p.x0 <= 1 || p.y1 - p.y0 <= 1) { + issues.push({ file, level: 'C', msg: `扇${gi} 子格${pi} 几何无效 [${p.x0?.toFixed(0)},${p.y0?.toFixed(0)}~${p.x1?.toFixed(0)},${p.y1?.toFixed(0)}]` }) + continue + } + if (!p.glass) stats.panelBoards++ + if (p.deco) { + stats.decos++ + if (!['decorationChinese', 'decorationEuropean', 'decorationModern'].includes(p.deco.tp) && !p.deco.tp.startsWith('decoration')) { + issues.push({ file, level: 'D', msg: `扇${gi} 子格${pi} 未知格条风格: ${p.deco.tp}` }) + } + } + } + // E级: 多扇同形状组时, 扇形状序与 glyph 序交叉提示(锚点命中率低即疑似) + if (!byAnchor && sashShapes.length > 1 && (matched.mm?.sp ?? []).length) { + issues.push({ file, level: 'E', msg: `扇${gi} 锚点未命中, 回退下标匹配(可能序交叉)` }) + } + }) + } +} + +console.log(`统计: 文件=${stats.files} 形状=${stats.shapes} 扇=${stats.glyphs} 子格=${stats.panels}(裙板=${stats.panelBoards}, 格条=${stats.decos}) 锚点命中=${stats.matched} 回退=${stats.fallback}`) +const byLevel = {} +for (const it of issues) (byLevel[it.level] = byLevel[it.level] ?? []).push(it) +for (const lv of ['A', 'B', 'C', 'D', 'E']) { + const list = byLevel[lv] ?? [] + console.log(`[${lv}级] ${list.length} 项`) + for (const it of list.slice(0, 40)) console.log(` ${it.file}: ${it.msg}`) + if (list.length > 40) console.log(` ... 其余 ${list.length - 40} 项`) +} +fs.writeFileSync('scripts/.sash-panel-issues.json', JSON.stringify(issues, null, 1)) +console.log('明细已写 scripts/.sash-panel-issues.json')