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