fix: v2.17 3D 建模对齐样板图——门/纱扇/多扇语义修复
- kind 判定收敛: 仅 Slide=推拉; DoubleSash/DoubleKfcSash=对开门按平开(修门渲染成窗) - 纱扇 Screen 同格不再单独渲染(消除 179 处双层玻璃叠放) - 同格多扇沿宽度等分(forceRect): 31337 对开两扇对半+isDoor 合并, 31729 上排 6 滑扇等分 - 2D 图纸补纱扇网格纹理(官方画法) - 验证: 31337/31729 3D 与官方图结构一致, validate3d 0 无效, ui-shots 6 页无报错
@@ -1,5 +1,7 @@
|
||||
# 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 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/*)
|
||||
|
||||
@@ -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()
|
||||
@@ -522,6 +522,25 @@ function drawFrame(
|
||||
}
|
||||
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
|
||||
&& !(selRegion.value && selRegion.value.shapeIdx === shapeIdx && selRegion.value.ri === hoverRegion.ri)) {
|
||||
|
||||
@@ -336,7 +336,8 @@ function hmOf(sub: SashShape): { openDirection?: string; openToward?: string } {
|
||||
|
||||
/**
|
||||
* 扇样式推断: 有效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[][]): {
|
||||
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))
|
||||
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'
|
||||
? 'sliding' as const : 'casement' as const)
|
||||
// kind 仅 Slide=推拉; DoubleSash/DoubleKfcSash=双扇对开门(KfcSash=内开内倒), 均按平开处理
|
||||
const kindOf = (sub: SashShape) => (String(sub.type) === 'Slide' ? 'sliding' as const : 'casement' as const)
|
||||
// polyId{idx,pos} 树寻址; 无树信息时退化为扁平下标
|
||||
const rectOfRef = (ref?: { idx?: number; pos?: number }) => {
|
||||
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
|
||||
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 hm = hmOf(sub)
|
||||
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({
|
||||
x0: rect.x0, y0: rect.y0, x1: rect.x1, y1: rect.y1,
|
||||
od: (e.od ?? sub.od ?? 90) as number,
|
||||
openToward: (e.openToward ?? sub.openToward ?? hm.openToward) 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),
|
||||
kind,
|
||||
})
|
||||
@@ -389,21 +390,22 @@ export function inferSashGlyphs(shape: FrameShape, replayed?: ReplayResult | Seg
|
||||
}
|
||||
const unmatched: { sub: SashShape; entries: any[] }[] = []
|
||||
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 entries = raw.filter(e => e && e.enabled !== false)
|
||||
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
|
||||
if (!rect) rect = rectOfRef(sub.polyId)
|
||||
if (rect) {
|
||||
if (entries.length === 2 && !raw.some(e => e?.polyId?.idx != null)) {
|
||||
// 双扇共区且无独立挂接: 沿长边对半
|
||||
const w = rect.x1 - rect.x0, h = rect.y1 - rect.y0
|
||||
const halves = w >= h
|
||||
? [{ ...rect, x1: (rect.x0 + rect.x1) / 2 }, { ...rect, x0: (rect.x0 + rect.x1) / 2 }]
|
||||
: [{ ...rect, y1: (rect.y0 + rect.y1) / 2 }, { ...rect, y0: (rect.y0 + rect.y1) / 2 }]
|
||||
entries.forEach((e, k) => pushGlyphs(halves[k], [e], sub))
|
||||
} else {
|
||||
pushGlyphs(rect, entries, sub)
|
||||
// 同格多扇(对开门/双扇/多滑扇): 沿宽度等分, 每块取对应扇属性
|
||||
for (let k = 0; k < n; k++) {
|
||||
const part = n > 1
|
||||
? { 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
|
||||
pushGlyphs(part, [entries[Math.min(k, entries.length - 1)]], sub, n > 1)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -428,7 +430,7 @@ export function inferSashGlyphs(shape: FrameShape, replayed?: ReplayResult | Seg
|
||||
od: (e.od ?? u.sub.od ?? 90) as number,
|
||||
openToward: (e.openToward ?? u.sub.openToward ?? hmOf(u.sub).openToward) 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),
|
||||
})
|
||||
i++
|
||||
|
||||
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 58 KiB |