Files
menchuang/frontend/src/components/WindoorThreed.vue
T
HHY 1585445436 feat: v1.5 3D 门窗开合动画
- 扇推断逻辑抽取为共享模块 inferSashGlyphs(2D符号与3D动画共用)
- WindoorThreed 重写: 扇面板独立网格组
  平开扇绕铰链旋转(od定铰链侧, 门75°/平开32°/双向门40°)
  推拉扇沿轨道平移(双扇前后轨交替)
  区域归属扇的不再重复渲染静态玻璃
- 开合控制条: 开合度滑杆+自动演示+全开/全关, 平滑插值动画
- 组合画布 3D 预览自动获得开合能力
2026-09-21 01:59:12 -07:00

304 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="threed-wrap" ref="wrapRef">
<canvas ref="cvRef" />
<div class="hud3d">
<span v-if="info">{{ info }}</span>
<span class="hint">左键旋转 · 滚轮缩放 · 右键平移</span>
</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>
</template>
<script setup lang="ts">
/**
* 3D 窗型查看器 + 门窗开合动画(自研 Three.js 实现)
* - 玻璃区域/中梃/外框静态重建(分割树重放)
* - 扇面板独立成组: 平开扇绕铰链旋转(门开角更大), 推拉扇沿轨道平移, 双向门两向开
* - 开合度滑杆 / 自动演示; 组合画布的多形状直接支持
*/
import { onMounted, onBeforeUnmount, ref, watch, computed } from 'vue'
import * as THREE from 'three'
import {
bboxOf, inferSashGlyphs, parseDrawing, regionPoints, replayShape,
type DrawingData, type Pt,
} from '../types/drawing'
const props = withDefaults(defineProps<{ drawingData: string | object; controls?: boolean }>(), { controls: true })
const cvRef = ref<HTMLCanvasElement>()
const wrapRef = ref<HTMLDivElement>()
const info = ref('')
const openPct = ref(0)
const autoPlay = ref(false)
const hasOpenable = ref(false)
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let raf = 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 sashAnims: {
group: THREE.Group
kind: 'sliding' | 'casement'
baseX: number
slideDir: number
hingeSign: number
maxAng: number
w: number
}[] = []
let curT = 0
function dispose() {
if (scene) {
scene.traverse(obj => {
const m = obj as THREE.Mesh
if (m.geometry) m.geometry.dispose()
const mat = m.material as THREE.Material | THREE.Material[] | undefined
if (Array.isArray(mat)) mat.forEach(x => x.dispose())
else mat?.dispose()
})
scene.clear()
}
}
function build(drawing: DrawingData) {
dispose()
scene = new THREE.Scene()
scene.background = new THREE.Color(0xf2f4f7)
scene.add(new THREE.AmbientLight(0xffffff, 0.75))
const dir = new THREE.DirectionalLight(0xffffff, 0.9)
dir.position.set(1, 2, 3)
scene.add(dir)
sashAnims = []
const pts: Pt[] = []
for (const s of drawing.sm) {
for (const r of replayShape(s).regions) {
for (const seg of r) { pts.push(seg.ps); pts.push(seg.pe) }
}
}
if (!pts.length) { info.value = '无几何数据'; hasOpenable.value = false; return }
const gb = bboxOf(pts)
const gw = gb.x1 - gb.x0, gh = gb.y1 - gb.y0
const maxDim = Math.max(gw, gh)
const S = 1 / maxDim
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 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({
color: 0xdfeef5, transparent: true, opacity: 0.42, roughness: 0.08,
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) {
const { regions, sashes } = inferSashGlyphs(shape)
if (!regions.length) continue
const cm = shape.cm ?? {}
const gm = cm.g
? new THREE.MeshPhysicalMaterial({
color: new THREE.Color(cm.g.replace(/rgba?\(([^)]+)\)/, (_m: string, v: string) => {
const parts = v.split(',').map(x => parseFloat(x))
return `rgb(${parts[0]},${parts[1]},${parts[2]})`
})),
transparent: true, opacity: 0.45, roughness: 0.1, side: THREE.DoubleSide,
})
: 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) {
const rpts = regionPoints(region)
if (rpts.length < 3) continue
const bb = bboxOf(rpts)
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 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)
frameGroup.add(glass)
}
// 扇面板(可开合):玻璃 + 四周扇框,装入铰链/滑动组
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 bar = new THREE.Mesh(new THREE.BoxGeometry(bw, bh, DEPTH), frameMat)
bar.position.set((x0 + x1) / 2 + ox, (y0 + y1) / 2 + oy, 0)
frameGroup.add(bar)
}
mk(w + fw * 2, fw, 0, y1 + fw / 2)
mk(w + fw * 2, fw, 0, y0 - fw / 2)
mk(fw, h, x0 - fw / 2, 0)
mk(fw, h, x1 + fw / 2, 0)
}
}
hasOpenable.value = sashAnims.some(a => a.kind === 'sliding' || a.maxAng > 0)
const grid = new THREE.GridHelper(2, 10, 0xc8cdd2, 0xdfe3e8)
grid.position.y = -gh * S / 2 - DEPTH
scene.add(grid)
camera!.position.set(
orbit.target.x + orbit.dist * Math.sin(orbit.phi) * Math.sin(orbit.theta),
orbit.target.y + orbit.dist * Math.cos(orbit.phi),
orbit.target.z + orbit.dist * Math.sin(orbit.phi) * Math.cos(orbit.theta))
camera!.lookAt(orbit.target)
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() {
raf = requestAnimationFrame(loop)
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(
orbit.target.x + orbit.dist * Math.sin(orbit.phi) * Math.sin(orbit.theta),
orbit.target.y + orbit.dist * Math.cos(orbit.phi),
orbit.target.z + orbit.dist * Math.sin(orbit.phi) * Math.cos(orbit.theta))
camera.lookAt(orbit.target)
renderer.render(scene, camera)
}
function onResize() {
const wrap = wrapRef.value, cv = cvRef.value
if (!wrap || !cv || !renderer || !camera) return
renderer.setSize(wrap.clientWidth, wrap.clientHeight)
camera.aspect = wrap.clientWidth / wrap.clientHeight
camera.updateProjectionMatrix()
}
function onDown(e: MouseEvent) { dragging = { x: e.clientX, y: e.clientY, button: e.button } }
function onMove(e: MouseEvent) {
if (!dragging) return
const dx = e.clientX - dragging.x, dy = e.clientY - dragging.y
dragging = { x: e.clientX, y: e.clientY, button: dragging.button }
if (dragging.button === 2) {
const pan = new THREE.Vector3().crossVectors(camera!.up, camera!.getWorldDirection(new THREE.Vector3()))
orbit.target.addScaledVector(pan, -dx * orbit.dist * 0.001)
orbit.target.y += dy * orbit.dist * 0.001
} else {
orbit.theta -= dx * 0.008
orbit.phi = Math.min(Math.PI - 0.1, Math.max(0.15, orbit.phi + dy * 0.008))
}
}
function onUp() { dragging = null }
function onWheel(e: WheelEvent) {
orbit.dist = Math.min(6, Math.max(0.3, orbit.dist * (e.deltaY > 0 ? 1.1 : 0.9)))
}
onMounted(() => {
const wrap = wrapRef.value!, cv = cvRef.value!
renderer = new THREE.WebGLRenderer({ canvas: cv, antialias: true })
renderer.setSize(wrap.clientWidth, wrap.clientHeight)
camera = new THREE.PerspectiveCamera(50, wrap.clientWidth / wrap.clientHeight, 0.01, 100)
cv.addEventListener('mousedown', onDown)
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
cv.addEventListener('wheel', e => { e.preventDefault(); onWheel(e) }, { passive: false })
cv.addEventListener('contextmenu', e => e.preventDefault())
window.addEventListener('resize', onResize)
watch(() => props.drawingData, () => {
try { if (props.drawingData) build(parseDrawing(props.drawingData)) } catch { info.value = '解析失败' }
}, { immediate: true })
loop()
})
onBeforeUnmount(() => {
cancelAnimationFrame(raf)
dispose()
window.removeEventListener('resize', onResize)
renderer?.dispose()
})
</script>
<style scoped>
.threed-wrap { position: relative; width: 100%; height: 100%; min-height: 320px; }
canvas { width: 100%; height: 100%; display: block; }
.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; }
.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>