Files
menchuang/frontend/src/components/WindoorThreed.vue
T
HHY d3accdc237 feat: v1.9 3D 建模深度完善——型材截面挤出+场景+光照+视角
- 探明源站 3D 技术路线: BabylonJS + DXF 型材截面挤出 + sashOpen 开合
- 自研对齐实现(Three.js):
  U 型槽型材截面 ExtrudeGeometry 沿框架挤出(外框/中梃/扇框)
  墙体洞口+地面场景(可开关), PCFSoft 阴影
  光照氛围(日景/黄昏/夜景), 视角预设(正面/45°/侧面/室内)
  自动旋转, 3D 截图导出 PNG(preserveDrawingBuffer)
- 开合动画保留: 平开绕铰链/推拉沿轨道/门大角度
2026-09-21 02:50:25 -07:00

480 lines
19 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="panel">
<div class="p-row">
<span class="p-label">光照</span>
<el-radio-group v-model="lightMode" size="small" @change="applyLight">
<el-radio-button value="day">日景</el-radio-button>
<el-radio-button value="dusk">黄昏</el-radio-button>
<el-radio-button value="night">夜景</el-radio-button>
</el-radio-group>
</div>
<div class="p-row">
<span class="p-label">视角</span>
<el-radio-group v-model="viewMode" size="small" @change="applyView">
<el-radio-button value="front">正面</el-radio-button>
<el-radio-button value="deg45">45°</el-radio-button>
<el-radio-button value="side">侧面</el-radio-button>
<el-radio-button value="indoor">室内</el-radio-button>
</el-radio-group>
</div>
<div class="p-row">
<span class="p-label">场景</span>
<el-checkbox v-model="showWall" size="small" @change="rebuild">墙体</el-checkbox>
<el-checkbox v-model="autoRotate" size="small">自动旋转</el-checkbox>
<el-button size="small" text type="primary" @click="screenshot">截图</el-button>
</div>
</div>
<!-- 开合控制 -->
<div class="ctrl" v-if="hasOpenable">
<span>开合</span>
<el-slider v-model="openPct" style="width: 150px" :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 窗型查看器 v2(自研 Three.js 实现,技术路线对齐源站:型材截面沿框架路径挤出 + 扇开合)
* - 型材: U 型槽截面 ExtrudeGeometry 挤出(外框/中梃/扇框),替代平板盒体
* - 场景: 墙体洞口 + 地面 + 阴影 + 光照氛围(日景/黄昏/夜景)
* - 开合: 平开扇绕铰链旋转 / 推拉扇轨道平移(开合度滑杆 + 自动演示)
* - 视角预设 / 自动旋转 / 截图导出
*/
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import * as THREE from 'three'
import {
bboxOf, inferSashGlyphs, parseDrawing, regionPoints, replayShape,
type DrawingData, type Pt,
} from '../types/drawing'
interface BBox { x0: number; y0: number; x1: number; y1: number }
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)
const lightMode = ref<'day' | 'dusk' | 'night'>('day')
const viewMode = ref<'front' | 'deg45' | 'side' | 'indoor'>('deg45')
const showWall = ref(true)
const autoRotate = ref(false)
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let dirLight: THREE.DirectionalLight | null = null
let raf = 0
let orbit = { theta: -0.7, phi: 1.1, dist: 1.7, 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
let sceneRoot: THREE.Group | null = null
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 overlapArea(a: BBox, b: BBox): number {
const w = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0)
const h = Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0)
return w > 0 && h > 0 ? w * h : 0
}
/** U 型槽型材截面: 沿长度方向挤出, 槽口朝玻璃面(u=0 侧) */
function profileGeo(len: number, fw: number, depth: number): THREE.BufferGeometry {
const t = Math.max(0.004, depth * 0.28) // 壁厚
const s = new THREE.Shape()
// 外轮廓 (u: 深度方向 0=玻璃侧, v: 宽度方向)
s.moveTo(0, 0)
s.lineTo(depth, 0)
s.lineTo(depth, fw)
s.lineTo(t, fw)
s.lineTo(t, t)
s.lineTo(depth - t * 1.6, t)
s.lineTo(depth - t * 1.6, fw - t)
s.lineTo(0, fw - t)
s.closePath()
const geo = new THREE.ExtrudeGeometry(s, { depth: len, bevelEnabled: false })
geo.translate(0, 0, -len / 2)
return geo
}
/** 沿 X 轴的型材杆(槽口朝 -Z 玻璃面) */
function barX(parent: THREE.Group, mat: THREE.Material, len: number, fw: number,
depth: number, cx: number, cy: number, cz: number) {
const geo = profileGeo(len, fw, depth)
const m = new THREE.Mesh(geo, mat)
m.rotation.y = -Math.PI / 2
m.position.set(cx, cy, cz)
m.castShadow = true
parent.add(m)
}
/** 沿 Y 轴的型材杆 */
function barY(parent: THREE.Group, mat: THREE.Material, len: number, fw: number,
depth: number, cx: number, cy: number, cz: number) {
const geo = profileGeo(len, fw, depth)
const m = new THREE.Mesh(geo, mat)
m.rotation.x = -Math.PI / 2
m.rotation.z = Math.PI / 2
m.position.set(cx, cy, cz)
m.castShadow = true
parent.add(m)
}
const LIGHT_PRESETS = {
day: { dir: 0xfff4e0, intensity: 1.05, ambient: 0.72, bg: 0xf2f4f7, pos: [1, 2, 3] },
dusk: { dir: 0xffb066, intensity: 0.85, ambient: 0.5, bg: 0xe8d5c0, pos: [2.2, 1.1, 1.5] },
night: { dir: 0x9fb6ff, intensity: 0.4, ambient: 0.34, bg: 0x252d3a, pos: [-1.5, 2, 2] },
} as const
function applyLight() {
const p = LIGHT_PRESETS[lightMode.value]
if (dirLight) {
dirLight.color.setHex(p.dir)
dirLight.intensity = p.intensity
dirLight.position.set(p.pos[0], p.pos[1], p.pos[2])
}
if (scene) {
scene.background = new THREE.Color(p.bg)
scene.traverse(o => {
if ((o as THREE.AmbientLight).isAmbientLight) (o as THREE.AmbientLight).intensity = p.ambient
})
}
}
function applyView() {
const presets = {
front: { theta: 0, phi: 1.35 },
deg45: { theta: -0.7, phi: 1.1 },
side: { theta: 1.25, phi: 1.2 },
indoor: { theta: Math.PI - 0.2, phi: 1.25 },
} as const
const p = presets[viewMode.value]
orbit.theta = p.theta
orbit.phi = p.phi
}
function build(drawing: DrawingData) {
dispose()
scene = new THREE.Scene()
sceneRoot = new THREE.Group()
scene.add(sceneRoot)
sashAnims = []
scene.add(new THREE.AmbientLight(0xffffff, 0.72))
dirLight = new THREE.DirectionalLight(0xfff4e0, 1.05)
dirLight.position.set(1, 2, 3)
dirLight.castShadow = true
dirLight.shadow.mapSize.set(1024, 1024)
scene.add(dirLight)
const pts: Pt[] = []
for (const s of drawing.sm) {
for (const r of replayShape(s).regions) {
for (const seg of r) {
if (Number.isFinite(seg.ps?.x) && Number.isFinite(seg.ps?.y)) pts.push(seg.ps)
if (Number.isFinite(seg.pe?.x) && Number.isFinite(seg.pe?.y)) 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)
if (!Number.isFinite(maxDim) || maxDim <= 0) { info.value = '无有效几何'; hasOpenable.value = false; return }
const S = 1 / maxDim
const cx = (gb.x0 + gb.x1) / 2, cy = (gb.y0 + gb.y1) / 2
const DEPTH = Math.max(0.05, (drawing.s?.cs ?? 60) * S * 1.4)
const frameMat = new THREE.MeshStandardMaterial({ color: 0x6b7076, roughness: 0.45, metalness: 0.55 })
const mullionMat = new THREE.MeshStandardMaterial({ color: 0x777c82, roughness: 0.45, metalness: 0.5 })
const sashMat = new THREE.MeshStandardMaterial({ color: 0x84898f, roughness: 0.4, metalness: 0.55 })
const wallMat = new THREE.MeshStandardMaterial({ color: 0xe8e2d6, roughness: 0.95 })
const groundMat = new THREE.MeshStandardMaterial({ color: 0xcfc9bd, roughness: 1 })
const glassMat = new THREE.MeshPhysicalMaterial({
color: 0xd8ecf5, transparent: true, opacity: 0.4, roughness: 0.06,
metalness: 0.1, side: THREE.DoubleSide, envMapIntensity: 1.2,
})
const frameGroup = new THREE.Group()
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 })
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.4, roughness: 0.06, metalness: 0.1, side: THREE.DoubleSide,
})
: glassMat
const fw = Math.max(0.018, fwOf(shape))
const regionBBs: BBox[] = regions.map(r => bboxOf(regionPoints(r)))
const used = new Set<number>()
const panels: { rect: BBox; sash: (typeof sashes)[number] }[] = []
for (const s of sashes) {
const sb: BBox = { x0: s.x0, y0: s.y0, x1: s.x1, y1: s.y1 }
let best = -1, bestA = 0
regionBBs.forEach((rb, ri) => {
if (used.has(ri)) return
const a = overlapArea(sb, rb)
if (a > bestA) { bestA = a; best = ri }
})
if (best >= 0 && bestA > 0) { used.add(best); panels.push({ rect: regionBBs[best], sash: s }) }
}
// 静态玻璃 + 中梃(区域薄型材)
regionBBs.forEach((bb, ri) => {
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
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)
frameGroup.add(glass)
const t = Math.max(0.01, fw * 0.4)
barX(frameGroup, mullionMat, w, t, DEPTH * 0.55, mx, my + h / 2 - t / 2, 0)
barX(frameGroup, mullionMat, w, t, DEPTH * 0.55, mx, my - h / 2 + t / 2, 0)
barY(frameGroup, mullionMat, h, t, DEPTH * 0.55, mx - w / 2 + t / 2, my, 0)
barY(frameGroup, mullionMat, h, t, DEPTH * 0.55, mx + w / 2 - t / 2, my, 0)
})
// 扇面板(可开合)
panels.forEach(({ rect, 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
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)
group.add(glass)
const t = Math.max(0.014, fw)
barX(group, sashMat, w, t, DEPTH * 0.7, 0, h / 2 - t / 2, 0)
barX(group, sashMat, w, t, DEPTH * 0.7, 0, -h / 2 + t / 2, 0)
barY(group, sashMat, h, t, DEPTH * 0.7, -w / 2 + t / 2, 0, 0)
barY(group, sashMat, h, t, DEPTH * 0.7, w / 2 - t / 2, 0, 0)
group.position.set(cxr, cyr, 0)
sashRoot.add(group)
if (s.kind === 'sliding') {
group.position.z = 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 {
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)
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 })
}
})
// 整体外框(U 型型材)
const all = regions.map(r => regionPoints(r)).flat()
if (all.length) {
const bb = bboxOf(all)
const p0 = world(bb.x0, bb.y0), p1 = world(bb.x1, bb.y1)
const w = p1.x - p0.x, h = p1.y - p0.y
const mx = (p0.x + p1.x) / 2, my = (p0.y + p1.y) / 2
barX(frameGroup, frameMat, w + fw * 2, fw, DEPTH, mx, my + h / 2 + fw / 2, 0)
barX(frameGroup, frameMat, w + fw * 2, fw, DEPTH, mx, my - h / 2 - fw / 2, 0)
barY(frameGroup, frameMat, h, fw, DEPTH, mx - w / 2 - fw / 2, my, 0)
barY(frameGroup, frameMat, h, fw, DEPTH, mx + w / 2 + fw / 2, my, 0)
}
}
// ---- 墙体洞口 + 地面 ----
if (showWall.value) {
const bw = 1 / S * 0.72, bh = 1 / S * 0.72
const wx0 = -gw * S / 2 - 0.06, wx1 = gw * S / 2 + 0.06
const wy0 = -gh * S / 2 - 0.04, wy1 = gh * S / 2 + 0.06
const wt = DEPTH * 3
const wallBoxes: [number, number, number, number, number][] = [
[wx0 - bw, wy1, bw * 2, bh], // 上
[wx0 - bw, wy0 - bh, bw * 2, bh], // 下
[wx0 - bw, wy0, bw, wy1 - wy0], // 左
[wx1, wy0, bw, wy1 - wy0], // 右
]
for (const [px, py, pw, ph] of wallBoxes) {
const m = new THREE.Mesh(new THREE.BoxGeometry(pw, ph, wt), wallMat)
m.position.set(px + pw / 2, py + ph / 2, 0)
m.receiveShadow = true
sceneRoot.add(m)
}
const ground = new THREE.Mesh(new THREE.BoxGeometry(bw * 4, 0.02, DEPTH * 8), groundMat)
ground.position.set(0, wy0 - 0.02, DEPTH * 2)
ground.receiveShadow = true
sceneRoot.add(ground)
}
sceneRoot.traverse(o => { if ((o as THREE.Mesh).isMesh) { o.castShadow = true; o.receiveShadow = true } })
// 玻璃不投影
sceneRoot.traverse(o => {
const mat = (o as THREE.Mesh).material as THREE.Material | undefined
if (mat && (mat as THREE.MeshPhysicalMaterial).transparent) { o.castShadow = false }
})
hasOpenable.value = sashAnims.length > 0
const grid = new THREE.GridHelper(2.4, 12, 0xc8cdd2, 0xdfe3e8)
grid.position.y = -gh * S / 2 - DEPTH * 2.2
scene.add(grid)
// 阴影相机范围
if (dirLight) {
const c = dirLight.shadow.camera as THREE.OrthographicCamera
c.left = -1.2; c.right = 1.2; c.top = 1.2; c.bottom = -1.2
c.updateProjectionMatrix()
}
applyLight()
applyView()
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)
if (autoRotate.value) orbit.theta += 0.0035
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)))
}
function screenshot() {
if (!renderer) return
renderer.render(scene!, camera!)
const a = document.createElement('a')
a.href = renderer.domElement.toDataURL('image/png')
a.download = '3D窗型.png'
a.click()
ElMessage.success('截图已保存')
}
onMounted(() => {
const wrap = wrapRef.value!, cv = cvRef.value!
renderer = new THREE.WebGLRenderer({ canvas: cv, antialias: true, preserveDrawingBuffer: true })
renderer.setSize(wrap.clientWidth, wrap.clientHeight)
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
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 })
watch([lightMode], () => applyLight())
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; }
.panel { position: absolute; left: 10px; top: 10px; background: rgba(255,255,255,0.94);
border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; gap: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.12); }
.p-row { display: flex; gap: 8px; align-items: center; font-size: 12px; color: #606266; flex-wrap: wrap; }
.p-label { width: 30px; color: #909399; }
.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>