781 lines
21 KiB
Vue
781 lines
21 KiB
Vue
<script setup lang="ts">
|
|
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
|
import * as THREE from 'three'
|
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
|
|
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'
|
|
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
|
|
import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js'
|
|
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'
|
|
import {
|
|
HOME_POSITION,
|
|
HOME_TARGET,
|
|
VIEW_POSITIONS,
|
|
calculateFitDistance,
|
|
getViewUpVector,
|
|
type ViewPreset,
|
|
} from '../utils/camera'
|
|
import { useSceneStore, type SceneObject } from '../stores/scene'
|
|
import {
|
|
createTranslateGizmo,
|
|
createDragState,
|
|
cycleGridSize,
|
|
getAxisDistance,
|
|
snapToGrid,
|
|
type TranslateGizmo,
|
|
type DragState,
|
|
} from '../composables/useTranslateGizmo'
|
|
import {
|
|
createRotateGizmo,
|
|
createRotateDragState,
|
|
getRotationAngle,
|
|
snapAngleToConstraint,
|
|
type RotateGizmo,
|
|
type RotateDragState,
|
|
} from '../composables/useRotateGizmo'
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
showGrid?: boolean
|
|
showAxis?: boolean
|
|
}>(),
|
|
{
|
|
showGrid: true,
|
|
showAxis: true,
|
|
}
|
|
)
|
|
|
|
const emit = defineEmits<{
|
|
pick: [object: SceneObject | null, event: MouseEvent]
|
|
contextmenu: [object: SceneObject | null, event: MouseEvent]
|
|
}>()
|
|
|
|
const sceneStore = useSceneStore()
|
|
const containerRef = ref<HTMLDivElement | null>(null)
|
|
|
|
let scene: THREE.Scene
|
|
let perspectiveCamera: THREE.PerspectiveCamera
|
|
let orthographicCamera: THREE.OrthographicCamera
|
|
let activeCamera: THREE.PerspectiveCamera | THREE.OrthographicCamera
|
|
let isOrthographic = false
|
|
let renderer: THREE.WebGLRenderer
|
|
let animationFrameId: number
|
|
let resizeObserver: ResizeObserver
|
|
let gridHelper: THREE.GridHelper
|
|
let axisHelper: THREE.AxesHelper
|
|
let controls: OrbitControls
|
|
let raycaster: THREE.Raycaster
|
|
const mouse = new THREE.Vector2()
|
|
let composer: EffectComposer
|
|
let selectionOutlinePass: OutlinePass
|
|
let hoverOutlinePass: OutlinePass
|
|
let hoveredObject: SceneObject | null = null
|
|
let translateGizmo: TranslateGizmo | null = null
|
|
let rotateGizmo: RotateGizmo | null = null
|
|
let dragState: DragState = createDragState()
|
|
let rotateDragState: RotateDragState = createRotateDragState()
|
|
let hoveredAxis: 'x' | 'y' | 'z' | null = null
|
|
let isCtrlPressed = false
|
|
|
|
function initScene() {
|
|
if (!containerRef.value) return
|
|
|
|
// Create scene
|
|
scene = new THREE.Scene()
|
|
scene.background = new THREE.Color(0x1a1a1a)
|
|
|
|
// Create cameras
|
|
const { clientWidth: width, clientHeight: height } = containerRef.value
|
|
const aspect = width / height
|
|
|
|
perspectiveCamera = new THREE.PerspectiveCamera(45, aspect, 0.1, 1000)
|
|
perspectiveCamera.position.copy(HOME_POSITION)
|
|
perspectiveCamera.lookAt(HOME_TARGET)
|
|
|
|
const frustumSize = 10
|
|
orthographicCamera = new THREE.OrthographicCamera(
|
|
(-frustumSize * aspect) / 2,
|
|
(frustumSize * aspect) / 2,
|
|
frustumSize / 2,
|
|
-frustumSize / 2,
|
|
0.1,
|
|
1000
|
|
)
|
|
orthographicCamera.position.copy(HOME_POSITION)
|
|
orthographicCamera.lookAt(HOME_TARGET)
|
|
|
|
activeCamera = perspectiveCamera
|
|
|
|
// Create renderer
|
|
renderer = new THREE.WebGLRenderer({ antialias: true })
|
|
renderer.setSize(width, height)
|
|
renderer.setPixelRatio(window.devicePixelRatio)
|
|
containerRef.value.appendChild(renderer.domElement)
|
|
|
|
// Prevent browser drag behavior on canvas
|
|
renderer.domElement.draggable = false
|
|
renderer.domElement.addEventListener('dragstart', (e) => e.preventDefault())
|
|
|
|
// Setup orbit controls
|
|
controls = new OrbitControls(activeCamera, renderer.domElement)
|
|
controls.enableDamping = true
|
|
controls.dampingFactor = 0.05
|
|
|
|
addLighting()
|
|
addGridHelper()
|
|
addAxisHelper()
|
|
|
|
raycaster = new THREE.Raycaster()
|
|
sceneStore.setScene(scene)
|
|
|
|
addTestCube()
|
|
|
|
setupPostProcessing()
|
|
setupGizmos()
|
|
setupMouseHandlers()
|
|
setupKeyboardHandlers()
|
|
setupResizeObserver()
|
|
animate()
|
|
}
|
|
|
|
function setupGizmos() {
|
|
translateGizmo = createTranslateGizmo()
|
|
scene.add(translateGizmo.group)
|
|
|
|
rotateGizmo = createRotateGizmo()
|
|
scene.add(rotateGizmo.group)
|
|
}
|
|
|
|
function setupPostProcessing() {
|
|
if (!containerRef.value) return
|
|
|
|
const { clientWidth: width, clientHeight: height } = containerRef.value
|
|
|
|
composer = new EffectComposer(renderer)
|
|
|
|
const renderPass = new RenderPass(scene, activeCamera)
|
|
composer.addPass(renderPass)
|
|
|
|
// Selection outline - cyan color
|
|
selectionOutlinePass = new OutlinePass(new THREE.Vector2(width, height), scene, activeCamera)
|
|
selectionOutlinePass.visibleEdgeColor.set(0x00ffff)
|
|
selectionOutlinePass.hiddenEdgeColor.set(0x004444)
|
|
selectionOutlinePass.edgeStrength = 3
|
|
selectionOutlinePass.edgeThickness = 1
|
|
selectionOutlinePass.edgeGlow = 0.5
|
|
composer.addPass(selectionOutlinePass)
|
|
|
|
// Hover outline - yellow color, thinner
|
|
hoverOutlinePass = new OutlinePass(new THREE.Vector2(width, height), scene, activeCamera)
|
|
hoverOutlinePass.visibleEdgeColor.set(0xffff00)
|
|
hoverOutlinePass.hiddenEdgeColor.set(0x444400)
|
|
hoverOutlinePass.edgeStrength = 2
|
|
hoverOutlinePass.edgeThickness = 1
|
|
hoverOutlinePass.edgeGlow = 0
|
|
composer.addPass(hoverOutlinePass)
|
|
|
|
const outputPass = new OutputPass()
|
|
composer.addPass(outputPass)
|
|
}
|
|
|
|
function addGridHelper() {
|
|
gridHelper = new THREE.GridHelper(10, 10, 0x444444, 0x333333)
|
|
gridHelper.visible = props.showGrid
|
|
scene.add(gridHelper)
|
|
}
|
|
|
|
watch(
|
|
() => props.showGrid,
|
|
(visible) => {
|
|
if (gridHelper) gridHelper.visible = visible
|
|
}
|
|
)
|
|
|
|
function addAxisHelper() {
|
|
axisHelper = new THREE.AxesHelper(5)
|
|
axisHelper.visible = props.showAxis
|
|
scene.add(axisHelper)
|
|
}
|
|
|
|
watch(
|
|
() => props.showAxis,
|
|
(visible) => {
|
|
if (axisHelper) axisHelper.visible = visible
|
|
}
|
|
)
|
|
|
|
function addLighting() {
|
|
// Ambient light for base illumination
|
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4)
|
|
scene.add(ambientLight)
|
|
|
|
// Primary directional light (top-front-right)
|
|
const directionalLight1 = new THREE.DirectionalLight(0xffffff, 0.8)
|
|
directionalLight1.position.set(5, 10, 7)
|
|
scene.add(directionalLight1)
|
|
|
|
// Secondary directional light (back-left, softer)
|
|
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.3)
|
|
directionalLight2.position.set(-5, 5, -5)
|
|
scene.add(directionalLight2)
|
|
}
|
|
|
|
function addTestCube() {
|
|
const geometry = new THREE.BoxGeometry(1, 1, 1)
|
|
const material = new THREE.MeshStandardMaterial({ color: 0x4a90d9 })
|
|
const cube = new THREE.Mesh(geometry, material)
|
|
sceneStore.addObject(cube, 'Cube')
|
|
}
|
|
|
|
function setupMouseHandlers() {
|
|
if (!renderer) return
|
|
|
|
renderer.domElement.addEventListener('click', onCanvasClick)
|
|
renderer.domElement.addEventListener('mousemove', onCanvasMouseMove)
|
|
renderer.domElement.addEventListener('contextmenu', onCanvasContextMenu)
|
|
renderer.domElement.addEventListener('mousedown', onCanvasMouseDown)
|
|
renderer.domElement.addEventListener('mouseup', onCanvasMouseUp)
|
|
renderer.domElement.addEventListener('wheel', onCanvasWheel, { passive: false })
|
|
}
|
|
|
|
function cleanupMouseHandlers() {
|
|
if (!renderer) return
|
|
|
|
renderer.domElement.removeEventListener('click', onCanvasClick)
|
|
renderer.domElement.removeEventListener('mousemove', onCanvasMouseMove)
|
|
renderer.domElement.removeEventListener('contextmenu', onCanvasContextMenu)
|
|
renderer.domElement.removeEventListener('mousedown', onCanvasMouseDown)
|
|
renderer.domElement.removeEventListener('mouseup', onCanvasMouseUp)
|
|
renderer.domElement.removeEventListener('wheel', onCanvasWheel)
|
|
}
|
|
|
|
function setupKeyboardHandlers() {
|
|
window.addEventListener('keydown', onKeyDown)
|
|
window.addEventListener('keyup', onKeyUp)
|
|
}
|
|
|
|
function cleanupKeyboardHandlers() {
|
|
window.removeEventListener('keydown', onKeyDown)
|
|
window.removeEventListener('keyup', onKeyUp)
|
|
}
|
|
|
|
function onKeyDown(event: KeyboardEvent) {
|
|
if (event.key === 'Control' && !isCtrlPressed) {
|
|
isCtrlPressed = true
|
|
updateGizmoVisibility()
|
|
}
|
|
}
|
|
|
|
function onKeyUp(event: KeyboardEvent) {
|
|
if (event.key === 'Control' && isCtrlPressed) {
|
|
isCtrlPressed = false
|
|
updateGizmoVisibility()
|
|
}
|
|
}
|
|
|
|
function updateGizmoVisibility() {
|
|
const hasSelection = sceneStore.selectedObjects.length > 0
|
|
|
|
if (!hasSelection) {
|
|
translateGizmo?.hide()
|
|
rotateGizmo?.hide()
|
|
return
|
|
}
|
|
|
|
if (isCtrlPressed) {
|
|
translateGizmo?.hide()
|
|
rotateGizmo?.show()
|
|
updateRotateGizmoPosition()
|
|
} else {
|
|
rotateGizmo?.hide()
|
|
translateGizmo?.show()
|
|
updateGizmoPosition()
|
|
}
|
|
}
|
|
|
|
function updateMouseCoordinates(event: MouseEvent) {
|
|
if (!containerRef.value) return
|
|
|
|
const rect = renderer.domElement.getBoundingClientRect()
|
|
// Convert to normalized device coordinates (-1 to +1)
|
|
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
|
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
|
|
}
|
|
|
|
function pickObject(event: MouseEvent): SceneObject | null {
|
|
updateMouseCoordinates(event)
|
|
|
|
raycaster.setFromCamera(mouse, activeCamera)
|
|
|
|
// Get all meshes from scene objects in the store
|
|
const meshes = sceneStore.objects.map((obj) => obj.mesh)
|
|
|
|
if (meshes.length === 0) return null
|
|
|
|
const intersects = raycaster.intersectObjects(meshes, false)
|
|
|
|
if (intersects.length > 0) {
|
|
const hitMesh = intersects[0]!.object as THREE.Mesh
|
|
// Find the SceneObject that owns this mesh
|
|
const sceneObject = sceneStore.objects.find((obj) => obj.mesh === hitMesh)
|
|
return sceneObject ?? null
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function onCanvasClick(event: MouseEvent) {
|
|
const pickedObject = pickObject(event)
|
|
emit('pick', pickedObject, event)
|
|
}
|
|
|
|
function onCanvasMouseMove(event: MouseEvent) {
|
|
updateMouseCoordinates(event)
|
|
|
|
// Handle translate gizmo dragging
|
|
if (dragState.isDragging && dragState.axis && translateGizmo) {
|
|
const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh)
|
|
if (selectedMeshes.length === 0) return
|
|
|
|
// Get current mouse position along axis relative to start position
|
|
const currentAxisDistance = getAxisDistance(
|
|
mouse,
|
|
activeCamera,
|
|
dragState.startWorldPosition,
|
|
dragState.axis
|
|
)
|
|
|
|
// Calculate movement: current position minus initial grab offset
|
|
const axisVector = new THREE.Vector3()
|
|
if (dragState.axis === 'x') axisVector.set(1, 0, 0)
|
|
else if (dragState.axis === 'y') axisVector.set(0, 1, 0)
|
|
else axisVector.set(0, 0, 1)
|
|
|
|
const rawDistance = currentAxisDistance - dragState.grabOffset
|
|
const snappedDistance = dragState.isRightDrag
|
|
? snapToGrid(rawDistance, dragState.gridSize)
|
|
: rawDistance
|
|
|
|
const delta = axisVector.multiplyScalar(snappedDistance)
|
|
|
|
// Move all selected objects
|
|
for (const mesh of selectedMeshes) {
|
|
const startPos = mesh.userData.__originalPosition as THREE.Vector3
|
|
if (startPos) {
|
|
mesh.position.copy(startPos).add(delta)
|
|
}
|
|
}
|
|
|
|
// Update gizmo position
|
|
updateGizmoPosition()
|
|
|
|
return
|
|
}
|
|
|
|
// Handle rotate gizmo dragging
|
|
if (rotateDragState.isDragging && rotateDragState.axis && rotateGizmo) {
|
|
const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh)
|
|
if (selectedMeshes.length === 0) return
|
|
|
|
// Get current rotation angle
|
|
const center = rotateGizmo.group.position
|
|
const currentAngle = getRotationAngle(mouse, activeCamera, center, rotateDragState.axis)
|
|
rotateDragState.currentAngle = currentAngle
|
|
|
|
// Calculate angle delta from start
|
|
let angleDelta = currentAngle - rotateDragState.startAngle
|
|
|
|
// Apply constraint snapping for right-drag
|
|
if (rotateDragState.isRightDrag) {
|
|
angleDelta = snapAngleToConstraint(angleDelta, rotateDragState.constraintAngles)
|
|
}
|
|
|
|
// Apply rotation to all selected objects
|
|
const rotationAxis = new THREE.Vector3()
|
|
if (rotateDragState.axis === 'x') rotationAxis.set(1, 0, 0)
|
|
else if (rotateDragState.axis === 'y') rotationAxis.set(0, 1, 0)
|
|
else rotationAxis.set(0, 0, 1)
|
|
|
|
for (const mesh of selectedMeshes) {
|
|
const startRotation = rotateDragState.startObjectRotations.get(mesh.uuid)
|
|
if (startRotation) {
|
|
// Reset to start rotation then apply delta
|
|
mesh.rotation.copy(startRotation)
|
|
mesh.rotateOnWorldAxis(rotationAxis, angleDelta)
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// Handle gizmo hover highlight
|
|
raycaster.setFromCamera(mouse, activeCamera)
|
|
|
|
if (isCtrlPressed && rotateGizmo?.group.visible) {
|
|
const newHoveredAxis = rotateGizmo.getHoveredAxis(raycaster)
|
|
if (newHoveredAxis !== hoveredAxis) {
|
|
hoveredAxis = newHoveredAxis
|
|
rotateGizmo.setHighlightedAxis(hoveredAxis)
|
|
}
|
|
} else if (translateGizmo?.group.visible) {
|
|
const newHoveredAxis = translateGizmo.getHoveredAxis(raycaster)
|
|
if (newHoveredAxis !== hoveredAxis) {
|
|
hoveredAxis = newHoveredAxis
|
|
translateGizmo.setHighlightedAxis(hoveredAxis)
|
|
}
|
|
}
|
|
|
|
// Handle object hover
|
|
const newHovered = pickObject(event)
|
|
if (newHovered !== hoveredObject) {
|
|
hoveredObject = newHovered
|
|
updateHoverOutline()
|
|
}
|
|
}
|
|
|
|
function onCanvasContextMenu(event: MouseEvent) {
|
|
event.preventDefault()
|
|
const pickedObject = pickObject(event)
|
|
emit('contextmenu', pickedObject, event)
|
|
}
|
|
|
|
function onCanvasMouseDown(event: MouseEvent) {
|
|
updateMouseCoordinates(event)
|
|
raycaster.setFromCamera(mouse, activeCamera)
|
|
|
|
// Check for rotation gizmo first (when Ctrl is held)
|
|
if (isCtrlPressed && rotateGizmo?.group.visible) {
|
|
const axis = rotateGizmo.getHoveredAxis(raycaster)
|
|
if (axis) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
|
|
controls.enabled = false
|
|
|
|
rotateDragState.isDragging = true
|
|
rotateDragState.axis = axis
|
|
rotateDragState.isRightDrag = event.button === 2
|
|
|
|
// Store starting rotations for all selected objects
|
|
const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh)
|
|
rotateDragState.startObjectRotations.clear()
|
|
for (const mesh of selectedMeshes) {
|
|
rotateDragState.startObjectRotations.set(mesh.uuid, mesh.rotation.clone())
|
|
}
|
|
|
|
// Calculate starting angle
|
|
const center = rotateGizmo.group.position
|
|
rotateDragState.startAngle = getRotationAngle(mouse, activeCamera, center, axis)
|
|
rotateDragState.currentAngle = rotateDragState.startAngle
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check for translate gizmo
|
|
if (translateGizmo?.group.visible) {
|
|
const axis = translateGizmo.getHoveredAxis(raycaster)
|
|
if (axis) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
|
|
controls.enabled = false
|
|
|
|
dragState.isDragging = true
|
|
dragState.axis = axis
|
|
dragState.isRightDrag = event.button === 2
|
|
dragState.startMousePosition.copy(mouse)
|
|
|
|
// Store original positions and calculate center
|
|
const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh)
|
|
if (selectedMeshes.length > 0) {
|
|
const center = new THREE.Vector3()
|
|
for (const mesh of selectedMeshes) {
|
|
mesh.userData.__originalPosition = mesh.position.clone()
|
|
center.add(mesh.position)
|
|
}
|
|
center.divideScalar(selectedMeshes.length)
|
|
dragState.startWorldPosition.copy(center)
|
|
|
|
// Calculate grab offset: distance from center to where mouse clicked along axis
|
|
dragState.grabOffset = getAxisDistance(mouse, activeCamera, center, axis)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function onCanvasMouseUp(_event: MouseEvent) {
|
|
if (dragState.isDragging) {
|
|
// Clear stored original positions
|
|
for (const obj of sceneStore.selectedObjects) {
|
|
delete obj.mesh.userData.__originalPosition
|
|
}
|
|
|
|
dragState.isDragging = false
|
|
dragState.axis = null
|
|
controls.enabled = true
|
|
}
|
|
|
|
if (rotateDragState.isDragging) {
|
|
rotateDragState.isDragging = false
|
|
rotateDragState.axis = null
|
|
rotateDragState.startObjectRotations.clear()
|
|
controls.enabled = true
|
|
}
|
|
}
|
|
|
|
function onCanvasWheel(event: WheelEvent) {
|
|
if (!translateGizmo) return
|
|
|
|
updateMouseCoordinates(event)
|
|
raycaster.setFromCamera(mouse, activeCamera)
|
|
|
|
const axis = translateGizmo.getHoveredAxis(raycaster)
|
|
if (axis) {
|
|
event.preventDefault()
|
|
const direction = event.deltaY > 0 ? -1 : 1
|
|
dragState.gridSize = cycleGridSize(dragState.gridSize, direction)
|
|
}
|
|
}
|
|
|
|
function setupResizeObserver() {
|
|
if (!containerRef.value) return
|
|
|
|
resizeObserver = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
const { width, height } = entry.contentRect
|
|
if (width > 0 && height > 0) {
|
|
const aspect = width / height
|
|
|
|
perspectiveCamera.aspect = aspect
|
|
perspectiveCamera.updateProjectionMatrix()
|
|
|
|
const frustumSize = 10
|
|
orthographicCamera.left = (-frustumSize * aspect) / 2
|
|
orthographicCamera.right = (frustumSize * aspect) / 2
|
|
orthographicCamera.top = frustumSize / 2
|
|
orthographicCamera.bottom = -frustumSize / 2
|
|
orthographicCamera.updateProjectionMatrix()
|
|
|
|
renderer.setSize(width, height)
|
|
composer.setSize(width, height)
|
|
|
|
const resolution = new THREE.Vector2(width, height)
|
|
selectionOutlinePass.resolution = resolution
|
|
hoverOutlinePass.resolution = resolution
|
|
}
|
|
}
|
|
})
|
|
|
|
resizeObserver.observe(containerRef.value)
|
|
}
|
|
|
|
function animate() {
|
|
animationFrameId = requestAnimationFrame(animate)
|
|
controls.update()
|
|
|
|
// Keep gizmos at consistent screen size regardless of zoom
|
|
if (translateGizmo) {
|
|
translateGizmo.updateScale(activeCamera)
|
|
}
|
|
if (rotateGizmo) {
|
|
rotateGizmo.updateScale(activeCamera)
|
|
}
|
|
|
|
composer.render()
|
|
}
|
|
|
|
function updateSelectionOutline() {
|
|
if (!selectionOutlinePass) return
|
|
selectionOutlinePass.selectedObjects = sceneStore.selectedObjects.map((obj) => obj.mesh)
|
|
}
|
|
|
|
function updateHoverOutline() {
|
|
if (!hoverOutlinePass) return
|
|
if (hoveredObject && !sceneStore.selectedIds.has(hoveredObject.id)) {
|
|
hoverOutlinePass.selectedObjects = [hoveredObject.mesh]
|
|
} else {
|
|
hoverOutlinePass.selectedObjects = []
|
|
}
|
|
}
|
|
|
|
function updateGizmoPosition() {
|
|
if (!translateGizmo) return
|
|
|
|
const selected = sceneStore.selectedObjects
|
|
if (selected.length === 0) {
|
|
translateGizmo.hide()
|
|
return
|
|
}
|
|
|
|
// Calculate bounding box of all selected objects
|
|
const box = new THREE.Box3()
|
|
for (const obj of selected) {
|
|
box.expandByObject(obj.mesh)
|
|
}
|
|
|
|
const center = box.getCenter(new THREE.Vector3())
|
|
|
|
translateGizmo.setPositionAndBounds(center, box)
|
|
if (!isCtrlPressed) {
|
|
translateGizmo.show()
|
|
}
|
|
}
|
|
|
|
function updateRotateGizmoPosition() {
|
|
if (!rotateGizmo) return
|
|
|
|
const selected = sceneStore.selectedObjects
|
|
if (selected.length === 0) {
|
|
rotateGizmo.hide()
|
|
return
|
|
}
|
|
|
|
// Calculate bounding box of all selected objects
|
|
const box = new THREE.Box3()
|
|
for (const obj of selected) {
|
|
box.expandByObject(obj.mesh)
|
|
}
|
|
|
|
const center = box.getCenter(new THREE.Vector3())
|
|
rotateGizmo.setPositionAndBounds(center, box)
|
|
|
|
if (isCtrlPressed) {
|
|
rotateGizmo.show()
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => sceneStore.selectedObjects,
|
|
() => {
|
|
updateSelectionOutline()
|
|
updateHoverOutline()
|
|
updateGizmoVisibility()
|
|
},
|
|
{ deep: true }
|
|
)
|
|
|
|
function fitToSelection(objects: THREE.Object3D[]) {
|
|
if (objects.length === 0) return
|
|
|
|
const box = new THREE.Box3()
|
|
for (const obj of objects) {
|
|
box.expandByObject(obj)
|
|
}
|
|
|
|
const center = box.getCenter(new THREE.Vector3())
|
|
const size = box.getSize(new THREE.Vector3())
|
|
const maxDim = Math.max(size.x, size.y, size.z)
|
|
|
|
const distance = calculateFitDistance(perspectiveCamera.fov, maxDim)
|
|
|
|
const direction = activeCamera.position.clone().sub(controls.target).normalize()
|
|
activeCamera.position.copy(center).add(direction.multiplyScalar(distance))
|
|
controls.target.copy(center)
|
|
controls.update()
|
|
}
|
|
|
|
function resetView() {
|
|
activeCamera.position.copy(HOME_POSITION)
|
|
controls.target.copy(HOME_TARGET)
|
|
controls.update()
|
|
}
|
|
|
|
function setViewPreset(preset: ViewPreset) {
|
|
const position = VIEW_POSITIONS[preset]
|
|
activeCamera.position.copy(position)
|
|
controls.target.set(0, 0, 0)
|
|
activeCamera.up.copy(getViewUpVector(preset))
|
|
controls.update()
|
|
}
|
|
|
|
function setOrthographic(ortho: boolean) {
|
|
if (isOrthographic === ortho) return
|
|
|
|
const newCamera = ortho ? orthographicCamera : perspectiveCamera
|
|
|
|
// Copy position and rotation from current camera
|
|
newCamera.position.copy(activeCamera.position)
|
|
newCamera.up.copy(activeCamera.up)
|
|
newCamera.lookAt(controls.target)
|
|
|
|
activeCamera = newCamera
|
|
isOrthographic = ortho
|
|
|
|
// Update controls to use new camera
|
|
controls.object = activeCamera
|
|
controls.update()
|
|
|
|
// Update outline passes camera
|
|
if (selectionOutlinePass) selectionOutlinePass.renderCamera = activeCamera
|
|
if (hoverOutlinePass) hoverOutlinePass.renderCamera = activeCamera
|
|
}
|
|
|
|
function toggleProjection() {
|
|
setOrthographic(!isOrthographic)
|
|
}
|
|
|
|
function cleanup() {
|
|
if (animationFrameId) {
|
|
cancelAnimationFrame(animationFrameId)
|
|
}
|
|
|
|
if (resizeObserver) {
|
|
resizeObserver.disconnect()
|
|
}
|
|
|
|
cleanupMouseHandlers()
|
|
cleanupKeyboardHandlers()
|
|
|
|
if (translateGizmo) {
|
|
translateGizmo.dispose()
|
|
}
|
|
|
|
if (rotateGizmo) {
|
|
rotateGizmo.dispose()
|
|
}
|
|
|
|
if (controls) {
|
|
controls.dispose()
|
|
}
|
|
|
|
if (renderer) {
|
|
renderer.dispose()
|
|
renderer.domElement.remove()
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
initScene()
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
cleanup()
|
|
})
|
|
|
|
defineExpose({
|
|
fitToSelection,
|
|
resetView,
|
|
setViewPreset,
|
|
setOrthographic,
|
|
toggleProjection,
|
|
pickObject,
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="containerRef" class="viewport"></div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.viewport {
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
user-select: none;
|
|
-webkit-user-drag: none;
|
|
}
|
|
|
|
.viewport :deep(canvas) {
|
|
-webkit-user-drag: none;
|
|
}
|
|
</style>
|