Files
menchuang/frontend/src/components/WindoorThreed.vue
T
HHY 3f871d7eeb feat: v0.8 平台扩展——3D渲染/打印中心/下料方案/设备/分享/验证码登录
- 3D展示: Three.js挤出重建窗型(玻璃半透明+型材盒体), 自研轨道控制
- 效果图渲染: Canvas场景合成(墙面/玻璃渐变高光/投影/三场景), 设置持久化
- 打印中心: A4生产订单模板(图纸+进度+签字栏), 打印CSS隔离
- 下料方案: 合同级汇总订单算料需求→FFD优化, 修正利用率为已排入/已用料
- 设备模块: 序列号+激活码绑定, 防重复绑定
- 订单分享: HMAC可逆token + 免登录公开只读页(customOrder-share)
- 验证码登录: mock短信通道全流程可用(发码/校验/自动注册), 接签名后换provider
- 帮助文章接口; 新增支付单适配层(微信/支付宝凭据就绪即接通)
- 前端新增6页面, 共18页面
2026-09-20 12:10:26 -07:00

197 lines
7.5 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>
</template>
<script setup lang="ts">
/**
* 3D 窗型查看器(自研 Three.js 实现)
* 从 drawing_data 重建:外框/中梃 → 型材盒体;玻璃区域 → 半透明玻璃板;
* ArcBall 轨道控制(自实现,不依赖 three examples 以控制体积)。
*/
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
import * as THREE from 'three'
import { bboxOf, parseDrawing, regionPoints, replayShape, type DrawingData, type Pt } from '../types/drawing'
const props = defineProps<{ drawingData: string | object }>()
const cvRef = ref<HTMLCanvasElement>()
const wrapRef = ref<HTMLDivElement>()
const info = ref('')
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
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)
// 全局包围盒(取全部基底区域)
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 = '无几何数据'; 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 // 归一化到 1 单位
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 GLASS_T = 0.008
const frameMat = new THREE.MeshStandardMaterial({ color: 0x636568, roughness: 0.55, metalness: 0.35 })
const glassMat = new THREE.MeshPhysicalMaterial({
color: 0xdfeef5, transparent: true, opacity: 0.42, roughness: 0.08,
metalness: 0.05, side: THREE.DoubleSide,
})
for (const shape of drawing.sm) {
const { regions } = replayShape(shape)
if (!regions.length) continue
const cm = shape.cm ?? {}
const gm = cm.g
? new THREE.MeshPhysicalMaterial({
color: new THREE.Color(cm.g.replace(/rgba?\(([^)]+)\)/, (_, 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
for (const region of regions) {
const rpts = regionPoints(region)
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, my = ((bb.y0 + bb.y1) / 2 - cy) * S
// 玻璃板
const glass = new THREE.Mesh(new THREE.BoxGeometry(w, h, GLASS_T), gm)
glass.position.set(mx, my, 0)
scene!.add(glass)
// 四周型材框(盒体,宽度=料宽 8% 视觉比例)
const fw = Math.max(0.02, (shape.fm?.ew?.[0] ?? 60) * S)
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(mx + ox, my + oy, 0)
scene!.add(bar)
}
mk(w + fw * 2, fw, 0, h / 2 + fw / 2) // 下(屏幕y翻转后为底)
mk(w + fw * 2, fw, 0, -h / 2 - fw / 2) // 上
mk(fw, h, -w / 2 - fw / 2, 0) // 左
mk(fw, h, w / 2 + fw / 2, 0) // 右
}
}
// 地面参考网格(模型已归一化到 1 单位,网格取 2 单位)
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 loop() {
raf = requestAnimationFrame(loop)
if (!renderer || !scene || !camera) return
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; }
</style>