- 探明源站 3D 技术路线: BabylonJS + DXF 型材截面挤出 + sashOpen 开合 - 自研对齐实现(Three.js): U 型槽型材截面 ExtrudeGeometry 沿框架挤出(外框/中梃/扇框) 墙体洞口+地面场景(可开关), PCFSoft 阴影 光照氛围(日景/黄昏/夜景), 视角预设(正面/45°/侧面/室内) 自动旋转, 3D 截图导出 PNG(preserveDrawingBuffer) - 开合动画保留: 平开绕铰链/推拉沿轨道/门大角度
480 lines
19 KiB
Vue
480 lines
19 KiB
Vue
<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>
|