mattercontrol/src/components/Viewport.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>