From 888a8803af0c6b70647726d994792c9ce599e1ad Mon Sep 17 00:00:00 2001 From: HHY Date: Mon, 5 Oct 2026 07:15:07 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B8=B2=E6=9F=93=E8=A7=86=E8=A7=89?= =?UTF-8?q?=E4=B8=8E=E5=9B=BE=E7=BA=B8=E5=AF=B9=E9=BD=90(=E6=BA=90?= =?UTF-8?q?=E7=AB=99=E5=8E=9F=E7=94=9F=E6=89=87=E5=86=85=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E6=B6=88=E8=B4=B9:=20=E6=89=87=E5=86=85?= =?UTF-8?q?=E5=88=86=E5=89=B2/=E8=A3=99=E6=9D=BF/=E6=A0=BC=E6=9D=A1/?= =?UTF-8?q?=E7=BC=96=E5=8F=B7,=20inferSashPanels=20=E5=B7=A5=E5=85=B7+?= =?UTF-8?q?=E4=B8=89=E6=B8=B2=E6=9F=93=E5=99=A8=E6=8E=A5=E5=85=A5+?= =?UTF-8?q?=E9=94=9A=E7=82=B9=E5=BD=92=E5=B1=9E=E5=8C=B9=E9=85=8D+?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=A1=86ResizeObserver)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG | 1 + frontend/src/components/RenderCanvas.vue | 122 +++++++++++++++++++++- frontend/src/components/WindoorCanvas.vue | 76 +++++++++++++- frontend/src/components/WindoorThreed.vue | 73 ++++++++++++- frontend/src/types/drawing.ts | 51 +++++++++ 5 files changed, 315 insertions(+), 8 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index e0abca2..d62e90b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # 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: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) diff --git a/frontend/src/components/RenderCanvas.vue b/frontend/src/components/RenderCanvas.vue index e76af13..3e64362 100644 --- a/frontend/src/components/RenderCanvas.vue +++ b/frontend/src/components/RenderCanvas.vue @@ -8,7 +8,7 @@ * 在 2D 窗型几何上叠加玻璃渐变/高光/投影/墙面光影,输出照片观感。 */ 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 cvRef = ref() @@ -81,6 +81,126 @@ function render() { ctx.fill() 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)) void all diff --git a/frontend/src/components/WindoorCanvas.vue b/frontend/src/components/WindoorCanvas.vue index 765d53f..db7bc08 100644 --- a/frontend/src/components/WindoorCanvas.vue +++ b/frontend/src/components/WindoorCanvas.vue @@ -119,10 +119,10 @@ * - 改梃: 直接点击中梃选中(高亮) → 拖动移动(line.pt 更新后重放) → 删除梃或 Del 键删除 * - 撤销栈; 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 { - 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, } from '../types/drawing' @@ -392,6 +392,7 @@ function hitMullion(sx: number, sy: number): { shapeIdx: number; spIdx: number } function render() { const cv = cvRef.value, wrap = wrapRef.value if (!cv || !wrap) return + if (!wrap.clientWidth || !wrap.clientHeight) return // 容器未布局(dialog 动画/隐藏)时跳过, 待 ResizeObserver 触发 const dpr = window.devicePixelRatio || 1 cv.width = wrap.clientWidth * dpr cv.height = wrap.clientHeight * dpr @@ -570,14 +571,72 @@ function drawFrame( } } - // 4) 扇: 扇料框 + 官方开向符号 + 把手 + // 4) 扇: 扇内子格(源站 Panel 裙板/格条/编号) + 扇料框 + 官方开向符号 + 把手 const glyphs = inferSashGlyphs(shape, rp).sashes 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 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) => { 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 + // 扇内子格: 锚点归属匹配 → 裙板实色/格条网格/编号(对齐官方图纸 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) const cx = (bx0 + bx1) / 2, cy = (by0 + by1) / 2 @@ -1207,6 +1266,17 @@ onMounted(() => { } }, { immediate: true }) 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) }) diff --git a/frontend/src/components/WindoorThreed.vue b/frontend/src/components/WindoorThreed.vue index 99dffad..a468780 100644 --- a/frontend/src/components/WindoorThreed.vue +++ b/frontend/src/components/WindoorThreed.vue @@ -59,8 +59,8 @@ let ro: ResizeObserver | null = null import { ElMessage } from 'element-plus' import * as THREE from 'three' import { - bboxOf, inferSashGlyphs, parseDrawing, regionPoints, replayShape, - type DecBar, type DrawingData, type Pt, + bboxOf, inferSashGlyphs, inferSashPanels, parseDrawing, regionPoints, replayShape, + type DecBar, type DrawingData, type FrameShape, type Pt, type SashShape, } from '../types/drawing' import { profileGeo } from '../three/profileGeo' @@ -256,6 +256,14 @@ function build(drawing: DrawingData) { for (const shape of drawing.sm) { const { regions, sashes } = inferSashGlyphs(shape) 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 gm = cm.g ? new THREE.MeshPhysicalMaterial({ @@ -271,16 +279,22 @@ function build(drawing: DrawingData) { const regionBBs: BBox[] = regions.map(r => bboxOf(regionPoints(r))) const used = new Set() const panels: { rect: BBox; sash: (typeof sashes)[number]; ri: number }[] = [] + // 先统计各区域覆盖扇数: 多扇共享(推拉/对开门)时保留静态底玻璃, 独占区域才跳过 + const coverCount = new Map() for (const s of sashes) { const sb: BBox = { x0: s.x0, y0: s.y0, x1: s.x1, y1: s.y1 } let best = -1, bestA = 0 regionBBs.forEach((rb, ri) => { - if (used.has(ri)) return const a = overlapArea(sb, rb) 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 扩展): 玻璃前表面加横竖格条体 */ 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) const dec = (shape.decs ?? []).find(d => d.ri === ri) 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) sashRoot.add(group) diff --git a/frontend/src/types/drawing.ts b/frontend/src/types/drawing.ts index 86c6206..5003be4 100644 --- a/frontend/src/types/drawing.ts +++ b/frontend/src/types/drawing.ts @@ -67,6 +67,7 @@ export interface SashShape { openToward?: string // inward/outward rt?: number | boolean // 圆弧半径(弧顶扇) sashes?: SashEntry[] // DoubleSash 的子扇 + mm?: { sp?: SplitLine[]; dbs?: unknown; fls?: { fls?: any[] }; phs?: unknown[]; lpfs?: unknown[] } // 扇内分割/玻璃/格条(源站原生) [k: string]: unknown } @@ -480,3 +481,53 @@ export function inferSashGlyphs(shape: FrameShape, replayed?: ReplayResult | Seg } 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).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), 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 } + }) +}