|
|
@@ -0,0 +1,607 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import { onMounted, onUnmounted, ref, reactive, watch } from 'vue'
|
|
|
+import * as THREE from 'three'
|
|
|
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
|
|
|
+import { Sky } from 'three/examples/jsm/objects/Sky.js'
|
|
|
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
|
|
+import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'
|
|
|
+
|
|
|
+const containerRef = ref<HTMLDivElement>()
|
|
|
+
|
|
|
+let scene!: THREE.Scene
|
|
|
+let camera!: THREE.PerspectiveCamera
|
|
|
+let renderer!: THREE.WebGLRenderer
|
|
|
+let controls!: OrbitControls
|
|
|
+let animationId: number
|
|
|
+let sky!: Sky
|
|
|
+
|
|
|
+let qushouGroup: THREE.Group | null = null
|
|
|
+let dirLight: THREE.DirectionalLight | null = null
|
|
|
+let sunLight: THREE.DirectionalLight | null = null
|
|
|
+let raycaster: THREE.Raycaster
|
|
|
+let mouse: THREE.Vector2
|
|
|
+
|
|
|
+const pickedPosition = ref<{ x: number; y: number; z: number } | null>({ x: 0, y: 0, z: 0 })
|
|
|
+const cameraInfo = ref({
|
|
|
+ position: { x: 0, y: 0, z: 0 },
|
|
|
+ target: { x: 0, y: 0, z: 0 },
|
|
|
+ distance: 0,
|
|
|
+ minDistance: 0,
|
|
|
+ maxDistance: 0,
|
|
|
+})
|
|
|
+
|
|
|
+const showCoordinatePanel = ref(false)
|
|
|
+const showCameraPanel = ref(false)
|
|
|
+const showLightPanel = ref(false)
|
|
|
+
|
|
|
+// 光照旋转角度控制
|
|
|
+const lightRotation = reactive({
|
|
|
+ x: 2, // 绕 X 轴旋转 (°)
|
|
|
+ y: 18, // 绕 Y 轴旋转 (°)
|
|
|
+ z: 104, // 绕 Z 轴旋转 (°)
|
|
|
+})
|
|
|
+
|
|
|
+function updateLightPosition() {
|
|
|
+ if (!sunLight) return
|
|
|
+ // 光源距离:~2828,模拟远距离日光
|
|
|
+ const basePos = new THREE.Vector3(2000, 2000, 0)
|
|
|
+ const euler = new THREE.Euler(
|
|
|
+ THREE.MathUtils.degToRad(lightRotation.x),
|
|
|
+ THREE.MathUtils.degToRad(lightRotation.y),
|
|
|
+ THREE.MathUtils.degToRad(lightRotation.z),
|
|
|
+ 'XYZ'
|
|
|
+ )
|
|
|
+ basePos.applyEuler(euler)
|
|
|
+ sunLight.position.copy(basePos)
|
|
|
+ // 同步天空太阳位置(场景与 Sky 均为 Y-up,直接使用)
|
|
|
+ if (sky) {
|
|
|
+ sky.material.uniforms.sunPosition.value.copy(basePos)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+watch(() => [lightRotation.x, lightRotation.y, lightRotation.z], updateLightPosition)
|
|
|
+
|
|
|
+let sceneInitialized = false
|
|
|
+
|
|
|
+const modelTransform = reactive({ positionX: 0, positionY: 0, positionZ: 0, rotationX: 0, rotationY: 0, rotationZ: 0, scaleX: 1, scaleY: 1, scaleZ: 1 })
|
|
|
+
|
|
|
+watch(modelTransform, () => {
|
|
|
+ if (!qushouGroup) return
|
|
|
+ const t = modelTransform
|
|
|
+ qushouGroup.position.set(t.positionX, t.positionY, t.positionZ)
|
|
|
+ qushouGroup.rotation.set(THREE.MathUtils.degToRad(t.rotationX), THREE.MathUtils.degToRad(t.rotationY), THREE.MathUtils.degToRad(t.rotationZ))
|
|
|
+ qushouGroup.scale.set(t.scaleX, t.scaleY, t.scaleZ)
|
|
|
+}, { deep: true })
|
|
|
+
|
|
|
+// 释放 Three.js 资源
|
|
|
+function disposeScene() {
|
|
|
+ cancelAnimationFrame(animationId)
|
|
|
+ animationId = 0
|
|
|
+
|
|
|
+ if (qushouGroup) {
|
|
|
+ if (scene) scene.remove(qushouGroup)
|
|
|
+ qushouGroup.traverse((child) => {
|
|
|
+ const mesh = child as THREE.Mesh
|
|
|
+ if (mesh.isMesh) {
|
|
|
+ mesh.geometry?.dispose()
|
|
|
+ if (Array.isArray(mesh.material)) {
|
|
|
+ mesh.material.forEach(m => m.dispose())
|
|
|
+ } else {
|
|
|
+ mesh.material?.dispose()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ qushouGroup = null
|
|
|
+ }
|
|
|
+
|
|
|
+ if (sky) {
|
|
|
+ if (scene) scene.remove(sky)
|
|
|
+ sky.material.dispose()
|
|
|
+ sky = null as any
|
|
|
+ }
|
|
|
+
|
|
|
+ if (scene) {
|
|
|
+ while (scene.children.length > 0) {
|
|
|
+ scene.remove(scene.children[0])
|
|
|
+ }
|
|
|
+ scene = null as any
|
|
|
+ }
|
|
|
+
|
|
|
+ if (controls) {
|
|
|
+ controls.dispose()
|
|
|
+ controls = null as any
|
|
|
+ }
|
|
|
+
|
|
|
+ if (renderer) {
|
|
|
+ const domEl = renderer.domElement
|
|
|
+ if (domEl && domEl.parentNode) {
|
|
|
+ domEl.parentNode.removeChild(domEl)
|
|
|
+ }
|
|
|
+ renderer.forceContextLoss()
|
|
|
+ renderer.dispose()
|
|
|
+ renderer = null as any
|
|
|
+ }
|
|
|
+
|
|
|
+ camera = null as any
|
|
|
+ sceneInitialized = false
|
|
|
+}
|
|
|
+
|
|
|
+function initScene() {
|
|
|
+ if (sceneInitialized) {
|
|
|
+ disposeScene()
|
|
|
+ }
|
|
|
+ const container = containerRef.value!
|
|
|
+
|
|
|
+ scene = new THREE.Scene()
|
|
|
+ scene.background = new THREE.Color(0x87ceeb)
|
|
|
+
|
|
|
+ camera = new THREE.PerspectiveCamera(60, container.clientWidth / container.clientHeight, 0.01, 100000)
|
|
|
+
|
|
|
+ renderer = new THREE.WebGLRenderer({ antialias: true })
|
|
|
+ renderer.setSize(container.clientWidth, container.clientHeight)
|
|
|
+ renderer.domElement.style.width = '100%'
|
|
|
+ renderer.domElement.style.height = '100%'
|
|
|
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
|
|
+ renderer.toneMapping = THREE.ACESFilmicToneMapping
|
|
|
+ renderer.toneMappingExposure = 1.2
|
|
|
+ renderer.outputColorSpace = THREE.SRGBColorSpace
|
|
|
+ ;(renderer as any).samples = 4
|
|
|
+ renderer.shadowMap.enabled = true
|
|
|
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap
|
|
|
+ container.appendChild(renderer.domElement)
|
|
|
+
|
|
|
+ controls = new OrbitControls(camera, renderer.domElement)
|
|
|
+ controls.target.set(0, 0, 0)
|
|
|
+ controls.enablePan = false
|
|
|
+ controls.enableRotate = true
|
|
|
+ controls.minDistance = 20
|
|
|
+ controls.maxDistance = 300
|
|
|
+ controls.mouseButtons = {
|
|
|
+ RIGHT: THREE.MOUSE.ROTATE,
|
|
|
+ }
|
|
|
+
|
|
|
+ sky = new Sky()
|
|
|
+ sky.scale.setScalar(10000000)
|
|
|
+ scene.add(sky)
|
|
|
+
|
|
|
+ const skyUniforms = sky.material.uniforms
|
|
|
+ skyUniforms.turbidity.value = 6
|
|
|
+ skyUniforms.rayleigh.value = 0.1
|
|
|
+ skyUniforms.mieCoefficient.value = 0.005
|
|
|
+ skyUniforms.mieDirectionalG.value = 0.7
|
|
|
+
|
|
|
+ const hemisphereLight = new THREE.HemisphereLight(0x87ceeb, 0x444444, 0.5)
|
|
|
+ scene.add(hemisphereLight)
|
|
|
+
|
|
|
+ // 环境填充光(不投影,负责整体照明)——已关闭
|
|
|
+ dirLight = new THREE.DirectionalLight(0xffffff, 0)
|
|
|
+ dirLight.position.set(-50, 80, 50)
|
|
|
+ scene.add(dirLight)
|
|
|
+
|
|
|
+ // 太阳投影光(主日光,负责阴影和照明)
|
|
|
+ sunLight = new THREE.DirectionalLight(0xffeedd, 2.5)
|
|
|
+ sunLight.castShadow = true
|
|
|
+ sunLight.shadow.mapSize.width = 4096
|
|
|
+ sunLight.shadow.mapSize.height = 4096
|
|
|
+ sunLight.shadow.camera.near = 0.5
|
|
|
+ sunLight.shadow.camera.far = 5000
|
|
|
+ sunLight.shadow.camera.left = -600
|
|
|
+ sunLight.shadow.camera.right = 600
|
|
|
+ sunLight.shadow.camera.top = 600
|
|
|
+ sunLight.shadow.camera.bottom = -600
|
|
|
+ sunLight.shadow.bias = -0.0005
|
|
|
+ sunLight.shadow.normalBias = 0.005
|
|
|
+ sunLight.shadow.radius = 2
|
|
|
+ scene.add(sunLight)
|
|
|
+ updateLightPosition()
|
|
|
+
|
|
|
+ // 创建 HDR 环境贴图(PBR 材质必需,否则粗糙度/金属度失效)
|
|
|
+ const pmremGenerator = new THREE.PMREMGenerator(renderer)
|
|
|
+ const envTexture = pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture
|
|
|
+ envTexture.mapping = THREE.EquirectangularReflectionMapping
|
|
|
+ scene.environment = envTexture
|
|
|
+ scene.environmentIntensity = 0.4
|
|
|
+ scene.backgroundIntensity = 0.15
|
|
|
+ pmremGenerator.dispose()
|
|
|
+
|
|
|
+ raycaster = new THREE.Raycaster()
|
|
|
+ mouse = new THREE.Vector2()
|
|
|
+
|
|
|
+ createTestScene()
|
|
|
+ sceneInitialized = true
|
|
|
+ animate()
|
|
|
+}
|
|
|
+
|
|
|
+function createTestScene() {
|
|
|
+ // 加载渠首模型
|
|
|
+ loadQushouModel()
|
|
|
+ // 初始化光照位置
|
|
|
+ if (sunLight) updateLightPosition()
|
|
|
+}
|
|
|
+
|
|
|
+async function loadQushouModel() {
|
|
|
+ try {
|
|
|
+ const loader = new GLTFLoader()
|
|
|
+ const gltf = await loader.loadAsync('/qushou/qushou03.glb')
|
|
|
+ gltf.scene.position.set(0, 0, 0)
|
|
|
+ gltf.scene.traverse((child) => {
|
|
|
+ if ((child as THREE.Mesh).isMesh) {
|
|
|
+ const mesh = child as THREE.Mesh
|
|
|
+ mesh.castShadow = true
|
|
|
+ mesh.receiveShadow = true
|
|
|
+
|
|
|
+ // 检查并转换材质为 PBR(MeshStandardMaterial / MeshPhysicalMaterial)
|
|
|
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
|
|
+ for (let i = 0; i < materials.length; i++) {
|
|
|
+ let mat = materials[i]
|
|
|
+ mat.side = THREE.DoubleSide
|
|
|
+
|
|
|
+ // 如果不是 PBR 材质,手动创建 MeshStandardMaterial 并复制属性
|
|
|
+ if (!(mat instanceof THREE.MeshStandardMaterial) && !(mat instanceof THREE.MeshPhysicalMaterial)) {
|
|
|
+ console.warn(`[Qushou] 非 PBR 材质 (${mat.type}),正在转换为 MeshStandardMaterial...`)
|
|
|
+ const pbr = new THREE.MeshStandardMaterial()
|
|
|
+ pbr.color.copy((mat as any).color || new THREE.Color(0xffffff))
|
|
|
+ if ((mat as any).map) pbr.map = (mat as any).map
|
|
|
+ if ((mat as any).normalMap) pbr.normalMap = (mat as any).normalMap
|
|
|
+ if ((mat as any).roughnessMap) pbr.roughnessMap = (mat as any).roughnessMap
|
|
|
+ if ((mat as any).metalnessMap) pbr.metalnessMap = (mat as any).metalnessMap
|
|
|
+ if ((mat as any).aoMap) pbr.aoMap = (mat as any).aoMap
|
|
|
+ if ((mat as any).emissiveMap) pbr.emissiveMap = (mat as any).emissiveMap
|
|
|
+ if ((mat as any).displacementMap) pbr.displacementMap = (mat as any).displacementMap
|
|
|
+ pbr.roughness = (mat as any).roughness ?? 0.5
|
|
|
+ pbr.metalness = (mat as any).metalness ?? 0.5
|
|
|
+ pbr.emissive.copy((mat as any).emissive || new THREE.Color(0x000000))
|
|
|
+ pbr.opacity = (mat as any).opacity ?? 1
|
|
|
+ pbr.transparent = (mat as any).transparent ?? false
|
|
|
+ pbr.side = THREE.DoubleSide
|
|
|
+ if (Array.isArray(mesh.material)) {
|
|
|
+ mesh.material[i] = pbr
|
|
|
+ } else {
|
|
|
+ mesh.material = pbr
|
|
|
+ }
|
|
|
+ mat.dispose()
|
|
|
+ mat = pbr
|
|
|
+ }
|
|
|
+
|
|
|
+ // 确保 PBR 纹理正确初始化
|
|
|
+ for (const key of ['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'aoMap', 'emissiveMap', 'displacementMap']) {
|
|
|
+ const tex = (mat as any)[key] as THREE.Texture | undefined
|
|
|
+ if (tex) {
|
|
|
+ tex.needsUpdate = true
|
|
|
+ if (!tex.wrapS) tex.wrapS = THREE.RepeatWrapping
|
|
|
+ if (!tex.wrapT) tex.wrapT = THREE.RepeatWrapping
|
|
|
+ if (key === 'normalMap') {
|
|
|
+ tex.colorSpace = THREE.LinearSRGBColorSpace
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if ((mat as any).normalScale) {
|
|
|
+ const ns = (mat as any).normalScale as THREE.Vector2
|
|
|
+ if (ns.x === 0 && ns.y === 0) ns.set(1, 1)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ qushouGroup = gltf.scene
|
|
|
+ scene.add(gltf.scene)
|
|
|
+
|
|
|
+ controls.target.set(0, 0, 0)
|
|
|
+ camera.position.set(188, 67, 16)
|
|
|
+ camera.lookAt(0, 0, 0)
|
|
|
+ } catch (e) {
|
|
|
+ console.error('渠首模型加载失败:', e)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function onMouseClick(event: MouseEvent) {
|
|
|
+ const target = event.target as HTMLElement
|
|
|
+ if (target.closest('.panel') || target.closest('.toolbar')) return
|
|
|
+ if (!containerRef.value) return
|
|
|
+ const rect = containerRef.value.getBoundingClientRect()
|
|
|
+ mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
|
|
+ mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
|
|
|
+
|
|
|
+ raycaster.setFromCamera(mouse, camera)
|
|
|
+ const intersects = raycaster.intersectObject(scene, true)
|
|
|
+ if (intersects.length > 0) {
|
|
|
+ const point = intersects[0].point
|
|
|
+ pickedPosition.value = { x: Math.round(point.x * 100) / 100, y: Math.round(point.y * 100) / 100, z: Math.round(point.z * 100) / 100 }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function animate() {
|
|
|
+ animationId = requestAnimationFrame(animate)
|
|
|
+
|
|
|
+ cameraInfo.value.position = {
|
|
|
+ x: Number(camera.position.x.toFixed(3)),
|
|
|
+ y: Number(camera.position.y.toFixed(3)),
|
|
|
+ z: Number(camera.position.z.toFixed(3)),
|
|
|
+ }
|
|
|
+ cameraInfo.value.target = {
|
|
|
+ x: Number(controls.target.x.toFixed(3)),
|
|
|
+ y: Number(controls.target.y.toFixed(3)),
|
|
|
+ z: Number(controls.target.z.toFixed(3)),
|
|
|
+ }
|
|
|
+ cameraInfo.value.distance = Number(camera.position.distanceTo(controls.target).toFixed(3))
|
|
|
+
|
|
|
+ renderer.render(scene, camera)
|
|
|
+}
|
|
|
+
|
|
|
+function onResize() {
|
|
|
+ if (!containerRef.value) return
|
|
|
+ const width = containerRef.value.clientWidth
|
|
|
+ const height = containerRef.value.clientHeight
|
|
|
+ camera.aspect = width / height
|
|
|
+ camera.updateProjectionMatrix()
|
|
|
+ renderer.setSize(width, height)
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ initScene()
|
|
|
+ window.addEventListener('resize', onResize)
|
|
|
+ window.addEventListener('click', onMouseClick)
|
|
|
+})
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ window.removeEventListener('resize', onResize)
|
|
|
+ window.removeEventListener('click', onMouseClick)
|
|
|
+ disposeScene()
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <div ref="containerRef" class="scene-container" />
|
|
|
+ <div class="toolbar">
|
|
|
+ <button class="toolbar-btn" :class="{ active: showCoordinatePanel }" @click="showCoordinatePanel = !showCoordinatePanel">坐标</button>
|
|
|
+ <button class="toolbar-btn" :class="{ active: showCameraPanel }" @click="showCameraPanel = !showCameraPanel">相机</button>
|
|
|
+ <button class="toolbar-btn" :class="{ active: showLightPanel }" @click="showLightPanel = !showLightPanel">光照</button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="showCoordinatePanel && pickedPosition" class="panel coordinate-panel">
|
|
|
+ <div class="panel-header">
|
|
|
+ <span class="panel-title">拾取坐标 (m)</span>
|
|
|
+ <button class="toggle-btn" @click="showCoordinatePanel = false">×</button>
|
|
|
+ </div>
|
|
|
+ <div class="coordinate-item">
|
|
|
+ <span class="coordinate-label">X:</span>
|
|
|
+ <span class="coordinate-value">{{ pickedPosition.x }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="coordinate-item">
|
|
|
+ <span class="coordinate-label">Y:</span>
|
|
|
+ <span class="coordinate-value">{{ pickedPosition.y }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="coordinate-item">
|
|
|
+ <span class="coordinate-label">Z:</span>
|
|
|
+ <span class="coordinate-value">{{ pickedPosition.z }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="showCameraPanel" class="panel camera-panel">
|
|
|
+ <div class="panel-header">
|
|
|
+ <span class="panel-title">相机信息</span>
|
|
|
+ <button class="toggle-btn" @click="showCameraPanel = false">×</button>
|
|
|
+ </div>
|
|
|
+ <div class="camera-section">
|
|
|
+ <div class="section-label">位置 (m)</div>
|
|
|
+ <div class="camera-item">
|
|
|
+ <span class="camera-label">X:</span>
|
|
|
+ <span class="camera-value">{{ cameraInfo.position.x }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="camera-item">
|
|
|
+ <span class="camera-label">Y:</span>
|
|
|
+ <span class="camera-value">{{ cameraInfo.position.y }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="camera-item">
|
|
|
+ <span class="camera-label">Z:</span>
|
|
|
+ <span class="camera-value">{{ cameraInfo.position.z }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="camera-section">
|
|
|
+ <div class="section-label">目标点 (m)</div>
|
|
|
+ <div class="camera-item">
|
|
|
+ <span class="camera-label">X:</span>
|
|
|
+ <span class="camera-value">{{ cameraInfo.target.x }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="camera-item">
|
|
|
+ <span class="camera-label">Y:</span>
|
|
|
+ <span class="camera-value">{{ cameraInfo.target.y }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="camera-item">
|
|
|
+ <span class="camera-label">Z:</span>
|
|
|
+ <span class="camera-value">{{ cameraInfo.target.z }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="camera-section">
|
|
|
+ <div class="section-label">缩放距离 (m)</div>
|
|
|
+ <div class="camera-item">
|
|
|
+ <span class="camera-label">当前:</span>
|
|
|
+ <span class="camera-value">{{ cameraInfo.distance }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="camera-item">
|
|
|
+ <span class="camera-label">最近:</span>
|
|
|
+ <span class="camera-value">{{ cameraInfo.minDistance }}m</span>
|
|
|
+ </div>
|
|
|
+ <div class="camera-item">
|
|
|
+ <span class="camera-label">最远:</span>
|
|
|
+ <span class="camera-value">{{ cameraInfo.maxDistance }}m</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="showLightPanel" class="panel light-panel">
|
|
|
+ <div class="panel-header">
|
|
|
+ <span class="panel-title">光照旋转</span>
|
|
|
+ <button class="toggle-btn" @click="showLightPanel = false">×</button>
|
|
|
+ </div>
|
|
|
+ <div class="light-section">
|
|
|
+ <div class="section-label">绕 X 轴</div>
|
|
|
+ <div class="slider-item">
|
|
|
+ <input type="range" v-model.number="lightRotation.x" min="-180" max="180" step="1" />
|
|
|
+ <span class="slider-value">{{ lightRotation.x }}°</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="light-section">
|
|
|
+ <div class="section-label">绕 Y 轴</div>
|
|
|
+ <div class="slider-item">
|
|
|
+ <input type="range" v-model.number="lightRotation.y" min="-180" max="180" step="1" />
|
|
|
+ <span class="slider-value">{{ lightRotation.y }}°</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="light-section">
|
|
|
+ <div class="section-label">绕 Z 轴</div>
|
|
|
+ <div class="slider-item">
|
|
|
+ <input type="range" v-model.number="lightRotation.z" min="-180" max="180" step="1" />
|
|
|
+ <span class="slider-value">{{ lightRotation.z }}°</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.scene-container {
|
|
|
+ position: fixed;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ width: 100vw;
|
|
|
+ height: 100vh;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar {
|
|
|
+ position: fixed;
|
|
|
+ top: 20px;
|
|
|
+ right: 20px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+ z-index: 1001;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-btn {
|
|
|
+ padding: 10px 16px;
|
|
|
+ border: none;
|
|
|
+ border-radius: 8px;
|
|
|
+ background: rgba(0, 0, 0, 0.85);
|
|
|
+ color: #888;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: bold;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.2s;
|
|
|
+ min-width: 70px;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-btn:hover {
|
|
|
+ background: rgba(30, 30, 30, 0.9);
|
|
|
+ color: #fff;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-btn.active {
|
|
|
+ color: #fff;
|
|
|
+ box-shadow: 0 0 10px rgba(255, 255, 255, 0.2);
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-btn:nth-child(1).active { color: #4fc3f7; }
|
|
|
+.toolbar-btn:nth-child(2).active { color: #ff9800; }
|
|
|
+.toolbar-btn:nth-child(3).active { color: #ffeb3b; }
|
|
|
+
|
|
|
+.panel {
|
|
|
+ position: fixed;
|
|
|
+ top: 20px;
|
|
|
+ left: 20px;
|
|
|
+ background: rgba(0, 0, 0, 0.85);
|
|
|
+ color: white;
|
|
|
+ padding: 15px;
|
|
|
+ border-radius: 8px;
|
|
|
+ font-family: 'Courier New', monospace;
|
|
|
+ z-index: 1000;
|
|
|
+ max-height: calc(100vh - 40px);
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.coordinate-panel { min-width: 200px; }
|
|
|
+.camera-panel { min-width: 200px; }
|
|
|
+.light-panel { min-width: 240px; }
|
|
|
+
|
|
|
+.light-panel .slider-item input[type="range"]::-webkit-slider-thumb {
|
|
|
+ background: #ffeb3b;
|
|
|
+}
|
|
|
+
|
|
|
+.panel-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ padding-bottom: 8px;
|
|
|
+ border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
|
|
+}
|
|
|
+
|
|
|
+.panel-title {
|
|
|
+ font-weight: bold;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.coordinate-panel .panel-title { color: #4fc3f7; }
|
|
|
+.camera-panel .panel-title { color: #ff9800; }
|
|
|
+.light-panel .panel-title { color: #ffeb3b; }
|
|
|
+
|
|
|
+.toggle-btn {
|
|
|
+ width: 22px;
|
|
|
+ height: 22px;
|
|
|
+ border: none;
|
|
|
+ border-radius: 4px;
|
|
|
+ background: rgba(255, 255, 255, 0.15);
|
|
|
+ color: white;
|
|
|
+ font-size: 14px;
|
|
|
+ line-height: 1;
|
|
|
+ cursor: pointer;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ transition: background 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.toggle-btn:hover {
|
|
|
+ background: rgba(255, 255, 255, 0.3);
|
|
|
+}
|
|
|
+
|
|
|
+.coordinate-item {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ margin: 5px 0;
|
|
|
+}
|
|
|
+
|
|
|
+.coordinate-label {
|
|
|
+ color: #81c784;
|
|
|
+ font-weight: bold;
|
|
|
+}
|
|
|
+
|
|
|
+.coordinate-value {
|
|
|
+ color: #fff;
|
|
|
+}
|
|
|
+
|
|
|
+.camera-section,
|
|
|
+.light-section {
|
|
|
+ margin-bottom: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.section-label {
|
|
|
+ color: #81c784;
|
|
|
+ font-size: 11px;
|
|
|
+ margin-bottom: 5px;
|
|
|
+ font-weight: bold;
|
|
|
+}
|
|
|
+
|
|
|
+.camera-item {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ margin: 3px 0;
|
|
|
+}
|
|
|
+
|
|
|
+.camera-label {
|
|
|
+ color: #4fc3f7;
|
|
|
+}
|
|
|
+
|
|
|
+.camera-value {
|
|
|
+ color: #fff;
|
|
|
+}
|
|
|
+</style>
|