diff --git a/CHANGELOG b/CHANGELOG index d44773b..45fdb47 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ # 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 13:35] FEAT: v2.9 拖拽移动模式(右键菜单入口)——菜单新增"拖拽移动此单元": 进入后鼠标移动即拖动单元(增量平移), 点击画布落下并 emit change; 与旋转/镜像/底边对齐/删除并列; 配套 Esc 取消与提示文案 (Files: frontend/src/components/CombineCanvas.vue, frontend/scripts/verify-move.mjs) diff --git a/frontend/scripts/analyze-split-order.mjs b/frontend/scripts/analyze-split-order.mjs new file mode 100644 index 0000000..6e1d521 --- /dev/null +++ b/frontend/scripts/analyze-split-order.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 +} diff --git a/frontend/scripts/analyze-split-tree.mjs b/frontend/scripts/analyze-split-tree.mjs new file mode 100644 index 0000000..8afa817 --- /dev/null +++ b/frontend/scripts/analyze-split-tree.mjs @@ -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)) diff --git a/frontend/scripts/validate3d.cjs b/frontend/scripts/validate3d.cjs index 618975f..12fb467 100644 --- a/frontend/scripts/validate3d.cjs +++ b/frontend/scripts/validate3d.cjs @@ -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')); diff --git a/frontend/src/components/WindoorCanvas.vue b/frontend/src/components/WindoorCanvas.vue index cb636cb..69720b7 100644 --- a/frontend/src/components/WindoorCanvas.vue +++ b/frontend/src/components/WindoorCanvas.vue @@ -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 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 let replays: Replay[] = [] 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) } - // 扇(推断: 有效bd → polyId区域(双扇对半) → 帧bbox按扇数均分) — 共享推断模块 - const glyphs = inferSashGlyphs(shape, rp.regions).sashes + // 扇(推断: 有效bd → polyId挂接区域 → 帧bbox按扇数均分) — 共享推断模块 + const glyphs = inferSashGlyphs(shape, rp).sashes for (const g of glyphs) { const bx0 = tx(g.x0), bx1 = tx(g.x1), by0 = ty(g.y1), by1 = ty(g.y0) const w = bx1 - bx0, h = by1 - by0 @@ -372,7 +372,7 @@ function onClick(e: MouseEvent) { mm.sp = mm.sp ?? [] mm.sp.push({ 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, } as any) replayOne(si) diff --git a/frontend/src/components/WindoorThreed.vue b/frontend/src/components/WindoorThreed.vue index 68cb03c..f5e3e50 100644 --- a/frontend/src/components/WindoorThreed.vue +++ b/frontend/src/components/WindoorThreed.vue @@ -87,6 +87,7 @@ let dragging: { x: number; y: number; button: number } | null = null let sashAnims: { group: THREE.Group kind: 'sliding' | 'casement' + axis?: 'x' | 'y' baseX: number slideDir: number hingeSign: number @@ -247,7 +248,8 @@ function build(drawing: DrawingData) { const sashRoot = new THREE.Group() sceneRoot.add(frameGroup) 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) { const { regions, sashes } = inferSashGlyphs(shape) @@ -283,7 +285,7 @@ function build(drawing: DrawingData) { if (used.has(ri)) return const w = (bb.x1 - bb.x0) * S, h = (bb.y1 - bb.y0) * 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 const glass = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.008), gm) glass.position.set(mx, my, 0) @@ -299,7 +301,7 @@ function build(drawing: DrawingData) { panels.forEach(({ rect, sash: s }, k) => { const w = (rect.x1 - rect.x0) * S, h = (rect.y1 - rect.y0) * 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 const group = new THREE.Group() 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, hingeSign: 1, maxAng: 0, w }) } else { - const odRad = ((s.od ?? 90) * Math.PI) / 180 - const hingeLeft = Math.cos(odRad) < 0 - 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) }) - const maxAng = (s.isDoor ? 75 : s.wtf ? 40 : 32) * Math.PI / 180 - const dirSign = s.openToward === 'inward' ? 1 : -1 - sashAnims.push({ group, kind: 'casement', baseX: hingeX, slideDir: 0, - hingeSign: dirSign, maxAng, w }) + // 平开/上悬: 铰链侧与内外开取自五金 hm.openDirection/openToward, 开幅取 od(全开=开向角度) + const outSign = s.openToward === 'inward' ? -1 : 1 // +1=外开(朝观察者) + const maxAng = Math.min(100, Number.isFinite(s.od) && s.od > 0 ? s.od : (s.isDoor ? 75 : 90)) * Math.PI / 180 + const dir = s.openDirection ?? 'left' + if (dir.startsWith('up')) { + // 上悬窗: 绕顶边旋转 + const hingeY = cyr + h / 2 + group.position.set(cxr, hingeY, 0) + group.children.forEach(child => { child.position.y += (cyr - hingeY) }) + 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) { if (a.kind === 'sliding') { 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 { a.group.rotation.y = a.hingeSign * a.maxAng * t } diff --git a/frontend/src/types/drawing.ts b/frontend/src/types/drawing.ts index 1e3f302..eaaffce 100644 --- a/frontend/src/types/drawing.ts +++ b/frontend/src/types/drawing.ts @@ -130,17 +130,44 @@ export function spPos(sp: SplitLine): number { return spAxis(sp) === 'v' ? sp.line.pt.x : sp.line.pt.y } -/** - * 分割树重放(逆向自原站 shapeManager 的分割模型): - * polygon.polygon 为基底多边形, mm.sp[] 为分割树 — 每条 sp 通过 polyId.idx 指向 - * 其作用区域, 按顺序应用后得到最终窗格。重放时: - * - idx 越界/为-1 → 作用于基底区域0 - * - 分割坐标落在目标区域边上(退化)或目标非凸 → 跳过(no-op), 该梃按目标bbox裁剪绘制 - */ -export function replayShape(shape: FrameShape): { +/** 点是否在多边形内(射线法) */ +export function pointInRegion(p: Pt, region: Segment[]): boolean { + let inside = false + const pts = regionPoints(region) + for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { + 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 + } + 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[][] 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 ?? []) .map(r => r .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)) .map(s => ({ ps: { ...s.ps }, pe: { ...s.pe } }))) .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() 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[]) { + // 节点 id = sp 的 0 基序号: 第 0 条分裂结果=根(节点0), 第 k 条结果=节点k + const myId = spIdx + spIdx++ if (!sp?.line?.pt || !sp.line.norm) continue const axis = spAxis(sp) const 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(regionPoints(target)) + // 目标区域: polyId{idx,pos} 树寻址优先, 锚点所在叶兜底 + let target: SpNode | null = null + const nid = sp.polyId?.idx + const cpos = sp.polyId?.pos + 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 hi = axis === 'v' ? bb.x1 : bb.y1 if (pos <= lo + 0.5 || pos >= hi - 0.5) { clips.push({ sp, bb }) // 退化: 边线上的记录, 仅用于绘制 + idMap.set(myId, target) continue } - const split = splitPolySegments(target, axis, pos) - if (!split) { clips.push({ sp, bb }); continue } - regions.splice(idx, 1, split.a, split.b) + const split = splitPolySegments(target.segs, axis, pos) + if (!split) { clips.push({ sp, bb }); idMap.set(myId, target); continue } + // 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 }) } - return { regions, clips } + + const leaves = allLeaves() + const regions = leaves.map(l => l.segs).filter(s => s.length >= 3) + // 叶寻址表 + polyId 解析 + const nodeId = new Map([[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 */ @@ -221,21 +322,31 @@ export interface SashGlyph { x0: number; y0: number; x1: number; y1: number od: number openToward?: string + openDirection?: string // hm.openDirection: left/right/up(含 *_with_* 变体) isDoor?: boolean wtf?: boolean 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[][] sashes: SashGlyph[] } { - const replay = replayed ?? replayShape(shape).regions - const regions = replay + let rr: ReplayResult | null = null + 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 glyphs: SashGlyph[] = [] const bdRect = (bd: NonNullable) => { @@ -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)) 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 kind = kindOf(sub) + const hm = hmOf(sub) for (const e of entries) { + // 子扇自带挂接区域时优先生效(双扇各自挂半区) + const rect = rectOfRef((e as { polyId?: { idx?: number; pos?: number } }).polyId) ?? r 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, - 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), wtf: Boolean(e.wtf ?? sub.wtf ?? sub.withTurningFrame), kind, @@ -266,28 +392,22 @@ export function inferSashGlyphs(shape: FrameShape, replayed?: Segment[][]): { const raw = (sub.sashes?.length ? sub.sashes : [sub]) as any[] const entries = raw.filter(e => e && e.enabled !== false) if (!entries.length) continue - const rect = sub.bd ? bdRect(sub.bd) : null - if (rect) { pushGlyphs(rect, entries, sub); continue } - const idx = sub.polyId?.idx - let rb = idx != null && regionBBs[idx] ? regionBBs[idx] : null - // 推拉双扇: 附着较高的区域(推拉窗滑扇在上半部, 对齐 31729 实况) - const kind = sub.type === 'DoubleSash' ? 'sliding' : 'casement' - 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 + let rect = sub.bd ? bdRect(sub.bd) : null + if (!rect) rect = rectOfRef(sub.polyId) + if (rect) { + if (entries.length === 2 && !raw.some(e => e?.polyId?.idx != null)) { + // 双扇共区且无独立挂接: 沿长边对半 + const w = rect.x1 - rect.x0, h = rect.y1 - rect.y0 const halves = w >= h - ? [{ ...rb, x1: (rb.x0 + rb.x1) / 2 }, { ...rb, x0: (rb.x0 + rb.x1) / 2 }] - : [{ ...rb, y1: (rb.y0 + rb.y1) / 2 }, { ...rb, y0: (rb.y0 + rb.y1) / 2 }] + ? [{ ...rect, x1: (rect.x0 + rect.x1) / 2 }, { ...rect, x0: (rect.x0 + rect.x1) / 2 }] + : [{ ...rect, y1: (rect.y0 + rect.y1) / 2 }, { ...rect, y0: (rect.y0 + rect.y1) / 2 }] entries.forEach((e, k) => pushGlyphs(halves[k], [e], sub)) } else { - pushGlyphs(rb, entries, sub) + pushGlyphs(rect, entries, sub) } - } else { - unmatched.push({ sub, entries }) + continue } + unmatched.push({ sub, entries }) } if (unmatched.length && regions.length) { const flatPts: Pt[] = [] @@ -306,7 +426,8 @@ export function inferSashGlyphs(shape: FrameShape, replayed?: Segment[][]): { glyphs.push({ 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, - 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), kind: kindOf(u.sub), }) diff --git a/shots/01-templates.png b/shots/01-templates.png index b4f5fdb..bb22641 100644 Binary files a/shots/01-templates.png and b/shots/01-templates.png differ diff --git a/shots/02-combine-two.png b/shots/02-combine-two.png index 5177936..1550d43 100644 Binary files a/shots/02-combine-two.png and b/shots/02-combine-two.png differ diff --git a/shots/03-combine-selected.png b/shots/03-combine-selected.png index 88ac132..e83c9c8 100644 Binary files a/shots/03-combine-selected.png and b/shots/03-combine-selected.png differ diff --git a/shots/04-combine-3d.png b/shots/04-combine-3d.png index 88ac132..e83c9c8 100644 Binary files a/shots/04-combine-3d.png and b/shots/04-combine-3d.png differ diff --git a/shots/05-threed.png b/shots/05-threed.png index ff746b0..0ae5c3b 100644 Binary files a/shots/05-threed.png and b/shots/05-threed.png differ diff --git a/shots/open-0.png b/shots/open-0.png index 789994b..1cd7ace 100644 Binary files a/shots/open-0.png and b/shots/open-0.png differ diff --git a/shots/open-100.png b/shots/open-100.png index 9d3d537..7b832f7 100644 Binary files a/shots/open-100.png and b/shots/open-100.png differ diff --git a/shots/verify-wall-closed.png b/shots/verify-wall-closed.png index ddcda57..1cd7ace 100644 Binary files a/shots/verify-wall-closed.png and b/shots/verify-wall-closed.png differ diff --git a/shots/verify-wall-front.png b/shots/verify-wall-front.png index 9d419cc..f2469c0 100644 Binary files a/shots/verify-wall-front.png and b/shots/verify-wall-front.png differ diff --git a/shots/verify-wall-open46.png b/shots/verify-wall-open46.png index 8b7cda6..2f358ae 100644 Binary files a/shots/verify-wall-open46.png and b/shots/verify-wall-open46.png differ