Add touch support to camera while in modeling mode (#7384)
* Add HammerJS * Fmt and little type cleanup * Implement multi-touch through HammerJS * Add velocity-decay "flick" behavior for orbit * Update src/clientSideScene/CameraControls.ts Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> * tsc fix * Update src/clientSideScene/CameraControls.ts Co-authored-by: Kurt Hutten <k.hutten@protonmail.ch> * Release KCL 80 (#7391) * Check for updates button in moar menus & toasts (#7369) * Check for update button in more menus Fixes #7368 * Add menubar item * Another one * Add Checking for updates... and No new update toasts * Lint * Trigger CI * Update src/main.ts Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> * Update electron-builder.yml Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> * Update electron-builder.yml * Moar clean up --------- Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> * Format examples in docs (#7378) Signed-off-by: Nick Cameron <nrc@ncameron.org> * Fix some typos in previous PR (#7392) Signed-off-by: Nick Cameron <nrc@ncameron.org> * Remove the untyped getters from std::args (#7377) * Move last uses of untypeed arg getters Signed-off-by: Nick Cameron <nrc@ncameron.org> * Rename _typed functions Signed-off-by: Nick Cameron <nrc@ncameron.org> --------- Signed-off-by: Nick Cameron <nrc@ncameron.org> * WIP #7226 Fix remove constraints (#7304) * handle if graphSelections is empty * fix removeConstrainingValuesInfo by using pathToNodes if available instead of selectionRanges: current selection should not be required to remove constraints * selectionRanges not needed for removeConstrainingValuesInfo anymore * fix remove constraint unit test: pass line's pathToNode instead of argument to remove constraint * Change to use artifact pathToNode (#7361) * Change to use artifact pathToNode * Fix to do bounds checking * move TTC capture to unit test (#7268) * move TTC capture to unit test * progress with artifact * fmt * abstract cases * add another case * add another test * update snapshots with proper file names * force to JSON * fmt * make jest happy * add another example and other tweaks * fix * tweak * add logs * more logs * strip out kcl version * remove logs * add comment explainer * more comments * more comment * remove package-lock line * Add support for tag on close segment when the last sketch edge is missing (#7375) * add test * fix * Update snapshots * Update snapshots --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * Use namespace for windows e2e tests (#7398) * Use namespace for windows e2e tests * Change to the new profile * Remove TODO * Commit new snapshots even if some tests failed (#7399) * Commit new snapshots even if some tests failed * Update snapshots --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * Clean up share link tests (#7372) * pierremtb/adhoc/clean-up-share-link-tests * Lint * WIP labels * Trigger CI * Change to skips * Remove old docs files (#7381) * Remove old files; no longer generated. * Update snapshots * Update snapshots --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Jace Browning <jacebrowning@gmail.com> * #7199 Fix broken links in docs (#7397) * update broken links * update github discussion links, fmt * update comment --------- Co-authored-by: Jace Browning <jacebrowning@gmail.com> * Inline engine issue from @Irev-Dev * Add commented-out test to be implemented later https://github.com/KittyCAD/modeling-app/issues/7403 * Update e2e/playwright/test-utils.ts Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> --------- Signed-off-by: Nick Cameron <nrc@ncameron.org> Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> Co-authored-by: Kurt Hutten <k.hutten@protonmail.ch> Co-authored-by: Jonathan Tran <jonnytran@gmail.com> Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com> Co-authored-by: Nick Cameron <nrc@ncameron.org> Co-authored-by: Andrew Varga <grizzly33@gmail.com> Co-authored-by: max <margorskyi@gmail.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Jace Browning <jacebrowning@gmail.com> Co-authored-by: Nick McCleery <34814836+nickmccleery@users.noreply.github.com>
This commit is contained in:
@ -1198,3 +1198,174 @@ export async function enableConsoleLogEverything({
|
||||
console.log(`[Main] ${msg.type()}: ${msg.text()}`)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a pan touch gesture from the center of an element.
|
||||
*
|
||||
* Adapted from Playwright docs: https://playwright.dev/docs/touch-events
|
||||
*/
|
||||
export async function panFromCenter(
|
||||
locator: Locator,
|
||||
deltaX = 0,
|
||||
deltaY = 0,
|
||||
steps = 5
|
||||
) {
|
||||
const { centerX, centerY } = await locator.evaluate((target: HTMLElement) => {
|
||||
const bounds = target.getBoundingClientRect()
|
||||
const centerX = bounds.left + bounds.width / 2
|
||||
const centerY = bounds.top + bounds.height / 2
|
||||
return { centerX, centerY }
|
||||
})
|
||||
|
||||
// Providing only clientX and clientY as the app only cares about those.
|
||||
const touches = [
|
||||
{
|
||||
identifier: 0,
|
||||
clientX: centerX,
|
||||
clientY: centerY,
|
||||
},
|
||||
]
|
||||
await locator.dispatchEvent('touchstart', {
|
||||
touches,
|
||||
changedTouches: touches,
|
||||
targetTouches: touches,
|
||||
})
|
||||
|
||||
for (let j = 1; j <= steps; j++) {
|
||||
const touches = [
|
||||
{
|
||||
identifier: 0,
|
||||
clientX: centerX + (deltaX * j) / steps,
|
||||
clientY: centerY + (deltaY * j) / steps,
|
||||
},
|
||||
]
|
||||
await locator.dispatchEvent('touchmove', {
|
||||
touches,
|
||||
changedTouches: touches,
|
||||
targetTouches: touches,
|
||||
})
|
||||
}
|
||||
|
||||
await locator.dispatchEvent('touchend')
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a 2-finger pan touch gesture from the center of an element.
|
||||
* with {touchSpacing} pixels between.
|
||||
*
|
||||
* Adapted from Playwright docs: https://playwright.dev/docs/touch-events
|
||||
*/
|
||||
export async function panTwoFingerFromCenter(
|
||||
locator: Locator,
|
||||
deltaX = 0,
|
||||
deltaY = 0,
|
||||
steps = 5,
|
||||
spacingX = 20
|
||||
) {
|
||||
const { centerX, centerY } = await locator.evaluate((target: HTMLElement) => {
|
||||
const bounds = target.getBoundingClientRect()
|
||||
const centerX = bounds.left + bounds.width / 2
|
||||
const centerY = bounds.top + bounds.height / 2
|
||||
return { centerX, centerY }
|
||||
})
|
||||
|
||||
// Providing only clientX and clientY as the app only cares about those.
|
||||
const touches = [
|
||||
{
|
||||
identifier: 0,
|
||||
clientX: centerX,
|
||||
clientY: centerY,
|
||||
},
|
||||
{
|
||||
identifier: 1,
|
||||
clientX: centerX + spacingX,
|
||||
clientY: centerY,
|
||||
},
|
||||
]
|
||||
await locator.dispatchEvent('touchstart', {
|
||||
touches,
|
||||
changedTouches: touches,
|
||||
targetTouches: touches,
|
||||
})
|
||||
|
||||
for (let j = 1; j <= steps; j++) {
|
||||
const touches = [
|
||||
{
|
||||
identifier: 0,
|
||||
clientX: centerX + (deltaX * j) / steps,
|
||||
clientY: centerY + (deltaY * j) / steps,
|
||||
},
|
||||
{
|
||||
identifier: 1,
|
||||
clientX: centerX + spacingX + (deltaX * j) / steps,
|
||||
clientY: centerY + (deltaY * j) / steps,
|
||||
},
|
||||
]
|
||||
await locator.dispatchEvent('touchmove', {
|
||||
touches,
|
||||
changedTouches: touches,
|
||||
targetTouches: touches,
|
||||
})
|
||||
}
|
||||
|
||||
await locator.dispatchEvent('touchend')
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a pinch touch gesture from the center of an element.
|
||||
* Touch points are set horizontally from each other, separated by {startDistance} pixels.
|
||||
*/
|
||||
export async function pinchFromCenter(
|
||||
locator: Locator,
|
||||
startDistance = 100,
|
||||
delta = 0,
|
||||
steps = 5
|
||||
) {
|
||||
const { centerX, centerY } = await locator.evaluate((target: HTMLElement) => {
|
||||
const bounds = target.getBoundingClientRect()
|
||||
const centerX = bounds.left + bounds.width / 2
|
||||
const centerY = bounds.top + bounds.height / 2
|
||||
return { centerX, centerY }
|
||||
})
|
||||
|
||||
// Providing only clientX and clientY as the app only cares about those.
|
||||
const touches = [
|
||||
{
|
||||
identifier: 0,
|
||||
clientX: centerX - startDistance / 2,
|
||||
clientY: centerY,
|
||||
},
|
||||
{
|
||||
identifier: 1,
|
||||
clientX: centerX + startDistance / 2,
|
||||
clientY: centerY,
|
||||
},
|
||||
]
|
||||
await locator.dispatchEvent('touchstart', {
|
||||
touches,
|
||||
changedTouches: touches,
|
||||
targetTouches: touches,
|
||||
})
|
||||
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const touches = [
|
||||
{
|
||||
identifier: 0,
|
||||
clientX: centerX - startDistance / 2 + (delta * i) / steps,
|
||||
clientY: centerY,
|
||||
},
|
||||
{
|
||||
identifier: 1,
|
||||
clientX: centerX + startDistance / 2 + (delta * i) / steps,
|
||||
clientY: centerY,
|
||||
},
|
||||
]
|
||||
await locator.dispatchEvent('touchmove', {
|
||||
touches,
|
||||
changedTouches: touches,
|
||||
targetTouches: touches,
|
||||
})
|
||||
}
|
||||
|
||||
await locator.dispatchEvent('touchend')
|
||||
}
|
||||
|
146
e2e/playwright/testing-camera-movement-touch.spec.ts
Normal file
146
e2e/playwright/testing-camera-movement-touch.spec.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { getUtils } from '@e2e/playwright/test-utils'
|
||||
import { expect, test } from '@e2e/playwright/zoo-test'
|
||||
import { type Page } from '@playwright/test'
|
||||
import type { SceneFixture } from '@e2e/playwright/fixtures/sceneFixture'
|
||||
|
||||
test.use({
|
||||
hasTouch: true,
|
||||
})
|
||||
test.describe('Testing Camera Movement (Touch Only)', () => {
|
||||
/**
|
||||
* DUPLICATED FROM `testing-camera-movement.spec.ts`, might need to become a util.
|
||||
*
|
||||
* hack that we're implemented our own retry instead of using retries built into playwright.
|
||||
* however each of these camera drags can be flaky, because of udp
|
||||
* and so putting them together means only one needs to fail to make this test extra flaky.
|
||||
* this way we can retry within the test
|
||||
* We could break them out into separate tests, but the longest past of the test is waiting
|
||||
* for the stream to start, so it can be good to bundle related things together.
|
||||
*/
|
||||
const _bakeInRetries = async ({
|
||||
mouseActions,
|
||||
afterPosition,
|
||||
beforePosition,
|
||||
retryCount = 0,
|
||||
page,
|
||||
scene,
|
||||
}: {
|
||||
mouseActions: () => Promise<void>
|
||||
beforePosition: [number, number, number]
|
||||
afterPosition: [number, number, number]
|
||||
retryCount?: number
|
||||
page: Page
|
||||
scene: SceneFixture
|
||||
}) => {
|
||||
const acceptableCamError = 5
|
||||
const u = await getUtils(page)
|
||||
|
||||
await test.step('Set up initial camera position', async () =>
|
||||
await scene.moveCameraTo({
|
||||
x: beforePosition[0],
|
||||
y: beforePosition[1],
|
||||
z: beforePosition[2],
|
||||
}))
|
||||
|
||||
await test.step('Do actions and watch for changes', async () =>
|
||||
u.doAndWaitForImageDiff(async () => {
|
||||
await mouseActions()
|
||||
|
||||
await u.openAndClearDebugPanel()
|
||||
await u.closeDebugPanel()
|
||||
await page.waitForTimeout(100)
|
||||
}, 300))
|
||||
|
||||
await u.openAndClearDebugPanel()
|
||||
await expect(page.getByTestId('cam-x-position')).toBeAttached()
|
||||
|
||||
const vals = await Promise.all([
|
||||
page.getByTestId('cam-x-position').inputValue(),
|
||||
page.getByTestId('cam-y-position').inputValue(),
|
||||
page.getByTestId('cam-z-position').inputValue(),
|
||||
])
|
||||
const errors = vals.map((v, i) => Math.abs(Number(v) - afterPosition[i]))
|
||||
let shouldRetry = false
|
||||
|
||||
if (errors.some((e) => e > acceptableCamError)) {
|
||||
if (retryCount > 2) {
|
||||
console.log('xVal', vals[0], 'xError', errors[0])
|
||||
console.log('yVal', vals[1], 'yError', errors[1])
|
||||
console.log('zVal', vals[2], 'zError', errors[2])
|
||||
|
||||
throw new Error('Camera position not as expected', {
|
||||
cause: {
|
||||
vals,
|
||||
errors,
|
||||
},
|
||||
})
|
||||
}
|
||||
shouldRetry = true
|
||||
}
|
||||
if (shouldRetry) {
|
||||
await _bakeInRetries({
|
||||
mouseActions,
|
||||
afterPosition: afterPosition,
|
||||
beforePosition: beforePosition,
|
||||
retryCount: retryCount + 1,
|
||||
page,
|
||||
scene,
|
||||
})
|
||||
}
|
||||
}
|
||||
// test(
|
||||
// 'Touch camera controls',
|
||||
// {
|
||||
// tag: '@web',
|
||||
// },
|
||||
// async ({ page, homePage, scene, cmdBar }) => {
|
||||
// const u = await getUtils(page)
|
||||
// const camInitialPosition: [number, number, number] = [0, 85, 85]
|
||||
//
|
||||
// await homePage.goToModelingScene()
|
||||
// await scene.settled(cmdBar)
|
||||
// const stream = page.getByTestId('stream')
|
||||
//
|
||||
// await u.openAndClearDebugPanel()
|
||||
// await u.closeKclCodePanel()
|
||||
//
|
||||
// await test.step('Orbit', async () => {
|
||||
// await bakeInRetries({
|
||||
// mouseActions: async () => {
|
||||
// await panFromCenter(stream, 200, 200)
|
||||
// await page.waitForTimeout(200)
|
||||
// },
|
||||
// afterPosition: [19, 85, 85],
|
||||
// beforePosition: camInitialPosition,
|
||||
// page,
|
||||
// scene,
|
||||
// })
|
||||
// })
|
||||
//
|
||||
// await test.step('Pan', async () => {
|
||||
// await bakeInRetries({
|
||||
// mouseActions: async () => {
|
||||
// await panTwoFingerFromCenter(stream, 200, 200)
|
||||
// await page.waitForTimeout(200)
|
||||
// },
|
||||
// afterPosition: [19, 85, 85],
|
||||
// beforePosition: camInitialPosition,
|
||||
// page,
|
||||
// scene,
|
||||
// })
|
||||
// })
|
||||
//
|
||||
// await test.step('Zoom', async () => {
|
||||
// await bakeInRetries({
|
||||
// mouseActions: async () => {
|
||||
// await pinchFromCenter(stream, 300, -100, 5)
|
||||
// },
|
||||
// afterPosition: [0, 118, 118],
|
||||
// beforePosition: camInitialPosition,
|
||||
// page,
|
||||
// scene,
|
||||
// })
|
||||
// })
|
||||
// }
|
||||
// )
|
||||
})
|
18
package-lock.json
generated
18
package-lock.json
generated
@ -47,6 +47,7 @@
|
||||
"diff": "^7.0.0",
|
||||
"electron-updater": "^6.6.2",
|
||||
"fuse.js": "^7.1.0",
|
||||
"hammerjs": "^2.0.8",
|
||||
"html2canvas-pro": "^1.5.8",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
"json-rpc-2.0": "^1.6.0",
|
||||
@ -93,6 +94,7 @@
|
||||
"@testing-library/react": "^15.0.7",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/electron": "^1.6.10",
|
||||
"@types/hammerjs": "^2.0.46",
|
||||
"@types/isomorphic-fetch": "^0.0.39",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/minimist": "^1.2.5",
|
||||
@ -7491,6 +7493,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/hammerjs": {
|
||||
"version": "2.0.46",
|
||||
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
|
||||
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/http-cache-semantics": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
|
||||
@ -15174,6 +15183,15 @@
|
||||
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hammerjs": {
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz",
|
||||
"integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/happy-dom": {
|
||||
"version": "17.4.4",
|
||||
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-17.4.4.tgz",
|
||||
|
@ -49,6 +49,7 @@
|
||||
"diff": "^7.0.0",
|
||||
"electron-updater": "^6.6.2",
|
||||
"fuse.js": "^7.1.0",
|
||||
"hammerjs": "^2.0.8",
|
||||
"html2canvas-pro": "^1.5.8",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
"json-rpc-2.0": "^1.6.0",
|
||||
@ -168,6 +169,7 @@
|
||||
"@testing-library/react": "^15.0.7",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/electron": "^1.6.10",
|
||||
"@types/hammerjs": "^2.0.46",
|
||||
"@types/isomorphic-fetch": "^0.0.39",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/minimist": "^1.2.5",
|
||||
|
@ -1,3 +1,4 @@
|
||||
import Hammer from 'hammerjs'
|
||||
import type {
|
||||
CameraDragInteractionType_type,
|
||||
CameraViewState_type,
|
||||
@ -44,6 +45,7 @@ import {
|
||||
} from '@src/lib/utils'
|
||||
import { deg2Rad } from '@src/lib/utils2d'
|
||||
import type { SettingsType } from '@src/lib/settings/initialSettings'
|
||||
import { degToRad } from 'three/src/math/MathUtils'
|
||||
|
||||
const ORTHOGRAPHIC_CAMERA_SIZE = 20
|
||||
const FRAMES_TO_ANIMATE_IN = 30
|
||||
@ -118,7 +120,7 @@ export class CameraControls {
|
||||
enableZoom = true
|
||||
moveSender: CameraRateLimiter = new CameraRateLimiter()
|
||||
zoomSender: CameraRateLimiter = new CameraRateLimiter()
|
||||
lastPerspectiveFov: number = 45
|
||||
lastPerspectiveFov = 45
|
||||
pendingZoom: number | null = null
|
||||
pendingRotation: Vector2 | null = null
|
||||
pendingPan: Vector2 | null = null
|
||||
@ -213,7 +215,10 @@ export class CameraControls {
|
||||
}
|
||||
}
|
||||
|
||||
doMove = (interaction: any, coordinates: any) => {
|
||||
doMove = (
|
||||
interaction: CameraDragInteractionType_type,
|
||||
coordinates: [number, number]
|
||||
) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
@ -244,9 +249,9 @@ export class CameraControls {
|
||||
}
|
||||
|
||||
constructor(
|
||||
isOrtho = false,
|
||||
domElement: HTMLCanvasElement,
|
||||
engineCommandManager: EngineCommandManager
|
||||
engineCommandManager: EngineCommandManager,
|
||||
isOrtho = false
|
||||
) {
|
||||
this.engineCommandManager = engineCommandManager
|
||||
this.camera = isOrtho ? new OrthographicCamera() : new PerspectiveCamera()
|
||||
@ -263,6 +268,7 @@ export class CameraControls {
|
||||
this.domElement.addEventListener('pointermove', this.onMouseMove)
|
||||
this.domElement.addEventListener('pointerup', this.onMouseUp)
|
||||
this.domElement.addEventListener('wheel', this.onMouseWheel)
|
||||
this.setUpMultiTouch(this.domElement)
|
||||
|
||||
window.addEventListener('resize', this.onWindowResize)
|
||||
this.onWindowResize()
|
||||
@ -422,6 +428,10 @@ export class CameraControls {
|
||||
}
|
||||
|
||||
onMouseMove = (event: PointerEvent) => {
|
||||
if (event.pointerType === 'touch') {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isDragging) {
|
||||
this.mouseNewPosition.set(event.clientX, event.clientY)
|
||||
const deltaMove = this.mouseNewPosition
|
||||
@ -429,8 +439,10 @@ export class CameraControls {
|
||||
.sub(this.mouseDownPosition)
|
||||
this.mouseDownPosition.copy(this.mouseNewPosition)
|
||||
|
||||
let interaction = this.getInteractionType(event)
|
||||
if (interaction === 'none') return
|
||||
const interaction = this.getInteractionType(event)
|
||||
if (interaction === 'none') {
|
||||
return
|
||||
}
|
||||
|
||||
// If there's a valid interaction and the mouse is moving,
|
||||
// our past (and current) interaction was a drag.
|
||||
@ -500,6 +512,14 @@ export class CameraControls {
|
||||
}
|
||||
|
||||
onMouseUp = (event: PointerEvent) => {
|
||||
if (event.pointerType === 'touch') {
|
||||
// We support momentum flick gestures so we have to do these things after that completes
|
||||
return
|
||||
}
|
||||
this.onMouseUpInner(event)
|
||||
}
|
||||
|
||||
onMouseUpInner = (event: PointerEvent) => {
|
||||
this.domElement.releasePointerCapture(event.pointerId)
|
||||
this.isDragging = false
|
||||
this.handleEnd()
|
||||
@ -1304,15 +1324,20 @@ export class CameraControls {
|
||||
Object.values(this._camChangeCallbacks).forEach((cb) => cb())
|
||||
}
|
||||
getInteractionType = (
|
||||
event: MouseEvent
|
||||
event: PointerEvent | WheelEvent | MouseEvent
|
||||
): CameraDragInteractionType_type | 'none' => {
|
||||
const initialInteractionType = _getInteractionType(
|
||||
this.interactionGuards,
|
||||
event,
|
||||
this.enablePan,
|
||||
this.enableRotate,
|
||||
this.enableZoom
|
||||
)
|
||||
// We just need to send any start value to the engine for touch.
|
||||
// I chose "rotate" because it's the 1-finger gesture
|
||||
const initialInteractionType =
|
||||
'pointerType' in event && event.pointerType === 'touch'
|
||||
? 'rotate'
|
||||
: _getInteractionType(
|
||||
this.interactionGuards,
|
||||
event,
|
||||
this.enablePan,
|
||||
this.enableRotate,
|
||||
this.enableZoom
|
||||
)
|
||||
if (
|
||||
initialInteractionType === 'rotate' &&
|
||||
this.getSettings?.().modeling.cameraOrbit.current === 'trackball'
|
||||
@ -1321,6 +1346,171 @@ export class CameraControls {
|
||||
}
|
||||
return initialInteractionType
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up HammerJS, a small library for multi-touch listeners,
|
||||
* and use it for the camera controls if touch is available.
|
||||
*
|
||||
* Note: users cannot change touch controls; this implementation
|
||||
* treats them as distinct from the mouse control scheme. This is because
|
||||
* a device may both have mouse controls and touch available.
|
||||
*
|
||||
* TODO: Add support for sketch mode touch camera movements
|
||||
*/
|
||||
setUpMultiTouch = (domElement: HTMLCanvasElement) => {
|
||||
/** Amount in px needed to pan before recognizer runs */
|
||||
const panDistanceThreshold = 3
|
||||
/** Amount in scale delta needed to pinch before recognizer runs */
|
||||
const zoomScaleThreshold = 0.01
|
||||
/**
|
||||
* Max speed a pinch can be moving (not the pinch but its XY drift) before it's considered a pan.
|
||||
* The closer to this value, the more we reduce the calculated zoom transformation to reduce jitter.
|
||||
*/
|
||||
const velocityLimit = 0.5
|
||||
/** Amount of pixel delta of calculated zoom transform needed before we send to the engine */
|
||||
const normalizedScaleThreshold = 5
|
||||
const velocityFlickDecay = 0.03
|
||||
/** Refresh rate for flick orbit decay timer */
|
||||
const decayRefreshRate = 16
|
||||
const decayIntervals: ReturnType<typeof setInterval>[] = []
|
||||
const clearIntervals = () => {
|
||||
for (let i = decayIntervals.length - 1; i >= 0; i--) {
|
||||
clearInterval(decayIntervals[i])
|
||||
decayIntervals.pop()
|
||||
}
|
||||
}
|
||||
|
||||
const hammertime = new Hammer(domElement, {
|
||||
recognizers: [
|
||||
[Hammer.Pan, { pointers: 1, direction: Hammer.DIRECTION_ALL }],
|
||||
[
|
||||
Hammer.Pan,
|
||||
{
|
||||
event: 'doublepan',
|
||||
pointers: 2,
|
||||
direction: Hammer.DIRECTION_ALL,
|
||||
threshold: panDistanceThreshold,
|
||||
},
|
||||
],
|
||||
[Hammer.Pinch, { enable: true, threshold: zoomScaleThreshold }],
|
||||
],
|
||||
})
|
||||
|
||||
// TODO: get the engine to coalesce simultaneous zoom/pan/orbit events,
|
||||
// then we won't have to worry about jitter at all.
|
||||
// https://github.com/KittyCAD/engine/issues/3528
|
||||
hammertime.get('pinch').recognizeWith(hammertime.get('doublepan'))
|
||||
|
||||
// Clear decay intervals on any interaction start
|
||||
hammertime.on('panstart doublepanstart pinchstart', () => {
|
||||
clearIntervals()
|
||||
})
|
||||
|
||||
// Orbit gesture is a 1-finger "pan"
|
||||
hammertime.on('pan', (ev) => {
|
||||
if (this.syncDirection === 'engineToClient' && ev.maxPointers === 1) {
|
||||
if (this.enableRotate) {
|
||||
const orbitMode =
|
||||
this.getSettings?.().modeling.cameraOrbit.current !== 'spherical'
|
||||
? 'rotatetrackball'
|
||||
: 'rotate'
|
||||
this.moveSender.send(() => {
|
||||
this.doMove(orbitMode, [ev.center.x, ev.center.y])
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
// Fake flicking by sending decaying orbit events in the last direction of orbit
|
||||
hammertime.on('panend', (ev) => {
|
||||
/** HammerJS's `event.velocity` gives you `Math.max(ev.velocityX, ev.velocityY`)`, not the actual velocity. */
|
||||
let velocity = Math.sqrt(ev.velocityY ** 2 + ev.velocityX ** 2)
|
||||
const center = ev.center
|
||||
const direction = ev.angle
|
||||
|
||||
if (
|
||||
this.syncDirection === 'engineToClient' &&
|
||||
ev.maxPointers === 1 &&
|
||||
this.enableRotate &&
|
||||
velocity > 0
|
||||
) {
|
||||
const orbitMode =
|
||||
this.getSettings?.().modeling.cameraOrbit.current !== 'spherical'
|
||||
? 'rotatetrackball'
|
||||
: 'rotate'
|
||||
|
||||
const decayInterval = setInterval(() => {
|
||||
const decayedVelocity = velocity - velocityFlickDecay
|
||||
if (decayedVelocity <= 0) {
|
||||
if (ev.srcEvent instanceof PointerEvent) {
|
||||
this.onMouseUpInner(ev.srcEvent)
|
||||
}
|
||||
clearInterval(decayInterval)
|
||||
} else {
|
||||
velocity = decayedVelocity
|
||||
}
|
||||
|
||||
// We have to multiply by the refresh rate, because `velocity` is in px/ms
|
||||
// but we only call every `decayRefreshRate` ms.
|
||||
center.x =
|
||||
center.x +
|
||||
Math.cos(degToRad(direction)) * velocity * decayRefreshRate
|
||||
center.y =
|
||||
center.y +
|
||||
Math.sin(degToRad(direction)) * velocity * decayRefreshRate
|
||||
|
||||
this.moveSender.send(() => {
|
||||
this.doMove(orbitMode, [center.x, center.y])
|
||||
})
|
||||
}, decayRefreshRate)
|
||||
decayIntervals.push(decayInterval)
|
||||
}
|
||||
})
|
||||
|
||||
// Pan gesture is a 2-finger gesture I named "doublepan"
|
||||
hammertime.on('doublepan', (ev) => {
|
||||
if (this.syncDirection === 'engineToClient' && this.enablePan) {
|
||||
this.moveSender.send(() => {
|
||||
this.doMove('pan', [ev.center.x, ev.center.y])
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Zoom is a pinch, which is very similar to a 2-finger pan
|
||||
// and must therefore be heuristically determined. My heuristics is:
|
||||
// A zoom should only occur if the gesture velocity is low and the scale delta is high
|
||||
let lastScale = 1
|
||||
hammertime.on('pinchmove', (ev) => {
|
||||
const scaleDelta = lastScale - ev.scale
|
||||
const isUnderVelocityLimit = ev.velocity < velocityLimit
|
||||
// The faster you move the less you zoom
|
||||
const velocityFactor =
|
||||
Math.abs(
|
||||
velocityLimit - Math.min(velocityLimit, Math.abs(ev.velocity))
|
||||
) * 0.5
|
||||
const normalizedScale = Math.ceil(scaleDelta * 2000 * velocityFactor)
|
||||
const isOverNormalizedScaleLimit =
|
||||
Math.abs(normalizedScale) > normalizedScaleThreshold
|
||||
|
||||
if (
|
||||
this.syncDirection === 'engineToClient' &&
|
||||
this.enableZoom &&
|
||||
isUnderVelocityLimit &&
|
||||
isOverNormalizedScaleLimit
|
||||
) {
|
||||
this.zoomSender.send(() => {
|
||||
this.doZoom(normalizedScale)
|
||||
})
|
||||
lastScale = ev.scale
|
||||
}
|
||||
})
|
||||
hammertime.on('pinchend pinchcancel', () => {
|
||||
lastScale = 1
|
||||
})
|
||||
|
||||
hammertime.on('pinchend pinchcancel doublepanend', (event) => {
|
||||
this.onMouseUpInner(event.srcEvent as PointerEvent)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Pure function helpers
|
||||
|
@ -291,9 +291,9 @@ export class SceneInfra {
|
||||
window.addEventListener('resize', this.onWindowResize)
|
||||
|
||||
this.camControls = new CameraControls(
|
||||
false,
|
||||
this.renderer.domElement,
|
||||
engineCommandManager
|
||||
engineCommandManager,
|
||||
false
|
||||
)
|
||||
this.camControls.subscribeToCamChange(() => this.onCameraChange())
|
||||
this.camControls.camera.layers.enable(SKETCH_LAYER)
|
||||
|
Reference in New Issue
Block a user