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 无效
@@ -1,5 +1,7 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
- [2026-09-23 04:45] FIX: v2.11 3D 全开/全关状态错误根治——分割树重放语义破译与扇五金解析重建(官方参考图逐样本实证)。①polyId 语义破译: {idx,pos}=(分裂子树节点id,子区域序), 节点id=sp 0基序号(节点0=根), child0=法线指向侧, 同一中梃跨区域记录多条, 分割线锚点=line.pt(所辖梃段中点)作定位兜底; replayShape 重写为树重放+regions/leafRefs/resolveRef 返回; 704(左下平开扇)/31594(7格双纱扇)/31729(上滑扇) 全部引用逐项精确命中官方平面图, 全量 798 扇矩形 100% 有效(validate3d 0 无效几何); ②坐标系修正: 模型坐标 y 向下(屏幕系), 3D 构建翻转 y, 布局上下关系与原图一致; ③扇开合五金解析: kind 由类型判定(Slide/DoubleSash=推拉), 铰链侧=hm.openDirection(left/right/up=上悬), 内外开=hm.openToward, 全开幅=od(90°, 平开扇垂直于墙面), 替换原 cos(od) 猜铰链侧与 32/40° 写死开幅; ④移除"滑扇附着上半区"兜底(v2.7 在错误重放上得出, 实为固定扇被误驱动开合), 2D 编辑器加梃写回 polyId 同步树寻址(leafRefs); ⑤验证: 真实浏览器截图(全关=官方图纸布局/46% 绕左铰链外开/100% 全开垂直)+ui-shots 6 页无报错 (Files: frontend/src/types/drawing.ts, frontend/src/components/WindoorThreed.vue, frontend/src/components/WindoorCanvas.vue, frontend/scripts/validate3d.cjs, frontend/scripts/analyze-split-tree.mjs, frontend/scripts/analyze-split-order.mjs, shots/*)
|
||||||
|
|
||||||
- [2026-09-23 03:26] FIX: v2.10 3D 预览"窗户嵌入墙体内"双重根因修复。①室外墙体定位: 原墙体(3×型深厚)与窗体同在 z=0 居中, 整窗埋入墙厚内部(洞口内只见深处玻璃); 改为墙厚 2.4×型深(≈240 实墙比例)且外墙面退至窗框外表面之后(窗框外露 30% 型深, 对齐外平开窗贴墙安装), 窗洞净空由固定 0.05 改为自适应外框面宽 fwMax+0.015 安装缝(防宽面型材边缘被墙面遮挡); ②平开扇铰链偏移符号修复: 扇子元素为面板中心局部坐标, 绕铰链边旋转应平移 +(cxr-hingeX)(原负号致面板整体多偏移一个扇宽——关闭态扇面板跑出框外右侧、开启态扇悬空脱离窗框, 实测样板704 偏移恰为扇宽 1137mm 与截图吻合); ③草地垫对齐新墙面、网格降至墙脚消除穿墙线; 真实浏览器三态截图验证(45°全关/正面全关/46%开合: 框体凸出外墙、扇全在框内、开扇贴框外旋✓) (Files: frontend/src/components/WindoorThreed.vue, frontend/scripts/verify-wall.mjs, shots/verify-wall-*.png)
|
- [2026-09-23 03:26] FIX: v2.10 3D 预览"窗户嵌入墙体内"双重根因修复。①室外墙体定位: 原墙体(3×型深厚)与窗体同在 z=0 居中, 整窗埋入墙厚内部(洞口内只见深处玻璃); 改为墙厚 2.4×型深(≈240 实墙比例)且外墙面退至窗框外表面之后(窗框外露 30% 型深, 对齐外平开窗贴墙安装), 窗洞净空由固定 0.05 改为自适应外框面宽 fwMax+0.015 安装缝(防宽面型材边缘被墙面遮挡); ②平开扇铰链偏移符号修复: 扇子元素为面板中心局部坐标, 绕铰链边旋转应平移 +(cxr-hingeX)(原负号致面板整体多偏移一个扇宽——关闭态扇面板跑出框外右侧、开启态扇悬空脱离窗框, 实测样板704 偏移恰为扇宽 1137mm 与截图吻合); ③草地垫对齐新墙面、网格降至墙脚消除穿墙线; 真实浏览器三态截图验证(45°全关/正面全关/46%开合: 框体凸出外墙、扇全在框内、开扇贴框外旋✓) (Files: frontend/src/components/WindoorThreed.vue, frontend/scripts/verify-wall.mjs, shots/verify-wall-*.png)
|
||||||
|
|
||||||
- [2026-09-23 13:35] FEAT: v2.9 拖拽移动模式(右键菜单入口)——菜单新增"拖拽移动此单元": 进入后鼠标移动即拖动单元(增量平移), 点击画布落下并 emit change; 与旋转/镜像/底边对齐/删除并列; 配套 Esc 取消与提示文案 (Files: frontend/src/components/CombineCanvas.vue, frontend/scripts/verify-move.mjs)
|
- [2026-09-23 13:35] FEAT: v2.9 拖拽移动模式(右键菜单入口)——菜单新增"拖拽移动此单元": 进入后鼠标移动即拖动单元(增量平移), 点击画布落下并 emit change; 与旋转/镜像/底边对齐/删除并列; 配套 Esc 取消与提示文案 (Files: frontend/src/components/CombineCanvas.vue, frontend/scripts/verify-move.mjs)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { profileGeo, geometryIsValid } = require('./.val/pg.cjs');
|
const { profileGeo, geometryIsValid } = require('./.val/pg.cjs');
|
||||||
const { parseDrawing, inferSashGlyphs, replayShape } = require('./.val/drawing.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')
|
const files = fs.readdirSync('D:/Code/huamenchuang/data/windoor-drawings')
|
||||||
.filter(f => f.endsWith('.dxf') === false && f.endsWith('.json'));
|
.filter(f => f.endsWith('.dxf') === false && f.endsWith('.json'));
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ let resizeDrag: null | { axis: 'w' | 'h' | 'wh'; startW: number; startH: number;
|
|||||||
let moveDrag: null | { shapeIdx: number; spIdx: number; startPos: number; startX: number; startY: number; snapshot: string } = null
|
let moveDrag: null | { shapeIdx: number; spIdx: number; startPos: number; startX: number; startY: number; snapshot: string } = null
|
||||||
const selected = ref<{ shapeIdx: number; spIdx: number } | null>(null)
|
const selected = ref<{ shapeIdx: number; spIdx: number } | null>(null)
|
||||||
|
|
||||||
type Replay = { regions: Segment[][]; clips: { sp: SplitLine; bb: { x0: number; y0: number; x1: number; y1: number } }[] }
|
type Replay = ReturnType<typeof replayShape>
|
||||||
let replays: Replay[] = []
|
let replays: Replay[] = []
|
||||||
|
|
||||||
let lastTransform = { scale: 1, midX: 0, midY: 0, W: 0, H: 0 }
|
let lastTransform = { scale: 1, midX: 0, midY: 0, W: 0, H: 0 }
|
||||||
@@ -245,8 +245,8 @@ function drawFrame(
|
|||||||
ctx.strokeRect(x0 + inset, y0 + inset, (x1 - x0) - inset * 2, (y1 - y0) - inset * 2)
|
ctx.strokeRect(x0 + inset, y0 + inset, (x1 - x0) - inset * 2, (y1 - y0) - inset * 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 扇(推断: 有效bd → polyId区域(双扇对半) → 帧bbox按扇数均分) — 共享推断模块
|
// 扇(推断: 有效bd → polyId挂接区域 → 帧bbox按扇数均分) — 共享推断模块
|
||||||
const glyphs = inferSashGlyphs(shape, rp.regions).sashes
|
const glyphs = inferSashGlyphs(shape, rp).sashes
|
||||||
for (const g of glyphs) {
|
for (const g of glyphs) {
|
||||||
const bx0 = tx(g.x0), bx1 = tx(g.x1), by0 = ty(g.y1), by1 = ty(g.y0)
|
const bx0 = tx(g.x0), bx1 = tx(g.x1), by0 = ty(g.y1), by1 = ty(g.y0)
|
||||||
const w = bx1 - bx0, h = by1 - by0
|
const w = bx1 - bx0, h = by1 - by0
|
||||||
@@ -372,7 +372,7 @@ function onClick(e: MouseEvent) {
|
|||||||
mm.sp = mm.sp ?? []
|
mm.sp = mm.sp ?? []
|
||||||
mm.sp.push({
|
mm.sp.push({
|
||||||
line: { pt: mid, norm, name: 'line' },
|
line: { pt: mid, norm, name: 'line' },
|
||||||
polyId: { idx: ri, pos: rp.regions.length },
|
polyId: rp.leafRefs[ri] ?? { idx: ri, pos: rp.regions.length },
|
||||||
alignType: 0, equalSplit: 0, kw: axis === 'v', ws: false, reinforced: false, db: false,
|
alignType: 0, equalSplit: 0, kw: axis === 'v', ws: false, reinforced: false, db: false,
|
||||||
} as any)
|
} as any)
|
||||||
replayOne(si)
|
replayOne(si)
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ let dragging: { x: number; y: number; button: number } | null = null
|
|||||||
let sashAnims: {
|
let sashAnims: {
|
||||||
group: THREE.Group
|
group: THREE.Group
|
||||||
kind: 'sliding' | 'casement'
|
kind: 'sliding' | 'casement'
|
||||||
|
axis?: 'x' | 'y'
|
||||||
baseX: number
|
baseX: number
|
||||||
slideDir: number
|
slideDir: number
|
||||||
hingeSign: number
|
hingeSign: number
|
||||||
@@ -247,7 +248,8 @@ function build(drawing: DrawingData) {
|
|||||||
const sashRoot = new THREE.Group()
|
const sashRoot = new THREE.Group()
|
||||||
sceneRoot.add(frameGroup)
|
sceneRoot.add(frameGroup)
|
||||||
sceneRoot.add(sashRoot)
|
sceneRoot.add(sashRoot)
|
||||||
const world = (x: number, y: number) => ({ x: (x - cx) * S, y: (y - cy) * S })
|
// 模型坐标 y 向下(屏幕系), Three.js y 向上: 翻转保证布局上下关系与原图一致
|
||||||
|
const world = (x: number, y: number) => ({ x: (x - cx) * S, y: (cy - y) * S })
|
||||||
|
|
||||||
for (const shape of drawing.sm) {
|
for (const shape of drawing.sm) {
|
||||||
const { regions, sashes } = inferSashGlyphs(shape)
|
const { regions, sashes } = inferSashGlyphs(shape)
|
||||||
@@ -283,7 +285,7 @@ function build(drawing: DrawingData) {
|
|||||||
if (used.has(ri)) return
|
if (used.has(ri)) return
|
||||||
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 = (cy - (bb.y0 + bb.y1) / 2) * S
|
||||||
if (![w, h, mx, my].every(Number.isFinite) || w <= 0 || h <= 0) return
|
if (![w, h, mx, my].every(Number.isFinite) || w <= 0 || h <= 0) return
|
||||||
const glass = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.008), gm)
|
const glass = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.008), gm)
|
||||||
glass.position.set(mx, my, 0)
|
glass.position.set(mx, my, 0)
|
||||||
@@ -299,7 +301,7 @@ function build(drawing: DrawingData) {
|
|||||||
panels.forEach(({ rect, sash: s }, k) => {
|
panels.forEach(({ rect, sash: s }, k) => {
|
||||||
const w = (rect.x1 - rect.x0) * S, h = (rect.y1 - rect.y0) * S
|
const w = (rect.x1 - rect.x0) * S, h = (rect.y1 - rect.y0) * S
|
||||||
const cxr = ((rect.x0 + rect.x1) / 2 - cx) * S
|
const cxr = ((rect.x0 + rect.x1) / 2 - cx) * S
|
||||||
const cyr = ((rect.y0 + rect.y1) / 2 - cy) * S
|
const cyr = (cy - (rect.y0 + rect.y1) / 2) * S
|
||||||
if (![w, h, cxr, cyr].every(Number.isFinite) || w < 0.02 || h < 0.02) return
|
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 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)
|
||||||
@@ -317,16 +319,26 @@ function build(drawing: DrawingData) {
|
|||||||
sashAnims.push({ group, kind: 'sliding', baseX: cxr, slideDir: k % 2 === 0 ? 1 : -1,
|
sashAnims.push({ group, kind: 'sliding', baseX: cxr, slideDir: k % 2 === 0 ? 1 : -1,
|
||||||
hingeSign: 1, maxAng: 0, w })
|
hingeSign: 1, maxAng: 0, w })
|
||||||
} else {
|
} else {
|
||||||
const odRad = ((s.od ?? 90) * Math.PI) / 180
|
// 平开/上悬: 铰链侧与内外开取自五金 hm.openDirection/openToward, 开幅取 od(全开=开向角度)
|
||||||
const hingeLeft = Math.cos(odRad) < 0
|
const outSign = s.openToward === 'inward' ? -1 : 1 // +1=外开(朝观察者)
|
||||||
const hingeX = hingeLeft ? cxr - w / 2 : cxr + w / 2
|
const maxAng = Math.min(100, Number.isFinite(s.od) && s.od > 0 ? s.od : (s.isDoor ? 75 : 90)) * Math.PI / 180
|
||||||
group.position.set(hingeX, cyr, 0)
|
const dir = s.openDirection ?? 'left'
|
||||||
// 子元素为面板中心局部坐标: 平移 +(cxr-hingeX) 使 t=0 世界位置不变, 仅将旋转支点移到铰链边
|
if (dir.startsWith('up')) {
|
||||||
group.children.forEach(child => { child.position.x += (cxr - hingeX) })
|
// 上悬窗: 绕顶边旋转
|
||||||
const maxAng = (s.isDoor ? 75 : s.wtf ? 40 : 32) * Math.PI / 180
|
const hingeY = cyr + h / 2
|
||||||
const dirSign = s.openToward === 'inward' ? 1 : -1
|
group.position.set(cxr, hingeY, 0)
|
||||||
sashAnims.push({ group, kind: 'casement', baseX: hingeX, slideDir: 0,
|
group.children.forEach(child => { child.position.y += (cyr - hingeY) })
|
||||||
hingeSign: dirSign, maxAng, w })
|
sashAnims.push({ group, kind: 'casement', axis: 'x', baseX: cxr, slideDir: 0,
|
||||||
|
hingeSign: -outSign, maxAng, w })
|
||||||
|
} else {
|
||||||
|
const hingeLeft = dir.startsWith('left')
|
||||||
|
const hingeX = hingeLeft ? cxr - w / 2 : cxr + w / 2
|
||||||
|
group.position.set(hingeX, cyr, 0)
|
||||||
|
// 子元素为面板中心局部坐标: 平移 +(cxr-hingeX) 使 t=0 世界位置不变, 仅将旋转支点移到铰链边
|
||||||
|
group.children.forEach(child => { child.position.x += (cxr - hingeX) })
|
||||||
|
sashAnims.push({ group, kind: 'casement', axis: 'y', baseX: hingeX, slideDir: 0,
|
||||||
|
hingeSign: (hingeLeft ? -1 : 1) * outSign, maxAng, w })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -418,6 +430,8 @@ function applyOpen(t: number) {
|
|||||||
for (const a of sashAnims) {
|
for (const a of sashAnims) {
|
||||||
if (a.kind === 'sliding') {
|
if (a.kind === 'sliding') {
|
||||||
a.group.position.x = a.baseX + a.slideDir * a.w * 0.42 * t
|
a.group.position.x = a.baseX + a.slideDir * a.w * 0.42 * t
|
||||||
|
} else if (a.axis === 'x') {
|
||||||
|
a.group.rotation.x = a.hingeSign * a.maxAng * t
|
||||||
} else {
|
} else {
|
||||||
a.group.rotation.y = a.hingeSign * a.maxAng * t
|
a.group.rotation.y = a.hingeSign * a.maxAng * t
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,17 +130,44 @@ export function spPos(sp: SplitLine): number {
|
|||||||
return spAxis(sp) === 'v' ? sp.line.pt.x : sp.line.pt.y
|
return spAxis(sp) === 'v' ? sp.line.pt.x : sp.line.pt.y
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 点是否在多边形内(射线法) */
|
||||||
* 分割树重放(逆向自原站 shapeManager 的分割模型):
|
export function pointInRegion(p: Pt, region: Segment[]): boolean {
|
||||||
* polygon.polygon 为基底多边形, mm.sp[] 为分割树 — 每条 sp 通过 polyId.idx 指向
|
let inside = false
|
||||||
* 其作用区域, 按顺序应用后得到最终窗格。重放时:
|
const pts = regionPoints(region)
|
||||||
* - idx 越界/为-1 → 作用于基底区域0
|
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
|
||||||
* - 分割坐标落在目标区域边上(退化)或目标非凸 → 跳过(no-op), 该梃按目标bbox裁剪绘制
|
const xi = pts[i].x, yi = pts[i].y, xj = pts[j].x, yj = pts[j].y
|
||||||
*/
|
if ((yi > p.y) !== (yj > p.y) && p.x < ((xj - xi) * (p.y - yi)) / (yj - yi) + xi) inside = !inside
|
||||||
export function replayShape(shape: FrameShape): {
|
}
|
||||||
|
return inside
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分割树节点: kids 为空 = 叶区域(含 segs); 分裂后 segs 清空、kids 恒为 2 */
|
||||||
|
interface SpNode { segs: Segment[]; kids: SpNode[] }
|
||||||
|
|
||||||
|
function dfsLeaves(node: SpNode, out: SpNode[]) {
|
||||||
|
if (node.kids.length === 0) { out.push(node); return }
|
||||||
|
node.kids.forEach(k => dfsLeaves(k, out))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重放结果: regions=叶区域(DFS 序, 供渲染); resolveRef 还原 polyId{idx,pos} 寻址的叶区域 */
|
||||||
|
export interface ReplayResult {
|
||||||
regions: Segment[][]
|
regions: Segment[][]
|
||||||
clips: { sp: SplitLine; bb: { x0: number; y0: number; x1: number; y1: number } }[]
|
clips: { sp: SplitLine; bb: { x0: number; y0: number; x1: number; y1: number } }[]
|
||||||
} {
|
/** 每个叶区域的寻址(与 regions 平行): {idx: 分裂子树节点id, pos: 子区域序}; 根=0 */
|
||||||
|
leafRefs: { idx: number; pos: number }[]
|
||||||
|
resolveRef: (idx: number, pos?: number) => Segment[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分割树重放(逆向自原站 shapeManager 的分割模型, 语义经 704/31594/31729 全引用实证):
|
||||||
|
* - polygon.polygon 为基底多边形, mm.sp[] 按序记录每次分割
|
||||||
|
* - sp.polyId{idx,pos} = 分裂第 idx 号子树节点的第 pos 个子区域(节点id=第几次分裂,根=0);
|
||||||
|
* 同一中梃跨区域时按所跨区域记录多条
|
||||||
|
* - 分割产生的 child0 = 法线(norm)指向侧, child1 = 另一侧
|
||||||
|
* - 分割线锚点(line.pt=该区域内的梃段中点)为定位兜底: polyId 失配时取锚点所在叶区域
|
||||||
|
* - 分割坐标落在目标区域边上(退化)或非凸 → 跳过(no-op), 该梃按目标bbox裁剪绘制
|
||||||
|
*/
|
||||||
|
export function replayShape(shape: FrameShape): ReplayResult {
|
||||||
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
|
.filter(s => s && typeof s === 'object' && s.ps && s.pe
|
||||||
@@ -148,29 +175,103 @@ export function replayShape(shape: FrameShape): {
|
|||||||
&& Number.isFinite(s.pe.x) && Number.isFinite(s.pe.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 root: SpNode = { segs: base[0] ?? [], kids: [] }
|
||||||
|
const extraRoots = base.slice(1).map(segs => ({ segs, kids: [] }))
|
||||||
|
const idMap = new Map<number, SpNode>()
|
||||||
const clips: { sp: SplitLine; bb: { x0: number; y0: number; x1: number; y1: number } }[] = []
|
const clips: { sp: SplitLine; bb: { x0: number; y0: number; x1: number; y1: number } }[] = []
|
||||||
|
const allLeaves = (): SpNode[] => {
|
||||||
|
const out: SpNode[] = []
|
||||||
|
dfsLeaves(root, out); extraRoots.forEach(r => dfsLeaves(r, out))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
let spIdx = 0
|
||||||
for (const sp of (shape.mm?.sp ?? []) as SplitLine[]) {
|
for (const sp of (shape.mm?.sp ?? []) as SplitLine[]) {
|
||||||
|
// 节点 id = sp 的 0 基序号: 第 0 条分裂结果=根(节点0), 第 k 条结果=节点k
|
||||||
|
const myId = spIdx
|
||||||
|
spIdx++
|
||||||
if (!sp?.line?.pt || !sp.line.norm) continue
|
if (!sp?.line?.pt || !sp.line.norm) continue
|
||||||
const axis = spAxis(sp)
|
const axis = spAxis(sp)
|
||||||
const pos = spPos(sp)
|
const pos = spPos(sp)
|
||||||
let idx = sp.polyId?.idx ?? -1
|
// 目标区域: polyId{idx,pos} 树寻址优先, 锚点所在叶兜底
|
||||||
if (!Number.isInteger(idx) || idx < 0 || idx >= regions.length) idx = 0
|
let target: SpNode | null = null
|
||||||
const target = regions[idx]
|
const nid = sp.polyId?.idx
|
||||||
if (!target || target.length < 3) { continue }
|
const cpos = sp.polyId?.pos
|
||||||
const bb = bboxOf(regionPoints(target))
|
if (Number.isInteger(nid) && (nid as number) >= 0) {
|
||||||
|
const node = nid === 0 ? root : idMap.get(nid as number)
|
||||||
|
if (node) {
|
||||||
|
if (node.kids.length === 0) target = node
|
||||||
|
else if (Number.isInteger(cpos) && (cpos as number) >= 0 && (cpos as number) < node.kids.length) {
|
||||||
|
target = node.kids[cpos as number]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!target) target = allLeaves().find(nd => pointInRegion(sp.line.pt as Pt, nd.segs)) ?? null
|
||||||
|
if (!target || target.segs.length < 3) continue
|
||||||
|
const bb = bboxOf(regionPoints(target.segs))
|
||||||
const lo = axis === 'v' ? bb.x0 : bb.y0
|
const lo = axis === 'v' ? bb.x0 : bb.y0
|
||||||
const hi = axis === 'v' ? bb.x1 : bb.y1
|
const hi = axis === 'v' ? bb.x1 : bb.y1
|
||||||
if (pos <= lo + 0.5 || pos >= hi - 0.5) {
|
if (pos <= lo + 0.5 || pos >= hi - 0.5) {
|
||||||
clips.push({ sp, bb }) // 退化: 边线上的记录, 仅用于绘制
|
clips.push({ sp, bb }) // 退化: 边线上的记录, 仅用于绘制
|
||||||
|
idMap.set(myId, target)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const split = splitPolySegments(target, axis, pos)
|
const split = splitPolySegments(target.segs, axis, pos)
|
||||||
if (!split) { clips.push({ sp, bb }); continue }
|
if (!split) { clips.push({ sp, bb }); idMap.set(myId, target); continue }
|
||||||
regions.splice(idx, 1, split.a, split.b)
|
// child0 = 法线指向侧(取片内首个远离分割线的顶点判定, 穿越点在线上不可用)
|
||||||
|
const nrm = sp.line.norm
|
||||||
|
const sideOf = (segs: Segment[]) => {
|
||||||
|
for (const s of segs) {
|
||||||
|
for (const p of [s.ps, s.pe]) {
|
||||||
|
const d = (p.x - sp.line.pt.x) * nrm.x + (p.y - sp.line.pt.y) * nrm.y
|
||||||
|
if (Math.abs(d) > 1e-6) return d > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const c0 = sideOf(split.a) ? split.a : split.b
|
||||||
|
target.kids = [{ segs: c0, kids: [] }, { segs: sideOf(split.a) ? split.b : split.a, kids: [] }]
|
||||||
|
target.segs = []
|
||||||
|
idMap.set(myId, target)
|
||||||
clips.push({ sp, bb })
|
clips.push({ sp, bb })
|
||||||
}
|
}
|
||||||
return { regions, clips }
|
|
||||||
|
const leaves = allLeaves()
|
||||||
|
const regions = leaves.map(l => l.segs).filter(s => s.length >= 3)
|
||||||
|
// 叶寻址表 + polyId 解析
|
||||||
|
const nodeId = new Map<SpNode, number>([[root, 0]])
|
||||||
|
const leafRefs: { idx: number; pos: number }[] = []
|
||||||
|
const walk = (node: SpNode, id: number) => {
|
||||||
|
node.kids.forEach((k, i) => {
|
||||||
|
if (k.kids.length === 0) leafRefs.push({ idx: id, pos: i })
|
||||||
|
else {
|
||||||
|
const kidId = nodeId.get(k)
|
||||||
|
if (kidId != null) walk(k, kidId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (root.kids.length === 0 && root.segs.length >= 3) leafRefs.push({ idx: 0, pos: 0 })
|
||||||
|
else walk(root, 0)
|
||||||
|
extraRoots.forEach((r, i) => {
|
||||||
|
const eid = base.length + spIdx + i
|
||||||
|
idMap.set(eid, r)
|
||||||
|
if (r.kids.length === 0) leafRefs.push({ idx: eid, pos: 0 })
|
||||||
|
else walk(r, eid)
|
||||||
|
})
|
||||||
|
const resolveRef = (idx: number, pos?: number): Segment[] | null => {
|
||||||
|
const node = idx === 0 ? root : idMap.get(idx)
|
||||||
|
if (!node) return null
|
||||||
|
if (node.kids.length === 0) return node.segs
|
||||||
|
if (Number.isInteger(pos) && (pos as number) >= 0 && (pos as number) < node.kids.length) {
|
||||||
|
const k = node.kids[pos as number]
|
||||||
|
if (k.kids.length === 0) return k.segs
|
||||||
|
const l: SpNode[] = []
|
||||||
|
dfsLeaves(k, l)
|
||||||
|
return l[0]?.segs ?? null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return { regions, clips, leafRefs, resolveRef }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 两次交叉点法分割闭合多边形段链(凸区域), 失败返回 null */
|
/** 两次交叉点法分割闭合多边形段链(凸区域), 失败返回 null */
|
||||||
@@ -221,21 +322,31 @@ export interface SashGlyph {
|
|||||||
x0: number; y0: number; x1: number; y1: number
|
x0: number; y0: number; x1: number; y1: number
|
||||||
od: number
|
od: number
|
||||||
openToward?: string
|
openToward?: string
|
||||||
|
openDirection?: string // hm.openDirection: left/right/up(含 *_with_* 变体)
|
||||||
isDoor?: boolean
|
isDoor?: boolean
|
||||||
wtf?: boolean
|
wtf?: boolean
|
||||||
kind: 'sliding' | 'casement'
|
kind: 'sliding' | 'casement'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 五金模型(hm): 铰链侧/内外开等真实开合语义来源 */
|
||||||
|
function hmOf(sub: SashShape): { openDirection?: string; openToward?: string } {
|
||||||
|
const hm = (sub as { hm?: { openDirection?: string; openToward?: string } }).hm
|
||||||
|
return hm ?? {}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扇样式推断: 有效bd → polyId区域(双扇对半) → 帧bbox按扇数竖向均分。
|
* 扇样式推断: 有效bd → polyId{idx,pos}挂接区域(原站以区域挂接扇) → 帧bbox按扇数均分兜底。
|
||||||
* 返回重放区域与推断出的扇矩形列表。
|
* kind 由类型决定(Slide/DoubleSash=推拉, 其余=平开); 开合五金取 hm.openDirection/hm.openToward。
|
||||||
*/
|
*/
|
||||||
export function inferSashGlyphs(shape: FrameShape, replayed?: Segment[][]): {
|
export function inferSashGlyphs(shape: FrameShape, replayed?: ReplayResult | Segment[][]): {
|
||||||
regions: Segment[][]
|
regions: Segment[][]
|
||||||
sashes: SashGlyph[]
|
sashes: SashGlyph[]
|
||||||
} {
|
} {
|
||||||
const replay = replayed ?? replayShape(shape).regions
|
let rr: ReplayResult | null = null
|
||||||
const regions = replay
|
let regions: Segment[][]
|
||||||
|
if (replayed && !Array.isArray(replayed)) { rr = replayed as ReplayResult; regions = rr.regions }
|
||||||
|
else if (replayed) { regions = replayed as Segment[][] }
|
||||||
|
else { rr = replayShape(shape); regions = rr.regions }
|
||||||
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']>) => {
|
||||||
@@ -247,14 +358,29 @@ export function inferSashGlyphs(shape: FrameShape, replayed?: Segment[][]): {
|
|||||||
const ok = [r.x0, r.y0, r.x1, r.y1].every(v => Number.isFinite(v))
|
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
|
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' || sub.type === 'Slide'
|
||||||
|
? 'sliding' as const : 'casement' as const)
|
||||||
|
// polyId{idx,pos} 树寻址; 无树信息时退化为扁平下标
|
||||||
|
const rectOfRef = (ref?: { idx?: number; pos?: number }) => {
|
||||||
|
if (!ref || !Number.isInteger(ref.idx) || (ref.idx as number) < 0) return null
|
||||||
|
if (rr) {
|
||||||
|
const segs = rr.resolveRef(ref.idx as number, ref.pos)
|
||||||
|
if (segs && segs.length >= 3) return bboxOf(regionPoints(segs))
|
||||||
|
}
|
||||||
|
const i = ref.idx as number
|
||||||
|
return i < regionBBs.length ? regionBBs[i] : null
|
||||||
|
}
|
||||||
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) => {
|
||||||
const kind = kindOf(sub)
|
const kind = kindOf(sub)
|
||||||
|
const hm = hmOf(sub)
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
|
// 子扇自带挂接区域时优先生效(双扇各自挂半区)
|
||||||
|
const rect = rectOfRef((e as { polyId?: { idx?: number; pos?: number } }).polyId) ?? r
|
||||||
glyphs.push({
|
glyphs.push({
|
||||||
x0: r.x0, y0: r.y0, x1: r.x1, y1: r.y1,
|
x0: rect.x0, y0: rect.y0, x1: rect.x1, y1: rect.y1,
|
||||||
od: (e.od ?? sub.od ?? 90) as number,
|
od: (e.od ?? sub.od ?? 90) as number,
|
||||||
openToward: (e.openToward ?? sub.openToward) as string | undefined,
|
openToward: (e.openToward ?? sub.openToward ?? hm.openToward) as string | undefined,
|
||||||
|
openDirection: (e.openDirection ?? hm.openDirection) as string | undefined,
|
||||||
isDoor: Boolean(e.isDoor ?? sub.isDoor),
|
isDoor: Boolean(e.isDoor ?? sub.isDoor),
|
||||||
wtf: Boolean(e.wtf ?? sub.wtf ?? sub.withTurningFrame),
|
wtf: Boolean(e.wtf ?? sub.wtf ?? sub.withTurningFrame),
|
||||||
kind,
|
kind,
|
||||||
@@ -266,28 +392,22 @@ export function inferSashGlyphs(shape: FrameShape, replayed?: Segment[][]): {
|
|||||||
const raw = (sub.sashes?.length ? sub.sashes : [sub]) as any[]
|
const raw = (sub.sashes?.length ? sub.sashes : [sub]) as any[]
|
||||||
const entries = raw.filter(e => e && e.enabled !== false)
|
const entries = raw.filter(e => e && e.enabled !== false)
|
||||||
if (!entries.length) continue
|
if (!entries.length) continue
|
||||||
const rect = sub.bd ? bdRect(sub.bd) : null
|
let rect = sub.bd ? bdRect(sub.bd) : null
|
||||||
if (rect) { pushGlyphs(rect, entries, sub); continue }
|
if (!rect) rect = rectOfRef(sub.polyId)
|
||||||
const idx = sub.polyId?.idx
|
if (rect) {
|
||||||
let rb = idx != null && regionBBs[idx] ? regionBBs[idx] : null
|
if (entries.length === 2 && !raw.some(e => e?.polyId?.idx != null)) {
|
||||||
// 推拉双扇: 附着较高的区域(推拉窗滑扇在上半部, 对齐 31729 实况)
|
// 双扇共区且无独立挂接: 沿长边对半
|
||||||
const kind = sub.type === 'DoubleSash' ? 'sliding' : 'casement'
|
const w = rect.x1 - rect.x0, h = rect.y1 - rect.y0
|
||||||
if (kind === 'sliding') {
|
|
||||||
rb = regionBBs.reduce((p, c) => (!p || (c.y0 + c.y1) / 2 > (p.y0 + p.y1) / 2 ? c : p), regionBBs[0] ?? null)
|
|
||||||
}
|
|
||||||
if (rb) {
|
|
||||||
if (entries.length === 2) {
|
|
||||||
const w = rb.x1 - rb.x0, h = rb.y1 - rb.y0
|
|
||||||
const halves = w >= h
|
const halves = w >= h
|
||||||
? [{ ...rb, x1: (rb.x0 + rb.x1) / 2 }, { ...rb, x0: (rb.x0 + rb.x1) / 2 }]
|
? [{ ...rect, x1: (rect.x0 + rect.x1) / 2 }, { ...rect, x0: (rect.x0 + rect.x1) / 2 }]
|
||||||
: [{ ...rb, y1: (rb.y0 + rb.y1) / 2 }, { ...rb, y0: (rb.y0 + rb.y1) / 2 }]
|
: [{ ...rect, y1: (rect.y0 + rect.y1) / 2 }, { ...rect, y0: (rect.y0 + rect.y1) / 2 }]
|
||||||
entries.forEach((e, k) => pushGlyphs(halves[k], [e], sub))
|
entries.forEach((e, k) => pushGlyphs(halves[k], [e], sub))
|
||||||
} else {
|
} else {
|
||||||
pushGlyphs(rb, entries, sub)
|
pushGlyphs(rect, entries, sub)
|
||||||
}
|
}
|
||||||
} else {
|
continue
|
||||||
unmatched.push({ sub, entries })
|
|
||||||
}
|
}
|
||||||
|
unmatched.push({ sub, entries })
|
||||||
}
|
}
|
||||||
if (unmatched.length && regions.length) {
|
if (unmatched.length && regions.length) {
|
||||||
const flatPts: Pt[] = []
|
const flatPts: Pt[] = []
|
||||||
@@ -306,7 +426,8 @@ export function inferSashGlyphs(shape: FrameShape, replayed?: Segment[][]): {
|
|||||||
glyphs.push({
|
glyphs.push({
|
||||||
x0: fb.x0 + i * colW, x1: fb.x0 + (i + 1) * colW, y0: fb.y0, y1: fb.y1,
|
x0: fb.x0 + i * colW, x1: fb.x0 + (i + 1) * colW, y0: fb.y0, y1: fb.y1,
|
||||||
od: (e.od ?? u.sub.od ?? 90) as number,
|
od: (e.od ?? u.sub.od ?? 90) as number,
|
||||||
openToward: (e.openToward ?? u.sub.openToward) as string | undefined,
|
openToward: (e.openToward ?? u.sub.openToward ?? hmOf(u.sub).openToward) as string | undefined,
|
||||||
|
openDirection: (e.openDirection ?? hmOf(u.sub).openDirection) as string | undefined,
|
||||||
isDoor: Boolean(e.isDoor ?? u.sub.isDoor),
|
isDoor: Boolean(e.isDoor ?? u.sub.isDoor),
|
||||||
kind: kindOf(u.sub),
|
kind: kindOf(u.sub),
|
||||||
})
|
})
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 64 KiB |