diff --git a/e2e/playwright/flow-tests.spec.ts b/e2e/playwright/flow-tests.spec.ts index a3c3ca972..36611127f 100644 --- a/e2e/playwright/flow-tests.spec.ts +++ b/e2e/playwright/flow-tests.spec.ts @@ -14,6 +14,12 @@ document.addEventListener('mousemove', (e) => ) */ +const commonPoints = { + startAt: '[26.38, -35.59]', + num1: 26.63, + num2: 53.01, +} + test.beforeEach(async ({ context, page }) => { // wait for Vite preview server to be up await waitOn({ @@ -72,35 +78,34 @@ test('Basic sketch', async ({ page }) => { const startXPx = 600 await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10) - const startAt = '[23.74, -32.03]' await expect(page.locator('.cm-content')) .toHaveText(`const part001 = startSketchOn('-XZ') - |> startProfileAt(${startAt}, %)`) + |> startProfileAt(${commonPoints.startAt}, %)`) await page.waitForTimeout(100) await u.closeDebugPanel() await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10) await page.waitForTimeout(100) - const num = 23.97 + const num = 26.63 await expect(page.locator('.cm-content')) .toHaveText(`const part001 = startSketchOn('-XZ') - |> startProfileAt(${startAt}, %) - |> line([${num}, 0], %)`) + |> startProfileAt(${commonPoints.startAt}, %) + |> line([${commonPoints.num1}, 0], %)`) await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 20) await expect(page.locator('.cm-content')) .toHaveText(`const part001 = startSketchOn('-XZ') - |> startProfileAt(${startAt}, %) - |> line([${num}, 0], %) - |> line([0, ${num}], %)`) + |> startProfileAt(${commonPoints.startAt}, %) + |> line([${commonPoints.num1}, 0], %) + |> line([0, ${commonPoints.num1}], %)`) await page.mouse.click(startXPx, 500 - PUR * 20) await expect(page.locator('.cm-content')) .toHaveText(`const part001 = startSketchOn('-XZ') - |> startProfileAt(${startAt}, %) - |> line([${num}, 0], %) - |> line([0, ${num}], %) - |> line([-47.71, 0], %)`) + |> startProfileAt(${commonPoints.startAt}, %) + |> line([${commonPoints.num1}, 0], %) + |> line([0, ${commonPoints.num1}], %) + |> line([-${commonPoints.num2}, 0], %)`) // deselect line tool await page.getByRole('button', { name: 'Line' }).click() @@ -122,9 +127,9 @@ test('Basic sketch', async ({ page }) => { await expect(page.locator('.cm-content')) .toHaveText(`const part001 = startSketchOn('-XZ') - |> startProfileAt(${startAt}, %) - |> line({ to: [${num}, 0], tag: 'seg01' }, %) - |> line([0, ${num}], %) + |> startProfileAt(${commonPoints.startAt}, %) + |> line({ to: [${commonPoints.num1}, 0], tag: 'seg01' }, %) + |> line([0, ${commonPoints.num1}], %) |> angledLine([180, segLen('seg01', %)], %)`) }) @@ -305,11 +310,9 @@ test('Can create sketches on all planes and their back sides', async ({ } const codeTemplate = ( - plane = 'XY', - rounded = false, - otherThing = '1' + plane = 'XY' ) => `const part001 = startSketchOn('${plane}') - |> startProfileAt([28.9${otherThing}, -39${rounded ? '' : '.01'}], %)` + |> startProfileAt([32.13, -43.34], %)` await TestSinglePlane({ viewCmd: camPos, expectedCode: codeTemplate('XY'), @@ -318,7 +321,7 @@ test('Can create sketches on all planes and their back sides', async ({ }) await TestSinglePlane({ viewCmd: camPos, - expectedCode: codeTemplate('YZ', true), + expectedCode: codeTemplate('YZ'), clickCoords: { x: 700, y: 300 }, // green plane }) await TestSinglePlane({ @@ -329,7 +332,7 @@ test('Can create sketches on all planes and their back sides', async ({ const camCmdBackSide: [number, number, number] = [-100, -100, -100] await TestSinglePlane({ viewCmd: camCmdBackSide, - expectedCode: codeTemplate('-XY', false, '3'), + expectedCode: codeTemplate('-XY'), clickCoords: { x: 601, y: 118 }, // back of red plane }) await TestSinglePlane({ @@ -339,7 +342,7 @@ test('Can create sketches on all planes and their back sides', async ({ }) await TestSinglePlane({ viewCmd: camCmdBackSide, - expectedCode: codeTemplate('-XZ', true), + expectedCode: codeTemplate('-XZ'), clickCoords: { x: 680, y: 427 }, // back of blue plane }) }) @@ -461,35 +464,32 @@ test('Selections work on fresh and edited sketch', async ({ page }) => { const startXPx = 600 await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10) - const startAt = '[23.74, -32.03]' await expect(page.locator('.cm-content')) .toHaveText(`const part001 = startSketchOn('-XZ') - |> startProfileAt(${startAt}, %)`) + |> startProfileAt(${commonPoints.startAt}, %)`) await u.closeDebugPanel() await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10) - const num = 23.97 - const num2 = '47.71' await expect(page.locator('.cm-content')) .toHaveText(`const part001 = startSketchOn('-XZ') - |> startProfileAt(${startAt}, %) - |> line([${num}, 0], %)`) + |> startProfileAt(${commonPoints.startAt}, %) + |> line([${commonPoints.num1}, 0], %)`) await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 20) await expect(page.locator('.cm-content')) .toHaveText(`const part001 = startSketchOn('-XZ') - |> startProfileAt(${startAt}, %) - |> line([${num}, 0], %) - |> line([0, ${num}], %)`) + |> startProfileAt(${commonPoints.startAt}, %) + |> line([${commonPoints.num1}, 0], %) + |> line([0, ${commonPoints.num1}], %)`) await page.mouse.click(startXPx, 500 - PUR * 20) await expect(page.locator('.cm-content')) .toHaveText(`const part001 = startSketchOn('-XZ') - |> startProfileAt(${startAt}, %) - |> line([${num}, 0], %) - |> line([0, ${num}], %) - |> line([-${num2}, 0], %)`) + |> startProfileAt(${commonPoints.startAt}, %) + |> line([${commonPoints.num1}, 0], %) + |> line([0, ${commonPoints.num1}], %) + |> line([-${commonPoints.num2}, 0], %)`) // deselect line tool await page.getByRole('button', { name: 'Line' }).click() @@ -539,7 +539,7 @@ test('Selections work on fresh and edited sketch', async ({ page }) => { await emptySpaceClick() // check the same selection again by putting cursor in code first then selecting axis - await page.getByText(` |> line([-${num2}, 0], %)`).click() + await page.getByText(` |> line([-${commonPoints.num2}, 0], %)`).click() await page.keyboard.down('Shift') await expect(absYButton).toBeDisabled() await xAxisClick() @@ -550,7 +550,7 @@ test('Selections work on fresh and edited sketch', async ({ page }) => { await emptySpaceClick() // select segment in editor than another segment in scene and check there are two cursors - await page.getByText(` |> line([-${num2}, 0], %)`).click() + await page.getByText(` |> line([-${commonPoints.num2}, 0], %)`).click() await page.waitForTimeout(300) await page.keyboard.down('Shift') await expect(page.locator('.cm-cursor')).toHaveCount(1) @@ -575,7 +575,7 @@ test('Selections work on fresh and edited sketch', async ({ page }) => { // select a line // await topHorzSegmentClick() - await page.getByText(startAt).click() // TODO remove this and reinstate // await topHorzSegmentClick() + await page.getByText(commonPoints.startAt).click() // TODO remove this and reinstate // await topHorzSegmentClick() await page.waitForTimeout(100) // enter sketch again @@ -737,34 +737,32 @@ test('Can add multiple sketches', async ({ page }) => { const startXPx = 600 await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10) - const startAt = '[23.74, -32.03]' await expect(page.locator('.cm-content')) .toHaveText(`const part001 = startSketchOn('-XZ') - |> startProfileAt(${startAt}, %)`) + |> startProfileAt(${commonPoints.startAt}, %)`) await page.waitForTimeout(100) await u.closeDebugPanel() await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10) await page.waitForTimeout(100) - const num = 23.97 await expect(page.locator('.cm-content')) .toHaveText(`const part001 = startSketchOn('-XZ') - |> startProfileAt(${startAt}, %) - |> line([${num}, 0], %)`) + |> startProfileAt(${commonPoints.startAt}, %) + |> line([${commonPoints.num1}, 0], %)`) await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 20) await expect(page.locator('.cm-content')) .toHaveText(`const part001 = startSketchOn('-XZ') - |> startProfileAt(${startAt}, %) - |> line([${num}, 0], %) - |> line([0, ${num}], %)`) + |> startProfileAt(${commonPoints.startAt}, %) + |> line([${commonPoints.num1}, 0], %) + |> line([0, ${commonPoints.num1}], %)`) await page.mouse.click(startXPx, 500 - PUR * 20) const finalCodeFirstSketch = `const part001 = startSketchOn('-XZ') - |> startProfileAt(${startAt}, %) - |> line([${num}, 0], %) - |> line([0, ${num}], %) - |> line([-47.71, 0], %)` + |> startProfileAt(${commonPoints.startAt}, %) + |> line([${commonPoints.num1}, 0], %) + |> line([0, ${commonPoints.num1}], %) + |> line([-${commonPoints.num2}, 0], %)` await expect(page.locator('.cm-content')).toHaveText(finalCodeFirstSketch) // exit the sketch @@ -786,7 +784,7 @@ test('Can add multiple sketches', async ({ page }) => { await u.clearAndCloseDebugPanel() await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10) - const startAt2 = '[23.61, -31.85]' + const startAt2 = '[26.23, -35.39]' await expect( (await page.locator('.cm-content').innerText()).replace(/\s/g, '') ).toBe( @@ -800,7 +798,7 @@ const part002 = startSketchOn('XY') await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10) await page.waitForTimeout(100) - const num2 = 23.83 + const num2 = 26.48 await expect( (await page.locator('.cm-content').innerText()).replace(/\s/g, '') ).toBe( @@ -829,7 +827,7 @@ const part002 = startSketchOn('XY') |> startProfileAt(${startAt2}, %) |> line([${num2}, 0], %) |> line([0, ${num2}], %) - |> line([-47.44, 0], %)`.replace(/\s/g, '') + |> line([-52.71, 0], %)`.replace(/\s/g, '') ) }) diff --git a/e2e/playwright/snapshot-tests.spec.ts b/e2e/playwright/snapshot-tests.spec.ts index 309f3fd86..329d88266 100644 --- a/e2e/playwright/snapshot-tests.spec.ts +++ b/e2e/playwright/snapshot-tests.spec.ts @@ -31,6 +31,12 @@ test.beforeEach(async ({ context, page }) => { test.setTimeout(60000) +const commonPoints = { + startAt: '[26.38, -35.59]', + num1: 26.63, + num2: 53.01, +} + test('change camera, show planes', async ({ page, context }) => { const u = getUtils(page) await page.setViewportSize({ width: 1200, height: 500 }) @@ -448,10 +454,9 @@ test('Draft segments should look right', async ({ page }) => { const startXPx = 600 await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10) - const startAt = '[23.74, -32.03]' await expect(page.locator('.cm-content')) .toHaveText(`const part001 = startSketchOn('-XZ') - |> startProfileAt(${startAt}, %)`) + |> startProfileAt(${commonPoints.startAt}, %)`) await page.waitForTimeout(100) await u.closeDebugPanel() @@ -463,11 +468,10 @@ test('Draft segments should look right', async ({ page }) => { await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10) await page.waitForTimeout(100) - const num = 23.97 await expect(page.locator('.cm-content')) .toHaveText(`const part001 = startSketchOn('-XZ') - |> startProfileAt(${startAt}, %) - |> line([${num}, 0], %)`) + |> startProfileAt(${commonPoints.startAt}, %) + |> line([${commonPoints.num1}, 0], %)`) await page.getByRole('button', { name: 'Tangential Arc' }).click() diff --git a/package.json b/package.json index 8728d7f79..f4b0ec43a 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "wasm-prep": "rm -rf src/wasm-lib/pkg && mkdir src/wasm-lib/pkg && rm -rf src/wasm-lib/kcl/bindings", "lint": "eslint --fix src", "bump-jsons": "echo \"$(jq --arg v \"$VERSION\" '.version=$v' package.json --indent 2)\" > package.json && echo \"$(jq --arg v \"$VERSION\" '.package.version=$v' src-tauri/tauri.conf.json --indent 2)\" > src-tauri/tauri.conf.json", - "postinstall": "patch-package && yarn xstate:typegen", + "postinstall": "yarn xstate:typegen", "xstate:typegen": "yarn xstate typegen \"src/**/*.ts?(x)\"" }, "prettier": { @@ -134,7 +134,6 @@ "eslint-plugin-css-modules": "^2.12.0", "happy-dom": "^10.8.0", "husky": "^8.0.3", - "patch-package": "^8.0.0", "pixelmatch": "^5.3.0", "pngjs": "^7.0.0", "postcss": "^8.4.31", diff --git a/patches/three+0.160.0.patch b/patches/three+0.160.0.patch deleted file mode 100644 index 7c71de904..000000000 --- a/patches/three+0.160.0.patch +++ /dev/null @@ -1,138 +0,0 @@ -diff --git a/node_modules/three/examples/jsm/controls/OrbitControls.js b/node_modules/three/examples/jsm/controls/OrbitControls.js -index f29e7fe..0ef636b 100644 ---- a/node_modules/three/examples/jsm/controls/OrbitControls.js -+++ b/node_modules/three/examples/jsm/controls/OrbitControls.js -@@ -113,6 +113,25 @@ class OrbitControls extends EventDispatcher { - // public methods - // - -+ this.interactionGuards = { -+ pan: { -+ description: 'Right click + Shift + drag or middle click + drag', -+ callback: (e) => e.button === 2 && !e.ctrlKey, -+ }, -+ zoom: { -+ description: 'Scroll wheel or Right click + Ctrl + drag', -+ dragCallback: (e) => e.button === 2 && e.ctrlKey, -+ scrollCallback: () => true, -+ }, -+ rotate: { -+ description: 'Right click + drag', -+ callback: (e) => e.button === 0, -+ }, -+ } -+ this.setMouseGuards = (interactionGuards) => { -+ this.interactionGuards = interactionGuards -+ } -+ - this.getPolarAngle = function () { - - return spherical.phi; -@@ -1057,92 +1076,21 @@ class OrbitControls extends EventDispatcher { - - function onMouseDown( event ) { - -- let mouseAction; -- -- switch ( event.button ) { -- -- case 0: -- -- mouseAction = scope.mouseButtons.LEFT; -- break; -- -- case 1: -- -- mouseAction = scope.mouseButtons.MIDDLE; -- break; -- -- case 2: -- -- mouseAction = scope.mouseButtons.RIGHT; -- break; -- -- default: -- -- mouseAction = - 1; -- -- } -- -- switch ( mouseAction ) { -- -- case MOUSE.DOLLY: -- -- if ( scope.enableZoom === false ) return; -- -- handleMouseDownDolly( event ); -- -- state = STATE.DOLLY; -- -- break; -- -- case MOUSE.ROTATE: -- -- if ( event.ctrlKey || event.metaKey || event.shiftKey ) { -- -- if ( scope.enablePan === false ) return; -- -- handleMouseDownPan( event ); -- -- state = STATE.PAN; -- -- } else { -- -- if ( scope.enableRotate === false ) return; -- -- handleMouseDownRotate( event ); -- -- state = STATE.ROTATE; -- -- } -- -- break; -- -- case MOUSE.PAN: -- -- if ( event.ctrlKey || event.metaKey || event.shiftKey ) { -- -- if ( scope.enableRotate === false ) return; -- -- handleMouseDownRotate( event ); -- -- state = STATE.ROTATE; -- -- } else { -- -- if ( scope.enablePan === false ) return; -- -- handleMouseDownPan( event ); -- -- state = STATE.PAN; -- -- } -- -- break; -- -- default: -- -- state = STATE.NONE; -- -- } -+ if (scope.interactionGuards.pan.callback(event)) { -+ if (scope.enablePan === false) return -+ handleMouseDownPan(event) -+ state = STATE.PAN -+ } else if (scope.interactionGuards.rotate.callback(event)) { -+ if (scope.enableRotate === false) return -+ handleMouseDownRotate(event) -+ state = STATE.ROTATE -+ } else if (scope.interactionGuards.zoom.dragCallback(event)) { -+ if (scope.enableZoom === false) return -+ handleMouseDownDolly(event) -+ state = STATE.DOLLY -+ } else { -+ return -+ } - - if ( state !== STATE.NONE ) { - diff --git a/src/clientSideScene/CameraControls.ts b/src/clientSideScene/CameraControls.ts new file mode 100644 index 000000000..91038e4b7 --- /dev/null +++ b/src/clientSideScene/CameraControls.ts @@ -0,0 +1,900 @@ +import { MouseGuard } from 'lib/cameraControls' +import { + Euler, + MathUtils, + Matrix4, + OrthographicCamera, + PerspectiveCamera, + Quaternion, + Spherical, + Vector2, + Vector3, +} from 'three' +import { + DEBUG_SHOW_INTERSECTION_PLANE, + INTERSECTION_PLANE_LAYER, + SKETCH_LAYER, + ZOOM_MAGIC_NUMBER, +} from './sceneInfra' +import { EngineCommand, engineCommandManager } from 'lang/std/engineConnection' +import { v4 as uuidv4 } from 'uuid' +import { deg2Rad } from 'lib/utils2d' +import { isReducedMotion, roundOff, throttle } from 'lib/utils' +import * as TWEEN from '@tweenjs/tween.js' +import { isQuaternionVertical } from './helpers' + +const ORTHOGRAPHIC_CAMERA_SIZE = 20 +const FRAMES_TO_ANIMATE_IN = 30 + +const tempQuaternion = new Quaternion() // just used for maths + +interface ThreeCamValues { + position: Vector3 + quaternion: Quaternion + zoom: number + isPerspective: boolean + target: Vector3 +} + +export type ReactCameraProperties = + | { + type: 'perspective' + fov?: number + position: [number, number, number] + quaternion: [number, number, number, number] + } + | { + type: 'orthographic' + zoom?: number + position: [number, number, number] + quaternion: [number, number, number, number] + } + +const lastCmdDelay = 50 + +let lastCmd: EngineCommand | null = null +let lastCmdTime: number = Date.now() +let lastCmdTimeoutId: number | null = null + +const sendLastReliableChannel = () => { + if (lastCmd && Date.now() - lastCmdTime >= lastCmdDelay) { + engineCommandManager.sendSceneCommand(lastCmd, true) + lastCmdTime = Date.now() + } +} + +const throttledUpdateEngineCamera = throttle((threeValues: ThreeCamValues) => { + const cmd: EngineCommand = { + type: 'modeling_cmd_req', + cmd_id: uuidv4(), + cmd: { + type: 'default_camera_look_at', + ...convertThreeCamValuesToEngineCam(threeValues), + }, + } + engineCommandManager.sendSceneCommand(cmd) + lastCmd = cmd + lastCmdTime = Date.now() + + if (lastCmdTimeoutId !== null) { + clearTimeout(lastCmdTimeoutId) + } + lastCmdTimeoutId = setTimeout( + sendLastReliableChannel, + lastCmdDelay + ) as any as number +}, 1000 / 30) + +let lastPerspectiveCmd: EngineCommand | null = null +let lastPerspectiveCmdTime: number = Date.now() +let lastPerspectiveCmdTimeoutId: number | null = null + +const sendLastPerspectiveReliableChannel = () => { + if ( + lastPerspectiveCmd && + Date.now() - lastPerspectiveCmdTime >= lastCmdDelay + ) { + engineCommandManager.sendSceneCommand(lastPerspectiveCmd, true) + lastPerspectiveCmdTime = Date.now() + } +} + +const throttledUpdateEngineFov = throttle( + (vals: { + position: Vector3 + quaternion: Quaternion + zoom: number + fov: number + target: Vector3 + }) => { + const cmd: EngineCommand = { + type: 'modeling_cmd_req', + cmd_id: uuidv4(), + cmd: { + type: 'default_camera_perspective_settings', + ...convertThreeCamValuesToEngineCam({ + ...vals, + isPerspective: true, + }), + fov_y: vals.fov, + ...calculateNearFarFromFOV(vals.fov), + }, + } + engineCommandManager.sendSceneCommand(cmd) + lastPerspectiveCmd = cmd + lastPerspectiveCmdTime = Date.now() + if (lastPerspectiveCmdTimeoutId !== null) { + clearTimeout(lastPerspectiveCmdTimeoutId) + } + lastPerspectiveCmdTimeoutId = setTimeout( + sendLastPerspectiveReliableChannel, + lastCmdDelay + ) as any as number + }, + 1000 / 15 +) + +export class CameraControls { + camera: PerspectiveCamera | OrthographicCamera + target: Vector3 + domElement: HTMLCanvasElement + isDragging: boolean + mouseDownPosition: Vector2 + mouseNewPosition: Vector2 + rotationSpeed = 0.3 + enableRotate = true + enablePan = true + enableZoom = true + lastPerspectiveFov: number = 45 + pendingZoom: number | null = null + pendingRotation: Vector2 | null = null + pendingPan: Vector2 | null = null + interactionGuards: MouseGuard = { + pan: { + description: 'Right click + Shift + drag or middle click + drag', + callback: (e) => !!(e.buttons & 4) && !e.ctrlKey, + }, + zoom: { + description: 'Scroll wheel or Right click + Ctrl + drag', + dragCallback: (e) => e.button === 2 && e.ctrlKey, + scrollCallback: () => true, + }, + rotate: { + description: 'Right click + drag', + callback: (e) => { + console.log('event', e) + return !!(e.buttons & 2) + }, + }, + } + isFovAnimationInProgress = false + fovBeforeOrtho = 45 + get isPerspective() { + return this.camera instanceof PerspectiveCamera + } + + // reacts hooks into some of this singleton's properties + reactCameraProperties: ReactCameraProperties = { + type: 'perspective', + fov: 12, + position: [0, 0, 0], + quaternion: [0, 0, 0, 1], + } + + setCam = (camProps: ReactCameraProperties) => { + if ( + camProps.type === 'perspective' && + this.camera instanceof OrthographicCamera + ) { + this.usePerspectiveCamera() + } else if ( + camProps.type === 'orthographic' && + this.camera instanceof PerspectiveCamera + ) { + this.useOrthographicCamera() + } + this.camera.position.set(...camProps.position) + this.camera.quaternion.set(...camProps.quaternion) + if ( + camProps.type === 'perspective' && + this.camera instanceof PerspectiveCamera + ) { + // not sure what to do here, calling dollyZoom here is buggy because it updates the position + // at the same time + } else if ( + camProps.type === 'orthographic' && + this.camera instanceof OrthographicCamera + ) { + this.camera.zoom = camProps.zoom || 1 + } + this.camera.updateProjectionMatrix() + this.update(true) + } + + constructor(isOrtho = false, domElement: HTMLCanvasElement) { + this.camera = isOrtho ? new OrthographicCamera() : new PerspectiveCamera() + this.camera.up.set(0, 0, 1) + this.camera.far = 20000 + this.target = new Vector3() + this.domElement = domElement + this.isDragging = false + this.mouseDownPosition = new Vector2() + this.mouseNewPosition = new Vector2() + + this.domElement.addEventListener('pointerdown', this.onMouseDown) + this.domElement.addEventListener('pointermove', this.onMouseMove) + this.domElement.addEventListener('pointerup', this.onMouseUp) + this.domElement.addEventListener('wheel', this.onMouseWheel) + + window.addEventListener('resize', this.onWindowResize) + this.onWindowResize() + + this.update() + } + + private _isCamMovingCallback: (isMoving: boolean, isTween: boolean) => void = + () => {} + setIsCamMovingCallback(cb: (isMoving: boolean, isTween: boolean) => void) { + this._isCamMovingCallback = cb + } + private _camChangeCallbacks: { [key: string]: () => void } = {} + subscribeToCamChange(cb: () => void) { + const cbId = uuidv4() + this._camChangeCallbacks[cbId] = cb + const unsubscribe = () => { + delete this._camChangeCallbacks[cbId] + } + return unsubscribe + } + + onWindowResize = () => { + if (this.camera instanceof PerspectiveCamera) { + this.camera.aspect = window.innerWidth / window.innerHeight + } else if (this.camera instanceof OrthographicCamera) { + const aspect = window.innerWidth / window.innerHeight + this.camera.left = -ORTHOGRAPHIC_CAMERA_SIZE * aspect + this.camera.right = ORTHOGRAPHIC_CAMERA_SIZE * aspect + this.camera.top = ORTHOGRAPHIC_CAMERA_SIZE + this.camera.bottom = -ORTHOGRAPHIC_CAMERA_SIZE + } + this.camera.updateProjectionMatrix() + } + + onMouseDown = (event: MouseEvent) => { + this.isDragging = true + this.mouseDownPosition.set(event.clientX, event.clientY) + } + + onMouseMove = (event: MouseEvent) => { + if (this.isDragging) { + this.mouseNewPosition.set(event.clientX, event.clientY) + const deltaMove = this.mouseNewPosition + .clone() + .sub(this.mouseDownPosition) + this.mouseDownPosition.copy(this.mouseNewPosition) + + let state: 'pan' | 'rotate' | 'zoom' = 'pan' + + if (this.interactionGuards.pan.callback(event as any)) { + if (this.enablePan === false) return + // handleMouseDownPan(event) + state = 'pan' + } else if (this.interactionGuards.rotate.callback(event as any)) { + if (this.enableRotate === false) return + // handleMouseDownRotate(event) + state = 'rotate' + } else if (this.interactionGuards.zoom.dragCallback(event as any)) { + if (this.enableZoom === false) return + // handleMouseDownDolly(event) + state = 'zoom' + } else { + return + } + + // Implement camera movement logic here based on deltaMove + // For example, for rotating the camera around the target: + if (state === 'rotate') { + this.pendingRotation = this.pendingRotation + ? this.pendingRotation + : new Vector2() + this.pendingRotation.x += deltaMove.x + this.pendingRotation.y += deltaMove.y + } else if (state === 'zoom') { + this.pendingZoom = this.pendingZoom ? this.pendingZoom : 1 + this.pendingZoom *= 1 + deltaMove.y * 0.01 + } else if (state === '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.fovBeforeOrtho + this.pendingPan.x += -deltaMove.x * panSpeed + this.pendingPan.y += deltaMove.y * panSpeed + } + } + } + + onMouseUp = (event: MouseEvent) => { + this.isDragging = false + } + + onMouseWheel = (event: WheelEvent) => { + // Assume trackpad if the deltas are small and integers + const isTrackpad = Math.abs(event.deltaY) <= 1 || event.deltaY % 1 === 0 + + const zoomSpeed = isTrackpad ? 0.02 : 0.1 // Reduced zoom speed for trackpad + this.pendingZoom = this.pendingZoom ? this.pendingZoom : 1 + this.pendingZoom *= 1 + (event.deltaY > 0 ? zoomSpeed : -zoomSpeed) + } + + useOrthographicCamera = () => { + if (this.camera instanceof OrthographicCamera) return + const { x: px, y: py, z: pz } = this.camera.position + const { x: qx, y: qy, z: qz, w: qw } = this.camera.quaternion + const aspect = window.innerWidth / window.innerHeight + this.lastPerspectiveFov = this.camera.fov + const { z_near, z_far } = calculateNearFarFromFOV(this.lastPerspectiveFov) + this.camera = new OrthographicCamera( + -ORTHOGRAPHIC_CAMERA_SIZE * aspect, + ORTHOGRAPHIC_CAMERA_SIZE * aspect, + ORTHOGRAPHIC_CAMERA_SIZE, + -ORTHOGRAPHIC_CAMERA_SIZE, + z_near, + z_far + ) + this.camera.up.set(0, 0, 1) + this.camera.layers.enable(SKETCH_LAYER) + if (DEBUG_SHOW_INTERSECTION_PLANE) + this.camera.layers.enable(INTERSECTION_PLANE_LAYER) + this.camera.position.set(px, py, pz) + const distance = this.camera.position.distanceTo(this.target.clone()) + const fovFactor = 45 / this.lastPerspectiveFov + this.camera.zoom = (ZOOM_MAGIC_NUMBER * fovFactor * 0.8) / distance + + this.camera.quaternion.set(qx, qy, qz, qw) + this.camera.updateProjectionMatrix() + engineCommandManager.sendSceneCommand({ + type: 'modeling_cmd_req', + cmd_id: uuidv4(), + cmd: { + type: 'default_camera_set_orthographic', + }, + }) + this.onCameraChange() + } + private createPerspectiveCamera = () => { + const { z_near, z_far } = calculateNearFarFromFOV(this.lastPerspectiveFov) + this.camera = new PerspectiveCamera( + this.lastPerspectiveFov, + window.innerWidth / window.innerHeight, + z_near, + z_far + ) + this.camera.up.set(0, 0, 1) + this.camera.layers.enable(SKETCH_LAYER) + if (DEBUG_SHOW_INTERSECTION_PLANE) + this.camera.layers.enable(INTERSECTION_PLANE_LAYER) + + return this.camera + } + usePerspectiveCamera = () => { + const { x: px, y: py, z: pz } = this.camera.position + const { x: qx, y: qy, z: qz, w: qw } = this.camera.quaternion + const zoom = this.camera.zoom + this.camera = this.createPerspectiveCamera() + + this.camera.position.set(px, py, pz) + this.camera.quaternion.set(qx, qy, qz, qw) + const zoomFudgeFactor = 2280 + const distance = zoomFudgeFactor / (zoom * this.lastPerspectiveFov) + const direction = new Vector3().subVectors( + this.camera.position, + this.target + ) + direction.normalize() + this.camera.position.copy(this.target).addScaledVector(direction, distance) + + engineCommandManager.sendSceneCommand({ + type: 'modeling_cmd_req', + cmd_id: uuidv4(), + cmd: { + type: 'default_camera_set_perspective', + parameters: { + fov_y: this.camera.fov, + ...calculateNearFarFromFOV(this.lastPerspectiveFov), + }, + }, + }) + this.onCameraChange() + this.update() + return this.camera + } + + dollyZoom = (newFov: number) => { + if (!(this.camera instanceof PerspectiveCamera)) { + console.warn('Dolly zoom is only applicable to perspective cameras.') + return + } + this.lastPerspectiveFov = newFov + + // Calculate the direction vector from the camera towards the controls target + const direction = new Vector3() + .subVectors(this.target, this.camera.position) + .normalize() + + // Calculate the distance to the controls target before changing the FOV + const distanceBefore = this.camera.position.distanceTo(this.target) + + // Calculate the scale factor for the new FOV compared to the old one + // This needs to be calculated before updating the camera's FOV + const oldFov = this.camera.fov + + const viewHeightFactor = (fov: number) => { + /* * + /| + / | + / | + / | + / | viewHeight/2 + / | + / | + /↙️fov/2 | + /________| + \ | + \._._._.| + */ + return Math.tan(deg2Rad(fov / 2)) + } + const scaleFactor = viewHeightFactor(oldFov) / viewHeightFactor(newFov) + + this.camera.fov = newFov + this.camera.updateProjectionMatrix() + + const distanceAfter = distanceBefore * scaleFactor + + const newPosition = this.target + .clone() + .add(direction.multiplyScalar(-distanceAfter)) + this.camera.position.copy(newPosition) + + const { z_near, z_far } = calculateNearFarFromFOV(this.lastPerspectiveFov) + this.camera.near = z_near + this.camera.far = z_far + + throttledUpdateEngineFov({ + fov: newFov, + position: newPosition, + quaternion: this.camera.quaternion, + zoom: this.camera.zoom, + target: this.target, + }) + } + + update = (forceUpdate = false) => { + // If there are any changes that need to be applied to the camera, apply them here. + + let didChange = forceUpdate + if (this.pendingRotation) { + this.rotateCamera(this.pendingRotation.x, this.pendingRotation.y) + this.pendingRotation = null // Clear the pending rotation after applying it + didChange = true + } + + if (this.pendingZoom) { + if (this.camera instanceof PerspectiveCamera) { + // move camera towards or away from the target + const distance = this.camera.position.distanceTo(this.target) + const newDistance = distance * this.pendingZoom + const direction = this.camera.position + .clone() + .sub(this.target) + .normalize() + const newPosition = this.target + .clone() + .add(direction.multiplyScalar(newDistance)) + this.camera.position.copy(newPosition) + + this.camera.updateProjectionMatrix() + this.pendingZoom = null // Clear the pending zoom after applying it + } else { + // TODO change ortho zoom + this.camera.zoom = this.camera.zoom / this.pendingZoom + this.pendingZoom = null + } + didChange = true + } + + if (this.pendingPan) { + // move camera left/right and up/down + const offset = this.camera.position.clone().sub(this.target) + const direction = offset.clone().normalize() + const cameraQuaternion = this.camera.quaternion + const up = new Vector3(0, 1, 0).applyQuaternion(cameraQuaternion) + const right = new Vector3().crossVectors(up, direction) + right.multiplyScalar(this.pendingPan.x) + up.multiplyScalar(this.pendingPan.y) + const newPosition = this.camera.position.clone().add(right).add(up) + this.target.add(right) + this.target.add(up) + this.camera.position.copy(newPosition) + this.pendingPan = null + didChange = true + } + + this.safeLookAtTarget() + + // Update the camera's matrices + this.camera.updateMatrixWorld() + if (didChange) { + this.onCameraChange() + } + + // damping would be implemented here in update if we choose to add it. + } + + rotateCamera = (deltaX: number, deltaY: number) => { + const quat = new Quaternion().setFromUnitVectors( + new Vector3(0, 0, 1), + new Vector3(0, 1, 0) + ) + const quatInverse = quat.clone().invert() + + const angleX = deltaX * this.rotationSpeed // rotationSpeed is a constant that defines how fast the camera rotates + const angleY = deltaY * this.rotationSpeed + + // Convert angles to radians + const radianX = MathUtils.degToRad(angleX) + const radianY = MathUtils.degToRad(angleY) + + // Get the offset from the camera to the target + const offset = new Vector3().subVectors(this.camera.position, this.target) + + // spherical is a y-up paradigm, need to conform to that for now + offset.applyQuaternion(quat) + + // Convert offset to spherical coordinates + const spherical = new Spherical().setFromVector3(offset) + + // Apply the rotations + spherical.theta -= radianX + spherical.phi -= radianY + + // Restrict the phi angle to avoid the camera flipping at the poles + spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi)) + + // Convert back to Cartesian coordinates + offset.setFromSpherical(spherical) + + // put the offset back into the z-up paradigm + offset.applyQuaternion(quatInverse) + + // Update the camera's position + this.camera.position.copy(this.target).add(offset) + + // Look at the target + this.camera.updateMatrixWorld() + } + + safeLookAtTarget(up = new Vector3(0, 0, 1)) { + const quaternion = _lookAt(this.camera.position, this.target, up) + this.camera.quaternion.copy(quaternion) + this.camera.updateMatrixWorld() + } + + tweenCamToNegYAxis( + // -90 degrees from the x axis puts the camera on the negative y axis + targetAngle = -Math.PI / 2, + duration = 500 + ): Promise { + // should tween the camera so that it has an xPosition of 0, and forcing it's yPosition to be negative + // zPosition should stay the same + const xyRadius = Math.sqrt( + (this.target.x - this.camera.position.x) ** 2 + + (this.target.y - this.camera.position.y) ** 2 + ) + const xyAngle = Math.atan2( + this.camera.position.y - this.target.y, + this.camera.position.x - this.target.x + ) + this._isCamMovingCallback(true, true) + return new Promise((resolve) => { + new TWEEN.Tween({ angle: xyAngle }) + .to({ angle: targetAngle }, duration) + .onUpdate((obj) => { + const x = xyRadius * Math.cos(obj.angle) + const y = xyRadius * Math.sin(obj.angle) + this.camera.position.set( + this.target.x + x, + this.target.y + y, + this.camera.position.z + ) + this.update() + this.onCameraChange() + }) + .onComplete((obj) => { + const x = xyRadius * Math.cos(obj.angle) + const y = xyRadius * Math.sin(obj.angle) + this.camera.position.set( + this.target.x + x, + this.target.y + y, + this.camera.position.z + ) + this.update() + this.onCameraChange() + this._isCamMovingCallback(false, true) + + // resolve after a couple of frames + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()) + }) + }) + .start() + }) + } + + async tweenCameraToQuaternion( + targetQuaternion: Quaternion, + duration = 500, + toOrthographic = true + ): Promise { + const isVertical = isQuaternionVertical(targetQuaternion) + let remainingDuration = duration + if (isVertical) { + remainingDuration = duration * 0.5 + const orbitRotationDuration = duration * 0.65 + let targetAngle = -Math.PI / 2 + const v = new Vector3(0, 0, 1).applyQuaternion(targetQuaternion) + if (v.z < 0) targetAngle = Math.PI / 2 + await this.tweenCamToNegYAxis(targetAngle, orbitRotationDuration) + } + await this._tweenCameraToQuaternion( + targetQuaternion, + remainingDuration, + toOrthographic + ) + } + _tweenCameraToQuaternion( + targetQuaternion: Quaternion, + duration = 500, + toOrthographic = false + ): Promise { + return new Promise((resolve) => { + const camera = this.camera + this._isCamMovingCallback(true, true) + const initialQuaternion = camera.quaternion.clone() + const isVertical = isQuaternionVertical(targetQuaternion) + let tweenEnd = isVertical ? 0.99 : 1 + const controlsTarget = this.target.clone() + const initialDistance = controlsTarget.distanceTo(camera.position.clone()) + + const cameraAtTime = (animationProgress: number /* 0 - 1 */) => { + const currentQ = tempQuaternion.slerpQuaternions( + initialQuaternion, + targetQuaternion, + animationProgress + ) + if (this.camera instanceof PerspectiveCamera) + // changing the camera position back when it's orthographic doesn't do anything + // and it messes up animating back to perspective later + this.camera.position + .set(0, 0, 1) + .applyQuaternion(currentQ) + .multiplyScalar(initialDistance) + .add(controlsTarget) + + this.camera.up.set(0, 1, 0).applyQuaternion(currentQ).normalize() + this.camera.quaternion.copy(currentQ) + this.target.copy(controlsTarget) + // this.controls.update() + this.camera.updateProjectionMatrix() + this.update() + this.onCameraChange() + } + + const onComplete = async () => { + if (isReducedMotion() && toOrthographic) { + cameraAtTime(0.99) + this.useOrthographicCamera() + } else if (toOrthographic) { + await this.animateToOrthographic() + } + this.enableRotate = false + this._isCamMovingCallback(false, true) + resolve() + } + + if (isReducedMotion()) { + onComplete() + return + } + + new TWEEN.Tween({ t: 0 }) + .to({ t: tweenEnd }, duration) + .easing(TWEEN.Easing.Quadratic.InOut) + .onUpdate(({ t }) => cameraAtTime(t)) + .onComplete(onComplete) + .start() + }) + } + + animateToOrthographic = () => + new Promise((resolve) => { + this.isFovAnimationInProgress = true + let currentFov = this.lastPerspectiveFov + this.fovBeforeOrtho = currentFov + + const targetFov = 4 + const fovAnimationStep = (currentFov - targetFov) / FRAMES_TO_ANIMATE_IN + let frameWaitOnFinish = 10 + + const animateFovChange = () => { + if (this.camera instanceof PerspectiveCamera) { + if (this.camera.fov > targetFov) { + // Decrease the FOV + currentFov = Math.max(currentFov - fovAnimationStep, targetFov) + this.camera.updateProjectionMatrix() + this.dollyZoom(currentFov) + requestAnimationFrame(animateFovChange) // Continue the animation + } else if (frameWaitOnFinish > 0) { + frameWaitOnFinish-- + requestAnimationFrame(animateFovChange) // Continue the animation + } else { + // Once the target FOV is reached, switch to the orthographic camera + // Needs to wait a couple frames after the FOV animation is complete + this.useOrthographicCamera() + this.isFovAnimationInProgress = false + resolve(true) + } + } + } + + animateFovChange() // Start the animation + }) + animateToPerspective = () => + new Promise((resolve) => { + this.isFovAnimationInProgress = true + // Immediately set the camera to perspective with a very low FOV + const targetFov = this.fovBeforeOrtho // Target FOV for perspective + this.lastPerspectiveFov = 4 + let currentFov = 4 + this.camera.updateProjectionMatrix() + const fovAnimationStep = (targetFov - currentFov) / FRAMES_TO_ANIMATE_IN + this.usePerspectiveCamera() + + const animateFovChange = () => { + if (this.camera instanceof OrthographicCamera) return + if (this.camera.fov < targetFov) { + // Increase the FOV + currentFov = Math.min(currentFov + fovAnimationStep, targetFov) + // this.camera.fov = currentFov + this.camera.updateProjectionMatrix() + this.dollyZoom(currentFov) + requestAnimationFrame(animateFovChange) // Continue the animation + } else { + // Set the flag to false as the FOV animation is complete + this.isFovAnimationInProgress = false + resolve(true) + } + } + animateFovChange() // Start the animation + }) + + reactCameraPropertiesCallback: (a: ReactCameraProperties) => void = () => {} + setReactCameraPropertiesCallback = ( + cb: (a: ReactCameraProperties) => void + ) => { + this.reactCameraPropertiesCallback = cb + } + + deferReactUpdate = throttle((a: ReactCameraProperties) => { + this.reactCameraPropertiesCallback(a) + }, 200) + + onCameraChange = () => { + const distance = this.target.distanceTo(this.camera.position) + if (this.camera.far / 2.1 < distance || this.camera.far / 1.9 > distance) { + this.camera.far = distance * 2 + this.camera.near = distance / 10 + this.camera.updateProjectionMatrix() + } + + throttledUpdateEngineCamera({ + quaternion: this.camera.quaternion, + position: this.camera.position, + zoom: this.camera.zoom, + isPerspective: this.isPerspective, + target: this.target, + }) + this.deferReactUpdate({ + type: this.isPerspective ? 'perspective' : 'orthographic', + [this.isPerspective ? 'fov' : 'zoom']: + this.camera instanceof PerspectiveCamera + ? this.camera.fov + : this.camera.zoom, + position: [ + roundOff(this.camera.position.x, 2), + roundOff(this.camera.position.y, 2), + roundOff(this.camera.position.z, 2), + ], + quaternion: [ + roundOff(this.camera.quaternion.x, 2), + roundOff(this.camera.quaternion.y, 2), + roundOff(this.camera.quaternion.z, 2), + roundOff(this.camera.quaternion.w, 2), + ], + }) + Object.values(this._camChangeCallbacks).forEach((cb) => cb()) + } +} + +// currently duplicated, delete one +function calculateNearFarFromFOV(fov: number) { + const nearFarRatio = (fov - 3) / (45 - 3) + // const z_near = 0.1 + nearFarRatio * (5 - 0.1) + const z_far = 1000 + nearFarRatio * (100000 - 1000) + return { z_near: 0.1, z_far } +} + +// currently duplicated, delete one +function convertThreeCamValuesToEngineCam({ + target, + position, + quaternion, + zoom, + isPerspective, +}: ThreeCamValues): { + center: Vector3 + up: Vector3 + vantage: Vector3 +} { + // Something to consider is that the orbit controls have a target, + // we're kind of deriving the target/lookAtVector here when it might not be needed + // leaving for now since it's working but maybe revisit later + const euler = new Euler().setFromQuaternion(quaternion, 'XYZ') + + const lookAtVector = new Vector3(0, 0, -1) + .applyEuler(euler) + .normalize() + .add(position) + + const upVector = new Vector3(0, 1, 0).applyEuler(euler).normalize() + if (isPerspective) { + return { + center: target, + up: upVector, + vantage: position, + } + } + const fudgeFactor2 = zoom * 0.9979224466814468 - 0.03473692325839295 + const zoomFactor = (-ZOOM_MAGIC_NUMBER + fudgeFactor2) / zoom + const direction = lookAtVector.clone().sub(position).normalize() + const newVantage = position.clone().add(direction.multiplyScalar(zoomFactor)) + return { + center: lookAtVector, + up: upVector, + vantage: newVantage, + } +} + +// Pure function helpers + +function _lookAt(position: Vector3, target: Vector3, up: Vector3): Quaternion { + // Direction from position to target, normalized. + let direction = new Vector3().subVectors(target, position).normalize() + + // Calculate a new "effective" up vector that is orthogonal to the direction. + // This step ensures that the up vector does not affect the direction the camera is looking. + let right = new Vector3().crossVectors(direction, up).normalize() + let orthogonalUp = new Vector3().crossVectors(right, direction).normalize() + + // Create a lookAt matrix using the position, and the recalculated orthogonal up vector. + let lookAtMatrix = new Matrix4() + lookAtMatrix.lookAt(position, target, orthogonalUp) + + // Create a quaternion from the lookAt matrix. + let quaternion = new Quaternion().setFromRotationMatrix(lookAtMatrix) + + return quaternion +} diff --git a/src/clientSideScene/ClientSideSceneComp.tsx b/src/clientSideScene/ClientSideSceneComp.tsx index f243942dd..52c3bb9d3 100644 --- a/src/clientSideScene/ClientSideSceneComp.tsx +++ b/src/clientSideScene/ClientSideSceneComp.tsx @@ -4,11 +4,8 @@ import { useModelingContext } from 'hooks/useModelingContext' import { cameraMouseDragGuards } from 'lib/cameraControls' import { useGlobalStateContext } from 'hooks/useGlobalStateContext' import { useStore } from 'useStore' -import { - DEBUG_SHOW_BOTH_SCENES, - ReactCameraProperties, - sceneInfra, -} from './sceneInfra' +import { DEBUG_SHOW_BOTH_SCENES, sceneInfra } from './sceneInfra' +import { ReactCameraProperties } from './CameraControls' import { throttle } from 'lib/utils' function useShouldHideScene(): { hideClient: boolean; hideServer: boolean } { @@ -18,7 +15,7 @@ function useShouldHideScene(): { hideClient: boolean; hideServer: boolean } { const { state } = useModelingContext() useEffect(() => { - sceneInfra.setIsCamMovingCallback((isMoving, isTween) => { + sceneInfra.camControls.setIsCamMovingCallback((isMoving, isTween) => { setIsCamMoving(isMoving) setIsTween(isTween) }) @@ -52,7 +49,8 @@ export const ClientSideScene = ({ // Listen for changes to the camera controls setting // and update the client-side scene's controls accordingly. useEffect(() => { - sceneInfra.setInteractionGuards(cameraMouseDragGuards[cameraControls]) + sceneInfra.camControls.interactionGuards = + cameraMouseDragGuards[cameraControls] }, [cameraControls]) useEffect(() => { sceneInfra.updateOtherSelectionColors( @@ -93,7 +91,7 @@ export const ClientSideScene = ({ const throttled = throttle((a: ReactCameraProperties) => { if (a.type === 'perspective' && a.fov) { - sceneInfra.dollyZoom(a.fov) + sceneInfra.camControls.dollyZoom(a.fov) } }, 1000 / 15) @@ -107,7 +105,7 @@ export const CamDebugSettings = () => { const [fov, setFov] = useState(12) useEffect(() => { - sceneInfra.setReactCameraPropertiesCallback(setCamSettings) + sceneInfra.camControls.setReactCameraPropertiesCallback(setCamSettings) }, [sceneInfra]) useEffect(() => { if (camSettings.type === 'perspective' && camSettings.fov) { @@ -124,9 +122,9 @@ export const CamDebugSettings = () => { checked={camSettings.type === 'perspective'} onChange={(e) => { if (camSettings.type === 'perspective') { - sceneInfra.useOrthographicCamera() + sceneInfra.camControls.useOrthographicCamera() } else { - sceneInfra.usePerspectiveCamera() + sceneInfra.camControls.usePerspectiveCamera() } }} /> @@ -156,7 +154,7 @@ export const CamDebugSettings = () => { value={camSettings.fov} className="text-black w-16" onChange={(e) => { - sceneInfra.setCam({ + sceneInfra.camControls.setCam({ ...camSettings, fov: parseFloat(e.target.value), }) @@ -173,7 +171,7 @@ export const CamDebugSettings = () => { value={camSettings.zoom} className="text-black w-16" onChange={(e) => { - sceneInfra.setCam({ + sceneInfra.camControls.setCam({ ...camSettings, zoom: parseFloat(e.target.value), }) @@ -194,7 +192,7 @@ export const CamDebugSettings = () => { value={camSettings.position[0]} className="text-black w-16" onChange={(e) => { - sceneInfra.setCam({ + sceneInfra.camControls.setCam({ ...camSettings, position: [ parseFloat(e.target.value), @@ -214,7 +212,7 @@ export const CamDebugSettings = () => { value={camSettings.position[1]} className="text-black w-16" onChange={(e) => { - sceneInfra.setCam({ + sceneInfra.camControls.setCam({ ...camSettings, position: [ camSettings.position[0], @@ -234,7 +232,7 @@ export const CamDebugSettings = () => { value={camSettings.position[2]} className="text-black w-16" onChange={(e) => { - sceneInfra.setCam({ + sceneInfra.camControls.setCam({ ...camSettings, position: [ camSettings.position[0], diff --git a/src/clientSideScene/sceneInfra.test.ts b/src/clientSideScene/helpers.test.ts similarity index 95% rename from src/clientSideScene/sceneInfra.test.ts rename to src/clientSideScene/helpers.test.ts index 90a710069..1b6a68309 100644 --- a/src/clientSideScene/sceneInfra.test.ts +++ b/src/clientSideScene/helpers.test.ts @@ -1,5 +1,5 @@ import { Quaternion } from 'three' -import { isQuaternionVertical } from './sceneInfra' +import { isQuaternionVertical } from './helpers' describe('isQuaternionVertical', () => { it('should identify vertical quaternions', () => { diff --git a/src/clientSideScene/helpers.ts b/src/clientSideScene/helpers.ts index 0a96e6fe3..233ff3219 100644 --- a/src/clientSideScene/helpers.ts +++ b/src/clientSideScene/helpers.ts @@ -1,3 +1,4 @@ +import { compareVec2Epsilon2 } from 'lang/std/sketch' import { GridHelper, LineBasicMaterial, @@ -5,6 +6,8 @@ import { PerspectiveCamera, Group, Mesh, + Quaternion, + Vector3, } from 'three' export function createGridHelper({ @@ -31,3 +34,9 @@ export const orthoScale = (cam: OrthographicCamera | PerspectiveCamera) => export const perspScale = (cam: PerspectiveCamera, group: Group | Mesh) => (group.position.distanceTo(cam.position) * cam.fov) / 4000 + +export function isQuaternionVertical(q: Quaternion) { + const v = new Vector3(0, 0, 1).applyQuaternion(q) + // no x or y components means it's vertical + return compareVec2Epsilon2([v.x, v.y], [0, 0]) +} diff --git a/src/clientSideScene/sceneEntities.ts b/src/clientSideScene/sceneEntities.ts index d3d3d080e..38578aa00 100644 --- a/src/clientSideScene/sceneEntities.ts +++ b/src/clientSideScene/sceneEntities.ts @@ -24,7 +24,6 @@ import { defaultPlaneColor, getSceneScale, INTERSECTION_PLANE_LAYER, - isQuaternionVertical, RAYCASTABLE_PLANE, sceneInfra, SKETCH_GROUP_SEGMENTS, @@ -34,6 +33,7 @@ import { Y_AXIS, YZ_PLANE, } from './sceneInfra' +import { isQuaternionVertical } from './helpers' import { CallExpression, getTangentialArcToInfo, @@ -98,17 +98,17 @@ class SceneEntities { currentSketchQuaternion: Quaternion | null = null constructor() { this.scene = sceneInfra?.scene - sceneInfra?.setOnCamChange(this.onCamChange) + sceneInfra?.camControls.subscribeToCamChange(this.onCamChange) } onCamChange = () => { - const orthoFactor = orthoScale(sceneInfra.camera) + const orthoFactor = orthoScale(sceneInfra.camControls.camera) Object.values(this.activeSegments).forEach((segment) => { const factor = - sceneInfra.camera instanceof OrthographicCamera + sceneInfra.camControls.camera instanceof OrthographicCamera ? orthoFactor - : perspScale(sceneInfra.camera, segment) + : perspScale(sceneInfra.camControls.camera, segment) if ( segment.userData.from && segment.userData.to && @@ -139,9 +139,9 @@ class SceneEntities { }) if (this.axisGroup) { const factor = - sceneInfra.camera instanceof OrthographicCamera + sceneInfra.camControls.camera instanceof OrthographicCamera ? orthoFactor - : perspScale(sceneInfra.camera, this.axisGroup) + : perspScale(sceneInfra.camControls.camera, this.axisGroup) const x = this.axisGroup.getObjectByName(X_AXIS) x?.scale.set(1, factor, 1) const y = this.axisGroup.getObjectByName(Y_AXIS) @@ -150,7 +150,12 @@ class SceneEntities { } createIntersectionPlane() { - const planeGeometry = new PlaneGeometry(100000, 100000) + if (sceneInfra.scene.getObjectByName(RAYCASTABLE_PLANE)) { + console.warn('createIntersectionPlane called when it already exists') + return + } + const hundredM = 1000000 + const planeGeometry = new PlaneGeometry(hundredM, hundredM) const planeMaterial = new MeshBasicMaterial({ color: 0xff0000, side: DoubleSide, @@ -195,11 +200,12 @@ class SceneEntities { this.axisGroup = new Group() const gridHelper = createGridHelper({ size: 100, divisions: 10 }) + gridHelper.position.z = -0.01 gridHelper.renderOrder = -3 // is this working? gridHelper.name = 'gridHelper' const sceneScale = getSceneScale( - sceneInfra.camera, - sceneInfra.controls.target + sceneInfra.camControls.camera, + sceneInfra.camControls.target ) gridHelper.scale.set(sceneScale, sceneScale, sceneScale) this.axisGroup.add(xAxisMesh, yAxisMesh, gridHelper) @@ -240,15 +246,6 @@ class SceneEntities { }) { sceneInfra.resetMouseListeners() this.createIntersectionPlane() - const distance = sceneInfra.controls.target.distanceTo( - sceneInfra.camera.position - ) - // TODO this should probably be distance to the sketch group, more important after sketch on face - // since sketches won't always so close to the origin - // is this the best place to adjust camera far? - if (sceneInfra.camera.far < distance * 1.5) { - sceneInfra.camera.far = distance * 2 - } const { truncatedAst, programMemoryOverride, variableDeclarationName } = this.prepareTruncatedMemoryAndAst( @@ -280,11 +277,11 @@ class SceneEntities { sketchGroup.position[1], sketchGroup.position[2] ) - const orthoFactor = orthoScale(sceneInfra.camera) + const orthoFactor = orthoScale(sceneInfra.camControls.camera) const factor = - sceneInfra.camera instanceof OrthographicCamera + sceneInfra.camControls.camera instanceof OrthographicCamera ? orthoFactor - : perspScale(sceneInfra.camera, dummy) + : perspScale(sceneInfra.camControls.camera, dummy) sketchGroup.value.forEach((segment, index) => { let segPathToNode = getNodePathFromSourceRange( draftSegment ? truncatedAst : kclManager.ast, @@ -451,7 +448,7 @@ class SceneEntities { }, }) } - sceneInfra.controls.enableRotate = false + sceneInfra.camControls.enableRotate = false } updateAstAndRejigSketch = async ( sketchPathToNode: PathToNode, @@ -554,7 +551,7 @@ class SceneEntities { this.sceneProgramMemory = programMemory const sketchGroup = programMemory.root[variableDeclarationName] .value as Path[] - const orthoFactor = orthoScale(sceneInfra.camera) + const orthoFactor = orthoScale(sceneInfra.camControls.camera) sketchGroup.forEach((segment, index) => { const segPathToNode = getNodePathFromSourceRange( modifiedAst, @@ -570,9 +567,9 @@ class SceneEntities { // const prevSegment = sketchGroup.slice(index - 1)[0] const type = group?.userData?.type const factor = - sceneInfra.camera instanceof OrthographicCamera + sceneInfra.camControls.camera instanceof OrthographicCamera ? orthoFactor - : perspScale(sceneInfra.camera, group) + : perspScale(sceneInfra.camControls.camera, group) if (type === TANGENTIAL_ARC_TO_SEGMENT) { this.updateTangentialArcToSegment({ prevSegment: sketchGroup[index - 1], @@ -729,10 +726,10 @@ class SceneEntities { } async animateAfterSketch() { if (isReducedMotion()) { - sceneInfra.usePerspectiveCamera() - } else { - await sceneInfra.animateToPerspective() + sceneInfra.camControls.usePerspectiveCamera() + return } + await sceneInfra.camControls.animateToPerspective() } removeSketchGrid() { if (this.axisGroup) this.scene.remove(this.axisGroup) @@ -764,7 +761,7 @@ class SceneEntities { reject() } } - sceneInfra.controls.enableRotate = true + sceneInfra.camControls.enableRotate = true this.activeSegments = {} // maybe should reset onMove etc handlers if (shouldResolve) resolve(true) @@ -797,9 +794,8 @@ class SceneEntities { onClick: (args) => { if (!args || !args.object) return if (args.event.which !== 1) return - const { object, intersection } = args - const type = object?.userData?.type || '' - console.log('intersection.normal?.z', intersection) + const { intersection } = args + const type = intersection.object.name || '' const posNorm = Number(intersection.normal?.z) > 0 let planeString: DefaultPlaneStr = posNorm ? 'XY' : '-XY' let normal: [number, number, number] = posNorm ? [0, 0, 1] : [0, 0, -1] diff --git a/src/clientSideScene/sceneInfra.ts b/src/clientSideScene/sceneInfra.ts index 82e7745e1..d0fe71b7d 100644 --- a/src/clientSideScene/sceneInfra.ts +++ b/src/clientSideScene/sceneInfra.ts @@ -1,12 +1,10 @@ import { AmbientLight, Color, - Euler, GridHelper, LineBasicMaterial, OrthographicCamera, PerspectiveCamera, - Quaternion, Scene, Vector3, WebGLRenderer, @@ -20,32 +18,26 @@ import { Intersection, Object3D, Object3DEventMap, + BoxGeometry, } from 'three' -import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' -import { EngineCommand, engineCommandManager } from 'lang/std/engineConnection' -import { v4 as uuidv4 } from 'uuid' -import { isReducedMotion, roundOff, throttle } from 'lib/utils' import { compareVec2Epsilon2 } from 'lang/std/sketch' import { useModelingContext } from 'hooks/useModelingContext' -import { deg2Rad } from 'lib/utils2d' import * as TWEEN from '@tweenjs/tween.js' -import { MouseGuard, cameraMouseDragGuards } from 'lib/cameraControls' import { SourceRange } from 'lang/wasm' import { Axis } from 'lib/selections' import { BaseUnit, SETTINGS_PERSIST_KEY } from 'machines/settingsMachine' +import { CameraControls } from './CameraControls' type SendType = ReturnType['send'] // 63.5 is definitely a bit of a magic number, play with it until it looked right // if it were 64, that would feel like it's something in the engine where a random // power of 2 is used, but it's the 0.5 seems to make things look much more correct -const ZOOM_MAGIC_NUMBER = 63.5 -const FRAMES_TO_ANIMATE_IN = 30 -const ORTHOGRAPHIC_CAMERA_SIZE = 20 +export const ZOOM_MAGIC_NUMBER = 63.5 export const INTERSECTION_PLANE_LAYER = 1 export const SKETCH_LAYER = 2 -const DEBUG_SHOW_INTERSECTION_PLANE = false +export const DEBUG_SHOW_INTERSECTION_PLANE = false export const DEBUG_SHOW_BOTH_SCENES = false export const RAYCASTABLE_PLANE = 'raycastable-plane' @@ -57,100 +49,6 @@ export const AXIS_GROUP = 'axisGroup' export const SKETCH_GROUP_SEGMENTS = 'sketch-group-segments' export const ARROWHEAD = 'arrowhead' -const tempQuaternion = new Quaternion() // just used for maths - -interface ThreeCamValues { - position: Vector3 - quaternion: Quaternion - zoom: number - isPerspective: boolean - target: Vector3 -} - -const lastCmdDelay = 50 - -let lastCmd: EngineCommand | null = null -let lastCmdTime: number = Date.now() -let lastCmdTimeoutId: number | null = null - -const sendLastReliableChannel = () => { - if (lastCmd && Date.now() - lastCmdTime >= lastCmdDelay) { - engineCommandManager.sendSceneCommand(lastCmd, true) - lastCmdTime = Date.now() - } -} - -const throttledUpdateEngineCamera = throttle((threeValues: ThreeCamValues) => { - const cmd: EngineCommand = { - type: 'modeling_cmd_req', - cmd_id: uuidv4(), - cmd: { - type: 'default_camera_look_at', - ...convertThreeCamValuesToEngineCam(threeValues), - }, - } - engineCommandManager.sendSceneCommand(cmd) - lastCmd = cmd - lastCmdTime = Date.now() - - if (lastCmdTimeoutId !== null) { - clearTimeout(lastCmdTimeoutId) - } - lastCmdTimeoutId = setTimeout( - sendLastReliableChannel, - lastCmdDelay - ) as any as number -}, 1000 / 30) - -let lastPerspectiveCmd: EngineCommand | null = null -let lastPerspectiveCmdTime: number = Date.now() -let lastPerspectiveCmdTimeoutId: number | null = null - -const sendLastPerspectiveReliableChannel = () => { - if ( - lastPerspectiveCmd && - Date.now() - lastPerspectiveCmdTime >= lastCmdDelay - ) { - engineCommandManager.sendSceneCommand(lastPerspectiveCmd, true) - lastPerspectiveCmdTime = Date.now() - } -} - -const throttledUpdateEngineFov = throttle( - (vals: { - position: Vector3 - quaternion: Quaternion - zoom: number - fov: number - target: Vector3 - }) => { - const cmd: EngineCommand = { - type: 'modeling_cmd_req', - cmd_id: uuidv4(), - cmd: { - type: 'default_camera_perspective_settings', - ...convertThreeCamValuesToEngineCam({ - ...vals, - isPerspective: true, - }), - fov_y: vals.fov, - ...calculateNearFarFromFOV(vals.fov), - }, - } - engineCommandManager.sendSceneCommand(cmd) - lastPerspectiveCmd = cmd - lastPerspectiveCmdTime = Date.now() - if (lastPerspectiveCmdTimeoutId !== null) { - clearTimeout(lastPerspectiveCmdTimeoutId) - } - lastPerspectiveCmdTimeoutId = setTimeout( - sendLastPerspectiveReliableChannel, - lastCmdDelay - ) as any as number - }, - 1000 / 15 -) - interface BaseCallbackArgs2 { object: any event: any @@ -178,34 +76,18 @@ interface onMoveCallbackArgs { intersection: Intersection> } -export type ReactCameraProperties = - | { - type: 'perspective' - fov?: number - position: [number, number, number] - quaternion: [number, number, number, number] - } - | { - type: 'orthographic' - zoom?: number - position: [number, number, number] - quaternion: [number, number, number, number] - } - // This singleton class is responsible for all of the under the hood setup for the client side scene. // That is the cameras and switching between them, raycasters for click mouse events and their abstractions (onClick etc), setting up controls. // Anything that added the the scene for the user to interact with is probably in SceneEntities.ts class SceneInfra { static instance: SceneInfra scene: Scene - camera: PerspectiveCamera | OrthographicCamera renderer: WebGLRenderer - controls: OrbitControls + camControls: CameraControls isPerspective = true fov = 45 fovBeforeAnimate = 45 isFovAnimationInProgress = false - interactionGuards: MouseGuard = cameraMouseDragGuards.KittyCAD onDragCallback: (arg: OnDragCallbackArgs) => void = () => {} onMoveCallback: (arg: onMoveCallbackArgs) => void = () => {} onClickCallback: (arg?: OnClickCallbackArgs) => void = () => {} @@ -256,55 +138,18 @@ class SceneInfra { selectedObject: null | any = null mouseDownVector: null | Vector2 = null - // reacts hooks into some of this singleton's properties - reactCameraProperties: ReactCameraProperties = { - type: 'perspective', - fov: 12, - position: [0, 0, 0], - quaternion: [0, 0, 0, 1], - } - reactCameraPropertiesCallback: (a: ReactCameraProperties) => void = () => {} - setReactCameraPropertiesCallback = ( - cb: (a: ReactCameraProperties) => void - ) => { - this.reactCameraPropertiesCallback = cb - } - setCam = (camProps: ReactCameraProperties) => { - if ( - camProps.type === 'perspective' && - this.camera instanceof OrthographicCamera - ) { - this.usePerspectiveCamera() - } else if ( - camProps.type === 'orthographic' && - this.camera instanceof PerspectiveCamera - ) { - this.useOrthographicCamera() - } - this.camera.position.set(...camProps.position) - this.camera.quaternion.set(...camProps.quaternion) - if ( - camProps.type === 'perspective' && - this.camera instanceof PerspectiveCamera - ) { - // not sure what to do here, calling dollyZoom here is buggy because it updates the position - // at the same time - } else if ( - camProps.type === 'orthographic' && - this.camera instanceof OrthographicCamera - ) { - this.camera.zoom = camProps.zoom || 1 - } - this.camera.updateProjectionMatrix() - this.controls.update() - } - constructor() { // SCENE this.scene = new Scene() this.scene.background = new Color(0x000000) this.scene.background = null + // RENDERER + this.renderer = new WebGLRenderer({ antialias: true, alpha: true }) // Enable transparency + this.renderer.setSize(window.innerWidth, window.innerHeight) + this.renderer.setClearColor(0x000000, 0) // Set clear color to black with 0 alpha (fully transparent) + window.addEventListener('resize', this.onWindowResize) + // CAMERA const camHeightDistanceRatio = 0.5 const baseUnit: BaseUnit = @@ -315,25 +160,19 @@ class SceneInfra { const ang = Math.atan(camHeightDistanceRatio) const x = Math.cos(ang) * length const y = Math.sin(ang) * length - this.camera = this.createPerspectiveCamera() - this.camera.position.set(0, -x, y) - if (DEBUG_SHOW_INTERSECTION_PLANE) - this.camera.layers.enable(INTERSECTION_PLANE_LAYER) - // RENDERER - this.renderer = new WebGLRenderer({ antialias: true, alpha: true }) // Enable transparency - this.renderer.setSize(window.innerWidth, window.innerHeight) - this.renderer.setClearColor(0x000000, 0) // Set clear color to black with 0 alpha (fully transparent) - window.addEventListener('resize', this.onWindowResize) + this.camControls = new CameraControls(false, this.renderer.domElement) + this.camControls.subscribeToCamChange(() => this.onCameraChange()) + this.camControls.camera.layers.enable(SKETCH_LAYER) + this.camControls.camera.position.set(0, -x, y) + if (DEBUG_SHOW_INTERSECTION_PLANE) + this.camControls.camera.layers.enable(INTERSECTION_PLANE_LAYER) // RAYCASTERS this.raycaster.layers.enable(SKETCH_LAYER) this.raycaster.layers.disable(0) this.planeRaycaster.layers.enable(INTERSECTION_PLANE_LAYER) - // CONTROLS - this.controls = this.setupOrbitControls() - // GRID const size = 100 const divisions = 10 @@ -353,415 +192,40 @@ class SceneInfra { SceneInfra.instance = this } - private _isCamMovingCallback: (isMoving: boolean, isTween: boolean) => void = - () => {} - setIsCamMovingCallback(cb: (isMoving: boolean, isTween: boolean) => void) { - this._isCamMovingCallback = cb - } - private _onCamChange: () => void = () => {} - setOnCamChange(cb: () => void) { - this._onCamChange = cb - } - setInteractionGuards = (guard: MouseGuard) => { - this.interactionGuards = guard - // setMouseGuards is oun patch-package patch to orbit controls - // see patches/three+0.160.0.patch - ;(this.controls as any).setMouseGuards(guard) - } - private createPerspectiveCamera = () => { - const { z_near, z_far } = calculateNearFarFromFOV(this.fov) - this.camera = new PerspectiveCamera( - this.fov, - window.innerWidth / window.innerHeight, - z_near, - z_far - ) - this.camera.up.set(0, 0, 1) - this.camera.layers.enable(SKETCH_LAYER) - if (DEBUG_SHOW_INTERSECTION_PLANE) - this.camera.layers.enable(INTERSECTION_PLANE_LAYER) - - return this.camera - } - setupOrbitControls = (target?: [number, number, number]): OrbitControls => { - if (this.controls) this.controls.dispose() - this.controls = new OrbitControls(this.camera, this.renderer.domElement) - if (target) { - // if we're swapping from perspective to orthographic, - // we'll need to recreate the orbit controls - // and most likely want the target to be the same - this.controls.target.set(...target) - } - this.controls.update() - this.controls.addEventListener('change', this.onCameraChange) - // debounce is needed because the start and end events are fired too often for zoom on scroll - let debounceTimer = 0 - const handleStart = () => { - if (debounceTimer) clearTimeout(debounceTimer) - this._isCamMovingCallback(true, false) - } - const handleEnd = () => { - debounceTimer = setTimeout(() => { - this._isCamMovingCallback(false, false) - }, 400) as any as number - } - this.controls.addEventListener('start', handleStart) - this.controls.addEventListener('end', handleEnd) - - // setMouseGuards is oun patch-package patch to orbit controls - // see patches/three+0.160.0.patch - ;(this.controls as any).setMouseGuards(this.interactionGuards) - return this.controls - } - onStreamStart = () => this.onCameraChange() - - deferReactUpdate = throttle((a: ReactCameraProperties) => { - this.reactCameraPropertiesCallback(a) - }, 200) onCameraChange = () => { - const scale = getSceneScale(this.camera, this.controls.target) + const scale = getSceneScale( + this.camControls.camera, + this.camControls.target + ) const planesGroup = this.scene.getObjectByName(DEFAULT_PLANES) const axisGroup = this.scene .getObjectByName(AXIS_GROUP) ?.getObjectByName('gridHelper') planesGroup && planesGroup.scale.set(scale, scale, scale) axisGroup?.name === 'gridHelper' && axisGroup.scale.set(scale, scale, scale) - - throttledUpdateEngineCamera({ - quaternion: this.camera.quaternion, - position: this.camera.position, - zoom: this.camera.zoom, - isPerspective: this.isPerspective, - target: this.controls.target, - }) - this.deferReactUpdate({ - type: - this.camera instanceof PerspectiveCamera - ? 'perspective' - : 'orthographic', - [this.camera instanceof PerspectiveCamera ? 'fov' : 'zoom']: - this.camera instanceof PerspectiveCamera - ? this.camera.fov - : this.camera.zoom, - position: [ - roundOff(this.camera.position.x, 2), - roundOff(this.camera.position.y, 2), - roundOff(this.camera.position.z, 2), - ], - quaternion: [ - roundOff(this.camera.quaternion.x, 2), - roundOff(this.camera.quaternion.y, 2), - roundOff(this.camera.quaternion.z, 2), - roundOff(this.camera.quaternion.w, 2), - ], - }) - this._onCamChange() } onWindowResize = () => { - if (this.camera instanceof PerspectiveCamera) { - this.camera.aspect = window.innerWidth / window.innerHeight - } else if (this.camera instanceof OrthographicCamera) { - const aspect = window.innerWidth / window.innerHeight - this.camera.left = -ORTHOGRAPHIC_CAMERA_SIZE * aspect - this.camera.right = ORTHOGRAPHIC_CAMERA_SIZE * aspect - this.camera.top = ORTHOGRAPHIC_CAMERA_SIZE - this.camera.bottom = -ORTHOGRAPHIC_CAMERA_SIZE - } - this.camera.updateProjectionMatrix() this.renderer.setSize(window.innerWidth, window.innerHeight) } animate = () => { requestAnimationFrame(this.animate) TWEEN.update() // This will update all tweens during the animation loop - if (!this.isFovAnimationInProgress) - this.renderer.render(this.scene, this.camera) - } - async tweenCameraToQuaternion( - targetQuaternion: Quaternion, - duration = 500, - toOrthographic = true - ): Promise { - const isVertical = isQuaternionVertical(targetQuaternion) - let _duration = duration - if (isVertical) { - _duration = duration * 0.6 - await this._tweenCameraToQuaternion(new Quaternion(), _duration, false) + if (!this.isFovAnimationInProgress) { + // console.log('animation frame', this.cameraControls.camera) + this.camControls.update() + this.renderer.render(this.scene, this.camControls.camera) } - await this._tweenCameraToQuaternion( - targetQuaternion, - _duration, - toOrthographic - ) - } - _tweenCameraToQuaternion( - targetQuaternion: Quaternion, - duration = 500, - toOrthographic = false - ): Promise { - return new Promise((resolve) => { - const camera = this.camera - this._isCamMovingCallback(true, true) - const initialQuaternion = camera.quaternion.clone() - const isVertical = isQuaternionVertical(targetQuaternion) - let tweenEnd = isVertical ? 0.99 : 1 - const controlsTarget = this.controls.target.clone() - const initialDistance = controlsTarget.distanceTo(camera.position.clone()) - - const cameraAtTime = (animationProgress: number /* 0 - 1 */) => { - const currentQ = tempQuaternion.slerpQuaternions( - initialQuaternion, - targetQuaternion, - animationProgress - ) - if (this.camera instanceof PerspectiveCamera) - // changing the camera position back when it's orthographic doesn't do anything - // and it messes up animating back to perspective later - this.camera.position - .set(0, 0, 1) - .applyQuaternion(currentQ) - .multiplyScalar(initialDistance) - .add(controlsTarget) - - this.camera.up.set(0, 1, 0).applyQuaternion(currentQ).normalize() - this.camera.quaternion.copy(currentQ) - this.controls.target.copy(controlsTarget) - this.controls.update() - this.camera.updateProjectionMatrix() - } - - const onComplete = async () => { - if (isReducedMotion() && toOrthographic) { - cameraAtTime(0.99) - this.useOrthographicCamera() - } else if (toOrthographic) { - await this.animateToOrthographic() - } - if (isVertical) cameraAtTime(1) - this.camera.up.set(0, 0, 1) - this.controls.enableRotate = false - this._isCamMovingCallback(false, true) - resolve() - } - - if (isReducedMotion()) { - onComplete() - return - } - - new TWEEN.Tween({ t: 0 }) - .to({ t: tweenEnd }, duration) - .easing(TWEEN.Easing.Quadratic.InOut) - .onUpdate(({ t }) => cameraAtTime(t)) - .onComplete(onComplete) - .start() - }) } - animateToOrthographic = () => - new Promise((resolve) => { - this.isFovAnimationInProgress = true - let currentFov = this.fov - this.fovBeforeAnimate = this.fov - - const targetFov = 4 - const fovAnimationStep = (currentFov - targetFov) / FRAMES_TO_ANIMATE_IN - let frameWaitOnFinish = 10 - - const animateFovChange = () => { - if (this.camera instanceof PerspectiveCamera) { - if (this.camera.fov > targetFov) { - // Decrease the FOV - currentFov = Math.max(currentFov - fovAnimationStep, targetFov) - this.camera.updateProjectionMatrix() - this.dollyZoom(currentFov) - requestAnimationFrame(animateFovChange) // Continue the animation - } else if (frameWaitOnFinish > 0) { - frameWaitOnFinish-- - requestAnimationFrame(animateFovChange) // Continue the animation - } else { - // Once the target FOV is reached, switch to the orthographic camera - // Needs to wait a couple frames after the FOV animation is complete - this.useOrthographicCamera() - this.isFovAnimationInProgress = false - resolve(true) - } - } - } - - animateFovChange() // Start the animation - }) - - animateToPerspective = () => - new Promise((resolve) => { - this.isFovAnimationInProgress = true - // Immediately set the camera to perspective with a very low FOV - this.fov = 4 - let currentFov = 4 - this.camera.updateProjectionMatrix() - const targetFov = this.fovBeforeAnimate // Target FOV for perspective - const fovAnimationStep = (targetFov - currentFov) / FRAMES_TO_ANIMATE_IN - this.usePerspectiveCamera() - - const animateFovChange = () => { - if (this.camera instanceof OrthographicCamera) return - if (this.camera.fov < targetFov) { - // Increase the FOV - currentFov = Math.min(currentFov + fovAnimationStep, targetFov) - // this.camera.fov = currentFov - this.camera.updateProjectionMatrix() - this.dollyZoom(currentFov) - requestAnimationFrame(animateFovChange) // Continue the animation - } else { - // Set the flag to false as the FOV animation is complete - this.isFovAnimationInProgress = false - resolve(true) - } - } - animateFovChange() // Start the animation - }) dispose = () => { // Dispose of scene resources, renderer, and controls this.renderer.dispose() window.removeEventListener('resize', this.onWindowResize) // Dispose of any other resources like geometries, materials, textures } - - useOrthographicCamera = () => { - this.isPerspective = false - const { x: px, y: py, z: pz } = this.camera.position - const { x: qx, y: qy, z: qz, w: qw } = this.camera.quaternion - const { x: tx, y: ty, z: tz } = this.controls.target - const aspect = window.innerWidth / window.innerHeight - const { z_near, z_far } = calculateNearFarFromFOV(this.fov) - this.camera = new OrthographicCamera( - -ORTHOGRAPHIC_CAMERA_SIZE * aspect, - ORTHOGRAPHIC_CAMERA_SIZE * aspect, - ORTHOGRAPHIC_CAMERA_SIZE, - -ORTHOGRAPHIC_CAMERA_SIZE, - z_near, - z_far - ) - this.camera.up.set(0, 0, 1) - this.camera.layers.enable(SKETCH_LAYER) - if (DEBUG_SHOW_INTERSECTION_PLANE) - this.camera.layers.enable(INTERSECTION_PLANE_LAYER) - this.camera.position.set(px, py, pz) - const distance = this.camera.position.distanceTo(new Vector3(tx, ty, tz)) - const fovFactor = 45 / this.fov - this.camera.zoom = (ZOOM_MAGIC_NUMBER * fovFactor * 0.8) / distance - - this.setupOrbitControls([tx, ty, tz]) - this.camera.quaternion.set(qx, qy, qz, qw) - this.camera.updateProjectionMatrix() - this.controls.update() - engineCommandManager.sendSceneCommand({ - type: 'modeling_cmd_req', - cmd_id: uuidv4(), - cmd: { - type: 'default_camera_set_orthographic', - }, - }) - } - usePerspectiveCamera = () => { - this.isPerspective = true - const { x: px, y: py, z: pz } = this.camera.position - const { x: qx, y: qy, z: qz, w: qw } = this.camera.quaternion - const { x: tx, y: ty, z: tz } = this.controls.target - const zoom = this.camera.zoom - this.camera = this.createPerspectiveCamera() - - this.camera.position.set(px, py, pz) - this.camera.quaternion.set(qx, qy, qz, qw) - const zoomFudgeFactor = 2280 - const distance = zoomFudgeFactor / (zoom * this.fov) - const direction = new Vector3().subVectors( - this.camera.position, - this.controls.target - ) - direction.normalize() - this.camera.position - .copy(this.controls.target) - .addScaledVector(direction, distance) - - this.setupOrbitControls([tx, ty, tz]) - - engineCommandManager.sendSceneCommand({ - type: 'modeling_cmd_req', - cmd_id: uuidv4(), - cmd: { - type: 'default_camera_set_perspective', - parameters: { - fov_y: this.camera.fov, - ...calculateNearFarFromFOV(this.fov), - }, - }, - }) - this.onCameraChange() - return this.camera - } - - dollyZoom = (newFov: number) => { - if (!(this.camera instanceof PerspectiveCamera)) { - console.warn('Dolly zoom is only applicable to perspective cameras.') - return - } - this.fov = newFov - - // Calculate the direction vector from the camera towards the controls target - const direction = new Vector3() - .subVectors(this.controls.target, this.camera.position) - .normalize() - - // Calculate the distance to the controls target before changing the FOV - const distanceBefore = this.camera.position.distanceTo(this.controls.target) - - // Calculate the scale factor for the new FOV compared to the old one - // This needs to be calculated before updating the camera's FOV - const oldFov = this.camera.fov - - const viewHeightFactor = (fov: number) => { - /* * - /| - / | - / | - / | - / | viewHeight/2 - / | - / | - /↙️fov/2 | - /________| - \ | - \._._._.| - */ - return Math.tan(deg2Rad(fov / 2)) - } - const scaleFactor = viewHeightFactor(oldFov) / viewHeightFactor(newFov) - - this.camera.fov = newFov - this.camera.updateProjectionMatrix() - - const distanceAfter = distanceBefore * scaleFactor - - const newPosition = this.controls.target - .clone() - .add(direction.multiplyScalar(-distanceAfter)) - this.camera.position.copy(newPosition) - - const { z_near, z_far } = calculateNearFarFromFOV(this.fov) - this.camera.near = z_near - this.camera.far = z_far - - throttledUpdateEngineFov({ - fov: newFov, - position: newPosition, - quaternion: this.camera.quaternion, - zoom: this.camera.zoom, - target: this.controls.target, - }) - } getPlaneIntersectPoint = (): { intersection2d?: Vector2 intersectPoint: Vector3 @@ -769,7 +233,7 @@ class SceneInfra { } | null => { this.planeRaycaster.setFromCamera( this.currentMouseVector, - sceneInfra.camera + sceneInfra.camControls.camera ) const planeIntersects = this.planeRaycaster.intersectObjects( this.scene.children, @@ -907,7 +371,7 @@ class SceneInfra { } // Check the center point - this.raycaster.setFromCamera(mouseDownVector, this.camera) + this.raycaster.setFromCamera(mouseDownVector, this.camControls.camera) updateClosestIntersection( this.raycaster.intersectObjects(this.scene.children, true) ) @@ -922,7 +386,7 @@ class SceneInfra { mouseDownVector.x + offsetX, mouseDownVector.y - offsetY ) - this.raycaster.setFromCamera(ringVector, this.camera) + this.raycaster.setFromCamera(ringVector, this.camControls.camera) updateClosestIntersection( this.raycaster.intersectObjects(this.scene.children, true) ) @@ -1015,7 +479,10 @@ class SceneInfra { } }) planesGroup.layers.enable(SKETCH_LAYER) - const sceneScale = getSceneScale(this.camera, this.controls.target) + const sceneScale = getSceneScale( + this.camControls.camera, + this.camControls.target + ) planesGroup.scale.set(sceneScale, sceneScale, sceneScale) this.scene.add(planesGroup) } @@ -1050,52 +517,6 @@ class SceneInfra { export const sceneInfra = new SceneInfra() -function convertThreeCamValuesToEngineCam({ - target, - position, - quaternion, - zoom, - isPerspective, -}: ThreeCamValues): { - center: Vector3 - up: Vector3 - vantage: Vector3 -} { - // Something to consider is that the orbit controls have a target, - // we're kind of deriving the target/lookAtVector here when it might not be needed - // leaving for now since it's working but maybe revisit later - const euler = new Euler().setFromQuaternion(quaternion, 'XYZ') - - const lookAtVector = new Vector3(0, 0, -1) - .applyEuler(euler) - .normalize() - .add(position) - - const upVector = new Vector3(0, 1, 0).applyEuler(euler).normalize() - if (isPerspective) { - return { - center: target, - up: upVector, - vantage: position, - } - } - const zoomFactor = -ZOOM_MAGIC_NUMBER / zoom - const direction = lookAtVector.clone().sub(position).normalize() - const newVantage = position.clone().add(direction.multiplyScalar(zoomFactor)) - return { - center: lookAtVector, - up: upVector, - vantage: newVantage, - } -} - -function calculateNearFarFromFOV(fov: number) { - const nearFarRatio = (fov - 3) / (45 - 3) - // const z_near = 0.1 + nearFarRatio * (5 - 0.1) - const z_far = 1000 + nearFarRatio * (100000 - 1000) - return { z_near: 0.1, z_far } -} - export function getSceneScale( camera: PerspectiveCamera | OrthographicCamera, target: Vector3 @@ -1131,12 +552,6 @@ function baseUnitTomm(baseUnit: BaseUnit) { } } -export function isQuaternionVertical(q: Quaternion) { - const v = new Vector3(0, 0, 1).applyQuaternion(q) - // no x or y components means it's vertical - return compareVec2Epsilon2([v.x, v.y], [0, 0]) -} - export type DefaultPlane = | 'xy-default-plane' | 'xz-default-plane' diff --git a/src/components/CamToggle.tsx b/src/components/CamToggle.tsx index 97bc49389..293acf74c 100644 --- a/src/components/CamToggle.tsx +++ b/src/components/CamToggle.tsx @@ -4,7 +4,7 @@ import { engineCommandManager } from 'lang/std/engineConnection' import { throttle, isReducedMotion } from 'lib/utils' const updateDollyZoom = throttle( - (newFov: number) => sceneInfra.dollyZoom(newFov), + (newFov: number) => sceneInfra.camControls.dollyZoom(newFov), 1000 / 15 ) @@ -15,19 +15,19 @@ export const CamToggle = () => { useEffect(() => { engineCommandManager.waitForReady.then(async () => { - sceneInfra.dollyZoom(fov) + sceneInfra.camControls.dollyZoom(fov) }) }, []) const toggleCamera = () => { if (isPerspective) { isReducedMotion() - ? sceneInfra.useOrthographicCamera() - : sceneInfra.animateToOrthographic() + ? sceneInfra.camControls.useOrthographicCamera() + : sceneInfra.camControls.animateToOrthographic() } else { isReducedMotion() - ? sceneInfra.usePerspectiveCamera() - : sceneInfra.animateToPerspective() + ? sceneInfra.camControls.usePerspectiveCamera() + : sceneInfra.camControls.animateToPerspective() } setIsPerspective(!isPerspective) } @@ -60,9 +60,9 @@ export const CamToggle = () => {