| import * as THREE from 'three' | |
| import type { CameraState, CameraWidgetOptions } from './types' | |
| export class CameraWidget { | |
| private container: HTMLElement | |
| private state: CameraState | |
| private onStateChange?: (state: CameraState) => void | |
| private scene!: THREE.Scene | |
| private camera!: THREE.PerspectiveCamera | |
| private previewCamera!: THREE.PerspectiveCamera | |
| private renderer!: THREE.WebGLRenderer | |
| private activeCamera!: THREE.Camera | |
| private cameraIndicator!: THREE.Mesh | |
| private camGlow!: THREE.Mesh | |
| private azimuthHandle!: THREE.Mesh | |
| private azGlow!: THREE.Mesh | |
| private elevationHandle!: THREE.Mesh | |
| private elGlow!: THREE.Mesh | |
| private distanceHandle!: THREE.Mesh | |
| private distGlow!: THREE.Mesh | |
| private glowRing!: THREE.Mesh | |
| private imagePlane!: THREE.Mesh | |
| private imageFrame!: THREE.LineSegments | |
| private planeMat!: THREE.MeshBasicMaterial | |
| private distanceTube: THREE.Mesh | null = null | |
| private azimuthRing!: THREE.Mesh | |
| private elevationArc!: THREE.Mesh | |
| private gridHelper!: THREE.GridHelper | |
| private readonly CENTER = new THREE.Vector3(0, 0.5, 0) | |
| private readonly AZIMUTH_RADIUS = 1.8 | |
| private readonly ELEVATION_RADIUS = 1.4 | |
| private readonly ELEV_ARC_X = -0.8 | |
| private liveAzimuth = 0 | |
| private liveElevation = 0 | |
| private liveDistance = 5 | |
| private isDragging = false | |
| private dragTarget: string | null = null | |
| private hoveredHandle: { mesh: THREE.Mesh; glow: THREE.Mesh; name: string } | null = null | |
| private raycaster = new THREE.Raycaster() | |
| private mouse = new THREE.Vector2() | |
| private useCameraView = false | |
| private isOrbitDragging = false | |
| private orbitStartX = 0 | |
| private orbitStartY = 0 | |
| private orbitStartAzimuth = 0 | |
| private orbitStartElevation = 0 | |
| private animationId: number | null = null | |
| private time = 0 | |
| constructor(options: CameraWidgetOptions) { | |
| this.container = options.container | |
| this.onStateChange = options.onStateChange | |
| this.state = { | |
| azimuth: options.initialState?.azimuth ?? 0, | |
| elevation: options.initialState?.elevation ?? 0, | |
| distance: options.initialState?.distance ?? 5, | |
| imageUrl: options.initialState?.imageUrl ?? null | |
| } | |
| this.liveAzimuth = this.state.azimuth | |
| this.liveElevation = this.state.elevation | |
| this.liveDistance = this.state.distance | |
| this.initThreeJS() | |
| this.bindEvents() | |
| this.animate() | |
| } | |
| private initThreeJS(): void { | |
| const width = this.container.clientWidth || 300 | |
| const height = this.container.clientHeight || 300 | |
| this.scene = new THREE.Scene() | |
| this.scene.background = new THREE.Color(0x0a0a0f) | |
| this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000) | |
| this.camera.position.set(4, 3.5, 4) | |
| this.camera.lookAt(0, 0.3, 0) | |
| this.previewCamera = new THREE.PerspectiveCamera(50, width / height, 0.1, 100) | |
| this.activeCamera = this.camera | |
| this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }) | |
| this.renderer.setSize(width, height, false) | |
| this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) | |
| this.renderer.outputColorSpace = THREE.SRGBColorSpace | |
| this.container.appendChild(this.renderer.domElement) | |
| const canvas = this.renderer.domElement | |
| canvas.style.position = 'absolute' | |
| canvas.style.top = '0' | |
| canvas.style.left = '0' | |
| canvas.style.width = '100%' | |
| canvas.style.height = '100%' | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.4) | |
| this.scene.add(ambientLight) | |
| const mainLight = new THREE.DirectionalLight(0xffffff, 0.8) | |
| mainLight.position.set(5, 10, 5) | |
| this.scene.add(mainLight) | |
| const fillLight = new THREE.DirectionalLight(0xE93D82, 0.3) | |
| fillLight.position.set(-5, 5, -5) | |
| this.scene.add(fillLight) | |
| this.gridHelper = new THREE.GridHelper(5, 20, 0x1a1a2e, 0x12121a) | |
| this.gridHelper.position.y = -0.01 | |
| this.scene.add(this.gridHelper) | |
| this.createSubject() | |
| this.createCameraIndicator() | |
| this.createAzimuthRing() | |
| this.createElevationArc() | |
| this.createDistanceHandle() | |
| this.updateVisuals() | |
| } | |
| private createGridTexture(): THREE.CanvasTexture { | |
| const canvas = document.createElement('canvas') | |
| const size = 256 | |
| canvas.width = size | |
| canvas.height = size | |
| const ctx = canvas.getContext('2d')! | |
| ctx.fillStyle = '#1a1a2a' | |
| ctx.fillRect(0, 0, size, size) | |
| ctx.strokeStyle = '#2a2a3a' | |
| ctx.lineWidth = 1 | |
| const gridSize = 16 | |
| for (let i = 0; i <= size; i += gridSize) { | |
| ctx.beginPath() | |
| ctx.moveTo(i, 0) | |
| ctx.lineTo(i, size) | |
| ctx.stroke() | |
| ctx.beginPath() | |
| ctx.moveTo(0, i) | |
| ctx.lineTo(size, i) | |
| ctx.stroke() | |
| } | |
| const texture = new THREE.CanvasTexture(canvas) | |
| texture.wrapS = THREE.RepeatWrapping | |
| texture.wrapT = THREE.RepeatWrapping | |
| texture.repeat.set(4, 4) | |
| return texture | |
| } | |
| private createSubject(): void { | |
| const cardThickness = 0.02 | |
| const cardGeo = new THREE.BoxGeometry(1.2, 1.2, cardThickness) | |
| const frontMat = new THREE.MeshBasicMaterial({ color: 0x3a3a4a }) | |
| const backMat = new THREE.MeshBasicMaterial({ map: this.createGridTexture() }) | |
| const edgeMat = new THREE.MeshBasicMaterial({ color: 0x1a1a2a }) | |
| const cardMaterials = [edgeMat, edgeMat, edgeMat, edgeMat, frontMat, backMat] | |
| this.imagePlane = new THREE.Mesh(cardGeo, cardMaterials) | |
| this.imagePlane.position.copy(this.CENTER) | |
| this.scene.add(this.imagePlane) | |
| this.planeMat = frontMat | |
| const frameGeo = new THREE.EdgesGeometry(cardGeo) | |
| const frameMat = new THREE.LineBasicMaterial({ color: 0xE93D82 }) | |
| this.imageFrame = new THREE.LineSegments(frameGeo, frameMat) | |
| this.imageFrame.position.copy(this.CENTER) | |
| this.scene.add(this.imageFrame) | |
| const glowRingGeo = new THREE.RingGeometry(0.55, 0.58, 64) | |
| const glowRingMat = new THREE.MeshBasicMaterial({ | |
| color: 0xE93D82, | |
| transparent: true, | |
| opacity: 0.4, | |
| side: THREE.DoubleSide | |
| }) | |
| this.glowRing = new THREE.Mesh(glowRingGeo, glowRingMat) | |
| this.glowRing.position.set(0, 0.01, 0) | |
| this.glowRing.rotation.x = -Math.PI / 2 | |
| this.scene.add(this.glowRing) | |
| } | |
| private createCameraIndicator(): void { | |
| const camGeo = new THREE.ConeGeometry(0.15, 0.4, 4) | |
| const camMat = new THREE.MeshStandardMaterial({ | |
| color: 0xE93D82, | |
| emissive: 0xE93D82, | |
| emissiveIntensity: 0.5, | |
| metalness: 0.8, | |
| roughness: 0.2 | |
| }) | |
| this.cameraIndicator = new THREE.Mesh(camGeo, camMat) | |
| this.scene.add(this.cameraIndicator) | |
| const camGlowGeo = new THREE.SphereGeometry(0.08, 16, 16) | |
| const camGlowMat = new THREE.MeshBasicMaterial({ | |
| color: 0xff6ba8, | |
| transparent: true, | |
| opacity: 0.8 | |
| }) | |
| this.camGlow = new THREE.Mesh(camGlowGeo, camGlowMat) | |
| this.scene.add(this.camGlow) | |
| } | |
| private createAzimuthRing(): void { | |
| const azRingGeo = new THREE.TorusGeometry(this.AZIMUTH_RADIUS, 0.04, 16, 100) | |
| const azRingMat = new THREE.MeshBasicMaterial({ | |
| color: 0xE93D82, | |
| transparent: true, | |
| opacity: 0.7 | |
| }) | |
| this.azimuthRing = new THREE.Mesh(azRingGeo, azRingMat) | |
| this.azimuthRing.rotation.x = Math.PI / 2 | |
| this.azimuthRing.position.y = 0.02 | |
| this.scene.add(this.azimuthRing) | |
| const azHandleGeo = new THREE.SphereGeometry(0.16, 32, 32) | |
| const azHandleMat = new THREE.MeshStandardMaterial({ | |
| color: 0xE93D82, | |
| emissive: 0xE93D82, | |
| emissiveIntensity: 0.6, | |
| metalness: 0.3, | |
| roughness: 0.4 | |
| }) | |
| this.azimuthHandle = new THREE.Mesh(azHandleGeo, azHandleMat) | |
| this.scene.add(this.azimuthHandle) | |
| const azGlowGeo = new THREE.SphereGeometry(0.22, 16, 16) | |
| const azGlowMat = new THREE.MeshBasicMaterial({ | |
| color: 0xE93D82, | |
| transparent: true, | |
| opacity: 0.2 | |
| }) | |
| this.azGlow = new THREE.Mesh(azGlowGeo, azGlowMat) | |
| this.scene.add(this.azGlow) | |
| } | |
| private createElevationArc(): void { | |
| const arcPoints: THREE.Vector3[] = [] | |
| for (let i = 0; i <= 32; i++) { | |
| const angle = (-30 + (90 * i / 32)) * Math.PI / 180 | |
| arcPoints.push(new THREE.Vector3( | |
| this.ELEV_ARC_X, | |
| this.ELEVATION_RADIUS * Math.sin(angle) + this.CENTER.y, | |
| this.ELEVATION_RADIUS * Math.cos(angle) | |
| )) | |
| } | |
| const arcCurve = new THREE.CatmullRomCurve3(arcPoints) | |
| const elArcGeo = new THREE.TubeGeometry(arcCurve, 32, 0.04, 8, false) | |
| const elArcMat = new THREE.MeshBasicMaterial({ | |
| color: 0x00FFD0, | |
| transparent: true, | |
| opacity: 0.8 | |
| }) | |
| this.elevationArc = new THREE.Mesh(elArcGeo, elArcMat) | |
| this.scene.add(this.elevationArc) | |
| const elHandleGeo = new THREE.SphereGeometry(0.16, 32, 32) | |
| const elHandleMat = new THREE.MeshStandardMaterial({ | |
| color: 0x00FFD0, | |
| emissive: 0x00FFD0, | |
| emissiveIntensity: 0.6, | |
| metalness: 0.3, | |
| roughness: 0.4 | |
| }) | |
| this.elevationHandle = new THREE.Mesh(elHandleGeo, elHandleMat) | |
| this.scene.add(this.elevationHandle) | |
| const elGlowGeo = new THREE.SphereGeometry(0.22, 16, 16) | |
| const elGlowMat = new THREE.MeshBasicMaterial({ | |
| color: 0x00FFD0, | |
| transparent: true, | |
| opacity: 0.2 | |
| }) | |
| this.elGlow = new THREE.Mesh(elGlowGeo, elGlowMat) | |
| this.scene.add(this.elGlow) | |
| } | |
| private createDistanceHandle(): void { | |
| const distHandleGeo = new THREE.SphereGeometry(0.15, 32, 32) | |
| const distHandleMat = new THREE.MeshStandardMaterial({ | |
| color: 0xFFB800, | |
| emissive: 0xFFB800, | |
| emissiveIntensity: 0.7, | |
| metalness: 0.5, | |
| roughness: 0.3 | |
| }) | |
| this.distanceHandle = new THREE.Mesh(distHandleGeo, distHandleMat) | |
| this.scene.add(this.distanceHandle) | |
| const distGlowGeo = new THREE.SphereGeometry(0.22, 16, 16) | |
| const distGlowMat = new THREE.MeshBasicMaterial({ | |
| color: 0xFFB800, | |
| transparent: true, | |
| opacity: 0.25 | |
| }) | |
| this.distGlow = new THREE.Mesh(distGlowGeo, distGlowMat) | |
| this.scene.add(this.distGlow) | |
| } | |
| private updateDistanceLine(start: THREE.Vector3, end: THREE.Vector3): void { | |
| if (this.distanceTube) { | |
| this.scene.remove(this.distanceTube) | |
| this.distanceTube.geometry.dispose() | |
| ;(this.distanceTube.material as THREE.Material).dispose() | |
| } | |
| const path = new THREE.LineCurve3(start, end) | |
| const tubeGeo = new THREE.TubeGeometry(path, 1, 0.025, 8, false) | |
| const tubeMat = new THREE.MeshBasicMaterial({ | |
| color: 0xFFB800, | |
| transparent: true, | |
| opacity: 0.8 | |
| }) | |
| this.distanceTube = new THREE.Mesh(tubeGeo, tubeMat) | |
| this.scene.add(this.distanceTube) | |
| } | |
| private updateVisuals(): void { | |
| const azRad = (this.liveAzimuth * Math.PI) / 180 | |
| const elRad = (this.liveElevation * Math.PI) / 180 | |
| const visualDist = 2.6 - (this.liveDistance / 10) * 2.0 | |
| const camX = visualDist * Math.sin(azRad) * Math.cos(elRad) | |
| const camY = this.CENTER.y + visualDist * Math.sin(elRad) | |
| const camZ = visualDist * Math.cos(azRad) * Math.cos(elRad) | |
| this.cameraIndicator.position.set(camX, camY, camZ) | |
| this.cameraIndicator.lookAt(this.CENTER) | |
| this.cameraIndicator.rotateX(Math.PI / 2) | |
| this.camGlow.position.copy(this.cameraIndicator.position) | |
| const azX = this.AZIMUTH_RADIUS * Math.sin(azRad) | |
| const azZ = this.AZIMUTH_RADIUS * Math.cos(azRad) | |
| this.azimuthHandle.position.set(azX, 0.16, azZ) | |
| this.azGlow.position.copy(this.azimuthHandle.position) | |
| const elY = this.CENTER.y + this.ELEVATION_RADIUS * Math.sin(elRad) | |
| const elZ = this.ELEVATION_RADIUS * Math.cos(elRad) | |
| this.elevationHandle.position.set(this.ELEV_ARC_X, elY, elZ) | |
| this.elGlow.position.copy(this.elevationHandle.position) | |
| const distT = 0.15 + ((10 - this.liveDistance) / 10) * 0.7 | |
| this.distanceHandle.position.lerpVectors(this.CENTER, this.cameraIndicator.position, distT) | |
| this.distGlow.position.copy(this.distanceHandle.position) | |
| this.updateDistanceLine(this.CENTER.clone(), this.cameraIndicator.position.clone()) | |
| this.previewCamera.position.copy(this.cameraIndicator.position) | |
| this.previewCamera.lookAt(this.CENTER) | |
| this.glowRing.rotation.z += 0.005 | |
| } | |
| private bindEvents(): void { | |
| const canvas = this.renderer.domElement | |
| canvas.addEventListener('mousedown', this.onPointerDown.bind(this)) | |
| canvas.addEventListener('mousemove', this.onPointerMove.bind(this)) | |
| canvas.addEventListener('mouseup', this.onPointerUp.bind(this)) | |
| canvas.addEventListener('mouseleave', this.onPointerUp.bind(this)) | |
| canvas.addEventListener('touchstart', (e) => { | |
| e.preventDefault() | |
| this.onPointerDown({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY } as MouseEvent) | |
| }, { passive: false }) | |
| canvas.addEventListener('touchmove', (e) => { | |
| e.preventDefault() | |
| this.onPointerMove({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY } as MouseEvent) | |
| }, { passive: false }) | |
| canvas.addEventListener('touchend', () => this.onPointerUp()) | |
| canvas.addEventListener('wheel', this.onWheel.bind(this), { passive: false }) | |
| const resizeObserver = new ResizeObserver(() => { | |
| this.onResize() | |
| }) | |
| resizeObserver.observe(this.container) | |
| } | |
| private getMousePos(event: MouseEvent): void { | |
| const rect = this.renderer.domElement.getBoundingClientRect() | |
| this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1 | |
| this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1 | |
| } | |
| private setHandleScale(handle: THREE.Mesh, glow: THREE.Mesh | null, scale: number): void { | |
| handle.scale.setScalar(scale) | |
| if (glow) glow.scale.setScalar(scale) | |
| } | |
| private onPointerDown(event: MouseEvent): void { | |
| this.getMousePos(event) | |
| if (this.useCameraView) { | |
| this.isOrbitDragging = true | |
| this.orbitStartX = event.clientX | |
| this.orbitStartY = event.clientY | |
| this.orbitStartAzimuth = this.liveAzimuth | |
| this.orbitStartElevation = this.liveElevation | |
| this.renderer.domElement.style.cursor = 'grabbing' | |
| return | |
| } | |
| this.raycaster.setFromCamera(this.mouse, this.camera) | |
| const handles = [ | |
| { mesh: this.azimuthHandle, glow: this.azGlow, name: 'azimuth' }, | |
| { mesh: this.elevationHandle, glow: this.elGlow, name: 'elevation' }, | |
| { mesh: this.distanceHandle, glow: this.distGlow, name: 'distance' } | |
| ] | |
| for (const h of handles) { | |
| if (this.raycaster.intersectObject(h.mesh).length > 0) { | |
| this.isDragging = true | |
| this.dragTarget = h.name | |
| this.setHandleScale(h.mesh, h.glow, 1.3) | |
| this.renderer.domElement.style.cursor = 'grabbing' | |
| return | |
| } | |
| } | |
| } | |
| private onPointerMove(event: MouseEvent): void { | |
| this.getMousePos(event) | |
| if (this.useCameraView && this.isOrbitDragging) { | |
| const deltaX = event.clientX - this.orbitStartX | |
| const deltaY = event.clientY - this.orbitStartY | |
| const sensitivity = 0.5 | |
| let newAzimuth = this.orbitStartAzimuth - deltaX * sensitivity | |
| while (newAzimuth < 0) newAzimuth += 360 | |
| while (newAzimuth >= 360) newAzimuth -= 360 | |
| this.liveAzimuth = newAzimuth | |
| this.state.azimuth = Math.round(this.liveAzimuth) | |
| let newElevation = this.orbitStartElevation + deltaY * sensitivity | |
| newElevation = Math.max(-30, Math.min(60, newElevation)) | |
| this.liveElevation = newElevation | |
| this.state.elevation = Math.round(this.liveElevation) | |
| this.updateVisuals() | |
| this.notifyStateChange() | |
| return | |
| } | |
| this.raycaster.setFromCamera(this.mouse, this.camera) | |
| if (!this.isDragging) { | |
| const handles = [ | |
| { mesh: this.azimuthHandle, glow: this.azGlow, name: 'azimuth' }, | |
| { mesh: this.elevationHandle, glow: this.elGlow, name: 'elevation' }, | |
| { mesh: this.distanceHandle, glow: this.distGlow, name: 'distance' } | |
| ] | |
| let foundHover: typeof handles[0] | null = null | |
| for (const h of handles) { | |
| if (this.raycaster.intersectObject(h.mesh).length > 0) { | |
| foundHover = h | |
| break | |
| } | |
| } | |
| if (this.hoveredHandle && this.hoveredHandle !== foundHover) { | |
| this.setHandleScale(this.hoveredHandle.mesh, this.hoveredHandle.glow, 1.0) | |
| } | |
| if (foundHover) { | |
| this.setHandleScale(foundHover.mesh, foundHover.glow, 1.15) | |
| this.renderer.domElement.style.cursor = 'grab' | |
| this.hoveredHandle = foundHover | |
| } else { | |
| this.renderer.domElement.style.cursor = 'default' | |
| this.hoveredHandle = null | |
| } | |
| return | |
| } | |
| const plane = new THREE.Plane() | |
| const intersect = new THREE.Vector3() | |
| if (this.dragTarget === 'azimuth') { | |
| plane.setFromNormalAndCoplanarPoint(new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 0)) | |
| if (this.raycaster.ray.intersectPlane(plane, intersect)) { | |
| let angle = Math.atan2(intersect.x, intersect.z) * (180 / Math.PI) | |
| if (angle < 0) angle += 360 | |
| this.liveAzimuth = Math.max(0, Math.min(360, angle)) | |
| this.state.azimuth = Math.round(this.liveAzimuth) | |
| this.updateVisuals() | |
| this.notifyStateChange() | |
| } | |
| } else if (this.dragTarget === 'elevation') { | |
| const elevPlane = new THREE.Plane(new THREE.Vector3(1, 0, 0), -this.ELEV_ARC_X) | |
| if (this.raycaster.ray.intersectPlane(elevPlane, intersect)) { | |
| const relY = intersect.y - this.CENTER.y | |
| const relZ = intersect.z | |
| let angle = Math.atan2(relY, relZ) * (180 / Math.PI) | |
| angle = Math.max(-30, Math.min(60, angle)) | |
| this.liveElevation = angle | |
| this.state.elevation = Math.round(this.liveElevation) | |
| this.updateVisuals() | |
| this.notifyStateChange() | |
| } | |
| } else if (this.dragTarget === 'distance') { | |
| const newDist = 5 - this.mouse.y * 5 | |
| this.liveDistance = Math.max(0, Math.min(10, newDist)) | |
| this.state.distance = Math.round(this.liveDistance * 10) / 10 | |
| this.updateVisuals() | |
| this.notifyStateChange() | |
| } | |
| } | |
| private onPointerUp(): void { | |
| if (this.isOrbitDragging) { | |
| this.isOrbitDragging = false | |
| this.renderer.domElement.style.cursor = this.useCameraView ? 'grab' : 'default' | |
| return | |
| } | |
| if (this.isDragging) { | |
| const handles = [ | |
| { mesh: this.azimuthHandle, glow: this.azGlow }, | |
| { mesh: this.elevationHandle, glow: this.elGlow }, | |
| { mesh: this.distanceHandle, glow: this.distGlow } | |
| ] | |
| handles.forEach(h => this.setHandleScale(h.mesh, h.glow, 1.0)) | |
| } | |
| this.isDragging = false | |
| this.dragTarget = null | |
| this.renderer.domElement.style.cursor = 'default' | |
| } | |
| private onWheel(event: WheelEvent): void { | |
| if (!this.useCameraView) return | |
| event.preventDefault() | |
| const sensitivity = 0.01 | |
| let newDistance = this.liveDistance - event.deltaY * sensitivity | |
| newDistance = Math.max(0, Math.min(10, newDistance)) | |
| this.liveDistance = newDistance | |
| this.state.distance = Math.round(this.liveDistance * 10) / 10 | |
| this.updateVisuals() | |
| this.notifyStateChange() | |
| } | |
| private onResize(): void { | |
| const w = this.container.clientWidth | |
| const h = this.container.clientHeight | |
| if (w === 0 || h === 0) return | |
| this.camera.aspect = w / h | |
| this.camera.updateProjectionMatrix() | |
| this.previewCamera.aspect = w / h | |
| this.previewCamera.updateProjectionMatrix() | |
| this.renderer.setSize(w, h, false) | |
| } | |
| private animate(): void { | |
| this.animationId = requestAnimationFrame(() => this.animate()) | |
| this.time += 0.01 | |
| const pulse = 1 + Math.sin(this.time * 2) * 0.03 | |
| this.camGlow.scale.setScalar(pulse) | |
| this.glowRing.rotation.z += 0.003 | |
| this.renderer.render(this.scene, this.activeCamera) | |
| } | |
| private notifyStateChange(): void { | |
| if (this.onStateChange) { | |
| this.onStateChange({ ...this.state }) | |
| } | |
| } | |
| public generatePrompt(): string { | |
| const hAngle = this.state.azimuth % 360 | |
| let hDirection: string | |
| if (hAngle < 22.5 || hAngle >= 337.5) { | |
| hDirection = "front view" | |
| } else if (hAngle < 67.5) { | |
| hDirection = "front-right quarter view" | |
| } else if (hAngle < 112.5) { | |
| hDirection = "right side view" | |
| } else if (hAngle < 157.5) { | |
| hDirection = "back-right quarter view" | |
| } else if (hAngle < 202.5) { | |
| hDirection = "back view" | |
| } else if (hAngle < 247.5) { | |
| hDirection = "back-left quarter view" | |
| } else if (hAngle < 292.5) { | |
| hDirection = "left side view" | |
| } else { | |
| hDirection = "front-left quarter view" | |
| } | |
| let vDirection: string | |
| if (this.state.elevation < -15) { | |
| vDirection = "low-angle shot" | |
| } else if (this.state.elevation < 15) { | |
| vDirection = "eye-level shot" | |
| } else if (this.state.elevation < 45) { | |
| vDirection = "elevated shot" | |
| } else { | |
| vDirection = "high-angle shot" | |
| } | |
| let distance: string | |
| if (this.state.distance < 2) { | |
| distance = "wide shot" | |
| } else if (this.state.distance < 6) { | |
| distance = "medium shot" | |
| } else { | |
| distance = "close-up" | |
| } | |
| return `<sks> ${hDirection} ${vDirection} ${distance}` | |
| } | |
| public setState(newState: Partial<CameraState>): void { | |
| if (newState.azimuth !== undefined) { | |
| this.state.azimuth = newState.azimuth | |
| this.liveAzimuth = newState.azimuth | |
| } | |
| if (newState.elevation !== undefined) { | |
| this.state.elevation = newState.elevation | |
| this.liveElevation = newState.elevation | |
| } | |
| if (newState.distance !== undefined) { | |
| this.state.distance = newState.distance | |
| this.liveDistance = newState.distance | |
| } | |
| if (newState.imageUrl !== undefined) { | |
| this.state.imageUrl = newState.imageUrl | |
| this.updateImage(newState.imageUrl) | |
| } | |
| this.updateVisuals() | |
| } | |
| public getState(): CameraState { | |
| return { ...this.state } | |
| } | |
| public getPrompt(): string { | |
| return this.generatePrompt() | |
| } | |
| public resetToDefaults(): void { | |
| this.state.azimuth = 0 | |
| this.state.elevation = 0 | |
| this.state.distance = 5.0 | |
| this.liveAzimuth = 0 | |
| this.liveElevation = 0 | |
| this.liveDistance = 5.0 | |
| this.updateVisuals() | |
| this.notifyStateChange() | |
| } | |
| public setCameraView(enabled: boolean): void { | |
| this.useCameraView = enabled | |
| this.isOrbitDragging = false | |
| if (this.useCameraView) { | |
| this.activeCamera = this.previewCamera | |
| this.azimuthRing.visible = false | |
| this.azimuthHandle.visible = false | |
| this.azGlow.visible = false | |
| this.elevationArc.visible = false | |
| this.elevationHandle.visible = false | |
| this.elGlow.visible = false | |
| this.distanceHandle.visible = false | |
| this.distGlow.visible = false | |
| if (this.distanceTube) this.distanceTube.visible = false | |
| this.cameraIndicator.visible = false | |
| this.camGlow.visible = false | |
| this.glowRing.visible = false | |
| this.gridHelper.visible = false | |
| this.imageFrame.visible = false | |
| this.renderer.domElement.style.cursor = 'grab' | |
| } else { | |
| this.activeCamera = this.camera | |
| this.azimuthRing.visible = true | |
| this.azimuthHandle.visible = true | |
| this.azGlow.visible = true | |
| this.elevationArc.visible = true | |
| this.elevationHandle.visible = true | |
| this.elGlow.visible = true | |
| this.distanceHandle.visible = true | |
| this.distGlow.visible = true | |
| if (this.distanceTube) this.distanceTube.visible = true | |
| this.cameraIndicator.visible = true | |
| this.camGlow.visible = true | |
| this.glowRing.visible = true | |
| this.gridHelper.visible = true | |
| this.imageFrame.visible = true | |
| this.renderer.domElement.style.cursor = 'default' | |
| } | |
| } | |
| public updateImage(url: string | null): void { | |
| if (url) { | |
| const img = new Image() | |
| if (!url.startsWith('data:')) { | |
| img.crossOrigin = 'anonymous' | |
| } | |
| img.onload = () => { | |
| const tex = new THREE.Texture(img) | |
| tex.colorSpace = THREE.SRGBColorSpace | |
| tex.needsUpdate = true | |
| this.planeMat.map = tex | |
| this.planeMat.color.set(0xffffff) | |
| this.planeMat.needsUpdate = true | |
| const ar = img.width / img.height | |
| const maxSize = 1.5 | |
| let scaleX: number, scaleY: number | |
| if (ar > 1) { | |
| scaleX = maxSize | |
| scaleY = maxSize / ar | |
| } else { | |
| scaleY = maxSize | |
| scaleX = maxSize * ar | |
| } | |
| this.imagePlane.scale.set(scaleX, scaleY, 1) | |
| this.imageFrame.scale.set(scaleX, scaleY, 1) | |
| } | |
| img.onerror = () => { | |
| this.planeMat.map = null | |
| this.planeMat.color.set(0xE93D82) | |
| this.planeMat.needsUpdate = true | |
| } | |
| img.src = url | |
| } else { | |
| this.planeMat.map = null | |
| this.planeMat.color.set(0x3a3a4a) | |
| this.planeMat.needsUpdate = true | |
| this.imagePlane.scale.set(1, 1, 1) | |
| this.imageFrame.scale.set(1, 1, 1) | |
| } | |
| } | |
| public dispose(): void { | |
| if (this.animationId !== null) { | |
| try { | |
| window.cancelAnimationFrame(this.animationId) | |
| } catch { | |
| } | |
| this.animationId = null | |
| } | |
| try { | |
| this.renderer.dispose() | |
| } catch { | |
| } | |
| try { | |
| this.scene.clear() | |
| } catch { | |
| } | |
| } | |
| } | |