Add translate mode controls
This commit is contained in:
parent
5c1471394c
commit
698286ec86
9 changed files with 527 additions and 41 deletions
12
TODO.md
12
TODO.md
|
|
@ -112,12 +112,12 @@ A step-by-step checklist for porting MatterControl's design features to a Vue +
|
|||
## Phase 5: Transform Tools
|
||||
|
||||
### Translate Mode (Default with Selection)
|
||||
- [ ] Create custom translate gizmo with 3 axis arrows (colored by axis)
|
||||
- [ ] Attach gizmo to selected object(s)
|
||||
- [ ] Left-drag handle: free translate along axis
|
||||
- [ ] Right-drag handle: grid-aligned translate along axis
|
||||
- [ ] Mouse wheel on handle: change grid granularity
|
||||
- [ ] Hide gizmo when nothing selected
|
||||
- [x] Create custom translate gizmo with 3 axis arrows (colored by axis)
|
||||
- [x] Attach gizmo to selected object(s)
|
||||
- [x] Left-drag handle: free translate along axis
|
||||
- [x] Right-drag handle: grid-aligned translate along axis
|
||||
- [x] Mouse wheel on handle: change grid granularity
|
||||
- [x] Hide gizmo when nothing selected
|
||||
|
||||
### Rotation Mode (Ctrl Modifier)
|
||||
- [ ] Create custom rotation gizmo with flat ring handles per axis
|
||||
|
|
|
|||
|
|
@ -8,6 +8,14 @@ export default tseslint.config(
|
|||
{ ignores: ['dist', 'node_modules'] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
},
|
||||
},
|
||||
...pluginVue.configs['flat/recommended'],
|
||||
{
|
||||
languageOptions: {
|
||||
|
|
|
|||
|
|
@ -145,5 +145,4 @@ onUnmounted(() => {
|
|||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@ const STORAGE_KEY = 'rapidmodel-sidebar-state'
|
|||
|
||||
const leftCollapsed = ref(false)
|
||||
|
||||
const leftSidebarWidth = computed(() =>
|
||||
leftCollapsed.value ? '0' : 'var(--sidebar-width)'
|
||||
)
|
||||
const leftSidebarWidth = computed(() => (leftCollapsed.value ? '0' : 'var(--sidebar-width)'))
|
||||
|
||||
const statusLeftOffset = computed(() =>
|
||||
leftCollapsed.value ? 'var(--space-md)' : 'calc(var(--sidebar-width) + var(--space-md))'
|
||||
|
|
|
|||
|
|
@ -50,11 +50,7 @@ onUnmounted(() => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="menuRef"
|
||||
class="context-menu"
|
||||
:style="{ left: `${x}px`, top: `${y}px` }"
|
||||
>
|
||||
<div ref="menuRef" class="context-menu" :style="{ left: `${x}px`, top: `${y}px` }">
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<div v-if="item.separator" class="separator" />
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -15,6 +15,15 @@ import {
|
|||
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'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
|
@ -52,6 +61,9 @@ let composer: EffectComposer
|
|||
let selectionOutlinePass: OutlinePass
|
||||
let hoverOutlinePass: OutlinePass
|
||||
let hoveredObject: SceneObject | null = null
|
||||
let translateGizmo: TranslateGizmo | null = null
|
||||
let dragState: DragState = createDragState()
|
||||
let hoveredAxis: 'x' | 'y' | 'z' | null = null
|
||||
|
||||
function initScene() {
|
||||
if (!containerRef.value) return
|
||||
|
|
@ -107,11 +119,17 @@ function initScene() {
|
|||
addTestCube()
|
||||
|
||||
setupPostProcessing()
|
||||
setupTranslateGizmo()
|
||||
setupMouseHandlers()
|
||||
setupResizeObserver()
|
||||
animate()
|
||||
}
|
||||
|
||||
function setupTranslateGizmo() {
|
||||
translateGizmo = createTranslateGizmo()
|
||||
scene.add(translateGizmo.group)
|
||||
}
|
||||
|
||||
function setupPostProcessing() {
|
||||
if (!containerRef.value) return
|
||||
|
||||
|
|
@ -123,11 +141,7 @@ function setupPostProcessing() {
|
|||
composer.addPass(renderPass)
|
||||
|
||||
// Selection outline - cyan color
|
||||
selectionOutlinePass = new OutlinePass(
|
||||
new THREE.Vector2(width, height),
|
||||
scene,
|
||||
activeCamera
|
||||
)
|
||||
selectionOutlinePass = new OutlinePass(new THREE.Vector2(width, height), scene, activeCamera)
|
||||
selectionOutlinePass.visibleEdgeColor.set(0x00ffff)
|
||||
selectionOutlinePass.hiddenEdgeColor.set(0x004444)
|
||||
selectionOutlinePass.edgeStrength = 3
|
||||
|
|
@ -136,11 +150,7 @@ function setupPostProcessing() {
|
|||
composer.addPass(selectionOutlinePass)
|
||||
|
||||
// Hover outline - yellow color, thinner
|
||||
hoverOutlinePass = new OutlinePass(
|
||||
new THREE.Vector2(width, height),
|
||||
scene,
|
||||
activeCamera
|
||||
)
|
||||
hoverOutlinePass = new OutlinePass(new THREE.Vector2(width, height), scene, activeCamera)
|
||||
hoverOutlinePass.visibleEdgeColor.set(0xffff00)
|
||||
hoverOutlinePass.hiddenEdgeColor.set(0x444400)
|
||||
hoverOutlinePass.edgeStrength = 2
|
||||
|
|
@ -207,6 +217,9 @@ function setupMouseHandlers() {
|
|||
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() {
|
||||
|
|
@ -215,6 +228,9 @@ function cleanupMouseHandlers() {
|
|||
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 updateMouseCoordinates(event: MouseEvent) {
|
||||
|
|
@ -254,6 +270,59 @@ function onCanvasClick(event: MouseEvent) {
|
|||
}
|
||||
|
||||
function onCanvasMouseMove(event: MouseEvent) {
|
||||
updateMouseCoordinates(event)
|
||||
|
||||
// Handle 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 gizmo hover highlight
|
||||
if (translateGizmo) {
|
||||
raycaster.setFromCamera(mouse, activeCamera)
|
||||
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
|
||||
|
|
@ -267,6 +336,69 @@ function onCanvasContextMenu(event: MouseEvent) {
|
|||
emit('contextmenu', pickedObject, event)
|
||||
}
|
||||
|
||||
function onCanvasMouseDown(event: MouseEvent) {
|
||||
if (!translateGizmo) return
|
||||
|
||||
updateMouseCoordinates(event)
|
||||
raycaster.setFromCamera(mouse, activeCamera)
|
||||
|
||||
const axis = translateGizmo.getHoveredAxis(raycaster)
|
||||
if (axis) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
// Disable orbit controls during gizmo drag
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -302,14 +434,18 @@ function setupResizeObserver() {
|
|||
function animate() {
|
||||
animationFrameId = requestAnimationFrame(animate)
|
||||
controls.update()
|
||||
|
||||
// Keep gizmo at consistent screen size regardless of zoom
|
||||
if (translateGizmo) {
|
||||
translateGizmo.updateScale(activeCamera)
|
||||
}
|
||||
|
||||
composer.render()
|
||||
}
|
||||
|
||||
function updateSelectionOutline() {
|
||||
if (!selectionOutlinePass) return
|
||||
selectionOutlinePass.selectedObjects = sceneStore.selectedObjects.map(
|
||||
(obj) => obj.mesh
|
||||
)
|
||||
selectionOutlinePass.selectedObjects = sceneStore.selectedObjects.map((obj) => obj.mesh)
|
||||
}
|
||||
|
||||
function updateHoverOutline() {
|
||||
|
|
@ -321,11 +457,33 @@ function updateHoverOutline() {
|
|||
}
|
||||
}
|
||||
|
||||
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)
|
||||
translateGizmo.show()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => sceneStore.selectedObjects,
|
||||
() => {
|
||||
updateSelectionOutline()
|
||||
updateHoverOutline()
|
||||
updateGizmoPosition()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
|
@ -401,6 +559,10 @@ function cleanup() {
|
|||
|
||||
cleanupMouseHandlers()
|
||||
|
||||
if (translateGizmo) {
|
||||
translateGizmo.dispose()
|
||||
}
|
||||
|
||||
if (controls) {
|
||||
controls.dispose()
|
||||
}
|
||||
|
|
|
|||
327
src/composables/useTranslateGizmo.ts
Normal file
327
src/composables/useTranslateGizmo.ts
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
import * as THREE from 'three'
|
||||
|
||||
export interface TranslateGizmoOptions {
|
||||
arrowLength?: number
|
||||
arrowHeadLength?: number
|
||||
arrowHeadRadius?: number
|
||||
shaftRadius?: number
|
||||
screenSize?: number // Target screen size in pixels
|
||||
}
|
||||
|
||||
export interface TranslateGizmo {
|
||||
group: THREE.Group
|
||||
show: () => void
|
||||
hide: () => void
|
||||
setPositionAndBounds: (center: THREE.Vector3, box: THREE.Box3) => void
|
||||
getHoveredAxis: (raycaster: THREE.Raycaster) => 'x' | 'y' | 'z' | null
|
||||
setHighlightedAxis: (axis: 'x' | 'y' | 'z' | null) => void
|
||||
updateScale: (camera: THREE.Camera) => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
const AXIS_COLORS = {
|
||||
x: 0xe74c3c, // Red
|
||||
y: 0x2ecc71, // Green
|
||||
z: 0x3498db, // Blue
|
||||
}
|
||||
|
||||
const HIGHLIGHT_COLORS = {
|
||||
x: 0xff6b6b,
|
||||
y: 0x5dff7f,
|
||||
z: 0x5dafff,
|
||||
}
|
||||
|
||||
export function createTranslateGizmo(options: TranslateGizmoOptions = {}): TranslateGizmo {
|
||||
const {
|
||||
arrowLength = 1.0,
|
||||
arrowHeadLength = 0.2,
|
||||
arrowHeadRadius = 0.08,
|
||||
shaftRadius = 0.025,
|
||||
screenSize = 150, // Target size in screen pixels
|
||||
} = options
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.name = '__translateGizmo__'
|
||||
group.visible = false
|
||||
|
||||
// Store materials for highlight toggling
|
||||
const materials: Record<string, THREE.MeshBasicMaterial> = {}
|
||||
|
||||
// Store arrow references for positioning
|
||||
const arrows: Record<string, THREE.Group> = {}
|
||||
|
||||
// Store world-space offsets from center to each arrow origin
|
||||
type ArrowKey = 'x_pos' | 'x_neg' | 'y_pos' | 'y_neg' | 'z_pos' | 'z_neg'
|
||||
const arrowOffsets: Record<ArrowKey, THREE.Vector3> = {
|
||||
x_pos: new THREE.Vector3(),
|
||||
x_neg: new THREE.Vector3(),
|
||||
y_pos: new THREE.Vector3(),
|
||||
y_neg: new THREE.Vector3(),
|
||||
z_pos: new THREE.Vector3(),
|
||||
z_neg: new THREE.Vector3(),
|
||||
}
|
||||
|
||||
function createArrow(axis: 'x' | 'y' | 'z', negative: boolean): THREE.Group {
|
||||
const arrowGroup = new THREE.Group()
|
||||
const suffix = negative ? '_neg' : '_pos'
|
||||
arrowGroup.name = `__gizmo_${axis}${suffix}__`
|
||||
arrowGroup.userData = { axis, isGizmoHandle: true, negative }
|
||||
|
||||
const color = AXIS_COLORS[axis]
|
||||
|
||||
// Create shaft (cylinder)
|
||||
const shaftLength = arrowLength - arrowHeadLength
|
||||
const shaftGeometry = new THREE.CylinderGeometry(shaftRadius, shaftRadius, shaftLength, 8)
|
||||
const shaftMaterial = new THREE.MeshBasicMaterial({ color })
|
||||
materials[`${axis}${suffix}_shaft`] = shaftMaterial
|
||||
const shaft = new THREE.Mesh(shaftGeometry, shaftMaterial)
|
||||
shaft.userData = { axis, isGizmoHandle: true, negative }
|
||||
shaft.position.y = shaftLength / 2
|
||||
|
||||
// Create cone (arrow head)
|
||||
const coneGeometry = new THREE.ConeGeometry(arrowHeadRadius, arrowHeadLength, 16)
|
||||
const coneMaterial = new THREE.MeshBasicMaterial({ color })
|
||||
materials[`${axis}${suffix}_cone`] = coneMaterial
|
||||
const cone = new THREE.Mesh(coneGeometry, coneMaterial)
|
||||
cone.userData = { axis, isGizmoHandle: true, negative }
|
||||
cone.position.y = shaftLength + arrowHeadLength / 2
|
||||
|
||||
arrowGroup.add(shaft)
|
||||
arrowGroup.add(cone)
|
||||
|
||||
// Rotate to point in correct direction
|
||||
if (axis === 'x') {
|
||||
arrowGroup.rotation.z = negative ? Math.PI / 2 : -Math.PI / 2
|
||||
} else if (axis === 'y') {
|
||||
arrowGroup.rotation.z = negative ? Math.PI : 0
|
||||
} else {
|
||||
// z axis
|
||||
arrowGroup.rotation.x = negative ? -Math.PI / 2 : Math.PI / 2
|
||||
}
|
||||
|
||||
arrows[`${axis}${suffix}`] = arrowGroup
|
||||
return arrowGroup
|
||||
}
|
||||
|
||||
// Create arrows in both directions for each axis
|
||||
group.add(createArrow('x', false))
|
||||
group.add(createArrow('x', true))
|
||||
group.add(createArrow('y', false))
|
||||
group.add(createArrow('y', true))
|
||||
group.add(createArrow('z', false))
|
||||
group.add(createArrow('z', true))
|
||||
|
||||
let currentScale = 1
|
||||
|
||||
function show(): void {
|
||||
group.visible = true
|
||||
}
|
||||
|
||||
function hide(): void {
|
||||
group.visible = false
|
||||
}
|
||||
|
||||
function setPositionAndBounds(center: THREE.Vector3, box: THREE.Box3): void {
|
||||
group.position.copy(center)
|
||||
|
||||
// Calculate world-space offsets from center to each bounding box face
|
||||
arrowOffsets.x_pos.set(box.max.x - center.x, 0, 0)
|
||||
arrowOffsets.x_neg.set(box.min.x - center.x, 0, 0)
|
||||
arrowOffsets.y_pos.set(0, box.max.y - center.y, 0)
|
||||
arrowOffsets.y_neg.set(0, box.min.y - center.y, 0)
|
||||
arrowOffsets.z_pos.set(0, 0, box.max.z - center.z)
|
||||
arrowOffsets.z_neg.set(0, 0, box.min.z - center.z)
|
||||
|
||||
// Apply offsets to arrow positions (compensating for current scale)
|
||||
updateArrowPositions()
|
||||
}
|
||||
|
||||
function updateArrowPositions(): void {
|
||||
// Position arrows so they appear at bounding box faces in world space
|
||||
// Since group.scale affects child positions, we divide by scale to compensate
|
||||
const keys: ArrowKey[] = ['x_pos', 'x_neg', 'y_pos', 'y_neg', 'z_pos', 'z_neg']
|
||||
for (const key of keys) {
|
||||
const arrow = arrows[key]
|
||||
const offset = arrowOffsets[key]
|
||||
if (arrow) {
|
||||
arrow.position.copy(offset).divideScalar(currentScale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getHoveredAxis(raycaster: THREE.Raycaster): 'x' | 'y' | 'z' | null {
|
||||
if (!group.visible) return null
|
||||
|
||||
const intersects = raycaster.intersectObject(group, true)
|
||||
if (intersects.length > 0) {
|
||||
const hit = intersects[0]!.object
|
||||
if (hit.userData?.axis) {
|
||||
return hit.userData.axis as 'x' | 'y' | 'z'
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function setHighlightedAxis(axis: 'x' | 'y' | 'z' | null): void {
|
||||
// Reset all to original colors
|
||||
for (const ax of ['x', 'y', 'z'] as const) {
|
||||
const color = ax === axis ? HIGHLIGHT_COLORS[ax] : AXIS_COLORS[ax]
|
||||
for (const suffix of ['_pos', '_neg']) {
|
||||
materials[`${ax}${suffix}_shaft`]?.color.setHex(color)
|
||||
materials[`${ax}${suffix}_cone`]?.color.setHex(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateScale(camera: THREE.Camera): void {
|
||||
if (!group.visible) return
|
||||
|
||||
// Calculate distance from camera to gizmo
|
||||
const distance = camera.position.distanceTo(group.position)
|
||||
|
||||
// For perspective camera, scale based on distance and FOV
|
||||
// For orthographic camera, scale based on zoom
|
||||
let scale: number
|
||||
if (camera instanceof THREE.PerspectiveCamera) {
|
||||
// Calculate the world size that corresponds to screenSize pixels at this distance
|
||||
const vFov = (camera.fov * Math.PI) / 180
|
||||
const worldHeight = 2 * Math.tan(vFov / 2) * distance
|
||||
// Assume a standard viewport height of ~800px for baseline
|
||||
scale = (worldHeight / 800) * screenSize * 0.5
|
||||
} else if (camera instanceof THREE.OrthographicCamera) {
|
||||
const viewHeight = camera.top - camera.bottom
|
||||
scale = (viewHeight / 800) * screenSize * 0.5
|
||||
} else {
|
||||
scale = 1
|
||||
}
|
||||
|
||||
currentScale = scale
|
||||
group.scale.setScalar(scale)
|
||||
|
||||
// Update arrow positions to compensate for new scale
|
||||
updateArrowPositions()
|
||||
}
|
||||
|
||||
function dispose(): void {
|
||||
// Dispose geometries and materials
|
||||
group.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.geometry.dispose()
|
||||
if (child.material instanceof THREE.Material) {
|
||||
child.material.dispose()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
group,
|
||||
show,
|
||||
hide,
|
||||
setPositionAndBounds,
|
||||
getHoveredAxis,
|
||||
setHighlightedAxis,
|
||||
updateScale,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
export interface DragState {
|
||||
isDragging: boolean
|
||||
axis: 'x' | 'y' | 'z' | null
|
||||
isRightDrag: boolean
|
||||
startWorldPosition: THREE.Vector3
|
||||
startMousePosition: THREE.Vector2
|
||||
grabOffset: number // Offset along axis from selection center to grab point
|
||||
gridSize: number
|
||||
}
|
||||
|
||||
export function createDragState(): DragState {
|
||||
return {
|
||||
isDragging: false,
|
||||
axis: null,
|
||||
isRightDrag: false,
|
||||
startWorldPosition: new THREE.Vector3(),
|
||||
startMousePosition: new THREE.Vector2(),
|
||||
grabOffset: 0,
|
||||
gridSize: 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
const GRID_SIZES = [0.1, 0.25, 0.5, 1.0, 2.0, 5.0]
|
||||
|
||||
export function cycleGridSize(currentSize: number, direction: number): number {
|
||||
const currentIndex = GRID_SIZES.indexOf(currentSize)
|
||||
if (currentIndex === -1) return GRID_SIZES[2]! // default to 0.5
|
||||
|
||||
const newIndex = Math.max(0, Math.min(GRID_SIZES.length - 1, currentIndex + direction))
|
||||
return GRID_SIZES[newIndex]!
|
||||
}
|
||||
|
||||
export function projectMouseToAxis(
|
||||
mouse: THREE.Vector2,
|
||||
camera: THREE.Camera,
|
||||
axisOrigin: THREE.Vector3,
|
||||
axis: 'x' | 'y' | 'z'
|
||||
): THREE.Vector3 {
|
||||
// Create a ray from camera through mouse position
|
||||
const raycaster = new THREE.Raycaster()
|
||||
raycaster.setFromCamera(mouse, camera)
|
||||
|
||||
// Create a plane perpendicular to the view direction that contains the axis line
|
||||
const cameraDirection = new THREE.Vector3()
|
||||
camera.getWorldDirection(cameraDirection)
|
||||
|
||||
const axisDirection = new THREE.Vector3()
|
||||
if (axis === 'x') axisDirection.set(1, 0, 0)
|
||||
else if (axis === 'y') axisDirection.set(0, 1, 0)
|
||||
else axisDirection.set(0, 0, 1)
|
||||
|
||||
// Find the plane that contains the axis and is most perpendicular to the camera
|
||||
// We use the plane defined by the axis and the camera direction
|
||||
const planeNormal = new THREE.Vector3()
|
||||
.crossVectors(axisDirection, cameraDirection)
|
||||
.cross(axisDirection)
|
||||
.normalize()
|
||||
|
||||
// If plane normal is zero (looking straight down axis), use camera up
|
||||
if (planeNormal.lengthSq() < 0.001) {
|
||||
const cameraUp = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion)
|
||||
planeNormal.crossVectors(axisDirection, cameraUp).cross(axisDirection).normalize()
|
||||
}
|
||||
|
||||
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(planeNormal, axisOrigin)
|
||||
|
||||
// Find intersection of ray with plane
|
||||
const intersection = new THREE.Vector3()
|
||||
raycaster.ray.intersectPlane(plane, intersection)
|
||||
|
||||
if (!intersection) {
|
||||
return axisOrigin.clone()
|
||||
}
|
||||
|
||||
// Project intersection onto the axis line
|
||||
const toIntersection = intersection.clone().sub(axisOrigin)
|
||||
const projectedDistance = toIntersection.dot(axisDirection)
|
||||
|
||||
return axisOrigin.clone().add(axisDirection.multiplyScalar(projectedDistance))
|
||||
}
|
||||
|
||||
export function getAxisDistance(
|
||||
mouse: THREE.Vector2,
|
||||
camera: THREE.Camera,
|
||||
axisOrigin: THREE.Vector3,
|
||||
axis: 'x' | 'y' | 'z'
|
||||
): number {
|
||||
const projected = projectMouseToAxis(mouse, camera, axisOrigin, axis)
|
||||
const axisDirection = new THREE.Vector3()
|
||||
if (axis === 'x') axisDirection.set(1, 0, 0)
|
||||
else if (axis === 'y') axisDirection.set(0, 1, 0)
|
||||
else axisDirection.set(0, 0, 1)
|
||||
|
||||
return projected.clone().sub(axisOrigin).dot(axisDirection)
|
||||
}
|
||||
|
||||
export function snapToGrid(value: number, gridSize: number): number {
|
||||
return Math.round(value / gridSize) * gridSize
|
||||
}
|
||||
|
|
@ -98,15 +98,11 @@ export const useSceneStore = defineStore('scene', () => {
|
|||
}
|
||||
|
||||
function setObjectLocked(id: string, locked: boolean): void {
|
||||
objects.value = objects.value.map((obj) =>
|
||||
obj.id === id ? { ...obj, locked } : obj
|
||||
)
|
||||
objects.value = objects.value.map((obj) => (obj.id === id ? { ...obj, locked } : obj))
|
||||
}
|
||||
|
||||
function renameObject(id: string, name: string): void {
|
||||
objects.value = objects.value.map((obj) =>
|
||||
obj.id === id ? { ...obj, name } : obj
|
||||
)
|
||||
objects.value = objects.value.map((obj) => (obj.id === id ? { ...obj, name } : obj))
|
||||
}
|
||||
|
||||
function select(id: string): void {
|
||||
|
|
@ -144,9 +140,7 @@ export const useSceneStore = defineStore('scene', () => {
|
|||
|
||||
const objectList = computed(() => objects.value)
|
||||
const objectCount = computed(() => objects.value.length)
|
||||
const selectedObjects = computed(() =>
|
||||
objects.value.filter((o) => selectedIds.value.has(o.id))
|
||||
)
|
||||
const selectedObjects = computed(() => objects.value.filter((o) => selectedIds.value.has(o.id)))
|
||||
|
||||
return {
|
||||
// State
|
||||
|
|
|
|||
|
|
@ -28,8 +28,7 @@
|
|||
--space-xl: 32px;
|
||||
|
||||
/* Typography */
|
||||
--font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
sans-serif;
|
||||
--font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-family-mono: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
|
||||
--font-size-xs: 11px;
|
||||
|
|
@ -129,7 +128,10 @@ button {
|
|||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue