Add scale mode
This commit is contained in:
parent
3c139fe58d
commit
56295c8c9d
3 changed files with 741 additions and 14 deletions
18
TODO.md
18
TODO.md
|
|
@ -129,15 +129,15 @@ A step-by-step checklist for porting MatterControl's design features to a Vue +
|
||||||
- [x] Track object rotation state for world-oriented snapping
|
- [x] Track object rotation state for world-oriented snapping
|
||||||
|
|
||||||
### Scale Mode (Ctrl+Shift Modifier)
|
### Scale Mode (Ctrl+Shift Modifier)
|
||||||
- [ ] Create bounding box visualization for selection
|
- [x] Create bounding box visualization for selection
|
||||||
- [ ] Show scale handles when Ctrl+Shift held with selection
|
- [x] Show scale handles when Ctrl+Shift held with selection
|
||||||
- [ ] Corner handles: white cubes for uniform scaling
|
- [x] Corner handles: white cubes for uniform scaling
|
||||||
- [ ] Left-drag: free scale
|
- [x] Left-drag: free scale
|
||||||
- [ ] Right-drag: symmetrical proportional scale from center
|
- [x] Right-drag: symmetrical proportional scale from center
|
||||||
- [ ] Face center handles: colored cubes (by axis) for stretch
|
- [x] Face center handles: colored cubes (by axis) for stretch
|
||||||
- [ ] Left-drag: one-directional stretch along axis
|
- [x] Left-drag: one-directional stretch along axis
|
||||||
- [ ] Right-drag: symmetrical stretch from center along axis
|
- [x] Right-drag: symmetrical stretch from center along axis
|
||||||
- [ ] Hover effect: handles grow subtly and brighten
|
- [x] Hover effect: handles grow subtly and brighten
|
||||||
|
|
||||||
### Camera Mode (Alt Modifier)
|
### Camera Mode (Alt Modifier)
|
||||||
- [ ] Left-drag: orbit camera
|
- [ ] Left-drag: orbit camera
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,15 @@ import {
|
||||||
type RotateGizmo,
|
type RotateGizmo,
|
||||||
type RotateDragState,
|
type RotateDragState,
|
||||||
} from '../composables/useRotateGizmo'
|
} from '../composables/useRotateGizmo'
|
||||||
|
import {
|
||||||
|
createScaleGizmo,
|
||||||
|
createScaleDragState,
|
||||||
|
getScaleFactor,
|
||||||
|
getFreeformScaleFactor,
|
||||||
|
type ScaleGizmo,
|
||||||
|
type ScaleDragState,
|
||||||
|
type ScaleHandleInfo,
|
||||||
|
} from '../composables/useScaleGizmo'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|
@ -74,10 +83,14 @@ let hoverOutlinePass: OutlinePass
|
||||||
let hoveredObject: SceneObject | null = null
|
let hoveredObject: SceneObject | null = null
|
||||||
let translateGizmo: TranslateGizmo | null = null
|
let translateGizmo: TranslateGizmo | null = null
|
||||||
let rotateGizmo: RotateGizmo | null = null
|
let rotateGizmo: RotateGizmo | null = null
|
||||||
|
let scaleGizmo: ScaleGizmo | null = null
|
||||||
let dragState: DragState = createDragState()
|
let dragState: DragState = createDragState()
|
||||||
let rotateDragState: RotateDragState = createRotateDragState()
|
let rotateDragState: RotateDragState = createRotateDragState()
|
||||||
|
let scaleDragState: ScaleDragState = createScaleDragState()
|
||||||
let hoveredAxis: 'x' | 'y' | 'z' | null = null
|
let hoveredAxis: 'x' | 'y' | 'z' | null = null
|
||||||
|
let hoveredScaleHandle: ScaleHandleInfo | null = null
|
||||||
let isCtrlPressed = false
|
let isCtrlPressed = false
|
||||||
|
let isShiftPressed = false
|
||||||
|
|
||||||
function initScene() {
|
function initScene() {
|
||||||
if (!containerRef.value) return
|
if (!containerRef.value) return
|
||||||
|
|
@ -146,6 +159,9 @@ function setupGizmos() {
|
||||||
|
|
||||||
rotateGizmo = createRotateGizmo()
|
rotateGizmo = createRotateGizmo()
|
||||||
scene.add(rotateGizmo.group)
|
scene.add(rotateGizmo.group)
|
||||||
|
|
||||||
|
scaleGizmo = createScaleGizmo()
|
||||||
|
scene.add(scaleGizmo.group)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupPostProcessing() {
|
function setupPostProcessing() {
|
||||||
|
|
@ -262,15 +278,31 @@ function cleanupKeyboardHandlers() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(event: KeyboardEvent) {
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
let needsUpdate = false
|
||||||
if (event.key === 'Control' && !isCtrlPressed) {
|
if (event.key === 'Control' && !isCtrlPressed) {
|
||||||
isCtrlPressed = true
|
isCtrlPressed = true
|
||||||
|
needsUpdate = true
|
||||||
|
}
|
||||||
|
if (event.key === 'Shift' && !isShiftPressed) {
|
||||||
|
isShiftPressed = true
|
||||||
|
needsUpdate = true
|
||||||
|
}
|
||||||
|
if (needsUpdate) {
|
||||||
updateGizmoVisibility()
|
updateGizmoVisibility()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyUp(event: KeyboardEvent) {
|
function onKeyUp(event: KeyboardEvent) {
|
||||||
|
let needsUpdate = false
|
||||||
if (event.key === 'Control' && isCtrlPressed) {
|
if (event.key === 'Control' && isCtrlPressed) {
|
||||||
isCtrlPressed = false
|
isCtrlPressed = false
|
||||||
|
needsUpdate = true
|
||||||
|
}
|
||||||
|
if (event.key === 'Shift' && isShiftPressed) {
|
||||||
|
isShiftPressed = false
|
||||||
|
needsUpdate = true
|
||||||
|
}
|
||||||
|
if (needsUpdate) {
|
||||||
updateGizmoVisibility()
|
updateGizmoVisibility()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -281,15 +313,26 @@ function updateGizmoVisibility() {
|
||||||
if (!hasSelection) {
|
if (!hasSelection) {
|
||||||
translateGizmo?.hide()
|
translateGizmo?.hide()
|
||||||
rotateGizmo?.hide()
|
rotateGizmo?.hide()
|
||||||
|
scaleGizmo?.hide()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCtrlPressed) {
|
if (isCtrlPressed && isShiftPressed) {
|
||||||
|
// Scale mode: Ctrl+Shift
|
||||||
translateGizmo?.hide()
|
translateGizmo?.hide()
|
||||||
|
rotateGizmo?.hide()
|
||||||
|
scaleGizmo?.show()
|
||||||
|
updateScaleGizmoPosition()
|
||||||
|
} else if (isCtrlPressed) {
|
||||||
|
// Rotate mode: Ctrl only
|
||||||
|
translateGizmo?.hide()
|
||||||
|
scaleGizmo?.hide()
|
||||||
rotateGizmo?.show()
|
rotateGizmo?.show()
|
||||||
updateRotateGizmoPosition()
|
updateRotateGizmoPosition()
|
||||||
} else {
|
} else {
|
||||||
|
// Translate mode: default
|
||||||
rotateGizmo?.hide()
|
rotateGizmo?.hide()
|
||||||
|
scaleGizmo?.hide()
|
||||||
translateGizmo?.show()
|
translateGizmo?.show()
|
||||||
updateGizmoPosition()
|
updateGizmoPosition()
|
||||||
}
|
}
|
||||||
|
|
@ -374,6 +417,112 @@ function onCanvasMouseMove(event: MouseEvent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle scale gizmo dragging
|
||||||
|
if (scaleDragState.isDragging && scaleDragState.handleInfo && scaleGizmo) {
|
||||||
|
const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh)
|
||||||
|
if (selectedMeshes.length === 0) return
|
||||||
|
|
||||||
|
const handleInfo = scaleDragState.handleInfo
|
||||||
|
const startBounds = scaleDragState.startBounds
|
||||||
|
const boundsCenter = startBounds.getCenter(new THREE.Vector3())
|
||||||
|
const boundsSize = startBounds.getSize(new THREE.Vector3())
|
||||||
|
|
||||||
|
// Calculate scale factor based on mouse movement
|
||||||
|
const scaleFactor = getScaleFactor(
|
||||||
|
mouse,
|
||||||
|
scaleDragState.startMousePosition,
|
||||||
|
activeCamera,
|
||||||
|
boundsCenter,
|
||||||
|
handleInfo,
|
||||||
|
scaleDragState.handleWorldPosition
|
||||||
|
)
|
||||||
|
|
||||||
|
// For corner handles with left-drag, use free-form scaling where corner follows mouse
|
||||||
|
let finalScaleFactor = scaleFactor.clone()
|
||||||
|
if (handleInfo.type === 'corner' && !scaleDragState.isRightDrag) {
|
||||||
|
// Calculate anchor point (opposite corner)
|
||||||
|
const corner = handleInfo.corner!
|
||||||
|
const halfSize = boundsSize.clone().multiplyScalar(0.5)
|
||||||
|
const anchor = new THREE.Vector3(
|
||||||
|
boundsCenter.x - corner.x * halfSize.x,
|
||||||
|
boundsCenter.y - corner.y * halfSize.y,
|
||||||
|
boundsCenter.z - corner.z * halfSize.z
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use free-form scaling that makes handle follow mouse position
|
||||||
|
finalScaleFactor = getFreeformScaleFactor(
|
||||||
|
mouse,
|
||||||
|
activeCamera,
|
||||||
|
anchor,
|
||||||
|
scaleDragState.handleWorldPosition
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply scale to all selected objects
|
||||||
|
for (const mesh of selectedMeshes) {
|
||||||
|
const startScale = scaleDragState.startMeshScales.get(mesh.uuid)
|
||||||
|
const startPos = scaleDragState.startMeshPositions.get(mesh.uuid)
|
||||||
|
if (!startScale || !startPos) continue
|
||||||
|
|
||||||
|
// Apply new scale
|
||||||
|
mesh.scale.set(
|
||||||
|
startScale.x * finalScaleFactor.x,
|
||||||
|
startScale.y * finalScaleFactor.y,
|
||||||
|
startScale.z * finalScaleFactor.z
|
||||||
|
)
|
||||||
|
|
||||||
|
if (handleInfo.type === 'corner') {
|
||||||
|
// For corner handles, use the opposite corner as anchor
|
||||||
|
const corner = handleInfo.corner!
|
||||||
|
const halfSize = boundsSize.clone().multiplyScalar(0.5)
|
||||||
|
// Opposite corner is at -corner direction
|
||||||
|
const anchor = new THREE.Vector3(
|
||||||
|
boundsCenter.x - corner.x * halfSize.x,
|
||||||
|
boundsCenter.y - corner.y * halfSize.y,
|
||||||
|
boundsCenter.z - corner.z * halfSize.z
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scale position relative to anchor
|
||||||
|
const offsetFromAnchor = startPos.clone().sub(anchor)
|
||||||
|
const newOffset = offsetFromAnchor.clone().multiply(finalScaleFactor)
|
||||||
|
mesh.position.copy(anchor).add(newOffset)
|
||||||
|
} else if (handleInfo.type === 'face') {
|
||||||
|
if (scaleDragState.isRightDrag) {
|
||||||
|
// Symmetrical scaling from center for right-drag
|
||||||
|
const offsetFromCenter = startPos.clone().sub(boundsCenter)
|
||||||
|
const newOffset = offsetFromCenter.clone().multiply(finalScaleFactor)
|
||||||
|
mesh.position.copy(boundsCenter).add(newOffset)
|
||||||
|
} else {
|
||||||
|
// One-directional stretch from opposite face for left-drag
|
||||||
|
const faceDir = new THREE.Vector3()
|
||||||
|
if (handleInfo.face!.startsWith('x')) faceDir.x = handleInfo.face!.endsWith('+') ? 1 : -1
|
||||||
|
else if (handleInfo.face!.startsWith('y'))
|
||||||
|
faceDir.y = handleInfo.face!.endsWith('+') ? 1 : -1
|
||||||
|
else if (handleInfo.face!.startsWith('z'))
|
||||||
|
faceDir.z = handleInfo.face!.endsWith('+') ? 1 : -1
|
||||||
|
|
||||||
|
// Anchor is at the opposite face
|
||||||
|
const halfSize = boundsSize.clone().multiplyScalar(0.5)
|
||||||
|
const anchor = new THREE.Vector3(
|
||||||
|
boundsCenter.x - faceDir.x * halfSize.x,
|
||||||
|
boundsCenter.y - faceDir.y * halfSize.y,
|
||||||
|
boundsCenter.z - faceDir.z * halfSize.z
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scale position relative to anchor
|
||||||
|
const offsetFromAnchor = startPos.clone().sub(anchor)
|
||||||
|
const newOffset = offsetFromAnchor.clone().multiply(finalScaleFactor)
|
||||||
|
mesh.position.copy(anchor).add(newOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update scale gizmo bounds
|
||||||
|
updateScaleGizmoPosition()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Handle rotate gizmo dragging
|
// Handle rotate gizmo dragging
|
||||||
if (rotateDragState.isDragging && rotateDragState.axis && rotateGizmo) {
|
if (rotateDragState.isDragging && rotateDragState.axis && rotateGizmo) {
|
||||||
const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh)
|
const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh)
|
||||||
|
|
@ -462,7 +611,21 @@ function onCanvasMouseMove(event: MouseEvent) {
|
||||||
// Handle gizmo hover highlight
|
// Handle gizmo hover highlight
|
||||||
raycaster.setFromCamera(mouse, activeCamera)
|
raycaster.setFromCamera(mouse, activeCamera)
|
||||||
|
|
||||||
if (isCtrlPressed && rotateGizmo?.group.visible) {
|
if (isCtrlPressed && isShiftPressed && scaleGizmo?.group.visible) {
|
||||||
|
const newHoveredHandle = scaleGizmo.getHoveredHandle(raycaster)
|
||||||
|
// Compare handle info (can't use simple !== due to object comparison)
|
||||||
|
const handleChanged =
|
||||||
|
!newHoveredHandle !== !hoveredScaleHandle ||
|
||||||
|
(newHoveredHandle &&
|
||||||
|
hoveredScaleHandle &&
|
||||||
|
(newHoveredHandle.type !== hoveredScaleHandle.type ||
|
||||||
|
newHoveredHandle.face !== hoveredScaleHandle.face ||
|
||||||
|
!newHoveredHandle.corner?.equals(hoveredScaleHandle.corner ?? new THREE.Vector3())))
|
||||||
|
if (handleChanged) {
|
||||||
|
hoveredScaleHandle = newHoveredHandle
|
||||||
|
scaleGizmo.setHighlightedHandle(hoveredScaleHandle)
|
||||||
|
}
|
||||||
|
} else if (isCtrlPressed && rotateGizmo?.group.visible) {
|
||||||
const newHoveredAxis = rotateGizmo.getHoveredAxis(raycaster)
|
const newHoveredAxis = rotateGizmo.getHoveredAxis(raycaster)
|
||||||
if (newHoveredAxis !== hoveredAxis) {
|
if (newHoveredAxis !== hoveredAxis) {
|
||||||
hoveredAxis = newHoveredAxis
|
hoveredAxis = newHoveredAxis
|
||||||
|
|
@ -486,6 +649,13 @@ function onCanvasMouseMove(event: MouseEvent) {
|
||||||
|
|
||||||
function onCanvasContextMenu(event: MouseEvent) {
|
function onCanvasContextMenu(event: MouseEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
// Don't show context menu when in scale mode (Ctrl+Shift held)
|
||||||
|
if (isCtrlPressed && isShiftPressed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const pickedObject = pickObject(event)
|
const pickedObject = pickObject(event)
|
||||||
emit('contextmenu', pickedObject, event)
|
emit('contextmenu', pickedObject, event)
|
||||||
}
|
}
|
||||||
|
|
@ -494,8 +664,65 @@ function onCanvasMouseDown(event: MouseEvent) {
|
||||||
updateMouseCoordinates(event)
|
updateMouseCoordinates(event)
|
||||||
raycaster.setFromCamera(mouse, activeCamera)
|
raycaster.setFromCamera(mouse, activeCamera)
|
||||||
|
|
||||||
// Check for rotation gizmo first (when Ctrl is held)
|
// Check for scale gizmo first (when Ctrl+Shift is held)
|
||||||
if (isCtrlPressed && rotateGizmo?.group.visible) {
|
if (isCtrlPressed && isShiftPressed && scaleGizmo?.group.visible) {
|
||||||
|
const handleInfo = scaleGizmo.getHoveredHandle(raycaster)
|
||||||
|
if (handleInfo) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
controls.enabled = false
|
||||||
|
|
||||||
|
scaleDragState.isDragging = true
|
||||||
|
scaleDragState.handleInfo = handleInfo
|
||||||
|
scaleDragState.isRightDrag = event.button === 2
|
||||||
|
scaleDragState.startMousePosition.copy(mouse)
|
||||||
|
|
||||||
|
// Store starting scales and positions for all selected objects
|
||||||
|
const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh)
|
||||||
|
scaleDragState.startMeshScales.clear()
|
||||||
|
scaleDragState.startMeshPositions.clear()
|
||||||
|
for (const mesh of selectedMeshes) {
|
||||||
|
scaleDragState.startMeshScales.set(mesh.uuid, mesh.scale.clone())
|
||||||
|
scaleDragState.startMeshPositions.set(mesh.uuid, mesh.position.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the starting bounding box
|
||||||
|
const box = new THREE.Box3()
|
||||||
|
for (const obj of sceneStore.selectedObjects) {
|
||||||
|
box.expandByObject(obj.mesh)
|
||||||
|
}
|
||||||
|
scaleDragState.startBounds.copy(box)
|
||||||
|
|
||||||
|
// Calculate and store the handle world position
|
||||||
|
const boundsCenter = box.getCenter(new THREE.Vector3())
|
||||||
|
const boundsSize = box.getSize(new THREE.Vector3())
|
||||||
|
const halfSize = boundsSize.clone().multiplyScalar(0.5)
|
||||||
|
|
||||||
|
if (handleInfo.type === 'corner' && handleInfo.corner) {
|
||||||
|
scaleDragState.handleWorldPosition.set(
|
||||||
|
boundsCenter.x + handleInfo.corner.x * halfSize.x,
|
||||||
|
boundsCenter.y + handleInfo.corner.y * halfSize.y,
|
||||||
|
boundsCenter.z + handleInfo.corner.z * halfSize.z
|
||||||
|
)
|
||||||
|
} else if (handleInfo.face) {
|
||||||
|
const faceDir = new THREE.Vector3()
|
||||||
|
if (handleInfo.face.startsWith('x')) faceDir.x = handleInfo.face.endsWith('+') ? 1 : -1
|
||||||
|
else if (handleInfo.face.startsWith('y')) faceDir.y = handleInfo.face.endsWith('+') ? 1 : -1
|
||||||
|
else if (handleInfo.face.startsWith('z')) faceDir.z = handleInfo.face.endsWith('+') ? 1 : -1
|
||||||
|
scaleDragState.handleWorldPosition.set(
|
||||||
|
boundsCenter.x + faceDir.x * halfSize.x,
|
||||||
|
boundsCenter.y + faceDir.y * halfSize.y,
|
||||||
|
boundsCenter.z + faceDir.z * halfSize.z
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for rotation gizmo (when Ctrl is held but not Shift)
|
||||||
|
if (isCtrlPressed && !isShiftPressed && rotateGizmo?.group.visible) {
|
||||||
const axis = rotateGizmo.getHoveredAxis(raycaster)
|
const axis = rotateGizmo.getHoveredAxis(raycaster)
|
||||||
if (axis) {
|
if (axis) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
@ -581,6 +808,14 @@ function onCanvasMouseUp(_event: MouseEvent) {
|
||||||
rotateDragState.startObjectRotations.clear()
|
rotateDragState.startObjectRotations.clear()
|
||||||
controls.enabled = true
|
controls.enabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (scaleDragState.isDragging) {
|
||||||
|
scaleDragState.isDragging = false
|
||||||
|
scaleDragState.handleInfo = null
|
||||||
|
scaleDragState.startMeshScales.clear()
|
||||||
|
scaleDragState.startMeshPositions.clear()
|
||||||
|
controls.enabled = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCanvasWheel(event: WheelEvent) {
|
function onCanvasWheel(event: WheelEvent) {
|
||||||
|
|
@ -640,6 +875,9 @@ function animate() {
|
||||||
if (rotateGizmo) {
|
if (rotateGizmo) {
|
||||||
rotateGizmo.updateScale(activeCamera)
|
rotateGizmo.updateScale(activeCamera)
|
||||||
}
|
}
|
||||||
|
if (scaleGizmo) {
|
||||||
|
scaleGizmo.updateScale(activeCamera)
|
||||||
|
}
|
||||||
|
|
||||||
composer.render()
|
composer.render()
|
||||||
}
|
}
|
||||||
|
|
@ -699,11 +937,33 @@ function updateRotateGizmoPosition() {
|
||||||
const center = box.getCenter(new THREE.Vector3())
|
const center = box.getCenter(new THREE.Vector3())
|
||||||
rotateGizmo.setPositionAndBounds(center, box)
|
rotateGizmo.setPositionAndBounds(center, box)
|
||||||
|
|
||||||
if (isCtrlPressed) {
|
if (isCtrlPressed && !isShiftPressed) {
|
||||||
rotateGizmo.show()
|
rotateGizmo.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateScaleGizmoPosition() {
|
||||||
|
if (!scaleGizmo) return
|
||||||
|
|
||||||
|
const selected = sceneStore.selectedObjects
|
||||||
|
if (selected.length === 0) {
|
||||||
|
scaleGizmo.hide()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate bounding box of all selected objects
|
||||||
|
const box = new THREE.Box3()
|
||||||
|
for (const obj of selected) {
|
||||||
|
box.expandByObject(obj.mesh)
|
||||||
|
}
|
||||||
|
|
||||||
|
scaleGizmo.setBounds(box)
|
||||||
|
|
||||||
|
if (isCtrlPressed && isShiftPressed) {
|
||||||
|
scaleGizmo.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => sceneStore.selectedObjects,
|
() => sceneStore.selectedObjects,
|
||||||
() => {
|
() => {
|
||||||
|
|
|
||||||
467
src/composables/useScaleGizmo.ts
Normal file
467
src/composables/useScaleGizmo.ts
Normal file
|
|
@ -0,0 +1,467 @@
|
||||||
|
import * as THREE from 'three'
|
||||||
|
|
||||||
|
export interface ScaleGizmoOptions {
|
||||||
|
handleSize?: number
|
||||||
|
highlightScale?: number
|
||||||
|
lineColor?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScaleGizmo {
|
||||||
|
group: THREE.Group
|
||||||
|
show: () => void
|
||||||
|
hide: () => void
|
||||||
|
setBounds: (box: THREE.Box3) => void
|
||||||
|
getHoveredHandle: (raycaster: THREE.Raycaster) => ScaleHandleInfo | null
|
||||||
|
setHighlightedHandle: (handle: ScaleHandleInfo | null) => void
|
||||||
|
updateScale: (camera: THREE.Camera) => void
|
||||||
|
dispose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScaleHandleType = 'corner' | 'face'
|
||||||
|
export type ScaleAxis = 'x' | 'y' | 'z' | 'xyz'
|
||||||
|
|
||||||
|
export interface ScaleHandleInfo {
|
||||||
|
type: ScaleHandleType
|
||||||
|
axis: ScaleAxis
|
||||||
|
corner?: THREE.Vector3 // For corner handles, which corner (-1 or 1 for each axis)
|
||||||
|
face?: 'x+' | 'x-' | 'y+' | 'y-' | 'z+' | 'z-' // For face handles
|
||||||
|
}
|
||||||
|
|
||||||
|
const AXIS_COLORS = {
|
||||||
|
x: 0xe74c3c, // Red
|
||||||
|
y: 0x2ecc71, // Green
|
||||||
|
z: 0x3498db, // Blue
|
||||||
|
xyz: 0xffffff, // White for uniform
|
||||||
|
}
|
||||||
|
|
||||||
|
const HIGHLIGHT_COLORS = {
|
||||||
|
x: 0xff6b6b,
|
||||||
|
y: 0x5dff7f,
|
||||||
|
z: 0x5dafff,
|
||||||
|
xyz: 0xffffaa,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createScaleGizmo(options: ScaleGizmoOptions = {}): ScaleGizmo {
|
||||||
|
const { handleSize = 0.04, highlightScale = 1.3, lineColor = 0x888888 } = options
|
||||||
|
|
||||||
|
const group = new THREE.Group()
|
||||||
|
group.name = '__scaleGizmo__'
|
||||||
|
group.visible = false
|
||||||
|
|
||||||
|
// Store current bounds
|
||||||
|
const currentBox = new THREE.Box3()
|
||||||
|
const boxCenter = new THREE.Vector3()
|
||||||
|
const boxSize = new THREE.Vector3()
|
||||||
|
|
||||||
|
// Bounding box wireframe
|
||||||
|
const boxGeometry = new THREE.BoxGeometry(1, 1, 1)
|
||||||
|
const boxEdges = new THREE.EdgesGeometry(boxGeometry)
|
||||||
|
const boxLineMaterial = new THREE.LineBasicMaterial({ color: lineColor })
|
||||||
|
const boxLine = new THREE.LineSegments(boxEdges, boxLineMaterial)
|
||||||
|
boxLine.name = '__scaleGizmo_box__'
|
||||||
|
group.add(boxLine)
|
||||||
|
|
||||||
|
// Store handles
|
||||||
|
const cornerHandles: THREE.Mesh[] = []
|
||||||
|
const faceHandles: THREE.Mesh[] = []
|
||||||
|
const handleMaterials: Map<THREE.Mesh, THREE.MeshBasicMaterial> = new Map()
|
||||||
|
|
||||||
|
// Track highlighted handle
|
||||||
|
let highlightedHandle: THREE.Mesh | null = null
|
||||||
|
|
||||||
|
// Create corner handles (8 corners)
|
||||||
|
const corners = [
|
||||||
|
new THREE.Vector3(-1, -1, -1),
|
||||||
|
new THREE.Vector3(-1, -1, 1),
|
||||||
|
new THREE.Vector3(-1, 1, -1),
|
||||||
|
new THREE.Vector3(-1, 1, 1),
|
||||||
|
new THREE.Vector3(1, -1, -1),
|
||||||
|
new THREE.Vector3(1, -1, 1),
|
||||||
|
new THREE.Vector3(1, 1, -1),
|
||||||
|
new THREE.Vector3(1, 1, 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
// Create geometry at unit size - scaling happens in updateScale
|
||||||
|
const cornerGeometry = new THREE.BoxGeometry(1, 1, 1)
|
||||||
|
|
||||||
|
for (const corner of corners) {
|
||||||
|
const material = new THREE.MeshBasicMaterial({ color: AXIS_COLORS.xyz })
|
||||||
|
const handle = new THREE.Mesh(cornerGeometry, material)
|
||||||
|
handle.name = `__scaleGizmo_corner_${corner.x}_${corner.y}_${corner.z}__`
|
||||||
|
handle.userData = {
|
||||||
|
isGizmoHandle: true,
|
||||||
|
type: 'scale',
|
||||||
|
handleType: 'corner' as ScaleHandleType,
|
||||||
|
axis: 'xyz' as ScaleAxis,
|
||||||
|
corner: corner.clone(),
|
||||||
|
}
|
||||||
|
handleMaterials.set(handle, material)
|
||||||
|
cornerHandles.push(handle)
|
||||||
|
group.add(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create face center handles (6 faces)
|
||||||
|
const faces: { position: THREE.Vector3; axis: 'x' | 'y' | 'z'; face: string }[] = [
|
||||||
|
{ position: new THREE.Vector3(1, 0, 0), axis: 'x', face: 'x+' },
|
||||||
|
{ position: new THREE.Vector3(-1, 0, 0), axis: 'x', face: 'x-' },
|
||||||
|
{ position: new THREE.Vector3(0, 1, 0), axis: 'y', face: 'y+' },
|
||||||
|
{ position: new THREE.Vector3(0, -1, 0), axis: 'y', face: 'y-' },
|
||||||
|
{ position: new THREE.Vector3(0, 0, 1), axis: 'z', face: 'z+' },
|
||||||
|
{ position: new THREE.Vector3(0, 0, -1), axis: 'z', face: 'z-' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Create geometry at unit size - scaling happens in updateScale
|
||||||
|
const faceGeometry = new THREE.BoxGeometry(1, 1, 1)
|
||||||
|
|
||||||
|
for (const faceInfo of faces) {
|
||||||
|
const material = new THREE.MeshBasicMaterial({ color: AXIS_COLORS[faceInfo.axis] })
|
||||||
|
const handle = new THREE.Mesh(faceGeometry, material)
|
||||||
|
handle.name = `__scaleGizmo_face_${faceInfo.face}__`
|
||||||
|
handle.userData = {
|
||||||
|
isGizmoHandle: true,
|
||||||
|
type: 'scale',
|
||||||
|
handleType: 'face' as ScaleHandleType,
|
||||||
|
axis: faceInfo.axis as ScaleAxis,
|
||||||
|
face: faceInfo.face,
|
||||||
|
faceDirection: faceInfo.position.clone(),
|
||||||
|
}
|
||||||
|
handleMaterials.set(handle, material)
|
||||||
|
faceHandles.push(handle)
|
||||||
|
group.add(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHandlePositions(): void {
|
||||||
|
const halfSize = boxSize.clone().multiplyScalar(0.5)
|
||||||
|
|
||||||
|
// Update corner handle positions
|
||||||
|
for (const handle of cornerHandles) {
|
||||||
|
const corner = handle.userData.corner as THREE.Vector3
|
||||||
|
handle.position.set(
|
||||||
|
boxCenter.x + corner.x * halfSize.x,
|
||||||
|
boxCenter.y + corner.y * halfSize.y,
|
||||||
|
boxCenter.z + corner.z * halfSize.z
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update face handle positions
|
||||||
|
for (const handle of faceHandles) {
|
||||||
|
const dir = handle.userData.faceDirection as THREE.Vector3
|
||||||
|
handle.position.set(
|
||||||
|
boxCenter.x + dir.x * halfSize.x,
|
||||||
|
boxCenter.y + dir.y * halfSize.y,
|
||||||
|
boxCenter.z + dir.z * halfSize.z
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update bounding box wireframe
|
||||||
|
boxLine.position.copy(boxCenter)
|
||||||
|
boxLine.scale.copy(boxSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(): void {
|
||||||
|
group.visible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide(): void {
|
||||||
|
group.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBounds(box: THREE.Box3): void {
|
||||||
|
currentBox.copy(box)
|
||||||
|
box.getCenter(boxCenter)
|
||||||
|
box.getSize(boxSize)
|
||||||
|
|
||||||
|
// Ensure minimum size
|
||||||
|
if (boxSize.x < 0.1) boxSize.x = 0.1
|
||||||
|
if (boxSize.y < 0.1) boxSize.y = 0.1
|
||||||
|
if (boxSize.z < 0.1) boxSize.z = 0.1
|
||||||
|
|
||||||
|
updateHandlePositions()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHoveredHandle(raycaster: THREE.Raycaster): ScaleHandleInfo | null {
|
||||||
|
if (!group.visible) return null
|
||||||
|
|
||||||
|
const allHandles = [...cornerHandles, ...faceHandles]
|
||||||
|
const intersects = raycaster.intersectObjects(allHandles, false)
|
||||||
|
|
||||||
|
if (intersects.length > 0) {
|
||||||
|
const hit = intersects[0]!.object
|
||||||
|
if (hit.userData?.isGizmoHandle && hit.userData?.type === 'scale') {
|
||||||
|
return {
|
||||||
|
type: hit.userData.handleType,
|
||||||
|
axis: hit.userData.axis,
|
||||||
|
corner: hit.userData.corner?.clone(),
|
||||||
|
face: hit.userData.face,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHighlightedHandle(handleInfo: ScaleHandleInfo | null): void {
|
||||||
|
// Reset previous highlight
|
||||||
|
if (highlightedHandle) {
|
||||||
|
const material = handleMaterials.get(highlightedHandle)
|
||||||
|
if (material) {
|
||||||
|
const axis = highlightedHandle.userData.axis as ScaleAxis
|
||||||
|
material.color.setHex(AXIS_COLORS[axis])
|
||||||
|
}
|
||||||
|
highlightedHandle.scale.setScalar(1)
|
||||||
|
highlightedHandle = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!handleInfo) return
|
||||||
|
|
||||||
|
// Find and highlight the matching handle
|
||||||
|
const allHandles = [...cornerHandles, ...faceHandles]
|
||||||
|
for (const handle of allHandles) {
|
||||||
|
const userData = handle.userData
|
||||||
|
if (userData.handleType === handleInfo.type) {
|
||||||
|
let matches = false
|
||||||
|
if (handleInfo.type === 'corner' && handleInfo.corner) {
|
||||||
|
const corner = userData.corner as THREE.Vector3
|
||||||
|
matches = corner.equals(handleInfo.corner)
|
||||||
|
} else if (handleInfo.type === 'face' && handleInfo.face) {
|
||||||
|
matches = userData.face === handleInfo.face
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches) {
|
||||||
|
highlightedHandle = handle
|
||||||
|
const material = handleMaterials.get(handle)
|
||||||
|
if (material) {
|
||||||
|
const axis = userData.axis as ScaleAxis
|
||||||
|
material.color.setHex(HIGHLIGHT_COLORS[axis])
|
||||||
|
}
|
||||||
|
handle.scale.setScalar(highlightScale)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScale(camera: THREE.Camera): void {
|
||||||
|
if (!group.visible) return
|
||||||
|
|
||||||
|
// Make handles screen-size invariant
|
||||||
|
// handleSize determines the target screen size (as a fraction of view)
|
||||||
|
const distance = camera.position.distanceTo(boxCenter)
|
||||||
|
|
||||||
|
let scale: number
|
||||||
|
if (camera instanceof THREE.PerspectiveCamera) {
|
||||||
|
const vFov = (camera.fov * Math.PI) / 180
|
||||||
|
const worldHeight = 2 * Math.tan(vFov / 2) * distance
|
||||||
|
// handleSize controls how big handles appear on screen
|
||||||
|
scale = (worldHeight * handleSize) / 2
|
||||||
|
} else if (camera instanceof THREE.OrthographicCamera) {
|
||||||
|
const viewHeight = camera.top - camera.bottom
|
||||||
|
scale = (viewHeight * handleSize) / 2
|
||||||
|
} else {
|
||||||
|
scale = handleSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale all handles uniformly for screen-invariant size
|
||||||
|
for (const handle of [...cornerHandles, ...faceHandles]) {
|
||||||
|
const isHighlighted = handle === highlightedHandle
|
||||||
|
const baseScale = isHighlighted ? highlightScale : 1
|
||||||
|
handle.scale.setScalar(baseScale * scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispose(): void {
|
||||||
|
boxGeometry.dispose()
|
||||||
|
boxEdges.dispose()
|
||||||
|
boxLineMaterial.dispose()
|
||||||
|
cornerGeometry.dispose()
|
||||||
|
faceGeometry.dispose()
|
||||||
|
|
||||||
|
for (const material of handleMaterials.values()) {
|
||||||
|
material.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
show,
|
||||||
|
hide,
|
||||||
|
setBounds,
|
||||||
|
getHoveredHandle,
|
||||||
|
setHighlightedHandle,
|
||||||
|
updateScale,
|
||||||
|
dispose,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScaleDragState {
|
||||||
|
isDragging: boolean
|
||||||
|
handleInfo: ScaleHandleInfo | null
|
||||||
|
isRightDrag: boolean
|
||||||
|
startMousePosition: THREE.Vector2
|
||||||
|
startBounds: THREE.Box3
|
||||||
|
startMeshScales: Map<string, THREE.Vector3>
|
||||||
|
startMeshPositions: Map<string, THREE.Vector3>
|
||||||
|
handleWorldPosition: THREE.Vector3 // World position of the handle being dragged
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createScaleDragState(): ScaleDragState {
|
||||||
|
return {
|
||||||
|
isDragging: false,
|
||||||
|
handleInfo: null,
|
||||||
|
isRightDrag: false,
|
||||||
|
startMousePosition: new THREE.Vector2(),
|
||||||
|
startBounds: new THREE.Box3(),
|
||||||
|
startMeshScales: new Map(),
|
||||||
|
startMeshPositions: new Map(),
|
||||||
|
handleWorldPosition: new THREE.Vector3(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp scale factor to avoid degenerate cases (zero scale).
|
||||||
|
* Allows negative values for object flipping/inversion.
|
||||||
|
*/
|
||||||
|
function clampScaleFactor(factor: number): number {
|
||||||
|
const minAbsScale = 0.01
|
||||||
|
if (Math.abs(factor) < minAbsScale) {
|
||||||
|
// If near zero, push to the minimum on the side we were approaching from
|
||||||
|
return factor >= 0 ? minAbsScale : -minAbsScale
|
||||||
|
}
|
||||||
|
return factor
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate scale factor based on mouse movement.
|
||||||
|
* For face handles with right-drag (symmetrical), uses axis-projected movement.
|
||||||
|
* For corner handles with right-drag (uniform), uses diagonal movement.
|
||||||
|
*/
|
||||||
|
export function getScaleFactor(
|
||||||
|
mouse: THREE.Vector2,
|
||||||
|
startMouse: THREE.Vector2,
|
||||||
|
camera: THREE.Camera,
|
||||||
|
center: THREE.Vector3,
|
||||||
|
handleInfo: ScaleHandleInfo,
|
||||||
|
handleWorldPosition: THREE.Vector3
|
||||||
|
): THREE.Vector3 {
|
||||||
|
// Calculate mouse delta in screen space
|
||||||
|
const mouseDelta = mouse.clone().sub(startMouse)
|
||||||
|
|
||||||
|
// Scale sensitivity
|
||||||
|
const sensitivity = 2.0
|
||||||
|
|
||||||
|
let baseFactor: number
|
||||||
|
|
||||||
|
if (handleInfo.type === 'face' && handleInfo.face) {
|
||||||
|
// For face handles, project mouse movement onto the direction from center to handle
|
||||||
|
// First, get the screen-space direction from center to handle
|
||||||
|
const centerScreen = center.clone().project(camera)
|
||||||
|
const handleScreen = handleWorldPosition.clone().project(camera)
|
||||||
|
const handleDir = new THREE.Vector2(
|
||||||
|
handleScreen.x - centerScreen.x,
|
||||||
|
handleScreen.y - centerScreen.y
|
||||||
|
).normalize()
|
||||||
|
|
||||||
|
// Project mouse delta onto this direction
|
||||||
|
const projectedDelta = mouseDelta.dot(handleDir) * sensitivity
|
||||||
|
baseFactor = 1 + projectedDelta
|
||||||
|
} else {
|
||||||
|
// For corner handles (right-drag uniform scaling), use diagonal mouse movement
|
||||||
|
const deltaLength = mouseDelta.length() * sensitivity
|
||||||
|
const direction = mouseDelta.x + mouseDelta.y > 0 ? 1 : -1
|
||||||
|
baseFactor = 1 + direction * deltaLength
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp to prevent zero scale, but allow negative for inversion
|
||||||
|
const clampedFactor = clampScaleFactor(baseFactor)
|
||||||
|
|
||||||
|
// Apply scale based on handle type
|
||||||
|
if (handleInfo.type === 'corner') {
|
||||||
|
// Uniform scaling for corners
|
||||||
|
return new THREE.Vector3(clampedFactor, clampedFactor, clampedFactor)
|
||||||
|
} else {
|
||||||
|
// Axis-specific scaling for faces
|
||||||
|
const scale = new THREE.Vector3(1, 1, 1)
|
||||||
|
if (handleInfo.axis === 'x') scale.x = clampedFactor
|
||||||
|
else if (handleInfo.axis === 'y') scale.y = clampedFactor
|
||||||
|
else if (handleInfo.axis === 'z') scale.z = clampedFactor
|
||||||
|
return scale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate scale factor for free-form corner scaling (left-drag).
|
||||||
|
* Constrains scaling to a plane aligned to major world axes.
|
||||||
|
* The plane is chosen based on camera angle:
|
||||||
|
* - Vertical axis (Y) is always included
|
||||||
|
* - Horizontal axis (X or Z) is whichever is most perpendicular to camera view
|
||||||
|
* The corner handle follows the mouse position exactly on this plane.
|
||||||
|
*/
|
||||||
|
export function getFreeformScaleFactor(
|
||||||
|
mouse: THREE.Vector2,
|
||||||
|
camera: THREE.Camera,
|
||||||
|
anchor: THREE.Vector3,
|
||||||
|
handleWorldPosition: THREE.Vector3
|
||||||
|
): THREE.Vector3 {
|
||||||
|
// Get camera's forward direction (where it's looking)
|
||||||
|
const cameraForward = new THREE.Vector3()
|
||||||
|
camera.getWorldDirection(cameraForward)
|
||||||
|
|
||||||
|
// Project onto XZ plane to get horizontal look direction
|
||||||
|
const horizontalForward = new THREE.Vector2(cameraForward.x, cameraForward.z)
|
||||||
|
if (horizontalForward.length() > 0.001) {
|
||||||
|
horizontalForward.normalize()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which horizontal axis (X or Z) is most perpendicular to camera
|
||||||
|
const dotX = Math.abs(horizontalForward.x) // How much we're looking along X
|
||||||
|
const dotZ = Math.abs(horizontalForward.y) // How much we're looking along Z
|
||||||
|
|
||||||
|
// If looking more along X, Z is perpendicular. If looking more along Z, X is perpendicular.
|
||||||
|
const useXAxis = dotZ > dotX
|
||||||
|
|
||||||
|
// Create a plane passing through the handle, with normal pointing toward camera
|
||||||
|
// The plane is defined by the axis we're NOT scaling (X or Z)
|
||||||
|
const planeNormal = useXAxis
|
||||||
|
? new THREE.Vector3(0, 0, 1) // XY plane (scaling X and Y, Z fixed)
|
||||||
|
: new THREE.Vector3(1, 0, 0) // YZ plane (scaling Y and Z, X fixed)
|
||||||
|
|
||||||
|
const plane = new THREE.Plane()
|
||||||
|
plane.setFromNormalAndCoplanarPoint(planeNormal, handleWorldPosition)
|
||||||
|
|
||||||
|
// Cast a ray from camera through mouse position
|
||||||
|
const raycaster = new THREE.Raycaster()
|
||||||
|
raycaster.setFromCamera(mouse, camera)
|
||||||
|
|
||||||
|
// Find intersection with the scaling plane
|
||||||
|
const intersection = new THREE.Vector3()
|
||||||
|
const hit = raycaster.ray.intersectPlane(plane, intersection)
|
||||||
|
|
||||||
|
if (!hit) {
|
||||||
|
// Fallback if ray doesn't hit plane (e.g., parallel)
|
||||||
|
return new THREE.Vector3(1, 1, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate scale factors based on world-space positions
|
||||||
|
const anchorToHandle = handleWorldPosition.clone().sub(anchor)
|
||||||
|
const anchorToMouse = intersection.clone().sub(anchor)
|
||||||
|
|
||||||
|
const result = new THREE.Vector3(1, 1, 1)
|
||||||
|
const minComponent = 0.001
|
||||||
|
|
||||||
|
// Y axis always scales
|
||||||
|
if (Math.abs(anchorToHandle.y) > minComponent) {
|
||||||
|
result.y = clampScaleFactor(anchorToMouse.y / anchorToHandle.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale X or Z based on which plane we're using
|
||||||
|
if (useXAxis) {
|
||||||
|
if (Math.abs(anchorToHandle.x) > minComponent) {
|
||||||
|
result.x = clampScaleFactor(anchorToMouse.x / anchorToHandle.x)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (Math.abs(anchorToHandle.z) > minComponent) {
|
||||||
|
result.z = clampScaleFactor(anchorToMouse.z / anchorToHandle.z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue