Compare commits
	
		
			76 Commits
		
	
	
		
			cut-releas
			...
			franknoiro
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| fc95f9e49e | |||
| 93d9b10e11 | |||
| 166487433c | |||
| 5512f99997 | |||
| 01cc9e751b | |||
| bfac6b7dc8 | |||
| d1f9a02ffa | |||
| d8236dd8da | |||
| dabf256e2b | |||
| 4285e81001 | |||
| 370375c328 | |||
| 9f22882c68 | |||
| db5331d9b9 | |||
| 5cc92f0162 | |||
| 2978e80226 | |||
| 4a74c60150 | |||
| 00fa40bbc9 | |||
| 62b78840b6 | |||
| f828c36e58 | |||
| 8c5b146c94 | |||
| 61c7d9844d | |||
| 8d48c17395 | |||
| 0ff820d4da | |||
| c4ff1c2ef1 | |||
| b6aba2f29c | |||
| 7467f7ea50 | |||
| 0c6d3e0ccf | |||
| e82917ea01 | |||
| 857c1aad3d | |||
| dc73acb1b1 | |||
| 8602e937d3 | |||
| a2133d8317 | |||
| 39ce0da3e5 | |||
| f235a950b0 | |||
| 3cd3e1af72 | |||
| 8c6266e94b | |||
| 755a6016c7 | |||
| 1cbbefba97 | |||
| 8610d606f4 | |||
| 728e87a627 | |||
| 772034af68 | |||
| 957a9ca4fe | |||
| 472eb2bafe | |||
| 88216d4c76 | |||
| 8b1e4d6708 | |||
| 769c3ec785 | |||
| 1c4179a9db | |||
| 292f89859f | |||
| a00800bddc | |||
| 78ceba6d20 | |||
| 6776a350af | |||
| dd75f06f77 | |||
| 394872d84e | |||
| f9eef6397f | |||
| 900bac999c | |||
| 5b2738f826 | |||
| dab96577a7 | |||
| 25443eba31 | |||
| 0a72d7a39a | |||
| 5f8d4f8294 | |||
| 7c2cfba0ac | |||
| 5ee43bda22 | |||
| a1b6bbac7e | |||
| e61516f3c3 | |||
| e2eeec37ad | |||
| d7fcc128aa | |||
| cf266b17c1 | |||
| b3a1796da9 | |||
| 39b9a6b2c4 | |||
| 6ba4fa305c | |||
| 1d043899c8 | |||
| cb8a087d89 | |||
| f2eb7b57b8 | |||
| eba653930f | |||
| 3deb5c689a | |||
| 11ebe11111 | 
| @ -13,6 +13,8 @@ | ||||
|       "plugin:css-modules/recommended" | ||||
|     ], | ||||
|     "rules": { | ||||
|       "@typescript-eslint/no-floating-promises": "error", | ||||
|       "@typescript-eslint/no-misused-promises": "error", | ||||
|       "semi": [ | ||||
|         "error", | ||||
|         "never" | ||||
| @ -24,7 +26,6 @@ | ||||
|       { | ||||
|         "files": ["e2e/**/*.ts"], // Update the pattern based on your file structure | ||||
|         "rules": { | ||||
|           "@typescript-eslint/no-floating-promises": "warn", | ||||
|           "suggest-no-throw/suggest-no-throw": "off", | ||||
|           "testing-library/prefer-screen-queries": "off", | ||||
|           "jest/valid-expect": "off" | ||||
|  | ||||
							
								
								
									
										71
									
								
								.github/workflows/build-test-publish-apps.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @ -5,7 +5,6 @@ on: | ||||
|   push: | ||||
|     branches: | ||||
|       - main | ||||
|       - cut-release-v0.25.1-updater-test-build-1 | ||||
|   release: | ||||
|     types: [published] | ||||
|   schedule: | ||||
| @ -14,8 +13,8 @@ on: | ||||
|   # Will checkout the last commit from the default branch (main as of 2023-10-04) | ||||
|  | ||||
| env: | ||||
|   CUT_RELEASE_PR: true | ||||
|   BUILD_RELEASE: true | ||||
|   CUT_RELEASE_PR: ${{ github.event_name == 'pull_request' && (contains(github.event.pull_request.title, 'Cut release v')) }} | ||||
|   BUILD_RELEASE: ${{ github.event_name == 'release' || github.event_name == 'schedule' || github.event_name == 'pull_request' && (contains(github.event.pull_request.title, 'Cut release v')) }} | ||||
|  | ||||
| concurrency: | ||||
|   group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} | ||||
| @ -53,7 +52,6 @@ jobs: | ||||
|           VERSION=$(date +'%-y.%-m.%-d') yarn bump-jsons | ||||
|  | ||||
|       # TODO: see if we need to inject updater nightly URL here https://dl.zoo.dev/releases/modeling-app/nightly/last_update.json | ||||
|       # TODO: see if we ned to add updater test URL here https://dl.zoo.dev/releases/modeling-app/updater-test/last_update.json | ||||
|  | ||||
|       - uses: actions/upload-artifact@v3 | ||||
|         with: | ||||
| @ -65,6 +63,17 @@ jobs: | ||||
|       - id: export_version | ||||
|         run: echo "version=`cat package.json | jq -r '.version'`" >> "$GITHUB_OUTPUT" | ||||
|  | ||||
|       - name: Prepare electron-builder.yml file for updater test | ||||
|         if: ${{ env.CUT_RELEASE_PR == 'true' }} | ||||
|         run: | | ||||
|           yq -i '.publish[0].url = "https://dl.zoo.dev/releases/modeling-app/updater-test"' electron-builder.yml | ||||
|  | ||||
|       - uses: actions/upload-artifact@v3 | ||||
|         with: | ||||
|           name: prepared-files-updater-test | ||||
|           path: | | ||||
|             electron-builder.yml | ||||
|  | ||||
|  | ||||
|   build-apps: | ||||
|     needs: [prepare-files] | ||||
| @ -150,22 +159,42 @@ jobs: | ||||
|  | ||||
|       # TODO: add the 'Build for Mac TestFlight (nightly)' stage back | ||||
|  | ||||
|       # TODO: add the updater tests back | ||||
|       - uses: actions/download-artifact@v3 | ||||
|         if: ${{ env.CUT_RELEASE_PR == 'true' }} | ||||
|         name: prepared-files-updater-test | ||||
|  | ||||
|       - name: Copy updated electron-builder.yml file for updater test | ||||
|         if: ${{ env.CUT_RELEASE_PR == 'true' }} | ||||
|         run: | | ||||
|           ls -R prepared-files-updater-test | ||||
|           cp prepared-files-updater-test/electron-builder.yml electron-builder.yml | ||||
|  | ||||
|       - name: Build the app (updater-test) | ||||
|         if: ${{ env.CUT_RELEASE_PR == 'true' }} | ||||
|         run: yarn electron-builder --config ${{ env.BUILD_RELEASE && '--publish always' || '' }} | ||||
|  | ||||
|       - uses: actions/upload-artifact@v3 | ||||
|         if: ${{ env.CUT_RELEASE_PR == 'true' }} | ||||
|         with: | ||||
|           name: updater-test-${{ matrix.os }} | ||||
|           path: | | ||||
|             out/Zoo*.* | ||||
|             out/latest*.yml | ||||
|  | ||||
|  | ||||
|   publish-apps-release: | ||||
|     runs-on: ubuntu-22.04 | ||||
|     permissions: | ||||
|       contents: write | ||||
|     # if: ${{ github.event_name == 'release' || github.event_name == 'schedule' }} | ||||
|     if: ${{ github.event_name == 'release' || github.event_name == 'schedule' }} | ||||
|     needs: [prepare-files, build-apps] | ||||
|     env: | ||||
|       VERSION_NO_V: ${{ needs.prepare-files.outputs.version }} | ||||
|       VERSION: ${{ github.event_name == 'schedule' && needs.prepare-files.outputs.version || format('v{0}', needs.prepare-files.outputs.version) }} | ||||
|       PUB_DATE: ${{ github.event_name == 'release' && github.event.release.created_at || github.event.repository.updated_at }} | ||||
|       NOTES: ${{ github.event_name == 'release' && github.event.release.body || format('Non-release build, commit {0}', github.sha) }} | ||||
|       BUCKET_DIR: ${{ github.event_name == 'schedule' && 'dl.kittycad.io/releases/modeling-app/nightly' || 'dl.kittycad.io/releases/modeling-app/test/cut-release-v0.25.1-updater-test' }} | ||||
|       WEBSITE_DIR: ${{ github.event_name == 'schedule' && 'dl.zoo.dev/releases/modeling-app/nightly' || 'dl.zoo.dev/releases/modeling-app/test/cut-release-v0.25.1-updater-test' }} | ||||
|       BUCKET_DIR: ${{ github.event_name == 'schedule' && 'dl.kittycad.io/releases/modeling-app/nightly' || 'dl.kittycad.io/releases/modeling-app' }} | ||||
|       WEBSITE_DIR: ${{ github.event_name == 'schedule' && 'dl.zoo.dev/releases/modeling-app/nightly' || 'dl.zoo.dev/releases/modeling-app' }} | ||||
|       URL_CODED_NAME: ${{ github.event_name == 'schedule' && 'Zoo%20Modeling%20App%20%28Nightly%29' || 'Zoo%20Modeling%20App' }} | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
| @ -194,8 +223,8 @@ jobs: | ||||
|             --arg notes "${NOTES}" \ | ||||
|             --arg mac_arm64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-arm64-mac.dmg" \ | ||||
|             --arg mac_x64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-x64-mac.dmg" \ | ||||
|             --arg windows_arm64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-arm64-win.msi" \ | ||||
|             --arg windows_x64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-x64-win.msi" \ | ||||
|             --arg windows_arm64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-arm64-win.exe" \ | ||||
|             --arg windows_x64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-x64-win.exe" \ | ||||
|             --arg linux_arm64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-arm64-linux.AppImage" \ | ||||
|             --arg linux_x64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-x86_64-linux.AppImage" \ | ||||
|             '{ | ||||
| @ -209,10 +238,10 @@ jobs: | ||||
|                 "dmg-x64": { | ||||
|                   "url": $mac_x64_url | ||||
|                 }, | ||||
|                 "msi-arm64": { | ||||
|                 "exe-arm64": { | ||||
|                   "url": $windows_arm64_url | ||||
|                 }, | ||||
|                 "msi-x64": { | ||||
|                 "exe-x64": { | ||||
|                   "url": $windows_x64_url | ||||
|                 }, | ||||
|                 "appimage-arm64": { | ||||
| @ -246,15 +275,6 @@ jobs: | ||||
|           parent: false | ||||
|           destination: ${{ env.BUCKET_DIR }} | ||||
|  | ||||
|       # TODO: remove workaround introduced in https://github.com/KittyCAD/modeling-app/issues/3817 | ||||
|       - name: Upload release files to public bucket (test/electron-builder workaround) | ||||
|         uses: google-github-actions/upload-cloud-storage@v2.2.0 | ||||
|         with: | ||||
|           path: out | ||||
|           glob: 'Zoo*' | ||||
|           parent: false | ||||
|           destination: '${{ env.BUCKET_DIR }}/test/electron-builder' | ||||
|  | ||||
|       - name: Upload update endpoint to public bucket | ||||
|         uses: google-github-actions/upload-cloud-storage@v2.2.0 | ||||
|         with: | ||||
| @ -263,15 +283,6 @@ jobs: | ||||
|           parent: false | ||||
|           destination: ${{ env.BUCKET_DIR }} | ||||
|  | ||||
|       # TODO: remove workaround introduced in https://github.com/KittyCAD/modeling-app/issues/3817 | ||||
|       - name: Upload update endpoint to public bucket (test/electron-builder workaround) | ||||
|         uses: google-github-actions/upload-cloud-storage@v2.2.0 | ||||
|         with: | ||||
|           path: out | ||||
|           glob: 'latest*' | ||||
|           parent: false | ||||
|           destination: '${{ env.BUCKET_DIR }}/test/electron-builder' | ||||
|  | ||||
|       - name: Upload download endpoint to public bucket | ||||
|         uses: google-github-actions/upload-cloud-storage@v2.2.0 | ||||
|         with: | ||||
|  | ||||
							
								
								
									
										2
									
								
								.github/workflows/build-test-web.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @ -45,7 +45,7 @@ jobs: | ||||
|       - run: yarn xstate:typegen | ||||
|       - run: yarn tsc | ||||
|       - name: Lint | ||||
|         run: yarn eslint --max-warnings 0 src e2e | ||||
|         run: yarn eslint --max-warnings 0 src e2e packages/codemirror-lsp-client | ||||
|  | ||||
|  | ||||
|   check-typos: | ||||
|  | ||||
							
								
								
									
										4
									
								
								.github/workflows/playwright.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @ -34,7 +34,7 @@ jobs: | ||||
|               - 'src/wasm-lib/**' | ||||
|  | ||||
|   playwright-chrome: | ||||
|     timeout-minutes: ${{ matrix.os == 'macos-14' && 60 || 40 }} | ||||
|     timeout-minutes: ${{ matrix.os == 'macos-14' && 60 || 50 }} | ||||
|     strategy: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
| @ -232,6 +232,7 @@ jobs: | ||||
|         exit 0 | ||||
|       env: | ||||
|         CI: true | ||||
|         FAIL_ON_CONSOLE_ERRORS: true | ||||
|         NODE_ENV: development | ||||
|         VITE_KC_DEV_TOKEN: ${{ secrets.KITTYCAD_API_TOKEN_DEV }} | ||||
|         VITE_KC_SKIP_AUTH: true | ||||
| @ -410,6 +411,7 @@ jobs: | ||||
|         exit 0 | ||||
|       env: | ||||
|         CI: true | ||||
|         FAIL_ON_CONSOLE_ERRORS: true | ||||
|         NODE_ENV: development | ||||
|         VITE_KC_DEV_TOKEN: ${{ secrets.KITTYCAD_API_TOKEN_DEV }} | ||||
|         VITE_KC_SKIP_AUTH: true | ||||
|  | ||||
| @ -22,8 +22,3 @@ once fixed in engine will just start working here with no language changes. | ||||
|  | ||||
| - **Chamfers**: Chamfers cannot intersect, you will get an error. Only simple | ||||
|     chamfer cases work currently. | ||||
|  | ||||
|     Sketching on the chamfered face does not currently work. | ||||
|  | ||||
| - **Shell**: Shell sometimes does not work when arcs or fillets are involved. | ||||
|     We are tracking the engine side bug on this. | ||||
|  | ||||
							
								
								
									
										858
									
								
								docs/kcl/arrayReduce.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -19,6 +19,7 @@ layout: manual | ||||
| * [`angledLineToX`](kcl/angledLineToX) | ||||
| * [`angledLineToY`](kcl/angledLineToY) | ||||
| * [`arc`](kcl/arc) | ||||
| * [`arrayReduce`](kcl/arrayReduce) | ||||
| * [`asin`](kcl/asin) | ||||
| * [`assert`](kcl/assert) | ||||
| * [`assertEqual`](kcl/assertEqual) | ||||
|  | ||||
							
								
								
									
										18150
									
								
								docs/kcl/std.json
									
									
									
									
									
								
							
							
						
						| @ -13,14 +13,16 @@ arrays can hold objects and vice versa. | ||||
|  | ||||
| `true` or `false` work when defining values. | ||||
|  | ||||
| ## Variable declaration | ||||
| ## Constant declaration | ||||
|  | ||||
| Variables are defined with the `let` keyword like so: | ||||
| Constants are defined with the `let` keyword like so: | ||||
|  | ||||
| ``` | ||||
| let myBool = false | ||||
| ``` | ||||
|  | ||||
| Currently you cannot redeclare a constant. | ||||
|  | ||||
| ## Array | ||||
|  | ||||
| An array is defined with `[]` braces. What is inside the brackets can | ||||
|  | ||||
| @ -8,8 +8,8 @@ import { | ||||
|   PERSIST_MODELING_CONTEXT, | ||||
| } from './test-utils' | ||||
|  | ||||
| test.beforeEach(async ({ context, page }) => { | ||||
|   await setup(context, page) | ||||
| test.beforeEach(async ({ context, page }, testInfo) => { | ||||
|   await setup(context, page, testInfo) | ||||
| }) | ||||
|  | ||||
| test.afterEach(async ({ page }, testInfo) => { | ||||
|  | ||||
| @ -3,8 +3,8 @@ import { getUtils, setup, tearDown } from './test-utils' | ||||
| import { EngineCommand } from 'lang/std/artifactGraph' | ||||
| import { uuidv4 } from 'lib/utils' | ||||
|  | ||||
| test.beforeEach(async ({ context, page }) => { | ||||
|   await setup(context, page) | ||||
| test.beforeEach(async ({ context, page }, testInfo) => { | ||||
|   await setup(context, page, testInfo) | ||||
| }) | ||||
|  | ||||
| test.afterEach(async ({ page }, testInfo) => { | ||||
|  | ||||
| @ -12,8 +12,8 @@ import { bracket } from 'lib/exampleKcl' | ||||
| import { TEST_CODE_LONG_WITH_ERROR_OUT_OF_VIEW } from './storageStates' | ||||
| import fsp from 'fs/promises' | ||||
|  | ||||
| test.beforeEach(async ({ context, page }) => { | ||||
|   await setup(context, page) | ||||
| test.beforeEach(async ({ context, page }, testInfo) => { | ||||
|   await setup(context, page, testInfo) | ||||
| }) | ||||
|  | ||||
| test.afterEach(async ({ page }, testInfo) => { | ||||
| @ -65,6 +65,8 @@ const extrude001 = extrude(5, sketch001)` | ||||
|   test('Opening and closing the code pane will consistently show error diagnostics', async ({ | ||||
|     page, | ||||
|   }) => { | ||||
|     await page.goto('http://localhost:3000') | ||||
|  | ||||
|     const u = await getUtils(page) | ||||
|  | ||||
|     // Load the app with the working starter code | ||||
| @ -90,7 +92,7 @@ const extrude001 = extrude(5, sketch001)` | ||||
|  | ||||
|     // Delete a character to break the KCL | ||||
|     await u.openKclCodePanel() | ||||
|     await page.getByText('extrude(').click() | ||||
|     await page.getByText('thickness, bracketLeg1Sketch)').click() | ||||
|     await page.keyboard.press('Backspace') | ||||
|  | ||||
|     // Ensure that a badge appears on the button | ||||
| @ -101,7 +103,7 @@ const extrude001 = extrude(5, sketch001)` | ||||
|  | ||||
|     // error text on hover | ||||
|     await page.hover('.cm-lint-marker-error') | ||||
|     await expect(page.getByText('Unexpected token: |').first()).toBeVisible() | ||||
|     await expect(page.locator('.cm-tooltip').first()).toBeVisible() | ||||
|  | ||||
|     // Close the code pane | ||||
|     await codePaneButton.click() | ||||
| @ -124,7 +126,7 @@ const extrude001 = extrude(5, sketch001)` | ||||
|  | ||||
|     // error text on hover | ||||
|     await page.hover('.cm-lint-marker-error') | ||||
|     await expect(page.getByText('Unexpected token: |').first()).toBeVisible() | ||||
|     await expect(page.locator('.cm-tooltip').first()).toBeVisible() | ||||
|   }) | ||||
|  | ||||
|   test('When error is not in view you can click the badge to scroll to it', async ({ | ||||
|  | ||||
| @ -3,8 +3,8 @@ import { test, expect } from '@playwright/test' | ||||
| import { getUtils, setup, tearDown } from './test-utils' | ||||
| import { KCL_DEFAULT_LENGTH } from 'lib/constants' | ||||
|  | ||||
| test.beforeEach(async ({ context, page }) => { | ||||
|   await setup(context, page) | ||||
| test.beforeEach(async ({ context, page }, testInfo) => { | ||||
|   await setup(context, page, testInfo) | ||||
| }) | ||||
|  | ||||
| test.afterEach(async ({ page }, testInfo) => { | ||||
|  | ||||
| @ -1,8 +1,8 @@ | ||||
| import { test, expect } from '@playwright/test' | ||||
| import { getUtils, setup, tearDown } from './test-utils' | ||||
|  | ||||
| test.beforeEach(async ({ context, page }) => { | ||||
|   await setup(context, page) | ||||
| test.beforeEach(async ({ context, page }, testInfo) => { | ||||
|   await setup(context, page, testInfo) | ||||
| }) | ||||
|  | ||||
| test.afterEach(async ({ page }, testInfo) => { | ||||
|  | ||||
| @ -2,8 +2,8 @@ import { test, expect } from '@playwright/test' | ||||
| import { uuidv4 } from 'lib/utils' | ||||
| import { getUtils, setup, tearDown } from './test-utils' | ||||
|  | ||||
| test.beforeEach(async ({ context, page }) => { | ||||
|   await setup(context, page) | ||||
| test.beforeEach(async ({ context, page }, testInfo) => { | ||||
|   await setup(context, page, testInfo) | ||||
| }) | ||||
|  | ||||
| test.afterEach(async ({ page }, testInfo) => { | ||||
|  | ||||
| @ -1,9 +1,18 @@ | ||||
| import { test, expect } from '@playwright/test' | ||||
| import * as fsp from 'fs/promises' | ||||
| import { getUtils, setup, setupElectron, tearDown } from './test-utils' | ||||
| import * as fs from 'fs' | ||||
| import { | ||||
|   executorInputPath, | ||||
|   getUtils, | ||||
|   setup, | ||||
|   setupElectron, | ||||
|   tearDown, | ||||
| } from './test-utils' | ||||
| import { join } from 'path' | ||||
| import { FILE_EXT } from 'lib/constants' | ||||
|  | ||||
| test.beforeEach(async ({ context, page }) => { | ||||
|   await setup(context, page) | ||||
| test.beforeEach(async ({ context, page }, testInfo) => { | ||||
|   await setup(context, page, testInfo) | ||||
| }) | ||||
|  | ||||
| test.afterEach(async ({ page }, testInfo) => { | ||||
| @ -108,7 +117,6 @@ test.describe('when using the file tree to', () => { | ||||
|     async ({ browser: _ }, testInfo) => { | ||||
|       const { electronApp, page } = await setupElectron({ | ||||
|         testInfo, | ||||
|         folderSetupFn: async () => {}, | ||||
|       }) | ||||
|  | ||||
|       const { | ||||
| @ -151,6 +159,7 @@ test.describe('when using the file tree to', () => { | ||||
|         await selectFile(kcl1) | ||||
|         await editorTextMatches(kclCube) | ||||
|       }) | ||||
|       await page.waitForTimeout(500) | ||||
|  | ||||
|       await test.step(`Postcondition: ${kcl2} still exists with the original content`, async () => { | ||||
|         await selectFile(kcl2) | ||||
| @ -277,3 +286,584 @@ test.describe('when using the file tree to', () => { | ||||
|     } | ||||
|   ) | ||||
| }) | ||||
|  | ||||
| test.describe('Renaming in the file tree', () => { | ||||
|   test( | ||||
|     'A file you have open', | ||||
|     { tag: '@electron' }, | ||||
|     async ({ browser: _ }, testInfo) => { | ||||
|       const { electronApp, page, dir } = await setupElectron({ | ||||
|         testInfo, | ||||
|         folderSetupFn: async (dir) => { | ||||
|           await fsp.mkdir(join(dir, 'Test Project'), { recursive: true }) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('basic_fillet_cube_end.kcl'), | ||||
|             join(dir, 'Test Project', 'main.kcl') | ||||
|           ) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('cylinder.kcl'), | ||||
|             join(dir, 'Test Project', 'fileToRename.kcl') | ||||
|           ) | ||||
|         }, | ||||
|       }) | ||||
|       const u = await getUtils(page) | ||||
|       await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|       page.on('console', console.log) | ||||
|  | ||||
|       // Constants and locators | ||||
|       const projectLink = page.getByText('Test Project') | ||||
|       const projectMenuButton = page.getByTestId('project-sidebar-toggle') | ||||
|       const checkUnRenamedFS = () => { | ||||
|         const filePath = join(dir, 'Test Project', 'fileToRename.kcl') | ||||
|         return fs.existsSync(filePath) | ||||
|       } | ||||
|       const newFileName = 'newFileName' | ||||
|       const checkRenamedFS = () => { | ||||
|         const filePath = join(dir, 'Test Project', `${newFileName}.kcl`) | ||||
|         return fs.existsSync(filePath) | ||||
|       } | ||||
|  | ||||
|       const fileToRename = page | ||||
|         .getByRole('listitem') | ||||
|         .filter({ has: page.getByRole('button', { name: 'fileToRename.kcl' }) }) | ||||
|       const renamedFile = page | ||||
|         .getByRole('listitem') | ||||
|         .filter({ has: page.getByRole('button', { name: 'newFileName.kcl' }) }) | ||||
|       const renameMenuItem = page.getByRole('button', { name: 'Rename' }) | ||||
|       const renameInput = page.getByPlaceholder('fileToRename.kcl') | ||||
|       const codeLocator = page.locator('.cm-content') | ||||
|  | ||||
|       await test.step('Open project and file pane', async () => { | ||||
|         await expect(projectLink).toBeVisible() | ||||
|         await projectLink.click() | ||||
|         await expect(projectMenuButton).toBeVisible() | ||||
|         await expect(projectMenuButton).toContainText('main.kcl') | ||||
|  | ||||
|         await u.openFilePanel() | ||||
|         await expect(fileToRename).toBeVisible() | ||||
|         expect(checkUnRenamedFS()).toBeTruthy() | ||||
|         expect(checkRenamedFS()).toBeFalsy() | ||||
|         await fileToRename.click() | ||||
|         await expect(projectMenuButton).toContainText('fileToRename.kcl') | ||||
|         await u.openKclCodePanel() | ||||
|         await expect(codeLocator).toContainText('circle(') | ||||
|         await u.closeKclCodePanel() | ||||
|       }) | ||||
|  | ||||
|       await test.step('Rename the file', async () => { | ||||
|         await fileToRename.click({ button: 'right' }) | ||||
|         await renameMenuItem.click() | ||||
|         await expect(renameInput).toBeVisible() | ||||
|         await renameInput.fill(newFileName) | ||||
|         await page.keyboard.press('Enter') | ||||
|       }) | ||||
|  | ||||
|       await test.step('Verify the file is renamed', async () => { | ||||
|         await expect(fileToRename).not.toBeAttached() | ||||
|         await expect(renamedFile).toBeVisible() | ||||
|         expect(checkUnRenamedFS()).toBeFalsy() | ||||
|         expect(checkRenamedFS()).toBeTruthy() | ||||
|       }) | ||||
|  | ||||
|       await test.step('Verify we navigated', async () => { | ||||
|         await expect(projectMenuButton).toContainText(newFileName + FILE_EXT) | ||||
|         const url = page.url() | ||||
|         expect(url).toContain(newFileName) | ||||
|         await expect(projectMenuButton).not.toContainText('fileToRename.kcl') | ||||
|         await expect(projectMenuButton).not.toContainText('main.kcl') | ||||
|         expect(url).not.toContain('fileToRename.kcl') | ||||
|         expect(url).not.toContain('main.kcl') | ||||
|  | ||||
|         await u.openKclCodePanel() | ||||
|         await expect(codeLocator).toContainText('circle(') | ||||
|       }) | ||||
|  | ||||
|       await electronApp.close() | ||||
|     } | ||||
|   ) | ||||
|  | ||||
|   test( | ||||
|     'A file you do not have open', | ||||
|     { tag: '@electron' }, | ||||
|     async ({ browser: _ }, testInfo) => { | ||||
|       const { electronApp, page, dir } = await setupElectron({ | ||||
|         testInfo, | ||||
|         folderSetupFn: async (dir) => { | ||||
|           await fsp.mkdir(join(dir, 'Test Project'), { recursive: true }) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('basic_fillet_cube_end.kcl'), | ||||
|             join(dir, 'Test Project', 'main.kcl') | ||||
|           ) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('cylinder.kcl'), | ||||
|             join(dir, 'Test Project', 'fileToRename.kcl') | ||||
|           ) | ||||
|         }, | ||||
|       }) | ||||
|       const u = await getUtils(page) | ||||
|       await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|       page.on('console', console.log) | ||||
|  | ||||
|       // Constants and locators | ||||
|       const newFileName = 'newFileName' | ||||
|       const checkUnRenamedFS = () => { | ||||
|         const filePath = join(dir, 'Test Project', 'fileToRename.kcl') | ||||
|         return fs.existsSync(filePath) | ||||
|       } | ||||
|       const checkRenamedFS = () => { | ||||
|         const filePath = join(dir, 'Test Project', `${newFileName}.kcl`) | ||||
|         return fs.existsSync(filePath) | ||||
|       } | ||||
|       const projectLink = page.getByText('Test Project') | ||||
|       const projectMenuButton = page.getByTestId('project-sidebar-toggle') | ||||
|       const fileToRename = page | ||||
|         .getByRole('listitem') | ||||
|         .filter({ has: page.getByRole('button', { name: 'fileToRename.kcl' }) }) | ||||
|       const renamedFile = page.getByRole('listitem').filter({ | ||||
|         has: page.getByRole('button', { name: newFileName + FILE_EXT }), | ||||
|       }) | ||||
|       const renameMenuItem = page.getByRole('button', { name: 'Rename' }) | ||||
|       const renameInput = page.getByPlaceholder('fileToRename.kcl') | ||||
|       const codeLocator = page.locator('.cm-content') | ||||
|  | ||||
|       await test.step('Open project and file pane', async () => { | ||||
|         await expect(projectLink).toBeVisible() | ||||
|         await projectLink.click() | ||||
|         await expect(projectMenuButton).toBeVisible() | ||||
|         await expect(projectMenuButton).toContainText('main.kcl') | ||||
|  | ||||
|         await u.openFilePanel() | ||||
|         await expect(fileToRename).toBeVisible() | ||||
|         expect(checkUnRenamedFS()).toBeTruthy() | ||||
|         expect(checkRenamedFS()).toBeFalsy() | ||||
|       }) | ||||
|  | ||||
|       await test.step('Rename the file', async () => { | ||||
|         await fileToRename.click({ button: 'right' }) | ||||
|         await renameMenuItem.click() | ||||
|         await expect(renameInput).toBeVisible() | ||||
|         await renameInput.fill(newFileName) | ||||
|         await page.keyboard.press('Enter') | ||||
|       }) | ||||
|  | ||||
|       await test.step('Verify the file is renamed', async () => { | ||||
|         await expect(fileToRename).not.toBeAttached() | ||||
|         await expect(renamedFile).toBeVisible() | ||||
|         expect(checkUnRenamedFS()).toBeFalsy() | ||||
|         expect(checkRenamedFS()).toBeTruthy() | ||||
|       }) | ||||
|  | ||||
|       await test.step('Verify we have not navigated', async () => { | ||||
|         await expect(projectMenuButton).toContainText('main.kcl') | ||||
|         await expect(projectMenuButton).not.toContainText( | ||||
|           newFileName + FILE_EXT | ||||
|         ) | ||||
|         await expect(projectMenuButton).not.toContainText('fileToRename.kcl') | ||||
|  | ||||
|         const url = page.url() | ||||
|         expect(url).toContain('main.kcl') | ||||
|         expect(url).not.toContain(newFileName) | ||||
|         expect(url).not.toContain('fileToRename.kcl') | ||||
|  | ||||
|         await u.openKclCodePanel() | ||||
|         await expect(codeLocator).toContainText('fillet(') | ||||
|       }) | ||||
|  | ||||
|       await electronApp.close() | ||||
|     } | ||||
|   ) | ||||
|  | ||||
|   test( | ||||
|     `A folder you're not inside`, | ||||
|     { tag: '@electron' }, | ||||
|     async ({ browser: _ }, testInfo) => { | ||||
|       const { electronApp, page, dir } = await setupElectron({ | ||||
|         testInfo, | ||||
|         folderSetupFn: async (dir) => { | ||||
|           await fsp.mkdir(join(dir, 'Test Project'), { recursive: true }) | ||||
|           await fsp.mkdir(join(dir, 'Test Project', 'folderToRename'), { | ||||
|             recursive: true, | ||||
|           }) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('basic_fillet_cube_end.kcl'), | ||||
|             join(dir, 'Test Project', 'main.kcl') | ||||
|           ) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('cylinder.kcl'), | ||||
|             join(dir, 'Test Project', 'folderToRename', 'someFileWithin.kcl') | ||||
|           ) | ||||
|         }, | ||||
|       }) | ||||
|  | ||||
|       const u = await getUtils(page) | ||||
|       await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|       page.on('console', console.log) | ||||
|  | ||||
|       // Constants and locators | ||||
|       const projectLink = page.getByText('Test Project') | ||||
|       const projectMenuButton = page.getByTestId('project-sidebar-toggle') | ||||
|       const folderToRename = page.getByRole('button', { | ||||
|         name: 'folderToRename', | ||||
|       }) | ||||
|       const renamedFolder = page.getByRole('button', { name: 'newFolderName' }) | ||||
|       const renameMenuItem = page.getByRole('button', { name: 'Rename' }) | ||||
|       const originalFolderName = 'folderToRename' | ||||
|       const renameInput = page.getByPlaceholder(originalFolderName) | ||||
|       const newFolderName = 'newFolderName' | ||||
|       const checkUnRenamedFolderFS = () => { | ||||
|         const folderPath = join(dir, 'Test Project', originalFolderName) | ||||
|         return fs.existsSync(folderPath) | ||||
|       } | ||||
|       const checkRenamedFolderFS = () => { | ||||
|         const folderPath = join(dir, 'Test Project', newFolderName) | ||||
|         return fs.existsSync(folderPath) | ||||
|       } | ||||
|  | ||||
|       await test.step('Open project and file pane', async () => { | ||||
|         await expect(projectLink).toBeVisible() | ||||
|         await projectLink.click() | ||||
|         await expect(projectMenuButton).toBeVisible() | ||||
|         await expect(projectMenuButton).toContainText('main.kcl') | ||||
|  | ||||
|         const url = page.url() | ||||
|         expect(url).toContain('main.kcl') | ||||
|         expect(url).not.toContain('folderToRename') | ||||
|  | ||||
|         await u.openFilePanel() | ||||
|         await expect(folderToRename).toBeVisible() | ||||
|         expect(checkUnRenamedFolderFS()).toBeTruthy() | ||||
|         expect(checkRenamedFolderFS()).toBeFalsy() | ||||
|       }) | ||||
|  | ||||
|       await test.step('Rename the folder', async () => { | ||||
|         await folderToRename.click({ button: 'right' }) | ||||
|         await expect(renameMenuItem).toBeVisible() | ||||
|         await renameMenuItem.click() | ||||
|         await expect(renameInput).toBeVisible() | ||||
|         await renameInput.fill(newFolderName) | ||||
|         await page.keyboard.press('Enter') | ||||
|       }) | ||||
|  | ||||
|       await test.step('Verify the folder is renamed, and no navigation occurred', async () => { | ||||
|         const url = page.url() | ||||
|         expect(url).toContain('main.kcl') | ||||
|         expect(url).not.toContain('folderToRename') | ||||
|  | ||||
|         await expect(projectMenuButton).toContainText('main.kcl') | ||||
|         await expect(renamedFolder).toBeVisible() | ||||
|         await expect(folderToRename).not.toBeAttached() | ||||
|         expect(checkUnRenamedFolderFS()).toBeFalsy() | ||||
|         expect(checkRenamedFolderFS()).toBeTruthy() | ||||
|       }) | ||||
|  | ||||
|       await electronApp.close() | ||||
|     } | ||||
|   ) | ||||
|  | ||||
|   test( | ||||
|     `A folder you are inside`, | ||||
|     { tag: '@electron' }, | ||||
|     async ({ browser: _ }, testInfo) => { | ||||
|       const { electronApp, page, dir } = await setupElectron({ | ||||
|         testInfo, | ||||
|         folderSetupFn: async (dir) => { | ||||
|           await fsp.mkdir(join(dir, 'Test Project'), { recursive: true }) | ||||
|           await fsp.mkdir(join(dir, 'Test Project', 'folderToRename'), { | ||||
|             recursive: true, | ||||
|           }) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('basic_fillet_cube_end.kcl'), | ||||
|             join(dir, 'Test Project', 'main.kcl') | ||||
|           ) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('cylinder.kcl'), | ||||
|             join(dir, 'Test Project', 'folderToRename', 'someFileWithin.kcl') | ||||
|           ) | ||||
|         }, | ||||
|       }) | ||||
|  | ||||
|       const u = await getUtils(page) | ||||
|       await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|       page.on('console', console.log) | ||||
|  | ||||
|       // Constants and locators | ||||
|       const projectLink = page.getByText('Test Project') | ||||
|       const projectMenuButton = page.getByTestId('project-sidebar-toggle') | ||||
|       const folderToRename = page.getByRole('button', { | ||||
|         name: 'folderToRename', | ||||
|       }) | ||||
|       const renamedFolder = page.getByRole('button', { name: 'newFolderName' }) | ||||
|       const fileWithinFolder = page.getByRole('listitem').filter({ | ||||
|         has: page.getByRole('button', { name: 'someFileWithin.kcl' }), | ||||
|       }) | ||||
|       const renameMenuItem = page.getByRole('button', { name: 'Rename' }) | ||||
|       const originalFolderName = 'folderToRename' | ||||
|       const renameInput = page.getByPlaceholder(originalFolderName) | ||||
|       const newFolderName = 'newFolderName' | ||||
|       const checkUnRenamedFolderFS = () => { | ||||
|         const folderPath = join(dir, 'Test Project', originalFolderName) | ||||
|         return fs.existsSync(folderPath) | ||||
|       } | ||||
|       const checkRenamedFolderFS = () => { | ||||
|         const folderPath = join(dir, 'Test Project', newFolderName) | ||||
|         return fs.existsSync(folderPath) | ||||
|       } | ||||
|  | ||||
|       await test.step('Open project and navigate into folder', async () => { | ||||
|         await expect(projectLink).toBeVisible() | ||||
|         await projectLink.click() | ||||
|         await expect(projectMenuButton).toBeVisible() | ||||
|         await expect(projectMenuButton).toContainText('main.kcl') | ||||
|  | ||||
|         const url = page.url() | ||||
|         expect(url).toContain('main.kcl') | ||||
|         expect(url).not.toContain('folderToRename') | ||||
|  | ||||
|         await u.openFilePanel() | ||||
|         await expect(folderToRename).toBeVisible() | ||||
|         await folderToRename.click() | ||||
|         await expect(fileWithinFolder).toBeVisible() | ||||
|         await fileWithinFolder.click() | ||||
|  | ||||
|         await expect(projectMenuButton).toContainText('someFileWithin.kcl') | ||||
|         const newUrl = page.url() | ||||
|         expect(newUrl).toContain('folderToRename') | ||||
|         expect(newUrl).toContain('someFileWithin.kcl') | ||||
|         expect(newUrl).not.toContain('main.kcl') | ||||
|         expect(checkUnRenamedFolderFS()).toBeTruthy() | ||||
|         expect(checkRenamedFolderFS()).toBeFalsy() | ||||
|       }) | ||||
|  | ||||
|       await test.step('Rename the folder', async () => { | ||||
|         await page.waitForTimeout(60000) | ||||
|         await folderToRename.click({ button: 'right' }) | ||||
|         await expect(renameMenuItem).toBeVisible() | ||||
|         await renameMenuItem.click() | ||||
|         await expect(renameInput).toBeVisible() | ||||
|         await renameInput.fill(newFolderName) | ||||
|         await page.keyboard.press('Enter') | ||||
|       }) | ||||
|  | ||||
|       await test.step('Verify the folder is renamed, and navigated to new path', async () => { | ||||
|         const urlSnippet = encodeURIComponent( | ||||
|           join(newFolderName, 'someFileWithin.kcl') | ||||
|         ) | ||||
|         await page.waitForURL(new RegExp(urlSnippet)) | ||||
|         await expect(projectMenuButton).toContainText('someFileWithin.kcl') | ||||
|         await expect(renamedFolder).toBeVisible() | ||||
|         await expect(folderToRename).not.toBeAttached() | ||||
|  | ||||
|         // URL is synchronous, so we check the other stuff first | ||||
|         const url = page.url() | ||||
|         expect(url).not.toContain('main.kcl') | ||||
|         expect(url).toContain(newFolderName) | ||||
|         expect(url).toContain('someFileWithin.kcl') | ||||
|         expect(checkUnRenamedFolderFS()).toBeFalsy() | ||||
|         expect(checkRenamedFolderFS()).toBeTruthy() | ||||
|       }) | ||||
|  | ||||
|       await electronApp.close() | ||||
|     } | ||||
|   ) | ||||
| }) | ||||
|  | ||||
| test.describe('Deleting items from the file pane', () => { | ||||
|   test( | ||||
|     `delete file when main.kcl exists, navigate to main.kcl`, | ||||
|     { tag: '@electron' }, | ||||
|     async ({ browserName }, testInfo) => { | ||||
|       const { electronApp, page } = await setupElectron({ | ||||
|         testInfo, | ||||
|         folderSetupFn: async (dir) => { | ||||
|           const testDir = join(dir, 'testProject') | ||||
|           await fsp.mkdir(testDir, { recursive: true }) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('cylinder.kcl'), | ||||
|             join(testDir, 'main.kcl') | ||||
|           ) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('basic_fillet_cube_end.kcl'), | ||||
|             join(testDir, 'fileToDelete.kcl') | ||||
|           ) | ||||
|         }, | ||||
|       }) | ||||
|       const u = await getUtils(page) | ||||
|       await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|       page.on('console', console.log) | ||||
|  | ||||
|       // Constants and locators | ||||
|       const projectCard = page.getByText('testProject') | ||||
|       const projectMenuButton = page.getByTestId('project-sidebar-toggle') | ||||
|       const fileToDelete = page | ||||
|         .getByRole('listitem') | ||||
|         .filter({ has: page.getByRole('button', { name: 'fileToDelete.kcl' }) }) | ||||
|       const deleteMenuItem = page.getByRole('button', { name: 'Delete' }) | ||||
|       const deleteConfirmation = page.getByTestId('delete-confirmation') | ||||
|  | ||||
|       await test.step('Open project and navigate to fileToDelete.kcl', async () => { | ||||
|         await projectCard.click() | ||||
|         await u.waitForPageLoad() | ||||
|         await u.openFilePanel() | ||||
|  | ||||
|         await fileToDelete.click() | ||||
|         await u.waitForPageLoad() | ||||
|         await u.openKclCodePanel() | ||||
|         await expect(u.codeLocator).toContainText('getOppositeEdge(thing)') | ||||
|         await u.closeKclCodePanel() | ||||
|       }) | ||||
|  | ||||
|       await test.step('Delete fileToDelete.kcl', async () => { | ||||
|         await fileToDelete.click({ button: 'right' }) | ||||
|         await expect(deleteMenuItem).toBeVisible() | ||||
|         await deleteMenuItem.click() | ||||
|         await expect(deleteConfirmation).toBeVisible() | ||||
|         await deleteConfirmation.click() | ||||
|       }) | ||||
|  | ||||
|       await test.step('Check deletion and navigation', async () => { | ||||
|         await u.waitForPageLoad() | ||||
|         await expect(fileToDelete).not.toBeVisible() | ||||
|         await u.closeFilePanel() | ||||
|         await u.openKclCodePanel() | ||||
|         await expect(u.codeLocator).toContainText('circle(') | ||||
|         await expect(projectMenuButton).toContainText('main.kcl') | ||||
|       }) | ||||
|  | ||||
|       await electronApp.close() | ||||
|     } | ||||
|   ) | ||||
|  | ||||
|   test.fixme( | ||||
|     'TODO - delete file we have open when main.kcl does not exist', | ||||
|     async () => {} | ||||
|   ) | ||||
|  | ||||
|   test( | ||||
|     `Delete folder we are not in, don't navigate`, | ||||
|     { tag: '@electron' }, | ||||
|     async ({ browserName }, testInfo) => { | ||||
|       const { electronApp, page } = await setupElectron({ | ||||
|         testInfo, | ||||
|         folderSetupFn: async (dir) => { | ||||
|           await fsp.mkdir(join(dir, 'Test Project'), { recursive: true }) | ||||
|           await fsp.mkdir(join(dir, 'Test Project', 'folderToDelete'), { | ||||
|             recursive: true, | ||||
|           }) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('basic_fillet_cube_end.kcl'), | ||||
|             join(dir, 'Test Project', 'main.kcl') | ||||
|           ) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('cylinder.kcl'), | ||||
|             join(dir, 'Test Project', 'folderToDelete', 'someFileWithin.kcl') | ||||
|           ) | ||||
|         }, | ||||
|       }) | ||||
|       const u = await getUtils(page) | ||||
|       await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|       page.on('console', console.log) | ||||
|  | ||||
|       // Constants and locators | ||||
|       const projectCard = page.getByText('Test Project') | ||||
|       const projectMenuButton = page.getByTestId('project-sidebar-toggle') | ||||
|       const folderToDelete = page.getByRole('button', { | ||||
|         name: 'folderToDelete', | ||||
|       }) | ||||
|       const deleteMenuItem = page.getByRole('button', { name: 'Delete' }) | ||||
|       const deleteConfirmation = page.getByTestId('delete-confirmation') | ||||
|  | ||||
|       await test.step('Open project and open project pane', async () => { | ||||
|         await projectCard.click() | ||||
|         await u.waitForPageLoad() | ||||
|         await expect(projectMenuButton).toContainText('main.kcl') | ||||
|         await u.closeKclCodePanel() | ||||
|         await u.openFilePanel() | ||||
|       }) | ||||
|  | ||||
|       await test.step('Delete folderToDelete', async () => { | ||||
|         await folderToDelete.click({ button: 'right' }) | ||||
|         await expect(deleteMenuItem).toBeVisible() | ||||
|         await deleteMenuItem.click() | ||||
|         await expect(deleteConfirmation).toBeVisible() | ||||
|         await deleteConfirmation.click() | ||||
|       }) | ||||
|  | ||||
|       await test.step('Check deletion and no navigation', async () => { | ||||
|         await expect(folderToDelete).not.toBeAttached() | ||||
|         await expect(projectMenuButton).toContainText('main.kcl') | ||||
|       }) | ||||
|  | ||||
|       await electronApp.close() | ||||
|     } | ||||
|   ) | ||||
|  | ||||
|   test( | ||||
|     `Delete folder we are in, navigate to main.kcl`, | ||||
|     { tag: '@electron' }, | ||||
|     async ({ browserName }, testInfo) => { | ||||
|       const { electronApp, page } = await setupElectron({ | ||||
|         testInfo, | ||||
|         folderSetupFn: async (dir) => { | ||||
|           await fsp.mkdir(join(dir, 'Test Project'), { recursive: true }) | ||||
|           await fsp.mkdir(join(dir, 'Test Project', 'folderToDelete'), { | ||||
|             recursive: true, | ||||
|           }) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('basic_fillet_cube_end.kcl'), | ||||
|             join(dir, 'Test Project', 'main.kcl') | ||||
|           ) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('cylinder.kcl'), | ||||
|             join(dir, 'Test Project', 'folderToDelete', 'someFileWithin.kcl') | ||||
|           ) | ||||
|         }, | ||||
|       }) | ||||
|       const u = await getUtils(page) | ||||
|       await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|       page.on('console', console.log) | ||||
|  | ||||
|       // Constants and locators | ||||
|       const projectCard = page.getByText('Test Project') | ||||
|       const projectMenuButton = page.getByTestId('project-sidebar-toggle') | ||||
|       const folderToDelete = page.getByRole('button', { | ||||
|         name: 'folderToDelete', | ||||
|       }) | ||||
|       const fileWithinFolder = page.getByRole('listitem').filter({ | ||||
|         has: page.getByRole('button', { name: 'someFileWithin.kcl' }), | ||||
|       }) | ||||
|       const deleteMenuItem = page.getByRole('button', { name: 'Delete' }) | ||||
|       const deleteConfirmation = page.getByTestId('delete-confirmation') | ||||
|  | ||||
|       await test.step('Open project and navigate into folderToDelete', async () => { | ||||
|         await projectCard.click() | ||||
|         await u.waitForPageLoad() | ||||
|         await expect(projectMenuButton).toContainText('main.kcl') | ||||
|         await u.closeKclCodePanel() | ||||
|         await u.openFilePanel() | ||||
|  | ||||
|         await folderToDelete.click() | ||||
|         await expect(fileWithinFolder).toBeVisible() | ||||
|         await fileWithinFolder.click() | ||||
|         await expect(projectMenuButton).toContainText('someFileWithin.kcl') | ||||
|       }) | ||||
|  | ||||
|       await test.step('Delete folderToDelete', async () => { | ||||
|         await folderToDelete.click({ button: 'right' }) | ||||
|         await expect(deleteMenuItem).toBeVisible() | ||||
|         await deleteMenuItem.click() | ||||
|         await expect(deleteConfirmation).toBeVisible() | ||||
|         await deleteConfirmation.click() | ||||
|       }) | ||||
|  | ||||
|       await test.step('Check deletion and navigation to main.kcl', async () => { | ||||
|         await expect(folderToDelete).not.toBeAttached() | ||||
|         await expect(fileWithinFolder).not.toBeAttached() | ||||
|         await expect(projectMenuButton).toContainText('main.kcl') | ||||
|       }) | ||||
|  | ||||
|       await electronApp.close() | ||||
|     } | ||||
|   ) | ||||
|  | ||||
|   test.fixme('TODO - delete folder we are in, with no main.kcl', async () => {}) | ||||
| }) | ||||
|  | ||||
							
								
								
									
										270
									
								
								e2e/playwright/lib/console-error-whitelist.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,270 @@ | ||||
| export const isErrorWhitelisted = (exception: Error) => { | ||||
|   // due to the way webkit/Google Chrome report errors, it was necessary | ||||
|   // to whitelist similar errors separately for each project | ||||
|   let whitelist: { | ||||
|     name: string | ||||
|     message: string | ||||
|     stack: string | ||||
|     foundInSpec: string | ||||
|     project: 'webkit' | 'Google Chrome' | ||||
|   }[] = [ | ||||
|     { | ||||
|       name: '', | ||||
|       message: 'undefined', | ||||
|       stack: '', | ||||
|       foundInSpec: `e2e/playwright/sketch-tests.spec.ts Existing sketch with bad code delete user's code`, | ||||
|       project: 'Google Chrome', | ||||
|     }, | ||||
|     { | ||||
|       name: '"{"kind"', | ||||
|       message: | ||||
|         '"engine","sourceRanges":[[0,0]],"msg":"Failed to get string from response from engine: `JsValue(undefined)`"}"', | ||||
|       stack: '', | ||||
|       foundInSpec: 'e2e/playwright/testing-settings.spec.ts', | ||||
|       project: 'Google Chrome', | ||||
|     }, | ||||
|     { | ||||
|       name: '', | ||||
|       message: 'false', | ||||
|       stack: '', | ||||
|       foundInSpec: 'e2e/playwright/testing-segment-overlays.spec.ts', | ||||
|       project: 'Google Chrome', | ||||
|     }, | ||||
|     { | ||||
|       name: '{"kind"', | ||||
|       // eslint-disable-next-line no-useless-escape | ||||
|       message: 'no connection to send on', | ||||
|       stack: '', | ||||
|       foundInSpec: 'e2e/playwright/various.spec.ts', | ||||
|       project: 'Google Chrome', | ||||
|     }, | ||||
|     { | ||||
|       name: '', | ||||
|       message: 'sketchGroup not found', | ||||
|       stack: '', | ||||
|       foundInSpec: | ||||
|         'e2e/playwright/testing-selections.spec.ts Deselecting line tool should mean nothing happens on click', | ||||
|       project: 'Google Chrome', | ||||
|     }, | ||||
|     { | ||||
|       name: 'engine error', | ||||
|       message: | ||||
|         '[{"error_code":"bad_request","message":"Cannot set the camera position with these values"}]', | ||||
|       stack: '', | ||||
|       foundInSpec: | ||||
|         'e2e/playwright/can-create-sketches-on-all-planes-and-their-back-sides.spec.ts XY', | ||||
|       project: 'Google Chrome', | ||||
|     }, | ||||
|     { | ||||
|       name: '', | ||||
|       message: 'no connection to send on', | ||||
|       stack: '', | ||||
|       foundInSpec: | ||||
|         'e2e/playwright/can-create-sketches-on-all-planes-and-their-back-sides.spec.ts XY', | ||||
|       project: 'Google Chrome', | ||||
|     }, | ||||
|     { | ||||
|       name: 'RangeError', | ||||
|       message: 'Position 160 is out of range for changeset of length 0', | ||||
|       stack: `RangeError: Position 160 is out of range for changeset of length 0 | ||||
|     at _ChangeSet.mapPos (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:756:13) | ||||
|     at findSharedChunks (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:3045:49) | ||||
|     at _RangeSet.compare (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:2840:24) | ||||
|     at findChangedDeco (http://localhost:3000/node_modules/.vite/deps/chunk-IZYF444B.js?v=412eae63:3320:12) | ||||
|     at DocView.update (http://localhost:3000/node_modules/.vite/deps/chunk-IZYF444B.js?v=412eae63:2774:20) | ||||
|     at _EditorView.update (http://localhost:3000/node_modules/.vite/deps/chunk-IZYF444B.js?v=412eae63:7056:30) | ||||
|     at DOMObserver.flush (http://localhost:3000/node_modules/.vite/deps/chunk-IZYF444B.js?v=412eae63:6621:17) | ||||
|     at MutationObserver.<anonymous> (http://localhost:3000/node_modules/.vite/deps/chunk-IZYF444B.js?v=412eae63:6322:14)`, | ||||
|       foundInSpec: 'e2e/playwright/editor-tests.spec.ts fold gutters work', | ||||
|       project: 'Google Chrome', | ||||
|     }, | ||||
|     { | ||||
|       name: 'RangeError', | ||||
|       message: 'Selection points outside of document', | ||||
|       stack: `RangeError: Selection points outside of document | ||||
|     +     at checkSelection (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:1453:13) | ||||
|     +     at new _Transaction (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:2014:7) | ||||
|     +     at _Transaction.create (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:2022:12) | ||||
|     +     at resolveTransaction (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:2155:24) | ||||
|     +     at _EditorState.update (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:2281:12) | ||||
|     +     at _EditorView.dispatch (http://localhost:3000/node_modules/.vite/deps/chunk-IZYF444B.js?v=412eae63:6988:148) | ||||
|     +     at EditorManager.selectRange (http://localhost:3000/src/editor/manager.ts:182:22) | ||||
|     +     at AST extrude (http://localhost:3000/src/machines/modelingMachine.ts:828:25)`, | ||||
|       foundInSpec: 'e2e/playwright/editor-tests.spec.ts', | ||||
|       project: 'Google Chrome', | ||||
|     }, | ||||
|     { | ||||
|       name: 'Unhandled Promise Rejection', | ||||
|       message: "TypeError: null is not an object (evaluating 'sg.value')", | ||||
|       stack: `Unhandled Promise Rejection: TypeError: null is not an object (evaluating 'sg.value') | ||||
|     at unknown (http://localhost:3000/src/clientSideScene/sceneEntities.ts:466:23) | ||||
|     at unknown (http://localhost:3000/src/clientSideScene/sceneEntities.ts:454:32) | ||||
|     at set up draft line without teardown (http://localhost:3000/src/machines/modelingMachine.ts:983:47) | ||||
|     at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1877:24) | ||||
|     at handleAction (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1064:26) | ||||
|     at processBlock (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1087:36) | ||||
|     at map ([native code]:0:0) | ||||
|     at resolveActions (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1109:49) | ||||
|     at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:3639:37) | ||||
|     at provide (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1117:18) | ||||
|     at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:2452:30) | ||||
|     at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1831:43) | ||||
|     at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1659:17) | ||||
|     at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1643:19) | ||||
|     at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1829:33) | ||||
|     at unknown (http://localhost:3000/src/clientSideScene/sceneEntities.ts:263:19)`, | ||||
|       foundInSpec: `e2e/playwright/testing-camera-movement.spec.ts Zoom should be consistent when exiting or entering sketches`, | ||||
|       project: 'webkit', | ||||
|     }, | ||||
|     { | ||||
|       name: 'Unhandled Promise Rejection', | ||||
|       message: 'false', | ||||
|       stack: `Unhandled Promise Rejection: false | ||||
|     at unknown (http://localhost:3000/src/clientSideScene/ClientSideSceneComp.tsx:455:78)`, | ||||
|       foundInSpec: `e2e/playwright/testing-segment-overlays.spec.ts line-[tagOutsideSketch]`, | ||||
|       project: 'webkit', | ||||
|     }, | ||||
|     { | ||||
|       name: 'Unhandled Promise Rejection', | ||||
|       message: `TypeError: null is not an object (evaluating 'programMemory.get(variableDeclarationName).value')`, | ||||
|       stack: `    +  stack:Unhandled Promise Rejection: TypeError: null is not an object (evaluating 'programMemory.get(variableDeclarationName).value') | ||||
|     +     at unknown (http://localhost:3000/src/machines/modelingMachine.ts:911:49)`, | ||||
|       foundInSpec: `e2e/playwright/can-create-sketches-on-all-planes-and-their-back-sides.spec.ts`, | ||||
|       project: 'webkit', | ||||
|     }, | ||||
|     { | ||||
|       name: 'Unhandled Promise Rejection', | ||||
|       message: `null is not an object (evaluating 'programMemory.get(variableDeclarationName).value')`, | ||||
|       stack: `Unhandled Promise Rejection: TypeError: null is not an object (evaluating 'programMemory.get(variableDeclarationName).value') | ||||
|     at unknown (http://localhost:3000/src/machines/modelingMachine.ts:911:49)`, | ||||
|       foundInSpec: `e2e/playwright/testing-camera-movement.spec.ts Zoom should be consistent when exiting or entering sketches`, | ||||
|       project: 'webkit', | ||||
|     }, | ||||
|     { | ||||
|       name: 'TypeError', | ||||
|       message: `null is not an object (evaluating 'gl.getShaderPrecisionFormat(gl.VERTEX_SHADER, gl.HIGH_FLOAT).precision')`, | ||||
|       stack: `TypeError: null is not an object (evaluating 'gl.getShaderPrecisionFormat(gl.VERTEX_SHADER, gl.HIGH_FLOAT).precision') | ||||
|     at getMaxPrecision (http://localhost:3000/node_modules/.vite/deps/chunk-DEEFU7IG.js?v=d328572b:9557:71) | ||||
|     at WebGLCapabilities (http://localhost:3000/node_modules/.vite/deps/chunk-DEEFU7IG.js?v=d328572b:9570:39) | ||||
|     at initGLContext (http://localhost:3000/node_modules/.vite/deps/chunk-DEEFU7IG.js?v=d328572b:16993:43) | ||||
|     at WebGLRenderer (http://localhost:3000/node_modules/.vite/deps/chunk-DEEFU7IG.js?v=d328572b:17024:18) | ||||
|     at SceneInfra (http://localhost:3000/src/clientSideScene/sceneInfra.ts:185:38) | ||||
|     at module code (http://localhost:3000/src/lib/singletons.ts:14:41)`, | ||||
|       foundInSpec: `e2e/playwright/testing-segment-overlays.spec.ts angledLineToX`, | ||||
|       project: 'webkit', | ||||
|     }, | ||||
|     { | ||||
|       name: 'Unhandled Promise Rejection', | ||||
|       message: | ||||
|         '{"kind":"engine","sourceRanges":[[0,0]],"msg":"Failed to get string from response from engine: `JsValue(undefined)`"}', | ||||
|       stack: `Unhandled Promise Rejection: {"kind":"engine","sourceRanges":[[0,0]],"msg":"Failed to get string from response from engine: \`JsValue(undefined)\`"} | ||||
|     at unknown (http://localhost:3000/src/lang/std/engineConnection.ts:1245:26)`, | ||||
|       foundInSpec: | ||||
|         'e2e/playwright/onboarding-tests.spec.ts Click through each onboarding step', | ||||
|       project: 'webkit', | ||||
|     }, | ||||
|     { | ||||
|       name: 'Unhandled Promise Rejection', | ||||
|       message: 'undefined', | ||||
|       stack: '', | ||||
|       foundInSpec: `e2e/playwright/sketch-tests.spec.ts Existing sketch with bad code delete user's code`, | ||||
|       project: 'webkit', | ||||
|     }, | ||||
|     { | ||||
|       name: 'Fetch API cannot load https', | ||||
|       message: '/api.dev.zoo.dev/logout due to access control checks.', | ||||
|       stack: `Fetch API cannot load https://api.dev.zoo.dev/logout due to access control checks. | ||||
|     at goToSignInPage (http://localhost:3000/src/components/SettingsAuthProvider.tsx:229:15) | ||||
|     at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1877:24) | ||||
|     at handleAction (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1064:26) | ||||
|     at processBlock (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1087:36) | ||||
|     at map (:1:11) | ||||
|     at resolveActions (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1109:49) | ||||
|     at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:3639:37) | ||||
|     at provide (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1117:18) | ||||
|     at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:2452:30) | ||||
|     at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1831:43) | ||||
|     at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1659:17) | ||||
|     at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1643:19) | ||||
|     at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1829:33) | ||||
|     at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:2601:23)`, | ||||
|       foundInSpec: | ||||
|         'e2e/playwright/testing-selections.spec.ts Solids should be select and deletable', | ||||
|       project: 'webkit', | ||||
|     }, | ||||
|     { | ||||
|       name: 'Unhandled Promise Rejection', | ||||
|       message: 'ReferenceError: Cannot access uninitialized variable.', | ||||
|       stack: `Unhandled Promise Rejection: ReferenceError: Cannot access uninitialized variable. | ||||
|     at setDiagnosticsForCurrentErrors (http://localhost:3000/src/lang/KclSingleton.ts:90:18) | ||||
|     at kclErrors (http://localhost:3000/src/lang/KclSingleton.ts:82:40) | ||||
|     at safeParse (http://localhost:3000/src/lang/KclSingleton.ts:150:9) | ||||
|     at unknown (http://localhost:3000/src/lang/KclSingleton.ts:113:32)`, | ||||
|       foundInSpec: | ||||
|         'e2e/playwright/testing-segment-overlays.spec.ts angledLineToX', | ||||
|       project: 'webkit', | ||||
|     }, | ||||
|     { | ||||
|       name: 'Unhandled Promise Rejection', | ||||
|       message: 'sketchGroup not found', | ||||
|       stack: `Unhandled Promise Rejection: sketchGroup not found | ||||
|     at unknown (http://localhost:3000/src/machines/modelingMachine.ts:911:49)`, | ||||
|       foundInSpec: | ||||
|         'e2e/playwright/testing-selections.spec.ts Deselecting line tool should mean nothing happens on click', | ||||
|       project: 'webkit', | ||||
|     }, | ||||
|     { | ||||
|       name: 'Unhandled Promise Rejection', | ||||
|       message: | ||||
|         'engine error: [{"error_code":"bad_request","message":"Cannot set the camera position with these values"}]', | ||||
|       stack: | ||||
|         'Unhandled Promise Rejection: engine error: [{"error_code":"bad_request","message":"Cannot set the camera position with these values"}]', | ||||
|       foundInSpec: | ||||
|         'e2e/playwright/testing-camera-movement.spec.ts Zoom should be consistent when exiting or entering sketches', | ||||
|       project: 'webkit', | ||||
|     }, | ||||
|     { | ||||
|       name: 'SecurityError', | ||||
|       stack: `SecurityError:  Failed to read the 'localStorage' property from 'Window': Access is denied for this document. | ||||
|      at <anonymous>:13:5 | ||||
|      at <anonymous>:18:5 | ||||
|      at <anonymous>:19:7`, | ||||
|       message: `Failed to read the 'localStorage' property from 'Window': Access is denied for this document.`, | ||||
|       project: 'Google Chrome', | ||||
|       foundInSpec: 'e2e/playwright/basic-sketch.spec.ts', | ||||
|     }, | ||||
|     { | ||||
|       name: '  - internal_engine', | ||||
|       stack: ` | ||||
| `, | ||||
|       message: `Nothing to export`, | ||||
|       project: 'Google Chrome', | ||||
|       foundInSpec: 'e2e/playwright/regression-tests.spec.ts', | ||||
|     }, | ||||
|     { | ||||
|       name: 'SyntaxError', | ||||
|       stack: `SyntaxError: Unexpected end of JSON input | ||||
|     at crossPlatformFetch (http://localhost:3000/src/lib/crossPlatformFetch.ts:34:31) | ||||
|     at async sendTelemetry (http://localhost:3000/src/lib/textToCad.ts:179:3)`, | ||||
|       message: `Unexpected end of JSON input`, | ||||
|       project: 'Google Chrome', | ||||
|       foundInSpec: 'e2e/playwright/text-to-cad-tests.spec.ts', | ||||
|     }, | ||||
|     { | ||||
|       name: '{"kind"', | ||||
|       stack: ``, | ||||
|       message: `engine","sourceRanges":[[0,0]],"msg":"Failed to wait for promise from engine: JsValue(\\"Force interrupt, executionIsStale, new AST requested\\")"}`, | ||||
|       project: 'Google Chrome', | ||||
|       foundInSpec: 'e2e/playwright/testing-settings.spec.ts', | ||||
|     }, | ||||
|   ] | ||||
|  | ||||
|   const cleanString = (str: string) => str.replace(/[`"]/g, '') | ||||
|   const foundItem = whitelist.find( | ||||
|     (item) => | ||||
|       cleanString(exception.name) === cleanString(item.name) && | ||||
|       cleanString(exception.message).includes(cleanString(item.message)) | ||||
|   ) | ||||
|  | ||||
|   return foundItem !== undefined | ||||
| } | ||||
| @ -38,7 +38,7 @@ test( | ||||
|     await expect(page.getByText(notFoundText).first()).not.toBeVisible() | ||||
|  | ||||
|     // Find the make button | ||||
|     const makeButton = page.getByRole('button', { name: 'Make' }) | ||||
|     const makeButton = page.getByRole('button', { name: 'Make part' }) | ||||
|     // Make sure the button is visible but disabled | ||||
|     await expect(makeButton).toBeVisible() | ||||
|     await expect(makeButton).toBeDisabled() | ||||
|  | ||||
| @ -12,7 +12,6 @@ import { | ||||
| import fsp from 'fs/promises' | ||||
| import fs from 'fs' | ||||
| import { join } from 'path' | ||||
| import { FILE_EXT } from 'lib/constants' | ||||
|  | ||||
| test.afterEach(async ({ page }, testInfo) => { | ||||
|   await tearDown(page, testInfo) | ||||
| @ -1371,455 +1370,6 @@ test( | ||||
|   } | ||||
| ) | ||||
|  | ||||
| test.describe('Renaming in the file tree', () => { | ||||
|   test( | ||||
|     'A file you have open', | ||||
|     { tag: '@electron' }, | ||||
|     async ({ browser: _ }, testInfo) => { | ||||
|       const { electronApp, page, dir } = await setupElectron({ | ||||
|         testInfo, | ||||
|         folderSetupFn: async (dir) => { | ||||
|           await fsp.mkdir(join(dir, 'Test Project'), { recursive: true }) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('basic_fillet_cube_end.kcl'), | ||||
|             join(dir, 'Test Project', 'main.kcl') | ||||
|           ) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('cylinder.kcl'), | ||||
|             join(dir, 'Test Project', 'fileToRename.kcl') | ||||
|           ) | ||||
|         }, | ||||
|       }) | ||||
|       const u = await getUtils(page) | ||||
|       await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|       page.on('console', console.log) | ||||
|  | ||||
|       // Constants and locators | ||||
|       const projectLink = page.getByText('Test Project') | ||||
|       const projectMenuButton = page.getByTestId('project-sidebar-toggle') | ||||
|       const checkUnRenamedFS = () => { | ||||
|         const filePath = join(dir, 'Test Project', 'fileToRename.kcl') | ||||
|         return fs.existsSync(filePath) | ||||
|       } | ||||
|       const newFileName = 'newFileName' | ||||
|       const checkRenamedFS = () => { | ||||
|         const filePath = join(dir, 'Test Project', `${newFileName}.kcl`) | ||||
|         return fs.existsSync(filePath) | ||||
|       } | ||||
|  | ||||
|       const fileToRename = page | ||||
|         .getByRole('listitem') | ||||
|         .filter({ has: page.getByRole('button', { name: 'fileToRename.kcl' }) }) | ||||
|       const renamedFile = page | ||||
|         .getByRole('listitem') | ||||
|         .filter({ has: page.getByRole('button', { name: 'newFileName.kcl' }) }) | ||||
|       const renameMenuItem = page.getByRole('button', { name: 'Rename' }) | ||||
|       const renameInput = page.getByPlaceholder('fileToRename.kcl') | ||||
|       const codeLocator = page.locator('.cm-content') | ||||
|  | ||||
|       await test.step('Open project and file pane', async () => { | ||||
|         await expect(projectLink).toBeVisible() | ||||
|         await projectLink.click() | ||||
|         await expect(projectMenuButton).toBeVisible() | ||||
|         await expect(projectMenuButton).toContainText('main.kcl') | ||||
|  | ||||
|         await u.openFilePanel() | ||||
|         await expect(fileToRename).toBeVisible() | ||||
|         expect(checkUnRenamedFS()).toBeTruthy() | ||||
|         expect(checkRenamedFS()).toBeFalsy() | ||||
|         await fileToRename.click() | ||||
|         await expect(projectMenuButton).toContainText('fileToRename.kcl') | ||||
|         await u.openKclCodePanel() | ||||
|         await expect(codeLocator).toContainText('circle(') | ||||
|         await u.closeKclCodePanel() | ||||
|       }) | ||||
|  | ||||
|       await test.step('Rename the file', async () => { | ||||
|         await fileToRename.click({ button: 'right' }) | ||||
|         await renameMenuItem.click() | ||||
|         await expect(renameInput).toBeVisible() | ||||
|         await renameInput.fill(newFileName) | ||||
|         await page.keyboard.press('Enter') | ||||
|       }) | ||||
|  | ||||
|       await test.step('Verify the file is renamed', async () => { | ||||
|         await expect(fileToRename).not.toBeAttached() | ||||
|         await expect(renamedFile).toBeVisible() | ||||
|         expect(checkUnRenamedFS()).toBeFalsy() | ||||
|         expect(checkRenamedFS()).toBeTruthy() | ||||
|       }) | ||||
|  | ||||
|       await test.step('Verify we navigated', async () => { | ||||
|         await expect(projectMenuButton).toContainText(newFileName + FILE_EXT) | ||||
|         const url = page.url() | ||||
|         expect(url).toContain(newFileName) | ||||
|         await expect(projectMenuButton).not.toContainText('fileToRename.kcl') | ||||
|         await expect(projectMenuButton).not.toContainText('main.kcl') | ||||
|         expect(url).not.toContain('fileToRename.kcl') | ||||
|         expect(url).not.toContain('main.kcl') | ||||
|  | ||||
|         await u.openKclCodePanel() | ||||
|         await expect(codeLocator).toContainText('circle(') | ||||
|       }) | ||||
|  | ||||
|       await electronApp.close() | ||||
|     } | ||||
|   ) | ||||
|  | ||||
|   test( | ||||
|     'A file you do not have open', | ||||
|     { tag: '@electron' }, | ||||
|     async ({ browser: _ }, testInfo) => { | ||||
|       const { electronApp, page, dir } = await setupElectron({ | ||||
|         testInfo, | ||||
|         folderSetupFn: async (dir) => { | ||||
|           await fsp.mkdir(join(dir, 'Test Project'), { recursive: true }) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('basic_fillet_cube_end.kcl'), | ||||
|             join(dir, 'Test Project', 'main.kcl') | ||||
|           ) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('cylinder.kcl'), | ||||
|             join(dir, 'Test Project', 'fileToRename.kcl') | ||||
|           ) | ||||
|         }, | ||||
|       }) | ||||
|       const u = await getUtils(page) | ||||
|       await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|       page.on('console', console.log) | ||||
|  | ||||
|       // Constants and locators | ||||
|       const newFileName = 'newFileName' | ||||
|       const checkUnRenamedFS = () => { | ||||
|         const filePath = join(dir, 'Test Project', 'fileToRename.kcl') | ||||
|         return fs.existsSync(filePath) | ||||
|       } | ||||
|       const checkRenamedFS = () => { | ||||
|         const filePath = join(dir, 'Test Project', `${newFileName}.kcl`) | ||||
|         return fs.existsSync(filePath) | ||||
|       } | ||||
|       const projectLink = page.getByText('Test Project') | ||||
|       const projectMenuButton = page.getByTestId('project-sidebar-toggle') | ||||
|       const fileToRename = page | ||||
|         .getByRole('listitem') | ||||
|         .filter({ has: page.getByRole('button', { name: 'fileToRename.kcl' }) }) | ||||
|       const renamedFile = page.getByRole('listitem').filter({ | ||||
|         has: page.getByRole('button', { name: newFileName + FILE_EXT }), | ||||
|       }) | ||||
|       const renameMenuItem = page.getByRole('button', { name: 'Rename' }) | ||||
|       const renameInput = page.getByPlaceholder('fileToRename.kcl') | ||||
|       const codeLocator = page.locator('.cm-content') | ||||
|  | ||||
|       await test.step('Open project and file pane', async () => { | ||||
|         await expect(projectLink).toBeVisible() | ||||
|         await projectLink.click() | ||||
|         await expect(projectMenuButton).toBeVisible() | ||||
|         await expect(projectMenuButton).toContainText('main.kcl') | ||||
|  | ||||
|         await u.openFilePanel() | ||||
|         await expect(fileToRename).toBeVisible() | ||||
|         expect(checkUnRenamedFS()).toBeTruthy() | ||||
|         expect(checkRenamedFS()).toBeFalsy() | ||||
|       }) | ||||
|  | ||||
|       await test.step('Rename the file', async () => { | ||||
|         await fileToRename.click({ button: 'right' }) | ||||
|         await renameMenuItem.click() | ||||
|         await expect(renameInput).toBeVisible() | ||||
|         await renameInput.fill(newFileName) | ||||
|         await page.keyboard.press('Enter') | ||||
|       }) | ||||
|  | ||||
|       await test.step('Verify the file is renamed', async () => { | ||||
|         await expect(fileToRename).not.toBeAttached() | ||||
|         await expect(renamedFile).toBeVisible() | ||||
|         expect(checkUnRenamedFS()).toBeFalsy() | ||||
|         expect(checkRenamedFS()).toBeTruthy() | ||||
|       }) | ||||
|  | ||||
|       await test.step('Verify we have not navigated', async () => { | ||||
|         await expect(projectMenuButton).toContainText('main.kcl') | ||||
|         await expect(projectMenuButton).not.toContainText( | ||||
|           newFileName + FILE_EXT | ||||
|         ) | ||||
|         await expect(projectMenuButton).not.toContainText('fileToRename.kcl') | ||||
|  | ||||
|         const url = page.url() | ||||
|         expect(url).toContain('main.kcl') | ||||
|         expect(url).not.toContain(newFileName) | ||||
|         expect(url).not.toContain('fileToRename.kcl') | ||||
|  | ||||
|         await u.openKclCodePanel() | ||||
|         await expect(codeLocator).toContainText('fillet(') | ||||
|       }) | ||||
|  | ||||
|       await electronApp.close() | ||||
|     } | ||||
|   ) | ||||
|  | ||||
|   test( | ||||
|     `A folder you're not inside`, | ||||
|     { tag: '@electron' }, | ||||
|     async ({ browser: _ }, testInfo) => { | ||||
|       const { electronApp, page, dir } = await setupElectron({ | ||||
|         testInfo, | ||||
|         folderSetupFn: async (dir) => { | ||||
|           await fsp.mkdir(join(dir, 'Test Project'), { recursive: true }) | ||||
|           await fsp.mkdir(join(dir, 'Test Project', 'folderToRename'), { | ||||
|             recursive: true, | ||||
|           }) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('basic_fillet_cube_end.kcl'), | ||||
|             join(dir, 'Test Project', 'main.kcl') | ||||
|           ) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('cylinder.kcl'), | ||||
|             join(dir, 'Test Project', 'folderToRename', 'someFileWithin.kcl') | ||||
|           ) | ||||
|         }, | ||||
|       }) | ||||
|  | ||||
|       const u = await getUtils(page) | ||||
|       await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|       page.on('console', console.log) | ||||
|  | ||||
|       // Constants and locators | ||||
|       const projectLink = page.getByText('Test Project') | ||||
|       const projectMenuButton = page.getByTestId('project-sidebar-toggle') | ||||
|       const folderToRename = page.getByRole('button', { | ||||
|         name: 'folderToRename', | ||||
|       }) | ||||
|       const renamedFolder = page.getByRole('button', { name: 'newFolderName' }) | ||||
|       const renameMenuItem = page.getByRole('button', { name: 'Rename' }) | ||||
|       const originalFolderName = 'folderToRename' | ||||
|       const renameInput = page.getByPlaceholder(originalFolderName) | ||||
|       const newFolderName = 'newFolderName' | ||||
|       const checkUnRenamedFolderFS = () => { | ||||
|         const folderPath = join(dir, 'Test Project', originalFolderName) | ||||
|         return fs.existsSync(folderPath) | ||||
|       } | ||||
|       const checkRenamedFolderFS = () => { | ||||
|         const folderPath = join(dir, 'Test Project', newFolderName) | ||||
|         return fs.existsSync(folderPath) | ||||
|       } | ||||
|  | ||||
|       await test.step('Open project and file pane', async () => { | ||||
|         await expect(projectLink).toBeVisible() | ||||
|         await projectLink.click() | ||||
|         await expect(projectMenuButton).toBeVisible() | ||||
|         await expect(projectMenuButton).toContainText('main.kcl') | ||||
|  | ||||
|         const url = page.url() | ||||
|         expect(url).toContain('main.kcl') | ||||
|         expect(url).not.toContain('folderToRename') | ||||
|  | ||||
|         await u.openFilePanel() | ||||
|         await expect(folderToRename).toBeVisible() | ||||
|         expect(checkUnRenamedFolderFS()).toBeTruthy() | ||||
|         expect(checkRenamedFolderFS()).toBeFalsy() | ||||
|       }) | ||||
|  | ||||
|       await test.step('Rename the folder', async () => { | ||||
|         await folderToRename.click({ button: 'right' }) | ||||
|         await expect(renameMenuItem).toBeVisible() | ||||
|         await renameMenuItem.click() | ||||
|         await expect(renameInput).toBeVisible() | ||||
|         await renameInput.fill(newFolderName) | ||||
|         await page.keyboard.press('Enter') | ||||
|       }) | ||||
|  | ||||
|       await test.step('Verify the folder is renamed, and no navigation occurred', async () => { | ||||
|         const url = page.url() | ||||
|         expect(url).toContain('main.kcl') | ||||
|         expect(url).not.toContain('folderToRename') | ||||
|  | ||||
|         await expect(projectMenuButton).toContainText('main.kcl') | ||||
|         await expect(renamedFolder).toBeVisible() | ||||
|         await expect(folderToRename).not.toBeAttached() | ||||
|         expect(checkUnRenamedFolderFS()).toBeFalsy() | ||||
|         expect(checkRenamedFolderFS()).toBeTruthy() | ||||
|       }) | ||||
|  | ||||
|       await electronApp.close() | ||||
|     } | ||||
|   ) | ||||
|  | ||||
|   test( | ||||
|     `A folder you are inside`, | ||||
|     { tag: '@electron' }, | ||||
|     async ({ browser: _ }, testInfo) => { | ||||
|       const { electronApp, page, dir } = await setupElectron({ | ||||
|         testInfo, | ||||
|         folderSetupFn: async (dir) => { | ||||
|           await fsp.mkdir(join(dir, 'Test Project'), { recursive: true }) | ||||
|           await fsp.mkdir(join(dir, 'Test Project', 'folderToRename'), { | ||||
|             recursive: true, | ||||
|           }) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('basic_fillet_cube_end.kcl'), | ||||
|             join(dir, 'Test Project', 'main.kcl') | ||||
|           ) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('cylinder.kcl'), | ||||
|             join(dir, 'Test Project', 'folderToRename', 'someFileWithin.kcl') | ||||
|           ) | ||||
|         }, | ||||
|       }) | ||||
|  | ||||
|       const u = await getUtils(page) | ||||
|       await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|       page.on('console', console.log) | ||||
|  | ||||
|       // Constants and locators | ||||
|       const projectLink = page.getByText('Test Project') | ||||
|       const projectMenuButton = page.getByTestId('project-sidebar-toggle') | ||||
|       const folderToRename = page.getByRole('button', { | ||||
|         name: 'folderToRename', | ||||
|       }) | ||||
|       const renamedFolder = page.getByRole('button', { name: 'newFolderName' }) | ||||
|       const fileWithinFolder = page.getByRole('listitem').filter({ | ||||
|         has: page.getByRole('button', { name: 'someFileWithin.kcl' }), | ||||
|       }) | ||||
|       const renameMenuItem = page.getByRole('button', { name: 'Rename' }) | ||||
|       const originalFolderName = 'folderToRename' | ||||
|       const renameInput = page.getByPlaceholder(originalFolderName) | ||||
|       const newFolderName = 'newFolderName' | ||||
|       const checkUnRenamedFolderFS = () => { | ||||
|         const folderPath = join(dir, 'Test Project', originalFolderName) | ||||
|         return fs.existsSync(folderPath) | ||||
|       } | ||||
|       const checkRenamedFolderFS = () => { | ||||
|         const folderPath = join(dir, 'Test Project', newFolderName) | ||||
|         return fs.existsSync(folderPath) | ||||
|       } | ||||
|  | ||||
|       await test.step('Open project and navigate into folder', async () => { | ||||
|         await expect(projectLink).toBeVisible() | ||||
|         await projectLink.click() | ||||
|         await expect(projectMenuButton).toBeVisible() | ||||
|         await expect(projectMenuButton).toContainText('main.kcl') | ||||
|  | ||||
|         const url = page.url() | ||||
|         expect(url).toContain('main.kcl') | ||||
|         expect(url).not.toContain('folderToRename') | ||||
|  | ||||
|         await u.openFilePanel() | ||||
|         await expect(folderToRename).toBeVisible() | ||||
|         await folderToRename.click() | ||||
|         await expect(fileWithinFolder).toBeVisible() | ||||
|         await fileWithinFolder.click() | ||||
|  | ||||
|         await expect(projectMenuButton).toContainText('someFileWithin.kcl') | ||||
|         const newUrl = page.url() | ||||
|         expect(newUrl).toContain('folderToRename') | ||||
|         expect(newUrl).toContain('someFileWithin.kcl') | ||||
|         expect(newUrl).not.toContain('main.kcl') | ||||
|         expect(checkUnRenamedFolderFS()).toBeTruthy() | ||||
|         expect(checkRenamedFolderFS()).toBeFalsy() | ||||
|       }) | ||||
|  | ||||
|       await test.step('Rename the folder', async () => { | ||||
|         await page.waitForTimeout(2000) | ||||
|         await folderToRename.click({ button: 'right' }) | ||||
|         await expect(renameMenuItem).toBeVisible() | ||||
|         await renameMenuItem.click() | ||||
|         await expect(renameInput).toBeVisible() | ||||
|         await renameInput.fill(newFolderName) | ||||
|         await page.keyboard.press('Enter') | ||||
|       }) | ||||
|  | ||||
|       await test.step('Verify the folder is renamed, and navigated to new path', async () => { | ||||
|         const urlSnippet = encodeURIComponent( | ||||
|           join(newFolderName, 'someFileWithin.kcl') | ||||
|         ) | ||||
|         await page.waitForURL(new RegExp(urlSnippet)) | ||||
|         await expect(projectMenuButton).toContainText('someFileWithin.kcl') | ||||
|         await expect(renamedFolder).toBeVisible() | ||||
|         await expect(folderToRename).not.toBeAttached() | ||||
|  | ||||
|         // URL is synchronous, so we check the other stuff first | ||||
|         const url = page.url() | ||||
|         expect(url).not.toContain('main.kcl') | ||||
|         expect(url).toContain(newFolderName) | ||||
|         expect(url).toContain('someFileWithin.kcl') | ||||
|         expect(checkUnRenamedFolderFS()).toBeFalsy() | ||||
|         expect(checkRenamedFolderFS()).toBeTruthy() | ||||
|       }) | ||||
|  | ||||
|       await electronApp.close() | ||||
|     } | ||||
|   ) | ||||
| }) | ||||
|  | ||||
| test.describe('Deleting files from the file pane', () => { | ||||
|   test( | ||||
|     `when main.kcl exists, navigate to main.kcl`, | ||||
|     { tag: '@electron' }, | ||||
|     async ({ browserName }, testInfo) => { | ||||
|       const { electronApp, page } = await setupElectron({ | ||||
|         testInfo, | ||||
|         folderSetupFn: async (dir) => { | ||||
|           const testDir = join(dir, 'testProject') | ||||
|           await fsp.mkdir(testDir, { recursive: true }) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('cylinder.kcl'), | ||||
|             join(testDir, 'main.kcl') | ||||
|           ) | ||||
|           await fsp.copyFile( | ||||
|             executorInputPath('basic_fillet_cube_end.kcl'), | ||||
|             join(testDir, 'fileToDelete.kcl') | ||||
|           ) | ||||
|         }, | ||||
|       }) | ||||
|       const u = await getUtils(page) | ||||
|       await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|       page.on('console', console.log) | ||||
|  | ||||
|       // Constants and locators | ||||
|       const projectCard = page.getByText('testProject') | ||||
|       const projectMenuButton = page.getByTestId('project-sidebar-toggle') | ||||
|       const fileToDelete = page | ||||
|         .getByRole('listitem') | ||||
|         .filter({ has: page.getByRole('button', { name: 'fileToDelete.kcl' }) }) | ||||
|       const deleteMenuItem = page.getByRole('button', { name: 'Delete' }) | ||||
|       const deleteConfirmation = page.getByTestId('delete-confirmation') | ||||
|  | ||||
|       await test.step('Open project and navigate to fileToDelete.kcl', async () => { | ||||
|         await projectCard.click() | ||||
|         await u.waitForPageLoad() | ||||
|         await u.openFilePanel() | ||||
|  | ||||
|         await fileToDelete.click() | ||||
|         await u.waitForPageLoad() | ||||
|         await u.openKclCodePanel() | ||||
|         await expect(u.codeLocator).toContainText('getOppositeEdge(thing)') | ||||
|         await u.closeKclCodePanel() | ||||
|       }) | ||||
|  | ||||
|       await test.step('Delete fileToDelete.kcl', async () => { | ||||
|         await fileToDelete.click({ button: 'right' }) | ||||
|         await expect(deleteMenuItem).toBeVisible() | ||||
|         await deleteMenuItem.click() | ||||
|         await expect(deleteConfirmation).toBeVisible() | ||||
|         await deleteConfirmation.click() | ||||
|       }) | ||||
|  | ||||
|       await test.step('Check deletion and navigation', async () => { | ||||
|         await u.waitForPageLoad() | ||||
|         await expect(fileToDelete).not.toBeVisible() | ||||
|         await u.closeFilePanel() | ||||
|         await u.openKclCodePanel() | ||||
|         await expect(u.codeLocator).toContainText('circle(') | ||||
|         await expect(projectMenuButton).toContainText('main.kcl') | ||||
|       }) | ||||
|  | ||||
|       await electronApp.close() | ||||
|     } | ||||
|   ) | ||||
|  | ||||
|   test.fixme('TODO - when main.kcl does not exist', async () => {}) | ||||
| }) | ||||
|  | ||||
| test( | ||||
|   'Original project name persist after onboarding', | ||||
|   { tag: '@electron' }, | ||||
|  | ||||
| @ -11,8 +11,8 @@ import { | ||||
| import { TEST_CODE_TRIGGER_ENGINE_EXPORT_ERROR } from './storageStates' | ||||
| import { bracket } from 'lib/exampleKcl' | ||||
|  | ||||
| test.beforeEach(async ({ context, page }) => { | ||||
|   await setup(context, page) | ||||
| test.beforeEach(async ({ context, page }, testInfo) => { | ||||
|   await setup(context, page, testInfo) | ||||
| }) | ||||
|  | ||||
| test.afterEach(async ({ page }, testInfo) => { | ||||
| @ -54,6 +54,67 @@ const sketch001 = startSketchAt([-0, -0]) | ||||
|     const crypticErrorText = `ApiError` | ||||
|     await expect(page.getByText(crypticErrorText).first()).toBeVisible() | ||||
|   }) | ||||
|   test('user should not have to press down twice in cmdbar', async ({ | ||||
|     page, | ||||
|   }) => { | ||||
|     // because the model has `line([0,0]..` it is valid code, but the model is invalid | ||||
|     // regression test for https://github.com/KittyCAD/modeling-app/issues/3251 | ||||
|     // Since the bad model also found as issue with the artifact graph, which in tern blocked the editor diognostics | ||||
|     const u = await getUtils(page) | ||||
|     await page.addInitScript(async () => { | ||||
|       localStorage.setItem( | ||||
|         'persistCode', | ||||
|         `const sketch2 = startSketchOn("XY") | ||||
| const sketch001 = startSketchAt([-0, -0]) | ||||
|   |> line([0, 0], %) | ||||
|   |> line([-4.84, -5.29], %) | ||||
|   |> lineTo([profileStartX(%), profileStartY(%)], %) | ||||
|   |> close(%)` | ||||
|       ) | ||||
|     }) | ||||
|  | ||||
|     await page.setViewportSize({ width: 1000, height: 500 }) | ||||
|  | ||||
|     await page.goto('/') | ||||
|     await u.waitForPageLoad() | ||||
|  | ||||
|     await test.step('Check arrow down works', async () => { | ||||
|       await page.getByTestId('command-bar-open-button').click() | ||||
|  | ||||
|       await page | ||||
|         .getByRole('option', { name: 'floppy disk arrow Export' }) | ||||
|         .click() | ||||
|  | ||||
|       // press arrow down key twice | ||||
|       await page.keyboard.press('ArrowDown') | ||||
|       await page.waitForTimeout(100) | ||||
|       await page.keyboard.press('ArrowDown') | ||||
|  | ||||
|       // STL is the third option, which makes sense for two arrow downs | ||||
|       await expect(page.locator('[data-headlessui-state="active"]')).toHaveText( | ||||
|         'STL' | ||||
|       ) | ||||
|  | ||||
|       await page.keyboard.press('Escape') | ||||
|       await page.waitForTimeout(200) | ||||
|       await page.keyboard.press('Escape') | ||||
|       await page.waitForTimeout(200) | ||||
|     }) | ||||
|  | ||||
|     await test.step('Check arrow up works', async () => { | ||||
|       // theme in test is dark, which is the second option, which means we can test arrow up | ||||
|       await page.getByTestId('command-bar-open-button').click() | ||||
|  | ||||
|       await page.getByText('The overall appearance of the').click() | ||||
|  | ||||
|       await page.keyboard.press('ArrowUp') | ||||
|       await page.waitForTimeout(100) | ||||
|  | ||||
|       await expect(page.locator('[data-headlessui-state="active"]')).toHaveText( | ||||
|         'light' | ||||
|       ) | ||||
|     }) | ||||
|   }) | ||||
|   test('executes on load', async ({ page }) => { | ||||
|     const u = await getUtils(page) | ||||
|     await page.addInitScript(async () => { | ||||
| @ -285,10 +346,7 @@ const sketch001 = startSketchAt([-0, -0]) | ||||
|     // Find the toast. | ||||
|     // Look out for the toast message | ||||
|     const exportingToastMessage = page.getByText(`Exporting...`) | ||||
|     await expect(exportingToastMessage).toBeVisible() | ||||
|  | ||||
|     const errorToastMessage = page.getByText(`Error while exporting`) | ||||
|     await expect(errorToastMessage).toBeVisible() | ||||
|  | ||||
|     const engineErrorToastMessage = page.getByText(`Nothing to export`) | ||||
|     await expect(engineErrorToastMessage).toBeVisible() | ||||
|  | ||||
| @ -9,8 +9,8 @@ import { | ||||
| } from './test-utils' | ||||
| import { uuidv4, roundOff } from 'lib/utils' | ||||
|  | ||||
| test.beforeEach(async ({ context, page }) => { | ||||
|   await setup(context, page) | ||||
| test.beforeEach(async ({ context, page }, testInfo) => { | ||||
|   await setup(context, page, testInfo) | ||||
| }) | ||||
|  | ||||
| test.afterEach(async ({ page }, testInfo) => { | ||||
| @ -40,7 +40,7 @@ test.describe('Sketch tests', () => { | ||||
|   const screwRadius = 3 | ||||
|   const wireRadius = 2 | ||||
|   const wireOffset = 0.5 | ||||
|    | ||||
|  | ||||
|   const screwHole = startSketchOn('XY') | ||||
|     ${startProfileAt1} | ||||
|     |> arc({ | ||||
| @ -48,7 +48,7 @@ test.describe('Sketch tests', () => { | ||||
|           angle_start: 0, | ||||
|           angle_end: 360 | ||||
|         }, %) | ||||
|    | ||||
|  | ||||
|   const part001 = startSketchOn('XY') | ||||
|     ${startProfileAt2} | ||||
|     |> xLine(width * .5, %) | ||||
| @ -57,7 +57,7 @@ test.describe('Sketch tests', () => { | ||||
|     |> close(%) | ||||
|     |> hole(screwHole, %) | ||||
|     |> extrude(thickness, %) | ||||
|    | ||||
|  | ||||
|   const part002 = startSketchOn('-XZ') | ||||
|     ${startProfileAt3} | ||||
|     |> xLine(width / 4, %) | ||||
| @ -618,19 +618,19 @@ test.describe('Sketch tests', () => { | ||||
|     await u.closeDebugPanel() | ||||
|  | ||||
|     await click00r(30, 0) | ||||
|     codeStr += `  |> startProfileAt([1.53, 0], %)` | ||||
|     codeStr += `  |> startProfileAt([2.03, 0], %)` | ||||
|     await expect(u.codeLocator).toHaveText(codeStr) | ||||
|  | ||||
|     await click00r(30, 0) | ||||
|     codeStr += `  |> line([1.53, 0], %)` | ||||
|     codeStr += `  |> line([2.04, 0], %)` | ||||
|     await expect(u.codeLocator).toHaveText(codeStr) | ||||
|  | ||||
|     await click00r(0, 30) | ||||
|     codeStr += `  |> line([0, -1.53], %)` | ||||
|     codeStr += `  |> line([0, -2.03], %)` | ||||
|     await expect(u.codeLocator).toHaveText(codeStr) | ||||
|  | ||||
|     await click00r(-30, 0) | ||||
|     codeStr += `  |> line([-1.53, 0], %)` | ||||
|     codeStr += `  |> line([-2.04, 0], %)` | ||||
|     await expect(u.codeLocator).toHaveText(codeStr) | ||||
|  | ||||
|     await click00r(undefined, undefined) | ||||
| @ -954,4 +954,68 @@ const sketch002 = startSketchOn(extrude001, 'END') | ||||
|       await u.getGreatestPixDiff(XYPlanePoint, noPlanesColor) | ||||
|     ).toBeLessThan(3) | ||||
|   }) | ||||
|  | ||||
|   test('Can attempt to sketch on revolved face', async ({ | ||||
|     page, | ||||
|     browserName, | ||||
|   }) => { | ||||
|     test.skip( | ||||
|       browserName === 'webkit', | ||||
|       'Skip on Safari until `window.tearDown` is working there' | ||||
|     ) | ||||
|     const u = await getUtils(page) | ||||
|     await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|  | ||||
|     await page.addInitScript(async () => { | ||||
|       localStorage.setItem( | ||||
|         'persistCode', | ||||
|         `const lugHeadLength = 0.25 | ||||
|         const lugDiameter = 0.5 | ||||
|         const lugLength = 2 | ||||
|  | ||||
|         fn lug = (origin, length, diameter, plane) => { | ||||
|           const lugSketch = startSketchOn(plane) | ||||
|             |> startProfileAt([origin[0] + lugDiameter / 2, origin[1]], %) | ||||
|             |> angledLineOfYLength({ angle: 60, length: lugHeadLength }, %) | ||||
|             |> xLineTo(0 + .001, %) | ||||
|             |> yLineTo(0, %) | ||||
|             |> close(%) | ||||
|             |> revolve({ axis: "Y" }, %) | ||||
|  | ||||
|           return lugSketch | ||||
|         } | ||||
|  | ||||
|         lug([0, 0], 10, .5, "XY")` | ||||
|       ) | ||||
|     }) | ||||
|  | ||||
|     await u.waitForAuthSkipAppStart() | ||||
|  | ||||
|     await u.openDebugPanel() | ||||
|     await u.expectCmdLog('[data-message-type="execution-done"]') | ||||
|     await u.closeDebugPanel() | ||||
|  | ||||
|     /*** | ||||
|      * Test Plan | ||||
|      * Start the sketch mode | ||||
|      * Click the middle of the screen which should click the top face that is revolved | ||||
|      * Wait till you see the line tool be enabled | ||||
|      * Wait till you see the exit sketch enabled | ||||
|      * | ||||
|      * This is supposed to test that you are allowed to go into sketch mode to sketch on a revolved face | ||||
|      */ | ||||
|  | ||||
|     await page.getByRole('button', { name: 'Start Sketch' }).click() | ||||
|  | ||||
|     await expect(async () => { | ||||
|       await page.mouse.click(600, 250) | ||||
|       await page.waitForTimeout(1000) | ||||
|       await expect( | ||||
|         page.getByRole('button', { name: 'Exit Sketch' }) | ||||
|       ).toBeVisible() | ||||
|       await expect( | ||||
|         page.getByRole('button', { name: 'line Line', exact: true }) | ||||
|       ).toHaveAttribute('aria-pressed', 'true') | ||||
|     }).toPass({ timeout: 40_000, intervals: [1_000] }) | ||||
|   }) | ||||
| }) | ||||
|  | ||||
| Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB | 
| Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 46 KiB | 
| Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB | 
| Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 43 KiB | 
| Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB | 
| Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 51 KiB | 
| Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 47 KiB | 
| Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB | 
| Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 41 KiB | 
| Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 37 KiB | 
| Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 38 KiB | 
| Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 35 KiB | 
| Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB | 
| Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 43 KiB | 
| Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB | 
| Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 30 KiB | 
| Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB | 
| Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 49 KiB | 
| Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB | 
| Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 62 KiB | 
| Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 46 KiB | 
| Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 43 KiB | 
| Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 62 KiB | 
| Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 58 KiB | 
| Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 33 KiB | 
| Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 33 KiB | 
| Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 36 KiB | 
| Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 36 KiB | 
| Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 35 KiB | 
| Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB | 
| Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 35 KiB | 
| Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 35 KiB | 
| Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB | 
| Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 38 KiB | 
| Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB | 
| Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB | 
| @ -2,8 +2,8 @@ import { test, expect } from '@playwright/test' | ||||
|  | ||||
| import { commonPoints, getUtils, setup, tearDown } from './test-utils' | ||||
|  | ||||
| test.beforeEach(async ({ context, page }) => { | ||||
|   await setup(context, page) | ||||
| test.beforeEach(async ({ context, page }, testInfo) => { | ||||
|   await setup(context, page, testInfo) | ||||
| }) | ||||
|  | ||||
| test.afterEach(async ({ page }, testInfo) => { | ||||
|  | ||||
| @ -26,7 +26,9 @@ import { | ||||
| import * as TOML from '@iarna/toml' | ||||
| import { SaveSettingsPayload } from 'lib/settings/settingsTypes' | ||||
| import { SETTINGS_FILE_NAME } from 'lib/constants' | ||||
| import { isErrorWhitelisted } from './lib/console-error-whitelist' | ||||
| import { isArray } from 'lib/utils' | ||||
| import { reportRejection } from 'lib/trap' | ||||
|  | ||||
| type TestColor = [number, number, number] | ||||
| export const TEST_COLORS = { | ||||
| @ -439,46 +441,78 @@ export async function getUtils(page: Page, test_?: typeof test) { | ||||
|       } | ||||
|       return maxDiff | ||||
|     }, | ||||
|     doAndWaitForImageDiff: (fn: () => Promise<any>, diffCount = 200) => | ||||
|       new Promise(async (resolve) => { | ||||
|         await page.screenshot({ | ||||
|           path: './e2e/playwright/temp1.png', | ||||
|           fullPage: true, | ||||
|         }) | ||||
|         await fn() | ||||
|         const isImageDiff = async () => { | ||||
|     getPixelRGBs: async ( | ||||
|       coords: { x: number; y: number }, | ||||
|       radius: number | ||||
|     ): Promise<[number, number, number][]> => { | ||||
|       const buffer = await page.screenshot({ | ||||
|         fullPage: true, | ||||
|       }) | ||||
|       const screenshot = await PNG.sync.read(buffer) | ||||
|       const pixMultiplier: number = await page.evaluate( | ||||
|         'window.devicePixelRatio' | ||||
|       ) | ||||
|       const allCords: [number, number][] = [[coords.x, coords.y]] | ||||
|       for (let i = 1; i < radius; i++) { | ||||
|         allCords.push([coords.x + i, coords.y]) | ||||
|         allCords.push([coords.x - i, coords.y]) | ||||
|         allCords.push([coords.x, coords.y + i]) | ||||
|         allCords.push([coords.x, coords.y - i]) | ||||
|       } | ||||
|       return allCords.map(([x, y]) => { | ||||
|         const index = | ||||
|           (screenshot.width * y * pixMultiplier + x * pixMultiplier) * 4 // rbga is 4 channels | ||||
|         return [ | ||||
|           screenshot.data[index], | ||||
|           screenshot.data[index + 1], | ||||
|           screenshot.data[index + 2], | ||||
|         ] | ||||
|       }) | ||||
|     }, | ||||
|     doAndWaitForImageDiff: (fn: () => Promise<unknown>, diffCount = 200) => | ||||
|       new Promise<boolean>((resolve) => { | ||||
|         ;(async () => { | ||||
|           await page.screenshot({ | ||||
|             path: './e2e/playwright/temp2.png', | ||||
|             path: './e2e/playwright/temp1.png', | ||||
|             fullPage: true, | ||||
|           }) | ||||
|           const screenshot1 = PNG.sync.read( | ||||
|             await fsp.readFile('./e2e/playwright/temp1.png') | ||||
|           ) | ||||
|           const screenshot2 = PNG.sync.read( | ||||
|             await fsp.readFile('./e2e/playwright/temp2.png') | ||||
|           ) | ||||
|           const actualDiffCount = pixelMatch( | ||||
|             screenshot1.data, | ||||
|             screenshot2.data, | ||||
|             null, | ||||
|             screenshot1.width, | ||||
|             screenshot2.height | ||||
|           ) | ||||
|           return actualDiffCount > diffCount | ||||
|         } | ||||
|  | ||||
|         // run isImageDiff every 50ms until it returns true or 5 seconds have passed (100 times) | ||||
|         let count = 0 | ||||
|         const interval = setInterval(async () => { | ||||
|           count++ | ||||
|           if (await isImageDiff()) { | ||||
|             clearInterval(interval) | ||||
|             resolve(true) | ||||
|           } else if (count > 100) { | ||||
|             clearInterval(interval) | ||||
|             resolve(false) | ||||
|           await fn() | ||||
|           const isImageDiff = async () => { | ||||
|             await page.screenshot({ | ||||
|               path: './e2e/playwright/temp2.png', | ||||
|               fullPage: true, | ||||
|             }) | ||||
|             const screenshot1 = PNG.sync.read( | ||||
|               await fsp.readFile('./e2e/playwright/temp1.png') | ||||
|             ) | ||||
|             const screenshot2 = PNG.sync.read( | ||||
|               await fsp.readFile('./e2e/playwright/temp2.png') | ||||
|             ) | ||||
|             const actualDiffCount = pixelMatch( | ||||
|               screenshot1.data, | ||||
|               screenshot2.data, | ||||
|               null, | ||||
|               screenshot1.width, | ||||
|               screenshot2.height | ||||
|             ) | ||||
|             return actualDiffCount > diffCount | ||||
|           } | ||||
|         }, 50) | ||||
|  | ||||
|           // run isImageDiff every 50ms until it returns true or 5 seconds have passed (100 times) | ||||
|           let count = 0 | ||||
|           const interval = setInterval(() => { | ||||
|             ;(async () => { | ||||
|               count++ | ||||
|               if (await isImageDiff()) { | ||||
|                 clearInterval(interval) | ||||
|                 resolve(true) | ||||
|               } else if (count > 100) { | ||||
|                 clearInterval(interval) | ||||
|                 resolve(false) | ||||
|               } | ||||
|             })().catch(reportRejection) | ||||
|           }, 50) | ||||
|         })().catch(reportRejection) | ||||
|       }), | ||||
|     emulateNetworkConditions: async ( | ||||
|       networkOptions: Protocol.Network.emulateNetworkConditionsParameters | ||||
| @ -817,7 +851,11 @@ export async function tearDown(page: Page, testInfo: TestInfo) { | ||||
|  | ||||
| // settingsOverrides may need to be augmented to take more generic items, | ||||
| // but we'll be strict for now | ||||
| export async function setup(context: BrowserContext, page: Page) { | ||||
| export async function setup( | ||||
|   context: BrowserContext, | ||||
|   page: Page, | ||||
|   testInfo?: TestInfo | ||||
| ) { | ||||
|   await context.addInitScript( | ||||
|     async ({ token, settingsKey, settings, IS_PLAYWRIGHT_KEY }) => { | ||||
|       localStorage.clear() | ||||
| @ -853,6 +891,8 @@ export async function setup(context: BrowserContext, page: Page) { | ||||
|       secure: true, | ||||
|     }, | ||||
|   ]) | ||||
|  | ||||
|   failOnConsoleErrors(page, testInfo) | ||||
|   // kill animations, speeds up tests and reduced flakiness | ||||
|   await page.emulateMedia({ reducedMotion: 'reduce' }) | ||||
|  | ||||
| @ -926,6 +966,48 @@ export async function setupElectron({ | ||||
|   return { electronApp, page, dir: projectDirName } | ||||
| } | ||||
|  | ||||
| function failOnConsoleErrors(page: Page, testInfo?: TestInfo) { | ||||
|   // enabled for chrome for now | ||||
|   if (page.context().browser()?.browserType().name() === 'chromium') { | ||||
|     page.on('pageerror', (exception) => { | ||||
|       if (isErrorWhitelisted(exception)) { | ||||
|         return | ||||
|       } | ||||
|  | ||||
|       // only set this env var to false if you want to collect console errors | ||||
|       // This can be configured in the GH workflow.  This should be set to true by default (we want tests to fail when | ||||
|       // unwhitelisted console errors are detected). | ||||
|       if (process.env.FAIL_ON_CONSOLE_ERRORS === 'true') { | ||||
|         // Fail when running on CI and FAIL_ON_CONSOLE_ERRORS is set | ||||
|         // use expect to prevent page from closing and not cleaning up | ||||
|         expect(`An error was detected in the console: \r\n message:${exception.message} \r\n name:${exception.name} \r\n stack:${exception.stack} | ||||
|            | ||||
|           *Either fix the console error or add it to the whitelist defined in ./lib/console-error-whitelist.ts (if the error can be safely ignored)        | ||||
|           `).toEqual('Console error detected') | ||||
|       } else { | ||||
|         // the (test-results/exceptions.txt) file will be uploaded as part of an upload artifact in GH | ||||
|         fsp | ||||
|           .appendFile( | ||||
|             './test-results/exceptions.txt', | ||||
|             [ | ||||
|               '~~~', | ||||
|               `triggered_by_test:${ | ||||
|                 testInfo?.file + ' ' + (testInfo?.title || ' ') | ||||
|               }`, | ||||
|               `name:${exception.name}`, | ||||
|               `message:${exception.message}`, | ||||
|               `stack:${exception.stack}`, | ||||
|               `project:${testInfo?.project.name}`, | ||||
|               '~~~', | ||||
|             ].join('\n') | ||||
|           ) | ||||
|           .catch((err) => { | ||||
|             console.error(err) | ||||
|           }) | ||||
|       } | ||||
|     }) | ||||
|   } | ||||
| } | ||||
| export async function isOutOfViewInScrollContainer( | ||||
|   element: Locator, | ||||
|   container: Locator | ||||
|  | ||||
| @ -3,8 +3,8 @@ import { EngineCommand } from 'lang/std/artifactGraph' | ||||
| import { uuidv4 } from 'lib/utils' | ||||
| import { getUtils, setup, tearDown } from './test-utils' | ||||
|  | ||||
| test.beforeEach(async ({ context, page }) => { | ||||
|   await setup(context, page) | ||||
| test.beforeEach(async ({ context, page }, testInfo) => { | ||||
|   await setup(context, page, testInfo) | ||||
| }) | ||||
|  | ||||
| test.afterEach(async ({ page }, testInfo) => { | ||||
| @ -12,8 +12,8 @@ test.afterEach(async ({ page }, testInfo) => { | ||||
| }) | ||||
|  | ||||
| test.describe('Testing Camera Movement', () => { | ||||
|   test('Can moving camera', async ({ page, context }) => { | ||||
|     test.skip(process.platform === 'darwin', 'Can moving camera') | ||||
|   test('Can move camera reliably', async ({ page, context }) => { | ||||
|     test.skip(process.platform === 'darwin', 'Can move camera reliably') | ||||
|     const u = await getUtils(page) | ||||
|     await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|  | ||||
| @ -102,6 +102,13 @@ test.describe('Testing Camera Movement', () => { | ||||
|     await bakeInRetries(async () => { | ||||
|       await page.mouse.move(700, 200) | ||||
|       await page.mouse.down({ button: 'right' }) | ||||
|       const appLogoBBox = await page.getByTestId('app-logo').boundingBox() | ||||
|       expect(appLogoBBox).not.toBeNull() | ||||
|       if (!appLogoBBox) throw new Error('app logo not found') | ||||
|       await page.mouse.move( | ||||
|         appLogoBBox.x + appLogoBBox.width / 2, | ||||
|         appLogoBBox.y + appLogoBBox.height / 2 | ||||
|       ) | ||||
|       await page.mouse.move(600, 303) | ||||
|       await page.mouse.up({ button: 'right' }) | ||||
|     }, [4, -10.5, -120]) | ||||
| @ -295,11 +302,11 @@ test.describe('Testing Camera Movement', () => { | ||||
|     await expect( | ||||
|       page.getByRole('button', { name: 'Edit Sketch' }) | ||||
|     ).toBeVisible() | ||||
|     await hoverOverNothing() | ||||
|     await page.getByRole('button', { name: 'Edit Sketch' }).click() | ||||
|  | ||||
|     await page.waitForTimeout(400) | ||||
|  | ||||
|     await hoverOverNothing() | ||||
|     x = 975 | ||||
|     y = 468 | ||||
|  | ||||
|  | ||||
| @ -3,8 +3,8 @@ import { test, expect } from '@playwright/test' | ||||
| import { getUtils, setup, tearDown, TEST_COLORS } from './test-utils' | ||||
| import { XOR } from 'lib/utils' | ||||
|  | ||||
| test.beforeEach(async ({ context, page }) => { | ||||
|   await setup(context, page) | ||||
| test.beforeEach(async ({ context, page }, testInfo) => { | ||||
|   await setup(context, page, testInfo) | ||||
| }) | ||||
|  | ||||
| test.afterEach(async ({ page }, testInfo) => { | ||||
|  | ||||
| @ -4,8 +4,8 @@ import { getUtils, setup, tearDown } from './test-utils' | ||||
| import { uuidv4 } from 'lib/utils' | ||||
| import { TEST_CODE_GIZMO } from './storageStates' | ||||
|  | ||||
| test.beforeEach(async ({ context, page }) => { | ||||
|   await setup(context, page) | ||||
| test.beforeEach(async ({ context, page }, testInfo) => { | ||||
|   await setup(context, page, testInfo) | ||||
| }) | ||||
|  | ||||
| test.afterEach(async ({ page }, testInfo) => { | ||||
|  | ||||
| @ -4,8 +4,8 @@ import { deg, getUtils, setup, tearDown, wiggleMove } from './test-utils' | ||||
| import { LineInputsType } from 'lang/std/sketchcombos' | ||||
| import { uuidv4 } from 'lib/utils' | ||||
|  | ||||
| test.beforeEach(async ({ context, page }) => { | ||||
|   await setup(context, page) | ||||
| test.beforeEach(async ({ context, page }, testInfo) => { | ||||
|   await setup(context, page, testInfo) | ||||
| }) | ||||
|  | ||||
| test.afterEach(async ({ page }, testInfo) => { | ||||
|  | ||||
| @ -5,8 +5,8 @@ import { Coords2d } from 'lang/std/sketch' | ||||
| import { KCL_DEFAULT_LENGTH } from 'lib/constants' | ||||
| import { uuidv4 } from 'lib/utils' | ||||
|  | ||||
| test.beforeEach(async ({ context, page }) => { | ||||
|   await setup(context, page) | ||||
| test.beforeEach(async ({ context, page }, testInfo) => { | ||||
|   await setup(context, page, testInfo) | ||||
| }) | ||||
|  | ||||
| test.afterEach(async ({ page }, testInfo) => { | ||||
| @ -31,12 +31,28 @@ test.describe('Testing selections', () => { | ||||
|  | ||||
|     const xAxisClick = () => | ||||
|       page.mouse.click(700, 253).then(() => page.waitForTimeout(100)) | ||||
|     const xAxisClickAfterExitingSketch = () => | ||||
|       page.mouse.click(639, 278).then(() => page.waitForTimeout(100)) | ||||
|     const emptySpaceHover = () => | ||||
|       test.step('Hover over empty space', async () => { | ||||
|         await page.mouse.move(700, 143, { steps: 5 }) | ||||
|         await expect(page.locator('.hover-highlight')).not.toBeVisible() | ||||
|       }) | ||||
|     const emptySpaceClick = () => | ||||
|       page.mouse.click(700, 343).then(() => page.waitForTimeout(100)) | ||||
|       test.step(`Click in empty space`, async () => { | ||||
|         await page.mouse.click(700, 143) | ||||
|         await expect(page.locator('.cm-line').last()).toHaveClass( | ||||
|           /cm-activeLine/ | ||||
|         ) | ||||
|       }) | ||||
|     const topHorzSegmentClick = () => | ||||
|       page.mouse.click(709, 290).then(() => page.waitForTimeout(100)) | ||||
|       page.mouse | ||||
|         .click(startXPx, 500 - PUR * 20) | ||||
|         .then(() => page.waitForTimeout(100)) | ||||
|     const bottomHorzSegmentClick = () => | ||||
|       page.mouse.click(767, 396).then(() => page.waitForTimeout(100)) | ||||
|       page.mouse | ||||
|         .click(startXPx + PUR * 10, 500 - PUR * 10) | ||||
|         .then(() => page.waitForTimeout(100)) | ||||
|  | ||||
|     await u.clearCommandLogs() | ||||
|     await expect( | ||||
| @ -171,7 +187,9 @@ test.describe('Testing selections', () => { | ||||
|       await emptySpaceClick() | ||||
|     } | ||||
|  | ||||
|     await selectionSequence() | ||||
|     await test.step(`Test hovering and selecting on fresh sketch`, async () => { | ||||
|       await selectionSequence() | ||||
|     }) | ||||
|  | ||||
|     // hovering in fresh sketch worked, lets try exiting and re-entering | ||||
|     await u.openAndClearDebugPanel() | ||||
| @ -184,16 +202,17 @@ test.describe('Testing selections', () => { | ||||
|  | ||||
|     // select a line, this verifies that sketches in the scene can be selected outside of sketch mode | ||||
|     await topHorzSegmentClick() | ||||
|     await xAxisClickAfterExitingSketch() | ||||
|     await page.waitForTimeout(100) | ||||
|     await emptySpaceHover() | ||||
|  | ||||
|     // enter sketch again | ||||
|     await u.doAndWaitForCmd( | ||||
|       () => page.getByRole('button', { name: 'Edit Sketch' }).click(), | ||||
|       'default_camera_get_settings' | ||||
|     ) | ||||
|     await page.waitForTimeout(150) | ||||
|  | ||||
|     await page.waitForTimeout(300) // wait for animation | ||||
|     await page.waitForTimeout(450) // wait for animation | ||||
|  | ||||
|     await u.openAndClearDebugPanel() | ||||
|     await u.sendCustomCmd({ | ||||
| @ -220,8 +239,9 @@ test.describe('Testing selections', () => { | ||||
|  | ||||
|     await u.closeDebugPanel() | ||||
|  | ||||
|     // hover again and check it works | ||||
|     await selectionSequence() | ||||
|     await test.step(`Test hovering and selecting on edited sketch`, async () => { | ||||
|       await selectionSequence() | ||||
|     }) | ||||
|   }) | ||||
|  | ||||
|   test('Solids should be select and deletable', async ({ page }) => { | ||||
| @ -413,7 +433,7 @@ const sketch002 = startSketchOn(launderExtrudeThroughVar, seg02) | ||||
|   |> line([0, 20.03], %) | ||||
|   |> line([62.61, 0], %, $seg03) | ||||
|   |> lineTo([profileStartX(%), profileStartY(%)], %) | ||||
|   |> close(%)       | ||||
|   |> close(%) | ||||
| ` | ||||
|       ) | ||||
|     }, KCL_DEFAULT_LENGTH) | ||||
| @ -516,11 +536,22 @@ const sketch002 = startSketchOn(launderExtrudeThroughVar, seg02) | ||||
|     await page.waitForTimeout(100) | ||||
|     await u.closeDebugPanel() | ||||
|  | ||||
|     const extrusionTop: Coords2d = [800, 240] | ||||
|     const extrusionTopCap: Coords2d = [800, 240] | ||||
|     const flatExtrusionFace: Coords2d = [960, 160] | ||||
|     const arc: Coords2d = [840, 160] | ||||
|     const tangentialArcTo: Coords2d = [840, 160] | ||||
|     const close: Coords2d = [720, 200] | ||||
|     const nothing: Coords2d = [600, 200] | ||||
|     const closeEdge: Coords2d = [744, 233] | ||||
|     const closeAdjacentEdge: Coords2d = [688, 123] | ||||
|     const closeOppositeEdge: Coords2d = [687, 169] | ||||
|  | ||||
|     const tangentialArcEdge: Coords2d = [811, 142] | ||||
|     const tangentialArcOppositeEdge: Coords2d = [820, 180] | ||||
|     const tangentialArcAdjacentEdge: Coords2d = [893, 165] | ||||
|  | ||||
|     const straightSegmentEdge: Coords2d = [819, 369] | ||||
|     const straightSegmentOppositeEdge: Coords2d = [635, 394] | ||||
|     const straightSegmentAdjacentEdge: Coords2d = [679, 329] | ||||
|  | ||||
|     await page.mouse.move(nothing[0], nothing[1]) | ||||
|     await page.mouse.click(nothing[0], nothing[1]) | ||||
| @ -528,26 +559,141 @@ const sketch002 = startSketchOn(launderExtrudeThroughVar, seg02) | ||||
|     await expect(page.getByTestId('hover-highlight')).not.toBeVisible() | ||||
|     await page.waitForTimeout(200) | ||||
|  | ||||
|     await page.mouse.move(extrusionTop[0], extrusionTop[1]) | ||||
|     await expect(page.getByTestId('hover-highlight').first()).toBeVisible() | ||||
|     await page.mouse.move(nothing[0], nothing[1]) | ||||
|     await expect(page.getByTestId('hover-highlight').first()).not.toBeVisible() | ||||
|     const checkCodeAtHoverPosition = async ( | ||||
|       name = '', | ||||
|       coord: Coords2d, | ||||
|       highlightCode: string, | ||||
|       activeLine = highlightCode | ||||
|     ) => { | ||||
|       await test.step(`test selection for: ${name}`, async () => { | ||||
|         const highlightedLocator = page.getByTestId('hover-highlight') | ||||
|         const activeLineLocator = page.locator('.cm-activeLine') | ||||
|  | ||||
|     await page.mouse.move(arc[0], arc[1]) | ||||
|     await expect(page.getByTestId('hover-highlight').first()).toBeVisible() | ||||
|     await page.mouse.move(nothing[0], nothing[1]) | ||||
|     await expect(page.getByTestId('hover-highlight').first()).not.toBeVisible() | ||||
|         await test.step(`hover should highlight correct code`, async () => { | ||||
|           await page.mouse.move(coord[0], coord[1]) | ||||
|           await expect(highlightedLocator.first()).toBeVisible() | ||||
|           await expect | ||||
|             .poll(async () => { | ||||
|               const textContents = await highlightedLocator.allTextContents() | ||||
|               return textContents.join('').replace(/\s+/g, '') | ||||
|             }) | ||||
|             .toBe(highlightCode) | ||||
|           await page.mouse.move(nothing[0], nothing[1]) | ||||
|         }) | ||||
|         await test.step(`click should put the cursor in the right place`, async () => { | ||||
|           await expect(highlightedLocator.first()).not.toBeVisible() | ||||
|           await page.mouse.click(coord[0], coord[1]) | ||||
|           await expect | ||||
|             .poll(async () => { | ||||
|               const activeLines = await activeLineLocator.allInnerTexts() | ||||
|               return activeLines.join('') | ||||
|             }) | ||||
|             .toContain(activeLine) | ||||
|           // check pixels near the click location are yellow | ||||
|         }) | ||||
|         await test.step(`check the engine agrees with selections`, async () => { | ||||
|           // ultimately the only way we know if the engine agrees with the selection from the FE | ||||
|           // perspective is if it highlights the pixels near where we clicked yellow. | ||||
|           await expect | ||||
|             .poll(async () => { | ||||
|               const RGBs = await u.getPixelRGBs({ x: coord[0], y: coord[1] }, 3) | ||||
|               for (const rgb of RGBs) { | ||||
|                 const [r, g, b] = rgb | ||||
|                 const RGAverage = (r + g) / 2 | ||||
|                 const isRedGreenSameIsh = Math.abs(r - g) < 3 | ||||
|                 const isBlueLessThanRG = RGAverage - b > 45 | ||||
|                 const isYellowy = isRedGreenSameIsh && isBlueLessThanRG | ||||
|                 if (isYellowy) return true | ||||
|               } | ||||
|               return false | ||||
|             }) | ||||
|             .toBeTruthy() | ||||
|           await page.mouse.click(nothing[0], nothing[1]) | ||||
|         }) | ||||
|       }) | ||||
|     } | ||||
|  | ||||
|     await page.mouse.move(close[0], close[1]) | ||||
|     await expect(page.getByTestId('hover-highlight').first()).toBeVisible() | ||||
|     await page.mouse.move(nothing[0], nothing[1]) | ||||
|     await expect(page.getByTestId('hover-highlight').first()).not.toBeVisible() | ||||
|     await checkCodeAtHoverPosition( | ||||
|       'extrusionTopCap', | ||||
|       extrusionTopCap, | ||||
|       'startProfileAt([20,0],%)', | ||||
|       'startProfileAt([20, 0], %)' | ||||
|     ) | ||||
|     await checkCodeAtHoverPosition( | ||||
|       'flatExtrusionFace', | ||||
|       flatExtrusionFace, | ||||
|       `angledLineThatIntersects({angle:3.14,intersectTag:a,offset:0},%)extrude(5+7,%)`, | ||||
|       '}, %)' | ||||
|     ) | ||||
|  | ||||
|     await page.mouse.move(flatExtrusionFace[0], flatExtrusionFace[1]) | ||||
|     await expect(page.getByTestId('hover-highlight')).toHaveCount(6) // multiple lines | ||||
|     await page.mouse.move(nothing[0], nothing[1]) | ||||
|     await page.waitForTimeout(100) | ||||
|     await expect(page.getByTestId('hover-highlight').first()).not.toBeVisible() | ||||
|     await checkCodeAtHoverPosition( | ||||
|       'tangentialArcTo', | ||||
|       tangentialArcTo, | ||||
|       'tangentialArcTo([13.14+0,13.14],%)extrude(5+7,%)', | ||||
|       'tangentialArcTo([13.14 + 0, 13.14], %)' | ||||
|     ) | ||||
|     await checkCodeAtHoverPosition( | ||||
|       'tangentialArcEdge', | ||||
|       tangentialArcEdge, | ||||
|       `tangentialArcTo([13.14+0,13.14],%)`, | ||||
|       'tangentialArcTo([13.14 + 0, 13.14], %)' | ||||
|     ) | ||||
|     await checkCodeAtHoverPosition( | ||||
|       'tangentialArcOppositeEdge', | ||||
|       tangentialArcOppositeEdge, | ||||
|       `tangentialArcTo([13.14+0,13.14],%)`, | ||||
|       'tangentialArcTo([13.14 + 0, 13.14], %)' | ||||
|     ) | ||||
|     await checkCodeAtHoverPosition( | ||||
|       'tangentialArcAdjacentEdge', | ||||
|       tangentialArcAdjacentEdge, | ||||
|       `tangentialArcTo([13.14+0,13.14],%)`, | ||||
|       'tangentialArcTo([13.14 + 0, 13.14], %)' | ||||
|     ) | ||||
|  | ||||
|     await checkCodeAtHoverPosition( | ||||
|       'close', | ||||
|       close, | ||||
|       'close(%)extrude(5+7,%)', | ||||
|       'close(%)' | ||||
|     ) | ||||
|     await checkCodeAtHoverPosition( | ||||
|       'closeEdge', | ||||
|       closeEdge, | ||||
|       `close(%)`, | ||||
|       'close(%)' | ||||
|     ) | ||||
|     await checkCodeAtHoverPosition( | ||||
|       'closeAdjacentEdge', | ||||
|       closeAdjacentEdge, | ||||
|       `close(%)`, | ||||
|       'close(%)' | ||||
|     ) | ||||
|     await checkCodeAtHoverPosition( | ||||
|       'closeOppositeEdge', | ||||
|       closeOppositeEdge, | ||||
|       `close(%)`, | ||||
|       'close(%)' | ||||
|     ) | ||||
|  | ||||
|     await checkCodeAtHoverPosition( | ||||
|       'straightSegmentEdge', | ||||
|       straightSegmentEdge, | ||||
|       `angledLineToY({angle:30,to:11.14},%)`, | ||||
|       'angledLineToY({ angle: 30, to: 11.14 }, %)' | ||||
|     ) | ||||
|     await checkCodeAtHoverPosition( | ||||
|       'straightSegmentOppositeEdge', | ||||
|       straightSegmentOppositeEdge, | ||||
|       `angledLineToY({angle:30,to:11.14},%)`, | ||||
|       'angledLineToY({ angle: 30, to: 11.14 }, %)' | ||||
|     ) | ||||
|     await checkCodeAtHoverPosition( | ||||
|       'straightSegmentAdjancentEdge', | ||||
|       straightSegmentAdjacentEdge, | ||||
|       `angledLineToY({angle:30,to:11.14},%)`, | ||||
|       'angledLineToY({ angle: 30, to: 11.14 }, %)' | ||||
|     ) | ||||
|   }) | ||||
|   test("Extrude button should be disabled if there's no extrudable geometry when nothing is selected", async ({ | ||||
|     page, | ||||
| @ -876,7 +1022,7 @@ const extrude001 = extrude(50, sketch001) | ||||
|     |> line([4.95, -8], %) | ||||
|     |> line([-20.38, -10.12], %) | ||||
|     |> line([-15.79, 17.08], %) | ||||
|    | ||||
|  | ||||
|   fn yohey = (pos) => { | ||||
|     const sketch004 = startSketchOn('XZ') | ||||
|     ${extrudeAndEditBlockedInFunction} | ||||
| @ -886,7 +1032,7 @@ const extrude001 = extrude(50, sketch001) | ||||
|     |> line([-15.79, 17.08], %) | ||||
|     return '' | ||||
|   } | ||||
|    | ||||
|  | ||||
|       yohey([15.79, -34.6]) | ||||
|   ` | ||||
|         ) | ||||
|  | ||||
| @ -8,12 +8,16 @@ import { | ||||
|   tearDown, | ||||
|   executorInputPath, | ||||
| } from './test-utils' | ||||
| import { SaveSettingsPayload } from 'lib/settings/settingsTypes' | ||||
| import { TEST_SETTINGS_KEY, TEST_SETTINGS_CORRUPTED } from './storageStates' | ||||
| import { SaveSettingsPayload, SettingsLevel } from 'lib/settings/settingsTypes' | ||||
| import { | ||||
|   TEST_SETTINGS_KEY, | ||||
|   TEST_SETTINGS_CORRUPTED, | ||||
|   TEST_SETTINGS, | ||||
| } from './storageStates' | ||||
| import * as TOML from '@iarna/toml' | ||||
|  | ||||
| test.beforeEach(async ({ context, page }) => { | ||||
|   await setup(context, page) | ||||
| test.beforeEach(async ({ context, page }, testInfo) => { | ||||
|   await setup(context, page, testInfo) | ||||
| }) | ||||
|  | ||||
| test.afterEach(async ({ page }, testInfo) => { | ||||
| @ -65,12 +69,15 @@ test.describe('Testing settings', () => { | ||||
|     page, | ||||
|   }) => { | ||||
|     const u = await getUtils(page) | ||||
|     await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|     await u.waitForAuthSkipAppStart() | ||||
|     await page | ||||
|       .getByRole('button', { name: 'Start Sketch' }) | ||||
|       .waitFor({ state: 'visible' }) | ||||
|     await test.step(`Setup`, async () => { | ||||
|       await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|       await u.waitForAuthSkipAppStart() | ||||
|       await page | ||||
|         .getByRole('button', { name: 'Start Sketch' }) | ||||
|         .waitFor({ state: 'visible' }) | ||||
|     }) | ||||
|  | ||||
|     // Selectors and constants | ||||
|     const paneButtonLocator = page.getByTestId('debug-pane-button') | ||||
|     const headingLocator = page.getByRole('heading', { | ||||
|       name: 'Settings', | ||||
| @ -78,11 +85,23 @@ test.describe('Testing settings', () => { | ||||
|     }) | ||||
|     const inputLocator = page.locator('input[name="modeling-showDebugPanel"]') | ||||
|  | ||||
|     // Open the settings modal with the browser keyboard shortcut | ||||
|     await page.keyboard.press('ControlOrMeta+Shift+,') | ||||
|     await test.step('Open settings dialog and set "Show debug panel" to on', async () => { | ||||
|       await page.keyboard.press('ControlOrMeta+Shift+,') | ||||
|       await expect(headingLocator).toBeVisible() | ||||
|  | ||||
|     await expect(headingLocator).toBeVisible() | ||||
|     await page.locator('#showDebugPanel').getByText('OffOn').click() | ||||
|       /** Test to close https://github.com/KittyCAD/modeling-app/issues/2713 */ | ||||
|       await test.step(`Confirm that this dialog has a solid background`, async () => { | ||||
|         await expect | ||||
|           .poll(() => u.getGreatestPixDiff({ x: 600, y: 250 }, [28, 28, 28]), { | ||||
|             timeout: 1000, | ||||
|             message: | ||||
|               'Checking for solid background, should not see default plane colors', | ||||
|           }) | ||||
|           .toBeLessThan(15) | ||||
|       }) | ||||
|  | ||||
|       await page.locator('#showDebugPanel').getByText('OffOn').click() | ||||
|     }) | ||||
|  | ||||
|     // Close it and open again with keyboard shortcut, while KCL editor is focused | ||||
|     // Put the cursor in the editor | ||||
| @ -154,29 +173,33 @@ test.describe('Testing settings', () => { | ||||
|  | ||||
|   test('Project and user settings can be reset', async ({ page }) => { | ||||
|     const u = await getUtils(page) | ||||
|     await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|     await u.waitForAuthSkipAppStart() | ||||
|     await page | ||||
|       .getByRole('button', { name: 'Start Sketch' }) | ||||
|       .waitFor({ state: 'visible' }) | ||||
|     await test.step(`Setup`, async () => { | ||||
|       await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|       await u.waitForAuthSkipAppStart() | ||||
|     }) | ||||
|  | ||||
|     // Selectors and constants | ||||
|     const projectSettingsTab = page.getByRole('radio', { name: 'Project' }) | ||||
|     const userSettingsTab = page.getByRole('radio', { name: 'User' }) | ||||
|     const resetButton = page.getByRole('button', { | ||||
|       name: 'Restore default settings', | ||||
|     }) | ||||
|     const resetButton = (level: SettingsLevel) => | ||||
|       page.getByRole('button', { | ||||
|         name: `Reset ${level}-level settings`, | ||||
|       }) | ||||
|     const themeColorSetting = page.locator('#themeColor').getByRole('slider') | ||||
|     const settingValues = { | ||||
|       default: '259', | ||||
|       user: '120', | ||||
|       project: '50', | ||||
|     } | ||||
|     const resetToast = (level: SettingsLevel) => | ||||
|       page.getByText(`${level}-level settings were reset`) | ||||
|  | ||||
|     // Open the settings modal with lower-right button | ||||
|     await page.getByRole('link', { name: 'Settings' }).last().click() | ||||
|     await expect( | ||||
|       page.getByRole('heading', { name: 'Settings', exact: true }) | ||||
|     ).toBeVisible() | ||||
|     await test.step(`Open the settings modal`, async () => { | ||||
|       await page.getByRole('link', { name: 'Settings' }).last().click() | ||||
|       await expect( | ||||
|         page.getByRole('heading', { name: 'Settings', exact: true }) | ||||
|       ).toBeVisible() | ||||
|     }) | ||||
|  | ||||
|     await test.step('Set up theme color', async () => { | ||||
|       // Verify we're looking at the project-level settings, | ||||
| @ -195,37 +218,40 @@ test.describe('Testing settings', () => { | ||||
|  | ||||
|     await test.step('Reset project settings', async () => { | ||||
|       // Click the reset settings button. | ||||
|       await resetButton.click() | ||||
|       await resetButton('project').click() | ||||
|  | ||||
|       await expect(page.getByText('Settings restored to default')).toBeVisible() | ||||
|       await expect( | ||||
|         page.getByText('Settings restored to default') | ||||
|       ).not.toBeVisible() | ||||
|       await expect(resetToast('project')).toBeVisible() | ||||
|       await expect(resetToast('project')).not.toBeVisible() | ||||
|  | ||||
|       // Verify it is now set to the inherited user value | ||||
|       await expect(themeColorSetting).toHaveValue(settingValues.default) | ||||
|       await expect(themeColorSetting).toHaveValue(settingValues.user) | ||||
|  | ||||
|       // Check that the user setting also rolled back | ||||
|       await userSettingsTab.click() | ||||
|       await expect(themeColorSetting).toHaveValue(settingValues.default) | ||||
|       await projectSettingsTab.click() | ||||
|       await test.step(`Check that the user settings did not change`, async () => { | ||||
|         await userSettingsTab.click() | ||||
|         await expect(themeColorSetting).toHaveValue(settingValues.user) | ||||
|       }) | ||||
|  | ||||
|       // Set project-level value to 50 again to test the user-level reset | ||||
|       await themeColorSetting.fill(settingValues.project) | ||||
|       await userSettingsTab.click() | ||||
|       await test.step(`Set project-level again to test the user-level reset`, async () => { | ||||
|         await projectSettingsTab.click() | ||||
|         await themeColorSetting.fill(settingValues.project) | ||||
|         await userSettingsTab.click() | ||||
|       }) | ||||
|     }) | ||||
|  | ||||
|     await test.step('Reset user settings', async () => { | ||||
|       // Change the setting and click the reset settings button. | ||||
|       await themeColorSetting.fill(settingValues.user) | ||||
|       await resetButton.click() | ||||
|       // Click the reset settings button. | ||||
|       await resetButton('user').click() | ||||
|  | ||||
|       await expect(resetToast('user')).toBeVisible() | ||||
|       await expect(resetToast('user')).not.toBeVisible() | ||||
|  | ||||
|       // Verify it is now set to the default value | ||||
|       await expect(themeColorSetting).toHaveValue(settingValues.default) | ||||
|  | ||||
|       // Check that the project setting also changed | ||||
|       await projectSettingsTab.click() | ||||
|       await expect(themeColorSetting).toHaveValue(settingValues.default) | ||||
|       await test.step(`Check that the project settings did not change`, async () => { | ||||
|         await projectSettingsTab.click() | ||||
|         await expect(themeColorSetting).toHaveValue(settingValues.project) | ||||
|       }) | ||||
|     }) | ||||
|   }) | ||||
|  | ||||
| @ -251,8 +277,6 @@ test.describe('Testing settings', () => { | ||||
|  | ||||
|       await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|  | ||||
|       page.on('console', console.log) | ||||
|  | ||||
|       // Selectors and constants | ||||
|       const userThemeColor = '120' | ||||
|       const projectThemeColor = '50' | ||||
| @ -266,7 +290,6 @@ test.describe('Testing settings', () => { | ||||
|       const projectLink = page.getByText('bracket') | ||||
|       const logoLink = page.getByTestId('app-logo') | ||||
|  | ||||
|       // Open the app and set the user theme color | ||||
|       await test.step('Set user theme color on home', async () => { | ||||
|         await expect(settingsOpenButton).toBeVisible() | ||||
|         await settingsOpenButton.click() | ||||
| @ -285,13 +308,15 @@ test.describe('Testing settings', () => { | ||||
|         await expect(projectSettingsTab).toBeChecked() | ||||
|         await themeColorSetting.fill(projectThemeColor) | ||||
|         await expect(logoLink).toHaveCSS('--primary-hue', projectThemeColor) | ||||
|         await settingsCloseButton.click() | ||||
|       }) | ||||
|  | ||||
|       await test.step('Refresh the application and see project setting applied', async () => { | ||||
|         // Make sure we're done navigating before we reload | ||||
|         await expect(settingsCloseButton).not.toBeVisible() | ||||
|         await page.reload({ waitUntil: 'domcontentloaded' }) | ||||
|  | ||||
|         await expect(logoLink).toHaveCSS('--primary-hue', projectThemeColor) | ||||
|         await settingsCloseButton.click() | ||||
|       }) | ||||
|  | ||||
|       await test.step(`Navigate back to the home view and see user setting applied`, async () => { | ||||
| @ -429,25 +454,37 @@ test.describe('Testing settings', () => { | ||||
|  | ||||
|   test('Changing modeling default unit', async ({ page }) => { | ||||
|     const u = await getUtils(page) | ||||
|     await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|     await u.waitForAuthSkipAppStart() | ||||
|     await page | ||||
|       .getByRole('button', { name: 'Start Sketch' }) | ||||
|       .waitFor({ state: 'visible' }) | ||||
|  | ||||
|     const userSettingsTab = page.getByRole('radio', { name: 'User' }) | ||||
|  | ||||
|     // Open the settings modal with lower-right button | ||||
|     await page.getByRole('link', { name: 'Settings' }).last().click() | ||||
|     await expect( | ||||
|       page.getByRole('heading', { name: 'Settings', exact: true }) | ||||
|     ).toBeVisible() | ||||
|  | ||||
|     const resetButton = page.getByRole('button', { | ||||
|       name: 'Restore default settings', | ||||
|     await test.step(`Test setup`, async () => { | ||||
|       await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|       await u.waitForAuthSkipAppStart() | ||||
|       await page | ||||
|         .getByRole('button', { name: 'Start Sketch' }) | ||||
|         .waitFor({ state: 'visible' }) | ||||
|     }) | ||||
|  | ||||
|     // Selectors and constants | ||||
|     const userSettingsTab = page.getByRole('radio', { name: 'User' }) | ||||
|     const projectSettingsTab = page.getByRole('radio', { name: 'Project' }) | ||||
|     const defaultUnitSection = page.getByText( | ||||
|       'default unitRoll back default unitRoll back to match' | ||||
|     ) | ||||
|     const defaultUnitRollbackButton = page.getByRole('button', { | ||||
|       name: 'Roll back default unit', | ||||
|     }) | ||||
|  | ||||
|     await test.step(`Open the settings modal`, async () => { | ||||
|       await page.getByRole('link', { name: 'Settings' }).last().click() | ||||
|       await expect( | ||||
|         page.getByRole('heading', { name: 'Settings', exact: true }) | ||||
|       ).toBeVisible() | ||||
|     }) | ||||
|  | ||||
|     await test.step(`Reset unit setting`, async () => { | ||||
|       await userSettingsTab.click() | ||||
|       await defaultUnitSection.hover() | ||||
|       await defaultUnitRollbackButton.click() | ||||
|       await projectSettingsTab.click() | ||||
|     }) | ||||
|     // Default unit should be mm | ||||
|     await resetButton.click() | ||||
|  | ||||
|     await test.step('Change modeling default unit within project tab', async () => { | ||||
|       const changeUnitOfMeasureInProjectTab = async (unitOfMeasure: string) => { | ||||
| @ -552,4 +589,148 @@ test.describe('Testing settings', () => { | ||||
|       await changeUnitOfMeasureInGizmo('m', 'Meters') | ||||
|     }) | ||||
|   }) | ||||
|  | ||||
|   test('Changing theme in sketch mode', async ({ page }) => { | ||||
|     const u = await getUtils(page) | ||||
|     await page.addInitScript(() => { | ||||
|       localStorage.setItem( | ||||
|         'persistCode', | ||||
|         `const sketch001 = startSketchOn('XZ') | ||||
|   |> startProfileAt([0, 0], %) | ||||
|   |> line([5, 0], %) | ||||
|   |> line([0, 5], %) | ||||
|   |> line([-5, 0], %) | ||||
|   |> lineTo([profileStartX(%), profileStartY(%)], %) | ||||
|   |> close(%) | ||||
| const extrude001 = extrude(5, sketch001) | ||||
| ` | ||||
|       ) | ||||
|     }) | ||||
|     await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|  | ||||
|     // Selectors and constants | ||||
|     const editSketchButton = page.getByRole('button', { name: 'Edit Sketch' }) | ||||
|     const lineToolButton = page.getByTestId('line') | ||||
|     const segmentOverlays = page.getByTestId('segment-overlay') | ||||
|     const sketchOriginLocation = { x: 600, y: 250 } | ||||
|     const darkThemeSegmentColor: [number, number, number] = [215, 215, 215] | ||||
|     const lightThemeSegmentColor: [number, number, number] = [90, 90, 90] | ||||
|  | ||||
|     await test.step(`Get into sketch mode`, async () => { | ||||
|       await u.waitForAuthSkipAppStart() | ||||
|       await page.mouse.click(700, 200) | ||||
|       await expect(editSketchButton).toBeVisible() | ||||
|       await editSketchButton.click() | ||||
|  | ||||
|       // We use the line tool as a proxy for sketch mode | ||||
|       await expect(lineToolButton).toBeVisible() | ||||
|       await expect(segmentOverlays).toHaveCount(4) | ||||
|       // but we allow more time to pass for animating to the sketch | ||||
|       await page.waitForTimeout(1000) | ||||
|     }) | ||||
|  | ||||
|     await test.step(`Check the sketch line color before`, async () => { | ||||
|       await expect | ||||
|         .poll(() => | ||||
|           u.getGreatestPixDiff(sketchOriginLocation, darkThemeSegmentColor) | ||||
|         ) | ||||
|         .toBeLessThan(15) | ||||
|     }) | ||||
|  | ||||
|     await test.step(`Change theme to light using command palette`, async () => { | ||||
|       await page.keyboard.press('ControlOrMeta+K') | ||||
|       await page.getByRole('option', { name: 'theme' }).click() | ||||
|       await page.getByRole('option', { name: 'light' }).click() | ||||
|       await expect(page.getByText('theme to "light"')).toBeVisible() | ||||
|  | ||||
|       // Make sure we haven't left sketch mode | ||||
|       await expect(lineToolButton).toBeVisible() | ||||
|     }) | ||||
|  | ||||
|     await test.step(`Check the sketch line color after`, async () => { | ||||
|       await expect | ||||
|         .poll(() => | ||||
|           u.getGreatestPixDiff(sketchOriginLocation, lightThemeSegmentColor) | ||||
|         ) | ||||
|         .toBeLessThan(15) | ||||
|     }) | ||||
|   }) | ||||
|  | ||||
|   test(`Turning off "Show debug panel" with debug panel open leaves no phantom panel`, async ({ | ||||
|     page, | ||||
|   }) => { | ||||
|     const u = await getUtils(page) | ||||
|  | ||||
|     // Override beforeEach test setup | ||||
|     // with debug panel open | ||||
|     // but "show debug panel" set to false | ||||
|     await page.addInitScript( | ||||
|       async ({ settingsKey, settings }) => { | ||||
|         localStorage.setItem(settingsKey, settings) | ||||
|         localStorage.setItem( | ||||
|           'persistModelingContext', | ||||
|           '{"openPanes":["debug"]}' | ||||
|         ) | ||||
|       }, | ||||
|       { | ||||
|         settingsKey: TEST_SETTINGS_KEY, | ||||
|         settings: TOML.stringify({ | ||||
|           settings: { | ||||
|             ...TEST_SETTINGS, | ||||
|             modeling: { ...TEST_SETTINGS.modeling, showDebugPanel: false }, | ||||
|           }, | ||||
|         }), | ||||
|       } | ||||
|     ) | ||||
|     await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|  | ||||
|     // Constants and locators | ||||
|     const resizeHandle = page.locator('.sidebar-resize-handles > div.block') | ||||
|     const debugPaneButton = page.getByTestId('debug-pane-button') | ||||
|     const commandsButton = page.getByRole('button', { name: 'Commands' }) | ||||
|     const debugPaneOption = page.getByRole('option', { | ||||
|       name: 'Settings · modeling · show debug panel', | ||||
|     }) | ||||
|  | ||||
|     async function setShowDebugPanelTo(value: 'On' | 'Off') { | ||||
|       await commandsButton.click() | ||||
|       await debugPaneOption.click() | ||||
|       await page.getByRole('option', { name: value }).click() | ||||
|       await expect( | ||||
|         page.getByText( | ||||
|           `Set show debug panel to "${value === 'On'}" for this project` | ||||
|         ) | ||||
|       ).toBeVisible() | ||||
|     } | ||||
|  | ||||
|     await test.step(`Initial load with corrupted settings`, async () => { | ||||
|       await u.waitForAuthSkipAppStart() | ||||
|       // Check that the debug panel is not visible | ||||
|       await expect(debugPaneButton).not.toBeVisible() | ||||
|       // Check the pane resize handle wrapper is not visible | ||||
|       await expect(resizeHandle).not.toBeVisible() | ||||
|     }) | ||||
|  | ||||
|     await test.step(`Open code pane to verify we see the resize handles`, async () => { | ||||
|       await u.openKclCodePanel() | ||||
|       await expect(resizeHandle).toBeVisible() | ||||
|       await u.closeKclCodePanel() | ||||
|     }) | ||||
|  | ||||
|     await test.step(`Turn on debug panel, open it`, async () => { | ||||
|       await setShowDebugPanelTo('On') | ||||
|       await expect(debugPaneButton).toBeVisible() | ||||
|       // We want the logic to clear the phantom panel, so we shouldn't see | ||||
|       // the real panel (and therefore the resize handle) yet | ||||
|       await expect(resizeHandle).not.toBeVisible() | ||||
|       await u.openDebugPanel() | ||||
|       await expect(resizeHandle).toBeVisible() | ||||
|     }) | ||||
|  | ||||
|     await test.step(`Turn off debug panel setting with it open`, async () => { | ||||
|       await setShowDebugPanelTo('Off') | ||||
|       await expect(debugPaneButton).not.toBeVisible() | ||||
|       await expect(resizeHandle).not.toBeVisible() | ||||
|     }) | ||||
|   }) | ||||
| }) | ||||
|  | ||||
| @ -2,8 +2,8 @@ import { test, expect } from '@playwright/test' | ||||
|  | ||||
| import { doExport, getUtils, makeTemplate, setup, tearDown } from './test-utils' | ||||
|  | ||||
| test.beforeEach(async ({ context, page }) => { | ||||
|   await setup(context, page) | ||||
| test.beforeEach(async ({ context, page }, testInfo) => { | ||||
|   await setup(context, page, testInfo) | ||||
| }) | ||||
|  | ||||
| test.afterEach(async ({ page }, testInfo) => { | ||||
|  | ||||
| @ -1,12 +1,9 @@ | ||||
| appId: dev.zoo.modeling-app | ||||
|  | ||||
| directories: | ||||
|   output: out | ||||
|   buildResources: assets | ||||
|  | ||||
| files: | ||||
|   - .vite/** | ||||
|  | ||||
| mac: | ||||
|   category: public.app-category.developer-tools | ||||
|   artifactName: "${productName}-${version}-${arch}-${os}.${ext}" | ||||
| @ -28,7 +25,6 @@ mac: | ||||
|       description: Zoo KCL File | ||||
|       role: Editor | ||||
|       rank: Owner | ||||
|  | ||||
| win: | ||||
|   artifactName: "${productName}-${version}-${arch}-${os}.${ext}" | ||||
|   target: | ||||
| @ -43,7 +39,7 @@ win: | ||||
|   signingHashAlgorithms: | ||||
|     - sha256 | ||||
|   sign: "./sign-win.js" | ||||
|   publisherName: "KittyCAD Inc"  # needs to be exactly like on Digicert | ||||
|   publisherName: "KittyCAD Inc" # needs to be exactly like on Digicert | ||||
|   icon: "assets/icon.ico" | ||||
|   fileAssociations: | ||||
|     - ext: kcl | ||||
| @ -51,18 +47,15 @@ win: | ||||
|       mimeType: text/vnd.zoo.kcl | ||||
|       description: Zoo KCL File | ||||
|       role: Editor | ||||
|  | ||||
| msi: | ||||
|   oneClick: false | ||||
|   perMachine: true | ||||
|  | ||||
| nsis: | ||||
|   oneClick: false | ||||
|   perMachine: true | ||||
|   allowElevation: true | ||||
|   installerIcon: "assets/icon.ico" | ||||
|   include: "./installer.nsh" | ||||
|  | ||||
| linux: | ||||
|   artifactName: "${productName}-${version}-${arch}-${os}.${ext}" | ||||
|   target: | ||||
| @ -76,8 +69,7 @@ linux: | ||||
|       mimeType: text/vnd.zoo.kcl | ||||
|       description: Zoo KCL File | ||||
|       role: Editor | ||||
|  | ||||
| publish: | ||||
|   - provider: generic | ||||
|     url: https://dl.zoo.dev/releases/modeling-app/test/cut-release-v0.25.1-updater-test | ||||
|     url: https://dl.zoo.dev/releases/modeling-app | ||||
|     channel: latest | ||||
|  | ||||
| @ -15,6 +15,7 @@ | ||||
|     /> | ||||
|     <link rel="apple-touch-icon" href="/logo192.png" /> | ||||
|     <link rel="manifest" href="/manifest.json" /> | ||||
|     <link rel="stylesheet" href="./inter/inter.css" /> | ||||
|     <link rel="stylesheet" href="https://use.typekit.net/zzv8rvm.css" /> | ||||
|     <script | ||||
|       defer | ||||
|  | ||||
							
								
								
									
										10
									
								
								package.json
									
									
									
									
									
								
							
							
						
						| @ -1,6 +1,6 @@ | ||||
| { | ||||
|   "name": "zoo-modeling-app", | ||||
|   "version": "0.25.1", | ||||
|   "version": "0.25.2", | ||||
|   "private": true, | ||||
|   "productName": "Zoo Modeling App", | ||||
|   "author": { | ||||
| @ -34,7 +34,7 @@ | ||||
|     "@ts-stack/markdown": "^1.5.0", | ||||
|     "@tweenjs/tween.js": "^23.1.1", | ||||
|     "@xstate/inspect": "^0.8.0", | ||||
|     "@xstate/react": "^3.2.2", | ||||
|     "@xstate/react": "^4.1.1", | ||||
|     "bonjour-service": "^1.2.1", | ||||
|     "codemirror": "^6.0.1", | ||||
|     "decamelize": "^6.0.0", | ||||
| @ -64,7 +64,7 @@ | ||||
|     "vscode-languageserver-protocol": "^3.17.5", | ||||
|     "vscode-uri": "^3.0.8", | ||||
|     "web-vitals": "^3.5.2", | ||||
|     "xstate": "^4.38.2" | ||||
|     "xstate": "^5.17.4" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "start": "vite", | ||||
| @ -88,7 +88,7 @@ | ||||
|     "build:wasm": "yarn wasm-prep && cd src/wasm-lib && wasm-pack build --release --target web --out-dir pkg && cargo test -p kcl-lib export_bindings && cd ../.. && yarn isomorphic-copy-wasm && yarn fmt", | ||||
|     "remove-importmeta": "sed -i 's/import.meta.url/window.location.origin/g' \"./src/wasm-lib/pkg/wasm_lib.js\"; sed -i '' 's/import.meta.url/window.location.origin/g' \"./src/wasm-lib/pkg/wasm_lib.js\" || echo \"sed for both mac and linux\"", | ||||
|     "wasm-prep": "rimraf src/wasm-lib/pkg && mkdirp src/wasm-lib/pkg && rimraf src/wasm-lib/kcl/bindings", | ||||
|     "lint": "eslint --fix src e2e", | ||||
|     "lint": "eslint --fix src e2e packages/codemirror-lsp-client", | ||||
|     "bump-jsons": "echo \"$(jq --arg v \"$VERSION\" '.version=$v' package.json --indent 2)\" > package.json", | ||||
|     "postinstall": "yarn xstate:typegen && ./node_modules/.bin/electron-rebuild", | ||||
|     "xstate:typegen": "yarn xstate typegen \"src/**/*.ts?(x)\"", | ||||
| @ -183,7 +183,7 @@ | ||||
|     "tailwindcss": "^3.4.1", | ||||
|     "ts-node": "^10.0.0", | ||||
|     "typescript": "^5.0.0", | ||||
|     "vite": "^5.4.2", | ||||
|     "vite": "^5.4.3", | ||||
|     "vite-plugin-eslint": "^1.8.1", | ||||
|     "vite-plugin-package-version": "^1.1.0", | ||||
|     "vite-tsconfig-paths": "^4.3.2", | ||||
|  | ||||
| @ -72,6 +72,7 @@ export class LanguageServerClient { | ||||
|   async initialize() { | ||||
|     // Start the client in the background. | ||||
|     this.client.setNotifyFn(this.processNotifications.bind(this)) | ||||
|     // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|     this.client.start() | ||||
|  | ||||
|     this.ready = true | ||||
| @ -195,6 +196,9 @@ export class LanguageServerClient { | ||||
|   } | ||||
|  | ||||
|   private processNotifications(notification: LSP.NotificationMessage) { | ||||
|     for (const plugin of this.plugins) plugin.processNotification(notification) | ||||
|     for (const plugin of this.plugins) { | ||||
|       // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|       plugin.processNotification(notification) | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -12,6 +12,7 @@ export default function lspFormatExt( | ||||
|       run: (view: EditorView) => { | ||||
|         let value = view.plugin(plugin) | ||||
|         if (!value) return false | ||||
|         // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|         value.requestFormatting() | ||||
|         return true | ||||
|       }, | ||||
|  | ||||
| @ -117,6 +117,7 @@ export class LanguageServerPlugin implements PluginValue { | ||||
|  | ||||
|     this.processLspNotification = options.processLspNotification | ||||
|  | ||||
|     // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|     this.initialize({ | ||||
|       documentText: this.getDocText(), | ||||
|     }) | ||||
| @ -149,6 +150,7 @@ export class LanguageServerPlugin implements PluginValue { | ||||
|   } | ||||
|  | ||||
|   async initialize({ documentText }: { documentText: string }) { | ||||
|     // eslint-disable-next-line @typescript-eslint/no-misused-promises | ||||
|     if (this.client.initializePromise) { | ||||
|       await this.client.initializePromise | ||||
|     } | ||||
| @ -162,7 +164,9 @@ export class LanguageServerPlugin implements PluginValue { | ||||
|       }, | ||||
|     }) | ||||
|  | ||||
|     // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|     this.requestSemanticTokens() | ||||
|     // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|     this.updateFoldingRanges() | ||||
|   } | ||||
|  | ||||
| @ -225,7 +229,9 @@ export class LanguageServerPlugin implements PluginValue { | ||||
|         contentChanges: [{ text: this.view.state.doc.toString() }], | ||||
|       }) | ||||
|  | ||||
|       // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|       this.requestSemanticTokens() | ||||
|       // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|       this.updateFoldingRanges() | ||||
|     } catch (e) { | ||||
|       console.error(e) | ||||
| @ -526,7 +532,9 @@ export class LanguageServerPlugin implements PluginValue { | ||||
|   processDiagnostics(params: PublishDiagnosticsParams) { | ||||
|     if (params.uri !== this.getDocUri()) return | ||||
|  | ||||
|     const diagnostics = params.diagnostics | ||||
|     // Commented to avoid the lint.  See TODO below. | ||||
|     // const diagnostics = | ||||
|     params.diagnostics | ||||
|       .map(({ range, message, severity }) => ({ | ||||
|         from: posToOffset(this.view.state.doc, range.start)!, | ||||
|         to: posToOffset(this.view.state.doc, range.end)!, | ||||
|  | ||||
| @ -57,10 +57,10 @@ export default defineConfig({ | ||||
|         }, | ||||
|       }, // or 'chrome-beta' | ||||
|     }, | ||||
|     { | ||||
|       name: 'webkit', | ||||
|       use: { ...devices['Desktop Safari'] }, | ||||
|     }, | ||||
|     // { | ||||
|     //   name: 'webkit', | ||||
|     //   use: { ...devices['Desktop Safari'] }, | ||||
|     // }, | ||||
|     // { | ||||
|     //   name: 'firefox', | ||||
|     //   use: { ...devices['Desktop Firefox'] }, | ||||
|  | ||||
							
								
								
									
										
											BIN
										
									
								
								public/inter/InterVariable-Italic.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								public/inter/InterVariable.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										14
									
								
								public/inter/inter.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,14 @@ | ||||
| @font-face { | ||||
|   font-family: Inter; | ||||
|   font-style: normal; | ||||
|   font-weight: 100 900; | ||||
|   font-display: swap; | ||||
|   src: url("InterVariable.woff2") format("woff2"); | ||||
| } | ||||
| @font-face { | ||||
|   font-family: Inter; | ||||
|   font-style: italic; | ||||
|   font-weight: 100 900; | ||||
|   font-display: swap; | ||||
|   src: url("InterVariable-Italic.woff2") format("woff2"); | ||||
| } | ||||
							
								
								
									
										59
									
								
								src/App.tsx
									
									
									
									
									
								
							
							
						
						| @ -1,12 +1,8 @@ | ||||
| import { MouseEventHandler, useEffect, useMemo, useRef } from 'react' | ||||
| import { uuidv4 } from 'lib/utils' | ||||
| import { useEffect, useMemo, useRef } from 'react' | ||||
| import { useHotKeyListener } from './hooks/useHotKeyListener' | ||||
| import { Stream } from './components/Stream' | ||||
| import { EngineCommand } from 'lang/std/artifactGraph' | ||||
| import { throttle } from './lib/utils' | ||||
| import { AppHeader } from './components/AppHeader' | ||||
| import { useHotkeys } from 'react-hotkeys-hook' | ||||
| import { getNormalisedCoordinates } from './lib/utils' | ||||
| import { useLoaderData, useNavigate } from 'react-router-dom' | ||||
| import { type IndexLoaderData } from 'lib/types' | ||||
| import { PATHS } from 'lib/paths' | ||||
| @ -14,7 +10,6 @@ import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' | ||||
| import { onboardingPaths } from 'routes/Onboarding/paths' | ||||
| import { useEngineConnectionSubscriptions } from 'hooks/useEngineConnectionSubscriptions' | ||||
| import { codeManager, engineCommandManager } from 'lib/singletons' | ||||
| import { useModelingContext } from 'hooks/useModelingContext' | ||||
| import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' | ||||
| import { isDesktop } from 'lib/isDesktop' | ||||
| import { useLspContext } from 'components/LspProvider' | ||||
| @ -44,7 +39,6 @@ export function App() { | ||||
|   }, [projectName, projectPath]) | ||||
|  | ||||
|   useHotKeyListener() | ||||
|   const { context, state } = useModelingContext() | ||||
|  | ||||
|   const { auth, settings } = useSettingsAuthContext() | ||||
|   const token = auth?.context?.token | ||||
| @ -73,61 +67,14 @@ export function App() { | ||||
|     (p) => p === onboardingStatus.current | ||||
|   ) | ||||
|     ? 'opacity-20' | ||||
|     : context.store?.didDragInStream | ||||
|     ? 'opacity-40' | ||||
|     : '' | ||||
|  | ||||
|   useEngineConnectionSubscriptions() | ||||
|  | ||||
|   const debounceSocketSend = throttle<EngineCommand>((message) => { | ||||
|     engineCommandManager.sendSceneCommand(message) | ||||
|   }, 1000 / 15) | ||||
|   const handleMouseMove: MouseEventHandler<HTMLDivElement> = (e) => { | ||||
|     if (state.matches('Sketch')) { | ||||
|       return | ||||
|     } | ||||
|  | ||||
|     const { x, y } = getNormalisedCoordinates({ | ||||
|       clientX: e.clientX, | ||||
|       clientY: e.clientY, | ||||
|       el: e.currentTarget, | ||||
|       ...context.store?.streamDimensions, | ||||
|     }) | ||||
|  | ||||
|     const newCmdId = uuidv4() | ||||
|     if (state.matches('idle.showPlanes')) return | ||||
|     if (context.store?.buttonDownInStream !== undefined) return | ||||
|     debounceSocketSend({ | ||||
|       type: 'modeling_cmd_req', | ||||
|       cmd: { | ||||
|         type: 'highlight_set_entity', | ||||
|         selected_at_window: { x, y }, | ||||
|       }, | ||||
|       cmd_id: newCmdId, | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div | ||||
|       className="relative h-full flex flex-col" | ||||
|       onMouseMove={handleMouseMove} | ||||
|       ref={ref} | ||||
|     > | ||||
|     <div className="relative h-full flex flex-col" ref={ref}> | ||||
|       <AppHeader | ||||
|         className={ | ||||
|           'transition-opacity transition-duration-75 ' + | ||||
|           paneOpacity + | ||||
|           (context.store?.buttonDownInStream ? ' pointer-events-none' : '') | ||||
|         } | ||||
|         // Override the electron window draggable region behavior as well | ||||
|         // when the button is down in the stream | ||||
|         style={ | ||||
|           isDesktop() && context.store?.buttonDownInStream | ||||
|             ? ({ | ||||
|                 '-webkit-app-region': 'no-drag', | ||||
|               } as React.CSSProperties) | ||||
|             : {} | ||||
|         } | ||||
|         className={'transition-opacity transition-duration-75 ' + paneOpacity} | ||||
|         project={{ project, file }} | ||||
|         enableMenu={true} | ||||
|       /> | ||||
|  | ||||
| @ -41,6 +41,7 @@ import toast from 'react-hot-toast' | ||||
| import { coreDump } from 'lang/wasm' | ||||
| import { useMemo } from 'react' | ||||
| import { AppStateProvider } from 'AppState' | ||||
| import { reportRejection } from 'lib/trap' | ||||
|  | ||||
| const createRouter = isDesktop() ? createHashRouter : createBrowserRouter | ||||
|  | ||||
| @ -173,21 +174,23 @@ function CoreDump() { | ||||
|     [] | ||||
|   ) | ||||
|   useHotkeyWrapper(['mod + shift + .'], () => { | ||||
|     toast.promise( | ||||
|       coreDump(coreDumpManager, true), | ||||
|       { | ||||
|         loading: 'Starting core dump...', | ||||
|         success: 'Core dump completed successfully', | ||||
|         error: 'Error while exporting core dump', | ||||
|       }, | ||||
|       { | ||||
|         success: { | ||||
|           // Note: this extended duration is especially important for Playwright e2e testing | ||||
|           // default duration is 2000 - https://react-hot-toast.com/docs/toast#default-durations | ||||
|           duration: 6000, | ||||
|     toast | ||||
|       .promise( | ||||
|         coreDump(coreDumpManager, true), | ||||
|         { | ||||
|           loading: 'Starting core dump...', | ||||
|           success: 'Core dump completed successfully', | ||||
|           error: 'Error while exporting core dump', | ||||
|         }, | ||||
|       } | ||||
|     ) | ||||
|         { | ||||
|           success: { | ||||
|             // Note: this extended duration is especially important for Playwright e2e testing | ||||
|             // default duration is 2000 - https://react-hot-toast.com/docs/toast#default-durations | ||||
|             duration: 6000, | ||||
|           }, | ||||
|         } | ||||
|       ) | ||||
|       .catch(reportRejection) | ||||
|   }) | ||||
|   return null | ||||
| } | ||||
|  | ||||
| @ -70,12 +70,12 @@ export function Toolbar({ | ||||
|    */ | ||||
|   const configCallbackProps: ToolbarItemCallbackProps = useMemo( | ||||
|     () => ({ | ||||
|       modelingStateMatches: state.matches, | ||||
|       modelingState: state, | ||||
|       modelingSend: send, | ||||
|       commandBarSend, | ||||
|       sketchPathId, | ||||
|     }), | ||||
|     [state.matches, send, commandBarSend, sketchPathId] | ||||
|     [state, send, commandBarSend, sketchPathId] | ||||
|   ) | ||||
|  | ||||
|   /** | ||||
| @ -124,7 +124,7 @@ export function Toolbar({ | ||||
|   }, [currentMode, disableAllButtons, configCallbackProps]) | ||||
|  | ||||
|   return ( | ||||
|     <menu className="max-w-full whitespace-nowrap rounded-b px-2 py-1 bg-chalkboard-10 dark:bg-chalkboard-90 relative border border-chalkboard-20 dark:border-chalkboard-80 border-t-0 shadow-sm"> | ||||
|     <menu className="max-w-full whitespace-nowrap rounded-b px-2 py-1 bg-chalkboard-10 dark:bg-chalkboard-90 relative border border-chalkboard-30 dark:border-chalkboard-80 border-t-0 shadow-sm"> | ||||
|       <ul | ||||
|         {...props} | ||||
|         ref={toolbarButtonsRef} | ||||
|  | ||||
| @ -22,14 +22,16 @@ import { | ||||
|   UnreliableSubscription, | ||||
| } from 'lang/std/engineConnection' | ||||
| import { EngineCommand } from 'lang/std/artifactGraph' | ||||
| import { uuidv4 } from 'lib/utils' | ||||
| import { toSync, uuidv4 } from 'lib/utils' | ||||
| import { deg2Rad } from 'lib/utils2d' | ||||
| import { isReducedMotion, roundOff, throttle } from 'lib/utils' | ||||
| import * as TWEEN from '@tweenjs/tween.js' | ||||
| import { isQuaternionVertical } from './helpers' | ||||
| import { reportRejection } from 'lib/trap' | ||||
|  | ||||
| const ORTHOGRAPHIC_CAMERA_SIZE = 20 | ||||
| const FRAMES_TO_ANIMATE_IN = 30 | ||||
| const ORTHOGRAPHIC_MAGIC_FOV = 4 | ||||
|  | ||||
| const tempQuaternion = new Quaternion() // just used for maths | ||||
|  | ||||
| @ -83,7 +85,7 @@ export class CameraControls { | ||||
|   pendingPan: Vector2 | null = null | ||||
|   interactionGuards: MouseGuard = cameraMouseDragGuards.KittyCAD | ||||
|   isFovAnimationInProgress = false | ||||
|   fovBeforeOrtho = 45 | ||||
|   perspectiveFovBeforeOrtho = 45 | ||||
|   get isPerspective() { | ||||
|     return this.camera instanceof PerspectiveCamera | ||||
|   } | ||||
| @ -100,6 +102,7 @@ export class CameraControls { | ||||
|       camProps.type === 'perspective' && | ||||
|       this.camera instanceof OrthographicCamera | ||||
|     ) { | ||||
|       // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|       this.usePerspectiveCamera() | ||||
|     } else if ( | ||||
|       camProps.type === 'orthographic' && | ||||
| @ -127,6 +130,7 @@ export class CameraControls { | ||||
|   } | ||||
|  | ||||
|   throttledEngCmd = throttle((cmd: EngineCommand) => { | ||||
|     // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|     this.engineCommandManager.sendSceneCommand(cmd) | ||||
|   }, 1000 / 30) | ||||
|  | ||||
| @ -139,6 +143,7 @@ export class CameraControls { | ||||
|         ...convertThreeCamValuesToEngineCam(threeValues), | ||||
|       }, | ||||
|     } | ||||
|     // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|     this.engineCommandManager.sendSceneCommand(cmd) | ||||
|   }, 1000 / 15) | ||||
|  | ||||
| @ -151,6 +156,7 @@ export class CameraControls { | ||||
|       this.lastPerspectiveCmd && | ||||
|       Date.now() - this.lastPerspectiveCmdTime >= lastCmdDelay | ||||
|     ) { | ||||
|       // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|       this.engineCommandManager.sendSceneCommand(this.lastPerspectiveCmd, true) | ||||
|       this.lastPerspectiveCmdTime = Date.now() | ||||
|     } | ||||
| @ -218,6 +224,7 @@ export class CameraControls { | ||||
|         this.useOrthographicCamera() | ||||
|       } | ||||
|       if (this.camera instanceof OrthographicCamera && !camSettings.ortho) { | ||||
|         // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|         this.usePerspectiveCamera() | ||||
|       } | ||||
|       if (this.camera instanceof PerspectiveCamera && camSettings.fov_y) { | ||||
| @ -249,6 +256,7 @@ export class CameraControls { | ||||
|     const doZoom = () => { | ||||
|       if (this.zoomDataFromLastFrame !== undefined) { | ||||
|         this.handleStart() | ||||
|         // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|         this.engineCommandManager.sendSceneCommand({ | ||||
|           type: 'modeling_cmd_req', | ||||
|           cmd: { | ||||
| @ -266,6 +274,7 @@ export class CameraControls { | ||||
|  | ||||
|     const doMove = () => { | ||||
|       if (this.moveDataFromLastFrame !== undefined) { | ||||
|         // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|         this.engineCommandManager.sendSceneCommand({ | ||||
|           type: 'modeling_cmd_req', | ||||
|           cmd: { | ||||
| @ -335,7 +344,8 @@ export class CameraControls { | ||||
|     this.camera.updateProjectionMatrix() | ||||
|   } | ||||
|  | ||||
|   onMouseDown = (event: MouseEvent) => { | ||||
|   onMouseDown = (event: PointerEvent) => { | ||||
|     this.domElement.setPointerCapture(event.pointerId) | ||||
|     this.isDragging = true | ||||
|     this.mouseDownPosition.set(event.clientX, event.clientY) | ||||
|     let interaction = this.getInteractionType(event) | ||||
| @ -355,7 +365,7 @@ export class CameraControls { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   onMouseMove = (event: MouseEvent) => { | ||||
|   onMouseMove = (event: PointerEvent) => { | ||||
|     if (this.isDragging) { | ||||
|       this.mouseNewPosition.set(event.clientX, event.clientY) | ||||
|       const deltaMove = this.mouseNewPosition | ||||
| @ -389,14 +399,33 @@ export class CameraControls { | ||||
|           const zoomFudgeFactor = 2280 | ||||
|           distance = zoomFudgeFactor / (this.camera.zoom * 45) | ||||
|         } | ||||
|         const panSpeed = (distance / 1000 / 45) * this.fovBeforeOrtho | ||||
|         const panSpeed = (distance / 1000 / 45) * this.perspectiveFovBeforeOrtho | ||||
|         this.pendingPan.x += -deltaMove.x * panSpeed | ||||
|         this.pendingPan.y += deltaMove.y * panSpeed | ||||
|       } | ||||
|     } else { | ||||
|       /** | ||||
|        * If we're not in sketch mode and not dragging, we can highlight entities | ||||
|        * under the cursor. This recently moved from being handled in App.tsx. | ||||
|        * This might not be the right spot, but it is more consolidated. | ||||
|        */ | ||||
|       if (this.syncDirection === 'engineToClient') { | ||||
|         const newCmdId = uuidv4() | ||||
|  | ||||
|         this.throttledEngCmd({ | ||||
|           type: 'modeling_cmd_req', | ||||
|           cmd: { | ||||
|             type: 'highlight_set_entity', | ||||
|             selected_at_window: { x: event.clientX, y: event.clientY }, | ||||
|           }, | ||||
|           cmd_id: newCmdId, | ||||
|         }) | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   onMouseUp = (event: MouseEvent) => { | ||||
|   onMouseUp = (event: PointerEvent) => { | ||||
|     this.domElement.releasePointerCapture(event.pointerId) | ||||
|     this.isDragging = false | ||||
|     this.handleEnd() | ||||
|     if (this.syncDirection === 'engineToClient') { | ||||
| @ -415,8 +444,19 @@ export class CameraControls { | ||||
|   } | ||||
|  | ||||
|   onMouseWheel = (event: WheelEvent) => { | ||||
|     const interaction = this.getInteractionType(event) | ||||
|     if (interaction === 'none') return | ||||
|     event.preventDefault() | ||||
|  | ||||
|     if (this.syncDirection === 'engineToClient') { | ||||
|       this.zoomDataFromLastFrame = event.deltaY | ||||
|       if (interaction === 'zoom') { | ||||
|         this.zoomDataFromLastFrame = event.deltaY | ||||
|       } else { | ||||
|         // This case will get handled when we add pan and rotate using Apple trackpad. | ||||
|         console.error( | ||||
|           `Unexpected interaction type for engineToClient wheel event: ${interaction}` | ||||
|         ) | ||||
|       } | ||||
|       return | ||||
|     } | ||||
|  | ||||
| @ -426,8 +466,16 @@ export class CameraControls { | ||||
|     // zoom commands to engine. This means dropping some zoom | ||||
|     // commands too. | ||||
|     // From onMouseMove zoom handling which seems to be really smooth | ||||
|  | ||||
|     this.handleStart() | ||||
|     this.pendingZoom = 1 + (event.deltaY / window.devicePixelRatio) * 0.001 | ||||
|     if (interaction === 'zoom') { | ||||
|       this.pendingZoom = 1 + (event.deltaY / window.devicePixelRatio) * 0.001 | ||||
|     } else { | ||||
|       // This case will get handled when we add pan and rotate using Apple trackpad. | ||||
|       console.error( | ||||
|         `Unexpected interaction type for wheel event: ${interaction}` | ||||
|       ) | ||||
|     } | ||||
|     this.handleEnd() | ||||
|   } | ||||
|  | ||||
| @ -459,6 +507,7 @@ export class CameraControls { | ||||
|  | ||||
|     this.camera.quaternion.set(qx, qy, qz, qw) | ||||
|     this.camera.updateProjectionMatrix() | ||||
|     // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|     this.engineCommandManager.sendSceneCommand({ | ||||
|       type: 'modeling_cmd_req', | ||||
|       cmd_id: uuidv4(), | ||||
| @ -487,19 +536,15 @@ export class CameraControls { | ||||
|   _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) | ||||
|   } | ||||
|   usePerspectiveCamera = async (forceSend = false) => { | ||||
|     this._usePerspectiveCamera() | ||||
| @ -541,7 +586,7 @@ export class CameraControls { | ||||
|     const oldFov = this.camera.fov | ||||
|  | ||||
|     const viewHeightFactor = (fov: number) => { | ||||
|       /*       *  | ||||
|       /*       * | ||||
|               /| | ||||
|              / | | ||||
|             /  | | ||||
| @ -929,6 +974,7 @@ export class CameraControls { | ||||
|       } | ||||
|  | ||||
|       if (isReducedMotion()) { | ||||
|         // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|         onComplete() | ||||
|         return | ||||
|       } | ||||
| @ -937,7 +983,7 @@ export class CameraControls { | ||||
|         .to({ t: tweenEnd }, duration) | ||||
|         .easing(TWEEN.Easing.Quadratic.InOut) | ||||
|         .onUpdate(({ t }) => cameraAtTime(t)) | ||||
|         .onComplete(onComplete) | ||||
|         .onComplete(toSync(onComplete, reportRejection)) | ||||
|         .start() | ||||
|     }) | ||||
|   } | ||||
| @ -950,9 +996,9 @@ export class CameraControls { | ||||
|         ) | ||||
|       this.isFovAnimationInProgress = true | ||||
|       let currentFov = this.lastPerspectiveFov | ||||
|       this.fovBeforeOrtho = currentFov | ||||
|       this.perspectiveFovBeforeOrtho = currentFov | ||||
|  | ||||
|       const targetFov = 4 | ||||
|       const targetFov = ORTHOGRAPHIC_MAGIC_FOV | ||||
|       const fovAnimationStep = (currentFov - targetFov) / FRAMES_TO_ANIMATE_IN | ||||
|       let frameWaitOnFinish = 10 | ||||
|  | ||||
| @ -962,6 +1008,7 @@ export class CameraControls { | ||||
|             // Decrease the FOV | ||||
|             currentFov = Math.max(currentFov - fovAnimationStep, targetFov) | ||||
|             this.camera.updateProjectionMatrix() | ||||
|             // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|             this.dollyZoom(currentFov) | ||||
|             requestAnimationFrame(animateFovChange) // Continue the animation | ||||
|           } else if (frameWaitOnFinish > 0) { | ||||
| @ -987,10 +1034,11 @@ export class CameraControls { | ||||
|         ) | ||||
|       } | ||||
|       this.isFovAnimationInProgress = true | ||||
|       const targetFov = this.fovBeforeOrtho // Target FOV for perspective | ||||
|       this.lastPerspectiveFov = 4 | ||||
|       let currentFov = 4 | ||||
|       const targetFov = this.perspectiveFovBeforeOrtho // Target FOV for perspective | ||||
|       this.lastPerspectiveFov = ORTHOGRAPHIC_MAGIC_FOV | ||||
|       let currentFov = ORTHOGRAPHIC_MAGIC_FOV | ||||
|       const initialCameraUp = this.camera.up.clone() | ||||
|       // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|       this.usePerspectiveCamera() | ||||
|       const tempVec = new Vector3() | ||||
|  | ||||
| @ -999,6 +1047,7 @@ export class CameraControls { | ||||
|           this.lastPerspectiveFov + (targetFov - this.lastPerspectiveFov) * t | ||||
|         const currentUp = tempVec.lerpVectors(initialCameraUp, targetCamUp, t) | ||||
|         this.camera.up.copy(currentUp) | ||||
|         // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|         this.dollyZoom(currentFov) | ||||
|       } | ||||
|  | ||||
| @ -1023,10 +1072,10 @@ export class CameraControls { | ||||
|       ) | ||||
|     } | ||||
|     this.isFovAnimationInProgress = true | ||||
|     const targetFov = this.fovBeforeOrtho // Target FOV for perspective | ||||
|     this.lastPerspectiveFov = 4 | ||||
|     let currentFov = 4 | ||||
|     const targetFov = this.perspectiveFovBeforeOrtho // Target FOV for perspective | ||||
|     let currentFov = ORTHOGRAPHIC_MAGIC_FOV | ||||
|     const initialCameraUp = this.camera.up.clone() | ||||
|     // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|     this.usePerspectiveCamera() | ||||
|     const tempVec = new Vector3() | ||||
|  | ||||
| @ -1093,7 +1142,7 @@ export class CameraControls { | ||||
|     this.deferReactUpdate(this.reactCameraProperties) | ||||
|     Object.values(this._camChangeCallbacks).forEach((cb) => cb()) | ||||
|   } | ||||
|   getInteractionType = (event: any) => | ||||
|   getInteractionType = (event: MouseEvent) => | ||||
|     _getInteractionType( | ||||
|       this.interactionGuards, | ||||
|       event, | ||||
| @ -1175,7 +1224,7 @@ function convertThreeCamValuesToEngineCam({ | ||||
|   const lookAt = buildLookAt(64 / zoom, target, position) | ||||
|   return { | ||||
|     center: new Vector3(lookAt.center.x, lookAt.center.y, lookAt.center.z), | ||||
|     up: new Vector3(0, 0, 1), | ||||
|     up: new Vector3(upVector.x, upVector.y, upVector.z), | ||||
|     vantage: new Vector3(lookAt.eye.x, lookAt.eye.y, lookAt.eye.z), | ||||
|   } | ||||
| } | ||||
| @ -1201,16 +1250,21 @@ function _lookAt(position: Vector3, target: Vector3, up: Vector3): Quaternion { | ||||
|  | ||||
| function _getInteractionType( | ||||
|   interactionGuards: MouseGuard, | ||||
|   event: any, | ||||
|   event: MouseEvent | WheelEvent, | ||||
|   enablePan: boolean, | ||||
|   enableRotate: boolean, | ||||
|   enableZoom: boolean | ||||
| ): interactionType | 'none' { | ||||
|   let state: interactionType | 'none' = 'none' | ||||
|   if (enablePan && interactionGuards.pan.callback(event)) return 'pan' | ||||
|   if (enableRotate && interactionGuards.rotate.callback(event)) return 'rotate' | ||||
|   if (enableZoom && interactionGuards.zoom.dragCallback(event)) return 'zoom' | ||||
|   return state | ||||
|   if (event instanceof WheelEvent) { | ||||
|     if (enableZoom && interactionGuards.zoom.scrollCallback(event)) | ||||
|       return 'zoom' | ||||
|   } else { | ||||
|     if (enablePan && interactionGuards.pan.callback(event)) return 'pan' | ||||
|     if (enableRotate && interactionGuards.rotate.callback(event)) | ||||
|       return 'rotate' | ||||
|     if (enableZoom && interactionGuards.zoom.dragCallback(event)) return 'zoom' | ||||
|   } | ||||
|   return 'none' | ||||
| } | ||||
|  | ||||
| /** | ||||
|  | ||||
| @ -5,7 +5,7 @@ import { cameraMouseDragGuards } from 'lib/cameraControls' | ||||
| import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' | ||||
| import { ARROWHEAD, DEBUG_SHOW_BOTH_SCENES } from './sceneInfra' | ||||
| import { ReactCameraProperties } from './CameraControls' | ||||
| import { throttle } from 'lib/utils' | ||||
| import { throttle, toSync } from 'lib/utils' | ||||
| import { | ||||
|   sceneInfra, | ||||
|   kclManager, | ||||
| @ -34,17 +34,15 @@ import { CustomIcon, CustomIconName } from 'components/CustomIcon' | ||||
| import { ConstrainInfo } from 'lang/std/stdTypes' | ||||
| import { getConstraintInfo } from 'lang/std/sketch' | ||||
| import { Dialog, Popover, Transition } from '@headlessui/react' | ||||
| import { LineInputsType } from 'lang/std/sketchcombos' | ||||
| import toast from 'react-hot-toast' | ||||
| import { InstanceProps, create } from 'react-modal-promise' | ||||
| import { executeAst } from 'lang/langHelpers' | ||||
| import { | ||||
|   deleteSegmentFromPipeExpression, | ||||
|   makeRemoveSingleConstraintInput, | ||||
|   removeSingleConstraintInfo, | ||||
| } from 'lang/modifyAst' | ||||
| import { ActionButton } from 'components/ActionButton' | ||||
| import { err, trap } from 'lib/trap' | ||||
| import { err, reportRejection, trap } from 'lib/trap' | ||||
|  | ||||
| function useShouldHideScene(): { hideClient: boolean; hideServer: boolean } { | ||||
|   const [isCamMoving, setIsCamMoving] = useState(false) | ||||
| @ -124,9 +122,9 @@ export const ClientSideScene = ({ | ||||
|     } else if (context.mouseState.type === 'isDragging') { | ||||
|       cursor = 'grabbing' | ||||
|     } else if ( | ||||
|       state.matches('Sketch.Line tool') || | ||||
|       state.matches('Sketch.Tangential arc to') || | ||||
|       state.matches('Sketch.Rectangle tool') | ||||
|       state.matches({ Sketch: 'Line tool' }) || | ||||
|       state.matches({ Sketch: 'Tangential arc to' }) || | ||||
|       state.matches({ Sketch: 'Rectangle tool' }) | ||||
|     ) { | ||||
|       cursor = 'crosshair' | ||||
|     } else { | ||||
| @ -214,9 +212,9 @@ const Overlay = ({ | ||||
|     overlay.visible && | ||||
|     typeof context?.segmentHoverMap?.[pathToNodeString] === 'number' && | ||||
|     !( | ||||
|       state.matches('Sketch.Line tool') || | ||||
|       state.matches('Sketch.Tangential arc to') || | ||||
|       state.matches('Sketch.Rectangle tool') | ||||
|       state.matches({ Sketch: 'Line tool' }) || | ||||
|       state.matches({ Sketch: 'Tangential arc to' }) || | ||||
|       state.matches({ Sketch: 'Rectangle tool' }) | ||||
|     ) | ||||
|  | ||||
|   return ( | ||||
| @ -542,12 +540,10 @@ const ConstraintSymbol = ({ | ||||
|       iconName: 'dimension', | ||||
|     }, | ||||
|   } | ||||
|   const varName = | ||||
|     _type in varNameMap ? varNameMap[_type as LineInputsType].varName : 'var' | ||||
|   const name: CustomIconName = varNameMap[_type as LineInputsType].iconName | ||||
|   const displayName = varNameMap[_type as LineInputsType]?.displayName | ||||
|   const implicitDesc = | ||||
|     varNameMap[_type as LineInputsType]?.implicitConstraintDesc | ||||
|   const varName = varNameMap?.[_type]?.varName || 'var' | ||||
|   const name: CustomIconName = varNameMap[_type].iconName | ||||
|   const displayName = varNameMap[_type]?.displayName | ||||
|   const implicitDesc = varNameMap[_type]?.implicitConstraintDesc | ||||
|  | ||||
|   const _node = useMemo( | ||||
|     () => getNodeFromPath<Expr>(kclManager.ast, pathToNode), | ||||
| @ -582,7 +578,7 @@ const ConstraintSymbol = ({ | ||||
|         }} | ||||
|         // disabled={isConstrained || !convertToVarEnabled} | ||||
|         // disabled={implicitDesc} TODO why does this change styles that are hard to override? | ||||
|         onClick={async () => { | ||||
|         onClick={toSync(async () => { | ||||
|           if (!isConstrained) { | ||||
|             send({ | ||||
|               type: 'Convert to variable', | ||||
| @ -604,25 +600,23 @@ const ConstraintSymbol = ({ | ||||
|               if (trap(_node1)) return Promise.reject(_node1) | ||||
|               const shallowPath = _node1.shallowPath | ||||
|  | ||||
|               const input = makeRemoveSingleConstraintInput( | ||||
|                 argPosition, | ||||
|                 shallowPath | ||||
|               ) | ||||
|               if (!input || !context.sketchDetails) return | ||||
|               if (!context.sketchDetails || !argPosition) return | ||||
|               const transform = removeSingleConstraintInfo( | ||||
|                 input, | ||||
|                 shallowPath, | ||||
|                 argPosition, | ||||
|                 kclManager.ast, | ||||
|                 kclManager.programMemory | ||||
|               ) | ||||
|               if (!transform) return | ||||
|               const { modifiedAst } = transform | ||||
|               // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|               kclManager.updateAst(modifiedAst, true) | ||||
|             } catch (e) { | ||||
|               console.log('error', e) | ||||
|             } | ||||
|             toast.success('Constraint removed') | ||||
|           } | ||||
|         }} | ||||
|         }, reportRejection)} | ||||
|       > | ||||
|         <CustomIcon name={name} /> | ||||
|       </button> | ||||
| @ -688,7 +682,7 @@ const ConstraintSymbol = ({ | ||||
|  | ||||
| const throttled = throttle((a: ReactCameraProperties) => { | ||||
|   if (a.type === 'perspective' && a.fov) { | ||||
|     sceneInfra.camControls.dollyZoom(a.fov) | ||||
|     sceneInfra.camControls.dollyZoom(a.fov).catch(reportRejection) | ||||
|   } | ||||
| }, 1000 / 15) | ||||
|  | ||||
| @ -718,6 +712,7 @@ export const CamDebugSettings = () => { | ||||
|           if (camSettings.type === 'perspective') { | ||||
|             sceneInfra.camControls.useOrthographicCamera() | ||||
|           } else { | ||||
|             // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|             sceneInfra.camControls.usePerspectiveCamera(true) | ||||
|           } | ||||
|         }} | ||||
| @ -725,7 +720,7 @@ export const CamDebugSettings = () => { | ||||
|       <div> | ||||
|         <button | ||||
|           onClick={() => { | ||||
|             sceneInfra.camControls.resetCameraPosition() | ||||
|             sceneInfra.camControls.resetCameraPosition().catch(reportRejection) | ||||
|           }} | ||||
|         > | ||||
|           Reset Camera Position | ||||
|  | ||||
| @ -1,10 +1,8 @@ | ||||
| import { | ||||
|   BoxGeometry, | ||||
|   DoubleSide, | ||||
|   ExtrudeGeometry, | ||||
|   Group, | ||||
|   Intersection, | ||||
|   LineCurve3, | ||||
|   Mesh, | ||||
|   MeshBasicMaterial, | ||||
|   Object3D, | ||||
| @ -15,7 +13,6 @@ import { | ||||
|   Points, | ||||
|   Quaternion, | ||||
|   Scene, | ||||
|   Shape, | ||||
|   Vector2, | ||||
|   Vector3, | ||||
| } from 'three' | ||||
| @ -27,9 +24,6 @@ import { | ||||
|   OnClickCallbackArgs, | ||||
|   OnMouseEnterLeaveArgs, | ||||
|   RAYCASTABLE_PLANE, | ||||
|   SEGMENT_LENGTH_LABEL, | ||||
|   SEGMENT_LENGTH_LABEL_OFFSET_PX, | ||||
|   SEGMENT_LENGTH_LABEL_TEXT, | ||||
|   SKETCH_GROUP_SEGMENTS, | ||||
|   SKETCH_LAYER, | ||||
|   X_AXIS, | ||||
| @ -38,7 +32,6 @@ import { | ||||
| import { isQuaternionVertical, quaternionFromUpNForward } from './helpers' | ||||
| import { | ||||
|   CallExpression, | ||||
|   getTangentialArcToInfo, | ||||
|   parse, | ||||
|   Path, | ||||
|   PathToNode, | ||||
| @ -62,11 +55,9 @@ import { | ||||
| import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst' | ||||
| import { executeAst } from 'lang/langHelpers' | ||||
| import { | ||||
|   createArcGeometry, | ||||
|   dashedStraight, | ||||
|   profileStart, | ||||
|   straightSegment, | ||||
|   tangentialArcToSegment, | ||||
|   createProfileStartHandle, | ||||
|   SegmentUtils, | ||||
|   segmentUtils, | ||||
| } from './segments' | ||||
| import { | ||||
|   addCallExpressionsToPipe, | ||||
| @ -75,13 +66,7 @@ import { | ||||
|   changeSketchArguments, | ||||
|   updateStartProfileAtArgs, | ||||
| } from 'lang/std/sketch' | ||||
| import { | ||||
|   isArray, | ||||
|   isOverlap, | ||||
|   normaliseAngle, | ||||
|   roundOff, | ||||
|   throttle, | ||||
| } from 'lib/utils' | ||||
| import { isArray, isOverlap, roundOff } from 'lib/utils' | ||||
| import { | ||||
|   addStartProfileAt, | ||||
|   createArrayExpression, | ||||
| @ -92,7 +77,6 @@ import { | ||||
|   findUniqueName, | ||||
| } from 'lang/modifyAst' | ||||
| import { Selections, getEventForSegmentSelection } from 'lib/selections' | ||||
| import { getTangentPointFromPreviousArc } from 'lib/utils2d' | ||||
| import { createGridHelper, orthoScale, perspScale } from './helpers' | ||||
| import { Models } from '@kittycad/lib' | ||||
| import { uuidv4 } from 'lib/utils' | ||||
| @ -102,8 +86,8 @@ import { | ||||
|   getRectangleCallExpressions, | ||||
|   updateRectangleSketch, | ||||
| } from 'lib/rectangleTool' | ||||
| import { getThemeColorForThreeJs } from 'lib/theme' | ||||
| import { err, trap } from 'lib/trap' | ||||
| import { getThemeColorForThreeJs, Themes } from 'lib/theme' | ||||
| import { err, reportRejection, trap } from 'lib/trap' | ||||
| import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer' | ||||
| import { Point3d } from 'wasm-lib/kcl/bindings/Point3d' | ||||
|  | ||||
| @ -122,6 +106,11 @@ export const TANGENTIAL_ARC_TO_SEGMENT_BODY = 'tangential-arc-to-segment-body' | ||||
| export const SEGMENT_WIDTH_PX = 1.6 | ||||
| export const HIDE_SEGMENT_LENGTH = 75 // in pixels | ||||
| export const HIDE_HOVER_SEGMENT_LENGTH = 60 // in pixels | ||||
| export const SEGMENT_BODIES = [STRAIGHT_SEGMENT, TANGENTIAL_ARC_TO_SEGMENT] | ||||
| export const SEGMENT_BODIES_PLUS_PROFILE_START = [ | ||||
|   ...SEGMENT_BODIES, | ||||
|   PROFILE_START, | ||||
| ] | ||||
|  | ||||
| type Vec3Array = [number, number, number] | ||||
|  | ||||
| @ -155,37 +144,35 @@ export class SceneEntities { | ||||
|           ? orthoFactor | ||||
|           : perspScale(sceneInfra.camControls.camera, segment)) / | ||||
|         sceneInfra._baseUnitMultiplier | ||||
|       const input = { | ||||
|         type: 'straight-segment', | ||||
|         from: segment.userData.from, | ||||
|         to: segment.userData.to, | ||||
|       } as const | ||||
|       let update: SegmentUtils['update'] | null = null | ||||
|       if ( | ||||
|         segment.userData.from && | ||||
|         segment.userData.to && | ||||
|         segment.userData.type === STRAIGHT_SEGMENT | ||||
|       ) { | ||||
|         callbacks.push( | ||||
|           this.updateStraightSegment({ | ||||
|             from: segment.userData.from, | ||||
|             to: segment.userData.to, | ||||
|             group: segment, | ||||
|             scale: factor, | ||||
|           }) | ||||
|         ) | ||||
|         update = segmentUtils.straight.update | ||||
|       } | ||||
|  | ||||
|       if ( | ||||
|         segment.userData.from && | ||||
|         segment.userData.to && | ||||
|         segment.userData.prevSegment && | ||||
|         segment.userData.type === TANGENTIAL_ARC_TO_SEGMENT | ||||
|       ) { | ||||
|         callbacks.push( | ||||
|           this.updateTangentialArcToSegment({ | ||||
|             prevSegment: segment.userData.prevSegment, | ||||
|             from: segment.userData.from, | ||||
|             to: segment.userData.to, | ||||
|             group: segment, | ||||
|             scale: factor, | ||||
|           }) | ||||
|         ) | ||||
|         update = segmentUtils.tangentialArcTo.update | ||||
|       } | ||||
|       const callBack = update?.({ | ||||
|         prevSegment: segment.userData.prevSegment, | ||||
|         input, | ||||
|         group: segment, | ||||
|         scale: factor, | ||||
|         sceneInfra, | ||||
|       }) | ||||
|       callBack && !err(callBack) && callbacks.push(callBack) | ||||
|       if (segment.name === PROFILE_START) { | ||||
|         segment.scale.set(factor, factor, factor) | ||||
|       } | ||||
| @ -324,6 +311,7 @@ export class SceneEntities { | ||||
|       ) | ||||
|     } | ||||
|     sceneInfra.setCallbacks({ | ||||
|       // eslint-disable-next-line @typescript-eslint/no-misused-promises | ||||
|       onClick: async (args) => { | ||||
|         if (!args) return | ||||
|         if (args.mouseEvent.which !== 1) return | ||||
| @ -421,7 +409,7 @@ export class SceneEntities { | ||||
|       maybeModdedAst, | ||||
|       sketchGroup.start.__geoMeta.sourceRange | ||||
|     ) | ||||
|     const _profileStart = profileStart({ | ||||
|     const _profileStart = createProfileStartHandle({ | ||||
|       from: sketchGroup.start.from, | ||||
|       id: sketchGroup.start.__geoMeta.id, | ||||
|       pathToNode: segPathToNode, | ||||
| @ -476,50 +464,31 @@ export class SceneEntities { | ||||
|       if (err(_node1)) return | ||||
|       const callExpName = _node1.node?.callee?.name | ||||
|  | ||||
|       if (segment.type === 'TangentialArcTo') { | ||||
|         seg = tangentialArcToSegment({ | ||||
|           prevSegment: sketchGroup.value[index - 1], | ||||
|       const initSegment = | ||||
|         segment.type === 'TangentialArcTo' | ||||
|           ? segmentUtils.tangentialArcTo.init | ||||
|           : segmentUtils.straight.init | ||||
|       const result = initSegment({ | ||||
|         prevSegment: sketchGroup.value[index - 1], | ||||
|         callExpName, | ||||
|         input: { | ||||
|           type: 'straight-segment', | ||||
|           from: segment.from, | ||||
|           to: segment.to, | ||||
|           id: segment.__geoMeta.id, | ||||
|           pathToNode: segPathToNode, | ||||
|           isDraftSegment, | ||||
|           scale: factor, | ||||
|           texture: sceneInfra.extraSegmentTexture, | ||||
|           theme: sceneInfra._theme, | ||||
|           isSelected, | ||||
|         }) | ||||
|         callbacks.push( | ||||
|           this.updateTangentialArcToSegment({ | ||||
|             prevSegment: sketchGroup.value[index - 1], | ||||
|             from: segment.from, | ||||
|             to: segment.to, | ||||
|             group: seg, | ||||
|             scale: factor, | ||||
|           }) | ||||
|         ) | ||||
|       } else { | ||||
|         seg = straightSegment({ | ||||
|           from: segment.from, | ||||
|           to: segment.to, | ||||
|           id: segment.__geoMeta.id, | ||||
|           pathToNode: segPathToNode, | ||||
|           isDraftSegment, | ||||
|           scale: factor, | ||||
|           callExpName, | ||||
|           texture: sceneInfra.extraSegmentTexture, | ||||
|           theme: sceneInfra._theme, | ||||
|           isSelected, | ||||
|         }) | ||||
|         callbacks.push( | ||||
|           this.updateStraightSegment({ | ||||
|             from: segment.from, | ||||
|             to: segment.to, | ||||
|             group: seg, | ||||
|             scale: factor, | ||||
|           }) | ||||
|         ) | ||||
|       } | ||||
|         }, | ||||
|         id: segment.__geoMeta.id, | ||||
|         pathToNode: segPathToNode, | ||||
|         isDraftSegment, | ||||
|         scale: factor, | ||||
|         texture: sceneInfra.extraSegmentTexture, | ||||
|         theme: sceneInfra._theme, | ||||
|         isSelected, | ||||
|         sceneInfra, | ||||
|       }) | ||||
|       if (err(result)) return | ||||
|       const { group: _group, updateOverlaysCallback } = result | ||||
|       seg = _group | ||||
|       callbacks.push(updateOverlaysCallback) | ||||
|       seg.layers.set(SKETCH_LAYER) | ||||
|       seg.traverse((child) => { | ||||
|         child.layers.set(SKETCH_LAYER) | ||||
| @ -602,16 +571,19 @@ export class SceneEntities { | ||||
|       kclManager.programMemory.get(variableDeclarationName), | ||||
|       variableDeclarationName | ||||
|     ) | ||||
|     if (err(sg)) return sg | ||||
|     const lastSeg = sg.value?.slice(-1)[0] || sg.start | ||||
|     if (err(sg)) return Promise.reject(sg) | ||||
|     const lastSeg = sg?.value?.slice(-1)[0] || sg.start | ||||
|  | ||||
|     const index = sg.value.length // because we've added a new segment that's not in the memory yet, no need for `-1` | ||||
|  | ||||
|     const mod = addNewSketchLn({ | ||||
|       node: _ast, | ||||
|       programMemory: kclManager.programMemory, | ||||
|       to: [lastSeg.to[0], lastSeg.to[1]], | ||||
|       from: [lastSeg.to[0], lastSeg.to[1]], | ||||
|       input: { | ||||
|         type: 'straight-segment', | ||||
|         to: lastSeg.to, | ||||
|         from: lastSeg.to, | ||||
|       }, | ||||
|       fnName: segmentName, | ||||
|       pathToNode: sketchPathToNode, | ||||
|     }) | ||||
| @ -634,6 +606,7 @@ export class SceneEntities { | ||||
|         draftExpressionsIndices, | ||||
|       }) | ||||
|     sceneInfra.setCallbacks({ | ||||
|       // eslint-disable-next-line @typescript-eslint/no-misused-promises | ||||
|       onClick: async (args) => { | ||||
|         if (!args) return | ||||
|         if (args.mouseEvent.which !== 1) return | ||||
| @ -681,8 +654,11 @@ export class SceneEntities { | ||||
|           const tmp = addNewSketchLn({ | ||||
|             node: kclManager.ast, | ||||
|             programMemory: kclManager.programMemory, | ||||
|             to: [intersection2d.x, intersection2d.y], | ||||
|             from: [lastSegment.to[0], lastSegment.to[1]], | ||||
|             input: { | ||||
|               type: 'straight-segment', | ||||
|               from: [lastSegment.to[0], lastSegment.to[1]], | ||||
|               to: [intersection2d.x, intersection2d.y], | ||||
|             }, | ||||
|             fnName: | ||||
|               lastSegment.type === 'TangentialArcTo' | ||||
|                 ? 'tangentialArcTo' | ||||
| @ -701,7 +677,7 @@ export class SceneEntities { | ||||
|         if (profileStart) { | ||||
|           sceneInfra.modelingSend({ type: 'CancelSketch' }) | ||||
|         } else { | ||||
|           this.setUpDraftSegment( | ||||
|           await this.setUpDraftSegment( | ||||
|             sketchPathToNode, | ||||
|             forward, | ||||
|             up, | ||||
| @ -771,6 +747,7 @@ export class SceneEntities { | ||||
|     }) | ||||
|  | ||||
|     sceneInfra.setCallbacks({ | ||||
|       // eslint-disable-next-line @typescript-eslint/no-misused-promises | ||||
|       onMove: async (args) => { | ||||
|         // Update the width and height of the draft rectangle | ||||
|         const pathToNodeTwo = structuredClone(sketchPathToNode) | ||||
| @ -818,6 +795,7 @@ export class SceneEntities { | ||||
|           this.updateSegment(seg, index, 0, _ast, orthoFactor, sketchGroup) | ||||
|         ) | ||||
|       }, | ||||
|       // eslint-disable-next-line @typescript-eslint/no-misused-promises | ||||
|       onClick: async (args) => { | ||||
|         // Commit the rectangle to the full AST/code and return to sketch.idle | ||||
|         const cornerPoint = args.intersectionPoint?.twoD | ||||
| @ -831,14 +809,14 @@ export class SceneEntities { | ||||
|           sketchPathToNode || [], | ||||
|           'VariableDeclaration' | ||||
|         ) | ||||
|         if (trap(_node)) return Promise.reject(_node) | ||||
|         if (trap(_node)) return | ||||
|         const sketchInit = _node.node?.declarations?.[0]?.init | ||||
|  | ||||
|         if (sketchInit.type === 'PipeExpression') { | ||||
|           updateRectangleSketch(sketchInit, x, y, tags[0]) | ||||
|  | ||||
|           let _recastAst = parse(recast(_ast)) | ||||
|           if (trap(_recastAst)) return Promise.reject(_recastAst) | ||||
|           if (trap(_recastAst)) return | ||||
|           _ast = _recastAst | ||||
|  | ||||
|           // Update the primary AST and unequip the rectangle tool | ||||
| @ -858,7 +836,7 @@ export class SceneEntities { | ||||
|             programMemory.get(variableDeclarationName), | ||||
|             variableDeclarationName | ||||
|           ) | ||||
|           if (err(sketchGroup)) return sketchGroup | ||||
|           if (err(sketchGroup)) return | ||||
|           const sgPaths = sketchGroup.value | ||||
|           const orthoFactor = orthoScale(sceneInfra.camControls.camera) | ||||
|  | ||||
| @ -892,9 +870,11 @@ export class SceneEntities { | ||||
|   }) => { | ||||
|     let addingNewSegmentStatus: 'nothing' | 'pending' | 'added' = 'nothing' | ||||
|     sceneInfra.setCallbacks({ | ||||
|       // eslint-disable-next-line @typescript-eslint/no-misused-promises | ||||
|       onDragEnd: async () => { | ||||
|         if (addingNewSegmentStatus !== 'nothing') { | ||||
|           await this.tearDownSketch({ removeAxis: false }) | ||||
|           // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|           this.setupSketch({ | ||||
|             sketchPathToNode: pathToNode, | ||||
|             maybeModdedAst: kclManager.ast, | ||||
| @ -911,6 +891,7 @@ export class SceneEntities { | ||||
|           }) | ||||
|         } | ||||
|       }, | ||||
|       // eslint-disable-next-line @typescript-eslint/no-misused-promises | ||||
|       onDrag: async ({ | ||||
|         selected, | ||||
|         intersectionPoint, | ||||
| @ -944,8 +925,11 @@ export class SceneEntities { | ||||
|             const mod = addNewSketchLn({ | ||||
|               node: kclManager.ast, | ||||
|               programMemory: kclManager.programMemory, | ||||
|               to: [intersectionPoint.twoD.x, intersectionPoint.twoD.y], | ||||
|               from: [prevSegment.from[0], prevSegment.from[1]], | ||||
|               input: { | ||||
|                 type: 'straight-segment', | ||||
|                 to: [intersectionPoint.twoD.x, intersectionPoint.twoD.y], | ||||
|                 from: prevSegment.from, | ||||
|               }, | ||||
|               // TODO assuming it's always a straight segments being added | ||||
|               // as this is easiest, and we'll need to add "tabbing" behavior | ||||
|               // to support other segment types | ||||
| @ -958,6 +942,7 @@ export class SceneEntities { | ||||
|  | ||||
|             await kclManager.executeAstMock(mod.modifiedAst) | ||||
|             await this.tearDownSketch({ removeAxis: false }) | ||||
|             // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|             this.setupSketch({ | ||||
|               sketchPathToNode: pathToNode, | ||||
|               maybeModdedAst: kclManager.ast, | ||||
| @ -1065,7 +1050,7 @@ export class SceneEntities { | ||||
|       group.userData.from[0], | ||||
|       group.userData.from[1], | ||||
|     ] | ||||
|     const to: [number, number] = [intersection2d.x, intersection2d.y] | ||||
|     const dragTo: [number, number] = [intersection2d.x, intersection2d.y] | ||||
|     let modifiedAst = draftInfo ? draftInfo.truncatedAst : { ...kclManager.ast } | ||||
|  | ||||
|     const _node = getNodeFromPath<CallExpression>( | ||||
| @ -1088,8 +1073,11 @@ export class SceneEntities { | ||||
|       modded = updateStartProfileAtArgs({ | ||||
|         node: modifiedAst, | ||||
|         pathToNode, | ||||
|         to, | ||||
|         from, | ||||
|         input: { | ||||
|           type: 'straight-segment', | ||||
|           to: dragTo, | ||||
|           from, | ||||
|         }, | ||||
|         previousProgramMemory: kclManager.programMemory, | ||||
|       }) | ||||
|     } else { | ||||
| @ -1097,8 +1085,11 @@ export class SceneEntities { | ||||
|         modifiedAst, | ||||
|         kclManager.programMemory, | ||||
|         [node.start, node.end], | ||||
|         to, | ||||
|         from | ||||
|         { | ||||
|           type: 'straight-segment', | ||||
|           from, | ||||
|           to: dragTo, | ||||
|         } | ||||
|       ) | ||||
|     } | ||||
|     if (trap(modded)) return | ||||
| @ -1161,7 +1152,7 @@ export class SceneEntities { | ||||
|         ) | ||||
|       ) | ||||
|       sceneInfra.overlayCallbacks(callBacks) | ||||
|     })() | ||||
|     })().catch(reportRejection) | ||||
|   } | ||||
|  | ||||
|   /** | ||||
| @ -1201,269 +1192,54 @@ export class SceneEntities { | ||||
|         ? orthoFactor | ||||
|         : perspScale(sceneInfra.camControls.camera, group)) / | ||||
|       sceneInfra._baseUnitMultiplier | ||||
|     const input = { | ||||
|       type: 'straight-segment', | ||||
|       from: segment.from, | ||||
|       to: segment.to, | ||||
|     } as const | ||||
|     let update: SegmentUtils['update'] | null = null | ||||
|     if (type === TANGENTIAL_ARC_TO_SEGMENT) { | ||||
|       return this.updateTangentialArcToSegment({ | ||||
|         prevSegment: sgPaths[index - 1], | ||||
|         from: segment.from, | ||||
|         to: segment.to, | ||||
|         group: group, | ||||
|         scale: factor, | ||||
|       }) | ||||
|       update = segmentUtils.tangentialArcTo.update | ||||
|     } else if (type === STRAIGHT_SEGMENT) { | ||||
|       return this.updateStraightSegment({ | ||||
|         from: segment.from, | ||||
|         to: segment.to, | ||||
|       update = segmentUtils.straight.update | ||||
|     } | ||||
|     const callBack = | ||||
|       update && | ||||
|       !err(update) && | ||||
|       update({ | ||||
|         input, | ||||
|         group, | ||||
|         scale: factor, | ||||
|         prevSegment: sgPaths[index - 1], | ||||
|         sceneInfra, | ||||
|       }) | ||||
|     } else if (type === PROFILE_START) { | ||||
|     if (callBack && !err(callBack)) return callBack | ||||
|  | ||||
|     if (type === PROFILE_START) { | ||||
|       group.position.set(segment.from[0], segment.from[1], 0) | ||||
|       group.scale.set(factor, factor, factor) | ||||
|     } | ||||
|     return () => null | ||||
|   } | ||||
|  | ||||
|   updateTangentialArcToSegment({ | ||||
|     prevSegment, | ||||
|     from, | ||||
|     to, | ||||
|     group, | ||||
|     scale = 1, | ||||
|   }: { | ||||
|     prevSegment: SketchGroup['value'][number] | ||||
|     from: [number, number] | ||||
|     to: [number, number] | ||||
|     group: Group | ||||
|     scale?: number | ||||
|   }): () => SegmentOverlayPayload | null { | ||||
|     group.userData.from = from | ||||
|     group.userData.to = to | ||||
|     group.userData.prevSegment = prevSegment | ||||
|     const arrowGroup = group.getObjectByName(ARROWHEAD) as Group | ||||
|     const extraSegmentGroup = group.getObjectByName(EXTRA_SEGMENT_HANDLE) | ||||
|  | ||||
|     const previousPoint = | ||||
|       prevSegment?.type === 'TangentialArcTo' | ||||
|         ? getTangentPointFromPreviousArc( | ||||
|             prevSegment.center, | ||||
|             prevSegment.ccw, | ||||
|             prevSegment.to | ||||
|           ) | ||||
|         : prevSegment.from | ||||
|  | ||||
|     const arcInfo = getTangentialArcToInfo({ | ||||
|       arcStartPoint: from, | ||||
|       arcEndPoint: to, | ||||
|       tanPreviousPoint: previousPoint, | ||||
|       obtuse: true, | ||||
|   /** | ||||
|    * Update the base color of each of the THREEjs meshes | ||||
|    * that represent each of the sketch segments, to get the | ||||
|    * latest value from `sceneInfra._theme` | ||||
|    */ | ||||
|   updateSegmentBaseColor(newColor: Themes.Light | Themes.Dark) { | ||||
|     const newColorThreeJs = getThemeColorForThreeJs(newColor) | ||||
|     Object.values(this.activeSegments).forEach((group) => { | ||||
|       group.userData.baseColor = newColorThreeJs | ||||
|       group.traverse((child) => { | ||||
|         if ( | ||||
|           child instanceof Mesh && | ||||
|           child.material instanceof MeshBasicMaterial | ||||
|         ) { | ||||
|           child.material.color.set(newColorThreeJs) | ||||
|         } | ||||
|       }) | ||||
|     }) | ||||
|  | ||||
|     const pxLength = arcInfo.arcLength / scale | ||||
|     const shouldHideIdle = pxLength < HIDE_SEGMENT_LENGTH | ||||
|     const shouldHideHover = pxLength < HIDE_HOVER_SEGMENT_LENGTH | ||||
|  | ||||
|     const hoveredParent = | ||||
|       sceneInfra.hoveredObject && | ||||
|       getParentGroup(sceneInfra.hoveredObject, [TANGENTIAL_ARC_TO_SEGMENT]) | ||||
|     let isHandlesVisible = !shouldHideIdle | ||||
|     if (hoveredParent && hoveredParent?.uuid === group?.uuid) { | ||||
|       isHandlesVisible = !shouldHideHover | ||||
|     } | ||||
|  | ||||
|     if (arrowGroup) { | ||||
|       arrowGroup.position.set(to[0], to[1], 0) | ||||
|  | ||||
|       const arrowheadAngle = | ||||
|         arcInfo.endAngle + (Math.PI / 2) * (arcInfo.ccw ? 1 : -1) | ||||
|       arrowGroup.quaternion.setFromUnitVectors( | ||||
|         new Vector3(0, 1, 0), | ||||
|         new Vector3(Math.cos(arrowheadAngle), Math.sin(arrowheadAngle), 0) | ||||
|       ) | ||||
|       arrowGroup.scale.set(scale, scale, scale) | ||||
|       arrowGroup.visible = isHandlesVisible | ||||
|     } | ||||
|  | ||||
|     if (extraSegmentGroup) { | ||||
|       const circumferenceInPx = (2 * Math.PI * arcInfo.radius) / scale | ||||
|       const extraSegmentAngleDelta = | ||||
|         (EXTRA_SEGMENT_OFFSET_PX / circumferenceInPx) * Math.PI * 2 | ||||
|       const extraSegmentAngle = | ||||
|         arcInfo.startAngle + (arcInfo.ccw ? 1 : -1) * extraSegmentAngleDelta | ||||
|       const extraSegmentOffset = new Vector2( | ||||
|         Math.cos(extraSegmentAngle) * arcInfo.radius, | ||||
|         Math.sin(extraSegmentAngle) * arcInfo.radius | ||||
|       ) | ||||
|       extraSegmentGroup.position.set( | ||||
|         arcInfo.center[0] + extraSegmentOffset.x, | ||||
|         arcInfo.center[1] + extraSegmentOffset.y, | ||||
|         0 | ||||
|       ) | ||||
|       extraSegmentGroup.scale.set(scale, scale, scale) | ||||
|       extraSegmentGroup.visible = isHandlesVisible | ||||
|     } | ||||
|  | ||||
|     const tangentialArcToSegmentBody = group.children.find( | ||||
|       (child) => child.userData.type === TANGENTIAL_ARC_TO_SEGMENT_BODY | ||||
|     ) as Mesh | ||||
|  | ||||
|     if (tangentialArcToSegmentBody) { | ||||
|       const newGeo = createArcGeometry({ ...arcInfo, scale }) | ||||
|       tangentialArcToSegmentBody.geometry = newGeo | ||||
|     } | ||||
|     const tangentialArcToSegmentBodyDashed = group.children.find( | ||||
|       (child) => child.userData.type === TANGENTIAL_ARC_TO__SEGMENT_DASH | ||||
|     ) as Mesh | ||||
|     if (tangentialArcToSegmentBodyDashed) { | ||||
|       // consider throttling the whole updateTangentialArcToSegment | ||||
|       // if there are more perf considerations going forward | ||||
|       this.throttledUpdateDashedArcGeo({ | ||||
|         ...arcInfo, | ||||
|         mesh: tangentialArcToSegmentBodyDashed, | ||||
|         isDashed: true, | ||||
|         scale, | ||||
|       }) | ||||
|     } | ||||
|     const angle = normaliseAngle( | ||||
|       (arcInfo.endAngle * 180) / Math.PI + (arcInfo.ccw ? 90 : -90) | ||||
|     ) | ||||
|     return () => | ||||
|       sceneInfra.updateOverlayDetails({ | ||||
|         arrowGroup, | ||||
|         group, | ||||
|         isHandlesVisible, | ||||
|         from, | ||||
|         to, | ||||
|         angle, | ||||
|       }) | ||||
|   } | ||||
|   throttledUpdateDashedArcGeo = throttle( | ||||
|     ( | ||||
|       args: Parameters<typeof createArcGeometry>[0] & { | ||||
|         mesh: Mesh | ||||
|         scale: number | ||||
|       } | ||||
|     ) => (args.mesh.geometry = createArcGeometry(args)), | ||||
|     1000 / 30 | ||||
|   ) | ||||
|   updateStraightSegment({ | ||||
|     from, | ||||
|     to, | ||||
|     group, | ||||
|     scale = 1, | ||||
|   }: { | ||||
|     from: [number, number] | ||||
|     to: [number, number] | ||||
|     group: Group | ||||
|     scale?: number | ||||
|   }): () => SegmentOverlayPayload | null { | ||||
|     group.userData.from = from | ||||
|     group.userData.to = to | ||||
|     const shape = new Shape() | ||||
|     shape.moveTo(0, (-SEGMENT_WIDTH_PX / 2) * scale) // The width of the line in px (2.4px in this case) | ||||
|     shape.lineTo(0, (SEGMENT_WIDTH_PX / 2) * scale) | ||||
|     const arrowGroup = group.getObjectByName(ARROWHEAD) as Group | ||||
|     const labelGroup = group.getObjectByName(SEGMENT_LENGTH_LABEL) as Group | ||||
|  | ||||
|     const length = Math.sqrt( | ||||
|       Math.pow(to[0] - from[0], 2) + Math.pow(to[1] - from[1], 2) | ||||
|     ) | ||||
|  | ||||
|     const pxLength = length / scale | ||||
|     const shouldHideIdle = pxLength < HIDE_SEGMENT_LENGTH | ||||
|     const shouldHideHover = pxLength < HIDE_HOVER_SEGMENT_LENGTH | ||||
|  | ||||
|     const hoveredParent = | ||||
|       sceneInfra.hoveredObject && | ||||
|       getParentGroup(sceneInfra.hoveredObject, [STRAIGHT_SEGMENT]) | ||||
|     let isHandlesVisible = !shouldHideIdle | ||||
|     if (hoveredParent && hoveredParent?.uuid === group?.uuid) { | ||||
|       isHandlesVisible = !shouldHideHover | ||||
|     } | ||||
|  | ||||
|     if (arrowGroup) { | ||||
|       arrowGroup.position.set(to[0], to[1], 0) | ||||
|  | ||||
|       const dir = new Vector3() | ||||
|         .subVectors( | ||||
|           new Vector3(to[0], to[1], 0), | ||||
|           new Vector3(from[0], from[1], 0) | ||||
|         ) | ||||
|         .normalize() | ||||
|       arrowGroup.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir) | ||||
|       arrowGroup.scale.set(scale, scale, scale) | ||||
|       arrowGroup.visible = isHandlesVisible | ||||
|     } | ||||
|  | ||||
|     const extraSegmentGroup = group.getObjectByName(EXTRA_SEGMENT_HANDLE) | ||||
|     if (extraSegmentGroup) { | ||||
|       const offsetFromBase = new Vector2(to[0] - from[0], to[1] - from[1]) | ||||
|         .normalize() | ||||
|         .multiplyScalar(EXTRA_SEGMENT_OFFSET_PX * scale) | ||||
|       extraSegmentGroup.position.set( | ||||
|         from[0] + offsetFromBase.x, | ||||
|         from[1] + offsetFromBase.y, | ||||
|         0 | ||||
|       ) | ||||
|       extraSegmentGroup.scale.set(scale, scale, scale) | ||||
|       extraSegmentGroup.visible = isHandlesVisible | ||||
|     } | ||||
|  | ||||
|     if (labelGroup) { | ||||
|       const labelWrapper = labelGroup.getObjectByName( | ||||
|         SEGMENT_LENGTH_LABEL_TEXT | ||||
|       ) as CSS2DObject | ||||
|       const labelWrapperElem = labelWrapper.element as HTMLDivElement | ||||
|       const label = labelWrapperElem.children[0] as HTMLParagraphElement | ||||
|       label.innerText = `${roundOff(length)}${sceneInfra._baseUnit}` | ||||
|       label.classList.add(SEGMENT_LENGTH_LABEL_TEXT) | ||||
|       const offsetFromMidpoint = new Vector2(to[0] - from[0], to[1] - from[1]) | ||||
|         .normalize() | ||||
|         .rotateAround(new Vector2(0, 0), Math.PI / 2) | ||||
|         .multiplyScalar(SEGMENT_LENGTH_LABEL_OFFSET_PX * scale) | ||||
|       label.style.setProperty('--x', `${offsetFromMidpoint.x}px`) | ||||
|       label.style.setProperty('--y', `${offsetFromMidpoint.y}px`) | ||||
|       labelWrapper.position.set( | ||||
|         (from[0] + to[0]) / 2 + offsetFromMidpoint.x, | ||||
|         (from[1] + to[1]) / 2 + offsetFromMidpoint.y, | ||||
|         0 | ||||
|       ) | ||||
|  | ||||
|       labelGroup.visible = isHandlesVisible | ||||
|     } | ||||
|  | ||||
|     const straightSegmentBody = group.children.find( | ||||
|       (child) => child.userData.type === STRAIGHT_SEGMENT_BODY | ||||
|     ) as Mesh | ||||
|     if (straightSegmentBody) { | ||||
|       const line = new LineCurve3( | ||||
|         new Vector3(from[0], from[1], 0), | ||||
|         new Vector3(to[0], to[1], 0) | ||||
|       ) | ||||
|       straightSegmentBody.geometry = new ExtrudeGeometry(shape, { | ||||
|         steps: 2, | ||||
|         bevelEnabled: false, | ||||
|         extrudePath: line, | ||||
|       }) | ||||
|     } | ||||
|     const straightSegmentBodyDashed = group.children.find( | ||||
|       (child) => child.userData.type === STRAIGHT_SEGMENT_DASH | ||||
|     ) as Mesh | ||||
|     if (straightSegmentBodyDashed) { | ||||
|       straightSegmentBodyDashed.geometry = dashedStraight( | ||||
|         from, | ||||
|         to, | ||||
|         shape, | ||||
|         scale | ||||
|       ) | ||||
|     } | ||||
|     return () => | ||||
|       sceneInfra.updateOverlayDetails({ | ||||
|         arrowGroup, | ||||
|         group, | ||||
|         isHandlesVisible, | ||||
|         from, | ||||
|         to, | ||||
|       }) | ||||
|   } | ||||
|   removeSketchGrid() { | ||||
|     if (this.axisGroup) this.scene.remove(this.axisGroup) | ||||
| @ -1558,27 +1334,30 @@ export class SceneEntities { | ||||
|           } | ||||
|           const orthoFactor = orthoScale(sceneInfra.camControls.camera) | ||||
|  | ||||
|           const input = { | ||||
|             type: 'straight-segment', | ||||
|             from: parent.userData.from, | ||||
|             to: parent.userData.to, | ||||
|           } as const | ||||
|           const factor = | ||||
|             (sceneInfra.camControls.camera instanceof OrthographicCamera | ||||
|               ? orthoFactor | ||||
|               : perspScale(sceneInfra.camControls.camera, parent)) / | ||||
|             sceneInfra._baseUnitMultiplier | ||||
|           let update: SegmentUtils['update'] | null = null | ||||
|           if (parent.name === STRAIGHT_SEGMENT) { | ||||
|             this.updateStraightSegment({ | ||||
|               from: parent.userData.from, | ||||
|               to: parent.userData.to, | ||||
|               group: parent, | ||||
|               scale: factor, | ||||
|             }) | ||||
|             update = segmentUtils.straight.update | ||||
|           } else if (parent.name === TANGENTIAL_ARC_TO_SEGMENT) { | ||||
|             this.updateTangentialArcToSegment({ | ||||
|             update = segmentUtils.tangentialArcTo.update | ||||
|           } | ||||
|           update && | ||||
|             update({ | ||||
|               prevSegment: parent.userData.prevSegment, | ||||
|               from: parent.userData.from, | ||||
|               to: parent.userData.to, | ||||
|               input, | ||||
|               group: parent, | ||||
|               scale: factor, | ||||
|               sceneInfra, | ||||
|             }) | ||||
|           } | ||||
|           return | ||||
|         } | ||||
|         editorManager.setHighlightRange([[0, 0]]) | ||||
| @ -1593,27 +1372,30 @@ export class SceneEntities { | ||||
|         if (parent) { | ||||
|           const orthoFactor = orthoScale(sceneInfra.camControls.camera) | ||||
|  | ||||
|           const input = { | ||||
|             type: 'straight-segment', | ||||
|             from: parent.userData.from, | ||||
|             to: parent.userData.to, | ||||
|           } as const | ||||
|           const factor = | ||||
|             (sceneInfra.camControls.camera instanceof OrthographicCamera | ||||
|               ? orthoFactor | ||||
|               : perspScale(sceneInfra.camControls.camera, parent)) / | ||||
|             sceneInfra._baseUnitMultiplier | ||||
|           let update: SegmentUtils['update'] | null = null | ||||
|           if (parent.name === STRAIGHT_SEGMENT) { | ||||
|             this.updateStraightSegment({ | ||||
|               from: parent.userData.from, | ||||
|               to: parent.userData.to, | ||||
|               group: parent, | ||||
|               scale: factor, | ||||
|             }) | ||||
|             update = segmentUtils.straight.update | ||||
|           } else if (parent.name === TANGENTIAL_ARC_TO_SEGMENT) { | ||||
|             this.updateTangentialArcToSegment({ | ||||
|             update = segmentUtils.tangentialArcTo.update | ||||
|           } | ||||
|           update && | ||||
|             update({ | ||||
|               prevSegment: parent.userData.prevSegment, | ||||
|               from: parent.userData.from, | ||||
|               to: parent.userData.to, | ||||
|               input, | ||||
|               group: parent, | ||||
|               scale: factor, | ||||
|               sceneInfra, | ||||
|             }) | ||||
|           } | ||||
|         } | ||||
|         const isSelected = parent?.userData?.isSelected | ||||
|         colorSegment( | ||||
|  | ||||
| @ -105,10 +105,6 @@ export class SceneInfra { | ||||
|   _baseUnit: BaseUnit = 'mm' | ||||
|   _baseUnitMultiplier = 1 | ||||
|   _theme: Themes = Themes.System | ||||
|   _streamDimensions: { streamWidth: number; streamHeight: number } = { | ||||
|     streamWidth: 1280, | ||||
|     streamHeight: 720, | ||||
|   } | ||||
|   extraSegmentTexture: Texture | ||||
|   lastMouseState: MouseState = { type: 'idle' } | ||||
|   onDragStartCallback: (arg: OnDragCallbackArgs) => void = () => {} | ||||
|  | ||||
| @ -26,6 +26,7 @@ import { PathToNode, SketchGroup, getTangentialArcToInfo } from 'lang/wasm' | ||||
| import { | ||||
|   EXTRA_SEGMENT_HANDLE, | ||||
|   EXTRA_SEGMENT_OFFSET_PX, | ||||
|   HIDE_HOVER_SEGMENT_LENGTH, | ||||
|   HIDE_SEGMENT_LENGTH, | ||||
|   PROFILE_START, | ||||
|   SEGMENT_WIDTH_PX, | ||||
| @ -35,18 +36,448 @@ import { | ||||
|   TANGENTIAL_ARC_TO_SEGMENT, | ||||
|   TANGENTIAL_ARC_TO_SEGMENT_BODY, | ||||
|   TANGENTIAL_ARC_TO__SEGMENT_DASH, | ||||
|   getParentGroup, | ||||
| } from './sceneEntities' | ||||
| import { getTangentPointFromPreviousArc } from 'lib/utils2d' | ||||
| import { | ||||
|   ARROWHEAD, | ||||
|   SceneInfra, | ||||
|   SEGMENT_LENGTH_LABEL, | ||||
|   SEGMENT_LENGTH_LABEL_OFFSET_PX, | ||||
|   SEGMENT_LENGTH_LABEL_TEXT, | ||||
| } from './sceneInfra' | ||||
| import { Themes, getThemeColorForThreeJs } from 'lib/theme' | ||||
| import { roundOff } from 'lib/utils' | ||||
| import { normaliseAngle, roundOff } from 'lib/utils' | ||||
| import { SegmentOverlayPayload } from 'machines/modelingMachine' | ||||
| import { SegmentInputs } from 'lang/std/stdTypes' | ||||
| import { err } from 'lib/trap' | ||||
|  | ||||
| export function profileStart({ | ||||
| interface CreateSegmentArgs { | ||||
|   input: SegmentInputs | ||||
|   prevSegment: SketchGroup['value'][number] | ||||
|   id: string | ||||
|   pathToNode: PathToNode | ||||
|   isDraftSegment?: boolean | ||||
|   scale?: number | ||||
|   callExpName: string | ||||
|   texture: Texture | ||||
|   theme: Themes | ||||
|   isSelected?: boolean | ||||
|   sceneInfra: SceneInfra | ||||
| } | ||||
|  | ||||
| interface UpdateSegmentArgs { | ||||
|   input: SegmentInputs | ||||
|   prevSegment: SketchGroup['value'][number] | ||||
|   group: Group | ||||
|   sceneInfra: SceneInfra | ||||
|   scale?: number | ||||
| } | ||||
|  | ||||
| interface CreateSegmentResult { | ||||
|   group: Group | ||||
|   updateOverlaysCallback: () => SegmentOverlayPayload | null | ||||
| } | ||||
|  | ||||
| export interface SegmentUtils { | ||||
|   /** | ||||
|    * the init is responsible for adding all of the correct entities to the group with important details like `mesh.name = ...` | ||||
|    * as these act like handles later | ||||
|    * | ||||
|    * It's **Not** responsible for doing all calculations to size and position the entities as this would be duplicated in the update function | ||||
|    * Which should instead be called at the end of the init function | ||||
|    */ | ||||
|   init: (args: CreateSegmentArgs) => CreateSegmentResult | Error | ||||
|   /** | ||||
|    * The update function is responsible for updating the group with the correct size and position of the entities | ||||
|    * It should be called at the end of the init function and return a callback that can be used to update the overlay | ||||
|    * | ||||
|    * It returns a callback for updating the overlays, this is so the overlays do not have to update at the same pace threeJs does | ||||
|    * This is useful for performance reasons | ||||
|    */ | ||||
|   update: ( | ||||
|     args: UpdateSegmentArgs | ||||
|   ) => CreateSegmentResult['updateOverlaysCallback'] | Error | ||||
| } | ||||
|  | ||||
| class StraightSegment implements SegmentUtils { | ||||
|   init: SegmentUtils['init'] = ({ | ||||
|     input, | ||||
|     id, | ||||
|     pathToNode, | ||||
|     isDraftSegment, | ||||
|     scale = 1, | ||||
|     callExpName, | ||||
|     texture, | ||||
|     theme, | ||||
|     isSelected = false, | ||||
|     sceneInfra, | ||||
|     prevSegment, | ||||
|   }) => { | ||||
|     if (input.type !== 'straight-segment') | ||||
|       return new Error('Invalid segment type') | ||||
|     const { from, to } = input | ||||
|     const baseColor = | ||||
|       callExpName === 'close' ? 0x444444 : getThemeColorForThreeJs(theme) | ||||
|     const color = isSelected ? 0x0000ff : baseColor | ||||
|     const meshType = isDraftSegment | ||||
|       ? STRAIGHT_SEGMENT_DASH | ||||
|       : STRAIGHT_SEGMENT_BODY | ||||
|  | ||||
|     const segmentGroup = new Group() | ||||
|     const shape = new Shape() | ||||
|     const line = new LineCurve3( | ||||
|       new Vector3(from[0], from[1], 0), | ||||
|       new Vector3(to[0], to[1], 0) | ||||
|     ) | ||||
|     const geometry = new ExtrudeGeometry(shape, { | ||||
|       steps: 2, | ||||
|       bevelEnabled: false, | ||||
|       extrudePath: line, | ||||
|     }) | ||||
|     const body = new MeshBasicMaterial({ color }) | ||||
|     const mesh = new Mesh(geometry, body) | ||||
|  | ||||
|     mesh.userData.type = meshType | ||||
|     mesh.name = meshType | ||||
|     segmentGroup.name = STRAIGHT_SEGMENT | ||||
|     segmentGroup.userData = { | ||||
|       type: STRAIGHT_SEGMENT, | ||||
|       id, | ||||
|       from, | ||||
|       to, | ||||
|       pathToNode, | ||||
|       isSelected, | ||||
|       callExpName, | ||||
|       baseColor, | ||||
|     } | ||||
|  | ||||
|     // All segment types get an extra segment handle, | ||||
|     // Which is a little plus sign that appears at the origin of the segment | ||||
|     // and can be dragged to insert a new segment | ||||
|     const extraSegmentGroup = createExtraSegmentHandle(scale, texture, theme) | ||||
|  | ||||
|     // Segment decorators that only apply to non-close segments | ||||
|     if (callExpName !== 'close') { | ||||
|       // an arrowhead that appears at the end of the segment | ||||
|       const arrowGroup = createArrowhead(scale, theme, color) | ||||
|       // A length indicator that appears at the midpoint of the segment | ||||
|       const lengthIndicatorGroup = createLengthIndicator({ | ||||
|         from, | ||||
|         to, | ||||
|         scale, | ||||
|       }) | ||||
|       segmentGroup.add(arrowGroup) | ||||
|       segmentGroup.add(lengthIndicatorGroup) | ||||
|     } | ||||
|  | ||||
|     segmentGroup.add(mesh, extraSegmentGroup) | ||||
|     let updateOverlaysCallback = this.update({ | ||||
|       prevSegment, | ||||
|       input, | ||||
|       group: segmentGroup, | ||||
|       scale, | ||||
|       sceneInfra, | ||||
|     }) | ||||
|     if (err(updateOverlaysCallback)) return updateOverlaysCallback | ||||
|  | ||||
|     return { | ||||
|       group: segmentGroup, | ||||
|       updateOverlaysCallback, | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   update: SegmentUtils['update'] = ({ | ||||
|     input, | ||||
|     group, | ||||
|     scale = 1, | ||||
|     sceneInfra, | ||||
|   }) => { | ||||
|     if (input.type !== 'straight-segment') | ||||
|       return new Error('Invalid segment type') | ||||
|     const { from, to } = input | ||||
|     group.userData.from = from | ||||
|     group.userData.to = to | ||||
|     const shape = new Shape() | ||||
|     shape.moveTo(0, (-SEGMENT_WIDTH_PX / 2) * scale) // The width of the line in px (2.4px in this case) | ||||
|     shape.lineTo(0, (SEGMENT_WIDTH_PX / 2) * scale) | ||||
|     const arrowGroup = group.getObjectByName(ARROWHEAD) as Group | ||||
|     const labelGroup = group.getObjectByName(SEGMENT_LENGTH_LABEL) as Group | ||||
|  | ||||
|     const length = Math.sqrt( | ||||
|       Math.pow(to[0] - from[0], 2) + Math.pow(to[1] - from[1], 2) | ||||
|     ) | ||||
|  | ||||
|     const pxLength = length / scale | ||||
|     const shouldHideIdle = pxLength < HIDE_SEGMENT_LENGTH | ||||
|     const shouldHideHover = pxLength < HIDE_HOVER_SEGMENT_LENGTH | ||||
|  | ||||
|     const hoveredParent = | ||||
|       sceneInfra.hoveredObject && | ||||
|       getParentGroup(sceneInfra.hoveredObject, [STRAIGHT_SEGMENT]) | ||||
|     let isHandlesVisible = !shouldHideIdle | ||||
|     if (hoveredParent && hoveredParent?.uuid === group?.uuid) { | ||||
|       isHandlesVisible = !shouldHideHover | ||||
|     } | ||||
|  | ||||
|     if (arrowGroup) { | ||||
|       arrowGroup.position.set(to[0], to[1], 0) | ||||
|  | ||||
|       const dir = new Vector3() | ||||
|         .subVectors( | ||||
|           new Vector3(to[0], to[1], 0), | ||||
|           new Vector3(from[0], from[1], 0) | ||||
|         ) | ||||
|         .normalize() | ||||
|       arrowGroup.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir) | ||||
|       arrowGroup.scale.set(scale, scale, scale) | ||||
|       arrowGroup.visible = isHandlesVisible | ||||
|     } | ||||
|  | ||||
|     const extraSegmentGroup = group.getObjectByName(EXTRA_SEGMENT_HANDLE) | ||||
|     if (extraSegmentGroup) { | ||||
|       const offsetFromBase = new Vector2(to[0] - from[0], to[1] - from[1]) | ||||
|         .normalize() | ||||
|         .multiplyScalar(EXTRA_SEGMENT_OFFSET_PX * scale) | ||||
|       extraSegmentGroup.position.set( | ||||
|         from[0] + offsetFromBase.x, | ||||
|         from[1] + offsetFromBase.y, | ||||
|         0 | ||||
|       ) | ||||
|       extraSegmentGroup.scale.set(scale, scale, scale) | ||||
|       extraSegmentGroup.visible = isHandlesVisible | ||||
|     } | ||||
|  | ||||
|     if (labelGroup) { | ||||
|       const labelWrapper = labelGroup.getObjectByName( | ||||
|         SEGMENT_LENGTH_LABEL_TEXT | ||||
|       ) as CSS2DObject | ||||
|       const labelWrapperElem = labelWrapper.element as HTMLDivElement | ||||
|       const label = labelWrapperElem.children[0] as HTMLParagraphElement | ||||
|       label.innerText = `${roundOff(length)}` | ||||
|       label.classList.add(SEGMENT_LENGTH_LABEL_TEXT) | ||||
|       const slope = (to[1] - from[1]) / (to[0] - from[0]) | ||||
|       let slopeAngle = ((Math.atan(slope) * 180) / Math.PI) * -1 | ||||
|       label.style.setProperty('--degree', `${slopeAngle}deg`) | ||||
|       label.style.setProperty('--x', `0px`) | ||||
|       label.style.setProperty('--y', `0px`) | ||||
|       labelWrapper.position.set((from[0] + to[0]) / 2, (from[1] + to[1]) / 2, 0) | ||||
|       labelGroup.visible = isHandlesVisible | ||||
|     } | ||||
|  | ||||
|     const straightSegmentBody = group.children.find( | ||||
|       (child) => child.userData.type === STRAIGHT_SEGMENT_BODY | ||||
|     ) as Mesh | ||||
|     if (straightSegmentBody) { | ||||
|       const line = new LineCurve3( | ||||
|         new Vector3(from[0], from[1], 0), | ||||
|         new Vector3(to[0], to[1], 0) | ||||
|       ) | ||||
|       straightSegmentBody.geometry = new ExtrudeGeometry(shape, { | ||||
|         steps: 2, | ||||
|         bevelEnabled: false, | ||||
|         extrudePath: line, | ||||
|       }) | ||||
|     } | ||||
|     const straightSegmentBodyDashed = group.children.find( | ||||
|       (child) => child.userData.type === STRAIGHT_SEGMENT_DASH | ||||
|     ) as Mesh | ||||
|     if (straightSegmentBodyDashed) { | ||||
|       straightSegmentBodyDashed.geometry = dashedStraight( | ||||
|         from, | ||||
|         to, | ||||
|         shape, | ||||
|         scale | ||||
|       ) | ||||
|     } | ||||
|     return () => | ||||
|       sceneInfra.updateOverlayDetails({ | ||||
|         arrowGroup, | ||||
|         group, | ||||
|         isHandlesVisible, | ||||
|         from, | ||||
|         to, | ||||
|       }) | ||||
|   } | ||||
| } | ||||
|  | ||||
| class TangentialArcToSegment implements SegmentUtils { | ||||
|   init: SegmentUtils['init'] = ({ | ||||
|     prevSegment, | ||||
|     input, | ||||
|     id, | ||||
|     pathToNode, | ||||
|     isDraftSegment, | ||||
|     scale = 1, | ||||
|     texture, | ||||
|     theme, | ||||
|     isSelected, | ||||
|     sceneInfra, | ||||
|   }) => { | ||||
|     if (input.type !== 'straight-segment') | ||||
|       return new Error('Invalid segment type') | ||||
|     const { from, to } = input | ||||
|     const meshName = isDraftSegment | ||||
|       ? TANGENTIAL_ARC_TO__SEGMENT_DASH | ||||
|       : TANGENTIAL_ARC_TO_SEGMENT_BODY | ||||
|  | ||||
|     const group = new Group() | ||||
|     const geometry = createArcGeometry({ | ||||
|       center: [0, 0], | ||||
|       radius: 1, | ||||
|       startAngle: 0, | ||||
|       endAngle: 1, | ||||
|       ccw: true, | ||||
|       isDashed: isDraftSegment, | ||||
|       scale, | ||||
|     }) | ||||
|     const baseColor = getThemeColorForThreeJs(theme) | ||||
|     const color = isSelected ? 0x0000ff : baseColor | ||||
|     const body = new MeshBasicMaterial({ color }) | ||||
|     const mesh = new Mesh(geometry, body) | ||||
|     const arrowGroup = createArrowhead(scale, theme, color) | ||||
|     const extraSegmentGroup = createExtraSegmentHandle(scale, texture, theme) | ||||
|  | ||||
|     group.name = TANGENTIAL_ARC_TO_SEGMENT | ||||
|     mesh.userData.type = meshName | ||||
|     mesh.name = meshName | ||||
|     group.userData = { | ||||
|       type: TANGENTIAL_ARC_TO_SEGMENT, | ||||
|       id, | ||||
|       from, | ||||
|       to, | ||||
|       prevSegment, | ||||
|       pathToNode, | ||||
|       isSelected, | ||||
|       baseColor, | ||||
|     } | ||||
|  | ||||
|     group.add(mesh, arrowGroup, extraSegmentGroup) | ||||
|     const updateOverlaysCallback = this.update({ | ||||
|       prevSegment, | ||||
|       input, | ||||
|       group, | ||||
|       scale, | ||||
|       sceneInfra, | ||||
|     }) | ||||
|     if (err(updateOverlaysCallback)) return updateOverlaysCallback | ||||
|  | ||||
|     return { | ||||
|       group, | ||||
|       updateOverlaysCallback, | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   update: SegmentUtils['update'] = ({ | ||||
|     prevSegment, | ||||
|     input, | ||||
|     group, | ||||
|     scale = 1, | ||||
|     sceneInfra, | ||||
|   }) => { | ||||
|     if (input.type !== 'straight-segment') | ||||
|       return new Error('Invalid segment type') | ||||
|     const { from, to } = input | ||||
|     group.userData.from = from | ||||
|     group.userData.to = to | ||||
|     group.userData.prevSegment = prevSegment | ||||
|     const arrowGroup = group.getObjectByName(ARROWHEAD) as Group | ||||
|     const extraSegmentGroup = group.getObjectByName(EXTRA_SEGMENT_HANDLE) | ||||
|  | ||||
|     const previousPoint = | ||||
|       prevSegment?.type === 'TangentialArcTo' | ||||
|         ? getTangentPointFromPreviousArc( | ||||
|             prevSegment.center, | ||||
|             prevSegment.ccw, | ||||
|             prevSegment.to | ||||
|           ) | ||||
|         : prevSegment.from | ||||
|  | ||||
|     const arcInfo = getTangentialArcToInfo({ | ||||
|       arcStartPoint: from, | ||||
|       arcEndPoint: to, | ||||
|       tanPreviousPoint: previousPoint, | ||||
|       obtuse: true, | ||||
|     }) | ||||
|  | ||||
|     const pxLength = arcInfo.arcLength / scale | ||||
|     const shouldHideIdle = pxLength < HIDE_SEGMENT_LENGTH | ||||
|     const shouldHideHover = pxLength < HIDE_HOVER_SEGMENT_LENGTH | ||||
|  | ||||
|     const hoveredParent = | ||||
|       sceneInfra?.hoveredObject && | ||||
|       getParentGroup(sceneInfra.hoveredObject, [TANGENTIAL_ARC_TO_SEGMENT]) | ||||
|     let isHandlesVisible = !shouldHideIdle | ||||
|     if (hoveredParent && hoveredParent?.uuid === group?.uuid) { | ||||
|       isHandlesVisible = !shouldHideHover | ||||
|     } | ||||
|  | ||||
|     if (arrowGroup) { | ||||
|       arrowGroup.position.set(to[0], to[1], 0) | ||||
|  | ||||
|       const arrowheadAngle = | ||||
|         arcInfo.endAngle + (Math.PI / 2) * (arcInfo.ccw ? 1 : -1) | ||||
|       arrowGroup.quaternion.setFromUnitVectors( | ||||
|         new Vector3(0, 1, 0), | ||||
|         new Vector3(Math.cos(arrowheadAngle), Math.sin(arrowheadAngle), 0) | ||||
|       ) | ||||
|       arrowGroup.scale.set(scale, scale, scale) | ||||
|       arrowGroup.visible = isHandlesVisible | ||||
|     } | ||||
|  | ||||
|     if (extraSegmentGroup) { | ||||
|       const circumferenceInPx = (2 * Math.PI * arcInfo.radius) / scale | ||||
|       const extraSegmentAngleDelta = | ||||
|         (EXTRA_SEGMENT_OFFSET_PX / circumferenceInPx) * Math.PI * 2 | ||||
|       const extraSegmentAngle = | ||||
|         arcInfo.startAngle + (arcInfo.ccw ? 1 : -1) * extraSegmentAngleDelta | ||||
|       const extraSegmentOffset = new Vector2( | ||||
|         Math.cos(extraSegmentAngle) * arcInfo.radius, | ||||
|         Math.sin(extraSegmentAngle) * arcInfo.radius | ||||
|       ) | ||||
|       extraSegmentGroup.position.set( | ||||
|         arcInfo.center[0] + extraSegmentOffset.x, | ||||
|         arcInfo.center[1] + extraSegmentOffset.y, | ||||
|         0 | ||||
|       ) | ||||
|       extraSegmentGroup.scale.set(scale, scale, scale) | ||||
|       extraSegmentGroup.visible = isHandlesVisible | ||||
|     } | ||||
|  | ||||
|     const tangentialArcToSegmentBody = group.children.find( | ||||
|       (child) => child.userData.type === TANGENTIAL_ARC_TO_SEGMENT_BODY | ||||
|     ) as Mesh | ||||
|  | ||||
|     if (tangentialArcToSegmentBody) { | ||||
|       const newGeo = createArcGeometry({ ...arcInfo, scale }) | ||||
|       tangentialArcToSegmentBody.geometry = newGeo | ||||
|     } | ||||
|     const tangentialArcToSegmentBodyDashed = group.getObjectByName( | ||||
|       TANGENTIAL_ARC_TO__SEGMENT_DASH | ||||
|     ) | ||||
|     if (tangentialArcToSegmentBodyDashed instanceof Mesh) { | ||||
|       tangentialArcToSegmentBodyDashed.geometry = createArcGeometry({ | ||||
|         ...arcInfo, | ||||
|         isDashed: true, | ||||
|         scale, | ||||
|       }) | ||||
|     } | ||||
|     const angle = normaliseAngle( | ||||
|       (arcInfo.endAngle * 180) / Math.PI + (arcInfo.ccw ? 90 : -90) | ||||
|     ) | ||||
|     return () => | ||||
|       sceneInfra.updateOverlayDetails({ | ||||
|         arrowGroup, | ||||
|         group, | ||||
|         isHandlesVisible, | ||||
|         from, | ||||
|         to, | ||||
|         angle, | ||||
|       }) | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function createProfileStartHandle({ | ||||
|   from, | ||||
|   id, | ||||
|   pathToNode, | ||||
| @ -85,127 +516,6 @@ export function profileStart({ | ||||
|   return group | ||||
| } | ||||
|  | ||||
| export function straightSegment({ | ||||
|   from, | ||||
|   to, | ||||
|   id, | ||||
|   pathToNode, | ||||
|   isDraftSegment, | ||||
|   scale = 1, | ||||
|   callExpName, | ||||
|   texture, | ||||
|   theme, | ||||
|   isSelected = false, | ||||
| }: { | ||||
|   from: Coords2d | ||||
|   to: Coords2d | ||||
|   id: string | ||||
|   pathToNode: PathToNode | ||||
|   isDraftSegment?: boolean | ||||
|   scale?: number | ||||
|   callExpName: string | ||||
|   texture: Texture | ||||
|   theme: Themes | ||||
|   isSelected?: boolean | ||||
| }): Group { | ||||
|   const segmentGroup = new Group() | ||||
|  | ||||
|   const shape = new Shape() | ||||
|   shape.moveTo(0, (-SEGMENT_WIDTH_PX / 2) * scale) | ||||
|   shape.lineTo(0, (SEGMENT_WIDTH_PX / 2) * scale) | ||||
|  | ||||
|   let geometry | ||||
|   if (isDraftSegment) { | ||||
|     geometry = dashedStraight(from, to, shape, scale) | ||||
|   } else { | ||||
|     const line = new LineCurve3( | ||||
|       new Vector3(from[0], from[1], 0), | ||||
|       new Vector3(to[0], to[1], 0) | ||||
|     ) | ||||
|  | ||||
|     geometry = new ExtrudeGeometry(shape, { | ||||
|       steps: 2, | ||||
|       bevelEnabled: false, | ||||
|       extrudePath: line, | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   const baseColor = | ||||
|     callExpName === 'close' ? 0x444444 : getThemeColorForThreeJs(theme) | ||||
|   const color = isSelected ? 0x0000ff : baseColor | ||||
|   const body = new MeshBasicMaterial({ color }) | ||||
|   const mesh = new Mesh(geometry, body) | ||||
|   mesh.userData.type = isDraftSegment | ||||
|     ? STRAIGHT_SEGMENT_DASH | ||||
|     : STRAIGHT_SEGMENT_BODY | ||||
|   mesh.name = STRAIGHT_SEGMENT_BODY | ||||
|  | ||||
|   segmentGroup.userData = { | ||||
|     type: STRAIGHT_SEGMENT, | ||||
|     id, | ||||
|     from, | ||||
|     to, | ||||
|     pathToNode, | ||||
|     isSelected, | ||||
|     callExpName, | ||||
|     baseColor, | ||||
|   } | ||||
|   segmentGroup.name = STRAIGHT_SEGMENT | ||||
|   segmentGroup.add(mesh) | ||||
|  | ||||
|   const length = Math.sqrt( | ||||
|     Math.pow(to[0] - from[0], 2) + Math.pow(to[1] - from[1], 2) | ||||
|   ) | ||||
|   const pxLength = length / scale | ||||
|   const shouldHide = pxLength < HIDE_SEGMENT_LENGTH | ||||
|  | ||||
|   // All segment types get an extra segment handle, | ||||
|   // Which is a little plus sign that appears at the origin of the segment | ||||
|   // and can be dragged to insert a new segment | ||||
|   const extraSegmentGroup = createExtraSegmentHandle(scale, texture, theme) | ||||
|   const directionVector = new Vector2( | ||||
|     to[0] - from[0], | ||||
|     to[1] - from[1] | ||||
|   ).normalize() | ||||
|   const offsetFromBase = directionVector.multiplyScalar( | ||||
|     EXTRA_SEGMENT_OFFSET_PX * scale | ||||
|   ) | ||||
|   extraSegmentGroup.position.set( | ||||
|     from[0] + offsetFromBase.x, | ||||
|     from[1] + offsetFromBase.y, | ||||
|     0 | ||||
|   ) | ||||
|   extraSegmentGroup.visible = !shouldHide | ||||
|   segmentGroup.add(extraSegmentGroup) | ||||
|  | ||||
|   // Segment decorators that only apply to non-close segments | ||||
|   if (callExpName !== 'close') { | ||||
|     // an arrowhead that appears at the end of the segment | ||||
|     const arrowGroup = createArrowhead(scale, theme, color) | ||||
|     arrowGroup.position.set(to[0], to[1], 0) | ||||
|     const dir = new Vector3() | ||||
|       .subVectors( | ||||
|         new Vector3(to[0], to[1], 0), | ||||
|         new Vector3(from[0], from[1], 0) | ||||
|       ) | ||||
|       .normalize() | ||||
|     arrowGroup.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir) | ||||
|     arrowGroup.visible = !shouldHide | ||||
|     segmentGroup.add(arrowGroup) | ||||
|  | ||||
|     // A length indicator that appears at the midpoint of the segment | ||||
|     const lengthIndicatorGroup = createLengthIndicator({ | ||||
|       from, | ||||
|       to, | ||||
|       scale, | ||||
|       length, | ||||
|     }) | ||||
|     segmentGroup.add(lengthIndicatorGroup) | ||||
|   } | ||||
|  | ||||
|   return segmentGroup | ||||
| } | ||||
|  | ||||
| function createArrowhead(scale = 1, theme: Themes, color?: number): Group { | ||||
|   const baseColor = getThemeColorForThreeJs(theme) | ||||
|   const arrowMaterial = new MeshBasicMaterial({ | ||||
| @ -267,12 +577,12 @@ function createLengthIndicator({ | ||||
|   from, | ||||
|   to, | ||||
|   scale, | ||||
|   length, | ||||
|   length = 0.1, | ||||
| }: { | ||||
|   from: Coords2d | ||||
|   to: Coords2d | ||||
|   scale: number | ||||
|   length: number | ||||
|   length?: number | ||||
| }) { | ||||
|   const lengthIndicatorGroup = new Group() | ||||
|   lengthIndicatorGroup.name = SEGMENT_LENGTH_LABEL | ||||
| @ -300,111 +610,6 @@ function createLengthIndicator({ | ||||
|   return lengthIndicatorGroup | ||||
| } | ||||
|  | ||||
| export function tangentialArcToSegment({ | ||||
|   prevSegment, | ||||
|   from, | ||||
|   to, | ||||
|   id, | ||||
|   pathToNode, | ||||
|   isDraftSegment, | ||||
|   scale = 1, | ||||
|   texture, | ||||
|   theme, | ||||
|   isSelected, | ||||
| }: { | ||||
|   prevSegment: SketchGroup['value'][number] | ||||
|   from: Coords2d | ||||
|   to: Coords2d | ||||
|   id: string | ||||
|   pathToNode: PathToNode | ||||
|   isDraftSegment?: boolean | ||||
|   scale?: number | ||||
|   texture: Texture | ||||
|   theme: Themes | ||||
|   isSelected?: boolean | ||||
| }): Group { | ||||
|   const group = new Group() | ||||
|  | ||||
|   const previousPoint = | ||||
|     prevSegment?.type === 'TangentialArcTo' | ||||
|       ? getTangentPointFromPreviousArc( | ||||
|           prevSegment.center, | ||||
|           prevSegment.ccw, | ||||
|           prevSegment.to | ||||
|         ) | ||||
|       : prevSegment.from | ||||
|  | ||||
|   const { center, radius, startAngle, endAngle, ccw, arcLength } = | ||||
|     getTangentialArcToInfo({ | ||||
|       arcStartPoint: from, | ||||
|       arcEndPoint: to, | ||||
|       tanPreviousPoint: previousPoint, | ||||
|       obtuse: true, | ||||
|     }) | ||||
|  | ||||
|   const geometry = createArcGeometry({ | ||||
|     center, | ||||
|     radius, | ||||
|     startAngle, | ||||
|     endAngle, | ||||
|     ccw, | ||||
|     isDashed: isDraftSegment, | ||||
|     scale, | ||||
|   }) | ||||
|  | ||||
|   const baseColor = getThemeColorForThreeJs(theme) | ||||
|   const color = isSelected ? 0x0000ff : baseColor | ||||
|   const body = new MeshBasicMaterial({ color }) | ||||
|   const mesh = new Mesh(geometry, body) | ||||
|   mesh.userData.type = isDraftSegment | ||||
|     ? TANGENTIAL_ARC_TO__SEGMENT_DASH | ||||
|     : TANGENTIAL_ARC_TO_SEGMENT_BODY | ||||
|  | ||||
|   group.userData = { | ||||
|     type: TANGENTIAL_ARC_TO_SEGMENT, | ||||
|     id, | ||||
|     from, | ||||
|     to, | ||||
|     prevSegment, | ||||
|     pathToNode, | ||||
|     isSelected, | ||||
|     baseColor, | ||||
|   } | ||||
|   group.name = TANGENTIAL_ARC_TO_SEGMENT | ||||
|  | ||||
|   const arrowGroup = createArrowhead(scale, theme, color) | ||||
|   arrowGroup.position.set(to[0], to[1], 0) | ||||
|   const arrowheadAngle = endAngle + (Math.PI / 2) * (ccw ? 1 : -1) | ||||
|   arrowGroup.quaternion.setFromUnitVectors( | ||||
|     new Vector3(0, 1, 0), | ||||
|     new Vector3(Math.cos(arrowheadAngle), Math.sin(arrowheadAngle), 0) | ||||
|   ) | ||||
|   const pxLength = arcLength / scale | ||||
|   const shouldHide = pxLength < HIDE_SEGMENT_LENGTH | ||||
|   arrowGroup.visible = !shouldHide | ||||
|  | ||||
|   const extraSegmentGroup = createExtraSegmentHandle(scale, texture, theme) | ||||
|   const circumferenceInPx = (2 * Math.PI * radius) / scale | ||||
|   const extraSegmentAngleDelta = | ||||
|     (EXTRA_SEGMENT_OFFSET_PX / circumferenceInPx) * Math.PI * 2 | ||||
|   const extraSegmentAngle = startAngle + (ccw ? 1 : -1) * extraSegmentAngleDelta | ||||
|   const extraSegmentOffset = new Vector2( | ||||
|     Math.cos(extraSegmentAngle) * radius, | ||||
|     Math.sin(extraSegmentAngle) * radius | ||||
|   ) | ||||
|   extraSegmentGroup.position.set( | ||||
|     center[0] + extraSegmentOffset.x, | ||||
|     center[1] + extraSegmentOffset.y, | ||||
|     0 | ||||
|   ) | ||||
|  | ||||
|   extraSegmentGroup.visible = !shouldHide | ||||
|  | ||||
|   group.add(mesh, arrowGroup, extraSegmentGroup) | ||||
|  | ||||
|   return group | ||||
| } | ||||
|  | ||||
| export function createArcGeometry({ | ||||
|   center, | ||||
|   radius, | ||||
| @ -579,3 +784,8 @@ export function dashedStraight( | ||||
|   geo.userData.type = 'dashed' | ||||
|   return geo | ||||
| } | ||||
|  | ||||
| export const segmentUtils = { | ||||
|   straight: new StraightSegment(), | ||||
|   tangentialArcTo: new TangentialArcToSegment(), | ||||
| } as const | ||||
|  | ||||
| @ -29,8 +29,8 @@ export const ActionIcon = ({ | ||||
|   size = 'md', | ||||
|   children, | ||||
| }: ActionIconProps) => { | ||||
|   const computedIconClassName = `h-auto text-inherit dark:text-current !group-disabled:text-chalkboard-60 !group-disabled:text-chalkboard-60 ${iconClassName}` | ||||
|   const computedBgClassName = `bg-chalkboard-20 dark:bg-chalkboard-80 !group-disabled:bg-chalkboard-30 !dark:group-disabled:bg-chalkboard-80 ${bgClassName}` | ||||
|   const computedIconClassName = `h-auto text-inherit dark:text-current group-disabled:text-chalkboard-60 group-disabled:text-chalkboard-60 ${iconClassName}` | ||||
|   const computedBgClassName = `bg-chalkboard-20 dark:bg-chalkboard-80 group-disabled:bg-chalkboard-30 dark:group-disabled:bg-chalkboard-80 ${bgClassName}` | ||||
|  | ||||
|   return ( | ||||
|     <div | ||||
|  | ||||
| @ -151,6 +151,7 @@ export function useCalc({ | ||||
|         }) | ||||
|         if (trap(error)) return | ||||
|       } | ||||
|       // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|       executeAst({ | ||||
|         ast, | ||||
|         engineCommandManager, | ||||
|  | ||||
| @ -2,6 +2,7 @@ import { useState, useEffect } from 'react' | ||||
| import { EngineCommandManagerEvents } from 'lang/std/engineConnection' | ||||
| import { engineCommandManager, sceneInfra } from 'lib/singletons' | ||||
| import { throttle, isReducedMotion } from 'lib/utils' | ||||
| import { reportRejection } from 'lib/trap' | ||||
|  | ||||
| const updateDollyZoom = throttle( | ||||
|   (newFov: number) => sceneInfra.camControls.dollyZoom(newFov), | ||||
| @ -16,8 +17,8 @@ export const CamToggle = () => { | ||||
|   useEffect(() => { | ||||
|     engineCommandManager.addEventListener( | ||||
|       EngineCommandManagerEvents.SceneReady, | ||||
|       async () => { | ||||
|         sceneInfra.camControls.dollyZoom(fov) | ||||
|       () => { | ||||
|         sceneInfra.camControls.dollyZoom(fov).catch(reportRejection) | ||||
|       } | ||||
|     ) | ||||
|   }, []) | ||||
| @ -26,11 +27,11 @@ export const CamToggle = () => { | ||||
|     if (isPerspective) { | ||||
|       isReducedMotion() | ||||
|         ? sceneInfra.camControls.useOrthographicCamera() | ||||
|         : sceneInfra.camControls.animateToOrthographic() | ||||
|         : sceneInfra.camControls.animateToOrthographic().catch(reportRejection) | ||||
|     } else { | ||||
|       isReducedMotion() | ||||
|         ? sceneInfra.camControls.usePerspectiveCamera() | ||||
|         : sceneInfra.camControls.animateToPerspective() | ||||
|         ? sceneInfra.camControls.usePerspectiveCamera().catch(reportRejection) | ||||
|         : sceneInfra.camControls.animateToPerspective().catch(reportRejection) | ||||
|     } | ||||
|     setIsPerspective(!isPerspective) | ||||
|   } | ||||
|  | ||||
| @ -71,6 +71,17 @@ function CommandArgOptionInput({ | ||||
|     inputRef.current?.focus() | ||||
|     inputRef.current?.select() | ||||
|   }, [inputRef]) | ||||
|   useEffect(() => { | ||||
|     // work around to make sure the user doesn't have to press the down arrow key to focus the first option | ||||
|     // instead this makes it move from the first hit | ||||
|     const downArrowEvent = new KeyboardEvent('keydown', { | ||||
|       key: 'ArrowDown', | ||||
|       keyCode: 40, | ||||
|       which: 40, | ||||
|       bubbles: true, | ||||
|     }) | ||||
|     inputRef?.current?.dispatchEvent(downArrowEvent) | ||||
|   }, []) | ||||
|  | ||||
|   // Filter the options based on the query, | ||||
|   // resetting the query when the options change | ||||
|  | ||||
| @ -1,53 +1,43 @@ | ||||
| import { useMachine } from '@xstate/react' | ||||
| import { createActorContext } from '@xstate/react' | ||||
| import { editorManager } from 'lib/singletons' | ||||
| import { commandBarMachine } from 'machines/commandBarMachine' | ||||
| import { createContext, useEffect } from 'react' | ||||
| import { EventFrom, StateFrom } from 'xstate' | ||||
| import { useEffect } from 'react' | ||||
|  | ||||
| type CommandsContextType = { | ||||
|   commandBarState: StateFrom<typeof commandBarMachine> | ||||
|   commandBarSend: (event: EventFrom<typeof commandBarMachine>) => void | ||||
| } | ||||
|  | ||||
| export const CommandsContext = createContext<CommandsContextType>({ | ||||
|   commandBarState: commandBarMachine.initialState, | ||||
|   commandBarSend: () => {}, | ||||
| }) | ||||
|  | ||||
| export const CommandBarProvider = ({ | ||||
|   children, | ||||
| }: { | ||||
|   children: React.ReactNode | ||||
| }) => { | ||||
|   const [commandBarState, commandBarSend] = useMachine(commandBarMachine, { | ||||
|     devTools: true, | ||||
| export const CommandsContext = createActorContext( | ||||
|   commandBarMachine.provide({ | ||||
|     guards: { | ||||
|       'Command has no arguments': (context, _event) => { | ||||
|       'Command has no arguments': ({ context }) => { | ||||
|         return ( | ||||
|           !context.selectedCommand?.args || | ||||
|           Object.keys(context.selectedCommand?.args).length === 0 | ||||
|         ) | ||||
|       }, | ||||
|       'All arguments are skippable': (context, _event) => { | ||||
|       'All arguments are skippable': ({ context }) => { | ||||
|         return Object.values(context.selectedCommand!.args!).every( | ||||
|           (argConfig) => argConfig.skip | ||||
|         ) | ||||
|       }, | ||||
|     }, | ||||
|   }) | ||||
| ) | ||||
|  | ||||
|   useEffect(() => { | ||||
|     editorManager.setCommandBarSend(commandBarSend) | ||||
|   }) | ||||
|  | ||||
| export const CommandBarProvider = ({ | ||||
|   children, | ||||
| }: { | ||||
|   children: React.ReactNode | ||||
| }) => { | ||||
|   return ( | ||||
|     <CommandsContext.Provider | ||||
|       value={{ | ||||
|         commandBarState, | ||||
|         commandBarSend, | ||||
|       }} | ||||
|     > | ||||
|       {children} | ||||
|     <CommandsContext.Provider> | ||||
|       <CommandBarProviderInner>{children}</CommandBarProviderInner> | ||||
|     </CommandsContext.Provider> | ||||
|   ) | ||||
| } | ||||
| function CommandBarProviderInner({ children }: { children: React.ReactNode }) { | ||||
|   const commandBarActor = CommandsContext.useActorRef() | ||||
|  | ||||
|   useEffect(() => { | ||||
|     editorManager.setCommandBarSend(commandBarActor.send) | ||||
|   }) | ||||
|  | ||||
|   return children | ||||
| } | ||||
|  | ||||
| @ -52,7 +52,7 @@ function CommandBarReview({ stepBack }: { stepBack: () => void }) { | ||||
|     e.preventDefault() | ||||
|     commandBarSend({ | ||||
|       type: 'Submit command', | ||||
|       data: argumentsToSubmit, | ||||
|       output: argumentsToSubmit, | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|  | ||||
| @ -9,7 +9,7 @@ import { | ||||
|   getSelectionTypeDisplayText, | ||||
| } from 'lib/selections' | ||||
| import { modelingMachine } from 'machines/modelingMachine' | ||||
| import { useCallback, useEffect, useRef, useState } from 'react' | ||||
| import { useEffect, useMemo, useRef, useState } from 'react' | ||||
| import { StateFrom } from 'xstate' | ||||
|  | ||||
| const semanticEntityNames: { [key: string]: Array<Selection['type']> } = { | ||||
| @ -48,15 +48,15 @@ function CommandBarSelectionInput({ | ||||
|   const { commandBarState, commandBarSend } = useCommandsContext() | ||||
|   const [hasSubmitted, setHasSubmitted] = useState(false) | ||||
|   const selection = useSelector(arg.machineActor, selectionSelector) | ||||
|   const initSelectionsByType = useCallback(() => { | ||||
|   const selectionsByType = useMemo(() => { | ||||
|     const selectionRangeEnd = selection.codeBasedSelections[0]?.range[1] | ||||
|     return !selectionRangeEnd || selectionRangeEnd === code.length | ||||
|       ? 'none' | ||||
|       : getSelectionType(selection) | ||||
|   }, [selection, code]) | ||||
|   const selectionsByType = initSelectionsByType() | ||||
|   const [canSubmitSelection, setCanSubmitSelection] = useState<boolean>( | ||||
|     canSubmitSelectionArg(selectionsByType, arg) | ||||
|   const canSubmitSelection = useMemo<boolean>( | ||||
|     () => canSubmitSelectionArg(selectionsByType, arg), | ||||
|     [selectionsByType] | ||||
|   ) | ||||
|  | ||||
|   useEffect(() => { | ||||
| @ -66,26 +66,18 @@ function CommandBarSelectionInput({ | ||||
|   // Fast-forward through this arg if it's marked as skippable | ||||
|   // and we have a valid selection already | ||||
|   useEffect(() => { | ||||
|     console.log('selection input effect', { | ||||
|       selectionsByType, | ||||
|       canSubmitSelection, | ||||
|       arg, | ||||
|     }) | ||||
|     setCanSubmitSelection(canSubmitSelectionArg(selectionsByType, arg)) | ||||
|     const argValue = commandBarState.context.argumentsToSubmit[arg.name] | ||||
|     if (canSubmitSelection && arg.skip && argValue === undefined) { | ||||
|       handleSubmit({ | ||||
|         preventDefault: () => {}, | ||||
|       } as React.FormEvent<HTMLFormElement>) | ||||
|       handleSubmit() | ||||
|     } | ||||
|   }, [selectionsByType, arg]) | ||||
|   }, [canSubmitSelection]) | ||||
|  | ||||
|   function handleChange() { | ||||
|     inputRef.current?.focus() | ||||
|   } | ||||
|  | ||||
|   function handleSubmit(e: React.FormEvent<HTMLFormElement>) { | ||||
|     e.preventDefault() | ||||
|   function handleSubmit(e?: React.FormEvent<HTMLFormElement>) { | ||||
|     e?.preventDefault() | ||||
|  | ||||
|     if (!canSubmitSelection) { | ||||
|       setHasSubmitted(true) | ||||
|  | ||||
| @ -11,6 +11,7 @@ export function CommandBarOpenButton() { | ||||
|     <button | ||||
|       className="group rounded-full flex items-center justify-center gap-2 px-2 py-1 bg-primary/10 dark:bg-chalkboard-90 dark:backdrop-blur-sm border-primary hover:border-primary dark:border-chalkboard-50 dark:hover:border-inherit text-primary dark:text-inherit" | ||||
|       onClick={() => commandBarSend({ type: 'Open' })} | ||||
|       data-testid="command-bar-open-button" | ||||
|     > | ||||
|       <span>Commands</span> | ||||
|       <kbd className="bg-primary/10 dark:bg-chalkboard-80 dark:group-hover:bg-primary font-mono rounded-sm dark:text-inherit inline-block px-1 border-primary dark:border-chalkboard-90"> | ||||
|  | ||||
| @ -681,6 +681,21 @@ const CustomIconMap = { | ||||
|       /> | ||||
|     </svg> | ||||
|   ), | ||||
|   logs: ( | ||||
|     <svg | ||||
|       viewBox="0 0 20 20" | ||||
|       fill="none" | ||||
|       xmlns="http://www.w3.org/2000/svg" | ||||
|       aria-label="logs" | ||||
|     > | ||||
|       <path | ||||
|         fillRule="evenodd" | ||||
|         clipRule="evenodd" | ||||
|         d="M6.5 15C6.5 14.1716 5.82843 13.5 5 13.5C4.17157 13.5 3.5 14.1716 3.5 15C3.5 15.8284 4.17157 16.5 5 16.5C5.82843 16.5 6.5 15.8284 6.5 15ZM6.5 10C6.5 9.17157 5.82843 8.5 5 8.5C4.17157 8.5 3.5 9.17157 3.5 10C3.5 10.8284 4.17157 11.5 5 11.5C5.82843 11.5 6.5 10.8284 6.5 10ZM5 3.5C5.82843 3.5 6.5 4.17157 6.5 5C6.5 5.82843 5.82843 6.5 5 6.5C4.17157 6.5 3.5 5.82843 3.5 5C3.5 4.17157 4.17157 3.5 5 3.5ZM8.5 5.5H16.5V4.5H8.5V5.5ZM8.5 10.5H16.5V9.5H8.5V10.5ZM16.5 15.5H8.5V14.5H16.5V15.5Z" | ||||
|         fill="currentColor" | ||||
|       /> | ||||
|     </svg> | ||||
|   ), | ||||
|   'make-variable': ( | ||||
|     <svg | ||||
|       viewBox="0 0 20 20" | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| import { CommandLog } from 'lang/std/engineConnection' | ||||
| import { engineCommandManager } from 'lib/singletons' | ||||
| import { reportRejection } from 'lib/trap' | ||||
| import { useState, useEffect } from 'react' | ||||
|  | ||||
| export function useEngineCommands(): [CommandLog[], () => void] { | ||||
| @ -77,9 +78,11 @@ export const EngineCommands = () => { | ||||
|       /> | ||||
|       <button | ||||
|         data-testid="custom-cmd-send-button" | ||||
|         onClick={() => | ||||
|           engineCommandManager.sendSceneCommand(JSON.parse(customCmd)) | ||||
|         } | ||||
|         onClick={() => { | ||||
|           engineCommandManager | ||||
|             .sendSceneCommand(JSON.parse(customCmd)) | ||||
|             .catch(reportRejection) | ||||
|         }} | ||||
|       > | ||||
|         Send custom command | ||||
|       </button> | ||||
|  | ||||
| @ -5,13 +5,12 @@ import { PATHS } from 'lib/paths' | ||||
| import React, { createContext } from 'react' | ||||
| import { toast } from 'react-hot-toast' | ||||
| import { | ||||
|   Actor, | ||||
|   AnyStateMachine, | ||||
|   ContextFrom, | ||||
|   EventFrom, | ||||
|   InterpreterFrom, | ||||
|   Prop, | ||||
|   StateFrom, | ||||
|   assign, | ||||
|   fromPromise, | ||||
| } from 'xstate' | ||||
| import { useCommandsContext } from 'hooks/useCommandsContext' | ||||
| import { fileMachine } from 'machines/fileMachine' | ||||
| @ -27,7 +26,7 @@ import { getNextDirName, getNextFileName } from 'lib/desktopFS' | ||||
| type MachineContext<T extends AnyStateMachine> = { | ||||
|   state: StateFrom<T> | ||||
|   context: ContextFrom<T> | ||||
|   send: Prop<InterpreterFrom<T>, 'send'> | ||||
|   send: Prop<Actor<T>, 'send'> | ||||
| } | ||||
|  | ||||
| export const FileContext = createContext( | ||||
| @ -43,239 +42,234 @@ export const FileMachineProvider = ({ | ||||
|   const { commandBarSend } = useCommandsContext() | ||||
|   const { project, file } = useRouteLoaderData(PATHS.FILE) as IndexLoaderData | ||||
|  | ||||
|   const [state, send] = useMachine(fileMachine, { | ||||
|     context: { | ||||
|       project, | ||||
|       selectedDirectory: project, | ||||
|     }, | ||||
|     actions: { | ||||
|       navigateToFile: (context, event) => { | ||||
|         if (event.data && 'name' in event.data) { | ||||
|           commandBarSend({ type: 'Close' }) | ||||
|           navigate( | ||||
|             `..${PATHS.FILE}/${encodeURIComponent( | ||||
|               context.selectedDirectory + | ||||
|                 window.electron.path.sep + | ||||
|                 event.data.name | ||||
|             )}` | ||||
|   const [state, send] = useMachine( | ||||
|     fileMachine.provide({ | ||||
|       actions: { | ||||
|         renameToastSuccess: ({ event }) => { | ||||
|           if (event.type !== 'xstate.done.actor.rename-file') return | ||||
|           toast.success(event.output.message) | ||||
|         }, | ||||
|         createToastSuccess: ({ event }) => { | ||||
|           if (event.type !== 'xstate.done.actor.create-and-open-file') return | ||||
|           toast.success(event.output.message) | ||||
|         }, | ||||
|         toastSuccess: ({ event }) => { | ||||
|           if ( | ||||
|             event.type !== 'xstate.done.actor.rename-file' && | ||||
|             event.type !== 'xstate.done.actor.delete-file' | ||||
|           ) | ||||
|         } else if ( | ||||
|           event.data && | ||||
|           'path' in event.data && | ||||
|           event.data.path.endsWith(FILE_EXT) | ||||
|         ) { | ||||
|           // Don't navigate to newly created directories | ||||
|           navigate(`..${PATHS.FILE}/${encodeURIComponent(event.data.path)}`) | ||||
|         } | ||||
|             return | ||||
|           toast.success(event.output.message) | ||||
|         }, | ||||
|         toastError: ({ event }) => { | ||||
|           if (event.type !== 'xstate.done.actor.rename-file') return | ||||
|           toast.error(event.output.message) | ||||
|         }, | ||||
|         navigateToFile: ({ context, event }) => { | ||||
|           if (event.type !== 'xstate.done.actor.create-and-open-file') return | ||||
|           if (event.output && 'name' in event.output) { | ||||
|             commandBarSend({ type: 'Close' }) | ||||
|             navigate( | ||||
|               `..${PATHS.FILE}/${encodeURIComponent( | ||||
|                 context.selectedDirectory + | ||||
|                   window.electron.path.sep + | ||||
|                   event.output.name | ||||
|               )}` | ||||
|             ) | ||||
|           } else if ( | ||||
|             event.output && | ||||
|             'path' in event.output && | ||||
|             event.output.path.endsWith(FILE_EXT) | ||||
|           ) { | ||||
|             // Don't navigate to newly created directories | ||||
|             navigate(`..${PATHS.FILE}/${encodeURIComponent(event.output.path)}`) | ||||
|           } | ||||
|         }, | ||||
|       }, | ||||
|       addFileToRenamingQueue: assign({ | ||||
|         itemsBeingRenamed: (context, event) => [ | ||||
|           ...context.itemsBeingRenamed, | ||||
|           event.data.path, | ||||
|         ], | ||||
|       }), | ||||
|       removeFileFromRenamingQueue: assign({ | ||||
|         itemsBeingRenamed: ( | ||||
|           context, | ||||
|           event: EventFrom<typeof fileMachine, 'done.invoke.rename-file'> | ||||
|         ) => | ||||
|           context.itemsBeingRenamed.filter( | ||||
|             (path) => path !== event.data.oldPath | ||||
|           ), | ||||
|       }), | ||||
|       renameToastSuccess: (_, event) => toast.success(event.data.message), | ||||
|       createToastSuccess: (_, event) => toast.success(event.data.message), | ||||
|       toastSuccess: (_, event) => | ||||
|         event.data && toast.success((event.data || '') + ''), | ||||
|       toastError: (_, event) => toast.error((event.data || '') + ''), | ||||
|     }, | ||||
|     services: { | ||||
|       readFiles: async (context: ContextFrom<typeof fileMachine>) => { | ||||
|         const newFiles = isDesktop() | ||||
|           ? (await getProjectInfo(context.project.path)).children | ||||
|           : [] | ||||
|         return { | ||||
|           ...context.project, | ||||
|           children: newFiles, | ||||
|         } | ||||
|       }, | ||||
|       createAndOpenFile: async (context, event) => { | ||||
|         let createdName = event.data.name.trim() || DEFAULT_FILE_NAME | ||||
|         let createdPath: string | ||||
|  | ||||
|         if (event.data.makeDir) { | ||||
|           let { name, path } = getNextDirName({ | ||||
|             entryName: createdName, | ||||
|             baseDir: context.selectedDirectory.path, | ||||
|           }) | ||||
|           createdName = name | ||||
|           createdPath = path | ||||
|           await window.electron.mkdir(createdPath) | ||||
|         } else { | ||||
|           const { name, path } = getNextFileName({ | ||||
|             entryName: createdName, | ||||
|             baseDir: context.selectedDirectory.path, | ||||
|           }) | ||||
|           createdName = name | ||||
|           createdPath = path | ||||
|           await window.electron.writeFile(createdPath, event.data.content ?? '') | ||||
|         } | ||||
|  | ||||
|         return { | ||||
|           message: `Successfully created "${createdName}"`, | ||||
|           path: createdPath, | ||||
|         } | ||||
|       }, | ||||
|       createFile: async (context, event) => { | ||||
|         let createdName = event.data.name.trim() || DEFAULT_FILE_NAME | ||||
|         let createdPath: string | ||||
|  | ||||
|         if (event.data.makeDir) { | ||||
|           let { name, path } = getNextDirName({ | ||||
|             entryName: createdName, | ||||
|             baseDir: context.selectedDirectory.path, | ||||
|           }) | ||||
|           createdName = name | ||||
|           createdPath = path | ||||
|           await window.electron.mkdir(createdPath) | ||||
|         } else { | ||||
|           const { name, path } = getNextFileName({ | ||||
|             entryName: createdName, | ||||
|             baseDir: context.selectedDirectory.path, | ||||
|           }) | ||||
|           createdName = name | ||||
|           createdPath = path | ||||
|           await window.electron.writeFile(createdPath, event.data.content ?? '') | ||||
|         } | ||||
|  | ||||
|         return { | ||||
|           path: createdPath, | ||||
|         } | ||||
|       }, | ||||
|       renameFile: async ( | ||||
|         context: ContextFrom<typeof fileMachine>, | ||||
|         event: EventFrom<typeof fileMachine, 'Rename file'> | ||||
|       ) => { | ||||
|         const { oldName, newName, isDir } = event.data | ||||
|         const name = newName | ||||
|           ? newName.endsWith(FILE_EXT) || isDir | ||||
|             ? newName | ||||
|             : newName + FILE_EXT | ||||
|           : DEFAULT_FILE_NAME | ||||
|         const oldPath = window.electron.path.join( | ||||
|           context.selectedDirectory.path, | ||||
|           oldName | ||||
|         ) | ||||
|         const newPath = window.electron.path.join( | ||||
|           context.selectedDirectory.path, | ||||
|           name | ||||
|         ) | ||||
|  | ||||
|         // no-op | ||||
|         if (oldPath === newPath) { | ||||
|       actors: { | ||||
|         readFiles: fromPromise(async ({ input }) => { | ||||
|           const newFiles = | ||||
|             (isDesktop() ? (await getProjectInfo(input.path)).children : []) ?? | ||||
|             [] | ||||
|           return { | ||||
|             message: `Old is the same as new.`, | ||||
|             ...input, | ||||
|             children: newFiles, | ||||
|           } | ||||
|         }), | ||||
|         createAndOpenFile: fromPromise(async ({ input }) => { | ||||
|           let createdName = input.name.trim() || DEFAULT_FILE_NAME | ||||
|           let createdPath: string | ||||
|  | ||||
|           if (input.makeDir) { | ||||
|             let { name, path } = getNextDirName({ | ||||
|               entryName: createdName, | ||||
|               baseDir: input.selectedDirectory.path, | ||||
|             }) | ||||
|             createdName = name | ||||
|             createdPath = path | ||||
|             await window.electron.mkdir(createdPath) | ||||
|           } else { | ||||
|             const { name, path } = getNextFileName({ | ||||
|               entryName: createdName, | ||||
|               baseDir: input.selectedDirectory.path, | ||||
|             }) | ||||
|             createdName = name | ||||
|             createdPath = path | ||||
|             await window.electron.writeFile(createdPath, input.content ?? '') | ||||
|           } | ||||
|  | ||||
|           return { | ||||
|             message: `Successfully created "${createdName}"`, | ||||
|             path: createdPath, | ||||
|           } | ||||
|         }), | ||||
|         createFile: fromPromise(async ({ input }) => { | ||||
|           let createdName = input.name.trim() || DEFAULT_FILE_NAME | ||||
|           let createdPath: string | ||||
|  | ||||
|           if (input.makeDir) { | ||||
|             let { name, path } = getNextDirName({ | ||||
|               entryName: createdName, | ||||
|               baseDir: input.selectedDirectory.path, | ||||
|             }) | ||||
|             createdName = name | ||||
|             createdPath = path | ||||
|             await window.electron.mkdir(createdPath) | ||||
|           } else { | ||||
|             const { name, path } = getNextFileName({ | ||||
|               entryName: createdName, | ||||
|               baseDir: input.selectedDirectory.path, | ||||
|             }) | ||||
|             createdName = name | ||||
|             createdPath = path | ||||
|             await window.electron.writeFile(createdPath, input.content ?? '') | ||||
|           } | ||||
|  | ||||
|           return { | ||||
|             path: createdPath, | ||||
|           } | ||||
|         }), | ||||
|         renameFile: fromPromise(async ({ input }) => { | ||||
|           const { oldName, newName, isDir } = input | ||||
|           const name = newName | ||||
|             ? newName.endsWith(FILE_EXT) || isDir | ||||
|               ? newName | ||||
|               : newName + FILE_EXT | ||||
|             : DEFAULT_FILE_NAME | ||||
|           const oldPath = window.electron.path.join( | ||||
|             input.selectedDirectory.path, | ||||
|             oldName | ||||
|           ) | ||||
|           const newPath = window.electron.path.join( | ||||
|             input.selectedDirectory.path, | ||||
|             name | ||||
|           ) | ||||
|  | ||||
|           // no-op | ||||
|           if (oldPath === newPath) { | ||||
|             return { | ||||
|               message: `Old is the same as new.`, | ||||
|               newPath, | ||||
|               oldPath, | ||||
|             } | ||||
|           } | ||||
|  | ||||
|           // if there are any siblings with the same name, report error. | ||||
|           const entries = await window.electron.readdir( | ||||
|             window.electron.path.dirname(newPath) | ||||
|           ) | ||||
|           for (let entry of entries) { | ||||
|             if (entry === newName) { | ||||
|               return Promise.reject(new Error('Filename already exists.')) | ||||
|             } | ||||
|           } | ||||
|  | ||||
|           window.electron.rename(oldPath, newPath) | ||||
|  | ||||
|           if (!file) { | ||||
|             return Promise.reject(new Error('file is not defined')) | ||||
|           } | ||||
|  | ||||
|           if (oldPath === file.path && project?.path) { | ||||
|             // If we just renamed the current file, navigate to the new path | ||||
|             navigate(`..${PATHS.FILE}/${encodeURIComponent(newPath)}`) | ||||
|           } else if (file?.path.includes(oldPath)) { | ||||
|             // If we just renamed a directory that the current file is in, navigate to the new path | ||||
|             navigate( | ||||
|               `..${PATHS.FILE}/${encodeURIComponent( | ||||
|                 file.path.replace(oldPath, newPath) | ||||
|               )}` | ||||
|             ) | ||||
|           } | ||||
|  | ||||
|           return { | ||||
|             message: `Successfully renamed "${oldName}" to "${name}"`, | ||||
|             newPath, | ||||
|             oldPath, | ||||
|           } | ||||
|         } | ||||
|         }), | ||||
|         deleteFile: fromPromise(async ({ input }) => { | ||||
|           const isDir = !!input.children | ||||
|  | ||||
|         // if there are any siblings with the same name, report error. | ||||
|         const entries = await window.electron.readdir( | ||||
|           window.electron.path.dirname(newPath) | ||||
|         ) | ||||
|         for (let entry of entries) { | ||||
|           if (entry === newName) { | ||||
|             return Promise.reject(new Error('Filename already exists.')) | ||||
|           if (isDir) { | ||||
|             await window.electron | ||||
|               .rm(input.path, { | ||||
|                 recursive: true, | ||||
|               }) | ||||
|               .catch((e) => console.error('Error deleting directory', e)) | ||||
|           } else { | ||||
|             await window.electron | ||||
|               .rm(input.path) | ||||
|               .catch((e) => console.error('Error deleting file', e)) | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         window.electron.rename(oldPath, newPath) | ||||
|           // If there are no more files at all in the project, create a main.kcl | ||||
|           // for when we navigate to the root. | ||||
|           if (!project?.path) { | ||||
|             return Promise.reject(new Error('Project path not set.')) | ||||
|           } | ||||
|  | ||||
|         if (!file) { | ||||
|           return Promise.reject(new Error('file is not defined')) | ||||
|         } | ||||
|           const entries = await window.electron.readdir(project.path) | ||||
|           const hasKclEntries = | ||||
|             entries.filter((e: string) => e.endsWith('.kcl')).length !== 0 | ||||
|           if (!hasKclEntries) { | ||||
|             await window.electron.writeFile( | ||||
|               window.electron.path.join(project.path, DEFAULT_PROJECT_KCL_FILE), | ||||
|               '' | ||||
|             ) | ||||
|             // Refresh the route selected above because it's possible we're on | ||||
|             // the same path on the navigate, which doesn't cause anything to | ||||
|             // refresh, leaving a stale execution state. | ||||
|             navigate(0) | ||||
|             return { | ||||
|               message: 'No more files in project, created main.kcl', | ||||
|             } | ||||
|           } | ||||
|  | ||||
|         if (oldPath === file.path && project?.path) { | ||||
|           // If we just renamed the current file, navigate to the new path | ||||
|           navigate(`..${PATHS.FILE}/${encodeURIComponent(newPath)}`) | ||||
|         } else if (file?.path.includes(oldPath)) { | ||||
|           // If we just renamed a directory that the current file is in, navigate to the new path | ||||
|           navigate( | ||||
|             `..${PATHS.FILE}/${encodeURIComponent( | ||||
|               file.path.replace(oldPath, newPath) | ||||
|             )}` | ||||
|           ) | ||||
|         } | ||||
|           // If we just deleted the current file or one of its parent directories, | ||||
|           // navigate to the project root | ||||
|           if ( | ||||
|             (input.path === file?.path || file?.path.includes(input.path)) && | ||||
|             project?.path | ||||
|           ) { | ||||
|             navigate(`../${PATHS.FILE}/${encodeURIComponent(project.path)}`) | ||||
|           } | ||||
|  | ||||
|         return { | ||||
|           message: `Successfully renamed "${oldName}" to "${name}"`, | ||||
|           newPath, | ||||
|           oldPath, | ||||
|         } | ||||
|           return { | ||||
|             message: `Successfully deleted ${isDir ? 'folder' : 'file'} "${ | ||||
|               input.name | ||||
|             }"`, | ||||
|           } | ||||
|         }), | ||||
|       }, | ||||
|       deleteFile: async ( | ||||
|         context: ContextFrom<typeof fileMachine>, | ||||
|         event: EventFrom<typeof fileMachine, 'Delete file'> | ||||
|       ) => { | ||||
|         const isDir = !!event.data.children | ||||
|  | ||||
|         if (isDir) { | ||||
|           await window.electron | ||||
|             .rm(event.data.path, { | ||||
|               recursive: true, | ||||
|             }) | ||||
|             .catch((e) => console.error('Error deleting directory', e)) | ||||
|         } else { | ||||
|           await window.electron | ||||
|             .rm(event.data.path) | ||||
|             .catch((e) => console.error('Error deleting file', e)) | ||||
|         } | ||||
|  | ||||
|         // If there are no more files at all in the project, create a main.kcl | ||||
|         // for when we navigate to the root. | ||||
|         if (!project?.path) { | ||||
|           return Promise.reject(new Error('Project path not set.')) | ||||
|         } | ||||
|  | ||||
|         const entries = await window.electron.readdir(project.path) | ||||
|         const hasKclEntries = | ||||
|           entries.filter((e: string) => e.endsWith('.kcl')).length !== 0 | ||||
|         if (!hasKclEntries) { | ||||
|           await window.electron.writeFile( | ||||
|             window.electron.path.join(project.path, DEFAULT_PROJECT_KCL_FILE), | ||||
|             '' | ||||
|           ) | ||||
|           // Refresh the route selected above because it's possible we're on | ||||
|           // the same path on the navigate, which doesn't cause anything to | ||||
|           // refresh, leaving a stale execution state. | ||||
|           navigate(0) | ||||
|           return | ||||
|         } | ||||
|  | ||||
|         // If we just deleted the current file or one of its parent directories, | ||||
|         // navigate to the project root | ||||
|         if ( | ||||
|           (event.data.path === file?.path || | ||||
|             file?.path.includes(event.data.path)) && | ||||
|           project?.path | ||||
|         ) { | ||||
|           navigate(`../${PATHS.FILE}/${encodeURIComponent(project.path)}`) | ||||
|         } | ||||
|  | ||||
|         return `Successfully deleted ${isDir ? 'folder' : 'file'} "${ | ||||
|           event.data.name | ||||
|         }"` | ||||
|     }), | ||||
|     { | ||||
|       input: { | ||||
|         project, | ||||
|         selectedDirectory: project, | ||||
|       }, | ||||
|     }, | ||||
|     guards: { | ||||
|       'Has at least 1 file': (_, event: EventFrom<typeof fileMachine>) => { | ||||
|         if (event.type !== 'done.invoke.read-files') return false | ||||
|         return !!event?.data?.children && event.data.children.length > 0 | ||||
|       }, | ||||
|       'Is not silent': (_, event) => !event.data?.silent, | ||||
|     }, | ||||
|   }) | ||||
|     } | ||||
|   ) | ||||
|  | ||||
|   return ( | ||||
|     <FileContext.Provider | ||||
|  | ||||
| @ -176,9 +176,11 @@ const FileTreeItem = ({ | ||||
|         `import("${fileOrDir.path.replace(project.path, '.')}")\n` + | ||||
|           codeManager.code | ||||
|       ) | ||||
|       // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|       codeManager.writeToFile() | ||||
|  | ||||
|       // Prevent seeing the model built one piece at a time when changing files | ||||
|       // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|       kclManager.executeCode(true) | ||||
|     } else { | ||||
|       // Let the lsp servers know we closed a file. | ||||
| @ -243,13 +245,13 @@ const FileTreeItem = ({ | ||||
|                   onClickCapture={(e) => | ||||
|                     fileSend({ | ||||
|                       type: 'Set selected directory', | ||||
|                       data: fileOrDir, | ||||
|                       directory: fileOrDir, | ||||
|                     }) | ||||
|                   } | ||||
|                   onFocusCapture={(e) => | ||||
|                     fileSend({ | ||||
|                       type: 'Set selected directory', | ||||
|                       data: fileOrDir, | ||||
|                       directory: fileOrDir, | ||||
|                     }) | ||||
|                   } | ||||
|                   onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()} | ||||
| @ -296,13 +298,13 @@ const FileTreeItem = ({ | ||||
|                   onClickCapture={(e) => { | ||||
|                     fileSend({ | ||||
|                       type: 'Set selected directory', | ||||
|                       data: fileOrDir, | ||||
|                       directory: fileOrDir, | ||||
|                     }) | ||||
|                   }} | ||||
|                   onFocusCapture={(e) => | ||||
|                     fileSend({ | ||||
|                       type: 'Set selected directory', | ||||
|                       data: fileOrDir, | ||||
|                       directory: fileOrDir, | ||||
|                     }) | ||||
|                   } | ||||
|                 > | ||||
| @ -388,14 +390,14 @@ interface FileTreeProps { | ||||
| export const FileTreeMenu = () => { | ||||
|   const { send } = useFileContext() | ||||
|  | ||||
|   async function createFile() { | ||||
|   function createFile() { | ||||
|     send({ | ||||
|       type: 'Create file', | ||||
|       data: { name: '', makeDir: false }, | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   async function createFolder() { | ||||
|   function createFolder() { | ||||
|     send({ | ||||
|       type: 'Create file', | ||||
|       data: { name: '', makeDir: true }, | ||||
| @ -482,7 +484,7 @@ export const FileTreeInner = ({ | ||||
|         onClickCapture={(e) => { | ||||
|           fileSend({ | ||||
|             type: 'Set selected directory', | ||||
|             data: fileContext.project, | ||||
|             directory: fileContext.project, | ||||
|           }) | ||||
|         }} | ||||
|       > | ||||
|  | ||||
