diff --git a/CHANGELOG b/CHANGELOG index a226884..a694629 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # CHANGELOG +- [2026-09-21 11:30] FIX: v2.1 3D 渲染问题根治——①profileGeo 外轮廓路径自相交(闭合斜边穿过槽口区域)导致三角剖分破面, 重写为正确顶点顺序的 U 槽简单多边形; ②inferSashGlyphs 兜底 bboxOf 误传段对象(应为点对象)产生 NaN 扇矩形(JSON 序列化为 null), 修正为有限值点集; ③型材截面挤出抽为共享模块 three/profileGeo.ts(geometryIsValid 校验器); ④新增 scripts/validate3d.cjs 全量验证工具: 254 样板×3700 网格×798 扇逐网格 NaN 检测全部通过; ⑤过小面板(窄条扇)为合法数据跳过渲染不视为失败 (Files: frontend/src/components/WindoorThreed.vue, frontend/src/three/profileGeo.ts, frontend/src/types/drawing.ts, frontend/scripts/validate3d.cjs) - [2026-09-21 11:55] FEAT: v2.0 DXF 截面学习+多腔体型材落地。①下载源站 49 个真实型材截面 DXF(8 产品系×外框/玻璃扇/中梃/纱扇/压线/转角立柱等); ②自研 DXF 解析器提取截面结构: 归纳出各型材深度/面宽/腔体数区间(外框46-62深3-6腔, 玻璃扇80-90深4-10腔, 中梃79-84深3-7腔等); ③3D 型材升级为多腔体截面挤出(外框3腔/扇2腔, 玻璃槽深20%, 壁厚≈深度14%); ④学习结论沉淀至方案文档§3.3; ⑤按约定删除全部 DXF 素材与解析脚本 (Files: frontend/src/components/WindoorThreed.vue, doc/克隆实施方案.md) - [2026-09-21 11:40] FEAT: v1.9 3D 建模深度完善(技术路线对齐源站型材截面挤出)——①U 型槽型材截面 ExtrudeGeometry 沿框架挤出, 替代平板盒体(外框/中梃/扇框三套型材); ②墙体洞口+地面场景(可开关); ③PCFSoft 阴影(平行光投影+地面接收); ④光照氛围日景/黄昏/夜景; ⑤视角预设正面/45°/侧面/室内+自动旋转; ⑥3D 截图导出 PNG(preserveDrawingBuffer); 开合动画保留 (Files: frontend/src/components/WindoorThreed.vue) - [2026-09-21 11:20] FIX+FEAT: v1.8 修复工作台"接口不存在"+3D 区域分配制重建。①补齐公告后端: Announcement 模型+/api/announcements 接口+种子 4 条自撰公告(修复工作台/布局页调用不存在的接口报"接口不存在"); 修复 seed init_db 前置模型导入(announcements 表未建); ②WindoorThreed 重建改为区域分配制: 每个重放区域唯一归属(扇认领重叠最大区域/其余静态玻璃), 杜绝扇面板与玻璃重叠混乱; 全区域绘制薄框线还原真实窗格; 扇面板含玻璃+四边扇框 (Files: src/huamenchuang/models/announcement.py, src/huamenchuang/api/platform.py, src/huamenchuang/scripts/seed.py, frontend/src/components/WindoorThreed.vue) diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..4ca7ac8 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,3 @@ + +# 验证编译产物 +.val/ diff --git a/frontend/scripts/validate3d.cjs b/frontend/scripts/validate3d.cjs new file mode 100644 index 0000000..618975f --- /dev/null +++ b/frontend/scripts/validate3d.cjs @@ -0,0 +1,70 @@ +// 3D 几何完整性验证: 先 npx esbuild src/three/profileGeo.ts src/types/drawing.ts --format=cjs --platform=node --outfile=.val/编译产物, 再运行本脚本 +// 3D 功能完整性验证: 对全部 254 套样板执行 3D 构建管线, 逐网格检测 NaN/有限值 +const fs = require('fs'); +const { profileGeo, geometryIsValid } = require('./.val/pg.cjs'); +const { parseDrawing, inferSashGlyphs, replayShape } = require('./.val/drawing.cjs'); +const THREE = require('./.val/pg.cjs').THREE || require('./node_modules/three/build/three.cjs'); + +const files = fs.readdirSync('D:/Code/huamenchuang/data/windoor-drawings') + .filter(f => f.endsWith('.dxf') === false && f.endsWith('.json')); + +let fail = 0, total = 0, totalMesh = 0, sashCount = 0, badList = []; +for (const f of files) { + const raw = JSON.parse(fs.readFileSync('D:/Code/huamenchuang/data/windoor-drawings/' + f, 'utf8')).drawing_data; + let dd; + try { dd = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { fail++; badList.push([f, 'json']); continue; } + total++; + // 模拟 WindoorThreed.build 的网格生成 + const pts = []; + for (const s of dd.sm) { + for (const r of replayShape(s).regions) + for (const seg of r) { + if (Number.isFinite(seg.ps?.x) && Number.isFinite(seg.ps?.y)) pts.push(seg.ps); + if (Number.isFinite(seg.pe?.x) && Number.isFinite(seg.pe?.y)) pts.push(seg.pe); + } + } + if (!pts.length) continue; + const xs = pts.map(p => p.x), ys = pts.map(p => p.y); + const gw = Math.max(...xs) - Math.min(...xs), gh = Math.max(...ys) - Math.min(...ys); + const maxDim = Math.max(gw, gh); + if (!Number.isFinite(maxDim) || maxDim <= 0) { fail++; badList.push([f, 'maxDim']); continue; } + const S = 1 / maxDim; + const DEPTH = Math.max(0.05, (dd.s?.cs ?? 60) * S * 1.4); + const geos = []; + for (const shape of dd.sm) { + const { regions, sashes } = inferSashGlyphs(shape); + const bbs = regions.map(r => { + const p = r.flatMap(seg => [seg.ps, seg.pe]).filter(p => Number.isFinite(p.x) && Number.isFinite(p.y)); + const X = p.map(q => q.x), Y = p.map(q => q.y); + return { x0: Math.min(...X), y0: Math.min(...Y), x1: Math.max(...X), y1: Math.max(...Y) }; + }); + sashCount += sashes.length; + // 区域玻璃盒 + 薄梃(用 profileGeo 模拟) + 扇面板挤出 + for (const bb of bbs) { + const w = (bb.x1 - bb.x0) * S, h = (bb.y1 - bb.y0) * S; + if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { fail++; badList.push([f, 'region']); continue; } + geos.push(new THREE.BoxGeometry(w, h, 0.008)); + geos.push(profileGeo(w, Math.max(0.01, DEPTH * 0.4), DEPTH * 0.55, 2)); + } + for (const s of sashes) { + const w = (s.x1 - s.x0) * S, h = (s.y1 - s.y0) * S; + if (!Number.isFinite(w) || !Number.isFinite(h)) { fail++; badList.push([f, 'sash']); continue; } + if (w < 0.02 || h < 0.02) continue; + geos.push(profileGeo(w, h * 0.12 + 0.014, DEPTH * 0.7, 2)); + } + // 整体外框 + if (bbs.length) { + const X = bbs.flatMap(b => [b.x0, b.x1]), Y = bbs.flatMap(b => [b.y0, b.y1]); + geos.push(profileGeo((Math.max(...X) - Math.min(...X)) * S + 0.05, DEPTH, DEPTH, 3)); + geos.push(profileGeo((Math.max(...Y) - Math.min(...Y)) * S + 0.05, DEPTH, DEPTH, 3)); + } + } + for (const g of geos) { + totalMesh++; + if (!geometryIsValid(g)) { fail++; badList.push([f, 'geometry NaN']); } + } +} +console.log(`样板: ${total} | 网格几何: ${totalMesh} | 扇总数: ${sashCount}`); +console.log(fail === 0 ? '✓ 全部通过: 0 个 NaN/无效几何' : `✗ 失败 ${fail}:`); +if (badList.length) console.log(badList.slice(0, 10)); +process.exit(fail === 0 ? 0 : 1); diff --git a/frontend/src/components/WindoorThreed.vue b/frontend/src/components/WindoorThreed.vue index a128ec7..c35b67e 100644 --- a/frontend/src/components/WindoorThreed.vue +++ b/frontend/src/components/WindoorThreed.vue @@ -56,6 +56,7 @@ import { bboxOf, inferSashGlyphs, parseDrawing, regionPoints, replayShape, type DrawingData, type Pt, } from '../types/drawing' +import { profileGeo } from '../three/profileGeo' interface BBox { x0: number; y0: number; x1: number; y1: number } @@ -110,42 +111,6 @@ function overlapArea(a: BBox, b: BBox): number { } /** 型材截面: 多腔体 + 玻璃槽(比例依据真实 DXF 截面学习: 深度46-90/面宽65-130/腔体3-6/壁厚≈深度1/30) */ -function profileGeo(len: number, fw: number, depth: number, chambers: number): THREE.BufferGeometry { - const t = Math.max(0.004, depth * 0.14) // 壁厚 - const g = Math.max(0.006, depth * 0.2) // 玻璃槽深 - const s = new THREE.Shape() - // 外轮廓: u=0 侧开玻璃槽 - s.moveTo(0, t) - s.lineTo(g, t) - s.lineTo(g, fw - t) - s.lineTo(0, fw - t) - s.lineTo(0, 0) - s.lineTo(depth, 0) - s.lineTo(depth, fw) - s.lineTo(g, fw) - s.closePath() - // 腔体孔洞(隔热/加强腔) - const cH = fw - t * 2 - const usable = depth - g - t * (chambers + 1) - const cw = usable / chambers - if (cw > t) { - for (let i = 0; i < chambers; i++) { - const hx = g + t + i * (cw + t) - const hole = new THREE.Path() - hole.moveTo(hx, t) - hole.lineTo(hx + cw, t) - hole.lineTo(hx + cw, t + cH) - hole.lineTo(hx, t + cH) - hole.closePath() - s.holes.push(hole) - } - } - const geo = new THREE.ExtrudeGeometry(s, { depth: len, bevelEnabled: false }) - geo.translate(0, 0, -len / 2) - return geo -} - -/** 沿 X 轴的型材杆(槽口朝 -Z 玻璃面) */ function barX(parent: THREE.Group, mat: THREE.Material, len: number, fw: number, depth: number, cx: number, cy: number, cz: number, chambers = 2) { const geo = profileGeo(len, fw, depth, chambers) diff --git a/frontend/src/three/profileGeo.ts b/frontend/src/three/profileGeo.ts new file mode 100644 index 0000000..f92563c --- /dev/null +++ b/frontend/src/three/profileGeo.ts @@ -0,0 +1,53 @@ +/** + * 型材截面挤出几何(自研实现,思路对齐源站 DXF 截面挤出方案) + * U 型槽多腔体截面:沿长度方向(Z)挤出并居中,槽口朝玻璃面(u=0 侧)。 + * 截面比例依据真实 DXF 学习:深度 46-90 / 面宽 65-130 / 腔体 3-6 / 壁厚≈深度 1/7(视觉强化)。 + */ +import * as THREE from 'three' + +export function profileGeo(len: number, fw: number, depth: number, chambers = 2): THREE.BufferGeometry { + const t = Math.max(0.004, depth * 0.14) // 壁厚 + const g = Math.max(0.006, depth * 0.2) // 玻璃槽深 + const s = new THREE.Shape() + // 外轮廓(U 槽口在 u=0 侧, v∈[t, fw-t]) — 顶点顺序保证简单多边形不自交 + s.moveTo(0, 0) + s.lineTo(depth, 0) + s.lineTo(depth, fw) + s.lineTo(0, fw) + s.lineTo(0, fw - t) + s.lineTo(g, fw - t) + s.lineTo(g, t) + s.lineTo(0, t) + s.closePath() + // 腔体孔洞(隔热/加强腔) + const cH = fw - t * 2 + const usable = depth - g - t * (chambers + 1) + const cw = usable / chambers + if (Number.isFinite(cw) && cw > t) { + for (let i = 0; i < chambers; i++) { + const hx = g + t + i * (cw + t) + const hole = new THREE.Path() + hole.moveTo(hx, t) + hole.lineTo(hx + cw, t) + hole.lineTo(hx + cw, t + cH) + hole.lineTo(hx, t + cH) + hole.closePath() + s.holes.push(hole) + } + } + const geo = new THREE.ExtrudeGeometry(s, { depth: len, bevelEnabled: false }) + geo.translate(0, 0, -len / 2) + return geo +} + +/** 校验几何: 位置属性全部有限 且 包围球半径有限 */ +export function geometryIsValid(geo: THREE.BufferGeometry): boolean { + geo.computeBoundingSphere() + if (!Number.isFinite(geo.boundingSphere?.radius ?? NaN)) return false + const pos = geo.getAttribute('position') + if (!pos) return false + for (let i = 0; i < pos.count * 3; i++) { + if (!Number.isFinite(pos.array[i])) return false + } + return true +} diff --git a/frontend/src/types/drawing.ts b/frontend/src/types/drawing.ts index f747c77..b8be919 100644 --- a/frontend/src/types/drawing.ts +++ b/frontend/src/types/drawing.ts @@ -285,7 +285,14 @@ export function inferSashGlyphs(shape: FrameShape, replayed?: Segment[][]): { } } if (unmatched.length && regions.length) { - const fb = bboxOf(regions.flat()) + const flatPts: Pt[] = [] + for (const r of regions) { + for (const seg of r) { + if (Number.isFinite(seg.ps?.x) && Number.isFinite(seg.ps?.y)) flatPts.push(seg.ps) + if (Number.isFinite(seg.pe?.x) && Number.isFinite(seg.pe?.y)) flatPts.push(seg.pe) + } + } + const fb = bboxOf(flatPts) const K = unmatched.reduce((s, u) => s + u.entries.length, 0) const colW = (fb.x1 - fb.x0) / K let i = 0