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
+1
View File
@@ -1,5 +1,6 @@
# CHANGELOG # CHANGELOG
- [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 00:40] FEAT: v1.4 旋转编辑+方向核验+双向门符号。①组合画布旋转编辑: 选中单元⟲/⟳90°旋转(绕几何中心, 分割线法向/开向角度同步旋转)与水平镜像, 工具栏按钮+Del删除; Node数学验证(宽高互换/法向转轴/可逆性✓); ②数据方向核验: 以31729(扇上固定下)与31740(裙板下)真实图纸比对, 确认2D渲染Y翻转方向正确(模型+y=屏幕上); ③2D渲染补双向开启门虚线交叉符号(wtf/withTurningFrame) (Files: frontend/src/components/CombineCanvas.vue, frontend/src/components/WindoorCanvas.vue, frontend/src/views/CombineView.vue) - [2026-09-21 00:40] FEAT: v1.4 旋转编辑+方向核验+双向门符号。①组合画布旋转编辑: 选中单元⟲/⟳90°旋转(绕几何中心, 分割线法向/开向角度同步旋转)与水平镜像, 工具栏按钮+Del删除; Node数学验证(宽高互换/法向转轴/可逆性✓); ②数据方向核验: 以31729(扇上固定下)与31740(裙板下)真实图纸比对, 确认2D渲染Y翻转方向正确(模型+y=屏幕上); ③2D渲染补双向开启门虚线交叉符号(wtf/withTurningFrame) (Files: frontend/src/components/CombineCanvas.vue, frontend/src/components/WindoorCanvas.vue, frontend/src/views/CombineView.vue)
- [2026-09-21 00:10] FEAT: v1.3 样板组合升级为画布形式——交互对齐原站画图编辑器(库面板可拖拽+shapeManager按位添加)。新组件 CombineCanvas: 拖拽样板入库投放(落点即中心)/自由拖移(同uid单元整体移动)/边缘吸附对齐(红色辅助线,12px容差)/点选高亮/底边对齐/Del删除/平移缩放; CombineView 重构为画布交互(拖放+点击双入口), 保留2D编辑与3D预览切换、另存我的样板(导出时剥离内部标记字段); 原"顺序拼接"模式由画布自由摆放取代 (Files: frontend/src/components/CombineCanvas.vue, frontend/src/views/CombineView.vue) - [2026-09-21 00:10] FEAT: v1.3 样板组合升级为画布形式——交互对齐原站画图编辑器(库面板可拖拽+shapeManager按位添加)。新组件 CombineCanvas: 拖拽样板入库投放(落点即中心)/自由拖移(同uid单元整体移动)/边缘吸附对齐(红色辅助线,12px容差)/点选高亮/底边对齐/Del删除/平移缩放; CombineView 重构为画布交互(拖放+点击双入口), 保留2D编辑与3D预览切换、另存我的样板(导出时剥离内部标记字段); 原"顺序拼接"模式由画布自由摆放取代 (Files: frontend/src/components/CombineCanvas.vue, frontend/src/views/CombineView.vue)
- [2026-09-20 23:40] FEAT: v1.2 样板组合工场+组合3D渲染+菜单分组。①样板组合(/combine): 样板库作为子组件点击加入, 多单元紧贴排布(平移归一), 等高对齐(参考首单元缩放), 删除/左右调序, 组合结果为标准多形状 drawing_data(与渲染/报价/AI炫图/3D全链路兼容), 可另存为我的样板; ②组合预览支持 2D/3D 双模式(Three.js 直接渲染组合 sm[]); ③侧边栏菜单分组: 销售业务/设计制图/生产供应/系统; 算法经 Node 几何验证(6形状无缝拼接/高度0误差) (Files: frontend/src/views/CombineView.vue, frontend/src/views/LayoutView.vue, frontend/src/router/index.ts) - [2026-09-20 23:40] FEAT: v1.2 样板组合工场+组合3D渲染+菜单分组。①样板组合(/combine): 样板库作为子组件点击加入, 多单元紧贴排布(平移归一), 等高对齐(参考首单元缩放), 删除/左右调序, 组合结果为标准多形状 drawing_data(与渲染/报价/AI炫图/3D全链路兼容), 可另存为我的样板; ②组合预览支持 2D/3D 双模式(Three.js 直接渲染组合 sm[]); ③侧边栏菜单分组: 销售业务/设计制图/生产供应/系统; 算法经 Node 几何验证(6形状无缝拼接/高度0误差) (Files: frontend/src/views/CombineView.vue, frontend/src/views/LayoutView.vue, frontend/src/router/index.ts)
+3 -63
View File
@@ -37,7 +37,7 @@
import { onMounted, ref, watch } from 'vue' import { onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { import {
bboxOf, parseDrawing, regionPoints, replayShape, spAxis, spPos, bboxOf, inferSashGlyphs, parseDrawing, regionPoints, replayShape, spAxis, spPos,
type DrawingData, type FrameShape, type Pt, type Segment, type SashShape, type SplitLine, type DrawingData, type FrameShape, type Pt, type Segment, type SashShape, type SplitLine,
} from '../types/drawing' } from '../types/drawing'
@@ -245,68 +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 regionBBs = regions.map(pts => bboxOf(pts)) const glyphs = inferSashGlyphs(shape, rp.regions).sashes
type Glyph = { x0: number; y0: number; x1: number; y1: number; od: number; openToward?: string; isDoor?: boolean; wtf?: boolean }
const glyphs: Glyph[] = []
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 pushGlyphs = (r: { x0: number; y0: number; x1: number; y1: number }, entries: any[], sub: SashShape) => {
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),
})
}
}
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),
})
i++
}
}
}
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
+134 -27
View File
@@ -5,23 +5,36 @@
<span v-if="info">{{ info }}</span> <span v-if="info">{{ info }}</span>
<span class="hint">左键旋转 · 滚轮缩放 · 右键平移</span> <span class="hint">左键旋转 · 滚轮缩放 · 右键平移</span>
</div> </div>
<div class="ctrl" v-if="hasOpenable">
<span>开合</span>
<el-slider v-model="openPct" style="width: 160px" :min="0" :max="100" :format-tooltip="v => v + '%'" />
<el-checkbox v-model="autoPlay" size="small">自动演示</el-checkbox>
<el-button size="small" text @click="openPct = openPct > 0 ? 0 : 100">{{ openPct > 0 ? '全关' : '全开' }}</el-button>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 3D 窗型查看器(自研 Three.js 实现) * 3D 窗型查看器 + 门窗开合动画(自研 Three.js 实现)
* 从 drawing_data 重建:外框/中梃 → 型材盒体;玻璃区域 → 半透明玻璃板; * - 玻璃区域/中梃/外框静态重建(分割树重放)
* ArcBall 轨道控制(自实现,不依赖 three examples 以控制体积)。 * - 扇面板独立成组: 平开扇绕铰链旋转(门开角更大), 推拉扇沿轨道平移, 双向门两向开
* - 开合度滑杆 / 自动演示; 组合画布的多形状直接支持
*/ */
import { onMounted, onBeforeUnmount, ref, watch } from 'vue' import { onMounted, onBeforeUnmount, ref, watch, computed } from 'vue'
import * as THREE from 'three' import * as THREE from 'three'
import { bboxOf, parseDrawing, regionPoints, replayShape, type DrawingData, type Pt } from '../types/drawing' import {
bboxOf, inferSashGlyphs, parseDrawing, regionPoints, replayShape,
type DrawingData, type Pt,
} from '../types/drawing'
const props = defineProps<{ drawingData: string | object }>() const props = withDefaults(defineProps<{ drawingData: string | object; controls?: boolean }>(), { controls: true })
const cvRef = ref<HTMLCanvasElement>() const cvRef = ref<HTMLCanvasElement>()
const wrapRef = ref<HTMLDivElement>() const wrapRef = ref<HTMLDivElement>()
const info = ref('') const info = ref('')
const openPct = ref(0)
const autoPlay = ref(false)
const hasOpenable = ref(false)
let renderer: THREE.WebGLRenderer | null = null let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null let scene: THREE.Scene | null = null
@@ -29,6 +42,16 @@ let camera: THREE.PerspectiveCamera | null = null
let raf = 0 let raf = 0
let orbit = { theta: -0.7, phi: 1.1, dist: 1.6, target: new THREE.Vector3(0, 0, 0) } let orbit = { theta: -0.7, phi: 1.1, dist: 1.6, target: new THREE.Vector3(0, 0, 0) }
let dragging: { x: number; y: number; button: number } | null = null let dragging: { x: number; y: number; button: number } | null = null
let sashAnims: {
group: THREE.Group
kind: 'sliding' | 'casement'
baseX: number
slideDir: number
hingeSign: number
maxAng: number
w: number
}[] = []
let curT = 0
function dispose() { function dispose() {
if (scene) { if (scene) {
@@ -51,67 +74,134 @@ function build(drawing: DrawingData) {
const dir = new THREE.DirectionalLight(0xffffff, 0.9) const dir = new THREE.DirectionalLight(0xffffff, 0.9)
dir.position.set(1, 2, 3) dir.position.set(1, 2, 3)
scene.add(dir) scene.add(dir)
sashAnims = []
// 全局包围盒(取全部基底区域)
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) { pts.push(seg.ps); pts.push(seg.pe) }
} }
} }
if (!pts.length) { info.value = '无几何数据'; 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)
const S = 1 / maxDim // 归一化到 1 单位 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)
const GLASS_T = 0.008
const frameMat = new THREE.MeshStandardMaterial({ color: 0x636568, roughness: 0.55, metalness: 0.35 }) const frameMat = new THREE.MeshStandardMaterial({ color: 0x636568, roughness: 0.55, metalness: 0.35 })
const sashMat = new THREE.MeshStandardMaterial({ color: 0x777c82, roughness: 0.5, metalness: 0.35 })
const glassMat = new THREE.MeshPhysicalMaterial({ const glassMat = new THREE.MeshPhysicalMaterial({
color: 0xdfeef5, transparent: true, opacity: 0.42, roughness: 0.08, color: 0xdfeef5, transparent: true, opacity: 0.42, roughness: 0.08,
metalness: 0.05, side: THREE.DoubleSide, metalness: 0.05, side: THREE.DoubleSide,
}) })
const frameGroup = new THREE.Group()
const sashRoot = new THREE.Group()
scene.add(frameGroup)
scene.add(sashRoot)
const fwOf = (shape: any) => {
const ew = shape.fm?.ew ?? [60, 60, 60, 60]
return Math.max(0.02, (ew.reduce((a: number, b: number) => a + b, 0) / ew.length) * S)
}
for (const shape of drawing.sm) { for (const shape of drawing.sm) {
const { regions } = replayShape(shape) const { regions, sashes } = inferSashGlyphs(shape)
if (!regions.length) continue if (!regions.length) continue
const cm = shape.cm ?? {} const cm = shape.cm ?? {}
const gm = cm.g const gm = cm.g
? new THREE.MeshPhysicalMaterial({ ? new THREE.MeshPhysicalMaterial({
color: new THREE.Color(cm.g.replace(/rgba?\(([^)]+)\)/, (_, v: string) => { color: new THREE.Color(cm.g.replace(/rgba?\(([^)]+)\)/, (_m: string, v: string) => {
const parts = v.split(',').map(x => parseFloat(x)) const parts = v.split(',').map(x => parseFloat(x))
return `rgb(${parts[0]},${parts[1]},${parts[2]})` return `rgb(${parts[0]},${parts[1]},${parts[2]})`
})), })),
transparent: true, opacity: 0.45, roughness: 0.1, side: THREE.DoubleSide, transparent: true, opacity: 0.45, roughness: 0.1, side: THREE.DoubleSide,
}) })
: glassMat : glassMat
const fw = fwOf(shape)
// 判断每个区域是否属于某扇(区域中心在扇矩形内 → 由扇面板渲染)
const sashRects = sashes.map(s => ({ ...s, cx: (s.x0 + s.x1) / 2, cy: (s.y0 + s.y1) / 2 }))
for (const region of regions) { for (const region of regions) {
const rpts = regionPoints(region) const rpts = regionPoints(region)
if (rpts.length < 3) continue
const bb = bboxOf(rpts) const bb = bboxOf(rpts)
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, my = ((bb.y0 + bb.y1) / 2 - cy) * S const mx = ((bb.x0 + bb.x1) / 2 - cx) * S
// 玻璃板 const my = ((bb.y0 + bb.y1) / 2 - cy) * S
const glass = new THREE.Mesh(new THREE.BoxGeometry(w, h, GLASS_T), gm) const inSash = sashRects.some(r => {
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
})
if (inSash) continue
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)
scene!.add(glass) frameGroup.add(glass)
// 四周型材框(盒体,宽度=料宽 8% 视觉比例) }
const fw = Math.max(0.02, (shape.fm?.ew?.[0] ?? 60) * S)
// 扇面板(可开合):玻璃 + 四周扇框,装入铰链/滑动组
sashes.forEach((s, k) => {
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 cyr = ((s.y0 + s.y1) / 2 - cy) * S
const group = new THREE.Group()
const panel = new THREE.Group()
const glass = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.006), gm)
panel.add(glass)
const t = Math.max(0.018, fw)
const mkBar = (bw: number, bh: number, ox: number, oy: number) => {
const bar = new THREE.Mesh(new THREE.BoxGeometry(bw, bh, DEPTH * 0.8), sashMat)
bar.position.set(ox, oy, 0)
panel.add(bar)
}
mkBar(w, t, 0, h / 2 - t / 2)
mkBar(w, t, 0, -h / 2 + t / 2)
mkBar(t, h, -w / 2 + t / 2, 0)
mkBar(t, h, w / 2 - t / 2, 0)
group.add(panel)
sashRoot.add(group)
if (s.kind === 'sliding') {
// 推拉: 双扇前后轨交替, 沿 X 平移
group.position.set(cxr, cyr, k % 2 === 0 ? 0.012 : -0.012)
sashAnims.push({ group, kind: 'sliding', baseX: cxr, slideDir: k % 2 === 0 ? 1 : -1, hingeSign: 1, maxAng: 0, w })
} else {
// 平开/门: 铰链在 od 指向的对侧(箭头指向开启方向), 绕 Y 轴旋转
const odRad = ((s.od ?? 90) * Math.PI) / 180
const hingeLeft = Math.cos(odRad) >= 0 ? false : true // 箭头偏右→铰链在左
const hingeX = hingeLeft ? cxr - w / 2 : cxr + w / 2
group.position.set(hingeX, cyr, 0)
panel.position.set(cxr - hingeX, 0, 0)
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 })
}
})
// 静态外框(整体)
if (regions.length) {
const all = regions.map(r => regionPoints(r).length ? r : r).flat()
const bb = bboxOf(all.flatMap(seg => [seg.ps, seg.pe]))
const x0 = ((bb.x0) - cx) * S, x1 = ((bb.x1) - cx) * S
const y0 = ((bb.y0) - cy) * S, y1 = ((bb.y1) - cy) * S
const w = x1 - x0, h = y1 - y0
const mk = (bw: number, bh: number, ox: number, oy: number) => { const mk = (bw: number, bh: number, ox: number, oy: number) => {
const bar = new THREE.Mesh(new THREE.BoxGeometry(bw, bh, DEPTH), frameMat) const bar = new THREE.Mesh(new THREE.BoxGeometry(bw, bh, DEPTH), frameMat)
bar.position.set(mx + ox, my + oy, 0) bar.position.set((x0 + x1) / 2 + ox, (y0 + y1) / 2 + oy, 0)
scene!.add(bar) frameGroup.add(bar)
} }
mk(w + fw * 2, fw, 0, h / 2 + fw / 2) // 下(屏幕y翻转后为底) mk(w + fw * 2, fw, 0, y1 + fw / 2)
mk(w + fw * 2, fw, 0, -h / 2 - fw / 2) // 上 mk(w + fw * 2, fw, 0, y0 - fw / 2)
mk(fw, h, -w / 2 - fw / 2, 0) // 左 mk(fw, h, x0 - fw / 2, 0)
mk(fw, h, w / 2 + fw / 2, 0) // 右 mk(fw, h, x1 + fw / 2, 0)
} }
} }
// 地面参考网格(模型已归一化到 1 单位,网格取 2 单位) hasOpenable.value = sashAnims.some(a => a.kind === 'sliding' || a.maxAng > 0)
const grid = new THREE.GridHelper(2, 10, 0xc8cdd2, 0xdfe3e8) const grid = new THREE.GridHelper(2, 10, 0xc8cdd2, 0xdfe3e8)
grid.position.y = -gh * S / 2 - DEPTH grid.position.y = -gh * S / 2 - DEPTH
scene.add(grid) scene.add(grid)
@@ -124,9 +214,23 @@ function build(drawing: DrawingData) {
info.value = `${Math.round(gw)} × ${Math.round(gh)} mm` info.value = `${Math.round(gw)} × ${Math.round(gh)} mm`
} }
function applyOpen(t: number) {
curT = t
for (const a of sashAnims) {
if (a.kind === 'sliding') {
a.group.position.x = a.baseX + a.slideDir * a.w * 0.42 * t
} else {
a.group.rotation.y = a.hingeSign * a.maxAng * t
}
}
}
function loop() { function loop() {
raf = requestAnimationFrame(loop) raf = requestAnimationFrame(loop)
if (!renderer || !scene || !camera) return if (!renderer || !scene || !camera) return
// 自动演示: 开合度按正弦摆动
const target = (autoPlay.value ? 50 + 50 * Math.sin(Date.now() / 1200) : openPct.value) / 100
applyOpen(curT + (target - curT) * 0.12)
camera.position.set( camera.position.set(
orbit.target.x + orbit.dist * Math.sin(orbit.phi) * Math.sin(orbit.theta), orbit.target.x + orbit.dist * Math.sin(orbit.phi) * Math.sin(orbit.theta),
orbit.target.y + orbit.dist * Math.cos(orbit.phi), orbit.target.y + orbit.dist * Math.cos(orbit.phi),
@@ -149,7 +253,6 @@ function onMove(e: MouseEvent) {
const dx = e.clientX - dragging.x, dy = e.clientY - dragging.y const dx = e.clientX - dragging.x, dy = e.clientY - dragging.y
dragging = { x: e.clientX, y: e.clientY, button: dragging.button } dragging = { x: e.clientX, y: e.clientY, button: dragging.button }
if (dragging.button === 2) { if (dragging.button === 2) {
// 平移
const pan = new THREE.Vector3().crossVectors(camera!.up, camera!.getWorldDirection(new THREE.Vector3())) const pan = new THREE.Vector3().crossVectors(camera!.up, camera!.getWorldDirection(new THREE.Vector3()))
orbit.target.addScaledVector(pan, -dx * orbit.dist * 0.001) orbit.target.addScaledVector(pan, -dx * orbit.dist * 0.001)
orbit.target.y += dy * orbit.dist * 0.001 orbit.target.y += dy * orbit.dist * 0.001
@@ -193,4 +296,8 @@ canvas { width: 100%; height: 100%; display: block; }
.hud3d { position: absolute; right: 10px; top: 10px; background: rgba(255,255,255,0.9); .hud3d { position: absolute; right: 10px; top: 10px; background: rgba(255,255,255,0.9);
border-radius: 6px; padding: 4px 10px; font-size: 12px; color: #606266; display: flex; gap: 12px; } border-radius: 6px; padding: 4px 10px; font-size: 12px; color: #606266; display: flex; gap: 12px; }
.hint { color: #b0b5bc; } .hint { color: #b0b5bc; }
.ctrl { position: absolute; left: 50%; transform: translateX(-50%); bottom: 12px;
background: rgba(255,255,255,0.92); border-radius: 10px; padding: 8px 14px;
display: flex; gap: 12px; align-items: center; font-size: 12px; color: #606266;
box-shadow: 0 2px 10px rgba(0,0,0,0.12); }
</style> </style>
+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 if (polyA.length < 3 || polyB.length < 3) return null
return { a: toSegs(polyA), b: toSegs(polyB) } 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 }
}