fix: 修复 3D NaN 崩溃/样品图不刷新/画布点选失效

- replayShape 段过滤补 Y 轴有限值校验(此前仅校验 X)
- inferSashGlyphs bdRect 增加有限值守卫
- WindoorThreed 逐网格有限值防护 + maxDim 守卫, 杜绝 NaN 网格
- CombineCanvas shapePts 过滤非有限点, 修复包围盒污染导致的点选失效
- 组合画布引入 rev 版本号: 原地变异几何后强制 3D 预览重建
  (修复尺寸编辑后样品图不变化)
This commit is contained in:
HHY
2026-09-21 02:26:08 -07:00
parent 4303aa6986
commit 4b52767654
5 changed files with 27 additions and 11 deletions
+1
View File
@@ -1,5 +1,6 @@
# CHANGELOG # CHANGELOG
- [2026-09-21 11:00] FIX: v1.7 修复 3D NaN 崩溃/样品图不刷新/画布点选失效——①THREE NaN: drawing_data 畸形数据进入 3D 网格, replayShape 段过滤补 Y 轴有限值校验、inferSashGlyphs bdRect 有限值校验、WindoorThreed 逐网格有限值防护+maxDim 守卫、CombineCanvas shapePts 过滤; ②样品图不刷新: 组合画布原地变异 shapes 数组, combinedJson computed 感知不到深层变化 → 引入 rev 版本号强制重算驱动 3D 重建; ③点选失效: 单元包围盒被非有限值污染致命中测试失效, 随①修复 (Files: frontend/src/types/drawing.ts, frontend/src/components/WindoorThreed.vue, frontend/src/components/CombineCanvas.vue, frontend/src/views/CombineView.vue)
- [2026-09-21 10:50] FIX: 修复 spa_fallback 的 NameError(JSONResponse 未导入导致未匹配 /api 路径 500)与 JWT 密钥长度警告(默认密钥补足 32 字节以上) (Files: src/huamenchuang/main.py, src/huamenchuang/core/config.py) - [2026-09-21 10:50] FIX: 修复 spa_fallback 的 NameError(JSONResponse 未导入导致未匹配 /api 路径 500)与 JWT 密钥长度警告(默认密钥补足 32 字节以上) (Files: src/huamenchuang/main.py, src/huamenchuang/core/config.py)
- [2026-09-21 10:55] FEAT: v1.6 组合画布编辑能力增强——①选中单元可视化: 蓝色虚线框+四角手柄+实时尺寸标注(W×H mm); ②精确尺寸编辑: 工具栏 W/H 输入框(选中单元几何按比例缩放, 锚定左下角, 中梃点位同步); ③方向键微调: 1mm/Shift 10mm; ④右侧单元清单点击选中联动画布; selSize 双向同步(画布拖拽/输入框改动均实时刷新) (Files: frontend/src/components/CombineCanvas.vue, frontend/src/views/CombineView.vue) - [2026-09-21 10:55] FEAT: v1.6 组合画布编辑能力增强——①选中单元可视化: 蓝色虚线框+四角手柄+实时尺寸标注(W×H mm); ②精确尺寸编辑: 工具栏 W/H 输入框(选中单元几何按比例缩放, 锚定左下角, 中梃点位同步); ③方向键微调: 1mm/Shift 10mm; ④右侧单元清单点击选中联动画布; selSize 双向同步(画布拖拽/输入框改动均实时刷新) (Files: frontend/src/components/CombineCanvas.vue, frontend/src/views/CombineView.vue)
- [2026-09-21 10:30] FEAT: v1.5 3D 门窗开合动画——①扇推断逻辑抽取为共享模块 inferSashGlyphs(types/drawing.ts), 2D 符号与 3D 动画共用; ②WindoorThreed 重写: 每个扇构建独立网格组(玻璃+扇框), 平开扇绕铰链旋转(od 定铰链侧, 门75°/平开32°/双向门40°), 推拉扇沿轨道平移(双扇前后轨交替), 区域归属扇的不再重复渲染静态玻璃; ③开合控制条: 开合度滑杆+自动演示(正弦摆动)+全开/全关, 平滑插值动画; 组合画布 3D 预览自动获得开合能力 (Files: frontend/src/types/drawing.ts, frontend/src/components/WindoorThreed.vue, frontend/src/components/WindoorCanvas.vue) - [2026-09-21 10:30] FEAT: v1.5 3D 门窗开合动画——①扇推断逻辑抽取为共享模块 inferSashGlyphs(types/drawing.ts), 2D 符号与 3D 动画共用; ②WindoorThreed 重写: 每个扇构建独立网格组(玻璃+扇框), 平开扇绕铰链旋转(od 定铰链侧, 门75°/平开32°/双向门40°), 推拉扇沿轨道平移(双扇前后轨交替), 区域归属扇的不再重复渲染静态玻璃; ③开合控制条: 开合度滑杆+自动演示(正弦摆动)+全开/全关, 平滑插值动画; 组合画布 3D 预览自动获得开合能力 (Files: frontend/src/types/drawing.ts, frontend/src/components/WindoorThreed.vue, frontend/src/components/WindoorCanvas.vue)
+5 -3
View File
@@ -47,10 +47,12 @@ function shapePts(shape: any): Pt[] {
const out: Pt[] = [] const out: Pt[] = []
for (const r of (shape.polygon?.polygon ?? [])) for (const r of (shape.polygon?.polygon ?? []))
for (const seg of r) { for (const seg of r) {
if (seg.ps && Number.isFinite(seg.ps.x)) out.push(seg.ps) if (seg.ps && Number.isFinite(seg.ps.x) && Number.isFinite(seg.ps.y)) out.push(seg.ps)
if (seg.pe && Number.isFinite(seg.pe.x)) out.push(seg.pe) if (seg.pe && Number.isFinite(seg.pe.x) && Number.isFinite(seg.pe.y)) out.push(seg.pe)
}
for (const sp of (shape.mm?.sp ?? [])) {
if (sp.line?.pt && Number.isFinite(sp.line.pt.x) && Number.isFinite(sp.line.pt.y)) out.push(sp.line.pt)
} }
for (const sp of (shape.mm?.sp ?? [])) if (sp.line?.pt) out.push(sp.line.pt)
return out return out
} }
function translateUid(uid: number, dx: number, dy: number) { function translateUid(uid: number, dx: number, dy: number) {
+7 -2
View File
@@ -79,13 +79,17 @@ function build(drawing: DrawingData) {
const pts: Pt[] = [] const pts: Pt[] = []
for (const s of drawing.sm) { for (const s of drawing.sm) {
for (const r of replayShape(s).regions) { for (const r of replayShape(s).regions) {
for (const seg of r) { pts.push(seg.ps); pts.push(seg.pe) } 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) { info.value = '无几何数据'; hasOpenable.value = false; return } if (!pts.length) { info.value = '无几何数据'; hasOpenable.value = false; return }
const gb = bboxOf(pts) const gb = bboxOf(pts)
const gw = gb.x1 - gb.x0, gh = gb.y1 - gb.y0 const gw = gb.x1 - gb.x0, gh = gb.y1 - gb.y0
const maxDim = Math.max(gw, gh) const maxDim = Math.max(gw, gh)
if (!Number.isFinite(maxDim) || maxDim <= 0) { info.value = '无有效几何'; hasOpenable.value = false; return }
const S = 1 / maxDim const S = 1 / maxDim
const cx = (gb.x0 + gb.x1) / 2, cy = (gb.y0 + gb.y1) / 2 const cx = (gb.x0 + gb.x1) / 2, cy = (gb.y0 + gb.y1) / 2
const DEPTH = Math.max(0.04, (drawing.s?.cs ?? 60) * S * 1.2) const DEPTH = Math.max(0.04, (drawing.s?.cs ?? 60) * S * 1.2)
@@ -132,6 +136,7 @@ function build(drawing: DrawingData) {
const w = (bb.x1 - bb.x0) * S, h = (bb.y1 - bb.y0) * S const w = (bb.x1 - bb.x0) * S, h = (bb.y1 - bb.y0) * S
const mx = ((bb.x0 + bb.x1) / 2 - cx) * S const mx = ((bb.x0 + bb.x1) / 2 - cx) * S
const my = ((bb.y0 + bb.y1) / 2 - cy) * S const my = ((bb.y0 + bb.y1) / 2 - cy) * S
if (![w, h, mx, my].every(Number.isFinite) || w <= 0 || h <= 0) continue
const inSash = sashRects.some(r => { const inSash = sashRects.some(r => {
const cxr = (r.x0 + r.x1) / 2, cyr = (r.y0 + r.y1) / 2 const cxr = (r.x0 + r.x1) / 2, cyr = (r.y0 + r.y1) / 2
return Math.abs(cxr - (bb.x0 + bb.x1) / 2) < w / 2 && Math.abs(cyr - (bb.y0 + bb.y1) / 2) < h / 2 return Math.abs(cxr - (bb.x0 + bb.x1) / 2) < w / 2 && Math.abs(cyr - (bb.y0 + bb.y1) / 2) < h / 2
@@ -145,9 +150,9 @@ function build(drawing: DrawingData) {
// 扇面板(可开合):玻璃 + 四周扇框,装入铰链/滑动组 // 扇面板(可开合):玻璃 + 四周扇框,装入铰链/滑动组
sashes.forEach((s, k) => { sashes.forEach((s, k) => {
const w = (s.x1 - s.x0) * S, h = (s.y1 - s.y0) * S const w = (s.x1 - s.x0) * S, h = (s.y1 - s.y0) * S
if (w < 0.02 || h < 0.02) return
const cxr = ((s.x0 + s.x1) / 2 - cx) * S const cxr = ((s.x0 + s.x1) / 2 - cx) * S
const cyr = ((s.y0 + s.y1) / 2 - cy) * S const cyr = ((s.y0 + s.y1) / 2 - cy) * S
if (![w, h, cxr, cyr].every(Number.isFinite) || w < 0.02 || h < 0.02) return
const group = new THREE.Group() const group = new THREE.Group()
const panel = new THREE.Group() const panel = new THREE.Group()
const glass = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.006), gm) const glass = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.006), gm)
+6 -2
View File
@@ -143,7 +143,9 @@ export function replayShape(shape: FrameShape): {
} { } {
const base = (shape.polygon?.polygon ?? []) const base = (shape.polygon?.polygon ?? [])
.map(r => r .map(r => r
.filter(s => s && typeof s === 'object' && s.ps && s.pe && Number.isFinite(s.ps.x) && Number.isFinite(s.pe.x)) .filter(s => s && typeof s === 'object' && s.ps && s.pe
&& Number.isFinite(s.ps.x) && Number.isFinite(s.ps.y)
&& Number.isFinite(s.pe.x) && Number.isFinite(s.pe.y))
.map(s => ({ ps: { ...s.ps }, pe: { ...s.pe } }))) .map(s => ({ ps: { ...s.ps }, pe: { ...s.pe } })))
.filter(r => r.length >= 3) .filter(r => r.length >= 3)
const regions: Segment[][] = base.map(r => r) const regions: Segment[][] = base.map(r => r)
@@ -237,11 +239,13 @@ export function inferSashGlyphs(shape: FrameShape, replayed?: Segment[][]): {
const regionBBs = regions.map(r => bboxOf(regionPoints(r))) const regionBBs = regions.map(r => bboxOf(regionPoints(r)))
const glyphs: SashGlyph[] = [] const glyphs: SashGlyph[] = []
const bdRect = (bd: NonNullable<SashShape['bd']>) => { const bdRect = (bd: NonNullable<SashShape['bd']>) => {
if (!bd.from || !bd.to) return null
const r = { const r = {
x0: Math.min(bd.from.x, bd.to.x), y0: Math.min(bd.from.y, bd.to.y), x0: Math.min(bd.from.x, bd.to.x), y0: Math.min(bd.from.y, bd.to.y),
x1: Math.max(bd.from.x, bd.to.x), y1: Math.max(bd.from.y, bd.to.y), x1: Math.max(bd.from.x, bd.to.x), y1: Math.max(bd.from.y, bd.to.y),
} }
return !bd.hidden && r.x1 - r.x0 > 10 && r.y1 - r.y0 > 10 ? r : null 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' ? 'sliding' : 'casement') as 'sliding' | 'casement' const kindOf = (sub: SashShape) => (sub.type === 'DoubleSash' ? 'sliding' : 'casement') as 'sliding' | 'casement'
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) => {
+8 -4
View File
@@ -109,10 +109,14 @@ const saveName = ref('')
const saving = ref(false) const saving = ref(false)
const canvasRef = ref() const canvasRef = ref()
let uidSeq = 1 let uidSeq = 1
const rev = ref(0)
const combinedJson = computed(() => const combinedJson = computed(() => {
shapes.value.length ? JSON.stringify({ s: { cs: 60, cjs: 100 }, cp: [], sm: shapes.value, void rev.value // 画布原地变异几何后通过版本号强制重算(驱动 3D 预览重建)
epi: [], sbf: [], wlc: '', wall: [], scale: 0, note: [] }) : '') if (!shapes.value.length) return ''
return JSON.stringify({ s: { cs: 60, cjs: 100 }, cp: [], sm: shapes.value,
epi: [], sbf: [], wlc: '', wall: [], scale: 0, note: [], rev: rev.value })
})
// ---------- 样板库 ---------- // ---------- 样板库 ----------
async function loadLib() { async function loadLib() {
@@ -198,7 +202,7 @@ function syncSelSize() {
if (bb) { selSize.w = Math.round(bb.x1 - bb.x0); selSize.h = Math.round(bb.y1 - bb.y0) } if (bb) { selSize.w = Math.round(bb.x1 - bb.x0); selSize.h = Math.round(bb.y1 - bb.y0) }
} }
function onCanvasChange() { refreshMeta(); syncSelSize() } function onCanvasChange() { refreshMeta(); syncSelSize(); rev.value++ }
function onSelResize() { function onSelResize() {
if (selSize.w >= 300 && selSize.h >= 300) canvasRef.value?.resizeSelected(selSize.w, selSize.h) if (selSize.w >= 300 && selSize.h >= 300) canvasRef.value?.resizeSelected(selSize.w, selSize.h)