Add a user-level projection setting, command, and toggle (#3983)
* Add cameraProjection setting * Add UI to toggle the user-level projection setting. * Make cameraProjection setting respected at startup * Add an E2E test for the perspective toggle * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest) * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest) * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest) * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest) * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest) * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest) * Don't force user back into perspective when exiting sketch * Make the projection setting more searchable * Make `current` label apply to the default option if not set * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest) * Re-run CI * Ohh *cargo fmt* * @lf94 feedback, fix found toggling bug, make command bar instantly toggle setting * Roll back the instant toggling behavior, it breaks the tests * Make ortho the default, keep tests using perspective * Move projection below camera controls setting * Fix up gizmo tests, which broke because the gizmo moved * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest) * Look at this (photo)Graph *in the voice of Nickelback* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: 49fl <ircsurfer33@gmail.com>
| 
		 Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 53 KiB  | 
| 
		 Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 50 KiB  | 
| 
		 Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 49 KiB  | 
| 
		 Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 46 KiB  | 
| 
		 Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 58 KiB  | 
| 
		 Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 55 KiB  | 
| 
		 Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 51 KiB  | 
| 
		 Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 47 KiB  | 
| 
		 Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 45 KiB  | 
| 
		 Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 42 KiB  | 
| 
		 Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 44 KiB  | 
| 
		 Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 41 KiB  | 
| 
		 Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 41 KiB  | 
| 
		 Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 39 KiB  | 
| 
		 Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 50 KiB  | 
| 
		 Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 47 KiB  | 
| 
		 Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 33 KiB  | 
| 
		 Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 34 KiB  | 
| 
		 Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 52 KiB  | 
| 
		 Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 53 KiB  | 
| 
		 Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 75 KiB  | 
| 
		 Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 66 KiB  | 
| 
		 Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 50 KiB  | 
| 
		 Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 47 KiB  | 
| 
		 Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 65 KiB  | 
| 
		 Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 62 KiB  | 
| 
		 Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 37 KiB  | 
| 
		 Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 37 KiB  | 
| 
		 Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 39 KiB  | 
| 
		 Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 40 KiB  | 
| 
		 Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 39 KiB  | 
| 
		 Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 39 KiB  | 
| 
		 Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 38 KiB  | 
| 
		 Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 39 KiB  | 
| 
		 Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 42 KiB  | 
| 
		 Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 42 KiB  | 
| 
		 Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 36 KiB  | 
| 
		 Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 36 KiB  | 
@ -15,6 +15,7 @@ export const TEST_SETTINGS = {
 | 
			
		||||
  modeling: {
 | 
			
		||||
    defaultUnit: 'in',
 | 
			
		||||
    mouseControls: 'KittyCAD',
 | 
			
		||||
    cameraProjection: 'perspective',
 | 
			
		||||
    showDebugPanel: true,
 | 
			
		||||
  },
 | 
			
		||||
  projects: {
 | 
			
		||||
@ -62,6 +63,7 @@ export const TEST_SETTINGS_CORRUPTED = {
 | 
			
		||||
  modeling: {
 | 
			
		||||
    defaultUnit: 'invalid' as any,
 | 
			
		||||
    mouseControls: `() => alert('hack the planet')` as any,
 | 
			
		||||
    cameraProjection: 'perspective',
 | 
			
		||||
    showDebugPanel: true,
 | 
			
		||||
  },
 | 
			
		||||
  projects: {
 | 
			
		||||
 | 
			
		||||
@ -16,37 +16,37 @@ test.describe('Testing Gizmo', () => {
 | 
			
		||||
  const cases = [
 | 
			
		||||
    {
 | 
			
		||||
      testDescription: 'top view',
 | 
			
		||||
      clickPosition: { x: 951, y: 385 },
 | 
			
		||||
      clickPosition: { x: 951, y: 347 },
 | 
			
		||||
      expectedCameraPosition: { x: 800, y: -152, z: 4886.02 },
 | 
			
		||||
      expectedCameraTarget: { x: 800, y: -152, z: 26 },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      testDescription: 'bottom view',
 | 
			
		||||
      clickPosition: { x: 951, y: 429 },
 | 
			
		||||
      clickPosition: { x: 951, y: 391 },
 | 
			
		||||
      expectedCameraPosition: { x: 800, y: -152, z: -4834.02 },
 | 
			
		||||
      expectedCameraTarget: { x: 800, y: -152, z: 26 },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      testDescription: 'right view',
 | 
			
		||||
      clickPosition: { x: 929, y: 417 },
 | 
			
		||||
      clickPosition: { x: 929, y: 379 },
 | 
			
		||||
      expectedCameraPosition: { x: 5660.02, y: -152, z: 26 },
 | 
			
		||||
      expectedCameraTarget: { x: 800, y: -152, z: 26 },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      testDescription: 'left view',
 | 
			
		||||
      clickPosition: { x: 974, y: 397 },
 | 
			
		||||
      clickPosition: { x: 974, y: 359 },
 | 
			
		||||
      expectedCameraPosition: { x: -4060.02, y: -152, z: 26 },
 | 
			
		||||
      expectedCameraTarget: { x: 800, y: -152, z: 26 },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      testDescription: 'back view',
 | 
			
		||||
      clickPosition: { x: 967, y: 421 },
 | 
			
		||||
      clickPosition: { x: 967, y: 383 },
 | 
			
		||||
      expectedCameraPosition: { x: 800, y: 4708.02, z: 26 },
 | 
			
		||||
      expectedCameraTarget: { x: 800, y: -152, z: 26 },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      testDescription: 'front view',
 | 
			
		||||
      clickPosition: { x: 935, y: 393 },
 | 
			
		||||
      clickPosition: { x: 935, y: 355 },
 | 
			
		||||
      expectedCameraPosition: { x: 800, y: -5012.02, z: 26 },
 | 
			
		||||
      expectedCameraTarget: { x: 800, y: -152, z: 26 },
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										112
									
								
								e2e/playwright/testing-perspective-toggle.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,112 @@
 | 
			
		||||
import { test, expect } from '@playwright/test'
 | 
			
		||||
import { getUtils, setup, tearDown } from './test-utils'
 | 
			
		||||
import { TEST_SETTINGS, TEST_SETTINGS_KEY } from './storageStates'
 | 
			
		||||
import * as TOML from '@iarna/toml'
 | 
			
		||||
 | 
			
		||||
test.beforeEach(async ({ context, page }, testInfo) => {
 | 
			
		||||
  await setup(context, page, testInfo)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
test.afterEach(async ({ page }, testInfo) => {
 | 
			
		||||
  await tearDown(page, testInfo)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
test.describe('Test toggling perspective', () => {
 | 
			
		||||
  test('via command palette and toggle', async ({ page }) => {
 | 
			
		||||
    const u = await getUtils(page)
 | 
			
		||||
 | 
			
		||||
    // Locators and constants
 | 
			
		||||
    const screenWidth = 1200
 | 
			
		||||
    const screenHeight = 500
 | 
			
		||||
    const checkedScreenLocation = {
 | 
			
		||||
      x: screenWidth * 0.71,
 | 
			
		||||
      y: screenHeight * 0.4,
 | 
			
		||||
    }
 | 
			
		||||
    const backgroundColor: [number, number, number] = [29, 29, 29]
 | 
			
		||||
    const xzPlaneColor: [number, number, number] = [50, 50, 99]
 | 
			
		||||
    const locationToHaveColor = async (color: [number, number, number]) => {
 | 
			
		||||
      return u.getGreatestPixDiff(checkedScreenLocation, color)
 | 
			
		||||
    }
 | 
			
		||||
    const commandPaletteButton = page.getByRole('button', { name: 'Commands' })
 | 
			
		||||
    const commandOption = page.getByRole('option', {
 | 
			
		||||
      name: 'camera projection',
 | 
			
		||||
    })
 | 
			
		||||
    const orthoOption = page.getByRole('option', { name: 'orthographic' })
 | 
			
		||||
    const commandToast = page.getByText(
 | 
			
		||||
      `Set camera projection to "orthographic"`
 | 
			
		||||
    )
 | 
			
		||||
    const projectionToggle = page.getByRole('switch', {
 | 
			
		||||
      name: 'Camera projection: ',
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    await test.step('Setup', async () => {
 | 
			
		||||
      await page.setViewportSize({ width: screenWidth, height: screenHeight })
 | 
			
		||||
      await u.waitForAuthSkipAppStart()
 | 
			
		||||
      await u.closeKclCodePanel()
 | 
			
		||||
      await expect
 | 
			
		||||
        .poll(async () => locationToHaveColor(backgroundColor), {
 | 
			
		||||
          timeout: 5000,
 | 
			
		||||
          message: 'This spot should have the background color',
 | 
			
		||||
        })
 | 
			
		||||
        .toBeLessThan(15)
 | 
			
		||||
      await expect(projectionToggle).toHaveAttribute('aria-checked', 'true')
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    await test.step('Switch to ortho via command palette', async () => {
 | 
			
		||||
      await commandPaletteButton.click()
 | 
			
		||||
      await commandOption.click()
 | 
			
		||||
      await orthoOption.click()
 | 
			
		||||
      await expect(commandToast).toBeVisible()
 | 
			
		||||
      await expect
 | 
			
		||||
        .poll(async () => locationToHaveColor(xzPlaneColor), {
 | 
			
		||||
          timeout: 5000,
 | 
			
		||||
          message: 'This spot should have the XZ plane color',
 | 
			
		||||
        })
 | 
			
		||||
        .toBeLessThan(15)
 | 
			
		||||
      await expect(projectionToggle).toHaveAttribute('aria-checked', 'false')
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    await test.step(`Refresh the page and ensure the stream is loaded in ortho`, async () => {
 | 
			
		||||
      // In playwright web, the settings set while testing are not persisted because
 | 
			
		||||
      // the `addInitScript` within `setup` is re-run on page reload
 | 
			
		||||
      await page.addInitScript(
 | 
			
		||||
        ({ settingsKey, settings }) => {
 | 
			
		||||
          localStorage.setItem(settingsKey, settings)
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          settingsKey: TEST_SETTINGS_KEY,
 | 
			
		||||
          settings: TOML.stringify({
 | 
			
		||||
            settings: {
 | 
			
		||||
              ...TEST_SETTINGS,
 | 
			
		||||
              modeling: {
 | 
			
		||||
                ...TEST_SETTINGS.modeling,
 | 
			
		||||
                cameraProjection: 'orthographic',
 | 
			
		||||
              },
 | 
			
		||||
            },
 | 
			
		||||
          }),
 | 
			
		||||
        }
 | 
			
		||||
      )
 | 
			
		||||
      await page.reload()
 | 
			
		||||
      await u.waitForAuthSkipAppStart()
 | 
			
		||||
      await expect
 | 
			
		||||
        .poll(async () => locationToHaveColor(xzPlaneColor), {
 | 
			
		||||
          timeout: 5000,
 | 
			
		||||
          message: 'This spot should have the XZ plane color',
 | 
			
		||||
        })
 | 
			
		||||
        .toBeLessThan(15)
 | 
			
		||||
      await expect(commandToast).not.toBeVisible()
 | 
			
		||||
      await expect(projectionToggle).toHaveAttribute('aria-checked', 'false')
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    await test.step(`Switch to perspective via toggle`, async () => {
 | 
			
		||||
      await projectionToggle.click()
 | 
			
		||||
      await expect(projectionToggle).toHaveAttribute('aria-checked', 'true')
 | 
			
		||||
      await expect
 | 
			
		||||
        .poll(async () => locationToHaveColor(backgroundColor), {
 | 
			
		||||
          timeout: 5000,
 | 
			
		||||
          message: 'This spot should have the background color',
 | 
			
		||||
        })
 | 
			
		||||
        .toBeLessThan(15)
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
@ -21,6 +21,7 @@ import useHotkeyWrapper from 'lib/hotkeyWrapper'
 | 
			
		||||
import Gizmo from 'components/Gizmo'
 | 
			
		||||
import { CoreDumpManager } from 'lib/coredump'
 | 
			
		||||
import { UnitsMenu } from 'components/UnitsMenu'
 | 
			
		||||
import { CameraProjectionToggle } from 'components/CameraProjectionToggle'
 | 
			
		||||
 | 
			
		||||
export function App() {
 | 
			
		||||
  const { project, file } = useLoaderData() as IndexLoaderData
 | 
			
		||||
@ -85,6 +86,7 @@ export function App() {
 | 
			
		||||
      <LowerRightControls coreDumpManager={coreDumpManager}>
 | 
			
		||||
        <UnitsMenu />
 | 
			
		||||
        <Gizmo />
 | 
			
		||||
        <CameraProjectionToggle />
 | 
			
		||||
      </LowerRightControls>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
@ -28,6 +28,7 @@ import { isReducedMotion, roundOff, throttle } from 'lib/utils'
 | 
			
		||||
import * as TWEEN from '@tweenjs/tween.js'
 | 
			
		||||
import { isQuaternionVertical } from './helpers'
 | 
			
		||||
import { reportRejection } from 'lib/trap'
 | 
			
		||||
import { CameraProjectionType } from 'wasm-lib/kcl/bindings/CameraProjectionType'
 | 
			
		||||
 | 
			
		||||
const ORTHOGRAPHIC_CAMERA_SIZE = 20
 | 
			
		||||
const FRAMES_TO_ANIMATE_IN = 30
 | 
			
		||||
@ -90,6 +91,14 @@ export class CameraControls {
 | 
			
		||||
    return this.camera instanceof PerspectiveCamera
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setEngineCameraProjection(projection: CameraProjectionType) {
 | 
			
		||||
    if (projection === 'orthographic') {
 | 
			
		||||
      this.useOrthographicCamera()
 | 
			
		||||
    } else {
 | 
			
		||||
      this.usePerspectiveCamera(true).catch(reportRejection)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleStart = () => {
 | 
			
		||||
    this._isCamMovingCallback(true, false)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -43,6 +43,7 @@ import {
 | 
			
		||||
} from 'lang/modifyAst'
 | 
			
		||||
import { ActionButton } from 'components/ActionButton'
 | 
			
		||||
import { err, reportRejection, trap } from 'lib/trap'
 | 
			
		||||
import { useCommandsContext } from 'hooks/useCommandsContext'
 | 
			
		||||
 | 
			
		||||
function useShouldHideScene(): { hideClient: boolean; hideServer: boolean } {
 | 
			
		||||
  const [isCamMoving, setIsCamMoving] = useState(false)
 | 
			
		||||
@ -718,6 +719,7 @@ export const CamDebugSettings = () => {
 | 
			
		||||
    sceneInfra.camControls.reactCameraProperties
 | 
			
		||||
  )
 | 
			
		||||
  const [fov, setFov] = useState(12)
 | 
			
		||||
  const { commandBarSend } = useCommandsContext()
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    sceneInfra.camControls.setReactCameraPropertiesCallback(setCamSettings)
 | 
			
		||||
@ -735,14 +737,15 @@ export const CamDebugSettings = () => {
 | 
			
		||||
      <input
 | 
			
		||||
        type="checkbox"
 | 
			
		||||
        checked={camSettings.type === 'perspective'}
 | 
			
		||||
        onChange={(e) => {
 | 
			
		||||
          if (camSettings.type === 'perspective') {
 | 
			
		||||
            sceneInfra.camControls.useOrthographicCamera()
 | 
			
		||||
          } else {
 | 
			
		||||
            // eslint-disable-next-line @typescript-eslint/no-floating-promises
 | 
			
		||||
            sceneInfra.camControls.usePerspectiveCamera(true)
 | 
			
		||||
          }
 | 
			
		||||
        }}
 | 
			
		||||
        onChange={() =>
 | 
			
		||||
          commandBarSend({
 | 
			
		||||
            type: 'Find and select command',
 | 
			
		||||
            data: {
 | 
			
		||||
              groupId: 'settings',
 | 
			
		||||
              name: 'modeling.cameraProjection',
 | 
			
		||||
            },
 | 
			
		||||
          })
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
      <div>
 | 
			
		||||
        <button
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										59
									
								
								src/components/CameraProjectionToggle.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,59 @@
 | 
			
		||||
import { Switch } from '@headlessui/react'
 | 
			
		||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
 | 
			
		||||
import { useEffect, useState } from 'react'
 | 
			
		||||
 | 
			
		||||
export function CameraProjectionToggle() {
 | 
			
		||||
  const { settings } = useSettingsAuthContext()
 | 
			
		||||
  const isCameraProjectionPerspective =
 | 
			
		||||
    settings.context.modeling.cameraProjection.current === 'perspective'
 | 
			
		||||
  const [checked, setChecked] = useState(isCameraProjectionPerspective)
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setChecked(
 | 
			
		||||
      settings.context.modeling.cameraProjection.current === 'perspective'
 | 
			
		||||
    )
 | 
			
		||||
  }, [settings.context.modeling.cameraProjection.current])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Switch
 | 
			
		||||
      checked={checked}
 | 
			
		||||
      onChange={(newValue) => {
 | 
			
		||||
        settings.send({
 | 
			
		||||
          type: 'set.modeling.cameraProjection',
 | 
			
		||||
          data: {
 | 
			
		||||
            level: 'user',
 | 
			
		||||
            value: newValue ? 'perspective' : 'orthographic',
 | 
			
		||||
          },
 | 
			
		||||
        })
 | 
			
		||||
      }}
 | 
			
		||||
      className={`pointer-events-auto p-0 text-xs text-chalkboard-60 dark:text-chalkboard-40 bg-chalkboard-10/70 hover:bg-chalkboard-10 dark:bg-chalkboard-100/80 dark:hover:bg-chalkboard-100 backdrop-blur-sm 
 | 
			
		||||
        border border-primary/10 hover:border-primary/50 focus-visible:border-primary/50 rounded-full`}
 | 
			
		||||
    >
 | 
			
		||||
      <span className="sr-only">Camera projection: </span>
 | 
			
		||||
      <div className="flex items-center gap-2">
 | 
			
		||||
        <span
 | 
			
		||||
          aria-hidden={checked}
 | 
			
		||||
          className={
 | 
			
		||||
            'border border-solid m-[-1px] rounded-full px-2 py-1 ' +
 | 
			
		||||
            (!checked
 | 
			
		||||
              ? 'text-primary border-primary -mr-2'
 | 
			
		||||
              : 'border-transparent')
 | 
			
		||||
          }
 | 
			
		||||
        >
 | 
			
		||||
          Orthographic
 | 
			
		||||
        </span>
 | 
			
		||||
        <span
 | 
			
		||||
          aria-hidden={checked}
 | 
			
		||||
          className={
 | 
			
		||||
            'border border-solid m-[-1px] rounded-full px-2 py-1 ' +
 | 
			
		||||
            (checked
 | 
			
		||||
              ? 'text-primary border-primary -ml-2'
 | 
			
		||||
              : 'border-transparent')
 | 
			
		||||
          }
 | 
			
		||||
        >
 | 
			
		||||
          Perspective
 | 
			
		||||
        </span>
 | 
			
		||||
      </div>
 | 
			
		||||
    </Switch>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
@ -22,6 +22,7 @@ function CommandComboBox({
 | 
			
		||||
  const fuse = new Fuse(options, {
 | 
			
		||||
    keys: ['displayName', 'name', 'description'],
 | 
			
		||||
    threshold: 0.3,
 | 
			
		||||
    ignoreLocation: true,
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
 | 
			
		||||
@ -104,7 +104,12 @@ export const ModelingMachineProvider = ({
 | 
			
		||||
    settings: {
 | 
			
		||||
      context: {
 | 
			
		||||
        app: { theme, enableSSAO },
 | 
			
		||||
        modeling: { defaultUnit, highlightEdges, showScaleGrid },
 | 
			
		||||
        modeling: {
 | 
			
		||||
          defaultUnit,
 | 
			
		||||
          cameraProjection,
 | 
			
		||||
          highlightEdges,
 | 
			
		||||
          showScaleGrid,
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  } = useSettingsAuthContext()
 | 
			
		||||
@ -145,7 +150,9 @@ export const ModelingMachineProvider = ({
 | 
			
		||||
          ;(async () => {
 | 
			
		||||
            sceneInfra.camControls.syncDirection = 'clientToEngine'
 | 
			
		||||
 | 
			
		||||
            await sceneInfra.camControls.snapToPerspectiveBeforeHandingBackControlToEngine()
 | 
			
		||||
            if (cameraProjection.current === 'perspective') {
 | 
			
		||||
              await sceneInfra.camControls.snapToPerspectiveBeforeHandingBackControlToEngine()
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            sceneInfra.camControls.syncDirection = 'engineToClient'
 | 
			
		||||
 | 
			
		||||
@ -974,6 +981,7 @@ export const ModelingMachineProvider = ({
 | 
			
		||||
      highlightEdges: highlightEdges.current,
 | 
			
		||||
      enableSSAO: enableSSAO.current,
 | 
			
		||||
      showScaleGrid: showScaleGrid.current,
 | 
			
		||||
      cameraProjection: cameraProjection.current,
 | 
			
		||||
    },
 | 
			
		||||
    token
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
@ -22,6 +22,7 @@ export function useSetupEngineManager(
 | 
			
		||||
    highlightEdges: true,
 | 
			
		||||
    enableSSAO: true,
 | 
			
		||||
    showScaleGrid: false,
 | 
			
		||||
    cameraProjection: 'perspective',
 | 
			
		||||
  } as SettingsViaQueryString,
 | 
			
		||||
  token?: string
 | 
			
		||||
) {
 | 
			
		||||
 | 
			
		||||
@ -1380,6 +1380,7 @@ export class EngineCommandManager extends EventTarget {
 | 
			
		||||
          highlightEdges: true,
 | 
			
		||||
          enableSSAO: true,
 | 
			
		||||
          showScaleGrid: false,
 | 
			
		||||
          cameraProjection: 'perspective',
 | 
			
		||||
        }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -1431,6 +1432,7 @@ export class EngineCommandManager extends EventTarget {
 | 
			
		||||
      highlightEdges: true,
 | 
			
		||||
      enableSSAO: true,
 | 
			
		||||
      showScaleGrid: false,
 | 
			
		||||
      cameraProjection: 'orthographic',
 | 
			
		||||
    },
 | 
			
		||||
    // When passed, use a completely separate connecting code path that simply
 | 
			
		||||
    // opens a websocket and this is a function that is called when connected.
 | 
			
		||||
@ -1487,6 +1489,19 @@ export class EngineCommandManager extends EventTarget {
 | 
			
		||||
 | 
			
		||||
    // eslint-disable-next-line @typescript-eslint/no-misused-promises
 | 
			
		||||
    this.onEngineConnectionOpened = async () => {
 | 
			
		||||
      // Set the stream's camera projection type
 | 
			
		||||
      // We don't send a command to the engine if in perspective mode because
 | 
			
		||||
      // for now it's the engine's default.
 | 
			
		||||
      if (settings.cameraProjection === 'orthographic') {
 | 
			
		||||
        this.sendSceneCommand({
 | 
			
		||||
          type: 'modeling_cmd_req',
 | 
			
		||||
          cmd_id: uuidv4(),
 | 
			
		||||
          cmd: {
 | 
			
		||||
            type: 'default_camera_set_orthographic',
 | 
			
		||||
          },
 | 
			
		||||
        }).catch(reportRejection)
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Set the theme
 | 
			
		||||
      this.setTheme(this.settings.theme).catch(reportRejection)
 | 
			
		||||
      // Set up a listener for the dark theme media query
 | 
			
		||||
 | 
			
		||||
@ -18,6 +18,7 @@ import { CustomIcon } from 'components/CustomIcon'
 | 
			
		||||
import Tooltip from 'components/Tooltip'
 | 
			
		||||
import { toSync } from 'lib/utils'
 | 
			
		||||
import { reportRejection } from 'lib/trap'
 | 
			
		||||
import { CameraProjectionType } from 'wasm-lib/kcl/bindings/CameraProjectionType'
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A setting that can be set at the user or project level
 | 
			
		||||
@ -100,6 +101,18 @@ export class Setting<T = unknown> {
 | 
			
		||||
        : this._default
 | 
			
		||||
      : this._default
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * For the purposes of showing the `current` label in the command bar,
 | 
			
		||||
   * is this setting at the given level the same as the given value?
 | 
			
		||||
   */
 | 
			
		||||
  public shouldShowCurrentLabel(
 | 
			
		||||
    level: SettingsLevel | 'default',
 | 
			
		||||
    valueToMatch: T
 | 
			
		||||
  ): boolean {
 | 
			
		||||
    return this[`_${level}`] === undefined
 | 
			
		||||
      ? this.getFallback(level) === valueToMatch
 | 
			
		||||
      : this[`_${level}`] === valueToMatch
 | 
			
		||||
  }
 | 
			
		||||
  public getParentLevel(level: SettingsLevel): SettingsLevel | 'default' {
 | 
			
		||||
    return level === 'project' ? 'user' : 'default'
 | 
			
		||||
  }
 | 
			
		||||
@ -284,9 +297,9 @@ export function createSettings() {
 | 
			
		||||
              value: v,
 | 
			
		||||
              isCurrent:
 | 
			
		||||
                v ===
 | 
			
		||||
                settingsContext.modeling.mouseControls[
 | 
			
		||||
                settingsContext.modeling.mouseControls.shouldShowCurrentLabel(
 | 
			
		||||
                  cmdContext.argumentsToSubmit.level as SettingsLevel
 | 
			
		||||
                ],
 | 
			
		||||
                ),
 | 
			
		||||
            })),
 | 
			
		||||
        },
 | 
			
		||||
        Component: ({ value, updateValue }) => (
 | 
			
		||||
@ -326,6 +339,36 @@ export function createSettings() {
 | 
			
		||||
          </>
 | 
			
		||||
        ),
 | 
			
		||||
      }),
 | 
			
		||||
      /**
 | 
			
		||||
       * Projection method applied to the 3D view, perspective or orthographic
 | 
			
		||||
       */
 | 
			
		||||
      cameraProjection: new Setting<CameraProjectionType>({
 | 
			
		||||
        defaultValue: 'orthographic',
 | 
			
		||||
        hideOnLevel: 'project',
 | 
			
		||||
        description:
 | 
			
		||||
          'Projection method applied to the 3D view, perspective or orthographic',
 | 
			
		||||
        validate: (v) => ['perspective', 'orthographic'].includes(v),
 | 
			
		||||
        commandConfig: {
 | 
			
		||||
          inputType: 'options',
 | 
			
		||||
          // This is how we could have toggling behavior for a non-boolean argument:
 | 
			
		||||
          // Set it to "skippable", and make the default value the opposite of the current value
 | 
			
		||||
          // skip: true,
 | 
			
		||||
          defaultValueFromContext: (context) =>
 | 
			
		||||
            context.modeling.cameraProjection.current === 'perspective'
 | 
			
		||||
              ? 'orthographic'
 | 
			
		||||
              : 'perspective',
 | 
			
		||||
          options: (cmdContext, settingsContext) =>
 | 
			
		||||
            (['perspective', 'orthographic'] as const).map((v) => ({
 | 
			
		||||
              name: v.charAt(0).toUpperCase() + v.slice(1),
 | 
			
		||||
              value: v,
 | 
			
		||||
              isCurrent:
 | 
			
		||||
                settingsContext.modeling.cameraProjection.shouldShowCurrentLabel(
 | 
			
		||||
                  cmdContext.argumentsToSubmit.level as SettingsLevel,
 | 
			
		||||
                  v
 | 
			
		||||
                ),
 | 
			
		||||
            })),
 | 
			
		||||
        },
 | 
			
		||||
      }),
 | 
			
		||||
      /**
 | 
			
		||||
       * Whether to highlight edges of 3D objects
 | 
			
		||||
       */
 | 
			
		||||
 | 
			
		||||
@ -3,6 +3,7 @@ import { Setting, settings } from './initialSettings'
 | 
			
		||||
import { AtLeast, PathValue, Paths } from 'lib/types'
 | 
			
		||||
import { CommandArgumentConfig } from 'lib/commandTypes'
 | 
			
		||||
import { Themes } from 'lib/theme'
 | 
			
		||||
import { CameraProjectionType } from 'wasm-lib/kcl/bindings/CameraProjectionType'
 | 
			
		||||
 | 
			
		||||
export interface SettingsViaQueryString {
 | 
			
		||||
  pool: string | null
 | 
			
		||||
@ -10,6 +11,7 @@ export interface SettingsViaQueryString {
 | 
			
		||||
  highlightEdges: boolean
 | 
			
		||||
  enableSSAO: boolean
 | 
			
		||||
  showScaleGrid: boolean
 | 
			
		||||
  cameraProjection: CameraProjectionType
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export enum UnitSystem {
 | 
			
		||||
 | 
			
		||||
@ -46,6 +46,7 @@ export function configurationToSettingsPayload(
 | 
			
		||||
    },
 | 
			
		||||
    modeling: {
 | 
			
		||||
      defaultUnit: configuration?.settings?.modeling?.base_unit,
 | 
			
		||||
      cameraProjection: configuration?.settings?.modeling?.camera_projection,
 | 
			
		||||
      mouseControls: mouseControlsToCameraSystem(
 | 
			
		||||
        configuration?.settings?.modeling?.mouse_controls
 | 
			
		||||
      ),
 | 
			
		||||
 | 
			
		||||
@ -13,6 +13,7 @@ import {
 | 
			
		||||
  projectConfigurationToSettingsPayload,
 | 
			
		||||
  setSettingsAtLevel,
 | 
			
		||||
} from 'lib/settings/settingsUtils'
 | 
			
		||||
import { sceneInfra } from 'lib/singletons'
 | 
			
		||||
 | 
			
		||||
export const settingsMachine = setup({
 | 
			
		||||
  types: {
 | 
			
		||||
@ -89,6 +90,10 @@ export const settingsMachine = setup({
 | 
			
		||||
        currentTheme === Themes.System ? getSystemTheme() : currentTheme
 | 
			
		||||
      )
 | 
			
		||||
    },
 | 
			
		||||
    setEngineCameraProjection: ({ context }) => {
 | 
			
		||||
      const newCurrentProjection = context.modeling.cameraProjection.current
 | 
			
		||||
      sceneInfra.camControls.setEngineCameraProjection(newCurrentProjection)
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
}).createMachine({
 | 
			
		||||
  /** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlnXwEMAHW-Ae2wCNHqAnCHKZNatAFdYAbQAMAXUShajWJizNpIAB6IALAFYAnPgBMARgDsBsQDY969QGYjmzQBoQAT0SnrADnwePY61r0PAwNtMyMAX3CnVAweAiJSCio6BjQACzAAWzAAYUZiRg5xKSQQWXlFbGU1BD1PfFtfE3UzTUNNaydXBCD1b209PTEPTTMtdQNNSOj0LFx4knJKNHxMxggwYh58DYAzakFiNABVbAVi5XKFTCVSmusxPXx7bRt1DzMxI3UjD3UutwhAz4MyeHxiV5+AYRKIgGJzPCERZJFYpfDpLJgC6lK6VaqIExPMwWGwdGxBPRmAE9PSafCPMQ-EzWbQ6ELTOGzOJIxLLVbrTbbNKYKBpLaitAAUWgcGxMjk11uoBqVmBH0ZLKCrVs-xciCCwLCvhCjyMFhGHPh3IS5AASnB0AACZYI0SSS4KvF3AlafADRl1YZ2IxiRx6hBtIzPb7abQ+DxGaxmYKWrnzHnkGKO6jEYjOtN4OVlT03KrehAtOnm7Qaup6Ixm6mR6OaR4dAwjM1mVOxdM2lH8jZbXD4WBpRgAd2QAGMc2AAOIcIhF3Gl-EIRPA6yGcyh4whSnU0xGJ5GAat0OfFowma9xH9gBUK5LStUiECdMmfx+mg8hmNTY-PgMYQpoZoxh41g9q6+C0GAHDyLACL5nesBkBAzBgIQ2AAG6MAA1lhcEIZgSFWvMz4VGu5YALTbtYwEnj8HhxnooT1mG3QhmY-TmJ82gGCyjzaJEsLYAK8ClOReAelRr41HRJiMZYvysexdjUuohh+poBiGDuXzGKy0HWossmKmWyqIDR3zAZWLSahM2jWJ04YjDxHbDMmmhaYE3wmemxGIchLpxOZXpWQgNEjMB1h6WEYHqK8ZgJk2EL6N8wR1Cy-gJqJ4RAA */
 | 
			
		||||
@ -153,6 +158,16 @@ export const settingsMachine = setup({
 | 
			
		||||
          actions: ['setSettingAtLevel', 'toastSuccess'],
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        'set.modeling.cameraProjection': {
 | 
			
		||||
          target: 'persisting settings',
 | 
			
		||||
 | 
			
		||||
          actions: [
 | 
			
		||||
            'setSettingAtLevel',
 | 
			
		||||
            'toastSuccess',
 | 
			
		||||
            'setEngineCameraProjection',
 | 
			
		||||
          ],
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        'set.modeling.highlightEdges': {
 | 
			
		||||
          target: 'persisting settings',
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -254,6 +254,9 @@ pub struct ModelingSettings {
 | 
			
		||||
    /// The default unit to use in modeling dimensions.
 | 
			
		||||
    #[serde(default, alias = "defaultUnit", skip_serializing_if = "is_default")]
 | 
			
		||||
    pub base_unit: UnitLength,
 | 
			
		||||
    /// The projection mode the camera should use while modeling.
 | 
			
		||||
    #[serde(default, alias = "cameraProjection", skip_serializing_if = "is_default")]
 | 
			
		||||
    pub camera_projection: CameraProjectionType,
 | 
			
		||||
    /// The controls for how to navigate the 3D view.
 | 
			
		||||
    #[serde(default, alias = "mouseControls", skip_serializing_if = "is_default")]
 | 
			
		||||
    pub mouse_controls: MouseControlType,
 | 
			
		||||
@ -397,6 +400,19 @@ pub enum MouseControlType {
 | 
			
		||||
    AutoCad,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// The types of camera projection for the 3D view.
 | 
			
		||||
#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
 | 
			
		||||
#[ts(export)]
 | 
			
		||||
#[serde(rename_all = "snake_case")]
 | 
			
		||||
#[display(style = "snake_case")]
 | 
			
		||||
pub enum CameraProjectionType {
 | 
			
		||||
    /// Perspective projection https://en.wikipedia.org/wiki/3D_projection#Perspective_projection
 | 
			
		||||
    Perspective,
 | 
			
		||||
    /// Orthographic projection https://en.wikipedia.org/wiki/3D_projection#Orthographic_projection
 | 
			
		||||
    #[default]
 | 
			
		||||
    Orthographic,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Settings that affect the behavior of the KCL text editor.
 | 
			
		||||
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
 | 
			
		||||
#[serde(rename_all = "snake_case")]
 | 
			
		||||
@ -522,8 +538,8 @@ mod tests {
 | 
			
		||||
    use validator::Validate;
 | 
			
		||||
 | 
			
		||||
    use super::{
 | 
			
		||||
        AppColor, AppSettings, AppTheme, AppearanceSettings, CommandBarSettings, Configuration, ModelingSettings,
 | 
			
		||||
        OnboardingStatus, ProjectSettings, Settings, TextEditorSettings, UnitLength,
 | 
			
		||||
        AppColor, AppSettings, AppTheme, AppearanceSettings, CameraProjectionType, CommandBarSettings, Configuration,
 | 
			
		||||
        ModelingSettings, OnboardingStatus, ProjectSettings, Settings, TextEditorSettings, UnitLength,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    #[test]
 | 
			
		||||
@ -538,6 +554,7 @@ enableSSAO = false
 | 
			
		||||
 | 
			
		||||
[settings.modeling]
 | 
			
		||||
defaultUnit = "in"
 | 
			
		||||
cameraProjection = "orthographic"
 | 
			
		||||
mouseControls = "KittyCAD"
 | 
			
		||||
showDebugPanel = true
 | 
			
		||||
 | 
			
		||||
@ -569,6 +586,7 @@ textWrapping = true
 | 
			
		||||
                    },
 | 
			
		||||
                    modeling: ModelingSettings {
 | 
			
		||||
                        base_unit: UnitLength::In,
 | 
			
		||||
                        camera_projection: CameraProjectionType::Orthographic,
 | 
			
		||||
                        mouse_controls: Default::default(),
 | 
			
		||||
                        highlight_edges: Default::default(),
 | 
			
		||||
                        show_debug_panel: true,
 | 
			
		||||
@ -629,6 +647,7 @@ includeSettings = false
 | 
			
		||||
                    },
 | 
			
		||||
                    modeling: ModelingSettings {
 | 
			
		||||
                        base_unit: UnitLength::Yd,
 | 
			
		||||
                        camera_projection: Default::default(),
 | 
			
		||||
                        mouse_controls: Default::default(),
 | 
			
		||||
                        highlight_edges: Default::default(),
 | 
			
		||||
                        show_debug_panel: true,
 | 
			
		||||
@ -694,6 +713,7 @@ defaultProjectName = "projects-$nnn"
 | 
			
		||||
                    },
 | 
			
		||||
                    modeling: ModelingSettings {
 | 
			
		||||
                        base_unit: UnitLength::Yd,
 | 
			
		||||
                        camera_projection: Default::default(),
 | 
			
		||||
                        mouse_controls: Default::default(),
 | 
			
		||||
                        highlight_edges: Default::default(),
 | 
			
		||||
                        show_debug_panel: true,
 | 
			
		||||
@ -771,6 +791,7 @@ projectDirectory = "/Users/macinatormax/Documents/kittycad-modeling-projects""#;
 | 
			
		||||
                    },
 | 
			
		||||
                    modeling: ModelingSettings {
 | 
			
		||||
                        base_unit: UnitLength::Mm,
 | 
			
		||||
                        camera_projection: Default::default(),
 | 
			
		||||
                        mouse_controls: Default::default(),
 | 
			
		||||
                        highlight_edges: true.into(),
 | 
			
		||||
                        show_debug_panel: false,
 | 
			
		||||
 | 
			
		||||
@ -127,6 +127,7 @@ includeSettings = false
 | 
			
		||||
                    },
 | 
			
		||||
                    modeling: ModelingSettings {
 | 
			
		||||
                        base_unit: UnitLength::Yd,
 | 
			
		||||
                        camera_projection: Default::default(),
 | 
			
		||||
                        mouse_controls: Default::default(),
 | 
			
		||||
                        highlight_edges: Default::default(),
 | 
			
		||||
                        show_debug_panel: true,
 | 
			
		||||
 | 
			
		||||