feat: v1.5 3D 门窗开合动画

- 扇推断逻辑抽取为共享模块 inferSashGlyphs(2D符号与3D动画共用)
- WindoorThreed 重写: 扇面板独立网格组
  平开扇绕铰链旋转(od定铰链侧, 门75°/平开32°/双向门40°)
  推拉扇沿轨道平移(双扇前后轨交替)
  区域归属扇的不再重复渲染静态玻璃
- 开合控制条: 开合度滑杆+自动演示+全开/全关, 平滑插值动画
- 组合画布 3D 预览自动获得开合能力
This commit is contained in:
HHY
2026-09-21 01:59:12 -07:00
parent b404653862
commit 1585445436
4 changed files with 225 additions and 90 deletions
+87
View File
@@ -213,3 +213,90 @@ export function splitPolySegments(region: Segment[], axis: 'v' | 'h', pos: numbe
if (polyA.length < 3 || polyB.length < 3) return null
return { a: toSegs(polyA), b: toSegs(polyB) }
}
/** 扇推断结果(模型坐标,供 2D 符号绘制与 3D 开合动画共用) */
export interface SashGlyph {
x0: number; y0: number; x1: number; y1: number
od: number
openToward?: string
isDoor?: boolean
wtf?: boolean
kind: 'sliding' | 'casement'
}
/**
* 扇样式推断: 有效bd → polyId区域(双扇对半) → 帧bbox按扇数竖向均分。
* 返回重放区域与推断出的扇矩形列表。
*/
export function inferSashGlyphs(shape: FrameShape, replayed?: Segment[][]): {
regions: Segment[][]
sashes: SashGlyph[]
} {
const replay = replayed ?? replayShape(shape).regions
const regions = replay
const regionBBs = regions.map(r => bboxOf(regionPoints(r)))
const glyphs: SashGlyph[] = []
const bdRect = (bd: NonNullable<SashShape['bd']>) => {
const r = {
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),
}
return !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 pushGlyphs = (r: { x0: number; y0: number; x1: number; y1: number }, entries: any[], sub: SashShape) => {
const kind = kindOf(sub)
for (const e of entries) {
glyphs.push({
x0: r.x0, y0: r.y0, x1: r.x1, y1: r.y1,
od: (e.od ?? sub.od ?? 90) as number,
openToward: (e.openToward ?? sub.openToward) as string | undefined,
isDoor: Boolean(e.isDoor ?? sub.isDoor),
wtf: Boolean(e.wtf ?? sub.wtf ?? sub.withTurningFrame),
kind,
})
}
}
const unmatched: { sub: SashShape; entries: any[] }[] = []
for (const sub of (shape.sm ?? []) as SashShape[]) {
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
const rb = idx != null && regionBBs[idx] ? regionBBs[idx] : null
if (rb) {
if (entries.length === 2) {
const w = rb.x1 - rb.x0, h = rb.y1 - rb.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 }]
entries.forEach((e, k) => pushGlyphs(halves[k], [e], sub))
} else {
pushGlyphs(rb, entries, sub)
}
} else {
unmatched.push({ sub, entries })
}
}
if (unmatched.length && regions.length) {
const fb = bboxOf(regions.flat())
const K = unmatched.reduce((s, u) => s + u.entries.length, 0)
const colW = (fb.x1 - fb.x0) / K
let i = 0
for (const u of unmatched) {
for (const e of u.entries) {
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,
isDoor: Boolean(e.isDoor ?? u.sub.isDoor),
kind: kindOf(u.sub),
})
i++
}
}
}
return { regions, sashes: glyphs }
}