fix: v2.11 3D 全开/全关状态错误根治——分割树重放语义破译与扇五金解析重建

- polyId 语义破译: {idx,pos}=(分裂子树节点id,子区域序), 节点id=sp 0基序号, child0=法线指向侧
- replayShape 重写为树重放(锚点兜底), 704/31594/31729 全引用精确命中官方平面图, 798 扇矩形 100% 有效
- 坐标系修正: 模型 y 向下, 3D 翻转 y, 布局与原图一致
- 扇五金解析: Slide/DoubleSash=推拉, hm.openDirection 定铰链侧(含上悬), hm.openToward 定内外开, od=全开幅
- 移除错误的滑扇附着兜底; 2D 编辑器写回 polyId 同步树寻址
- 验证: 全关/46%/100% 三态截图 + ui-shots 6 页 + validate3d 全量 0 无效
This commit is contained in:
HHY
2026-09-23 04:09:40 -07:00
parent fbef67e450
commit c2c6359a65
17 changed files with 452 additions and 63 deletions
+125
View File
@@ -0,0 +1,125 @@
// 分割树切片顺序枚举实证: node scripts/analyze-split-order.mjs
// 候选规则: A=遍历序c1→c2(现实现) B=遍历序c2→c1 C=min侧先(竖左/横上) D=max侧先
// 评分: 扇 polyId.idx 与玻璃 fls pid.idx 落在最终区域数内的比例 + 704/31594 样本特检
import fs from 'fs'
import path from 'path'
const DIR = 'D:/Code/huamenchuang/data/windoor-drawings'
const bboxOf = pts => ({
x0: Math.min(...pts.map(p => p.x)), y0: Math.min(...pts.map(p => p.y)),
x1: Math.max(...pts.map(p => p.x)), y1: Math.max(...pts.map(p => p.y)),
})
const spAxis = sp => {
const n = sp.line?.norm ?? { x: 1, y: 0 }
return Math.abs(n.x) >= Math.abs(n.y) ? 'v' : 'h'
}
const spPos = sp => (spAxis(sp) === 'v' ? sp.line.pt.x : sp.line.pt.y)
function splitPoly(region, axis, pos, rule) {
const pts = region.map(s => s.ps)
const n = pts.length
const get = p => (axis === 'v' ? p.x : p.y)
const crossings = []
for (let i = 0; i < n; i++) {
const a = pts[i], b = pts[(i + 1) % n]
const da = get(a) - pos, db = get(b) - pos
if ((da < 0 && db > 0) || (da > 0 && db < 0)) {
const t = da / (da - db)
crossings.push({ i, p: axis === 'v' ? { x: pos, y: a.y + (b.y - a.y) * t } : { x: a.x + (b.x - a.x) * t, y: pos } })
}
}
if (crossings.length !== 2) return null
const [c1, c2] = crossings
if (c1.i === c2.i) return null
const collect = (from, to) => {
const ps = [from.p]
for (let k = (from.i + 1) % n; ; k = (k + 1) % n) {
ps.push(pts[k])
if (k === to.i) break
}
ps.push(to.p)
return ps.filter((p, i) => i === 0 || Math.abs(p.x - ps[i - 1].x) > 0.01 || Math.abs(p.y - ps[i - 1].y) > 0.01)
}
const toSegs = ps => ps.map((p, i) => ({ ps: p, pe: ps[(i + 1) % ps.length], name: 'segment' }))
let A = collect(c1, c2), B = collect(c2, c1)
if (A.length < 3 || B.length < 3) return null
const pa = toSegs(A), pb = toSegs(B)
if (rule === 'A') return [pa, pb]
if (rule === 'B') return [pb, pa]
const ba = bboxOf(A), bb = bboxOf(B)
const minFirst = axis === 'v' ? ba.x0 <= bb.x0 : ba.y0 <= bb.y0
return minFirst ? (rule === 'C' ? [pa, pb] : [pb, pa]) : (rule === 'C' ? [pb, pa] : [pa, pb])
}
function replay(shape, rule, trace) {
const base = (shape.polygon?.polygon ?? [])
.map(r => r.filter(s => s?.ps && s?.pe && [s.ps.x, s.ps.y, s.pe.x, s.pe.y].every(Number.isFinite))
.map(s => ({ ps: { ...s.ps }, pe: { ...s.pe } })))
.filter(r => r.length >= 3)
const regions = base.map(r => r)
for (const sp of (shape.mm?.sp ?? [])) {
if (!sp?.line?.pt || !sp.line.norm) continue
const axis = spAxis(sp), pos = spPos(sp)
let idx = sp.polyId?.idx ?? -1
const rawIdx = idx
if (!Number.isInteger(idx) || idx < 0 || idx >= regions.length) idx = 0
const target = regions[idx]
if (!target || target.length < 3) continue
const bb = bboxOf(target.map(s => s.ps))
const lo = axis === 'v' ? bb.x0 : bb.y0, hi = axis === 'v' ? bb.x1 : bb.y1
const inside = pos > lo + 0.5 && pos < hi - 0.5
const pieces = inside ? splitPoly(target, axis, pos, rule) : null
if (trace) {
console.log(` sp axis=${axis} pos=${pos.toFixed(1)} polyId=${JSON.stringify(sp.polyId)} rawIdx=${rawIdx}->${idx}`
+ ` 目标bbox=${JSON.stringify(bb)} ${inside ? '内部✓' : '退化✗'} ${pieces ? '切分✓' : '切分✗'}`)
}
if (!pieces) continue
regions.splice(idx, 1, pieces[0], pieces[1])
}
return regions
}
const files = fs.readdirSync(DIR).filter(f => f.endsWith('.json'))
const shapes = []
for (const f of files) {
const row = JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8'))
const dd = typeof row.drawing_data === 'string' ? JSON.parse(row.drawing_data) : row.drawing_data
dd.sm?.forEach(shape => shapes.push({ f, shape }))
}
for (const rule of ['A', 'B', 'C', 'D']) {
let sashOk = 0, sashN = 0, glassOk = 0, glassN = 0
for (const { f, shape } of shapes) {
const regions = replay(shape, rule)
for (const sub of (shape.sm ?? [])) {
const idx = sub.polyId?.idx
if (idx == null || idx < 0 || sub.enabled === false) continue
sashN++
if (idx < regions.length) sashOk++
}
for (const fl of (shape.mm?.fls?.fls ?? [])) {
const idx = fl.pid?.idx
if (idx == null || idx < 0) continue
glassN++
if (idx < regions.length) glassOk++
}
if (f === '704.json') {
const r = regions[sub704Idx(shape)]
console.log(` [${rule}] 704 扇挂接区域:`, r ? JSON.stringify(bboxOf(r.map(s => s.ps))) : '越界')
}
if (f === '31594.json') {
console.log(` [${rule}] 31594 区域数:`, regions.length, '(应为 7)')
if (process.argv[2] === 'trace') {
console.log(` [${rule}] 31594 逐条重放:`)
replay(shape, rule, true)
}
}
}
console.log(`规则${rule}: 扇引用有效 ${sashOk}/${sashN} (${(100 * sashOk / sashN).toFixed(1)}%) | 玻璃引用有效 ${glassOk}/${glassN} (${(100 * glassOk / glassN).toFixed(1)}%)`)
}
function sub704Idx(shape) {
const sub = (shape.sm ?? []).find(s => s.enabled !== false)
return sub?.polyId?.idx ?? -1
}
+127
View File
@@ -0,0 +1,127 @@
// 分割树重放语义实证分析: node scripts/analyze-split-tree.mjs
// V0=现实现(切片顺序跟随多边形遍历) vs V1=规范化排序(竖切左先/横切上先, 屏幕坐标y向下)
// 验证信号: 扇 polyId.idx 与玻璃 fls pid.idx 是否落在最终区域索引范围内
import fs from 'fs'
import path from 'path'
const DIR = 'D:/Code/huamenchuang/data/windoor-drawings'
// ---- 与 drawing.ts 相同的底层工具 ----
const bboxOf = pts => ({
x0: Math.min(...pts.map(p => p.x)), y0: Math.min(...pts.map(p => p.y)),
x1: Math.max(...pts.map(p => p.x)), y1: Math.max(...pts.map(p => p.y)),
})
const spAxis = sp => {
const n = sp.line?.norm ?? { x: 1, y: 0 }
return Math.abs(n.x) >= Math.abs(n.y) ? 'v' : 'h'
}
const spPos = sp => (spAxis(sp) === 'v' ? sp.line.pt.x : sp.line.pt.y)
function splitPolySegments(region, axis, pos, sortPieces) {
const pts = region.map(s => s.ps)
const n = pts.length
const get = p => (axis === 'v' ? p.x : p.y)
const crossings = []
for (let i = 0; i < n; i++) {
const a = pts[i], b = pts[(i + 1) % n]
const da = get(a) - pos, db = get(b) - pos
if ((da < 0 && db > 0) || (da > 0 && db < 0)) {
const t = da / (da - db)
crossings.push({ i, p: axis === 'v' ? { x: pos, y: a.y + (b.y - a.y) * t } : { x: a.x + (b.x - a.x) * t, y: pos } })
}
}
if (crossings.length !== 2) return null
const [c1, c2] = crossings
if (c1.i === c2.i) return null
const collect = (from, to) => {
const ps = [from.p]
for (let k = (from.i + 1) % n; ; k = (k + 1) % n) {
ps.push(pts[k])
if (k === to.i) break
}
ps.push(to.p)
return ps.filter((p, i) => i === 0 || Math.abs(p.x - ps[i - 1].x) > 0.01 || Math.abs(p.y - ps[i - 1].y) > 0.01)
}
const toSegs = ps => ps.map((p, i) => ({ ps: p, pe: ps[(i + 1) % ps.length], name: 'segment' }))
const pieces = [toSegs(collect(c1, c2)), toSegs(collect(c2, c1))].filter(ps => ps.length >= 3)
if (pieces.length < 2) return null
if (sortPieces) {
pieces.sort((A, B) => {
const ca = bboxOf(A.map(s => s.ps)), cb = bboxOf(B.map(s => s.ps))
return axis === 'v' ? ca.x0 - cb.x0 : ca.y0 - cb.y0
})
}
return { a: pieces[0], b: pieces[1] }
}
function replay(shape, sortPieces) {
const base = (shape.polygon?.polygon ?? [])
.map(r => r.filter(s => s?.ps && s?.pe && [s.ps.x, s.ps.y, s.pe.x, s.pe.y].every(Number.isFinite))
.map(s => ({ ps: { ...s.ps }, pe: { ...s.pe } })))
.filter(r => r.length >= 3)
const regions = base.map(r => r)
for (const sp of (shape.mm?.sp ?? [])) {
if (!sp?.line?.pt || !sp.line.norm) continue
const axis = spAxis(sp), pos = spPos(sp)
let idx = sp.polyId?.idx ?? -1
if (!Number.isInteger(idx) || idx < 0 || idx >= regions.length) idx = 0
const target = regions[idx]
if (!target || target.length < 3) continue
const bb = bboxOf(target.map(s => s.ps))
const lo = axis === 'v' ? bb.x0 : bb.y0, hi = axis === 'v' ? bb.x1 : bb.y1
if (pos <= lo + 0.5 || pos >= hi - 0.5) continue
const split = splitPolySegments(target, axis, pos, sortPieces)
if (!split) continue
regions.splice(idx, 1, split.a, split.b)
}
return regions
}
// ---- 全量扫描 ----
const files = fs.readdirSync(DIR).filter(f => f.endsWith('.json'))
let sashStat = { v0ok: 0, v1ok: 0, total: 0, v0miss: [], v1miss: [] }
let glassStat = { v0ok: 0, v1ok: 0, total: 0, v0miss: [], v1miss: [] }
const hmDist = {}
let sample704 = null
for (const f of files) {
const row = JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8'))
const dd = typeof row.drawing_data === 'string' ? JSON.parse(row.drawing_data) : row.drawing_data
for (const shape of (dd.sm ?? [])) {
const r0 = replay(shape, false)
const r1 = replay(shape, true)
for (const sub of (shape.sm ?? [])) {
const e = sub.enabled === false ? sub.sashes ?? [] : [sub]
if (!e.length) continue
sashStat.total++
const idx = sub.polyId?.idx
if (idx == null || idx < 0) continue // 未挂接, 走兜底
if (idx < r0.length) sashStat.v0ok++; else sashStat.v0miss.push(`${f} idx=${idx} n=${r0.length}`)
if (idx < r1.length) sashStat.v1ok++; else sashStat.v1miss.push(`${f} idx=${idx} n=${r1.length}`)
const hm = sub.hm ?? {}
const key = `type=${sub.type} slide=${hm.slide ?? '-'} dir=${hm.openDirection ?? '-'} toward=${hm.openToward ?? '-'} hinge=${hm.hingeType ?? '-'} od=${sub.od ?? '-'}`
hmDist[key] = (hmDist[key] ?? 0) + 1
}
for (const fl of (shape.mm?.fls?.fls ?? [])) {
glassStat.total++
const idx = fl.pid?.idx
if (idx == null || idx < 0) continue
if (idx < r0.length) glassStat.v0ok++; else glassStat.v0miss.push(`${f}`)
if (idx < r1.length) glassStat.v1ok++; else glassStat.v1miss.push(`${f}`)
}
if (f === '704.json' && !sample704) {
sample704 = r1.map(r => bboxOf(r.map(s => s.ps)))
}
}
}
console.log('样板数:', files.length)
console.log('扇引用: 总数', sashStat.total, '| V0 有效', sashStat.v0ok, '| V1 有效', sashStat.v1ok)
console.log('V0 越界样例:', sashStat.v0miss.slice(0, 8))
console.log('V1 越界样例:', sashStat.v1miss.slice(0, 8))
console.log('玻璃引用: 总数', glassStat.total, '| V0 有效', glassStat.v0ok, '| V1 有效', glassStat.v1ok)
console.log('玻璃 V0 越界数:', glassStat.v0miss.length, '| V1 越界数:', glassStat.v1miss.length)
console.log('\n704 V1 区域:')
sample704?.forEach((b, i) => console.log(' region', i, JSON.stringify(b)))
console.log('\nhm 五金字段分布 (按频次):')
Object.entries(hmDist).sort((a, b) => b[1] - a[1]).forEach(([k, v]) => console.log(' ', v, k))
+1 -1
View File
@@ -3,7 +3,7 @@
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 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'));