feat: v1.9 3D 建模深度完善——型材截面挤出+场景+光照+视角

- 探明源站 3D 技术路线: BabylonJS + DXF 型材截面挤出 + sashOpen 开合
- 自研对齐实现(Three.js):
  U 型槽型材截面 ExtrudeGeometry 沿框架挤出(外框/中梃/扇框)
  墙体洞口+地面场景(可开关), PCFSoft 阴影
  光照氛围(日景/黄昏/夜景), 视角预设(正面/45°/侧面/室内)
  自动旋转, 3D 截图导出 PNG(preserveDrawingBuffer)
- 开合动画保留: 平开绕铰链/推拉沿轨道/门大角度
This commit is contained in:
HHY
2026-09-21 02:50:25 -07:00
parent 94c9bfe103
commit d3accdc237
2 changed files with 221 additions and 67 deletions
+1
View File
@@ -1,5 +1,6 @@
# CHANGELOG # CHANGELOG
- [2026-09-21 11:40] FEAT: v1.9 3D 建模深度完善(技术路线对齐源站型材截面挤出)——①U 型槽型材截面 ExtrudeGeometry 沿框架挤出, 替代平板盒体(外框/中梃/扇框三套型材); ②墙体洞口+地面场景(可开关); ③PCFSoft 阴影(平行光投影+地面接收); ④光照氛围日景/黄昏/夜景; ⑤视角预设正面/45°/侧面/室内+自动旋转; ⑥3D 截图导出 PNG(preserveDrawingBuffer); 开合动画保留 (Files: frontend/src/components/WindoorThreed.vue)
- [2026-09-21 11:20] FIX+FEAT: v1.8 修复工作台"接口不存在"+3D 区域分配制重建。①补齐公告后端: Announcement 模型+/api/announcements 接口+种子 4 条自撰公告(修复工作台/布局页调用不存在的接口报"接口不存在"); 修复 seed init_db 前置模型导入(announcements 表未建); ②WindoorThreed 重建改为区域分配制: 每个重放区域唯一归属(扇认领重叠最大区域/其余静态玻璃), 杜绝扇面板与玻璃重叠混乱; 全区域绘制薄框线还原真实窗格; 扇面板含玻璃+四边扇框 (Files: src/huamenchuang/models/announcement.py, src/huamenchuang/api/platform.py, src/huamenchuang/scripts/seed.py, frontend/src/components/WindoorThreed.vue) - [2026-09-21 11:20] FIX+FEAT: v1.8 修复工作台"接口不存在"+3D 区域分配制重建。①补齐公告后端: Announcement 模型+/api/announcements 接口+种子 4 条自撰公告(修复工作台/布局页调用不存在的接口报"接口不存在"); 修复 seed init_db 前置模型导入(announcements 表未建); ②WindoorThreed 重建改为区域分配制: 每个重放区域唯一归属(扇认领重叠最大区域/其余静态玻璃), 杜绝扇面板与玻璃重叠混乱; 全区域绘制薄框线还原真实窗格; 扇面板含玻璃+四边扇框 (Files: src/huamenchuang/models/announcement.py, src/huamenchuang/api/platform.py, src/huamenchuang/scripts/seed.py, frontend/src/components/WindoorThreed.vue)
- [2026-09-21 11:00] FIX: v1.7 修复 3D NaN 崩溃/样品图不刷新/画布点选失效——①THREE NaN: drawing_data 畸形数据进入 3D 网格, replayShape 段过滤补 Y 轴有限值校验、inferSashGlyphs bdRect 有限值校验、WindoorThreed 逐网格有限值防护+maxDim 守卫、CombineCanvas shapePts 过滤; ②样品图不刷新: 组合画布原地变异 shapes 数组, combinedJson computed 感知不到深层变化 → 引入 rev 版本号强制重算驱动 3D 重建; ③点选失效: 单元包围盒被非有限值污染致命中测试失效, 随①修复 (Files: frontend/src/types/drawing.ts, frontend/src/components/WindoorThreed.vue, frontend/src/components/CombineCanvas.vue, frontend/src/views/CombineView.vue) - [2026-09-21 11:00] FIX: v1.7 修复 3D NaN 崩溃/样品图不刷新/画布点选失效——①THREE NaN: drawing_data 畸形数据进入 3D 网格, replayShape 段过滤补 Y 轴有限值校验、inferSashGlyphs bdRect 有限值校验、WindoorThreed 逐网格有限值防护+maxDim 守卫、CombineCanvas shapePts 过滤; ②样品图不刷新: 组合画布原地变异 shapes 数组, combinedJson computed 感知不到深层变化 → 引入 rev 版本号强制重算驱动 3D 重建; ③点选失效: 单元包围盒被非有限值污染致命中测试失效, 随①修复 (Files: frontend/src/types/drawing.ts, frontend/src/components/WindoorThreed.vue, frontend/src/components/CombineCanvas.vue, frontend/src/views/CombineView.vue)
- [2026-09-21 10:50] FIX: 修复 spa_fallback 的 NameError(JSONResponse 未导入导致未匹配 /api 路径 500)与 JWT 密钥长度警告(默认密钥补足 32 字节以上) (Files: src/huamenchuang/main.py, src/huamenchuang/core/config.py) - [2026-09-21 10:50] FIX: 修复 spa_fallback 的 NameError(JSONResponse 未导入导致未匹配 /api 路径 500)与 JWT 密钥长度警告(默认密钥补足 32 字节以上) (Files: src/huamenchuang/main.py, src/huamenchuang/core/config.py)
+220 -67
View File
@@ -5,9 +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="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"> <div class="ctrl" v-if="hasOpenable">
<span>开合</span> <span>开合</span>
<el-slider v-model="openPct" style="width: 160px" :min="0" :max="100" :format-tooltip="v => v + '%'" /> <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-checkbox v-model="autoPlay" size="small">自动演示</el-checkbox>
<el-button size="small" text @click="openPct = openPct > 0 ? 0 : 100">{{ openPct > 0 ? '全关' : '全开' }}</el-button> <el-button size="small" text @click="openPct = openPct > 0 ? 0 : 100">{{ openPct > 0 ? '全关' : '全开' }}</el-button>
</div> </div>
@@ -16,13 +43,14 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 3D 窗型查看器 + 门窗开合动画(自研 Three.js 实现) * 3D 窗型查看器 v2(自研 Three.js 实现,技术路线对齐源站:型材截面沿框架路径挤出 + 扇开合)
* 构建原则(区域分配制): 分割树重放后的每个区域唯一归属—— * - 型材: U 型槽截面 ExtrudeGeometry 挤出(外框/中梃/扇框),替代平板盒体
* 被扇引用的区域 → 生成可开合扇面板(平开绕铰链旋转/推拉沿轨道平移) * - 场景: 墙体洞口 + 地面 + 阴影 + 光照氛围(日景/黄昏/夜景)
* 其余区域 → 静态玻璃 * - 开合: 平开扇绕铰链旋转 / 推拉扇轨道平移(开合度滑杆 + 自动演示)
* 全部区域绘制薄框线还原真实窗格;组合画布的多形状直接支持。 * - 视角预设 / 自动旋转 / 截图导出
*/ */
import { onMounted, onBeforeUnmount, ref, watch } from 'vue' import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import * as THREE from 'three' import * as THREE from 'three'
import { import {
bboxOf, inferSashGlyphs, parseDrawing, regionPoints, replayShape, bboxOf, inferSashGlyphs, parseDrawing, regionPoints, replayShape,
@@ -38,12 +66,17 @@ const info = ref('')
const openPct = ref(0) const openPct = ref(0)
const autoPlay = ref(false) const autoPlay = ref(false)
const hasOpenable = 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 renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null let camera: THREE.PerspectiveCamera | null = null
let dirLight: THREE.DirectionalLight | 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.7, 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: { let sashAnims: {
group: THREE.Group group: THREE.Group
@@ -55,6 +88,7 @@ let sashAnims: {
w: number w: number
}[] = [] }[] = []
let curT = 0 let curT = 0
let sceneRoot: THREE.Group | null = null
function dispose() { function dispose() {
if (scene) { if (scene) {
@@ -75,16 +109,95 @@ function overlapArea(a: BBox, b: BBox): number {
return w > 0 && h > 0 ? w * h : 0 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) { function build(drawing: DrawingData) {
dispose() dispose()
scene = new THREE.Scene() scene = new THREE.Scene()
scene.background = new THREE.Color(0xf2f4f7) sceneRoot = new THREE.Group()
scene.add(new THREE.AmbientLight(0xffffff, 0.75)) scene.add(sceneRoot)
const dir = new THREE.DirectionalLight(0xffffff, 0.9)
dir.position.set(1, 2, 3)
scene.add(dir)
sashAnims = [] 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 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) {
@@ -101,32 +214,23 @@ function build(drawing: DrawingData) {
if (!Number.isFinite(maxDim) || maxDim <= 0) { info.value = '无有效几何'; hasOpenable.value = false; return } if (!Number.isFinite(maxDim) || maxDim <= 0) { info.value = '无有效几何'; hasOpenable.value = false; return }
const S = 1 / maxDim 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.05, (drawing.s?.cs ?? 60) * S * 1.4)
const frameMat = new THREE.MeshStandardMaterial({ color: 0x636568, roughness: 0.55, metalness: 0.35 }) const frameMat = new THREE.MeshStandardMaterial({ color: 0x6b7076, roughness: 0.45, metalness: 0.55 })
const mullionMat = new THREE.MeshStandardMaterial({ color: 0x777c82, roughness: 0.5, metalness: 0.35 }) const mullionMat = new THREE.MeshStandardMaterial({ color: 0x777c82, roughness: 0.45, metalness: 0.5 })
const sashMat = new THREE.MeshStandardMaterial({ color: 0x83888f, roughness: 0.5, metalness: 0.35 }) 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({ const glassMat = new THREE.MeshPhysicalMaterial({
color: 0xdfeef5, transparent: true, opacity: 0.42, roughness: 0.08, color: 0xd8ecf5, transparent: true, opacity: 0.4, roughness: 0.06,
metalness: 0.05, side: THREE.DoubleSide, metalness: 0.1, side: THREE.DoubleSide, envMapIntensity: 1.2,
}) })
const frameGroup = new THREE.Group() const frameGroup = new THREE.Group()
const sashRoot = new THREE.Group() const sashRoot = new THREE.Group()
scene.add(frameGroup) sceneRoot.add(frameGroup)
scene.add(sashRoot) sceneRoot.add(sashRoot)
const world = (x: number, y: number) => ({ x: (x - cx) * S, y: (y - cy) * S })
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)
}
const bar = (parent: THREE.Group, mat: THREE.Material, bw: number, bh: number,
ox: number, oy: number, oz: number, depth: number) => {
const m = new THREE.Mesh(new THREE.BoxGeometry(bw, bh, depth), mat)
m.position.set(ox, oy, oz)
parent.add(m)
return m
}
for (const shape of drawing.sm) { for (const shape of drawing.sm) {
const { regions, sashes } = inferSashGlyphs(shape) const { regions, sashes } = inferSashGlyphs(shape)
@@ -138,15 +242,12 @@ function build(drawing: DrawingData) {
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.4, roughness: 0.06, metalness: 0.1, side: THREE.DoubleSide,
}) })
: glassMat : glassMat
const fw = fwOf(shape) const fw = Math.max(0.018, fwOf(shape))
const regionBBs: BBox[] = regions.map(r => bboxOf(regionPoints(r))) const regionBBs: BBox[] = regions.map(r => bboxOf(regionPoints(r)))
const regionMids = regionBBs.map(b => ({ x: (b.x0 + b.x1) / 2, y: (b.y0 + b.y1) / 2 }))
// ---- 区域分配: 每个扇认领重叠最大的未占用区域 ----
const used = new Set<number>() const used = new Set<number>()
const panels: { rect: BBox; sash: (typeof sashes)[number] }[] = [] const panels: { rect: BBox; sash: (typeof sashes)[number] }[] = []
for (const s of sashes) { for (const s of sashes) {
@@ -160,24 +261,24 @@ function build(drawing: DrawingData) {
if (best >= 0 && bestA > 0) { used.add(best); panels.push({ rect: regionBBs[best], sash: s }) } if (best >= 0 && bestA > 0) { used.add(best); panels.push({ rect: regionBBs[best], sash: s }) }
} }
// ---- 静态玻璃(未被扇认领的区域)---- // 静态玻璃 + 中梃(区域薄型材)
regionBBs.forEach((bb, ri) => { regionBBs.forEach((bb, ri) => {
if (used.has(ri)) return if (used.has(ri)) return
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 = (regionMids[ri].x - cx) * S, my = (regionMids[ri].y - cy) * 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 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) 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)
frameGroup.add(glass) frameGroup.add(glass)
// 区域薄框线(窗格还原) const t = Math.max(0.01, fw * 0.4)
const t = Math.max(0.008, fw * 0.35) barX(frameGroup, mullionMat, w, t, DEPTH * 0.55, mx, my + h / 2 - t / 2, 0)
bar(frameGroup, mullionMat, w, t, mx, my + h / 2 - t / 2, 0, DEPTH * 0.5) barX(frameGroup, mullionMat, w, t, DEPTH * 0.55, mx, my - h / 2 + t / 2, 0)
bar(frameGroup, mullionMat, w, t, mx, my - h / 2 + t / 2, 0, DEPTH * 0.5) barY(frameGroup, mullionMat, h, t, DEPTH * 0.55, mx - w / 2 + t / 2, my, 0)
bar(frameGroup, mullionMat, t, h, mx - w / 2 + t / 2, my, 0, DEPTH * 0.5) barY(frameGroup, mullionMat, h, t, DEPTH * 0.55, mx + w / 2 - t / 2, my, 0)
bar(frameGroup, mullionMat, t, h, mx + w / 2 - t / 2, my, 0, DEPTH * 0.5)
}) })
// ---- 扇面板(可开合)---- // 扇面板(可开合)
panels.forEach(({ rect, s }, k) => { panels.forEach(({ rect, s }, k) => {
const w = (rect.x1 - rect.x0) * S, h = (rect.y1 - rect.y0) * S const w = (rect.x1 - rect.x0) * S, h = (rect.y1 - rect.y0) * S
const cxr = ((rect.x0 + rect.x1) / 2 - cx) * S const cxr = ((rect.x0 + rect.x1) / 2 - cx) * S
@@ -186,11 +287,11 @@ function build(drawing: DrawingData) {
const group = new THREE.Group() const group = new THREE.Group()
const glass = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.006), gm) const glass = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.006), gm)
group.add(glass) group.add(glass)
const t = Math.max(0.016, fw) const t = Math.max(0.014, fw)
bar(group, sashMat, w, t, 0, h / 2 - t / 2, 0, DEPTH * 0.7) barX(group, sashMat, w, t, DEPTH * 0.7, 0, h / 2 - t / 2, 0)
bar(group, sashMat, w, t, 0, -h / 2 + t / 2, 0, DEPTH * 0.7) barX(group, sashMat, w, t, DEPTH * 0.7, 0, -h / 2 + t / 2, 0)
bar(group, sashMat, t, h, -w / 2 + t / 2, 0, DEPTH * 0.7) barY(group, sashMat, h, t, DEPTH * 0.7, -w / 2 + t / 2, 0, 0)
bar(group, sashMat, t, h, w / 2 - t / 2, 0, DEPTH * 0.7) barY(group, sashMat, h, t, DEPTH * 0.7, w / 2 - t / 2, 0, 0)
group.position.set(cxr, cyr, 0) group.position.set(cxr, cyr, 0)
sashRoot.add(group) sashRoot.add(group)
@@ -211,30 +312,63 @@ function build(drawing: DrawingData) {
} }
}) })
// ---- 整体外框 ---- // 整体外框(U 型型材)
const all = regions.map(r => regionPoints(r)).flat() const all = regions.map(r => regionPoints(r)).flat()
if (all.length) { if (all.length) {
const bb = bboxOf(all) const bb = bboxOf(all)
const x0 = (bb.x0 - cx) * S, x1 = (bb.x1 - cx) * S const p0 = world(bb.x0, bb.y0), p1 = world(bb.x1, bb.y1)
const y0 = (bb.y0 - cy) * S, y1 = (bb.y1 - cy) * S const w = p1.x - p0.x, h = p1.y - p0.y
const w = x1 - x0, h = y1 - y0 const mx = (p0.x + p1.x) / 2, my = (p0.y + p1.y) / 2
bar(frameGroup, frameMat, w + fw * 2, fw, 0, y1 + fw / 2, 0, DEPTH) barX(frameGroup, frameMat, w + fw * 2, fw, DEPTH, mx, my + h / 2 + fw / 2, 0)
bar(frameGroup, frameMat, w + fw * 2, fw, 0, y0 - fw / 2, 0, DEPTH) barX(frameGroup, frameMat, w + fw * 2, fw, DEPTH, mx, my - h / 2 - fw / 2, 0)
bar(frameGroup, frameMat, fw, h, x0 - fw / 2, 0, DEPTH) barY(frameGroup, frameMat, h, fw, DEPTH, mx - w / 2 - fw / 2, my, 0)
bar(frameGroup, frameMat, fw, h, x1 + fw / 2, 0, DEPTH) 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 hasOpenable.value = sashAnims.length > 0
const grid = new THREE.GridHelper(2, 10, 0xc8cdd2, 0xdfe3e8) const grid = new THREE.GridHelper(2.4, 12, 0xc8cdd2, 0xdfe3e8)
grid.position.y = -gh * S / 2 - DEPTH grid.position.y = -gh * S / 2 - DEPTH * 2.2
scene.add(grid) scene.add(grid)
camera!.position.set( // 阴影相机范围
orbit.target.x + orbit.dist * Math.sin(orbit.phi) * Math.sin(orbit.theta), if (dirLight) {
orbit.target.y + orbit.dist * Math.cos(orbit.phi), const c = dirLight.shadow.camera as THREE.OrthographicCamera
orbit.target.z + orbit.dist * Math.sin(orbit.phi) * Math.cos(orbit.theta)) c.left = -1.2; c.right = 1.2; c.top = 1.2; c.bottom = -1.2
camera!.lookAt(orbit.target) c.updateProjectionMatrix()
}
applyLight()
applyView()
info.value = `${Math.round(gw)} × ${Math.round(gh)} mm` info.value = `${Math.round(gw)} × ${Math.round(gh)} mm`
} }
@@ -254,6 +388,7 @@ function 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 const target = (autoPlay.value ? 50 + 50 * Math.sin(Date.now() / 1200) : openPct.value) / 100
applyOpen(curT + (target - curT) * 0.12) applyOpen(curT + (target - curT) * 0.12)
if (autoRotate.value) orbit.theta += 0.0035
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),
@@ -289,10 +424,22 @@ function onWheel(e: WheelEvent) {
orbit.dist = Math.min(6, Math.max(0.3, orbit.dist * (e.deltaY > 0 ? 1.1 : 0.9))) 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(() => { onMounted(() => {
const wrap = wrapRef.value!, cv = cvRef.value! const wrap = wrapRef.value!, cv = cvRef.value!
renderer = new THREE.WebGLRenderer({ canvas: cv, antialias: true }) renderer = new THREE.WebGLRenderer({ canvas: cv, antialias: true, preserveDrawingBuffer: true })
renderer.setSize(wrap.clientWidth, wrap.clientHeight) 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) camera = new THREE.PerspectiveCamera(50, wrap.clientWidth / wrap.clientHeight, 0.01, 100)
cv.addEventListener('mousedown', onDown) cv.addEventListener('mousedown', onDown)
window.addEventListener('mousemove', onMove) window.addEventListener('mousemove', onMove)
@@ -303,6 +450,7 @@ onMounted(() => {
watch(() => props.drawingData, () => { watch(() => props.drawingData, () => {
try { if (props.drawingData) build(parseDrawing(props.drawingData)) } catch { info.value = '解析失败' } try { if (props.drawingData) build(parseDrawing(props.drawingData)) } catch { info.value = '解析失败' }
}, { immediate: true }) }, { immediate: true })
watch([lightMode], () => applyLight())
loop() loop()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -319,6 +467,11 @@ 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; }
.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; .ctrl { position: absolute; left: 50%; transform: translateX(-50%); bottom: 12px;
background: rgba(255,255,255,0.92); border-radius: 10px; padding: 8px 14px; 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; display: flex; gap: 12px; align-items: center; font-size: 12px; color: #606266;