feat: 渲染视觉与图纸对齐(源站原生扇内结构全量消费: 扇内分割/裙板/格条/编号, inferSashPanels 工具+三渲染器接入+锚点归属匹配+对话框ResizeObserver)

This commit is contained in:
HHY
2026-10-05 07:15:07 -07:00
parent cbc96dd166
commit 888a8803af
5 changed files with 315 additions and 8 deletions
+1
View File
@@ -1,5 +1,6 @@
# CHANGELOG # CHANGELOG
- [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: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) - [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)
- [2026-10-05 05:58] FEAT: v2.36 成本核算模块——鲁铭成本核算 Excel 全量适配。解析 12 表工作簿(铝成本/铝计算/木成本+价格库 8 表): 价格库 580 条(华健型材模具 397/丝吉利娅 207/索宾柯 52/隔热条 42/胶条 56/赛遨 54/螺丝 39/其他 23)导入产品价格本(同名更新/异名新建, material 按 sheet 语义映射 profile/hardware/addon/glass); 「铝成本」页→CostSeriesTemplate 系列成本模板(75框扇齐平1.4/85框扇齐平1.8/95窗纱一体1.8, data JSON 八分区: window/profile/addon/strip/seal/glass/extra/hardware/labor); 估算器口径对齐核算表(固定类料随固定面积/开启类随开启面积/once 按樘数缩放, 人工+其他成本优先用核算表每平实际口径, 毛利阶梯 10%~70%); 75 系列基准窗估算 total=1118.20 与核算表成本合计完全一致(85/95 同样精确还原); API: /api/costbook import-excel/series/estimate CRUD; 前端 /cost_estimate 页(系列卡片+参数估算+八分区明细+毛利阶梯+价格库 Tab+Excel 上传导入); 种子脚本 seed_costbook.py; 修复 6 处解析缺陷(95系列号格式/隔热条total字段/胶条辅材辅件编号列空漏采/玻璃面积重复乘块数/once分支漏加total/基准窗面积等效); 回归: 冒烟 60/60(新增 COST-1~9)+24 页巡检 0 异常+深度审计 16/16 (Files: src/huamenchuang/services/costbook_service.py, src/huamenchuang/api/costbook.py, src/huamenchuang/api/router.py, src/huamenchuang/models/business.py, src/huamenchuang/models/__init__.py, src/huamenchuang/scripts/seed_costbook.py, src/huamenchuang/scripts/smoke_all.py, frontend/src/views/CostEstimateView.vue, frontend/src/views/LayoutView.vue, frontend/src/router/index.ts, frontend/scripts/verify-pages.mjs, doc/API.md) - [2026-10-05 05:58] FEAT: v2.36 成本核算模块——鲁铭成本核算 Excel 全量适配。解析 12 表工作簿(铝成本/铝计算/木成本+价格库 8 表): 价格库 580 条(华健型材模具 397/丝吉利娅 207/索宾柯 52/隔热条 42/胶条 56/赛遨 54/螺丝 39/其他 23)导入产品价格本(同名更新/异名新建, material 按 sheet 语义映射 profile/hardware/addon/glass); 「铝成本」页→CostSeriesTemplate 系列成本模板(75框扇齐平1.4/85框扇齐平1.8/95窗纱一体1.8, data JSON 八分区: window/profile/addon/strip/seal/glass/extra/hardware/labor); 估算器口径对齐核算表(固定类料随固定面积/开启类随开启面积/once 按樘数缩放, 人工+其他成本优先用核算表每平实际口径, 毛利阶梯 10%~70%); 75 系列基准窗估算 total=1118.20 与核算表成本合计完全一致(85/95 同样精确还原); API: /api/costbook import-excel/series/estimate CRUD; 前端 /cost_estimate 页(系列卡片+参数估算+八分区明细+毛利阶梯+价格库 Tab+Excel 上传导入); 种子脚本 seed_costbook.py; 修复 6 处解析缺陷(95系列号格式/隔热条total字段/胶条辅材辅件编号列空漏采/玻璃面积重复乘块数/once分支漏加total/基准窗面积等效); 回归: 冒烟 60/60(新增 COST-1~9)+24 页巡检 0 异常+深度审计 16/16 (Files: src/huamenchuang/services/costbook_service.py, src/huamenchuang/api/costbook.py, src/huamenchuang/api/router.py, src/huamenchuang/models/business.py, src/huamenchuang/models/__init__.py, src/huamenchuang/scripts/seed_costbook.py, src/huamenchuang/scripts/smoke_all.py, frontend/src/views/CostEstimateView.vue, frontend/src/views/LayoutView.vue, frontend/src/router/index.ts, frontend/scripts/verify-pages.mjs, doc/API.md)
+121 -1
View File
@@ -8,7 +8,7 @@
* 在 2D 窗型几何上叠加玻璃渐变/高光/投影/墙面光影,输出照片观感。 * 在 2D 窗型几何上叠加玻璃渐变/高光/投影/墙面光影,输出照片观感。
*/ */
import { onMounted, ref, watch } from 'vue' import { onMounted, ref, watch } from 'vue'
import { bboxOf, parseDrawing, regionPoints, replayShape, type DrawingData } from '../types/drawing' import { bboxOf, inferSashGlyphs, inferSashPanels, parseDrawing, regionPoints, replayShape, type DrawingData, type FrameShape, type SashShape } from '../types/drawing'
const props = defineProps<{ drawingData: string | object; tint?: string }>() const props = defineProps<{ drawingData: string | object; tint?: string }>()
const cvRef = ref<HTMLCanvasElement>() const cvRef = ref<HTMLCanvasElement>()
@@ -81,6 +81,126 @@ function render() {
ctx.fill() ctx.fill()
ctx.restore() ctx.restore()
} }
// 扇层:扇内子格(裙板/格条/编号) + 扇料框 + 开向符号 + 把手(语义同 WindoorCanvas,视觉对齐 2D 图纸)
const rp = replayShape(shape)
const glyphs = inferSashGlyphs(shape, rp).sashes
const sashShapes: SashShape[] = []
const collect = (s: FrameShape | SashShape) => {
if (!['Frame', 'Slide'].includes(String(s.type))) sashShapes.push(s as SashShape)
;(((s as FrameShape).sm ?? []) as (FrameShape | SashShape)[]).forEach(collect)
;(((s as SashShape).sashes ?? []) as SashShape[]).forEach(collect)
}
collect(shape)
glyphs.forEach((g, gi) => {
const bx0 = tx(g.x0), bx1 = tx(g.x1), by0 = ty(g.y0), by1 = ty(g.y1)
const w = bx1 - bx0, h = by1 - by0
if (w < 6 || h < 6) return
// 子格:锚点归属匹配扇形状 → 玻璃格/裙板/格条/编号
const hit = sashShapes.find(s => (s.mm?.sp ?? []).some(sp => {
const pt = sp.line?.pt
return pt && pt.x >= g.x0 && pt.x <= g.x1 && pt.y >= g.y0 && pt.y <= g.y1
})) ?? sashShapes[gi % Math.max(1, sashShapes.length)]
if (hit) {
for (const p of inferSashPanels(hit, g)) {
const px0 = tx(p.x0), px1 = tx(p.x1), py0 = ty(p.y0), py1 = ty(p.y1)
if (px1 - px0 < 4 || py1 - py0 < 4) continue
if (!p.glass) {
// 裙板/实板:暖米色 + 内凹描边
ctx.fillStyle = '#ddd3c0'
ctx.fillRect(px0, py0, px1 - px0, py1 - py0)
ctx.strokeStyle = 'rgba(90,84,72,0.55)'
ctx.lineWidth = 1.5
ctx.strokeRect(px0 + 3, py0 + 3, px1 - px0 - 6, py1 - py0 - 6)
} else if (p.deco) {
// 源站格条(decorationChinese 等): hc 等分主格 + gp 内框 + 四角回纹块
const bw = Math.max(1.2, 14 * scale)
ctx.strokeStyle = 'rgba(72,58,40,0.7)'
ctx.lineWidth = bw
const inner = Math.max(6, p.deco.gp * scale)
const w2 = px1 - px0, h2 = py1 - py0
ctx.save()
ctx.beginPath()
ctx.rect(px0, py0, w2, h2)
ctx.clip()
// 内圈框
ctx.strokeRect(px0 + inner, py0 + inner, w2 - 2 * inner, h2 - 2 * inner)
// 主格等分线(hc)
const hc = Math.max(2, p.deco.hc)
for (let i = 1; i < hc; i++) {
ctx.beginPath(); ctx.moveTo(px0 + (w2 * i) / hc, py0 + inner); ctx.lineTo(px0 + (w2 * i) / hc, py1 - inner); ctx.stroke()
ctx.beginPath(); ctx.moveTo(px0 + inner, py0 + (h2 * i) / hc); ctx.lineTo(px1 - inner, py0 + (h2 * i) / hc); ctx.stroke()
}
// 四角回纹块(嵌套双框)
const qs = Math.min(inner * 0.72, w2 * 0.14, h2 * 0.14)
for (const [qx, qy] of [[px0 + inner, py0 + inner], [px1 - inner, py0 + inner], [px0 + inner, py1 - inner], [px1 - inner, py1 - inner]] as const) {
const sx = qx === px0 + inner ? 1 : -1, sy = qy === py0 + inner ? 1 : -1
ctx.strokeRect(Math.min(qx, qx + sx * qs), Math.min(qy, qy + sy * qs), qs, qs)
ctx.strokeRect(Math.min(qx, qx + sx * qs * 0.55), Math.min(qy, qy + sy * qs * 0.55), qs * 0.55, qs * 0.55)
}
ctx.restore()
}
// 玻璃/板编号(对齐图纸 A1/A2...)
if (p.glassText) {
const fs = Math.max(11, Math.min(18, (px1 - px0) * 0.09))
ctx.fillStyle = p.glass ? 'rgba(255,255,255,0.85)' : 'rgba(90,84,72,0.8)'
ctx.font = `bold ${fs}px sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(p.glassText, (px0 + px1) / 2, (py0 + py1) / 2)
}
}
}
// 扇料框
ctx.strokeStyle = '#4a4e52'
ctx.lineWidth = 3.2
ctx.strokeRect(bx0, by0, w, h)
const cx = (bx0 + bx1) / 2, cy = (by0 + by1) / 2
// 开向符号: 平开=虚线三角(apex 指铰链侧), 推拉=水平滑行箭头
ctx.strokeStyle = 'rgba(60,66,72,0.75)'
ctx.lineWidth = 1.4
if (g.kind === 'sliding') {
const arrowDir = gi % 2 === 0 ? 1 : -1
const L = Math.min(w, h) * 0.3
ctx.setLineDash([])
ctx.beginPath()
ctx.moveTo(cx - L * arrowDir, cy)
ctx.lineTo(cx + L * arrowDir, cy)
ctx.lineTo(cx + (L - 9) * arrowDir, cy - 6)
ctx.moveTo(cx + L * arrowDir, cy)
ctx.lineTo(cx + (L - 9) * arrowDir, cy + 6)
ctx.stroke()
} else {
ctx.setLineDash([7, 5])
ctx.beginPath()
const dir = g.openDirection ?? 'left'
if (dir.startsWith('up')) {
ctx.moveTo(bx0, by1); ctx.lineTo(cx, by0); ctx.lineTo(bx1, by1)
} else if (dir.startsWith('right')) {
ctx.moveTo(bx0, by0); ctx.lineTo(bx1, cy); ctx.lineTo(bx0, by1)
} else {
ctx.moveTo(bx1, by0); ctx.lineTo(bx0, cy); ctx.lineTo(bx1, by1)
}
ctx.stroke()
ctx.setLineDash([])
}
// 把手: 铰链对面侧白条
const dir = g.openDirection ?? 'left'
const hl = Math.max(10, h * (g.isDoor ? 0.34 : 0.2))
const hw2 = Math.max(4, 5)
ctx.fillStyle = '#f4f4f6'
ctx.strokeStyle = '#3c4147'
ctx.lineWidth = 1
ctx.beginPath()
if (g.kind === 'casement') {
if (dir.startsWith('left')) ctx.rect(bx1 - hw2 - 3, cy - hl / 2, hw2, hl)
else if (dir.startsWith('right')) ctx.rect(bx0 + 3, cy - hl / 2, hw2, hl)
else ctx.rect(cx - hl / 2, by1 - hw2 - 3, hl, hw2)
} else {
ctx.rect(cx - hw2 / 2, cy - hl / 2, hw2, hl)
}
ctx.fill()
ctx.stroke()
})
// 外框 // 外框
const all = dd.sm.flatMap(s => replayShape(s).regions.flat().map(seg => seg.ps)) const all = dd.sm.flatMap(s => replayShape(s).regions.flat().map(seg => seg.ps))
void all void all
+73 -3
View File
@@ -119,10 +119,10 @@
* - 改梃: 直接点击中梃选中(高亮) → 拖动移动(line.pt 更新后重放) → 删除梃或 Del 键删除 * - 改梃: 直接点击中梃选中(高亮) → 拖动移动(line.pt 更新后重放) → 删除梃或 Del 键删除
* - 撤销栈; exportDrawing() 取回 drawing_data; change 事件通知父组件 * - 撤销栈; exportDrawing() 取回 drawing_data; change 事件通知父组件
*/ */
import { onMounted, reactive, ref, watch } from 'vue' import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { import {
bboxOf, DEC_PRESETS, inferSashGlyphs, parseDrawing, regionPoints, replayShape, spAxis, spPos, bboxOf, DEC_PRESETS, inferSashGlyphs, inferSashPanels, parseDrawing, regionPoints, replayShape, spAxis, spPos,
type DecBar, type DrawingData, type FrameShape, type Pt, type SashAssign, type Segment, type SashShape, type SplitLine, type DecBar, type DrawingData, type FrameShape, type Pt, type SashAssign, type Segment, type SashShape, type SplitLine,
} from '../types/drawing' } from '../types/drawing'
@@ -392,6 +392,7 @@ function hitMullion(sx: number, sy: number): { shapeIdx: number; spIdx: number }
function render() { function render() {
const cv = cvRef.value, wrap = wrapRef.value const cv = cvRef.value, wrap = wrapRef.value
if (!cv || !wrap) return if (!cv || !wrap) return
if (!wrap.clientWidth || !wrap.clientHeight) return // 容器未布局(dialog 动画/隐藏)时跳过, 待 ResizeObserver 触发
const dpr = window.devicePixelRatio || 1 const dpr = window.devicePixelRatio || 1
cv.width = wrap.clientWidth * dpr cv.width = wrap.clientWidth * dpr
cv.height = wrap.clientHeight * dpr cv.height = wrap.clientHeight * dpr
@@ -570,14 +571,72 @@ function drawFrame(
} }
} }
// 4) 扇: 扇料框 + 官方开向符号 + 把手 // 4) 扇: 扇内子格(源站 Panel 裙板/格条/编号) + 扇料框 + 官方开向符号 + 把手
const glyphs = inferSashGlyphs(shape, rp).sashes const glyphs = inferSashGlyphs(shape, rp).sashes
const ewAvg = ((shape.fm?.ew ?? [60, 60, 60, 60]).reduce((a, b) => a + b, 0)) / 4 const ewAvg = ((shape.fm?.ew ?? [60, 60, 60, 60]).reduce((a, b) => a + b, 0)) / 4
const fw = Math.max(2.5, Math.min(lw, ewAvg * scale)) const fw = Math.max(2.5, Math.min(lw, ewAvg * scale))
const sashShapes: SashShape[] = []
const collectSash = (s: FrameShape | SashShape) => {
if (!['Frame', 'Slide'].includes(String(s.type))) sashShapes.push(s as SashShape)
;(((s as FrameShape).sm ?? []) as (FrameShape | SashShape)[]).forEach(collectSash)
;(((s as SashShape).sashes ?? []) as SashShape[]).forEach(collectSash)
}
collectSash(shape)
glyphs.forEach((g, gi) => { glyphs.forEach((g, gi) => {
const bx0 = tx(g.x0), bx1 = tx(g.x1), by0 = ty(g.y0), by1 = ty(g.y1) const bx0 = tx(g.x0), bx1 = tx(g.x1), by0 = ty(g.y0), by1 = ty(g.y1)
const w = bx1 - bx0, h = by1 - by0 const w = bx1 - bx0, h = by1 - by0
if (w < 6 || h < 6) return if (w < 6 || h < 6) return
// 扇内子格: 锚点归属匹配 → 裙板实色/格条网格/编号(对齐官方图纸 A1/A2...)
const hit = sashShapes.find(s => (s.mm?.sp ?? []).some(sp => {
const pt = sp.line?.pt
return pt && pt.x >= g.x0 && pt.x <= g.x1 && pt.y >= g.y0 && pt.y <= g.y1
})) ?? sashShapes[gi % Math.max(1, sashShapes.length)]
if (hit) {
for (const p of inferSashPanels(hit, g)) {
const px0 = tx(p.x0), px1 = tx(p.x1), py0 = ty(p.y0), py1 = ty(p.y1)
if (px1 - px0 < 4 || py1 - py0 < 4) continue
if (!p.glass) {
ctx.fillStyle = '#e5dfd3'
ctx.fillRect(px0, py0, px1 - px0, py1 - py0)
ctx.strokeStyle = STYLE.profileDark
ctx.lineWidth = 1
ctx.strokeRect(px0 + 2, py0 + 2, px1 - px0 - 4, py1 - py0 - 4)
} else if (p.deco) {
// hc 等分主格 + gp 内框 + 四角回纹块(近似源站 decorationChinese)
const inner = Math.max(5, p.deco.gp * scale)
const w2 = px1 - px0, h2 = py1 - py0
ctx.save()
ctx.strokeStyle = 'rgba(58,64,72,0.6)'
ctx.lineWidth = 1.2
ctx.beginPath()
ctx.rect(px0, py0, w2, h2)
ctx.clip()
ctx.strokeRect(px0 + inner, py0 + inner, w2 - 2 * inner, h2 - 2 * inner)
const hc = Math.max(2, p.deco.hc)
for (let i = 1; i < hc; i++) {
ctx.beginPath(); ctx.moveTo(px0 + (w2 * i) / hc, py0 + inner); ctx.lineTo(px0 + (w2 * i) / hc, py1 - inner); ctx.stroke()
ctx.beginPath(); ctx.moveTo(px0 + inner, py0 + (h2 * i) / hc); ctx.lineTo(px1 - inner, py0 + (h2 * i) / hc); ctx.stroke()
}
const qs = Math.min(inner * 0.72, w2 * 0.14, h2 * 0.14)
for (const [qx, qy] of [[px0 + inner, py0 + inner], [px1 - inner, py0 + inner], [px0 + inner, py1 - inner], [px1 - inner, py1 - inner]] as const) {
const sx = qx === px0 + inner ? 1 : -1, sy = qy === py0 + inner ? 1 : -1
ctx.strokeRect(Math.min(qx, qx + sx * qs), Math.min(qy, qy + sy * qs), qs, qs)
ctx.strokeRect(Math.min(qx, qx + sx * qs * 0.55), Math.min(qy, qy + sy * qs * 0.55), qs * 0.55, qs * 0.55)
}
ctx.restore()
}
if (p.glassText) {
const fs2 = Math.max(9, Math.min(14, (px1 - px0) * 0.1))
ctx.fillStyle = p.glass ? 'rgba(255,255,255,0.92)' : STYLE.label
ctx.font = `bold ${fs2}px sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(p.glassText, (px0 + px1) / 2, (py0 + py1) / 2)
ctx.textAlign = 'left'
ctx.textBaseline = 'alphabetic'
}
}
}
// 扇料框(细一档的型材) // 扇料框(细一档的型材)
drawProfileRect(ctx, bx0, by0, w, h, fw) drawProfileRect(ctx, bx0, by0, w, h, fw)
const cx = (bx0 + bx1) / 2, cy = (by0 + by1) / 2 const cx = (bx0 + bx1) / 2, cy = (by0 + by1) / 2
@@ -1207,6 +1266,17 @@ onMounted(() => {
} }
}, { immediate: true }) }, { immediate: true })
window.addEventListener('resize', render) window.addEventListener('resize', render)
// 容器尺寸追踪: el-dialog 内挂载时首帧可能 0 尺寸, 布局完成后自动重渲染
if (wrapRef.value) {
renderRo = new ResizeObserver(() => render())
renderRo.observe(wrapRef.value)
}
})
let renderRo: ResizeObserver | null = null
onBeforeUnmount(() => {
renderRo?.disconnect()
window.removeEventListener('resize', render)
}) })
</script> </script>
+69 -4
View File
@@ -59,8 +59,8 @@ let ro: ResizeObserver | null = null
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import * as THREE from 'three' import * as THREE from 'three'
import { import {
bboxOf, inferSashGlyphs, parseDrawing, regionPoints, replayShape, bboxOf, inferSashGlyphs, inferSashPanels, parseDrawing, regionPoints, replayShape,
type DecBar, type DrawingData, type Pt, type DecBar, type DrawingData, type FrameShape, type Pt, type SashShape,
} from '../types/drawing' } from '../types/drawing'
import { profileGeo } from '../three/profileGeo' import { profileGeo } from '../three/profileGeo'
@@ -256,6 +256,14 @@ function build(drawing: DrawingData) {
for (const shape of drawing.sm) { for (const shape of drawing.sm) {
const { regions, sashes } = inferSashGlyphs(shape) const { regions, sashes } = inferSashGlyphs(shape)
if (!regions.length) continue if (!regions.length) continue
// 收集 Sash 级形状, 供扇内子格(裙板/格条)锚点归属匹配
const sashShapes3d: SashShape[] = []
const collectSash3d = (s: FrameShape | SashShape) => {
if (!['Frame', 'Slide'].includes(String(s.type))) sashShapes3d.push(s as SashShape)
;(((s as FrameShape).sm ?? []) as (FrameShape | SashShape)[]).forEach(x => collectSash3d(x))
;(((s as SashShape).sashes ?? []) as SashShape[]).forEach(x => collectSash3d(x))
}
collectSash3d(shape)
const cm = shape.cm ?? {} const cm = shape.cm ?? {}
const gm = cm.g const gm = cm.g
? new THREE.MeshPhysicalMaterial({ ? new THREE.MeshPhysicalMaterial({
@@ -271,16 +279,22 @@ function build(drawing: DrawingData) {
const regionBBs: BBox[] = regions.map(r => bboxOf(regionPoints(r))) const regionBBs: BBox[] = regions.map(r => bboxOf(regionPoints(r)))
const used = new Set<number>() const used = new Set<number>()
const panels: { rect: BBox; sash: (typeof sashes)[number]; ri: number }[] = [] const panels: { rect: BBox; sash: (typeof sashes)[number]; ri: number }[] = []
// 先统计各区域覆盖扇数: 多扇共享(推拉/对开门)时保留静态底玻璃, 独占区域才跳过
const coverCount = new Map<number, number>()
for (const s of sashes) { for (const s of sashes) {
const sb: BBox = { x0: s.x0, y0: s.y0, x1: s.x1, y1: s.y1 } const sb: BBox = { x0: s.x0, y0: s.y0, x1: s.x1, y1: s.y1 }
let best = -1, bestA = 0 let best = -1, bestA = 0
regionBBs.forEach((rb, ri) => { regionBBs.forEach((rb, ri) => {
if (used.has(ri)) return
const a = overlapArea(sb, rb) const a = overlapArea(sb, rb)
if (a > bestA) { bestA = a; best = ri } if (a > bestA) { bestA = a; best = ri }
}) })
if (best >= 0 && bestA > 0) { used.add(best); panels.push({ rect: regionBBs[best], sash: s, ri: best }) } if (best >= 0 && bestA > 0) {
coverCount.set(best, (coverCount.get(best) ?? 0) + 1)
// 扇面板用 glyph 自身矩形(多扇可共享同一区域)
panels.push({ rect: sb, sash: s, ri: best })
} }
}
for (const [ri, n] of coverCount) if (n === 1) used.add(ri)
/** 装饰格条(自研 decs 扩展): 玻璃前表面加横竖格条体 */ /** 装饰格条(自研 decs 扩展): 玻璃前表面加横竖格条体 */
const addDecBars = (parent: THREE.Group, dec: DecBar, w: number, h: number, mx: number, my: number, z: number) => { const addDecBars = (parent: THREE.Group, dec: DecBar, w: number, h: number, mx: number, my: number, z: number) => {
@@ -333,6 +347,57 @@ function build(drawing: DrawingData) {
barY(group, sashMat, h, t, DEPTH * 0.7, w / 2 - t / 2, 0, 0) barY(group, sashMat, h, t, DEPTH * 0.7, w / 2 - t / 2, 0, 0)
const dec = (shape.decs ?? []).find(d => d.ri === ri) const dec = (shape.decs ?? []).find(d => d.ri === ri)
if (dec) addDecBars(group, dec, w, h, 0, 0, 0.011) if (dec) addDecBars(group, dec, w, h, 0, 0, 0.011)
// 扇内子格(源站原生): 裙板实板 + dbs 格条(锚点归属匹配扇形状: sp 锚点落在该扇矩形内)
const gRect = { x0: s.x0, y0: s.y0, x1: s.x1, y1: s.y1 }
const matched = sashShapes3d.find(sh => (sh.mm?.sp ?? []).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
})) ?? sashShapes3d[k % Math.max(1, sashShapes3d.length)]
if (matched) {
for (const p of inferSashPanels(matched, gRect)) {
const pw = (p.x1 - p.x0) * S, ph = (p.y1 - p.y0) * S
// 子格中心 → group 局部坐标(group 原点=扇矩形中心)
const lmx = ((p.x0 + p.x1) / 2 - gRect.x1 / 2 - gRect.x0 / 2) * S
const lmy = (gRect.y1 / 2 + gRect.y0 / 2 - (p.y0 + p.y1) / 2) * S
if (![pw, ph, lmx, lmy].every(Number.isFinite) || pw <= 0.01 || ph <= 0.01) continue
if (!p.glass) {
const board = new THREE.Mesh(new THREE.BoxGeometry(pw, ph, 0.012),
new THREE.MeshStandardMaterial({ color: 0xd9cdb8, roughness: 0.7, metalness: 0.05 }))
board.position.set(lmx, lmy, 0.004)
group.add(board)
} else if (p.deco) {
// hc 等分主格 + gp 内框 + 四角回纹块(3D 条体)
const bw = Math.max(0.006, 0.014 * S)
const inner = Math.max(0.015, p.deco.gp * S)
const barMat = new THREE.MeshStandardMaterial({ color: 0x6b563c, roughness: 0.5, metalness: 0.25 })
const iw = pw - 2 * inner, ih = ph - 2 * inner
if (iw > 0.02 && ih > 0.02) {
// 内圈框
barX(group, barMat, iw, bw, DEPTH * 0.2, lmx, lmy + ih / 2, 0.008, 1)
barX(group, barMat, iw, bw, DEPTH * 0.2, lmx, lmy - ih / 2, 0.008, 1)
barY(group, barMat, ih, bw, DEPTH * 0.2, lmx - iw / 2, lmy, 0.008, 1)
barY(group, barMat, ih, bw, DEPTH * 0.2, lmx + iw / 2, lmy, 0.008, 1)
// 主格等分
const hc = Math.max(2, p.deco.hc)
for (let i = 1; i < hc; i++) {
barY(group, barMat, ih, bw, DEPTH * 0.2, lmx - iw / 2 + (iw * i) / hc, lmy, 0.008, 1)
barX(group, barMat, iw, bw, DEPTH * 0.2, lmx, lmy + ih / 2 - (ih * i) / hc, 0.008, 1)
}
// 四角回纹块(嵌套双框)
const qs = Math.min(inner * 0.72, iw * 0.14, ih * 0.14)
for (const [sx, sy] of [[-1, 1], [1, 1], [-1, -1], [1, -1]] as const) {
const cx2 = lmx + sx * (iw / 2 - qs / 2), cy2 = lmy + sy * (ih / 2 - qs / 2)
for (const f of [1, 0.55]) {
barX(group, barMat, qs * f, bw, DEPTH * 0.2, cx2, cy2 + sy * (qs * f) / 2, 0.008, 1)
barX(group, barMat, qs * f, bw, DEPTH * 0.2, cx2, cy2 - sy * (qs * f) / 2, 0.008, 1)
barY(group, barMat, qs * f, bw, DEPTH * 0.2, cx2 + sx * (qs * f) / 2, cy2, 0.008, 1)
barY(group, barMat, qs * f, bw, DEPTH * 0.2, cx2 - sx * (qs * f) / 2, cy2, 0.008, 1)
}
}
}
}
}
}
group.position.set(cxr, cyr, 0) group.position.set(cxr, cyr, 0)
sashRoot.add(group) sashRoot.add(group)
+51
View File
@@ -67,6 +67,7 @@ export interface SashShape {
openToward?: string // inward/outward openToward?: string // inward/outward
rt?: number | boolean // 圆弧半径(弧顶扇) rt?: number | boolean // 圆弧半径(弧顶扇)
sashes?: SashEntry[] // DoubleSash 的子扇 sashes?: SashEntry[] // DoubleSash 的子扇
mm?: { sp?: SplitLine[]; dbs?: unknown; fls?: { fls?: any[] }; phs?: unknown[]; lpfs?: unknown[] } // 扇内分割/玻璃/格条(源站原生)
[k: string]: unknown [k: string]: unknown
} }
@@ -480,3 +481,53 @@ export function inferSashGlyphs(shape: FrameShape, replayed?: ReplayResult | Seg
} }
return { regions, sashes: glyphs } return { regions, sashes: glyphs }
} }
/** 扇内子格(源站原生结构消费):扇自身 mm.sp 内部分割 + mm.fls 玻璃挂接 + mm.dbs.bars 源站格条。
* 扇无基底 polygon(几何=挂接区域矩形),以 rect 构造临时 shape 复用 replayShape 全语义重放。 */
export interface SashPanel {
x0: number; y0: number; x1: number; y1: number
glass: boolean // true=玻璃格(挂 fls 或无分割默认) / false=实板(门裙板等)
glassText?: string // 玻璃编号(ser.text)
glassSpec?: string // 玻璃配置(spet.sp)
deco?: { tp: string; gp: number; hc: number } // 源站格条(dbs.bars: tp=风格, gp=回纹尺寸 mm, hc=主格等分数)
}
export function inferSashPanels(sash: SashShape, rect: { x0: number; y0: number; x1: number; y1: number }): SashPanel[] {
const mm = ((sash as Record<string, unknown>).mm ?? {}) as { sp?: SplitLine[]; fls?: { fls?: any[] }; dbs?: unknown }
const sps = mm.sp ?? []
const fls = mm.fls?.fls ?? []
if (!sps.length) {
const fl = fls[0]
return [{ ...rect, glass: true, glassText: fl?.ser?.text, glassSpec: fl?.spet?.sp }]
}
const ring = [
[{ ps: { x: rect.x0, y: rect.y0 }, pe: { x: rect.x1, y: rect.y0 } },
{ ps: { x: rect.x1, y: rect.y0 }, pe: { x: rect.x1, y: rect.y1 } },
{ ps: { x: rect.x1, y: rect.y1 }, pe: { x: rect.x0, y: rect.y1 } },
{ ps: { x: rect.x0, y: rect.y1 }, pe: { x: rect.x0, y: rect.y0 } }],
]
const fake = { ...(sash as Record<string, unknown>), polygon: { type: 'polygon', polygon: ring, spLine: null, virtual: false }, mm } as unknown as FrameShape
const rp = replayShape(fake)
const decos: { pid: { idx: number; pos: number }; tp: string; gp: number; hc: number }[] = []
const barsRaw = (mm.dbs && typeof mm.dbs === 'object' ? (mm.dbs as { bars?: unknown[] }).bars : (mm.dbs as unknown[])) ?? []
for (const entry of barsRaw as unknown[]) {
try {
const pair = entry as [string, { db?: { tp?: string; gp?: number } } | undefined]
const pid = typeof pair[0] === 'string' ? JSON.parse(pair[0]) : pair[0]
const db = (pair[1]?.db ?? pair[1]) as { tp?: string; gp?: number; hc?: number } | undefined
if (pid && Number.isInteger(pid.idx)) decos.push({ pid, tp: String(db?.tp ?? ''), gp: Number(db?.gp) || 80, hc: Number(db?.hc) || 2 })
} catch { /* 坏数据忽略 */ }
}
return rp.regions.map((segs, i) => {
const bb = bboxOf(regionPoints(segs))
const ref = rp.leafRefs[i] ?? { idx: -1, pos: -1 }
const fl = fls.find(f => { const p = f?.pid ?? {}; return p.idx === ref.idx && p.pos === ref.pos })
?? (fls.length === 1 && (fls[0]?.pid?.idx ?? -1) < 0 ? fls[0] : undefined)
const isPanel = String(fl?.type ?? '') === 'Panel'
const deco = decos.find(d => d.pid.idx === ref.idx && d.pid.pos === ref.pos)
return { x0: bb.x0, y0: bb.y0, x1: bb.x1, y1: bb.y1,
glass: isPanel ? false : Boolean(fl) || fls.length === 0,
glassText: fl?.ser?.text, glassSpec: fl?.spet?.sp,
deco: deco ? { tp: deco.tp, gp: deco.gp, hc: deco.hc } : undefined }
})
}