feat: 渲染视觉与图纸对齐(源站原生扇内结构全量消费: 扇内分割/裙板/格条/编号, inferSashPanels 工具+三渲染器接入+锚点归属匹配+对话框ResizeObserver)
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user