diff --git a/interface.d.ts b/interface.d.ts index faf9ce2744..6b82af5ad5 100644 --- a/interface.d.ts +++ b/interface.d.ts @@ -60,6 +60,7 @@ export interface IElectronAPI { kittycad: (access: string, args: any) => any listMachines: () => Promise getMachineApiIp: () => Promise + readNaturalScrollDirection: () => Promise } declare global { diff --git a/src/clientSideScene/CameraControls.ts b/src/clientSideScene/CameraControls.ts index 2cf0394efa..5c9dd624da 100644 --- a/src/clientSideScene/CameraControls.ts +++ b/src/clientSideScene/CameraControls.ts @@ -22,7 +22,12 @@ import { UnreliableSubscription, } from 'lang/std/engineConnection' import { EngineCommand } from 'lang/std/artifactGraph' -import { toSync, uuidv4 } from 'lib/utils' +import { + cachedNaturalScrollDirection, + refreshNaturalScrollDirection, + toSync, + uuidv4, +} from 'lib/utils' import { deg2Rad } from 'lib/utils2d' import { isReducedMotion, roundOff, throttle } from 'lib/utils' import * as TWEEN from '@tweenjs/tween.js' @@ -33,6 +38,9 @@ const ORTHOGRAPHIC_CAMERA_SIZE = 20 const FRAMES_TO_ANIMATE_IN = 30 const ORTHOGRAPHIC_MAGIC_FOV = 4 +// Load the setting from the OS. +refreshNaturalScrollDirection().catch(reportRejection) + const tempQuaternion = new Quaternion() // just used for maths type interactionType = 'pan' | 'rotate' | 'zoom' @@ -77,8 +85,13 @@ export class CameraControls { enablePan = true enableZoom = true zoomDataFromLastFrame?: number = undefined - // holds coordinates, and interaction - moveDataFromLastFrame?: [number, number, string] = undefined + // Holds event type, coordinates (for wheel, it's delta), and interaction + moveDataFromLastFrame?: [ + 'pointer' | 'wheel', + number, + number, + interactionType + ] = undefined lastPerspectiveFov: number = 45 pendingZoom: number | null = null pendingRotation: Vector2 | null = null @@ -274,19 +287,75 @@ export class CameraControls { const doMove = () => { if (this.moveDataFromLastFrame !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.engineCommandManager.sendSceneCommand({ - type: 'modeling_cmd_req', - cmd: { - type: 'camera_drag_move', - interaction: this.moveDataFromLastFrame[2] as any, - window: { - x: this.moveDataFromLastFrame[0], - y: this.moveDataFromLastFrame[1], - }, - }, - cmd_id: uuidv4(), - }) + const interaction = this.moveDataFromLastFrame[3] + if (this.moveDataFromLastFrame[0] === 'pointer') { + this.engineCommandManager + .sendSceneCommand({ + type: 'modeling_cmd_req', + cmd: { + type: 'camera_drag_move', + interaction, + window: { + x: this.moveDataFromLastFrame[1], + y: this.moveDataFromLastFrame[2], + }, + }, + cmd_id: uuidv4(), + }) + .catch(reportRejection) + } else if (this.moveDataFromLastFrame[0] === 'wheel') { + const deltaX = this.moveDataFromLastFrame[1] + const deltaY = this.moveDataFromLastFrame[2] + this.isDragging = true + this.handleStart() + + this.engineCommandManager + .sendSceneCommand({ + type: 'modeling_cmd_batch_req', + batch_id: uuidv4(), + requests: [ + { + cmd: { + type: 'camera_drag_start', + interaction, + window: { x: 0, y: 0 }, + }, + cmd_id: uuidv4(), + }, + { + cmd: { + type: 'camera_drag_move', + interaction, + window: { + x: -deltaX, + y: -deltaY, + }, + }, + cmd_id: uuidv4(), + }, + { + cmd: { + type: 'camera_drag_end', + interaction, + window: { + x: -deltaX, + y: -deltaY, + }, + }, + cmd_id: uuidv4(), + }, + ], + responses: false, + }) + .catch(reportRejection) + + this.isDragging = false + this.handleEnd() + } else { + console.error( + `Unknown moveDataFromLastFrame event type: ${this.moveDataFromLastFrame[0]}` + ) + } } this.moveDataFromLastFrame = undefined } @@ -377,32 +446,16 @@ export class CameraControls { if (interaction === 'none') return if (this.syncDirection === 'engineToClient') { - this.moveDataFromLastFrame = [event.clientX, event.clientY, interaction] + this.moveDataFromLastFrame = [ + 'pointer', + event.clientX, + event.clientY, + interaction, + ] return } - // Implement camera movement logic here based on deltaMove - // For example, for rotating the camera around the target: - if (interaction === 'rotate') { - this.pendingRotation = this.pendingRotation - ? this.pendingRotation - : new Vector2() - this.pendingRotation.x += deltaMove.x - this.pendingRotation.y += deltaMove.y - } else if (interaction === 'zoom') { - this.pendingZoom = this.pendingZoom ? this.pendingZoom : 1 - this.pendingZoom *= 1 + deltaMove.y * 0.01 - } else if (interaction === 'pan') { - this.pendingPan = this.pendingPan ? this.pendingPan : new Vector2() - let distance = this.camera.position.distanceTo(this.target) - if (this.camera instanceof OrthographicCamera) { - const zoomFudgeFactor = 2280 - distance = zoomFudgeFactor / (this.camera.zoom * 45) - } - const panSpeed = (distance / 1000 / 45) * this.perspectiveFovBeforeOrtho - this.pendingPan.x += -deltaMove.x * panSpeed - this.pendingPan.y += deltaMove.y * panSpeed - } + this.moveCamera(interaction, deltaMove) } else { /** * If we're not in sketch mode and not dragging, we can highlight entities @@ -424,6 +477,31 @@ export class CameraControls { } } + moveCamera(interaction: interactionType, deltaMove: Vector2) { + // Implement camera movement logic here based on deltaMove + // For example, for rotating the camera around the target: + if (interaction === 'rotate') { + this.pendingRotation = this.pendingRotation + ? this.pendingRotation + : new Vector2() + this.pendingRotation.x += deltaMove.x + this.pendingRotation.y += deltaMove.y + } else if (interaction === 'zoom') { + this.pendingZoom = this.pendingZoom ? this.pendingZoom : 1 + this.pendingZoom *= 1 + deltaMove.y * 0.01 + } else if (interaction === 'pan') { + this.pendingPan = this.pendingPan ? this.pendingPan : new Vector2() + let distance = this.camera.position.distanceTo(this.target) + if (this.camera instanceof OrthographicCamera) { + const zoomFudgeFactor = 2280 + distance = zoomFudgeFactor / (this.camera.zoom * 45) + } + const panSpeed = (distance / 1000 / 45) * this.perspectiveFovBeforeOrtho + this.pendingPan.x += -deltaMove.x * panSpeed + this.pendingPan.y += deltaMove.y * panSpeed + } + } + onMouseUp = (event: PointerEvent) => { this.domElement.releasePointerCapture(event.pointerId) this.isDragging = false @@ -443,6 +521,16 @@ export class CameraControls { } } + zoomDirection = (event: WheelEvent): 1 | -1 => { + if (!this.interactionGuards.zoom.scrollAllowInvertY) return 1 + // Safari provides the updated user setting on every event, so it's more + // accurate than our cached value. + if ('webkitDirectionInvertedFromDevice' in event) { + return event.webkitDirectionInvertedFromDevice ? -1 : 1 + } + return cachedNaturalScrollDirection ? -1 : 1 + } + onMouseWheel = (event: WheelEvent) => { const interaction = this.getInteractionType(event) if (interaction === 'none') return @@ -450,12 +538,15 @@ export class CameraControls { if (this.syncDirection === 'engineToClient') { if (interaction === 'zoom') { - this.zoomDataFromLastFrame = event.deltaY + const zoomDir = this.zoomDirection(event) + this.zoomDataFromLastFrame = event.deltaY * zoomDir } else { - // This case will get handled when we add pan and rotate using Apple trackpad. - console.error( - `Unexpected interaction type for engineToClient wheel event: ${interaction}` - ) + this.moveDataFromLastFrame = [ + 'wheel', + event.deltaX, + event.deltaY, + interaction, + ] } return } @@ -469,12 +560,20 @@ export class CameraControls { this.handleStart() if (interaction === 'zoom') { - this.pendingZoom = 1 + (event.deltaY / window.devicePixelRatio) * 0.001 + const zoomDir = this.zoomDirection(event) + this.pendingZoom = + 1 + (event.deltaY / window.devicePixelRatio) * 0.001 * zoomDir } else { - // This case will get handled when we add pan and rotate using Apple trackpad. - console.error( - `Unexpected interaction type for wheel event: ${interaction}` + this.isDragging = true + this.mouseDownPosition.set(event.clientX, event.clientY) + + this.moveCamera(interaction, new Vector2(-event.deltaX, -event.deltaY)) + + this.mouseDownPosition.set( + event.clientX + event.deltaX, + event.clientY + event.deltaY ) + this.isDragging = false } this.handleEnd() } @@ -1256,6 +1355,9 @@ function _getInteractionType( enableZoom: boolean ): interactionType | 'none' { if (event instanceof WheelEvent) { + if (enablePan && interactionGuards.pan.scrollCallback(event)) return 'pan' + if (enableRotate && interactionGuards.rotate.scrollCallback(event)) + return 'rotate' if (enableZoom && interactionGuards.zoom.scrollCallback(event)) return 'zoom' } else { diff --git a/src/lib/cameraControls.ts b/src/lib/cameraControls.ts index 8ad6b4930c..3ef0a49f07 100644 --- a/src/lib/cameraControls.ts +++ b/src/lib/cameraControls.ts @@ -13,6 +13,7 @@ export type CameraSystem = | 'KittyCAD' | 'OnShape' | 'Trackpad Friendly' + | 'Apple Trackpad' | 'Solidworks' | 'NX' | 'Creo' @@ -22,6 +23,7 @@ export const cameraSystems: CameraSystem[] = [ 'KittyCAD', 'OnShape', 'Trackpad Friendly', + 'Apple Trackpad', 'Solidworks', 'NX', 'Creo', @@ -38,6 +40,8 @@ export function mouseControlsToCameraSystem( return 'OnShape' case 'trackpad_friendly': return 'Trackpad Friendly' + case 'apple_trackpad': + return 'Apple Trackpad' case 'solidworks': return 'Solidworks' case 'nx': @@ -54,6 +58,7 @@ export function mouseControlsToCameraSystem( interface MouseGuardHandler { description: string callback: (e: MouseEvent) => boolean + scrollCallback: (e: WheelEvent) => boolean lenientDragStartButton?: number } @@ -61,6 +66,7 @@ interface MouseGuardZoomHandler { description: string dragCallback: (e: MouseEvent) => boolean scrollCallback: (e: WheelEvent) => boolean + scrollAllowInvertY?: boolean lenientDragStartButton?: number } @@ -83,6 +89,7 @@ export const cameraMouseDragGuards: Record = { callback: (e) => (btnName(e).middle && noModifiersPressed(e)) || (btnName(e).right && e.shiftKey), + scrollCallback: () => false, }, zoom: { description: 'Scroll or Ctrl + Right click drag', @@ -92,6 +99,7 @@ export const cameraMouseDragGuards: Record = { rotate: { description: 'Right click drag', callback: (e) => btnName(e).right && noModifiersPressed(e), + scrollCallback: () => false, }, }, OnShape: { @@ -100,6 +108,7 @@ export const cameraMouseDragGuards: Record = { callback: (e) => (btnName(e).right && e.ctrlKey) || (btnName(e).middle && noModifiersPressed(e)), + scrollCallback: () => false, }, zoom: { description: 'Scroll', @@ -109,6 +118,7 @@ export const cameraMouseDragGuards: Record = { rotate: { description: 'Right click drag', callback: (e) => btnName(e).right && noModifiersPressed(e), + scrollCallback: () => false, }, }, 'Trackpad Friendly': { @@ -117,6 +127,7 @@ export const cameraMouseDragGuards: Record = { callback: (e) => (btnName(e).left && e.altKey && e.shiftKey && !e.metaKey) || (btnName(e).middle && noModifiersPressed(e)), + scrollCallback: () => false, }, zoom: { description: `Scroll or ${ALT} + ${META} + Left click drag`, @@ -126,13 +137,44 @@ export const cameraMouseDragGuards: Record = { rotate: { description: `${ALT} + Left click drag`, callback: (e) => btnName(e).left && e.altKey && !e.shiftKey && !e.metaKey, + scrollCallback: () => false, + lenientDragStartButton: 0, + }, + }, + 'Apple Trackpad': { + pan: { + description: `Scroll or one finger drag`, + callback: (e) => btnName(e).left && noModifiersPressed(e), + scrollCallback: (e) => e.deltaMode === 0 && noModifiersPressed(e), lenientDragStartButton: 0, }, + zoom: { + description: `Shift + Scroll`, + dragCallback: (e) => false, + scrollCallback: (e) => + e.deltaMode === 0 && + e.shiftKey && + !e.ctrlKey && + !e.altKey && + !e.metaKey, + scrollAllowInvertY: true, + }, + rotate: { + description: `${ALT} + Scroll`, + callback: (e) => false, + scrollCallback: (e) => + e.deltaMode === 0 && + e.altKey && + !e.ctrlKey && + !e.shiftKey && + !e.metaKey, + }, }, Solidworks: { pan: { description: 'Ctrl + Right click drag', callback: (e) => btnName(e).right && e.ctrlKey, + scrollCallback: () => false, lenientDragStartButton: 2, }, zoom: { @@ -143,12 +185,14 @@ export const cameraMouseDragGuards: Record = { rotate: { description: 'Middle click drag', callback: (e) => btnName(e).middle && noModifiersPressed(e), + scrollCallback: () => false, }, }, NX: { pan: { description: 'Shift + Middle click drag', callback: (e) => btnName(e).middle && e.shiftKey, + scrollCallback: () => false, }, zoom: { description: 'Scroll or Ctrl + Middle click drag', @@ -158,12 +202,14 @@ export const cameraMouseDragGuards: Record = { rotate: { description: 'Middle click drag', callback: (e) => btnName(e).middle && noModifiersPressed(e), + scrollCallback: () => false, }, }, Creo: { pan: { description: 'Ctrl + Left click drag', callback: (e) => btnName(e).left && !btnName(e).right && e.ctrlKey, + scrollCallback: () => false, }, zoom: { description: 'Scroll or Ctrl + Right click drag', @@ -176,12 +222,14 @@ export const cameraMouseDragGuards: Record = { const b = btnName(e) return (b.middle || (b.left && b.right)) && e.ctrlKey }, + scrollCallback: () => false, }, }, AutoCAD: { pan: { description: 'Middle click drag', callback: (e) => btnName(e).middle && noModifiersPressed(e), + scrollCallback: () => false, }, zoom: { description: 'Scroll', @@ -191,6 +239,7 @@ export const cameraMouseDragGuards: Record = { rotate: { description: 'Shift + Middle click drag', callback: (e) => btnName(e).middle && e.shiftKey, + scrollCallback: () => false, }, }, } diff --git a/src/lib/desktop.ts b/src/lib/desktop.ts index 7cdf6fe313..5a6e0ee977 100644 --- a/src/lib/desktop.ts +++ b/src/lib/desktop.ts @@ -565,3 +565,7 @@ export const getUser = async ( } return Promise.reject(new Error('unreachable')) } + +export async function readNaturalScrollDirection() { + return window.electron.readNaturalScrollDirection() +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 1446838249..da3981bc42 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,6 +4,7 @@ import { v4 } from 'uuid' import { isDesktop } from './isDesktop' import { AnyMachineSnapshot } from 'xstate' import { AsyncFn } from './types' +import { readNaturalScrollDirection } from './desktop' export const uuidv4 = v4 @@ -229,6 +230,19 @@ export function isReducedMotion(): boolean { ) } +/** + * True if Apple Trackpad scroll should move the content. I.e. if this is true, + * and the user scrolls down, the viewport moves up relative to the content. + */ +export let cachedNaturalScrollDirection = platform() === 'macos' + +export async function refreshNaturalScrollDirection() { + if (!isDesktop()) return cachedNaturalScrollDirection + const isNatural = await readNaturalScrollDirection() + cachedNaturalScrollDirection = isNatural + return isNatural +} + export function XOR(bool1: boolean, bool2: boolean): boolean { return (bool1 || bool2) && !(bool1 && bool2) } diff --git a/src/preload.ts b/src/preload.ts index b75575e6c9..edf3f7236b 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -5,6 +5,7 @@ import os from 'node:os' import fsSync from 'node:fs' import packageJson from '../package.json' import { MachinesListing } from 'lib/machineManager' +import { exec } from 'child_process' const open = (args: any) => ipcRenderer.invoke('dialog.showOpenDialog', args) const save = (args: any) => ipcRenderer.invoke('dialog.showSaveDialog', args) @@ -60,6 +61,25 @@ const listMachines = async (): Promise => { const getMachineApiIp = async (): Promise => ipcRenderer.invoke('find_machine_api') +async function readNaturalScrollDirection(): Promise { + if (os.platform() !== 'darwin') { + // TODO: Detect this on other OS's. + return false + } + return new Promise((resolve, reject) => { + exec( + 'defaults read -globalDomain com.apple.swipescrolldirection', + (err, stdout) => { + if (err) { + reject(err) + } else { + resolve(stdout.trim() === '1') + } + } + ) + }) +} + contextBridge.exposeInMainWorld('electron', { login, // Passing fs directly is not recommended since it gives a lot of power @@ -120,4 +140,5 @@ contextBridge.exposeInMainWorld('electron', { kittycad, listMachines, getMachineApiIp, + readNaturalScrollDirection, }) diff --git a/src/wasm-lib/kcl/src/settings/types/mod.rs b/src/wasm-lib/kcl/src/settings/types/mod.rs index c9bd07eb4e..79eb0e7caf 100644 --- a/src/wasm-lib/kcl/src/settings/types/mod.rs +++ b/src/wasm-lib/kcl/src/settings/types/mod.rs @@ -386,6 +386,8 @@ pub enum MouseControlType { OnShape, #[serde(alias = "Trackpad Friendly")] TrackpadFriendly, + #[serde(alias = "Apple Trackpad")] + AppleTrackpad, #[serde(alias = "Solidworks")] Solidworks, #[serde(alias = "NX")]