Files
menchuang/frontend/src/components/WindoorThreed.vue
T

632 lines
28 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-radio-group v-model="scene3d" size="small" @change="rebuild">
<el-radio-button value="indoor">室内</el-radio-button>
<el-radio-button value="outdoor">室外</el-radio-button>
<el-radio-button value="studio">影棚</el-radio-button>
</el-radio-group>
<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: number) => 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'
let ro: ResizeObserver | null = null
import { ElMessage } from 'element-plus'
import * as THREE from 'three'
import {
bboxOf, inferSashGlyphs, inferSashPanels, parseDrawing, regionPoints, replayShape,
type DecBar, type DrawingData, type FrameShape, type Pt, type SashShape,
} from '../types/drawing'
import { profileGeo } from '../three/profileGeo'
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 scene3d = ref<'indoor' | 'outdoor' | 'studio'>('outdoor')
const SCENE_BG = { indoor: 0xf5efe6, outdoor: 0xbcd9f0, studio: 0xf2f4f7 } as const
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'
axis?: 'x' | 'y'
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
}
/** 型材面宽(世界单位) */
function fwOf(shape: any, S: number): number {
const ew = shape.fm?.ew ?? [60, 60, 60, 60]
return Math.max(0.018, (ew.reduce((a: number, b: number) => a + b, 0) / ew.length) * S)
}
/** 型材截面: 多腔体 + 玻璃槽(比例依据真实 DXF 截面学习: 深度46-90/面宽65-130/腔体3-6/壁厚≈深度1/30) */
function barX(parent: THREE.Group, mat: THREE.Material, len: number, fw: number,
depth: number, cx: number, cy: number, cz: number, chambers = 2) {
const geo = profileGeo(len, fw, depth, chambers)
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, chambers = 2) {
const geo = profileGeo(len, fw, depth, chambers)
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(SCENE_BG[scene3d.value])
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 rebuild() {
try {
if (props.drawingData) build(parseDrawing(props.drawingData))
} catch (e) {
console.error('[WindoorThreed] rebuild 失败:', e)
info.value = '解析失败: ' + (e as Error).message
}
}
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[] = []
const shapeBBs: BBox[] = []
for (const s of drawing.sm) {
const sp: Pt[] = []
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); sp.push(seg.ps) }
if (Number.isFinite(seg.pe?.x) && Number.isFinite(seg.pe?.y)) { pts.push(seg.pe); sp.push(seg.pe) }
}
}
if (sp.length) shapeBBs.push(bboxOf(sp))
}
if (!pts.length) { info.value = '无几何数据'; hasOpenable.value = false; return }
// 主体簇: 以形状 bbox 中心的中位数为中心, 剔除远离主体的离群形状后取包围
const med = (arr: number[]) => { const a = [...arr].sort((x, y) => x - y); return a[Math.floor(a.length / 2)] ?? 0 }
const mcx = med(shapeBBs.map(b => (b.x0 + b.x1) / 2))
const mcy = med(shapeBBs.map(b => (b.y0 + b.y1) / 2))
const msize = med(shapeBBs.map(b => Math.max(b.x1 - b.x0, b.y1 - b.y0)))
const mainBBs = shapeBBs.filter(b =>
Math.abs((b.x0 + b.x1) / 2 - mcx) < msize * 1.5 && Math.abs((b.y0 + b.y1) / 2 - mcy) < msize * 1.5)
const gb = bboxOf(mainBBs.length ? mainBBs.flatMap(b => [
{ x: b.x0, y: b.y0 }, { x: b.x1, y: b.y1 },
]) : 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 floorMat = new THREE.MeshStandardMaterial({ color: 0xb59b74, roughness: 0.9 })
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)
// 模型坐标 y 向下(屏幕系), Three.js y 向上: 翻转保证布局上下关系与原图一致
const world = (x: number, y: number) => ({ x: (x - cx) * S, y: (cy - y) * S })
for (const shape of drawing.sm) {
const { regions, sashes } = inferSashGlyphs(shape)
if (!regions.length) continue
// 收集 Sash 级形状, 供扇内子格(裙板/格条)锚点归属匹配
const sashShapes3d: SashShape[] = []
const collectSash3d = (s: FrameShape | SashShape) => {
if (!['Frame', 'Slide'].includes(String(s.type))) sashShapes3d.push(s as SashShape)
;(((s as FrameShape).sm ?? []) as (FrameShape | SashShape)[]).forEach(x => collectSash3d(x))
;(((s as SashShape).sashes ?? []) as SashShape[]).forEach(x => collectSash3d(x))
}
collectSash3d(shape)
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, S))
const regionBBs: BBox[] = regions.map(r => bboxOf(regionPoints(r)))
const used = new Set<number>()
const panels: { rect: BBox; sash: (typeof sashes)[number]; ri: number }[] = []
// 先统计各区域覆盖扇数: 多扇共享(推拉/对开门)时保留静态底玻璃, 独占区域才跳过
const coverCount = new Map<number, 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) => {
const a = overlapArea(sb, rb)
if (a > bestA) { bestA = a; best = ri }
})
if (best >= 0 && bestA > 0) {
coverCount.set(best, (coverCount.get(best) ?? 0) + 1)
// 扇面板用 glyph 自身矩形(多扇可共享同一区域)
panels.push({ rect: sb, sash: s, ri: best })
}
}
for (const [ri, n] of coverCount) if (n === 1) used.add(ri)
/** 装饰格条(自研 decs 扩展): 玻璃前表面加横竖格条体 */
const addDecBars = (parent: THREE.Group, dec: DecBar, w: number, h: number, mx: number, my: number, z: number) => {
const bw = Math.max(0.004, dec.bw * S)
const mat = new THREE.MeshStandardMaterial({
color: new THREE.Color(dec.color), roughness: 0.5, metalness: 0.3,
})
for (let i = 1; i <= dec.rows; i++) {
const y = my - h / 2 + (h * i) / (dec.rows + 1)
barX(parent, mat, w * 0.995, bw, DEPTH * 0.25, mx, y, z, 1)
}
for (let i = 1; i <= dec.cols; i++) {
const x = mx - w / 2 + (w * i) / (dec.cols + 1)
barY(parent, mat, h * 0.995, bw, DEPTH * 0.25, x, my, z, 1)
}
}
// 静态玻璃 + 中梃(区域薄型材)
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 = (cy - (bb.y0 + bb.y1) / 2) * 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)
const dec = (shape.decs ?? []).find(d => d.ri === ri)
if (dec) addDecBars(frameGroup, dec, w, h, mx, my, 0.012)
})
// 扇面板(可开合)
panels.forEach(({ rect, sash: s, ri }, 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 = (cy - (rect.y0 + rect.y1) / 2) * 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)
const dec = (shape.decs ?? []).find(d => d.ri === ri)
if (dec) addDecBars(group, dec, w, h, 0, 0, 0.011)
// 扇内子格(源站原生): 裙板实板 + dbs 格条(锚点归属匹配扇形状: sp 锚点落在该扇矩形内)
const gRect = { x0: s.x0, y0: s.y0, x1: s.x1, y1: s.y1 }
const matched = sashShapes3d.find(sh => (sh.mm?.sp ?? []).some(sp => {
const pt = sp.line?.pt
return pt && pt.x >= gRect.x0 && pt.x <= gRect.x1 && pt.y >= gRect.y0 && pt.y <= gRect.y1
})) ?? sashShapes3d[k % Math.max(1, sashShapes3d.length)]
if (matched) {
for (const p of inferSashPanels(matched, gRect)) {
const pw = (p.x1 - p.x0) * S, ph = (p.y1 - p.y0) * S
// 子格中心 → group 局部坐标(group 原点=扇矩形中心)
const lmx = ((p.x0 + p.x1) / 2 - gRect.x1 / 2 - gRect.x0 / 2) * S
const lmy = (gRect.y1 / 2 + gRect.y0 / 2 - (p.y0 + p.y1) / 2) * S
if (![pw, ph, lmx, lmy].every(Number.isFinite) || pw <= 0.01 || ph <= 0.01) continue
if (!p.glass) {
const board = new THREE.Mesh(new THREE.BoxGeometry(pw, ph, 0.012),
new THREE.MeshStandardMaterial({ color: 0xd9cdb8, roughness: 0.7, metalness: 0.05 }))
board.position.set(lmx, lmy, 0.004)
group.add(board)
} else if (p.deco) {
// hc 等分主格 + gp 内框 + 四角回纹块(3D 条体)
const bw = Math.max(0.006, 0.014 * S)
const inner = Math.max(0.015, p.deco.gp * S)
const barMat = new THREE.MeshStandardMaterial({ color: 0x6b563c, roughness: 0.5, metalness: 0.25 })
const iw = pw - 2 * inner, ih = ph - 2 * inner
if (iw > 0.02 && ih > 0.02) {
// 内圈框
barX(group, barMat, iw, bw, DEPTH * 0.2, lmx, lmy + ih / 2, 0.008, 1)
barX(group, barMat, iw, bw, DEPTH * 0.2, lmx, lmy - ih / 2, 0.008, 1)
barY(group, barMat, ih, bw, DEPTH * 0.2, lmx - iw / 2, lmy, 0.008, 1)
barY(group, barMat, ih, bw, DEPTH * 0.2, lmx + iw / 2, lmy, 0.008, 1)
// 主格等分
const hc = Math.max(2, p.deco.hc)
for (let i = 1; i < hc; i++) {
barY(group, barMat, ih, bw, DEPTH * 0.2, lmx - iw / 2 + (iw * i) / hc, lmy, 0.008, 1)
barX(group, barMat, iw, bw, DEPTH * 0.2, lmx, lmy + ih / 2 - (ih * i) / hc, 0.008, 1)
}
// 四角回纹块(嵌套双框)
const qs = Math.min(inner * 0.72, iw * 0.14, ih * 0.14)
for (const [sx, sy] of [[-1, 1], [1, 1], [-1, -1], [1, -1]] as const) {
const cx2 = lmx + sx * (iw / 2 - qs / 2), cy2 = lmy + sy * (ih / 2 - qs / 2)
for (const f of [1, 0.55]) {
barX(group, barMat, qs * f, bw, DEPTH * 0.2, cx2, cy2 + sy * (qs * f) / 2, 0.008, 1)
barX(group, barMat, qs * f, bw, DEPTH * 0.2, cx2, cy2 - sy * (qs * f) / 2, 0.008, 1)
barY(group, barMat, qs * f, bw, DEPTH * 0.2, cx2 + sx * (qs * f) / 2, cy2, 0.008, 1)
barY(group, barMat, qs * f, bw, DEPTH * 0.2, cx2 - sx * (qs * f) / 2, cy2, 0.008, 1)
}
}
}
}
}
}
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 {
// 平开/上悬: 铰链侧与内外开取自五金 hm.openDirection/openToward, 开幅取 od(全开=开向角度)
const outSign = s.openToward === 'inward' ? -1 : 1 // +1=外开(朝观察者)
const maxAng = Math.min(100, Number.isFinite(s.od) && s.od > 0 ? s.od : (s.isDoor ? 75 : 90)) * Math.PI / 180
const dir = s.openDirection ?? 'left'
if (dir.startsWith('up')) {
// 上悬窗: 绕顶边旋转
const hingeY = cyr + h / 2
group.position.set(cxr, hingeY, 0)
group.children.forEach(child => { child.position.y += (cyr - hingeY) })
sashAnims.push({ group, kind: 'casement', axis: 'x', baseX: cxr, slideDir: 0,
hingeSign: -outSign, maxAng, w })
} else {
const hingeLeft = dir.startsWith('left')
const hingeX = hingeLeft ? cxr - w / 2 : cxr + w / 2
group.position.set(hingeX, cyr, 0)
// 子元素为面板中心局部坐标: 平移 +(cxr-hingeX) 使 t=0 世界位置不变, 仅将旋转支点移到铰链边
group.children.forEach(child => { child.position.x += (cxr - hingeX) })
sashAnims.push({ group, kind: 'casement', axis: 'y', baseX: hingeX, slideDir: 0,
hingeSign: (hingeLeft ? -1 : 1) * outSign, 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, 3)
barX(frameGroup, frameMat, w + fw * 2, fw, DEPTH, mx, my - h / 2 - fw / 2, 0, 3)
barY(frameGroup, frameMat, h, fw, DEPTH, mx - w / 2 - fw / 2, my, 0, 3)
barY(frameGroup, frameMat, h, fw, DEPTH, mx + w / 2 + fw / 2, my, 0, 3)
}
}
// ---- 环境场景(室内/室外/影棚)----
let gridY = -gh * S / 2 - DEPTH * 2.2
if (scene3d.value === 'outdoor') {
// 室外: 建筑外墙围绕窗洞 + 草地(墙体外墙面退至窗框外表面之后, 窗框外露30%型深, 避免整窗陷入墙体内)
const fwMax = Math.max(0.018, ...drawing.sm.map(sh => fwOf(sh, S)))
const gap = fwMax + 0.015 // 窗洞净空: 外框面宽 + 安装缝(固定值会遮挡宽面型材)
const hw = gw * S / 2 + gap // 窗洞半宽
const hh = gh * S / 2 + gap // 窗洞半高
const side = 0.55 // 上下墙高度/左右墙宽度
const wt = DEPTH * 2.4 // 墙厚(≈240 实墙比例)
const wallFront = DEPTH * 0.2 // 外墙面 z
const wallZ = wallFront - wt / 2
const wallBoxes: number[][] = [
[-hw - side, hh, side * 2 + hw * 2, side], // 上
[-hw - side, -hh - side, side * 2 + hw * 2, side], // 下
[-hw - side, -hh, side, hh * 2], // 左
[hw, -hh, side, hh * 2], // 右
]
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, wallZ)
m.receiveShadow = true
m.userData.isWall = true
sceneRoot.add(m)
}
const ground = new THREE.Mesh(new THREE.BoxGeometry(3, 0.02, DEPTH * 8), groundMat)
ground.position.set(0, -hh - side - 0.02, wallFront + DEPTH * 4)
ground.receiveShadow = true
sceneRoot.add(ground)
gridY = -hh - side - 0.008 // 网格降至墙脚, 避免穿墙
} else if (scene3d.value === 'indoor') {
// 室内: 窗后整墙 + 室内地板(木色)
const wall = new THREE.Mesh(new THREE.BoxGeometry(gw * S + 1.4, gh * S + 1.2, 0.02), wallMat)
wall.position.set(0, 0, -DEPTH)
wall.receiveShadow = true
wall.userData.isWall = true
sceneRoot.add(wall)
const floor = new THREE.Mesh(new THREE.BoxGeometry(gw * S + 1.4, 0.02, gh * S + 1.2), floorMat)
floor.position.set(0, -gh * S / 2 - DEPTH, 0)
floor.receiveShadow = true
sceneRoot.add(floor)
}
// 影棚(studio): 仅网格地面(下方已添加)
sceneRoot.traverse(o => {
const m = o as THREE.Mesh
if (m.isMesh) { m.castShadow = !m.userData.isWall; m.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 = gridY
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 if (a.axis === 'x') {
a.group.rotation.x = a.hingeSign * a.maxAng * 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
// 容器不可见(display:none)时尺寸为 0,跳过避免 NaN 投影矩阵
if (!wrap.clientWidth || !wrap.clientHeight) 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 || 1, wrap.clientHeight || 1)
renderer.shadowMap.enabled = true
camera = new THREE.PerspectiveCamera(50, (wrap.clientWidth || 1) / (wrap.clientHeight || 1), 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)
// 容器尺寸追踪:v-show 隐藏下挂载(如效果图页切 Tab)时初始尺寸为 0,显示瞬间自动恢复
ro = new ResizeObserver(onResize)
ro.observe(wrap)
watch(() => props.drawingData, () => {
try {
if (props.drawingData) build(parseDrawing(props.drawingData))
} catch (e) {
console.error('[WindoorThreed] 构建/解析失败:', e)
info.value = '解析失败: ' + (e as Error).message
}
}, { immediate: true })
watch([lightMode], () => applyLight())
loop()
})
onBeforeUnmount(() => {
cancelAnimationFrame(raf)
dispose()
ro?.disconnect()
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>