Compare commits
	
		
			54 Commits
		
	
	
		
			pierremtb/
			...
			paultag/di
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 23df7e5429 | |||
| 7ed26e21c6 | |||
| c668d40efc | |||
| f38c6b90b7 | |||
| 7bc8bae0ec | |||
| 3804aca27e | |||
| b127680f2f | |||
| b7de8e60cf | |||
| 058fccb5e1 | |||
| 0006d72973 | |||
| 00e97257ae | |||
| aeb656d176 | |||
| ac49ebd6e0 | |||
| b40f03ad25 | |||
| a8ad86e645 | |||
| 87f50cd5e9 | |||
| 0400e6228e | |||
| 26f150fd6c | |||
| 3049f405f5 | |||
| 53d40301dc | |||
| 671c01e36f | |||
| e80151979b | |||
| 668e2afb99 | |||
| ea57de0074 | |||
| 548c664db0 | |||
| d3a3f4410c | |||
| 22eb343171 | |||
| f2cfa4d5cf | |||
| 3f1f40eeba | |||
| ff2d161606 | |||
| 210c78029d | |||
| e27840219b | |||
| c943a3f192 | |||
| 6aa588f09f | |||
| 59a6333aad | |||
| 403f1507ae | |||
| eac7b83504 | |||
| 667500d1b9 | |||
| b15aac9f48 | |||
| 54153aa646 | |||
| 943cf21d34 | |||
| 5a6728c45a | |||
| ff2103d493 | |||
| 2dfa8f2176 | |||
| 29ed330326 | |||
| ca2cc825a6 | |||
| 83fe1b7ce0 | |||
| 157b76cc78 | |||
| cf957d880e | |||
| dfc3d19677 | |||
| dd370a9365 | |||
| 2274d6459c | |||
| 32ce857119 | |||
| 88b51da417 | 
							
								
								
									
										9
									
								
								.github/workflows/build-apps.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @ -165,7 +165,6 @@ jobs: | ||||
|       - name: Build the app (release) | ||||
|         if: ${{ env.IS_RELEASE == 'true' || env.IS_NIGHTLY == 'true' }} | ||||
|         env: | ||||
|           PUBLISH_FOR_PULL_REQUEST: true | ||||
|           APPLE_ID: ${{ secrets.APPLE_ID }} | ||||
|           APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} | ||||
|           APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }} | ||||
| @ -173,7 +172,6 @@ jobs: | ||||
|           CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }} | ||||
|           CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} | ||||
|           CSC_KEYCHAIN: ${{ secrets.APPLE_SIGNING_IDENTITY }} | ||||
|           CSC_FOR_PULL_REQUEST: true | ||||
|           WINDOWS_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_CERTIFICATE_THUMBPRINT }} | ||||
|         run: yarn electron-builder --config --publish always | ||||
|  | ||||
| @ -229,7 +227,6 @@ jobs: | ||||
|           CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }} | ||||
|           CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} | ||||
|           CSC_KEYCHAIN: ${{ secrets.APPLE_SIGNING_IDENTITY }} | ||||
|           CSC_FOR_PULL_REQUEST: true | ||||
|           WINDOWS_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_CERTIFICATE_THUMBPRINT }} | ||||
|         run: yarn electron-builder --config --publish always | ||||
|  | ||||
| @ -365,7 +362,7 @@ jobs: | ||||
|       - name: Set more complete nightly release notes | ||||
|         if: ${{ env.IS_NIGHTLY == 'true' }} | ||||
|         run: | | ||||
|           # Note: prefered going this way instead of a full clone in the checkout step, | ||||
|           # Note: preferred going this way instead of a full clone in the checkout step, | ||||
|           # see https://github.com/actions/checkout/issues/1471 | ||||
|           git fetch --prune --unshallow --tags | ||||
|           export TAG="nightly-${VERSION}" | ||||
| @ -394,6 +391,10 @@ jobs: | ||||
|           parent: false | ||||
|           destination: 'dl.kittycad.io/releases/modeling-app/nightly' | ||||
|  | ||||
|       - name: Invalidate bucket cache on latest*.yml and last_download.json files | ||||
|         if: ${{ env.IS_NIGHTLY == 'true' }} | ||||
|         run: yarn files:invalidate-bucket:nightly | ||||
|  | ||||
|       - name: Tag nightly commit | ||||
|         if: ${{ env.IS_NIGHTLY == 'true' }} | ||||
|         uses: actions/github-script@v7 | ||||
|  | ||||
							
								
								
									
										2
									
								
								.github/workflows/cargo-test.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @ -71,7 +71,7 @@ jobs: | ||||
|           KITTYCAD_API_TOKEN: ${{secrets.KITTYCAD_API_TOKEN}} | ||||
|           RUST_MIN_STACK: 10485760000 | ||||
|       - name: Upload to codecov.io | ||||
|         uses: codecov/codecov-action@v4 | ||||
|         uses: codecov/codecov-action@v5 | ||||
|         with: | ||||
|           token: ${{secrets.CODECOV_TOKEN}} | ||||
|           fail_ci_if_error: true | ||||
|  | ||||
							
								
								
									
										6
									
								
								.github/workflows/publish-apps-release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @ -126,11 +126,7 @@ jobs: | ||||
|           destination: 'dl.kittycad.io/releases/modeling-app' | ||||
|  | ||||
|       - name: Invalidate bucket cache on latest*.yml and last_download.json files | ||||
|         run: | | ||||
|           gcloud compute url-maps invalidate-cdn-cache dl-url-map --path="/releases/modeling-app/last_download.json" --async | ||||
|           gcloud compute url-maps invalidate-cdn-cache dl-url-map --path="/releases/modeling-app/latest-linux-arm64.yml" --async | ||||
|           gcloud compute url-maps invalidate-cdn-cache dl-url-map --path="/releases/modeling-app/latest-mac.yml" --async | ||||
|           gcloud compute url-maps invalidate-cdn-cache dl-url-map --path="/releases/modeling-app/latest.yml" --async | ||||
|         run: yarn files:invalidate-bucket | ||||
|  | ||||
|       - name: Upload release files to Github | ||||
|         if: ${{ github.event_name == 'release' }} | ||||
|  | ||||
							
								
								
									
										43
									
								
								INSTALL.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,43 @@ | ||||
| # Setting Up Zoo Modeling App | ||||
|  | ||||
| Compared to other CAD software, getting Zoo Modeling App up and running is quick and straightforward across platforms. It's about 100MB to download and is quick to install. | ||||
|  | ||||
| ## Windows | ||||
|  | ||||
| 1. Download the [Zoo Modeling App installer](https://zoo.dev/modeling-app/download) for Windows and for your processor type. | ||||
|  | ||||
| 2. Once downloaded, run the installer `Zoo Modeling App-{version}-{arch}-win.exe` which should take a few seconds. | ||||
|  | ||||
| 3. The installation happens at `C:\Program Files\Zoo Modeling App`. A shortcut in the start menu is also created so you can run the app easily by clicking on it. | ||||
|  | ||||
| ## macOS | ||||
|  | ||||
| 1. Download the [Zoo Modeling App installer](https://zoo.dev/modeling-app/download) for macOS and for your processor type. | ||||
|  | ||||
| 2. Once downloaded, open the disk image `Zoo Modeling App-{version}-{arch}-mac.dmg` and drag the applications to your `Applications` directory. | ||||
|  | ||||
| 3. You can then open your `Applications` directory and double-click on `Zoo Modeling App` to open. | ||||
|  | ||||
|  | ||||
| ## Linux  | ||||
|  | ||||
| 1. Download the [Zoo Modeling App installer](https://zoo.dev/modeling-app/download) for Linux and for your processor type. | ||||
|  | ||||
| 2. Install the dependencies needed to run the [AppImage format](https://appimage.org/). | ||||
|     -  On Ubuntu, install the FUSE library with these commands in a terminal. | ||||
|        ```bash | ||||
|        sudo apt update | ||||
|        sudo apt install libfuse2 | ||||
|        ``` | ||||
|     - Optionally, follow [these steps](https://github.com/probonopd/go-appimage/blob/master/src/appimaged/README.md#initial-setup) to install `appimaged`. It is a daemon that makes interacting with AppImage files more seamless.  | ||||
|     - Once installed, copy the downloaded `Zoo Modeling App-{version}-{arch}-linux.AppImage` to the directory of your choice, for instance `~/Applications`. | ||||
|  | ||||
|    - `appimaged` should automatically find it and make it executable. If not, run: | ||||
|      ```bash | ||||
|      chmod a+x ~/Applications/Zoo\ Modeling\ App-{version}-{arch}-linux.AppImage | ||||
|      ``` | ||||
|  | ||||
| 3. You can double-click on the AppImage to run it, or in a terminal with this command: | ||||
|    ```bash | ||||
|     ~/Applications/Zoo\ Modeling\ App-{version}-{arch}-linux.AppImage | ||||
|    ``` | ||||
| @ -22,3 +22,5 @@ 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. | ||||
|  | ||||
| - **Appearance**: Changing the appearance on a loft does not work. | ||||
|  | ||||
							
								
								
									
										239
									
								
								docs/kcl/appearance.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -19,6 +19,7 @@ layout: manual | ||||
| * [`angledLineThatIntersects`](kcl/angledLineThatIntersects) | ||||
| * [`angledLineToX`](kcl/angledLineToX) | ||||
| * [`angledLineToY`](kcl/angledLineToY) | ||||
| * [`appearance`](kcl/appearance) | ||||
| * [`arc`](kcl/arc) | ||||
| * [`arcTo`](kcl/arcTo) | ||||
| * [`asin`](kcl/asin) | ||||
| @ -101,6 +102,7 @@ layout: manual | ||||
| * [`startProfileAt`](kcl/startProfileAt) | ||||
| * [`startSketchAt`](kcl/startSketchAt) | ||||
| * [`startSketchOn`](kcl/startSketchOn) | ||||
| * [`sweep`](kcl/sweep) | ||||
| * [`tan`](kcl/tan) | ||||
| * [`tangentToEnd`](kcl/tangentToEnd) | ||||
| * [`tangentialArc`](kcl/tangentialArc) | ||||
|  | ||||
| @ -45,7 +45,7 @@ circles = map([1..3], drawCircle) | ||||
| ```js | ||||
| r = 10 // radius | ||||
| // Call `map`, using an anonymous function instead of a named one. | ||||
| circles = map([1..3], (id) { | ||||
| circles = map([1..3], fn(id) { | ||||
|   return startSketchOn("XY") | ||||
|     |> circle({ center = [id * 2 * r, 0], radius = r }, %) | ||||
| }) | ||||
|  | ||||
| @ -61,7 +61,7 @@ assertEqual(sum([1, 2, 3]), 6, 0.00001, "1 + 2 + 3 summed is 6") | ||||
| // an anonymous `add` function as its parameter, instead of declaring a | ||||
| // named function outside. | ||||
| arr = [1, 2, 3] | ||||
| sum = reduce(arr, 0, (i, result_so_far) { | ||||
| sum = reduce(arr, 0, fn(i, result_so_far) { | ||||
|   return i + result_so_far | ||||
| }) | ||||
|  | ||||
| @ -84,7 +84,7 @@ fn decagon(radius) { | ||||
|   // Use a `reduce` to draw the remaining decagon sides. | ||||
|   // For each number in the array 1..10, run the given function, | ||||
|   // which takes a partially-sketched decagon and adds one more edge to it. | ||||
|   fullDecagon = reduce([1..10], startOfDecagonSketch, (i, partialDecagon) { | ||||
|   fullDecagon = reduce([1..10], startOfDecagonSketch, fn(i, partialDecagon) { | ||||
|     // Draw one edge of the decagon. | ||||
|     x = cos(stepAngle * i) * radius | ||||
|     y = sin(stepAngle * i) * radius | ||||
|  | ||||
							
								
								
									
										7150
									
								
								docs/kcl/std.json
									
									
									
									
									
								
							
							
						
						
							
								
								
									
										55
									
								
								docs/kcl/sweep.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										23
									
								
								docs/kcl/types/AppearanceData.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,23 @@ | ||||
| --- | ||||
| title: "AppearanceData" | ||||
| excerpt: "Data for appearance." | ||||
| layout: manual | ||||
| --- | ||||
|  | ||||
| Data for appearance. | ||||
|  | ||||
| **Type:** `object` | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| ## Properties | ||||
|  | ||||
| | Property | Type | Description | Required | | ||||
| |----------|------|-------------|----------| | ||||
| | `color` |`string`| Color of the new material, a hex string like "#ff0000". | No | | ||||
| | `metalness` |`number` (**maximum:** 100.0)| Metalness of the new material, a percentage like 95.7. | No | | ||||
| | `roughness` |`number` (**maximum:** 100.0)| Roughness of the new material, a percentage like 95.7. | No | | ||||
|  | ||||
|  | ||||
| @ -12,5 +12,10 @@ KCL value for an optional parameter which was not given an argument. (remember, | ||||
|  | ||||
|  | ||||
|  | ||||
| ## Properties | ||||
|  | ||||
| | Property | Type | Description | Required | | ||||
| |----------|------|-------------|----------| | ||||
| | `digest` |`[, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`, `integer`]`|  | No | | ||||
|  | ||||
|  | ||||
|  | ||||
							
								
								
									
										23
									
								
								docs/kcl/types/SweepData.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,23 @@ | ||||
| --- | ||||
| title: "SweepData" | ||||
| excerpt: "Data for a sweep." | ||||
| layout: manual | ||||
| --- | ||||
|  | ||||
| Data for a sweep. | ||||
|  | ||||
| **Type:** `object` | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| ## Properties | ||||
|  | ||||
| | Property | Type | Description | Required | | ||||
| |----------|------|-------------|----------| | ||||
| | `path` |[`Sketch`](/docs/kcl/types/Sketch)| The path to sweep along. | No | | ||||
| | `sectional` |`boolean`| If true, the sweep will be broken up into sub-sweeps (extrusions, revolves, sweeps) based on the trajectory path components. | No | | ||||
| | `tolerance` |`number`| Tolerance for the sweep operation. | No | | ||||
|  | ||||
|  | ||||
| @ -458,8 +458,8 @@ test.describe('Editor tests', () => { | ||||
|  | ||||
|     /* add the following code to the editor ($ error is not a valid line) | ||||
|       $ error | ||||
|       const topAng = 30 | ||||
|       const bottomAng = 25 | ||||
|       topAng = 30 | ||||
|       bottomAng = 25 | ||||
|      */ | ||||
|     await u.codeLocator.click() | ||||
|     await page.keyboard.type('$ error') | ||||
| @ -474,12 +474,14 @@ test.describe('Editor tests', () => { | ||||
|     await page.keyboard.type('bottomAng = 25') | ||||
|     await page.keyboard.press('Enter') | ||||
|  | ||||
|     // error in guter | ||||
|     // error in gutter | ||||
|     await expect(page.locator('.cm-lint-marker-error')).toBeVisible() | ||||
|  | ||||
|     // error text on hover | ||||
|     await page.hover('.cm-lint-marker-error') | ||||
|     await expect(page.getByText('Unexpected token: $').first()).toBeVisible() | ||||
|     await expect( | ||||
|       page.getByText('Tag names must not be empty').first() | ||||
|     ).toBeVisible() | ||||
|  | ||||
|     // select the line that's causing the error and delete it | ||||
|     await page.getByText('$ error').click() | ||||
|  | ||||
| @ -7,6 +7,7 @@ export class ToolbarFixture { | ||||
|  | ||||
|   extrudeButton!: Locator | ||||
|   loftButton!: Locator | ||||
|   shellButton!: Locator | ||||
|   offsetPlaneButton!: Locator | ||||
|   startSketchBtn!: Locator | ||||
|   lineBtn!: Locator | ||||
| @ -28,6 +29,7 @@ export class ToolbarFixture { | ||||
|     this.page = page | ||||
|     this.extrudeButton = page.getByTestId('extrude') | ||||
|     this.loftButton = page.getByTestId('loft') | ||||
|     this.shellButton = page.getByTestId('shell') | ||||
|     this.offsetPlaneButton = page.getByTestId('plane-offset') | ||||
|     this.startSketchBtn = page.getByTestId('sketch') | ||||
|     this.lineBtn = page.getByTestId('line') | ||||
|  | ||||
| @ -768,3 +768,168 @@ loftPointAndClickCases.forEach(({ shouldPreselect }) => { | ||||
|     }) | ||||
|   }) | ||||
| }) | ||||
|  | ||||
| const shellPointAndClickCapCases = [ | ||||
|   { shouldPreselect: true }, | ||||
|   { shouldPreselect: false }, | ||||
| ] | ||||
| shellPointAndClickCapCases.forEach(({ shouldPreselect }) => { | ||||
|   test(`Shell point-and-click cap (preselected sketches: ${shouldPreselect})`, async ({ | ||||
|     app, | ||||
|     scene, | ||||
|     editor, | ||||
|     toolbar, | ||||
|     cmdBar, | ||||
|   }) => { | ||||
|     const initialCode = `sketch001 = startSketchOn('XZ') | ||||
|     |> circle({ center = [0, 0], radius = 30 }, %) | ||||
|     extrude001 = extrude(30, sketch001) | ||||
|     ` | ||||
|     await app.initialise(initialCode) | ||||
|  | ||||
|     // One dumb hardcoded screen pixel value | ||||
|     const testPoint = { x: 575, y: 200 } | ||||
|     const [clickOnCap] = scene.makeMouseHelpers(testPoint.x, testPoint.y) | ||||
|     const shellDeclaration = | ||||
|       "shell001 = shell({ faces = ['end'], thickness = 5 }, extrude001)" | ||||
|  | ||||
|     await test.step(`Look for the grey of the shape`, async () => { | ||||
|       await scene.expectPixelColor([127, 127, 127], testPoint, 15) | ||||
|     }) | ||||
|  | ||||
|     if (!shouldPreselect) { | ||||
|       await test.step(`Go through the command bar flow without preselected faces`, async () => { | ||||
|         await toolbar.shellButton.click() | ||||
|         await cmdBar.expectState({ | ||||
|           stage: 'arguments', | ||||
|           currentArgKey: 'selection', | ||||
|           currentArgValue: '', | ||||
|           headerArguments: { | ||||
|             Selection: '', | ||||
|             Thickness: '', | ||||
|           }, | ||||
|           highlightedHeaderArg: 'selection', | ||||
|           commandName: 'Shell', | ||||
|         }) | ||||
|         await clickOnCap() | ||||
|         await app.page.waitForTimeout(500) | ||||
|         await cmdBar.progressCmdBar() | ||||
|         await cmdBar.progressCmdBar() | ||||
|         await cmdBar.expectState({ | ||||
|           stage: 'review', | ||||
|           headerArguments: { | ||||
|             Selection: '1 cap', | ||||
|             Thickness: '5', | ||||
|           }, | ||||
|           commandName: 'Shell', | ||||
|         }) | ||||
|         await cmdBar.progressCmdBar() | ||||
|       }) | ||||
|     } else { | ||||
|       await test.step(`Preselect the cap`, async () => { | ||||
|         await clickOnCap() | ||||
|         await app.page.waitForTimeout(500) | ||||
|       }) | ||||
|  | ||||
|       await test.step(`Go through the command bar flow with a preselected face (cap)`, async () => { | ||||
|         await toolbar.shellButton.click() | ||||
|         await cmdBar.progressCmdBar() | ||||
|         await cmdBar.progressCmdBar() | ||||
|         await cmdBar.expectState({ | ||||
|           stage: 'review', | ||||
|           headerArguments: { | ||||
|             Selection: '1 cap', | ||||
|             Thickness: '5', | ||||
|           }, | ||||
|           commandName: 'Shell', | ||||
|         }) | ||||
|         await cmdBar.progressCmdBar() | ||||
|       }) | ||||
|     } | ||||
|  | ||||
|     await test.step(`Confirm code is added to the editor, scene has changed`, async () => { | ||||
|       await editor.expectEditor.toContain(shellDeclaration) | ||||
|       await editor.expectState({ | ||||
|         diagnostics: [], | ||||
|         activeLines: [shellDeclaration], | ||||
|         highlightedCode: '', | ||||
|       }) | ||||
|       await scene.expectPixelColor([146, 146, 146], testPoint, 15) | ||||
|     }) | ||||
|   }) | ||||
| }) | ||||
|  | ||||
| test('Shell point-and-click wall', async ({ | ||||
|   app, | ||||
|   page, | ||||
|   scene, | ||||
|   editor, | ||||
|   toolbar, | ||||
|   cmdBar, | ||||
| }) => { | ||||
|   const initialCode = `sketch001 = startSketchOn('XY') | ||||
|   |> startProfileAt([-20, 20], %) | ||||
|   |> xLine(40, %) | ||||
|   |> yLine(-60, %) | ||||
|   |> xLine(-40, %) | ||||
|   |> lineTo([profileStartX(%), profileStartY(%)], %) | ||||
|   |> close(%) | ||||
| extrude001 = extrude(40, sketch001) | ||||
|   ` | ||||
|   await app.initialise(initialCode) | ||||
|  | ||||
|   // One dumb hardcoded screen pixel value | ||||
|   const testPoint = { x: 580, y: 180 } | ||||
|   const [clickOnCap] = scene.makeMouseHelpers(testPoint.x, testPoint.y) | ||||
|   const [clickOnWall] = scene.makeMouseHelpers(testPoint.x, testPoint.y + 70) | ||||
|   const mutatedCode = 'xLine(-40, %, $seg01)' | ||||
|   const shellDeclaration = | ||||
|     "shell001 = shell({  faces = ['end', seg01],  thickness = 5}, extrude001)" | ||||
|   const formattedOutLastLine = '}, extrude001)' | ||||
|  | ||||
|   await test.step(`Look for the grey of the shape`, async () => { | ||||
|     await scene.expectPixelColor([99, 99, 99], testPoint, 15) | ||||
|   }) | ||||
|  | ||||
|   await test.step(`Go through the command bar flow, selecting a wall and keeping default thickness`, async () => { | ||||
|     await toolbar.shellButton.click() | ||||
|     await cmdBar.expectState({ | ||||
|       stage: 'arguments', | ||||
|       currentArgKey: 'selection', | ||||
|       currentArgValue: '', | ||||
|       headerArguments: { | ||||
|         Selection: '', | ||||
|         Thickness: '', | ||||
|       }, | ||||
|       highlightedHeaderArg: 'selection', | ||||
|       commandName: 'Shell', | ||||
|     }) | ||||
|     await clickOnCap() | ||||
|     await page.keyboard.down('Shift') | ||||
|     await clickOnWall() | ||||
|     await app.page.waitForTimeout(500) | ||||
|     await page.keyboard.up('Shift') | ||||
|     await cmdBar.progressCmdBar() | ||||
|     await cmdBar.progressCmdBar() | ||||
|     await cmdBar.expectState({ | ||||
|       stage: 'review', | ||||
|       headerArguments: { | ||||
|         Selection: '1 cap, 1 face', | ||||
|         Thickness: '5', | ||||
|       }, | ||||
|       commandName: 'Shell', | ||||
|     }) | ||||
|     await cmdBar.progressCmdBar() | ||||
|   }) | ||||
|  | ||||
|   await test.step(`Confirm code is added to the editor, scene has changed`, async () => { | ||||
|     await editor.expectEditor.toContain(mutatedCode) | ||||
|     await editor.expectEditor.toContain(shellDeclaration) | ||||
|     await editor.expectState({ | ||||
|       diagnostics: [], | ||||
|       activeLines: [formattedOutLastLine], | ||||
|       highlightedCode: '', | ||||
|     }) | ||||
|     await scene.expectPixelColor([49, 49, 49], testPoint, 15) | ||||
|   }) | ||||
| }) | ||||
|  | ||||
| @ -136,6 +136,335 @@ test( | ||||
|   } | ||||
| ) | ||||
|  | ||||
| test( | ||||
|   'open a file in a project works and renders, open another file in different project with errors, it should clear the scene', | ||||
|   { tag: '@electron' }, | ||||
|   async ({ browserName }, testInfo) => { | ||||
|     const { electronApp, page } = await setupElectron({ | ||||
|       testInfo, | ||||
|       folderSetupFn: async (dir) => { | ||||
|         const bracketDir = join(dir, 'bracket') | ||||
|         await fsp.mkdir(bracketDir, { recursive: true }) | ||||
|         await fsp.copyFile( | ||||
|           executorInputPath('focusrite_scarlett_mounting_braket.kcl'), | ||||
|           join(bracketDir, 'main.kcl') | ||||
|         ) | ||||
|         const errorDir = join(dir, 'broken-code') | ||||
|         await fsp.mkdir(errorDir, { recursive: true }) | ||||
|         await fsp.copyFile( | ||||
|           executorInputPath('broken-code-test.kcl'), | ||||
|           join(errorDir, 'main.kcl') | ||||
|         ) | ||||
|       }, | ||||
|     }) | ||||
|  | ||||
|     await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|     const u = await getUtils(page) | ||||
|  | ||||
|     page.on('console', console.log) | ||||
|  | ||||
|     const pointOnModel = { x: 630, y: 280 } | ||||
|  | ||||
|     await test.step('Opening the bracket project should load the stream', async () => { | ||||
|       // expect to see the text bracket | ||||
|       await expect(page.getByText('bracket')).toBeVisible() | ||||
|  | ||||
|       await page.getByText('bracket').click() | ||||
|  | ||||
|       await expect(page.getByTestId('loading')).toBeAttached() | ||||
|       await expect(page.getByTestId('loading')).not.toBeAttached({ | ||||
|         timeout: 20_000, | ||||
|       }) | ||||
|  | ||||
|       await expect( | ||||
|         page.getByRole('button', { name: 'Start Sketch' }) | ||||
|       ).toBeEnabled({ | ||||
|         timeout: 20_000, | ||||
|       }) | ||||
|  | ||||
|       // gray at this pixel means the stream has loaded in the most | ||||
|       // user way we can verify it (pixel color) | ||||
|       await expect | ||||
|         .poll(() => u.getGreatestPixDiff(pointOnModel, [85, 85, 85]), { | ||||
|           timeout: 10_000, | ||||
|         }) | ||||
|         .toBeLessThan(15) | ||||
|     }) | ||||
|  | ||||
|     await test.step('Clicking the logo takes us back to the projects page / home', async () => { | ||||
|       await page.getByTestId('app-logo').click() | ||||
|  | ||||
|       await expect(page.getByRole('link', { name: 'bracket' })).toBeVisible() | ||||
|       await expect(page.getByText('broken-code')).toBeVisible() | ||||
|       await expect(page.getByText('bracket')).toBeVisible() | ||||
|       await expect(page.getByText('New Project')).toBeVisible() | ||||
|     }) | ||||
|     await test.step('opening broken code project should clear the scene and show the error', async () => { | ||||
|       // Go back home. | ||||
|       await expect(page.getByText('broken-code')).toBeVisible() | ||||
|  | ||||
|       await page.getByText('broken-code').click() | ||||
|  | ||||
|       // error in guter | ||||
|       await expect(page.locator('.cm-lint-marker-error')).toBeVisible() | ||||
|  | ||||
|       // error text on hover | ||||
|       await page.hover('.cm-lint-marker-error') | ||||
|       const crypticErrorText = `Expected a tag declarator` | ||||
|       await expect(page.getByText(crypticErrorText).first()).toBeVisible() | ||||
|  | ||||
|       // black pixel means the scene has been cleared. | ||||
|       await expect | ||||
|         .poll(() => u.getGreatestPixDiff(pointOnModel, [30, 30, 30]), { | ||||
|           timeout: 10_000, | ||||
|         }) | ||||
|         .toBeLessThan(15) | ||||
|     }) | ||||
|  | ||||
|     await electronApp.close() | ||||
|   } | ||||
| ) | ||||
|  | ||||
| test( | ||||
|   'open a file in a project works and renders, open another file in different project that is empty, it should clear the scene', | ||||
|   { tag: '@electron' }, | ||||
|   async ({ browserName }, testInfo) => { | ||||
|     const { electronApp, page } = await setupElectron({ | ||||
|       testInfo, | ||||
|       folderSetupFn: async (dir) => { | ||||
|         const bracketDir = join(dir, 'bracket') | ||||
|         await fsp.mkdir(bracketDir, { recursive: true }) | ||||
|         await fsp.copyFile( | ||||
|           executorInputPath('focusrite_scarlett_mounting_braket.kcl'), | ||||
|           join(bracketDir, 'main.kcl') | ||||
|         ) | ||||
|         const emptyDir = join(dir, 'empty') | ||||
|         await fsp.mkdir(emptyDir, { recursive: true }) | ||||
|         await fsp.writeFile(join(emptyDir, 'main.kcl'), '') | ||||
|       }, | ||||
|     }) | ||||
|  | ||||
|     await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|     const u = await getUtils(page) | ||||
|  | ||||
|     page.on('console', console.log) | ||||
|  | ||||
|     const pointOnModel = { x: 630, y: 280 } | ||||
|  | ||||
|     await test.step('Opening the bracket project should load the stream', async () => { | ||||
|       // expect to see the text bracket | ||||
|       await expect(page.getByText('bracket')).toBeVisible() | ||||
|  | ||||
|       await page.getByText('bracket').click() | ||||
|  | ||||
|       await expect(page.getByTestId('loading')).toBeAttached() | ||||
|       await expect(page.getByTestId('loading')).not.toBeAttached({ | ||||
|         timeout: 20_000, | ||||
|       }) | ||||
|  | ||||
|       await expect( | ||||
|         page.getByRole('button', { name: 'Start Sketch' }) | ||||
|       ).toBeEnabled({ | ||||
|         timeout: 20_000, | ||||
|       }) | ||||
|  | ||||
|       // gray at this pixel means the stream has loaded in the most | ||||
|       // user way we can verify it (pixel color) | ||||
|       await expect | ||||
|         .poll(() => u.getGreatestPixDiff(pointOnModel, [85, 85, 85]), { | ||||
|           timeout: 10_000, | ||||
|         }) | ||||
|         .toBeLessThan(15) | ||||
|     }) | ||||
|  | ||||
|     await test.step('Clicking the logo takes us back to the projects page / home', async () => { | ||||
|       await page.getByTestId('app-logo').click() | ||||
|  | ||||
|       await expect(page.getByRole('link', { name: 'bracket' })).toBeVisible() | ||||
|       await expect(page.getByText('empty')).toBeVisible() | ||||
|       await expect(page.getByText('bracket')).toBeVisible() | ||||
|       await expect(page.getByText('New Project')).toBeVisible() | ||||
|     }) | ||||
|     await test.step('opening empty code project should clear the scene', async () => { | ||||
|       // Go back home. | ||||
|       await expect(page.getByText('empty')).toBeVisible() | ||||
|  | ||||
|       await page.getByText('empty').click() | ||||
|  | ||||
|       // Ensure the code is empty. | ||||
|       await expect(u.codeLocator).toContainText('') | ||||
|       expect(u.codeLocator.innerHTML.length).toBeLessThan(2) | ||||
|  | ||||
|       // planes colors means the scene has been cleared. | ||||
|       await expect | ||||
|         .poll(() => u.getGreatestPixDiff(pointOnModel, [92, 53, 53]), { | ||||
|           timeout: 10_000, | ||||
|         }) | ||||
|         .toBeLessThan(15) | ||||
|     }) | ||||
|  | ||||
|     await electronApp.close() | ||||
|   } | ||||
| ) | ||||
|  | ||||
| test( | ||||
|   'open a file in a project works and renders, open empty file, it should clear the scene', | ||||
|   { tag: '@electron' }, | ||||
|   async ({ browserName }, testInfo) => { | ||||
|     const { electronApp, page } = await setupElectron({ | ||||
|       testInfo, | ||||
|       folderSetupFn: async (dir) => { | ||||
|         const bracketDir = join(dir, 'bracket') | ||||
|         await fsp.mkdir(bracketDir, { recursive: true }) | ||||
|         await fsp.copyFile( | ||||
|           executorInputPath('focusrite_scarlett_mounting_braket.kcl'), | ||||
|           join(bracketDir, 'main.kcl') | ||||
|         ) | ||||
|  | ||||
|         await fsp.writeFile(join(bracketDir, 'empty.kcl'), '') | ||||
|       }, | ||||
|     }) | ||||
|  | ||||
|     await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|     const u = await getUtils(page) | ||||
|  | ||||
|     page.on('console', console.log) | ||||
|  | ||||
|     const pointOnModel = { x: 630, y: 280 } | ||||
|  | ||||
|     await test.step('Opening the bracket project should load the stream', async () => { | ||||
|       // expect to see the text bracket | ||||
|       await expect(page.getByText('bracket')).toBeVisible() | ||||
|  | ||||
|       await page.getByText('bracket').click() | ||||
|  | ||||
|       await expect(page.getByTestId('loading')).toBeAttached() | ||||
|       await expect(page.getByTestId('loading')).not.toBeAttached({ | ||||
|         timeout: 20_000, | ||||
|       }) | ||||
|  | ||||
|       await expect( | ||||
|         page.getByRole('button', { name: 'Start Sketch' }) | ||||
|       ).toBeEnabled({ | ||||
|         timeout: 20_000, | ||||
|       }) | ||||
|  | ||||
|       // gray at this pixel means the stream has loaded in the most | ||||
|       // user way we can verify it (pixel color) | ||||
|       await expect | ||||
|         .poll(() => u.getGreatestPixDiff(pointOnModel, [85, 85, 85]), { | ||||
|           timeout: 10_000, | ||||
|         }) | ||||
|         .toBeLessThan(15) | ||||
|     }) | ||||
|     await test.step('creating a empty file should clear the scene', async () => { | ||||
|       // open the file pane. | ||||
|       await page.getByTestId('files-pane-button').click() | ||||
|  | ||||
|       // OPen the other file. | ||||
|       const file = page.getByRole('button', { name: 'empty.kcl' }) | ||||
|       await expect(file).toBeVisible() | ||||
|  | ||||
|       await file.click() | ||||
|  | ||||
|       // planes colors means the scene has been cleared. | ||||
|       await expect | ||||
|         .poll(() => u.getGreatestPixDiff(pointOnModel, [92, 53, 53]), { | ||||
|           timeout: 10_000, | ||||
|         }) | ||||
|         .toBeLessThan(15) | ||||
|  | ||||
|       // Ensure the code is empty. | ||||
|       await expect(u.codeLocator).toContainText('') | ||||
|       expect(u.codeLocator.innerHTML.length).toBeLessThan(2) | ||||
|     }) | ||||
|  | ||||
|     await electronApp.close() | ||||
|   } | ||||
| ) | ||||
|  | ||||
| test( | ||||
|   'open a file in a project works and renders, open another file in the same project with errors, it should clear the scene', | ||||
|   { tag: '@electron' }, | ||||
|   async ({ browserName }, testInfo) => { | ||||
|     const { electronApp, page } = await setupElectron({ | ||||
|       testInfo, | ||||
|       folderSetupFn: async (dir) => { | ||||
|         const bracketDir = join(dir, 'bracket') | ||||
|         await fsp.mkdir(bracketDir, { recursive: true }) | ||||
|         await fsp.copyFile( | ||||
|           executorInputPath('focusrite_scarlett_mounting_braket.kcl'), | ||||
|           join(bracketDir, 'main.kcl') | ||||
|         ) | ||||
|         await fsp.copyFile( | ||||
|           executorInputPath('broken-code-test.kcl'), | ||||
|           join(bracketDir, 'broken-code-test.kcl') | ||||
|         ) | ||||
|       }, | ||||
|     }) | ||||
|  | ||||
|     await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|     const u = await getUtils(page) | ||||
|  | ||||
|     page.on('console', console.log) | ||||
|  | ||||
|     const pointOnModel = { x: 630, y: 280 } | ||||
|  | ||||
|     await test.step('Opening the bracket project should load the stream', async () => { | ||||
|       // expect to see the text bracket | ||||
|       await expect(page.getByText('bracket')).toBeVisible() | ||||
|  | ||||
|       await page.getByText('bracket').click() | ||||
|  | ||||
|       await expect(page.getByTestId('loading')).toBeAttached() | ||||
|       await expect(page.getByTestId('loading')).not.toBeAttached({ | ||||
|         timeout: 20_000, | ||||
|       }) | ||||
|  | ||||
|       await expect( | ||||
|         page.getByRole('button', { name: 'Start Sketch' }) | ||||
|       ).toBeEnabled({ | ||||
|         timeout: 20_000, | ||||
|       }) | ||||
|  | ||||
|       // gray at this pixel means the stream has loaded in the most | ||||
|       // user way we can verify it (pixel color) | ||||
|       await expect | ||||
|         .poll(() => u.getGreatestPixDiff(pointOnModel, [85, 85, 85]), { | ||||
|           timeout: 10_000, | ||||
|         }) | ||||
|         .toBeLessThan(15) | ||||
|     }) | ||||
|     await test.step('opening broken code file should clear the scene and show the error', async () => { | ||||
|       // open the file pane. | ||||
|       await page.getByTestId('files-pane-button').click() | ||||
|  | ||||
|       // OPen the other file. | ||||
|       const file = page.getByRole('button', { name: 'broken-code-test.kcl' }) | ||||
|       await expect(file).toBeVisible() | ||||
|  | ||||
|       await file.click() | ||||
|  | ||||
|       // error in guter | ||||
|       await expect(page.locator('.cm-lint-marker-error')).toBeVisible() | ||||
|  | ||||
|       // error text on hover | ||||
|       await page.hover('.cm-lint-marker-error') | ||||
|       const crypticErrorText = `Expected a tag declarator` | ||||
|       await expect(page.getByText(crypticErrorText).first()).toBeVisible() | ||||
|  | ||||
|       // black pixel means the scene has been cleared. | ||||
|       await expect | ||||
|         .poll(() => u.getGreatestPixDiff(pointOnModel, [30, 30, 30]), { | ||||
|           timeout: 10_000, | ||||
|         }) | ||||
|         .toBeLessThan(15) | ||||
|     }) | ||||
|  | ||||
|     await electronApp.close() | ||||
|   } | ||||
| ) | ||||
|  | ||||
| test( | ||||
|   'when code with error first loads you get errors in console', | ||||
|   { tag: '@electron' }, | ||||
|  | ||||
| @ -950,7 +950,75 @@ test( | ||||
|  | ||||
| test.describe('Grid visibility', { tag: '@snapshot' }, () => { | ||||
|   // FIXME: Skip on macos its being weird. | ||||
|   test.skip(process.platform === 'darwin', 'Skip on macos') | ||||
|   // test.skip(process.platform === 'darwin', 'Skip on macos') | ||||
|  | ||||
|   test('Grid turned off to on via command bar', async ({ page }) => { | ||||
|     const u = await getUtils(page) | ||||
|     const stream = page.getByTestId('stream') | ||||
|     const mask = [ | ||||
|       page.locator('#app-header'), | ||||
|       page.locator('#sidebar-top-ribbon'), | ||||
|       page.locator('#sidebar-bottom-ribbon'), | ||||
|     ] | ||||
|  | ||||
|     await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|     await page.goto('/') | ||||
|     await u.waitForAuthSkipAppStart() | ||||
|  | ||||
|     await u.openDebugPanel() | ||||
|     // wait for execution done | ||||
|     await expect( | ||||
|       page.locator('[data-message-type="execution-done"]') | ||||
|     ).toHaveCount(1) | ||||
|     await u.closeDebugPanel() | ||||
|     await u.closeKclCodePanel() | ||||
|     // TODO: Find a way to truly know that the objects have finished | ||||
|     // rendering, because an execution-done message is not sufficient. | ||||
|     await page.waitForTimeout(1000) | ||||
|  | ||||
|     // Open the command bar. | ||||
|     await page | ||||
|       .getByRole('button', { name: 'Commands', exact: false }) | ||||
|       .or(page.getByRole('button', { name: '⌘K' })) | ||||
|       .click() | ||||
|     const commandName = 'show scale grid' | ||||
|     const commandOption = page.getByRole('option', { | ||||
|       name: commandName, | ||||
|       exact: false, | ||||
|     }) | ||||
|     const cmdSearchBar = page.getByPlaceholder('Search commands') | ||||
|     // This selector changes after we set the setting | ||||
|     await cmdSearchBar.fill(commandName) | ||||
|     await expect(commandOption).toBeVisible() | ||||
|     await commandOption.click() | ||||
|  | ||||
|     const toggleInput = page.getByPlaceholder('Off') | ||||
|     await expect(toggleInput).toBeVisible() | ||||
|     await expect(toggleInput).toBeFocused() | ||||
|  | ||||
|     // Select On | ||||
|     await page.keyboard.press('ArrowDown') | ||||
|     await expect(page.getByRole('option', { name: 'Off' })).toHaveAttribute( | ||||
|       'data-headlessui-state', | ||||
|       'active selected' | ||||
|     ) | ||||
|     await page.keyboard.press('ArrowUp') | ||||
|     await expect(page.getByRole('option', { name: 'On' })).toHaveAttribute( | ||||
|       'data-headlessui-state', | ||||
|       'active' | ||||
|     ) | ||||
|     await page.keyboard.press('Enter') | ||||
|  | ||||
|     // Check the toast appeared | ||||
|     await expect( | ||||
|       page.getByText(`Set show scale grid to "true" as a user default`) | ||||
|     ).toBeVisible() | ||||
|  | ||||
|     await expect(stream).toHaveScreenshot({ | ||||
|       maxDiffPixels: 100, | ||||
|       mask, | ||||
|     }) | ||||
|   }) | ||||
|  | ||||
|   test('Grid turned off', async ({ page }) => { | ||||
|     const u = await getUtils(page) | ||||
| @ -1096,3 +1164,109 @@ test.fixme('theme persists', async ({ page, context }) => { | ||||
|     maxDiffPixels: 100, | ||||
|   }) | ||||
| }) | ||||
|  | ||||
| test.describe('code color goober', { tag: '@snapshot' }, () => { | ||||
|   test('code color goober', async ({ page, context }) => { | ||||
|     const u = await getUtils(page) | ||||
|     await context.addInitScript(async () => { | ||||
|       localStorage.setItem( | ||||
|         'persistCode', | ||||
|         `// Create a pipe using a sweep. | ||||
|  | ||||
| // Create a path for the sweep. | ||||
| sweepPath = startSketchOn('XZ') | ||||
|   |> startProfileAt([0.05, 0.05], %) | ||||
|   |> line([0, 7], %) | ||||
|   |> tangentialArc({ offset = 90, radius = 5 }, %) | ||||
|   |> line([-3, 0], %) | ||||
|   |> tangentialArc({ offset = -90, radius = 5 }, %) | ||||
|   |> line([0, 7], %) | ||||
|  | ||||
| sweepSketch = startSketchOn('XY') | ||||
|   |> startProfileAt([2, 0], %) | ||||
|   |> arc({ | ||||
|        angleEnd = 360, | ||||
|        angleStart = 0, | ||||
|        radius = 2 | ||||
|      }, %) | ||||
|   |> sweep({ | ||||
|     path = sweepPath, | ||||
|   }, %) | ||||
|   |> appearance({ | ||||
|        color = "#bb00ff", | ||||
|        metalness = 90, | ||||
|        roughness = 90 | ||||
|      }, %) | ||||
| ` | ||||
|       ) | ||||
|     }) | ||||
|  | ||||
|     await page.setViewportSize({ width: 1200, height: 1000 }) | ||||
|  | ||||
|     await u.waitForAuthSkipAppStart() | ||||
|  | ||||
|     await u.openDebugPanel() | ||||
|     await u.expectCmdLog('[data-message-type="execution-done"]') | ||||
|     await u.clearAndCloseDebugPanel() | ||||
|  | ||||
|     await expect(page, 'expect small color widget').toHaveScreenshot({ | ||||
|       maxDiffPixels: 100, | ||||
|     }) | ||||
|   }) | ||||
|  | ||||
|   test('code color goober opening window', async ({ page, context }) => { | ||||
|     const u = await getUtils(page) | ||||
|     await context.addInitScript(async () => { | ||||
|       localStorage.setItem( | ||||
|         'persistCode', | ||||
|         `// Create a pipe using a sweep. | ||||
|  | ||||
| // Create a path for the sweep. | ||||
| sweepPath = startSketchOn('XZ') | ||||
|   |> startProfileAt([0.05, 0.05], %) | ||||
|   |> line([0, 7], %) | ||||
|   |> tangentialArc({ offset = 90, radius = 5 }, %) | ||||
|   |> line([-3, 0], %) | ||||
|   |> tangentialArc({ offset = -90, radius = 5 }, %) | ||||
|   |> line([0, 7], %) | ||||
|  | ||||
| sweepSketch = startSketchOn('XY') | ||||
|   |> startProfileAt([2, 0], %) | ||||
|   |> arc({ | ||||
|        angleEnd = 360, | ||||
|        angleStart = 0, | ||||
|        radius = 2 | ||||
|      }, %) | ||||
|   |> sweep({ | ||||
|     path = sweepPath, | ||||
|   }, %) | ||||
|   |> appearance({ | ||||
|        color = "#bb00ff", | ||||
|        metalness = 90, | ||||
|        roughness = 90 | ||||
|      }, %) | ||||
| ` | ||||
|       ) | ||||
|     }) | ||||
|  | ||||
|     await page.setViewportSize({ width: 1200, height: 1000 }) | ||||
|  | ||||
|     await u.waitForAuthSkipAppStart() | ||||
|  | ||||
|     await u.openDebugPanel() | ||||
|     await u.expectCmdLog('[data-message-type="execution-done"]') | ||||
|     await u.clearAndCloseDebugPanel() | ||||
|  | ||||
|     await expect(page.locator('.cm-css-color-picker-wrapper')).toBeVisible() | ||||
|  | ||||
|     // Click the color widget | ||||
|     await page.locator('.cm-css-color-picker-wrapper input').click() | ||||
|  | ||||
|     await expect( | ||||
|       page, | ||||
|       'expect small color widget to have window open' | ||||
|     ).toHaveScreenshot({ | ||||
|       maxDiffPixels: 100, | ||||
|     }) | ||||
|   }) | ||||
| }) | ||||
|  | ||||
| Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB | 
| After Width: | Height: | Size: 52 KiB | 
| After Width: | Height: | Size: 54 KiB | 
| After Width: | Height: | Size: 144 KiB | 
| After Width: | Height: | Size: 130 KiB | 
| After Width: | Height: | Size: 139 KiB | 
| After Width: | Height: | Size: 124 KiB | 
| Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB | 
| Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB | 
| Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB | 
| Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB | 
| Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB | 
| Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB | 
| @ -14,7 +14,7 @@ export const TEST_SETTINGS = { | ||||
|   }, | ||||
|   modeling: { | ||||
|     defaultUnit: 'in', | ||||
|     mouseControls: 'KittyCAD', | ||||
|     mouseControls: 'Zoo', | ||||
|     cameraProjection: 'perspective', | ||||
|     showDebugPanel: true, | ||||
|   }, | ||||
|  | ||||
| @ -479,4 +479,26 @@ test.describe('Testing Camera Movement', () => { | ||||
|       }) | ||||
|     } | ||||
|   }) | ||||
|  | ||||
|   test('Right-click opens context menu when not dragged', async ({ page }) => { | ||||
|     const u = await getUtils(page) | ||||
|     await u.waitForAuthSkipAppStart() | ||||
|  | ||||
|     await test.step(`The menu should not show if we drag the mouse`, async () => { | ||||
|       await page.mouse.move(900, 200) | ||||
|       await page.mouse.down({ button: 'right' }) | ||||
|       await page.mouse.move(900, 300) | ||||
|       await page.mouse.up({ button: 'right' }) | ||||
|  | ||||
|       await expect(page.getByTestId('view-controls-menu')).not.toBeVisible() | ||||
|     }) | ||||
|  | ||||
|     await test.step(`The menu should show if we don't drag the mouse`, async () => { | ||||
|       await page.mouse.move(900, 200) | ||||
|       await page.mouse.down({ button: 'right' }) | ||||
|       await page.mouse.up({ button: 'right' }) | ||||
|  | ||||
|       await expect(page.getByTestId('view-controls-menu')).toBeVisible() | ||||
|     }) | ||||
|   }) | ||||
| }) | ||||
|  | ||||
| @ -26,7 +26,17 @@ test.describe('Testing constraints', () => { | ||||
|     }) | ||||
|  | ||||
|     const u = await getUtils(page) | ||||
|     const PUR = 400 / 37.5 //pixeltoUnitRatio | ||||
|     // constants and locators | ||||
|     const lengthValue = { | ||||
|       old: '20', | ||||
|       new: '25', | ||||
|     } | ||||
|     const cmdBarKclInput = page | ||||
|       .getByTestId('cmd-bar-arg-value') | ||||
|       .getByRole('textbox') | ||||
|     const cmdBarSubmitButton = page.getByRole('button', { | ||||
|       name: 'arrow right Continue', | ||||
|     }) | ||||
|     await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|  | ||||
|     await u.waitForAuthSkipAppStart() | ||||
| @ -36,26 +46,26 @@ test.describe('Testing constraints', () => { | ||||
|     await u.closeDebugPanel() | ||||
|  | ||||
|     // Click the line of code for line. | ||||
|     await page.getByText(`line([0, 20], %)`).click() // TODO remove this and reinstate // await topHorzSegmentClick() | ||||
|     // TODO remove this and reinstate `await topHorzSegmentClick()` | ||||
|     await page.getByText(`line([0, ${lengthValue.old}], %)`).click() | ||||
|     await page.waitForTimeout(100) | ||||
|  | ||||
|     // enter sketch again | ||||
|     await page.getByRole('button', { name: 'Edit Sketch' }).click() | ||||
|     await page.waitForTimeout(500) // wait for animation | ||||
|  | ||||
|     const startXPx = 500 | ||||
|     await page.mouse.move(startXPx + PUR * 15, 250 - PUR * 10) | ||||
|     await page.keyboard.down('Shift') | ||||
|     await page.mouse.click(834, 244) | ||||
|     await page.keyboard.up('Shift') | ||||
|  | ||||
|     await page | ||||
|       .getByRole('button', { name: 'dimension Length', exact: true }) | ||||
|       .click() | ||||
|     await page.getByText('Add constraining value').click() | ||||
|     await expect(cmdBarKclInput).toHaveText('20') | ||||
|     await cmdBarKclInput.fill(lengthValue.new) | ||||
|     await expect( | ||||
|       page.getByText(`Can't calculate`), | ||||
|       `Something went wrong with the KCL expression evaluation` | ||||
|     ).not.toBeVisible() | ||||
|     await cmdBarSubmitButton.click() | ||||
|  | ||||
|     await expect(page.locator('.cm-content')).toHaveText( | ||||
|       `length001 = 20sketch001 = startSketchOn('XY')  |> startProfileAt([-10, -10], %)  |> line([20, 0], %)  |> angledLine([90, length001], %)  |> xLine(-20, %)` | ||||
|       `length001 = ${lengthValue.new}sketch001 = startSketchOn('XY')  |> startProfileAt([-10, -10], %)  |> line([20, 0], %)  |> angledLine([90, length001], %)  |> xLine(-20, %)` | ||||
|     ) | ||||
|  | ||||
|     // Make sure we didn't pop out of sketch mode. | ||||
| @ -66,7 +76,6 @@ test.describe('Testing constraints', () => { | ||||
|     await page.waitForTimeout(500) // wait for animation | ||||
|  | ||||
|     // Exit sketch | ||||
|     await page.mouse.move(startXPx + PUR * 15, 250 - PUR * 10) | ||||
|     await page.keyboard.press('Escape') | ||||
|     await expect( | ||||
|       page.getByRole('button', { name: 'Exit Sketch' }) | ||||
| @ -524,7 +533,7 @@ part002 = startSketchOn('XZ') | ||||
|       }) | ||||
|     } | ||||
|   }) | ||||
|   test.describe('Test Angle/Length constraint single selection', () => { | ||||
|   test.describe('Test Angle constraint single selection', () => { | ||||
|     const cases = [ | ||||
|       { | ||||
|         testName: 'Angle - Add variable', | ||||
| @ -538,18 +547,6 @@ part002 = startSketchOn('XZ') | ||||
|         constraint: 'angle', | ||||
|         value: '83, 78.33', | ||||
|       }, | ||||
|       { | ||||
|         testName: 'Length - Add variable', | ||||
|         addVariable: true, | ||||
|         constraint: 'length', | ||||
|         value: '83, length001', | ||||
|       }, | ||||
|       { | ||||
|         testName: 'Length - No variable', | ||||
|         addVariable: false, | ||||
|         constraint: 'length', | ||||
|         value: '83, 78.33', | ||||
|       }, | ||||
|     ] as const | ||||
|     for (const { testName, addVariable, value, constraint } of cases) { | ||||
|       test(`${testName}`, async ({ page }) => { | ||||
| @ -608,6 +605,90 @@ part002 = startSketchOn('XZ') | ||||
|       }) | ||||
|     } | ||||
|   }) | ||||
|   test.describe('Test Length constraint single selection', () => { | ||||
|     const cases = [ | ||||
|       { | ||||
|         testName: 'Length - Add variable', | ||||
|         addVariable: true, | ||||
|         constraint: 'length', | ||||
|         value: '83, length001', | ||||
|       }, | ||||
|       { | ||||
|         testName: 'Length - No variable', | ||||
|         addVariable: false, | ||||
|         constraint: 'length', | ||||
|         value: '83, 78.33', | ||||
|       }, | ||||
|     ] as const | ||||
|     for (const { testName, addVariable, value, constraint } of cases) { | ||||
|       test(`${testName}`, async ({ page }) => { | ||||
|         // constants and locators | ||||
|         const cmdBarKclInput = page | ||||
|           .getByTestId('cmd-bar-arg-value') | ||||
|           .getByRole('textbox') | ||||
|         const cmdBarKclVariableNameInput = | ||||
|           page.getByPlaceholder('Variable name') | ||||
|         const cmdBarSubmitButton = page.getByRole('button', { | ||||
|           name: 'arrow right Continue', | ||||
|         }) | ||||
|  | ||||
|         await page.addInitScript(async () => { | ||||
|           localStorage.setItem( | ||||
|             'persistCode', | ||||
|             `yo = 5 | ||||
| part001 = startSketchOn('XZ') | ||||
|   |> startProfileAt([-7.54, -26.74], %) | ||||
|   |> line([74.36, 130.4], %) | ||||
|   |> line([78.92, -120.11], %) | ||||
|   |> line([9.16, 77.79], %) | ||||
|   |> line([51.19, 48.97], %) | ||||
| part002 = startSketchOn('XZ') | ||||
|   |> startProfileAt([299.05, 231.45], %) | ||||
|   |> xLine(-425.34, %, $seg_what) | ||||
|   |> yLine(-264.06, %) | ||||
|   |> xLine(segLen(seg_what), %) | ||||
|   |> lineTo([profileStartX(%), profileStartY(%)], %)` | ||||
|           ) | ||||
|         }) | ||||
|         const u = await getUtils(page) | ||||
|         await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|  | ||||
|         await u.waitForAuthSkipAppStart() | ||||
|  | ||||
|         await page.getByText('line([74.36, 130.4], %)').click() | ||||
|         await page.getByRole('button', { name: 'Edit Sketch' }).click() | ||||
|  | ||||
|         const line3 = await u.getSegmentBodyCoords( | ||||
|           `[data-overlay-index="${2}"]` | ||||
|         ) | ||||
|  | ||||
|         await page.mouse.click(line3.x, line3.y) | ||||
|         await page | ||||
|           .getByRole('button', { | ||||
|             name: 'Length: open menu', | ||||
|           }) | ||||
|           .click() | ||||
|         await page.getByTestId('dropdown-constraint-' + constraint).click() | ||||
|  | ||||
|         if (!addVariable) { | ||||
|           await test.step(`Clear the variable input`, async () => { | ||||
|             await cmdBarKclVariableNameInput.clear() | ||||
|             await cmdBarKclVariableNameInput.press('Backspace') | ||||
|           }) | ||||
|         } | ||||
|         await expect(cmdBarKclInput).toHaveText('78.33') | ||||
|         await cmdBarSubmitButton.click() | ||||
|  | ||||
|         const changedCode = `|> angledLine([${value}], %)` | ||||
|         await expect(page.locator('.cm-content')).toContainText(changedCode) | ||||
|         // checking active assures the cursor is where it should be | ||||
|         await expect(page.locator('.cm-activeLine')).toHaveText(changedCode) | ||||
|  | ||||
|         // checking the count of the overlays is a good proxy check that the client sketch scene is in a good state | ||||
|         await expect(page.getByTestId('segment-overlay')).toHaveCount(4) | ||||
|       }) | ||||
|     } | ||||
|   }) | ||||
|   test.describe('Many segments - no modal constraints', () => { | ||||
|     const cases = [ | ||||
|       { | ||||
| @ -868,6 +949,15 @@ part002 = startSketchOn('XZ') | ||||
|   |> line([3.13, -2.4], %)` | ||||
|       ) | ||||
|     }) | ||||
|  | ||||
|     // constants and locators | ||||
|     const cmdBarKclInput = page | ||||
|       .getByTestId('cmd-bar-arg-value') | ||||
|       .getByRole('textbox') | ||||
|     const cmdBarSubmitButton = page.getByRole('button', { | ||||
|       name: 'arrow right Continue', | ||||
|     }) | ||||
|  | ||||
|     const u = await getUtils(page) | ||||
|     await page.setViewportSize({ width: 1200, height: 500 }) | ||||
|  | ||||
| @ -928,8 +1018,8 @@ part002 = startSketchOn('XZ') | ||||
|     // await page.getByRole('button', { name: 'length', exact: true }).click() | ||||
|     await page.getByTestId('dropdown-constraint-length').click() | ||||
|  | ||||
|     await page.getByLabel('length Value').fill('10') | ||||
|     await page.getByRole('button', { name: 'Add constraining value' }).click() | ||||
|     await cmdBarKclInput.fill('10') | ||||
|     await cmdBarSubmitButton.click() | ||||
|  | ||||
|     activeLinesContent = await page.locator('.cm-activeLine').all() | ||||
|     await expect(activeLinesContent[0]).toHaveText(`|> xLine(length001, %)`) | ||||
|  | ||||
| @ -91,7 +91,14 @@ test.describe('Testing segment overlays', () => { | ||||
|           await page.getByTestId('constraint-symbol-popover').count() | ||||
|         ).toBeGreaterThan(0) | ||||
|         await unconstrainedLocator.click() | ||||
|         await page.getByText('Add variable').click() | ||||
|         await expect( | ||||
|           page.getByTestId('cmd-bar-arg-value').getByRole('textbox') | ||||
|         ).toBeFocused() | ||||
|         await page | ||||
|           .getByRole('button', { | ||||
|             name: 'arrow right Continue', | ||||
|           }) | ||||
|           .click() | ||||
|         await expect(page.locator('.cm-content')).toContainText(expectFinal) | ||||
|       } | ||||
|  | ||||
| @ -151,7 +158,14 @@ test.describe('Testing segment overlays', () => { | ||||
|           await page.getByTestId('constraint-symbol-popover').count() | ||||
|         ).toBeGreaterThan(0) | ||||
|         await unconstrainedLocator.click() | ||||
|         await page.getByText('Add variable').click() | ||||
|         await expect( | ||||
|           page.getByTestId('cmd-bar-arg-value').getByRole('textbox') | ||||
|         ).toBeFocused() | ||||
|         await page | ||||
|           .getByRole('button', { | ||||
|             name: 'arrow right Continue', | ||||
|           }) | ||||
|           .click() | ||||
|         await expect(page.locator('.cm-content')).toContainText( | ||||
|           expectAfterUnconstrained | ||||
|         ) | ||||
|  | ||||
| @ -1,20 +1,9 @@ | ||||
| import type { ForgeConfig } from '@electron-forge/shared-types' | ||||
| import { MakerSquirrel } from '@electron-forge/maker-squirrel' | ||||
| import { MakerZIP } from '@electron-forge/maker-zip' | ||||
| import { MakerDeb } from '@electron-forge/maker-deb' | ||||
| import { MakerRpm } from '@electron-forge/maker-rpm' | ||||
| import { VitePlugin } from '@electron-forge/plugin-vite' | ||||
| import { MakerWix, MakerWixConfig } from '@electron-forge/maker-wix' | ||||
| import { FusesPlugin } from '@electron-forge/plugin-fuses' | ||||
| import { FuseV1Options, FuseVersion } from '@electron/fuses' | ||||
| import path from 'path' | ||||
|  | ||||
| interface ExtendedMakerWixConfig extends MakerWixConfig { | ||||
|   // see https://github.com/electron/forge/issues/3673 | ||||
|   // this is an undocumented property of electron-wix-msi | ||||
|   associateExtensions?: string | ||||
| } | ||||
|  | ||||
| const rootDir = process.cwd() | ||||
|  | ||||
| const config: ForgeConfig = { | ||||
| @ -39,26 +28,7 @@ const config: ForgeConfig = { | ||||
|     extendInfo: 'Info.plist', // Information for file associations. | ||||
|   }, | ||||
|   rebuildConfig: {}, | ||||
|   makers: [ | ||||
|     new MakerSquirrel({ | ||||
|       setupIcon: path.resolve(rootDir, 'assets', 'icon.ico'), | ||||
|     }), | ||||
|     new MakerWix({ | ||||
|       icon: path.resolve(rootDir, 'assets', 'icon.ico'), | ||||
|       associateExtensions: 'kcl', | ||||
|     } as ExtendedMakerWixConfig), | ||||
|     new MakerZIP({}, ['darwin']), | ||||
|     new MakerRpm({ | ||||
|       options: { | ||||
|         icon: path.resolve(rootDir, 'assets', 'icon.png'), | ||||
|       }, | ||||
|     }), | ||||
|     new MakerDeb({ | ||||
|       options: { | ||||
|         icon: path.resolve(rootDir, 'assets', 'icon.png'), | ||||
|       }, | ||||
|     }), | ||||
|   ], | ||||
|   makers: [], | ||||
|   plugins: [ | ||||
|     new VitePlugin({ | ||||
|       // `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc. | ||||
|  | ||||
							
								
								
									
										31
									
								
								package.json
									
									
									
									
									
								
							
							
						
						| @ -39,7 +39,6 @@ | ||||
|     "chokidar": "^4.0.1", | ||||
|     "codemirror": "^6.0.1", | ||||
|     "decamelize": "^6.0.0", | ||||
|     "electron-squirrel-startup": "^1.0.1", | ||||
|     "electron-updater": "6.3.0", | ||||
|     "fuse.js": "^7.0.0", | ||||
|     "html2canvas-pro": "^1.5.8", | ||||
| @ -69,7 +68,7 @@ | ||||
|     "yargs": "^17.7.2" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "start": "vite", | ||||
|     "start": "vite --port=3000 --host=0.0.0.0", | ||||
|     "start:prod": "vite preview --port=3000", | ||||
|     "serve": "vite serve --port=3000", | ||||
|     "build": "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && source \"$HOME/.cargo/env\" && curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh -s -- -y && yarn build:wasm && vite build", | ||||
| @ -81,6 +80,7 @@ | ||||
|     "simpleserver": "yarn pretest && http-server ./public --cors -p 3000", | ||||
|     "simpleserver:ci": "yarn pretest && http-server ./public --cors -p 3000 &", | ||||
|     "simpleserver:bg": "yarn pretest && http-server ./public --cors -p 3000 &", | ||||
|     "simpleserver:stop": "kill-port 3000", | ||||
|     "fmt": "prettier --write ./src *.ts *.json *.js ./e2e ./packages", | ||||
|     "fmt-check": "prettier --check ./src *.ts *.json *.js ./e2e ./packages", | ||||
|     "fetch:wasm": "./get-latest-wasm-bundle.sh", | ||||
| @ -95,14 +95,14 @@ | ||||
|     "files:set-version": "echo \"$(jq --arg v \"$VERSION\" '.version=$v' package.json --indent 2)\" > package.json", | ||||
|     "files:set-notes": "./scripts/set-files-notes.sh", | ||||
|     "files:flip-to-nightly": "./scripts/flip-files-to-nightly.sh", | ||||
|     "files:invalidate-bucket": "./scripts/invalidate-files-bucket.sh", | ||||
|     "files:invalidate-bucket:nightly": "./scripts/invalidate-files-bucket.sh --nightly", | ||||
|     "postinstall": "yarn fetch:samples && yarn xstate:typegen && ./node_modules/.bin/electron-rebuild", | ||||
|     "xstate:typegen": "yarn xstate typegen \"src/**/*.ts?(x)\"", | ||||
|     "make:dev": "make dev", | ||||
|     "generate:machine-api": "npx openapi-typescript ./openapi/machine-api.json -o src/lib/machine-api.d.ts", | ||||
|     "tron:start": "electron-forge start", | ||||
|     "tron:package": "electron-forge package", | ||||
|     "tron:make": "electron-forge make", | ||||
|     "tron:publish": "electron-forge publish", | ||||
|     "tron:test": "NODE_ENV=development yarn playwright test --config=playwright.electron.config.ts --grep=@electron", | ||||
|     "tronb:vite": "vite build -c vite.main.config.ts && vite build -c vite.preload.config.ts && vite build -c vite.renderer.config.ts", | ||||
|     "tronb:package": "electron-builder --config electron-builder.yml", | ||||
| @ -145,17 +145,10 @@ | ||||
|   "devDependencies": { | ||||
|     "@babel/plugin-proposal-private-property-in-object": "^7.21.11", | ||||
|     "@babel/preset-env": "^7.25.4", | ||||
|     "@electron-forge/cli": "^7.4.0", | ||||
|     "@electron-forge/maker-deb": "^7.4.0", | ||||
|     "@electron-forge/maker-rpm": "^7.4.0", | ||||
|     "@electron-forge/maker-squirrel": "^7.4.0", | ||||
|     "@electron-forge/maker-wix": "^7.5.0", | ||||
|     "@electron-forge/maker-zip": "^7.5.0", | ||||
|     "@electron-forge/plugin-auto-unpack-natives": "^7.4.0", | ||||
|     "@electron-forge/plugin-fuses": "^7.4.0", | ||||
|     "@electron-forge/plugin-vite": "^7.4.0", | ||||
|     "@electron/fuses": "^1.8.0", | ||||
|     "@electron/rebuild": "^3.6.0", | ||||
|     "@electron-forge/cli": "7.4.0", | ||||
|     "@electron-forge/plugin-fuses": "7.4.0", | ||||
|     "@electron-forge/plugin-vite": "7.4.0", | ||||
|     "@electron/fuses": "1.8.0", | ||||
|     "@iarna/toml": "^2.2.5", | ||||
|     "@lezer/generator": "^1.7.1", | ||||
|     "@nabla/vite-plugin-eslint": "^2.0.5", | ||||
| @ -171,7 +164,7 @@ | ||||
|     "@types/pixelmatch": "^5.2.6", | ||||
|     "@types/pngjs": "^6.0.4", | ||||
|     "@types/react": "^18.3.4", | ||||
|     "@types/react-dom": "^18.2.25", | ||||
|     "@types/react-dom": "^18.3.1", | ||||
|     "@types/react-modal": "^3.16.3", | ||||
|     "@types/three": "^0.163.0", | ||||
|     "@types/ua-parser-js": "^0.7.39", | ||||
| @ -185,9 +178,9 @@ | ||||
|     "@xstate/cli": "^0.5.17", | ||||
|     "autoprefixer": "^10.4.19", | ||||
|     "d3-force": "^3.0.0", | ||||
|     "electron": "^32.1.2", | ||||
|     "electron-builder": "^24.13.3", | ||||
|     "electron-notarize": "^1.2.2", | ||||
|     "electron": "32.1.2", | ||||
|     "electron-builder": "24.13.3", | ||||
|     "electron-notarize": "1.2.2", | ||||
|     "eslint": "^8.0.1", | ||||
|     "eslint-config-react-app": "^7.0.1", | ||||
|     "eslint-plugin-css-modules": "^2.12.0", | ||||
|  | ||||
							
								
								
									
										11
									
								
								scripts/invalidate-files-bucket.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @ -0,0 +1,11 @@ | ||||
| #!/bin/bash | ||||
| base_dir="/releases/modeling-app" | ||||
| if [[ $1 = "--nightly" ]]; then | ||||
|     base_dir="/releases/modeling-app/nightly" | ||||
| fi | ||||
|  | ||||
| echo "Invalidating json and yml files at $base_dir in the download bucket" | ||||
| gcloud compute url-maps invalidate-cdn-cache dl-url-map --path="$base_dir/last_download.json" --async | ||||
| gcloud compute url-maps invalidate-cdn-cache dl-url-map --path="$base_dir/latest-linux-arm64.yml" --async | ||||
| gcloud compute url-maps invalidate-cdn-cache dl-url-map --path="$base_dir/latest-mac.yml" --async | ||||
| gcloud compute url-maps invalidate-cdn-cache dl-url-map --path="$base_dir/latest.yml" --async | ||||
| @ -105,7 +105,7 @@ export class CameraControls { | ||||
|   pendingZoom: number | null = null | ||||
|   pendingRotation: Vector2 | null = null | ||||
|   pendingPan: Vector2 | null = null | ||||
|   interactionGuards: MouseGuard = cameraMouseDragGuards.KittyCAD | ||||
|   interactionGuards: MouseGuard = cameraMouseDragGuards.Zoo | ||||
|   isFovAnimationInProgress = false | ||||
|   perspectiveFovBeforeOrtho = 45 | ||||
|   get isPerspective() { | ||||
|  | ||||
| @ -505,7 +505,8 @@ const ConstraintSymbol = ({ | ||||
|   constrainInfo: ConstrainInfo | ||||
|   verticalPosition: 'top' | 'bottom' | ||||
| }) => { | ||||
|   const { context, send } = useModelingContext() | ||||
|   const { commandBarSend } = useCommandsContext() | ||||
|   const { context } = useModelingContext() | ||||
|   const varNameMap: { | ||||
|     [key in ConstrainInfo['type']]: { | ||||
|       varName: string | ||||
| @ -624,11 +625,18 @@ const ConstraintSymbol = ({ | ||||
|         // disabled={implicitDesc} TODO why does this change styles that are hard to override? | ||||
|         onClick={toSync(async () => { | ||||
|           if (!isConstrained) { | ||||
|             send({ | ||||
|               type: 'Convert to variable', | ||||
|             commandBarSend({ | ||||
|               type: 'Find and select command', | ||||
|               data: { | ||||
|                 pathToNode, | ||||
|                 variableName: varName, | ||||
|                 name: 'Constrain with named value', | ||||
|                 groupId: 'modeling', | ||||
|                 argDefaultValues: { | ||||
|                   currentValue: { | ||||
|                     pathToNode, | ||||
|                     variableName: varName, | ||||
|                     valueText: value, | ||||
|                   }, | ||||
|                 }, | ||||
|               }, | ||||
|             }) | ||||
|           } else if (isConstrained) { | ||||
|  | ||||
| @ -8,11 +8,16 @@ import { getSystemTheme } from 'lib/theme' | ||||
| import { useCalculateKclExpression } from 'lib/useCalculateKclExpression' | ||||
| import { roundOff } from 'lib/utils' | ||||
| import { varMentions } from 'lib/varCompletionExtension' | ||||
| import { useEffect, useRef, useState } from 'react' | ||||
| import { useEffect, useMemo, useRef, useState } from 'react' | ||||
| import { useHotkeys } from 'react-hotkeys-hook' | ||||
| import styles from './CommandBarKclInput.module.css' | ||||
| import { createIdentifier, createVariableDeclaration } from 'lang/modifyAst' | ||||
| import { useCodeMirror } from 'components/ModelingSidebar/ModelingPanes/CodeEditor' | ||||
| import { useSelector } from '@xstate/react' | ||||
|  | ||||
| const machineContextSelector = (snapshot?: { | ||||
|   context: Record<string, unknown> | ||||
| }) => snapshot?.context | ||||
|  | ||||
| function CommandBarKclInput({ | ||||
|   arg, | ||||
| @ -31,12 +36,44 @@ function CommandBarKclInput({ | ||||
|     arg.name | ||||
|   ] as KclCommandValue | undefined | ||||
|   const { settings } = useSettingsAuthContext() | ||||
|   const defaultValue = (arg.defaultValue as string) || '' | ||||
|   const argMachineContext = useSelector( | ||||
|     arg.machineActor, | ||||
|     machineContextSelector | ||||
|   ) | ||||
|   const defaultValue = useMemo( | ||||
|     () => | ||||
|       arg.defaultValue | ||||
|         ? arg.defaultValue instanceof Function | ||||
|           ? arg.defaultValue(commandBarState.context, argMachineContext) | ||||
|           : arg.defaultValue | ||||
|         : '', | ||||
|     [arg.defaultValue, commandBarState.context, argMachineContext] | ||||
|   ) | ||||
|   const initialVariableName = useMemo(() => { | ||||
|     // Use the configured variable name if it exists | ||||
|     if (arg.variableName !== undefined) { | ||||
|       return arg.variableName instanceof Function | ||||
|         ? arg.variableName(commandBarState.context, argMachineContext) | ||||
|         : arg.variableName | ||||
|     } | ||||
|     // or derive it from the previously set value or the argument name | ||||
|     return previouslySetValue && 'variableName' in previouslySetValue | ||||
|       ? previouslySetValue.variableName | ||||
|       : arg.name | ||||
|   }, [ | ||||
|     arg.variableName, | ||||
|     commandBarState.context, | ||||
|     argMachineContext, | ||||
|     arg.name, | ||||
|     previouslySetValue, | ||||
|   ]) | ||||
|   const [value, setValue] = useState( | ||||
|     previouslySetValue?.valueText || defaultValue || '' | ||||
|   ) | ||||
|   const [createNewVariable, setCreateNewVariable] = useState( | ||||
|     previouslySetValue && 'variableName' in previouslySetValue | ||||
|     (previouslySetValue && 'variableName' in previouslySetValue) || | ||||
|       arg.createVariableByDefault || | ||||
|       false | ||||
|   ) | ||||
|   const [canSubmit, setCanSubmit] = useState(true) | ||||
|   useHotkeys('mod + k, mod + /', () => commandBarSend({ type: 'Close' })) | ||||
| @ -52,10 +89,7 @@ function CommandBarKclInput({ | ||||
|     isNewVariableNameUnique, | ||||
|   } = useCalculateKclExpression({ | ||||
|     value, | ||||
|     initialVariableName: | ||||
|       previouslySetValue && 'variableName' in previouslySetValue | ||||
|         ? previouslySetValue.variableName | ||||
|         : arg.name, | ||||
|     initialVariableName, | ||||
|   }) | ||||
|   const varMentionData: Completion[] = prevVariables.map((v) => ({ | ||||
|     label: v.key, | ||||
|  | ||||
| @ -1,13 +1,23 @@ | ||||
| import toast from 'react-hot-toast' | ||||
| import { ActionIcon, ActionIconProps } from './ActionIcon' | ||||
| import { RefObject, useEffect, useMemo, useRef, useState } from 'react' | ||||
| import { | ||||
|   MouseEvent, | ||||
|   RefObject, | ||||
|   useCallback, | ||||
|   useEffect, | ||||
|   useMemo, | ||||
|   useRef, | ||||
|   useState, | ||||
| } from 'react' | ||||
| import { useHotkeys } from 'react-hotkeys-hook' | ||||
| import { Dialog } from '@headlessui/react' | ||||
|  | ||||
| interface ContextMenuProps | ||||
| export interface ContextMenuProps | ||||
|   extends Omit<React.HTMLAttributes<HTMLUListElement>, 'children'> { | ||||
|   items?: React.ReactElement[] | ||||
|   menuTargetElement?: RefObject<HTMLElement> | ||||
|   guard?: (e: globalThis.MouseEvent) => boolean | ||||
|   event?: 'contextmenu' | 'mouseup' | ||||
| } | ||||
|  | ||||
| const DefaultContextMenuItems = [ | ||||
| @ -20,6 +30,8 @@ export function ContextMenu({ | ||||
|   items = DefaultContextMenuItems, | ||||
|   menuTargetElement, | ||||
|   className, | ||||
|   guard, | ||||
|   event = 'contextmenu', | ||||
|   ...props | ||||
| }: ContextMenuProps) { | ||||
|   const dialogRef = useRef<HTMLDivElement>(null) | ||||
| @ -32,6 +44,15 @@ export function ContextMenu({ | ||||
|   useHotkeys('esc', () => setOpen(false), { | ||||
|     enabled: open, | ||||
|   }) | ||||
|   const handleContextMenu = useCallback( | ||||
|     (e: globalThis.MouseEvent) => { | ||||
|       if (guard && !guard(e)) return | ||||
|       e.preventDefault() | ||||
|       setPosition({ x: e.clientX, y: e.clientY }) | ||||
|       setOpen(true) | ||||
|     }, | ||||
|     [guard, setPosition, setOpen] | ||||
|   ) | ||||
|  | ||||
|   const dialogPositionStyle = useMemo(() => { | ||||
|     if (!dialogRef.current) | ||||
| @ -78,21 +99,9 @@ export function ContextMenu({ | ||||
|  | ||||
|   // Add context menu listener to target once mounted | ||||
|   useEffect(() => { | ||||
|     const handleContextMenu = (e: MouseEvent) => { | ||||
|       console.log('context menu', e) | ||||
|       e.preventDefault() | ||||
|       setPosition({ x: e.x, y: e.y }) | ||||
|       setOpen(true) | ||||
|     } | ||||
|     menuTargetElement?.current?.addEventListener( | ||||
|       'contextmenu', | ||||
|       handleContextMenu | ||||
|     ) | ||||
|     menuTargetElement?.current?.addEventListener(event, handleContextMenu) | ||||
|     return () => { | ||||
|       menuTargetElement?.current?.removeEventListener( | ||||
|         'contextmenu', | ||||
|         handleContextMenu | ||||
|       ) | ||||
|       menuTargetElement?.current?.removeEventListener(event, handleContextMenu) | ||||
|     } | ||||
|   }, [menuTargetElement?.current]) | ||||
|  | ||||
| @ -100,7 +109,10 @@ export function ContextMenu({ | ||||
|     <Dialog open={open} onClose={() => setOpen(false)}> | ||||
|       <div | ||||
|         className="fixed inset-0 z-50 w-screen h-screen" | ||||
|         onContextMenu={(e) => e.preventDefault()} | ||||
|         onContextMenu={(e) => { | ||||
|           e.preventDefault() | ||||
|           setPosition({ x: e.clientX, y: e.clientY }) | ||||
|         }} | ||||
|       > | ||||
|         <Dialog.Backdrop className="fixed z-10 inset-0" /> | ||||
|         <Dialog.Panel | ||||
|  | ||||
| @ -266,6 +266,7 @@ const FileTreeItem = ({ | ||||
|       // Let the lsp servers know we closed a file. | ||||
|       onFileClose(currentFile?.path || null, project?.path || null) | ||||
|       onFileOpen(fileOrDir.path, project?.path || null) | ||||
|       kclManager.switchedFiles = true | ||||
|  | ||||
|       // Open kcl files | ||||
|       navigate(`${PATHS.FILE}/${encodeURIComponent(fileOrDir.path)}`) | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| import { SceneInfra } from 'clientSideScene/sceneInfra' | ||||
| import { sceneInfra } from 'lib/singletons' | ||||
| import { MutableRefObject, useEffect, useMemo, useRef } from 'react' | ||||
| import { MutableRefObject, useEffect, useRef } from 'react' | ||||
| import { | ||||
|   WebGLRenderer, | ||||
|   Scene, | ||||
| @ -19,16 +19,14 @@ import { | ||||
|   Intersection, | ||||
|   Object3D, | ||||
| } from 'three' | ||||
| import { | ||||
|   ContextMenu, | ||||
|   ContextMenuDivider, | ||||
|   ContextMenuItem, | ||||
|   ContextMenuItemRefresh, | ||||
| } from './ContextMenu' | ||||
| import { Popover } from '@headlessui/react' | ||||
| import { CustomIcon } from './CustomIcon' | ||||
| import { reportRejection } from 'lib/trap' | ||||
| import { useModelingContext } from 'hooks/useModelingContext' | ||||
| import { | ||||
|   useViewControlMenuItems, | ||||
|   ViewControlContextMenu, | ||||
| } from './ViewControlMenu' | ||||
| import { AxisNames } from 'lib/constants' | ||||
|  | ||||
| const CANVAS_SIZE = 80 | ||||
| const FRUSTUM_SIZE = 0.5 | ||||
| @ -40,64 +38,14 @@ enum AxisColors { | ||||
|   Z = '#6689ef', | ||||
|   Gray = '#c6c7c2', | ||||
| } | ||||
| enum AxisNames { | ||||
|   X = 'x', | ||||
|   Y = 'y', | ||||
|   Z = 'z', | ||||
|   NEG_X = '-x', | ||||
|   NEG_Y = '-y', | ||||
|   NEG_Z = '-z', | ||||
| } | ||||
| const axisNamesSemantic: Record<AxisNames, string> = { | ||||
|   [AxisNames.X]: 'Right', | ||||
|   [AxisNames.Y]: 'Back', | ||||
|   [AxisNames.Z]: 'Top', | ||||
|   [AxisNames.NEG_X]: 'Left', | ||||
|   [AxisNames.NEG_Y]: 'Front', | ||||
|   [AxisNames.NEG_Z]: 'Bottom', | ||||
| } | ||||
|  | ||||
| export default function Gizmo() { | ||||
|   const menuItems = useViewControlMenuItems() | ||||
|   const wrapperRef = useRef<HTMLDivElement | null>(null) | ||||
|   const canvasRef = useRef<HTMLCanvasElement | null>(null) | ||||
|   const raycasterIntersect = useRef<Intersection<Object3D> | null>(null) | ||||
|   const cameraPassiveUpdateTimer = useRef(0) | ||||
|   const raycasterPassiveUpdateTimer = useRef(0) | ||||
|   const { send: modelingSend } = useModelingContext() | ||||
|   const menuItems = useMemo( | ||||
|     () => [ | ||||
|       ...Object.entries(axisNamesSemantic).map(([axisName, axisSemantic]) => ( | ||||
|         <ContextMenuItem | ||||
|           key={axisName} | ||||
|           onClick={() => { | ||||
|             sceneInfra.camControls | ||||
|               .updateCameraToAxis(axisName as AxisNames) | ||||
|               .catch(reportRejection) | ||||
|           }} | ||||
|         > | ||||
|           {axisSemantic} view | ||||
|         </ContextMenuItem> | ||||
|       )), | ||||
|       <ContextMenuDivider />, | ||||
|       <ContextMenuItem | ||||
|         onClick={() => { | ||||
|           sceneInfra.camControls.resetCameraPosition().catch(reportRejection) | ||||
|         }} | ||||
|       > | ||||
|         Reset view | ||||
|       </ContextMenuItem>, | ||||
|       <ContextMenuItem | ||||
|         onClick={() => { | ||||
|           modelingSend({ type: 'Center camera on selection' }) | ||||
|         }} | ||||
|       > | ||||
|         Center view on selection | ||||
|       </ContextMenuItem>, | ||||
|       <ContextMenuDivider />, | ||||
|       <ContextMenuItemRefresh />, | ||||
|     ], | ||||
|     [axisNamesSemantic] | ||||
|   ) | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (!canvasRef.current) return | ||||
| @ -161,7 +109,7 @@ export default function Gizmo() { | ||||
|         className="grid place-content-center rounded-full overflow-hidden border border-solid border-primary/50 pointer-events-auto bg-chalkboard-10/70 dark:bg-chalkboard-100/80 backdrop-blur-sm" | ||||
|       > | ||||
|         <canvas ref={canvasRef} /> | ||||
|         <ContextMenu menuTargetElement={wrapperRef} items={menuItems} /> | ||||
|         <ViewControlContextMenu menuTargetElement={wrapperRef} /> | ||||
|       </div> | ||||
|       <GizmoDropdown items={menuItems} /> | ||||
|     </div> | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| import { APP_VERSION } from 'routes/Settings' | ||||
| import { APP_VERSION, getReleaseUrl } from 'routes/Settings' | ||||
| import { CustomIcon } from 'components/CustomIcon' | ||||
| import Tooltip from 'components/Tooltip' | ||||
| import { PATHS } from 'lib/paths' | ||||
| @ -72,10 +72,8 @@ export function LowerRightControls({ | ||||
|       <menu className="flex items-center justify-end gap-3 pointer-events-auto"> | ||||
|         {!location.pathname.startsWith(PATHS.HOME) && <ModelStateIndicator />} | ||||
|         <a | ||||
|           onClick={openExternalBrowserIfDesktop( | ||||
|             `https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}` | ||||
|           )} | ||||
|           href={`https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`} | ||||
|           onClick={openExternalBrowserIfDesktop(getReleaseUrl())} | ||||
|           href={getReleaseUrl()} | ||||
|           target="_blank" | ||||
|           rel="noopener noreferrer" | ||||
|           className={'!no-underline font-mono text-xs ' + linkOverrideClassName} | ||||
|  | ||||
| @ -69,14 +69,7 @@ export const LspProvider = ({ children }: { children: React.ReactNode }) => { | ||||
|   const [isKclLspReady, setIsKclLspReady] = useState(false) | ||||
|   const [isCopilotLspReady, setIsCopilotLspReady] = useState(false) | ||||
|  | ||||
|   const { | ||||
|     auth, | ||||
|     settings: { | ||||
|       context: { | ||||
|         modeling: { defaultUnit }, | ||||
|       }, | ||||
|     }, | ||||
|   } = useSettingsAuthContext() | ||||
|   const { auth } = useSettingsAuthContext() | ||||
|   const token = auth?.context.token | ||||
|   const navigate = useNavigate() | ||||
|  | ||||
| @ -92,7 +85,6 @@ export const LspProvider = ({ children }: { children: React.ReactNode }) => { | ||||
|     const initEvent: KclWorkerOptions = { | ||||
|       wasmUrl: wasmUrl(), | ||||
|       token: token, | ||||
|       baseUnit: defaultUnit.current, | ||||
|       apiBaseUrl: VITE_KC_API_BASE_URL, | ||||
|     } | ||||
|     lspWorker.postMessage({ | ||||
|  | ||||
| @ -41,7 +41,10 @@ import { | ||||
|   angleBetweenInfo, | ||||
|   applyConstraintAngleBetween, | ||||
| } from './Toolbar/SetAngleBetween' | ||||
| import { applyConstraintAngleLength } from './Toolbar/setAngleLength' | ||||
| import { | ||||
|   applyConstraintAngleLength, | ||||
|   applyConstraintLength, | ||||
| } from './Toolbar/setAngleLength' | ||||
| import { | ||||
|   canSweepSelection, | ||||
|   handleSelectionBatch, | ||||
| @ -51,6 +54,8 @@ import { | ||||
|   Selections, | ||||
|   updateSelections, | ||||
|   canLoftSelection, | ||||
|   canRevolveSelection, | ||||
|   canShellSelection, | ||||
| } from 'lib/selections' | ||||
| import { applyConstraintIntersect } from './Toolbar/Intersect' | ||||
| import { applyConstraintAbsDistance } from './Toolbar/SetAbsDistance' | ||||
| @ -62,13 +67,15 @@ import { | ||||
|   getSketchOrientationDetails, | ||||
| } from 'clientSideScene/sceneEntities' | ||||
| import { | ||||
|   moveValueIntoNewVariablePath, | ||||
|   insertNamedConstant, | ||||
|   replaceValueAtNodePath, | ||||
|   sketchOnExtrudedFace, | ||||
|   sketchOnOffsetPlane, | ||||
|   startSketchOnDefault, | ||||
| } from 'lang/modifyAst' | ||||
| import { Program, parse, recast, resultIsOk } from 'lang/wasm' | ||||
| import { PathToNode, Program, parse, recast, resultIsOk } from 'lang/wasm' | ||||
| import { | ||||
|   doesSceneHaveExtrudedSketch, | ||||
|   doesSceneHaveSweepableSketch, | ||||
|   getNodePathFromSourceRange, | ||||
|   isSingleCursorInPipe, | ||||
| @ -79,7 +86,6 @@ import toast from 'react-hot-toast' | ||||
| import { EditorSelection, Transaction } from '@codemirror/state' | ||||
| import { useLoaderData, useNavigate, useSearchParams } from 'react-router-dom' | ||||
| import { letEngineAnimateAndSyncCamAfter } from 'clientSideScene/CameraControls' | ||||
| import { getVarNameModal } from 'hooks/useToolbarGuards' | ||||
| import { err, reportRejection, trap } from 'lib/trap' | ||||
| import { useCommandsContext } from 'hooks/useCommandsContext' | ||||
| import { modelingMachineEvent } from 'editor/manager' | ||||
| @ -570,6 +576,26 @@ export const ModelingMachineProvider = ({ | ||||
|           if (err(canSweep)) return false | ||||
|           return canSweep | ||||
|         }, | ||||
|         'has valid revolve selection': ({ context: { selectionRanges } }) => { | ||||
|           // A user can begin extruding if they either have 1+ faces selected or nothing selected | ||||
|           // TODO: I believe this guard only allows for extruding a single face at a time | ||||
|           const hasNoSelection = | ||||
|             selectionRanges.graphSelections.length === 0 || | ||||
|             isRangeBetweenCharacters(selectionRanges) || | ||||
|             isSelectionLastLine(selectionRanges, codeManager.code) | ||||
|  | ||||
|           if (hasNoSelection) { | ||||
|             // they have no selection, we should enable the button | ||||
|             // so they can select the face through the cmdbar | ||||
|             // BUT only if there's extrudable geometry | ||||
|             return doesSceneHaveSweepableSketch(kclManager.ast) | ||||
|           } | ||||
|           if (!isSketchPipe(selectionRanges)) return false | ||||
|  | ||||
|           const canSweep = canRevolveSelection(selectionRanges) | ||||
|           if (err(canSweep)) return false | ||||
|           return canSweep | ||||
|         }, | ||||
|         'has valid loft selection': ({ context: { selectionRanges } }) => { | ||||
|           const hasNoSelection = | ||||
|             selectionRanges.graphSelections.length === 0 || | ||||
| @ -585,6 +611,24 @@ export const ModelingMachineProvider = ({ | ||||
|           if (err(canLoft)) return false | ||||
|           return canLoft | ||||
|         }, | ||||
|         'has valid shell selection': ({ | ||||
|           context: { selectionRanges }, | ||||
|           event, | ||||
|         }) => { | ||||
|           const hasNoSelection = | ||||
|             selectionRanges.graphSelections.length === 0 || | ||||
|             isRangeBetweenCharacters(selectionRanges) || | ||||
|             isSelectionLastLine(selectionRanges, codeManager.code) | ||||
|  | ||||
|           if (hasNoSelection) { | ||||
|             return doesSceneHaveExtrudedSketch(kclManager.ast) | ||||
|           } | ||||
|  | ||||
|           const canShell = canShellSelection(selectionRanges) | ||||
|           console.log('canShellSelection', canShellSelection(selectionRanges)) | ||||
|           if (err(canShell)) return false | ||||
|           return canShell | ||||
|         }, | ||||
|         'has valid selection for deletion': ({ | ||||
|           context: { selectionRanges }, | ||||
|         }) => { | ||||
| @ -869,12 +913,18 @@ export const ModelingMachineProvider = ({ | ||||
|             } | ||||
|           } | ||||
|         ), | ||||
|         'Get length info': fromPromise( | ||||
|           async ({ input: { selectionRanges, sketchDetails } }) => { | ||||
|             const { modifiedAst, pathToNodeMap } = | ||||
|               await applyConstraintAngleLength({ | ||||
|                 selectionRanges, | ||||
|               }) | ||||
|         astConstrainLength: fromPromise( | ||||
|           async ({ | ||||
|             input: { selectionRanges, sketchDetails, lengthValue }, | ||||
|           }) => { | ||||
|             if (!lengthValue) | ||||
|               return Promise.reject(new Error('No length value')) | ||||
|             const constraintResult = await applyConstraintLength({ | ||||
|               selectionRanges, | ||||
|               length: lengthValue, | ||||
|             }) | ||||
|             if (err(constraintResult)) return Promise.reject(constraintResult) | ||||
|             const { modifiedAst, pathToNodeMap } = constraintResult | ||||
|             const pResult = parse(recast(modifiedAst)) | ||||
|             if (trap(pResult) || !resultIsOk(pResult)) | ||||
|               return Promise.reject(new Error('Unexpected compilation error')) | ||||
| @ -1043,38 +1093,88 @@ export const ModelingMachineProvider = ({ | ||||
|             } | ||||
|           } | ||||
|         ), | ||||
|         'Get convert to variable info': fromPromise( | ||||
|         'Apply named value constraint': fromPromise( | ||||
|           async ({ input: { selectionRanges, sketchDetails, data } }) => { | ||||
|             if (!sketchDetails) | ||||
|             if (!sketchDetails) { | ||||
|               return Promise.reject(new Error('No sketch details')) | ||||
|             const { variableName } = await getVarNameModal({ | ||||
|               valueName: data?.variableName || 'var', | ||||
|             }) | ||||
|             } | ||||
|             if (!data) { | ||||
|               return Promise.reject(new Error('No data from command flow')) | ||||
|             } | ||||
|             let pResult = parse(recast(kclManager.ast)) | ||||
|             if (trap(pResult) || !resultIsOk(pResult)) | ||||
|               return Promise.reject(new Error('Unexpected compilation error')) | ||||
|             let parsed = pResult.program | ||||
|  | ||||
|             const { modifiedAst: _modifiedAst, pathToReplacedNode } = | ||||
|               moveValueIntoNewVariablePath( | ||||
|                 parsed, | ||||
|                 kclManager.programMemory, | ||||
|                 data?.pathToNode || [], | ||||
|                 variableName | ||||
|             let result: { | ||||
|               modifiedAst: Node<Program> | ||||
|               pathToReplaced: PathToNode | null | ||||
|             } = { | ||||
|               modifiedAst: parsed, | ||||
|               pathToReplaced: null, | ||||
|             } | ||||
|             // If the user provided a constant name, | ||||
|             // we need to insert the named constant | ||||
|             // and then replace the node with the constant's name. | ||||
|             if ('variableName' in data.namedValue) { | ||||
|               const astAfterReplacement = replaceValueAtNodePath({ | ||||
|                 ast: parsed, | ||||
|                 pathToNode: data.currentValue.pathToNode, | ||||
|                 newExpressionString: data.namedValue.variableName, | ||||
|               }) | ||||
|               if (trap(astAfterReplacement)) { | ||||
|                 return Promise.reject(astAfterReplacement) | ||||
|               } | ||||
|               const parseResultAfterInsertion = parse( | ||||
|                 recast( | ||||
|                   insertNamedConstant({ | ||||
|                     node: astAfterReplacement.modifiedAst, | ||||
|                     newExpression: data.namedValue, | ||||
|                   }) | ||||
|                 ) | ||||
|               ) | ||||
|             pResult = parse(recast(_modifiedAst)) | ||||
|               if ( | ||||
|                 trap(parseResultAfterInsertion) || | ||||
|                 !resultIsOk(parseResultAfterInsertion) | ||||
|               ) | ||||
|                 return Promise.reject(parseResultAfterInsertion) | ||||
|               result = { | ||||
|                 modifiedAst: parseResultAfterInsertion.program, | ||||
|                 pathToReplaced: astAfterReplacement.pathToReplaced, | ||||
|               } | ||||
|             } else if ('valueText' in data.namedValue) { | ||||
|               // If they didn't provide a constant name, | ||||
|               // just replace the node with the value. | ||||
|               const astAfterReplacement = replaceValueAtNodePath({ | ||||
|                 ast: parsed, | ||||
|                 pathToNode: data.currentValue.pathToNode, | ||||
|                 newExpressionString: data.namedValue.valueText, | ||||
|               }) | ||||
|               if (trap(astAfterReplacement)) { | ||||
|                 return Promise.reject(astAfterReplacement) | ||||
|               } | ||||
|               // The `replacer` function returns a pathToNode that assumes | ||||
|               // an identifier is also being inserted into the AST, creating an off-by-one error. | ||||
|               // This corrects that error, but TODO we should fix this upstream | ||||
|               // to avoid this kind of error in the future. | ||||
|               astAfterReplacement.pathToReplaced[1][0] = | ||||
|                 (astAfterReplacement.pathToReplaced[1][0] as number) - 1 | ||||
|               result = astAfterReplacement | ||||
|             } | ||||
|  | ||||
|             pResult = parse(recast(result.modifiedAst)) | ||||
|             if (trap(pResult) || !resultIsOk(pResult)) | ||||
|               return Promise.reject(new Error('Unexpected compilation error')) | ||||
|             parsed = pResult.program | ||||
|  | ||||
|             if (trap(parsed)) return Promise.reject(parsed) | ||||
|             parsed = parsed as Node<Program> | ||||
|             if (!pathToReplacedNode) | ||||
|             if (!result.pathToReplaced) | ||||
|               return Promise.reject(new Error('No path to replaced node')) | ||||
|  | ||||
|             const updatedAst = | ||||
|               await sceneEntitiesManager.updateAstAndRejigSketch( | ||||
|                 pathToReplacedNode || [], | ||||
|                 result.pathToReplaced || [], | ||||
|                 parsed, | ||||
|                 sketchDetails.zAxis, | ||||
|                 sketchDetails.yAxis, | ||||
| @ -1087,7 +1187,7 @@ export const ModelingMachineProvider = ({ | ||||
|             ) | ||||
|  | ||||
|             const selection = updateSelections( | ||||
|               { 0: pathToReplacedNode }, | ||||
|               { 0: result.pathToReplaced }, | ||||
|               selectionRanges, | ||||
|               updatedAst.newAst | ||||
|             ) | ||||
| @ -1095,7 +1195,7 @@ export const ModelingMachineProvider = ({ | ||||
|             return { | ||||
|               selectionType: 'completeSelection', | ||||
|               selection, | ||||
|               updatedPathToNode: pathToReplacedNode, | ||||
|               updatedPathToNode: result.pathToReplaced, | ||||
|             } | ||||
|           } | ||||
|         ), | ||||
|  | ||||
| @ -76,7 +76,7 @@ export const ModelingPane = ({ | ||||
|   return ( | ||||
|     <section | ||||
|       {...props} | ||||
|       title={title && typeof title === 'string' ? title : ''} | ||||
|       aria-label={title && typeof title === 'string' ? title : ''} | ||||
|       data-testid={detailsTestId} | ||||
|       id={id} | ||||
|       className={ | ||||
|  | ||||
| @ -40,7 +40,9 @@ export const KclEditorMenu = ({ children }: PropsWithChildren) => { | ||||
|         <Menu.Items className="absolute right-0 left-auto w-72 flex flex-col gap-1 divide-y divide-chalkboard-20 dark:divide-chalkboard-70 align-stretch px-0 py-1 bg-chalkboard-10 dark:bg-chalkboard-100 rounded-sm shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50"> | ||||
|           <Menu.Item> | ||||
|             <button | ||||
|               onClick={() => kclManager.format()} | ||||
|               onClick={() => { | ||||
|                 kclManager.format().catch(reportRejection) | ||||
|               }} | ||||
|               className={styles.button} | ||||
|             > | ||||
|               <span>Format code</span> | ||||
|  | ||||
| @ -10,7 +10,7 @@ import { APP_NAME } from 'lib/constants' | ||||
| import { useCommandsContext } from 'hooks/useCommandsContext' | ||||
| import { CustomIcon } from './CustomIcon' | ||||
| import { useLspContext } from './LspProvider' | ||||
| import { engineCommandManager } from 'lib/singletons' | ||||
| import { engineCommandManager, kclManager } from 'lib/singletons' | ||||
| import { MachineManagerContext } from 'components/MachineManagerProvider' | ||||
| import usePlatform from 'hooks/usePlatform' | ||||
| import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' | ||||
| @ -68,8 +68,7 @@ function AppLogoLink({ | ||||
|       data-testid="app-logo" | ||||
|       onClick={() => { | ||||
|         onProjectClose(file || null, project?.path || null, false) | ||||
|         // Clear the scene. | ||||
|         engineCommandManager.clearScene() | ||||
|         kclManager.switchedFiles = true | ||||
|       }} | ||||
|       to={PATHS.HOME} | ||||
|       className={wrapperClassName + ' hover:before:brightness-110'} | ||||
| @ -190,8 +189,7 @@ function ProjectMenuPopover({ | ||||
|           className: !isDesktop() ? 'hidden' : '', | ||||
|           onClick: () => { | ||||
|             onProjectClose(file || null, project?.path || null, true) | ||||
|             // Clear the scene. | ||||
|             engineCommandManager.clearScene() | ||||
|             kclManager.switchedFiles = true | ||||
|           }, | ||||
|         }, | ||||
|       ].filter( | ||||
|  | ||||
| @ -10,7 +10,7 @@ interface AllKeybindingsFieldsProps {} | ||||
|  | ||||
| export const AllKeybindingsFields = forwardRef( | ||||
|   ( | ||||
|     props: AllKeybindingsFieldsProps, | ||||
|     _props: AllKeybindingsFieldsProps, | ||||
|     scrollRef: ForwardedRef<HTMLDivElement> | ||||
|   ) => { | ||||
|     // This is how we will get the interaction map from the context | ||||
| @ -25,7 +25,7 @@ export const AllKeybindingsFields = forwardRef( | ||||
|             .map(([category, categoryItems]) => ( | ||||
|               <div className="flex flex-col gap-4 px-2 pr-4"> | ||||
|                 <h2 | ||||
|                   id={`category-${category}`} | ||||
|                   id={`category-${category.replaceAll(/\s/g, '-')}`} | ||||
|                   className="text-xl mt-6 first-of-type:mt-0 capitalize font-bold" | ||||
|                 > | ||||
|                   {category} | ||||
|  | ||||
| @ -13,7 +13,7 @@ import { isDesktop } from 'lib/isDesktop' | ||||
| import { ActionButton } from 'components/ActionButton' | ||||
| import { SettingsFieldInput } from './SettingsFieldInput' | ||||
| import toast from 'react-hot-toast' | ||||
| import { APP_VERSION, PACKAGE_NAME } from 'routes/Settings' | ||||
| import { APP_VERSION, IS_NIGHTLY, getReleaseUrl } from 'routes/Settings' | ||||
| import { PATHS } from 'lib/paths' | ||||
| import { | ||||
|   createAndOpenNewTutorialProject, | ||||
| @ -246,10 +246,8 @@ export const AllSettingsFields = forwardRef( | ||||
|                   to inject the version from package.json */} | ||||
|               App version {APP_VERSION}.{' '} | ||||
|               <a | ||||
|                 onClick={openExternalBrowserIfDesktop( | ||||
|                   `https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}` | ||||
|                 )} | ||||
|                 href={`https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`} | ||||
|                 onClick={openExternalBrowserIfDesktop(getReleaseUrl())} | ||||
|                 href={getReleaseUrl()} | ||||
|                 target="_blank" | ||||
|                 rel="noopener noreferrer" | ||||
|               > | ||||
| @ -271,7 +269,7 @@ export const AllSettingsFields = forwardRef( | ||||
|               , and start a discussion if you don't see it! Your feedback will | ||||
|               help us prioritize what to build next. | ||||
|             </p> | ||||
|             {PACKAGE_NAME.indexOf('-nightly') === -1 && ( | ||||
|             {!IS_NIGHTLY && ( | ||||
|               <p className="max-w-2xl mt-6"> | ||||
|                 Want to experience the latest and (hopefully) greatest from our | ||||
|                 main development branch?{' '} | ||||
|  | ||||
| @ -19,7 +19,7 @@ export function KeybindingsSectionsList({ | ||||
|             key={category} | ||||
|             onClick={() => | ||||
|               scrollRef.current | ||||
|                 ?.querySelector(`#category-${category}`) | ||||
|                 ?.querySelector(`#category-${category.replaceAll(/\s/g, '-')}`) | ||||
|                 ?.scrollIntoView({ | ||||
|                   block: 'center', | ||||
|                   behavior: 'smooth', | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| import { trap } from 'lib/trap' | ||||
| import { useMachine } from '@xstate/react' | ||||
| import { useMachine, useSelector } from '@xstate/react' | ||||
| import { useNavigate, useRouteLoaderData, useLocation } from 'react-router-dom' | ||||
| import { PATHS, BROWSER_PATH } from 'lib/paths' | ||||
| import { authMachine, TOKEN_PERSIST_KEY } from '../machines/authMachine' | ||||
| @ -23,7 +23,6 @@ import { | ||||
|   engineCommandManager, | ||||
|   sceneEntitiesManager, | ||||
| } from 'lib/singletons' | ||||
| import { uuidv4 } from 'lib/utils' | ||||
| import { IndexLoaderData } from 'lib/types' | ||||
| import { settings } from 'lib/settings/initialSettings' | ||||
| import { | ||||
| @ -55,11 +54,15 @@ type SettingsAuthContextType = { | ||||
|   settings: MachineContext<typeof settingsMachine> | ||||
| } | ||||
|  | ||||
| // a little hacky for sure, open to changing it | ||||
| // this implies that we should only even have one instance of this provider mounted at any one time | ||||
| // but I think that's a safe assumption | ||||
| let settingsStateRef: ContextFrom<typeof settingsMachine> | undefined | ||||
| export const getSettingsState = () => settingsStateRef | ||||
| /** | ||||
|  * This variable is used to store the last snapshot of the settings context | ||||
|  * for use outside of React, such as in `wasm.ts`. It is updated every time | ||||
|  * the settings machine changes with `useSelector`. | ||||
|  * TODO: when we decouple XState from React, we can just subscribe to the actor directly from `wasm.ts` | ||||
|  */ | ||||
| export let lastSettingsContextSnapshot: | ||||
|   | ContextFrom<typeof settingsMachine> | ||||
|   | undefined | ||||
|  | ||||
| export const SettingsAuthContext = createContext({} as SettingsAuthContextType) | ||||
|  | ||||
| @ -129,27 +132,11 @@ export const SettingsAuthProviderBase = ({ | ||||
|             .setTheme(context.app.theme.current) | ||||
|             .catch(reportRejection) | ||||
|         }, | ||||
|         setEngineScaleGridVisibility: ({ context }) => { | ||||
|           engineCommandManager.setScaleGridVisibility( | ||||
|             context.modeling.showScaleGrid.current | ||||
|           ) | ||||
|         }, | ||||
|         setClientTheme: ({ context }) => { | ||||
|           const opposingTheme = getOppositeTheme(context.app.theme.current) | ||||
|           sceneInfra.theme = opposingTheme | ||||
|           sceneEntitiesManager.updateSegmentBaseColor(opposingTheme) | ||||
|         }, | ||||
|         setEngineEdges: ({ context }) => { | ||||
|           // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|           engineCommandManager.sendSceneCommand({ | ||||
|             cmd_id: uuidv4(), | ||||
|             type: 'modeling_cmd_req', | ||||
|             cmd: { | ||||
|               type: 'edge_lines_visible' as any, // TODO update kittycad.ts to get this new command type | ||||
|               hidden: !context.modeling.highlightEdges.current, | ||||
|             }, | ||||
|           }) | ||||
|         }, | ||||
|         toastSuccess: ({ event }) => { | ||||
|           if (!('data' in event)) return | ||||
|           const eventParts = event.type.replace(/^set./, '').split('.') as [ | ||||
| @ -175,17 +162,27 @@ export const SettingsAuthProviderBase = ({ | ||||
|         }, | ||||
|         'Execute AST': ({ context, event }) => { | ||||
|           try { | ||||
|             const relevantSetting = (s: typeof settings) => { | ||||
|               return ( | ||||
|                 s.modeling?.defaultUnit?.current !== | ||||
|                   context.modeling.defaultUnit.current || | ||||
|                 s.modeling.showScaleGrid.current !== | ||||
|                   context.modeling.showScaleGrid.current || | ||||
|                 s.modeling?.highlightEdges.current !== | ||||
|                   context.modeling.highlightEdges.current | ||||
|               ) | ||||
|             } | ||||
|  | ||||
|             const allSettingsIncludesUnitChange = | ||||
|               event.type === 'Set all settings' && | ||||
|               event.settings?.modeling?.defaultUnit?.current !== | ||||
|                 context.modeling.defaultUnit.current | ||||
|               relevantSetting(event.settings) | ||||
|             const resetSettingsIncludesUnitChange = | ||||
|               event.type === 'Reset settings' && | ||||
|               context.modeling.defaultUnit.current !== | ||||
|                 settings?.modeling?.defaultUnit?.default | ||||
|               event.type === 'Reset settings' && relevantSetting(settings) | ||||
|  | ||||
|             if ( | ||||
|               event.type === 'set.modeling.defaultUnit' || | ||||
|               event.type === 'set.modeling.showScaleGrid' || | ||||
|               event.type === 'set.modeling.highlightEdges' || | ||||
|               allSettingsIncludesUnitChange || | ||||
|               resetSettingsIncludesUnitChange | ||||
|             ) { | ||||
| @ -214,7 +211,10 @@ export const SettingsAuthProviderBase = ({ | ||||
|     }), | ||||
|     { input: loadedSettings } | ||||
|   ) | ||||
|   settingsStateRef = settingsState.context | ||||
|   // Any time the actor changes, update the settings state for external use | ||||
|   useSelector(settingsActor, (s) => { | ||||
|     lastSettingsContextSnapshot = s.context | ||||
|   }) | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (!isDesktop()) return | ||||
|  | ||||
| @ -20,6 +20,7 @@ import { IndexLoaderData } from 'lib/types' | ||||
| import { useCommandsContext } from 'hooks/useCommandsContext' | ||||
| import { err, reportRejection } from 'lib/trap' | ||||
| import { getArtifactOfTypes } from 'lang/std/artifactGraph' | ||||
| import { ViewControlContextMenu } from './ViewControlMenu' | ||||
|  | ||||
| enum StreamState { | ||||
|   Playing = 'playing', | ||||
| @ -30,6 +31,7 @@ enum StreamState { | ||||
|  | ||||
| export const Stream = () => { | ||||
|   const [isLoading, setIsLoading] = useState(true) | ||||
|   const videoWrapperRef = useRef<HTMLDivElement>(null) | ||||
|   const videoRef = useRef<HTMLVideoElement>(null) | ||||
|   const { settings } = useSettingsAuthContext() | ||||
|   const { state, send } = useModelingContext() | ||||
| @ -258,7 +260,7 @@ export const Stream = () => { | ||||
|     setIsLoading(false) | ||||
|   }, [mediaStream]) | ||||
|  | ||||
|   const handleMouseUp: MouseEventHandler<HTMLDivElement> = (e) => { | ||||
|   const handleClick: MouseEventHandler<HTMLDivElement> = (e) => { | ||||
|     // If we've got no stream or connection, don't do anything | ||||
|     if (!isNetworkOkay) return | ||||
|     if (!videoRef.current) return | ||||
| @ -320,10 +322,11 @@ export const Stream = () => { | ||||
|  | ||||
|   return ( | ||||
|     <div | ||||
|       ref={videoWrapperRef} | ||||
|       className="absolute inset-0 z-0" | ||||
|       id="stream" | ||||
|       data-testid="stream" | ||||
|       onClick={handleMouseUp} | ||||
|       onClick={handleClick} | ||||
|       onDoubleClick={enterSketchModeIfSelectingSketch} | ||||
|       onContextMenu={(e) => e.preventDefault()} | ||||
|       onContextMenuCapture={(e) => e.preventDefault()} | ||||
| @ -384,6 +387,14 @@ export const Stream = () => { | ||||
|           </Loading> | ||||
|         </div> | ||||
|       )} | ||||
|       <ViewControlContextMenu | ||||
|         event="mouseup" | ||||
|         guard={(e) => | ||||
|           sceneInfra.camControls.wasDragging === false && | ||||
|           btnName(e).right === true | ||||
|         } | ||||
|         menuTargetElement={videoWrapperRef} | ||||
|       /> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| @ -2,6 +2,7 @@ import toast from 'react-hot-toast' | ||||
| import { ActionButton } from './ActionButton' | ||||
| import { openExternalBrowserIfDesktop } from 'lib/openWindow' | ||||
| import { Marked } from '@ts-stack/markdown' | ||||
| import { getReleaseUrl } from 'routes/Settings' | ||||
|  | ||||
| export function ToastUpdate({ | ||||
|   version, | ||||
| @ -32,10 +33,8 @@ export function ToastUpdate({ | ||||
|             A new update has downloaded and will be available next time you | ||||
|             start the app. You can view the release notes{' '} | ||||
|             <a | ||||
|               onClick={openExternalBrowserIfDesktop( | ||||
|                 `https://github.com/KittyCAD/modeling-app/releases/tag/v${version}` | ||||
|               )} | ||||
|               href={`https://github.com/KittyCAD/modeling-app/releases/tag/v${version}`} | ||||
|               onClick={openExternalBrowserIfDesktop(getReleaseUrl(version))} | ||||
|               href={getReleaseUrl(version)} | ||||
|               target="_blank" | ||||
|               rel="noreferrer" | ||||
|             > | ||||
|  | ||||
| @ -22,6 +22,7 @@ import { removeDoubleNegatives } from '../AvailableVarsHelpers' | ||||
| import { normaliseAngle } from '../../lib/utils' | ||||
| import { kclManager } from 'lib/singletons' | ||||
| import { err } from 'lib/trap' | ||||
| import { KclCommandValue } from 'lib/commandTypes' | ||||
|  | ||||
| const getModalInfo = createSetAngleLengthModal(SetAngleLengthModal) | ||||
|  | ||||
| @ -63,6 +64,57 @@ export function angleLengthInfo({ | ||||
|   return { enabled, transforms } | ||||
| } | ||||
|  | ||||
| export async function applyConstraintLength({ | ||||
|   length, | ||||
|   selectionRanges, | ||||
| }: { | ||||
|   length: KclCommandValue | ||||
|   selectionRanges: Selections | ||||
| }) { | ||||
|   const ast = kclManager.ast | ||||
|   const angleLength = angleLengthInfo({ selectionRanges }) | ||||
|   if (err(angleLength)) return angleLength | ||||
|   const { transforms } = angleLength | ||||
|  | ||||
|   let distanceExpression: Expr = length.valueAst | ||||
|  | ||||
|   /** | ||||
|    * To be "constrained", the value must be a binary expression, a named value, or a function call. | ||||
|    * If it has a variable name, we need to insert a variable declaration at the correct index. | ||||
|    */ | ||||
|   if ( | ||||
|     'variableName' in length && | ||||
|     length.variableName && | ||||
|     length.insertIndex !== undefined | ||||
|   ) { | ||||
|     const newBody = [...ast.body] | ||||
|     newBody.splice(length.insertIndex, 0, length.variableDeclarationAst) | ||||
|     ast.body = newBody | ||||
|     distanceExpression = createIdentifier(length.variableName) | ||||
|   } | ||||
|  | ||||
|   if (!isExprBinaryPart(distanceExpression)) { | ||||
|     return new Error('Invalid valueNode, is not a BinaryPart') | ||||
|   } | ||||
|  | ||||
|   const retval = transformAstSketchLines({ | ||||
|     ast, | ||||
|     selectionRanges, | ||||
|     transformInfos: transforms, | ||||
|     programMemory: kclManager.programMemory, | ||||
|     referenceSegName: '', | ||||
|     forceValueUsedInTransform: distanceExpression, | ||||
|   }) | ||||
|   if (err(retval)) return Promise.reject(retval) | ||||
|  | ||||
|   const { modifiedAst: _modifiedAst, pathToNodeMap } = retval | ||||
|  | ||||
|   return { | ||||
|     modifiedAst: _modifiedAst, | ||||
|     pathToNodeMap, | ||||
|   } | ||||
| } | ||||
|  | ||||
| export async function applyConstraintAngleLength({ | ||||
|   selectionRanges, | ||||
|   angleOrLength = 'setLength', | ||||
|  | ||||
| @ -41,7 +41,10 @@ export function UnitsMenu() { | ||||
|                       close() | ||||
|                     }} | ||||
|                   > | ||||
|                     {baseUnitLabels[unit]} | ||||
|                     <span className="flex-1">{baseUnitLabels[unit]}</span> | ||||
|                     {unit === settings.context.modeling.defaultUnit.current && ( | ||||
|                       <span className="text-chalkboard-60">current</span> | ||||
|                     )} | ||||
|                   </button> | ||||
|                 </li> | ||||
|               ))} | ||||
|  | ||||
							
								
								
									
										66
									
								
								src/components/ViewControlMenu.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,66 @@ | ||||
| import { reportRejection } from 'lib/trap' | ||||
| import { | ||||
|   ContextMenu, | ||||
|   ContextMenuDivider, | ||||
|   ContextMenuItem, | ||||
|   ContextMenuItemRefresh, | ||||
|   ContextMenuProps, | ||||
| } from './ContextMenu' | ||||
| import { AxisNames, VIEW_NAMES_SEMANTIC } from 'lib/constants' | ||||
| import { useModelingContext } from 'hooks/useModelingContext' | ||||
| import { useMemo } from 'react' | ||||
| import { sceneInfra } from 'lib/singletons' | ||||
|  | ||||
| export function useViewControlMenuItems() { | ||||
|   const { send: modelingSend } = useModelingContext() | ||||
|   const menuItems = useMemo( | ||||
|     () => [ | ||||
|       ...Object.entries(VIEW_NAMES_SEMANTIC).map(([axisName, axisSemantic]) => ( | ||||
|         <ContextMenuItem | ||||
|           key={axisName} | ||||
|           onClick={() => { | ||||
|             sceneInfra.camControls | ||||
|               .updateCameraToAxis(axisName as AxisNames) | ||||
|               .catch(reportRejection) | ||||
|           }} | ||||
|         > | ||||
|           {axisSemantic} view | ||||
|         </ContextMenuItem> | ||||
|       )), | ||||
|       <ContextMenuDivider />, | ||||
|       <ContextMenuItem | ||||
|         onClick={() => { | ||||
|           sceneInfra.camControls.resetCameraPosition().catch(reportRejection) | ||||
|         }} | ||||
|       > | ||||
|         Reset view | ||||
|       </ContextMenuItem>, | ||||
|       <ContextMenuItem | ||||
|         onClick={() => { | ||||
|           modelingSend({ type: 'Center camera on selection' }) | ||||
|         }} | ||||
|       > | ||||
|         Center view on selection | ||||
|       </ContextMenuItem>, | ||||
|       <ContextMenuDivider />, | ||||
|       <ContextMenuItemRefresh />, | ||||
|     ], | ||||
|     [VIEW_NAMES_SEMANTIC] | ||||
|   ) | ||||
|   return menuItems | ||||
| } | ||||
|  | ||||
| export function ViewControlContextMenu({ | ||||
|   menuTargetElement: wrapperRef, | ||||
|   ...props | ||||
| }: ContextMenuProps) { | ||||
|   const menuItems = useViewControlMenuItems() | ||||
|   return ( | ||||
|     <ContextMenu | ||||
|       data-testid="view-controls-menu" | ||||
|       menuTargetElement={wrapperRef} | ||||
|       items={menuItems} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										327
									
								
								src/editor/plugins/lsp/kcl/colors.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,327 @@ | ||||
| import { | ||||
|   EditorView, | ||||
|   WidgetType, | ||||
|   ViewUpdate, | ||||
|   ViewPlugin, | ||||
|   DecorationSet, | ||||
|   Decoration, | ||||
| } from '@codemirror/view' | ||||
| import { Range, Extension, Text } from '@codemirror/state' | ||||
| import { NodeProp, Tree } from '@lezer/common' | ||||
| import { language, syntaxTree } from '@codemirror/language' | ||||
|  | ||||
| interface PickerState { | ||||
|   from: number | ||||
|   to: number | ||||
|   alpha: string | ||||
|   colorType: ColorType | ||||
| } | ||||
|  | ||||
| export interface WidgetOptions extends PickerState { | ||||
|   color: string | ||||
| } | ||||
|  | ||||
| export type ColorData = Omit<WidgetOptions, 'from' | 'to'> | ||||
|  | ||||
| const pickerState = new WeakMap<HTMLInputElement, PickerState>() | ||||
|  | ||||
| export enum ColorType { | ||||
|   hex = 'HEX', | ||||
| } | ||||
|  | ||||
| const hexRegex = /(^|\b)(#[0-9a-f]{3,9})(\b|$)/i | ||||
|  | ||||
| function discoverColorsInKCL( | ||||
|   syntaxTree: Tree, | ||||
|   from: number, | ||||
|   to: number, | ||||
|   typeName: string, | ||||
|   doc: Text, | ||||
|   language?: string | ||||
| ): WidgetOptions | Array<WidgetOptions> | null { | ||||
|   switch (typeName) { | ||||
|     case 'Program': | ||||
|     case 'VariableDeclaration': | ||||
|     case 'CallExpression': | ||||
|     case 'ObjectExpression': | ||||
|     case 'ObjectProperty': | ||||
|     case 'ArgumentList': | ||||
|     case 'PipeExpression': { | ||||
|       let innerTree = syntaxTree.resolveInner(from, 0).tree | ||||
|  | ||||
|       if (!innerTree) { | ||||
|         innerTree = syntaxTree.resolveInner(from, 1).tree | ||||
|         if (!innerTree) { | ||||
|           return null | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       const overlayTree = innerTree.prop(NodeProp.mounted)?.tree | ||||
|  | ||||
|       if (overlayTree?.type.name !== 'Styles') { | ||||
|         return null | ||||
|       } | ||||
|  | ||||
|       const ret: Array<WidgetOptions> = [] | ||||
|       overlayTree.iterate({ | ||||
|         from: 0, | ||||
|         to: overlayTree.length, | ||||
|         enter: ({ type, from: overlayFrom, to: overlayTo }) => { | ||||
|           const maybeWidgetOptions = discoverColorsInKCL( | ||||
|             syntaxTree, | ||||
|             // We add one because the tree doesn't include the | ||||
|             // quotation mark from the style tag | ||||
|             from + 1 + overlayFrom, | ||||
|             from + 1 + overlayTo, | ||||
|             type.name, | ||||
|             doc, | ||||
|             language | ||||
|           ) | ||||
|  | ||||
|           if (maybeWidgetOptions) { | ||||
|             if (Array.isArray(maybeWidgetOptions)) { | ||||
|               console.error('Unexpected nested overlays') | ||||
|               ret.push(...maybeWidgetOptions) | ||||
|             } else { | ||||
|               ret.push(maybeWidgetOptions) | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|       }) | ||||
|  | ||||
|       return ret | ||||
|     } | ||||
|  | ||||
|     case 'String': { | ||||
|       const result = parseColorLiteral(doc.sliceString(from, to)) | ||||
|       if (!result) { | ||||
|         return null | ||||
|       } | ||||
|       return { | ||||
|         ...result, | ||||
|         from, | ||||
|         to, | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     default: | ||||
|       return null | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function parseColorLiteral(colorLiteral: string): ColorData | null { | ||||
|   const literal = colorLiteral.replace(/"/g, '') | ||||
|   const match = hexRegex.exec(literal) | ||||
|   if (!match) { | ||||
|     return null | ||||
|   } | ||||
|   const [color, alpha] = toFullHex(literal) | ||||
|  | ||||
|   return { | ||||
|     colorType: ColorType.hex, | ||||
|     color, | ||||
|     alpha, | ||||
|   } | ||||
| } | ||||
|  | ||||
| function colorPickersDecorations( | ||||
|   view: EditorView, | ||||
|   discoverColors: typeof discoverColorsInKCL | ||||
| ) { | ||||
|   const widgets: Array<Range<Decoration>> = [] | ||||
|  | ||||
|   const st = syntaxTree(view.state) | ||||
|  | ||||
|   for (const range of view.visibleRanges) { | ||||
|     st.iterate({ | ||||
|       from: range.from, | ||||
|       to: range.to, | ||||
|       enter: ({ type, from, to }) => { | ||||
|         const maybeWidgetOptions = discoverColors( | ||||
|           st, | ||||
|           from, | ||||
|           to, | ||||
|           type.name, | ||||
|           view.state.doc, | ||||
|           view.state.facet(language)?.name | ||||
|         ) | ||||
|  | ||||
|         if (!maybeWidgetOptions) { | ||||
|           return | ||||
|         } | ||||
|  | ||||
|         if (!Array.isArray(maybeWidgetOptions)) { | ||||
|           widgets.push( | ||||
|             Decoration.widget({ | ||||
|               widget: new ColorPickerWidget(maybeWidgetOptions), | ||||
|               side: 1, | ||||
|             }).range(maybeWidgetOptions.from) | ||||
|           ) | ||||
|  | ||||
|           return | ||||
|         } | ||||
|  | ||||
|         for (const wo of maybeWidgetOptions) { | ||||
|           widgets.push( | ||||
|             Decoration.widget({ | ||||
|               widget: new ColorPickerWidget(wo), | ||||
|               side: 1, | ||||
|             }).range(wo.from) | ||||
|           ) | ||||
|         } | ||||
|       }, | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   return Decoration.set(widgets) | ||||
| } | ||||
|  | ||||
| function toFullHex(color: string): string[] { | ||||
|   if (color.length === 4) { | ||||
|     // 3-char hex | ||||
|     return [ | ||||
|       `#${color[1].repeat(2)}${color[2].repeat(2)}${color[3].repeat(2)}`, | ||||
|       '', | ||||
|     ] | ||||
|   } | ||||
|  | ||||
|   if (color.length === 5) { | ||||
|     // 4-char hex (alpha) | ||||
|     return [ | ||||
|       `#${color[1].repeat(2)}${color[2].repeat(2)}${color[3].repeat(2)}`, | ||||
|       color[4].repeat(2), | ||||
|     ] | ||||
|   } | ||||
|  | ||||
|   if (color.length === 9) { | ||||
|     // 8-char hex (alpha) | ||||
|     return [`#${color.slice(1, -2)}`, color.slice(-2)] | ||||
|   } | ||||
|  | ||||
|   return [color, ''] | ||||
| } | ||||
|  | ||||
| export const wrapperClassName = 'cm-css-color-picker-wrapper' | ||||
|  | ||||
| class ColorPickerWidget extends WidgetType { | ||||
|   private readonly state: PickerState | ||||
|   private readonly color: string | ||||
|  | ||||
|   constructor({ color, ...state }: WidgetOptions) { | ||||
|     super() | ||||
|     this.state = state | ||||
|     this.color = color | ||||
|   } | ||||
|  | ||||
|   eq(other: ColorPickerWidget) { | ||||
|     return ( | ||||
|       other.state.colorType === this.state.colorType && | ||||
|       other.color === this.color && | ||||
|       other.state.from === this.state.from && | ||||
|       other.state.to === this.state.to && | ||||
|       other.state.alpha === this.state.alpha | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   toDOM() { | ||||
|     const picker = document.createElement('input') | ||||
|     pickerState.set(picker, this.state) | ||||
|     picker.type = 'color' | ||||
|     picker.value = this.color | ||||
|  | ||||
|     const wrapper = document.createElement('span') | ||||
|     wrapper.appendChild(picker) | ||||
|     wrapper.className = wrapperClassName | ||||
|  | ||||
|     return wrapper | ||||
|   } | ||||
|  | ||||
|   ignoreEvent() { | ||||
|     return false | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const colorPickerTheme = EditorView.baseTheme({ | ||||
|   [`.${wrapperClassName}`]: { | ||||
|     display: 'inline-block', | ||||
|     outline: '1px solid #eee', | ||||
|     marginRight: '0.6ch', | ||||
|     height: '1em', | ||||
|     width: '1em', | ||||
|     transform: 'translateY(1px)', | ||||
|   }, | ||||
|   [`.${wrapperClassName} input[type="color"]`]: { | ||||
|     cursor: 'pointer', | ||||
|     height: '100%', | ||||
|     width: '100%', | ||||
|     padding: 0, | ||||
|     border: 'none', | ||||
|     '&::-webkit-color-swatch-wrapper': { | ||||
|       padding: 0, | ||||
|     }, | ||||
|     '&::-webkit-color-swatch': { | ||||
|       border: 'none', | ||||
|     }, | ||||
|     '&::-moz-color-swatch': { | ||||
|       border: 'none', | ||||
|     }, | ||||
|   }, | ||||
| }) | ||||
|  | ||||
| interface IFactoryOptions { | ||||
|   discoverColors: typeof discoverColorsInKCL | ||||
| } | ||||
|  | ||||
| export const makeColorPicker = (options: IFactoryOptions) => | ||||
|   ViewPlugin.fromClass( | ||||
|     class ColorPickerViewPlugin { | ||||
|       decorations: DecorationSet | ||||
|  | ||||
|       constructor(view: EditorView) { | ||||
|         this.decorations = colorPickersDecorations(view, options.discoverColors) | ||||
|       } | ||||
|  | ||||
|       update(update: ViewUpdate) { | ||||
|         if (update.docChanged || update.viewportChanged) { | ||||
|           this.decorations = colorPickersDecorations( | ||||
|             update.view, | ||||
|             options.discoverColors | ||||
|           ) | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     { | ||||
|       decorations: (v) => v.decorations, | ||||
|       eventHandlers: { | ||||
|         change: (e, view) => { | ||||
|           const target = e.target as HTMLInputElement | ||||
|           if ( | ||||
|             target.nodeName !== 'INPUT' || | ||||
|             !target.parentElement || | ||||
|             !target.parentElement.classList.contains(wrapperClassName) | ||||
|           ) { | ||||
|             return false | ||||
|           } | ||||
|  | ||||
|           const data = pickerState.get(target)! | ||||
|  | ||||
|           let converted = '"' + target.value + data.alpha + '"' | ||||
|  | ||||
|           view.dispatch({ | ||||
|             changes: { | ||||
|               from: data.from, | ||||
|               to: data.to, | ||||
|               insert: converted, | ||||
|             }, | ||||
|           }) | ||||
|  | ||||
|           return true | ||||
|         }, | ||||
|       }, | ||||
|     } | ||||
|   ) | ||||
|  | ||||
| export const colorPicker: Extension = [ | ||||
|   makeColorPicker({ discoverColors: discoverColorsInKCL }), | ||||
|   colorPickerTheme, | ||||
| ] | ||||
| @ -17,6 +17,7 @@ import { kclPlugin } from '.' | ||||
| import type * as LSP from 'vscode-languageserver-protocol' | ||||
| // @ts-ignore: No types available | ||||
| import { parser } from './kcl.grammar' | ||||
| import { colorPicker } from './colors' | ||||
|  | ||||
| export interface LanguageOptions { | ||||
|   workspaceFolders: LSP.WorkspaceFolder[] | ||||
| @ -54,14 +55,14 @@ export const KclLanguage = LRLanguage.define({ | ||||
| }) | ||||
|  | ||||
| export function kcl(options: LanguageOptions) { | ||||
|   return new LanguageSupport( | ||||
|     KclLanguage, | ||||
|   return new LanguageSupport(KclLanguage, [ | ||||
|     colorPicker, | ||||
|     kclPlugin({ | ||||
|       documentUri: options.documentUri, | ||||
|       workspaceFolders: options.workspaceFolders, | ||||
|       allowHTMLContent: true, | ||||
|       client: options.client, | ||||
|       processLspNotification: options.processLspNotification, | ||||
|     }) | ||||
|   ) | ||||
|     }), | ||||
|   ]) | ||||
| } | ||||
|  | ||||
| @ -1,7 +1,5 @@ | ||||
| import { LspWorkerEventType } from '@kittycad/codemirror-lsp-client' | ||||
|  | ||||
| import { UnitLength } from 'wasm-lib/kcl/bindings/UnitLength' | ||||
|  | ||||
| export enum LspWorker { | ||||
|   Kcl = 'kcl', | ||||
|   Copilot = 'copilot', | ||||
| @ -9,7 +7,6 @@ export enum LspWorker { | ||||
| export interface KclWorkerOptions { | ||||
|   wasmUrl: string | ||||
|   token: string | ||||
|   baseUnit: UnitLength | ||||
|   apiBaseUrl: string | ||||
| } | ||||
|  | ||||
|  | ||||
| @ -17,7 +17,6 @@ import { | ||||
|   KclWorkerOptions, | ||||
|   CopilotWorkerOptions, | ||||
| } from 'editor/plugins/lsp/types' | ||||
| import { EngineCommandManager } from 'lang/std/engineConnection' | ||||
| import { err, reportRejection } from 'lib/trap' | ||||
|  | ||||
| const intoServer: IntoServer = new IntoServer() | ||||
| @ -46,14 +45,12 @@ export async function copilotLspRun( | ||||
|  | ||||
| export async function kclLspRun( | ||||
|   config: ServerConfig, | ||||
|   engineCommandManager: EngineCommandManager | null, | ||||
|   token: string, | ||||
|   baseUnit: string, | ||||
|   baseUrl: string | ||||
| ) { | ||||
|   try { | ||||
|     console.log('start kcl lsp') | ||||
|     await kcl_lsp_run(config, engineCommandManager, baseUnit, token, baseUrl) | ||||
|     await kcl_lsp_run(config, null, undefined, token, baseUrl) | ||||
|   } catch (e: any) { | ||||
|     console.log('kcl lsp failed', e) | ||||
|     // We can't restart here because a moved value, we should do this another way. | ||||
| @ -82,13 +79,7 @@ onmessage = function (event: MessageEvent) { | ||||
|           switch (worker) { | ||||
|             case LspWorker.Kcl: | ||||
|               const kclData = eventData as KclWorkerOptions | ||||
|               await kclLspRun( | ||||
|                 config, | ||||
|                 null, | ||||
|                 kclData.token, | ||||
|                 kclData.baseUnit, | ||||
|                 kclData.apiBaseUrl | ||||
|               ) | ||||
|               await kclLspRun(config, kclData.token, kclData.apiBaseUrl) | ||||
|               break | ||||
|             case LspWorker.Copilot: | ||||
|               let copilotData = eventData as CopilotWorkerOptions | ||||
|  | ||||
| @ -2,7 +2,7 @@ import { useLayoutEffect, useEffect, useRef } from 'react' | ||||
| import { engineCommandManager, kclManager } from 'lib/singletons' | ||||
| import { deferExecution } from 'lib/utils' | ||||
| import { Themes } from 'lib/theme' | ||||
| import { makeDefaultPlanes, modifyGrid } from 'lang/wasm' | ||||
| import { makeDefaultPlanes } from 'lang/wasm' | ||||
| import { useModelingContext } from './useModelingContext' | ||||
| import { useNetworkContext } from 'hooks/useNetworkContext' | ||||
| import { useAppState, useAppStream } from 'AppState' | ||||
| @ -56,9 +56,6 @@ export function useSetupEngineManager( | ||||
|       makeDefaultPlanes: () => { | ||||
|         return makeDefaultPlanes(kclManager.engineCommandManager) | ||||
|       }, | ||||
|       modifyGrid: (hidden: boolean) => { | ||||
|         return modifyGrid(kclManager.engineCommandManager, hidden) | ||||
|       }, | ||||
|     }) | ||||
|     hasSetNonZeroDimensions.current = true | ||||
|   } | ||||
|  | ||||
| @ -24,6 +24,8 @@ export function useConvertToVariable(range?: SourceRange) { | ||||
|   }, [enable]) | ||||
|  | ||||
|   useEffect(() => { | ||||
|     // Return early if there are no selection ranges for whatever reason | ||||
|     if (!context.selectionRanges) return | ||||
|     const parsed = ast | ||||
|  | ||||
|     const meta = isNodeSafeToReplace( | ||||
|  | ||||
| @ -317,3 +317,8 @@ code { | ||||
| #code-mirror-override .cm-editor { | ||||
|   height: 100% !important; | ||||
| } | ||||
|  | ||||
| /* Can't use #code-mirror-override here as we're outside of this div */ | ||||
| .body-bg .cm-diagnosticAction { | ||||
|   @apply bg-primary; | ||||
| } | ||||
|  | ||||
| @ -12,6 +12,7 @@ import { EXECUTE_AST_INTERRUPT_ERROR_MESSAGE } from 'lib/constants' | ||||
|  | ||||
| import { | ||||
|   CallExpression, | ||||
|   clearSceneAndBustCache, | ||||
|   emptyExecState, | ||||
|   ExecState, | ||||
|   initPromise, | ||||
| @ -60,6 +61,7 @@ export class KclManager { | ||||
|   private _executeIsStale: ExecuteArgs | null = null | ||||
|   private _wasmInitFailed = true | ||||
|   private _hasErrors = false | ||||
|   private _switchedFiles = false | ||||
|  | ||||
|   engineCommandManager: EngineCommandManager | ||||
|  | ||||
| @ -79,6 +81,10 @@ export class KclManager { | ||||
|     this._astCallBack(ast) | ||||
|   } | ||||
|  | ||||
|   set switchedFiles(switchedFiles: boolean) { | ||||
|     this._switchedFiles = switchedFiles | ||||
|   } | ||||
|  | ||||
|   get programMemory() { | ||||
|     return this._programMemory | ||||
|   } | ||||
| @ -166,8 +172,12 @@ export class KclManager { | ||||
|     this.engineCommandManager = engineCommandManager | ||||
|  | ||||
|     // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|     this.ensureWasmInit().then(() => { | ||||
|       this.ast = this.safeParse(codeManager.code) || this.ast | ||||
|     this.ensureWasmInit().then(async () => { | ||||
|       await this.safeParse(codeManager.code).then((ast) => { | ||||
|         if (ast) { | ||||
|           this.ast = ast | ||||
|         } | ||||
|       }) | ||||
|     }) | ||||
|   } | ||||
|  | ||||
| @ -211,7 +221,25 @@ export class KclManager { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   safeParse(code: string): Node<Program> | null { | ||||
|   // (jess) I'm not in love with this, but it ensures we clear the scene and | ||||
|   // bust the cache on | ||||
|   // errors from parsing when opening new files. | ||||
|   // Why not just clear the cache on all parse errors, you ask? well its actually | ||||
|   // really nice to keep the cache on parse errors within the same file, and | ||||
|   // only bust on engine errors esp if they take a long time to execute and | ||||
|   // you hit the wrong key! | ||||
|   private async checkIfSwitchedFilesShouldClear() { | ||||
|     // If we were switching files and we hit an error on parse we need to bust | ||||
|     // the cache and clear the scene. | ||||
|     if (this._hasErrors && this._switchedFiles) { | ||||
|       await clearSceneAndBustCache(this.engineCommandManager) | ||||
|     } else if (this._switchedFiles) { | ||||
|       // Reset the switched files boolean. | ||||
|       this._switchedFiles = false | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async safeParse(code: string): Promise<Node<Program> | null> { | ||||
|     const result = parse(code) | ||||
|     this.diagnostics = [] | ||||
|     this._hasErrors = false | ||||
| @ -220,6 +248,8 @@ export class KclManager { | ||||
|       const kclerror: KCLError = result as KCLError | ||||
|       this.diagnostics = kclErrorsToDiagnostics([kclerror]) | ||||
|       this._hasErrors = true | ||||
|  | ||||
|       await this.checkIfSwitchedFilesShouldClear() | ||||
|       return null | ||||
|     } | ||||
|  | ||||
| @ -228,6 +258,7 @@ export class KclManager { | ||||
|     if (result.errors.length > 0) { | ||||
|       this._hasErrors = true | ||||
|  | ||||
|       await this.checkIfSwitchedFilesShouldClear() | ||||
|       return null | ||||
|     } | ||||
|  | ||||
| @ -353,7 +384,7 @@ export class KclManager { | ||||
|       console.error(newCode) | ||||
|       return | ||||
|     } | ||||
|     const newAst = this.safeParse(newCode) | ||||
|     const newAst = await this.safeParse(newCode) | ||||
|     if (!newAst) { | ||||
|       this.clearAst() | ||||
|       return | ||||
| @ -408,7 +439,7 @@ export class KclManager { | ||||
|     }) | ||||
|   } | ||||
|   async executeCode(zoomToFit?: boolean): Promise<void> { | ||||
|     const ast = this.safeParse(codeManager.code) | ||||
|     const ast = await this.safeParse(codeManager.code) | ||||
|     if (!ast) { | ||||
|       this.clearAst() | ||||
|       return | ||||
| @ -416,9 +447,9 @@ export class KclManager { | ||||
|     this.ast = { ...ast } | ||||
|     return this.executeAst({ zoomToFit }) | ||||
|   } | ||||
|   format() { | ||||
|   async format() { | ||||
|     const originalCode = codeManager.code | ||||
|     const ast = this.safeParse(originalCode) | ||||
|     const ast = await this.safeParse(originalCode) | ||||
|     if (!ast) { | ||||
|       this.clearAst() | ||||
|       return | ||||
| @ -458,7 +489,7 @@ export class KclManager { | ||||
|     const newCode = recast(ast) | ||||
|     if (err(newCode)) return Promise.reject(newCode) | ||||
|  | ||||
|     const astWithUpdatedSource = this.safeParse(newCode) | ||||
|     const astWithUpdatedSource = await this.safeParse(newCode) | ||||
|     if (!astWithUpdatedSource) return Promise.reject(new Error('bad ast')) | ||||
|     let returnVal: Selections | undefined = undefined | ||||
|  | ||||
|  | ||||
| @ -45,6 +45,7 @@ import { TagDeclarator } from 'wasm-lib/kcl/bindings/TagDeclarator' | ||||
| import { Models } from '@kittycad/lib' | ||||
| import { ExtrudeFacePlane } from 'machines/modelingMachine' | ||||
| import { Node } from 'wasm-lib/kcl/bindings/Node' | ||||
| import { KclExpressionWithVariable } from 'lib/commandTypes' | ||||
|  | ||||
| export function startSketchOnDefault( | ||||
|   node: Node<Program>, | ||||
| @ -590,6 +591,25 @@ export function addOffsetPlane({ | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Return a modified clone of an AST with a named constant inserted into the body | ||||
|  */ | ||||
| export function insertNamedConstant({ | ||||
|   node, | ||||
|   newExpression, | ||||
| }: { | ||||
|   node: Node<Program> | ||||
|   newExpression: KclExpressionWithVariable | ||||
| }): Node<Program> { | ||||
|   const ast = structuredClone(node) | ||||
|   ast.body.splice( | ||||
|     newExpression.insertIndex, | ||||
|     0, | ||||
|     newExpression.variableDeclarationAst | ||||
|   ) | ||||
|   return ast | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Modify the AST to create a new sketch using the variable declaration | ||||
|  * of an offset plane. The new sketch just has to come after the offset | ||||
| @ -933,6 +953,31 @@ export function giveSketchFnCallTag( | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Replace a | ||||
|  */ | ||||
| export function replaceValueAtNodePath({ | ||||
|   ast, | ||||
|   pathToNode, | ||||
|   newExpressionString, | ||||
| }: { | ||||
|   ast: Node<Program> | ||||
|   pathToNode: PathToNode | ||||
|   newExpressionString: string | ||||
| }) { | ||||
|   const replaceCheckResult = isNodeSafeToReplacePath(ast, pathToNode) | ||||
|   if (err(replaceCheckResult)) { | ||||
|     return replaceCheckResult | ||||
|   } | ||||
|   const { isSafe, value, replacer } = replaceCheckResult | ||||
|  | ||||
|   if (!isSafe || value.type === 'Identifier') { | ||||
|     return new Error('Not safe to replace') | ||||
|   } | ||||
|  | ||||
|   return replacer(ast, newExpressionString) | ||||
| } | ||||
|  | ||||
| export function moveValueIntoNewVariablePath( | ||||
|   ast: Node<Program>, | ||||
|   programMemory: ProgramMemory, | ||||
|  | ||||
| @ -22,7 +22,7 @@ import { | ||||
| import { getNodeFromPath, getNodePathFromSourceRange } from '../queryAst' | ||||
| import { createLiteral } from 'lang/modifyAst' | ||||
| import { err } from 'lib/trap' | ||||
| import { Selections } from 'lib/selections' | ||||
| import { Selection, Selections } from 'lib/selections' | ||||
| import { engineCommandManager, kclManager } from 'lib/singletons' | ||||
| import { VITE_KC_DEV_TOKEN } from 'env' | ||||
| import { isOverlap } from 'lib/utils' | ||||
| @ -40,7 +40,6 @@ beforeAll(async () => { | ||||
|       makeDefaultPlanes: () => makeDefaultPlanes(engineCommandManager), | ||||
|       setMediaStream: () => {}, | ||||
|       setIsStreamReady: () => {}, | ||||
|       modifyGrid: async () => {}, | ||||
|       callbackOnEngineLiteConnect: () => { | ||||
|         resolve(true) | ||||
|       }, | ||||
| @ -118,13 +117,8 @@ const runGetPathToExtrudeForSegmentSelectionTest = async ( | ||||
|     code.indexOf(selectedSegmentSnippet) + selectedSegmentSnippet.length, | ||||
|     true, | ||||
|   ] | ||||
|   const selection: Selections = { | ||||
|     graphSelections: [ | ||||
|       { | ||||
|         codeRef: codeRefFromRange(segmentRange, ast), | ||||
|       }, | ||||
|     ], | ||||
|     otherSelections: [], | ||||
|   const selection: Selection = { | ||||
|     codeRef: codeRefFromRange(segmentRange, ast), | ||||
|   } | ||||
|  | ||||
|   // executeAst and artifactGraph | ||||
|  | ||||
| @ -29,7 +29,7 @@ import { | ||||
|   sketchLineHelperMap, | ||||
| } from '../std/sketch' | ||||
| import { err, trap } from 'lib/trap' | ||||
| import { Selections } from 'lib/selections' | ||||
| import { Selection, Selections } from 'lib/selections' | ||||
| import { KclCommandValue } from 'lib/commandTypes' | ||||
| import { | ||||
|   Artifact, | ||||
| @ -99,14 +99,9 @@ export function modifyAstWithEdgeTreatmentAndTag( | ||||
|   const lookupMap: Map<string, PathToNode> = new Map() // work around for Map key comparison | ||||
|  | ||||
|   for (const selection of selections.graphSelections) { | ||||
|     const singleSelection = { | ||||
|       graphSelections: [selection], | ||||
|       otherSelections: [], | ||||
|     } | ||||
|  | ||||
|     const result = getPathToExtrudeForSegmentSelection( | ||||
|       clonedAstForGetExtrude, | ||||
|       singleSelection, | ||||
|       selection, | ||||
|       artifactGraph | ||||
|     ) | ||||
|     if (err(result)) return result | ||||
| @ -259,12 +254,12 @@ function insertParametersIntoAst( | ||||
|  | ||||
| export function getPathToExtrudeForSegmentSelection( | ||||
|   ast: Program, | ||||
|   selection: Selections, | ||||
|   selection: Selection, | ||||
|   artifactGraph: ArtifactGraph | ||||
| ): { pathToSegmentNode: PathToNode; pathToExtrudeNode: PathToNode } | Error { | ||||
|   const pathToSegmentNode = getNodePathFromSourceRange( | ||||
|     ast, | ||||
|     selection.graphSelections[0]?.codeRef?.range | ||||
|     selection.codeRef?.range | ||||
|   ) | ||||
|  | ||||
|   const varDecNode = getNodeFromPath<VariableDeclaration>( | ||||
| @ -308,7 +303,7 @@ async function updateAstAndFocus( | ||||
|   } | ||||
| } | ||||
|  | ||||
| function mutateAstWithTagForSketchSegment( | ||||
| export function mutateAstWithTagForSketchSegment( | ||||
|   astClone: Node<Program>, | ||||
|   pathToSegmentNode: PathToNode | ||||
| ): { modifiedAst: Program; tag: string } | Error { | ||||
| @ -340,7 +335,7 @@ function mutateAstWithTagForSketchSegment( | ||||
|   return { modifiedAst: astClone, tag } | ||||
| } | ||||
|  | ||||
| function getEdgeTagCall( | ||||
| export function getEdgeTagCall( | ||||
|   tag: string, | ||||
|   artifact: Artifact | ||||
| ): Node<Identifier | CallExpression> { | ||||
|  | ||||
							
								
								
									
										154
									
								
								src/lang/modifyAst/addRevolve.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,154 @@ | ||||
| import { err } from 'lib/trap' | ||||
| import { KCL_DEFAULT_CONSTANT_PREFIXES } from 'lib/constants' | ||||
| import { | ||||
|   Program, | ||||
|   PathToNode, | ||||
|   Expr, | ||||
|   CallExpression, | ||||
|   PipeExpression, | ||||
|   VariableDeclarator, | ||||
| } from 'lang/wasm' | ||||
| import { Selections } from 'lib/selections' | ||||
| import { Node } from 'wasm-lib/kcl/bindings/Node' | ||||
| import { | ||||
|   createLiteral, | ||||
|   createCallExpressionStdLib, | ||||
|   createObjectExpression, | ||||
|   createIdentifier, | ||||
|   createPipeExpression, | ||||
|   findUniqueName, | ||||
|   createVariableDeclaration, | ||||
| } from 'lang/modifyAst' | ||||
| import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst' | ||||
| import { | ||||
|   mutateAstWithTagForSketchSegment, | ||||
|   getEdgeTagCall, | ||||
| } from 'lang/modifyAst/addEdgeTreatment' | ||||
| export function revolveSketch( | ||||
|   ast: Node<Program>, | ||||
|   pathToSketchNode: PathToNode, | ||||
|   shouldPipe = false, | ||||
|   angle: Expr = createLiteral(4), | ||||
|   axis: Selections | ||||
| ): | ||||
|   | { | ||||
|       modifiedAst: Node<Program> | ||||
|       pathToSketchNode: PathToNode | ||||
|       pathToRevolveArg: PathToNode | ||||
|     } | ||||
|   | Error { | ||||
|   const clonedAst = structuredClone(ast) | ||||
|   const sketchNode = getNodeFromPath(clonedAst, pathToSketchNode) | ||||
|   if (err(sketchNode)) return sketchNode | ||||
|  | ||||
|   // testing code | ||||
|   const pathToAxisSelection = getNodePathFromSourceRange( | ||||
|     clonedAst, | ||||
|     axis.graphSelections[0]?.codeRef.range | ||||
|   ) | ||||
|  | ||||
|   const lineNode = getNodeFromPath<CallExpression>( | ||||
|     clonedAst, | ||||
|     pathToAxisSelection, | ||||
|     'CallExpression' | ||||
|   ) | ||||
|   if (err(lineNode)) return lineNode | ||||
|  | ||||
|   // TODO Kevin: What if |> close(%)? | ||||
|   // TODO Kevin: What if opposite edge | ||||
|   // TODO Kevin: What if the edge isn't planar to the sketch? | ||||
|   // TODO Kevin: add a tag. | ||||
|   const tagResult = mutateAstWithTagForSketchSegment( | ||||
|     clonedAst, | ||||
|     pathToAxisSelection | ||||
|   ) | ||||
|  | ||||
|   // Have the tag whether it is already created or a new one is generated | ||||
|   if (err(tagResult)) return tagResult | ||||
|   const { tag } = tagResult | ||||
|  | ||||
|   /* Original Code */ | ||||
|   const { node: sketchExpression } = sketchNode | ||||
|  | ||||
|   // determine if sketchExpression is in a pipeExpression or not | ||||
|   const sketchPipeExpressionNode = getNodeFromPath<PipeExpression>( | ||||
|     clonedAst, | ||||
|     pathToSketchNode, | ||||
|     'PipeExpression' | ||||
|   ) | ||||
|   if (err(sketchPipeExpressionNode)) return sketchPipeExpressionNode | ||||
|   const { node: sketchPipeExpression } = sketchPipeExpressionNode | ||||
|   const isInPipeExpression = sketchPipeExpression.type === 'PipeExpression' | ||||
|  | ||||
|   const sketchVariableDeclaratorNode = getNodeFromPath<VariableDeclarator>( | ||||
|     clonedAst, | ||||
|     pathToSketchNode, | ||||
|     'VariableDeclarator' | ||||
|   ) | ||||
|   if (err(sketchVariableDeclaratorNode)) return sketchVariableDeclaratorNode | ||||
|   const { | ||||
|     node: sketchVariableDeclarator, | ||||
|     shallowPath: sketchPathToDecleration, | ||||
|   } = sketchVariableDeclaratorNode | ||||
|  | ||||
|   const axisSelection = axis?.graphSelections[0]?.artifact | ||||
|  | ||||
|   if (!axisSelection) return new Error('Axis selection is missing.') | ||||
|  | ||||
|   const revolveCall = createCallExpressionStdLib('revolve', [ | ||||
|     createObjectExpression({ | ||||
|       angle: angle, | ||||
|       axis: getEdgeTagCall(tag, axisSelection), | ||||
|     }), | ||||
|     createIdentifier(sketchVariableDeclarator.id.name), | ||||
|   ]) | ||||
|  | ||||
|   if (shouldPipe) { | ||||
|     const pipeChain = createPipeExpression( | ||||
|       isInPipeExpression | ||||
|         ? [...sketchPipeExpression.body, revolveCall] | ||||
|         : [sketchExpression as any, revolveCall] | ||||
|     ) | ||||
|  | ||||
|     sketchVariableDeclarator.init = pipeChain | ||||
|     const pathToRevolveArg: PathToNode = [ | ||||
|       ...sketchPathToDecleration, | ||||
|       ['init', 'VariableDeclarator'], | ||||
|       ['body', ''], | ||||
|       [pipeChain.body.length - 1, 'index'], | ||||
|       ['arguments', 'CallExpression'], | ||||
|       [0, 'index'], | ||||
|     ] | ||||
|  | ||||
|     return { | ||||
|       modifiedAst: clonedAst, | ||||
|       pathToSketchNode, | ||||
|       pathToRevolveArg, | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // We're not creating a pipe expression, | ||||
|   // but rather a separate constant for the extrusion | ||||
|   const name = findUniqueName(clonedAst, KCL_DEFAULT_CONSTANT_PREFIXES.REVOLVE) | ||||
|   const VariableDeclaration = createVariableDeclaration(name, revolveCall) | ||||
|   const sketchIndexInPathToNode = | ||||
|     sketchPathToDecleration.findIndex((a) => a[0] === 'body') + 1 | ||||
|   const sketchIndexInBody = sketchPathToDecleration[sketchIndexInPathToNode][0] | ||||
|   if (typeof sketchIndexInBody !== 'number') | ||||
|     return new Error('expected sketchIndexInBody to be a number') | ||||
|   clonedAst.body.splice(sketchIndexInBody + 1, 0, VariableDeclaration) | ||||
|  | ||||
|   const pathToRevolveArg: PathToNode = [ | ||||
|     ['body', ''], | ||||
|     [sketchIndexInBody + 1, 'index'], | ||||
|     ['declaration', 'VariableDeclaration'], | ||||
|     ['init', 'VariableDeclarator'], | ||||
|     ['arguments', 'CallExpression'], | ||||
|     [0, 'index'], | ||||
|   ] | ||||
|   return { | ||||
|     modifiedAst: clonedAst, | ||||
|     pathToSketchNode: [...pathToSketchNode.slice(0, -1), [-1, 'index']], | ||||
|     pathToRevolveArg, | ||||
|   } | ||||
| } | ||||
							
								
								
									
										123
									
								
								src/lang/modifyAst/addShell.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,123 @@ | ||||
| import { ArtifactGraph } from 'lang/std/artifactGraph' | ||||
| import { Selections } from 'lib/selections' | ||||
| import { Expr } from 'wasm-lib/kcl/bindings/Expr' | ||||
| import { Program } from 'wasm-lib/kcl/bindings/Program' | ||||
| import { Node } from 'wasm-lib/kcl/bindings/Node' | ||||
| import { PathToNode, VariableDeclarator } from 'lang/wasm' | ||||
| import { | ||||
|   getPathToExtrudeForSegmentSelection, | ||||
|   mutateAstWithTagForSketchSegment, | ||||
| } from './addEdgeTreatment' | ||||
| import { getNodeFromPath } from 'lang/queryAst' | ||||
| import { err } from 'lib/trap' | ||||
| import { | ||||
|   createLiteral, | ||||
|   createIdentifier, | ||||
|   findUniqueName, | ||||
|   createCallExpressionStdLib, | ||||
|   createObjectExpression, | ||||
|   createArrayExpression, | ||||
|   createVariableDeclaration, | ||||
| } from 'lang/modifyAst' | ||||
| import { KCL_DEFAULT_CONSTANT_PREFIXES } from 'lib/constants' | ||||
|  | ||||
| export function addShell({ | ||||
|   node, | ||||
|   selection, | ||||
|   artifactGraph, | ||||
|   thickness, | ||||
| }: { | ||||
|   node: Node<Program> | ||||
|   selection: Selections | ||||
|   artifactGraph: ArtifactGraph | ||||
|   thickness: Expr | ||||
| }): Error | { modifiedAst: Node<Program>; pathToNode: PathToNode } { | ||||
|   const modifiedAst = structuredClone(node) | ||||
|  | ||||
|   // Look up the corresponding extrude | ||||
|   const clonedAstForGetExtrude = structuredClone(modifiedAst) | ||||
|  | ||||
|   const expressions: Expr[] = [] | ||||
|   let pathToExtrudeNode: PathToNode | undefined = undefined | ||||
|   for (const graphSelection of selection.graphSelections) { | ||||
|     const extrudeLookupResult = getPathToExtrudeForSegmentSelection( | ||||
|       clonedAstForGetExtrude, | ||||
|       graphSelection, | ||||
|       artifactGraph | ||||
|     ) | ||||
|     if (err(extrudeLookupResult)) { | ||||
|       return new Error("Couldn't find extrude") | ||||
|     } | ||||
|  | ||||
|     pathToExtrudeNode = extrudeLookupResult.pathToExtrudeNode | ||||
|     // Get the sketch ref from the selection | ||||
|     // TODO: this assumes the segment is piped directly from the sketch, with no intermediate `VariableDeclarator` between. | ||||
|     // We must find a technique for these situations that is robust to intermediate declarations | ||||
|     const sketchNode = getNodeFromPath<VariableDeclarator>( | ||||
|       modifiedAst, | ||||
|       graphSelection.codeRef.pathToNode, | ||||
|       'VariableDeclarator' | ||||
|     ) | ||||
|     if (err(sketchNode)) { | ||||
|       return sketchNode | ||||
|     } | ||||
|  | ||||
|     const selectedArtifact = graphSelection.artifact | ||||
|     if (!selectedArtifact) { | ||||
|       return new Error('Bad artifact') | ||||
|     } | ||||
|  | ||||
|     // Check on the selection, and handle the wall vs cap casees | ||||
|     let expr: Expr | ||||
|     if (selectedArtifact.type === 'cap') { | ||||
|       expr = createLiteral(selectedArtifact.subType) | ||||
|     } else if (selectedArtifact.type === 'wall') { | ||||
|       const tagResult = mutateAstWithTagForSketchSegment( | ||||
|         modifiedAst, | ||||
|         extrudeLookupResult.pathToSegmentNode | ||||
|       ) | ||||
|       if (err(tagResult)) return tagResult | ||||
|       const { tag } = tagResult | ||||
|       expr = createIdentifier(tag) | ||||
|     } else { | ||||
|       continue | ||||
|     } | ||||
|     expressions.push(expr) | ||||
|   } | ||||
|  | ||||
|   if (!pathToExtrudeNode) return new Error('No extrude found') | ||||
|  | ||||
|   const extrudeNode = getNodeFromPath<VariableDeclarator>( | ||||
|     modifiedAst, | ||||
|     pathToExtrudeNode, | ||||
|     'VariableDeclarator' | ||||
|   ) | ||||
|   if (err(extrudeNode)) { | ||||
|     return extrudeNode | ||||
|   } | ||||
|  | ||||
|   const name = findUniqueName(node, KCL_DEFAULT_CONSTANT_PREFIXES.SHELL) | ||||
|   const shell = createCallExpressionStdLib('shell', [ | ||||
|     createObjectExpression({ | ||||
|       faces: createArrayExpression(expressions), | ||||
|       thickness, | ||||
|     }), | ||||
|     createIdentifier(extrudeNode.node.id.name), | ||||
|   ]) | ||||
|   const declaration = createVariableDeclaration(name, shell) | ||||
|  | ||||
|   // TODO: check if we should append at the end like here or right after the extrude | ||||
|   modifiedAst.body.push(declaration) | ||||
|   const pathToNode: PathToNode = [ | ||||
|     ['body', ''], | ||||
|     [modifiedAst.body.length - 1, 'index'], | ||||
|     ['declaration', 'VariableDeclaration'], | ||||
|     ['init', 'VariableDeclarator'], | ||||
|     ['arguments', 'CallExpression'], | ||||
|     [0, 'index'], | ||||
|   ] | ||||
|   return { | ||||
|     modifiedAst, | ||||
|     pathToNode, | ||||
|   } | ||||
| } | ||||
| @ -17,6 +17,7 @@ import { | ||||
|   doesSceneHaveSweepableSketch, | ||||
|   traverse, | ||||
|   getNodeFromPath, | ||||
|   doesSceneHaveExtrudedSketch, | ||||
| } from './queryAst' | ||||
| import { enginelessExecutor } from '../lib/testHelpers' | ||||
| import { | ||||
| @ -654,6 +655,38 @@ extrude001 = extrude(10, sketch001) | ||||
|   }) | ||||
| }) | ||||
|  | ||||
| describe('Testing doesSceneHaveExtrudedSketch', () => { | ||||
|   it('finds extruded sketch as variable', async () => { | ||||
|     const exampleCode = `sketch001 = startSketchOn('XZ') | ||||
|   |> circle({ center = [0, 0], radius = 1 }, %) | ||||
| extrude001 = extrude(1, sketch001) | ||||
| ` | ||||
|     const ast = assertParse(exampleCode) | ||||
|     if (err(ast)) throw ast | ||||
|     const extrudable = doesSceneHaveExtrudedSketch(ast) | ||||
|     expect(extrudable).toBeTruthy() | ||||
|   }) | ||||
|   it('finds extruded sketch in pipe', async () => { | ||||
|     const exampleCode = `extrude001 = startSketchOn('XZ') | ||||
|   |> circle({ center = [0, 0], radius = 1 }, %) | ||||
|   |> extrude(1, %) | ||||
| ` | ||||
|     const ast = assertParse(exampleCode) | ||||
|     if (err(ast)) throw ast | ||||
|     const extrudable = doesSceneHaveExtrudedSketch(ast) | ||||
|     expect(extrudable).toBeTruthy() | ||||
|   }) | ||||
|   it('finds no extrusion with sketch only', async () => { | ||||
|     const exampleCode = `extrude001 = startSketchOn('XZ') | ||||
|   |> circle({ center = [0, 0], radius = 1 }, %) | ||||
| ` | ||||
|     const ast = assertParse(exampleCode) | ||||
|     if (err(ast)) throw ast | ||||
|     const extrudable = doesSceneHaveExtrudedSketch(ast) | ||||
|     expect(extrudable).toBeFalsy() | ||||
|   }) | ||||
| }) | ||||
|  | ||||
| describe('Testing traverse and pathToNode', () => { | ||||
|   it.each([ | ||||
|     ['basic', '2.73'], | ||||
|  | ||||
| @ -1064,6 +1064,35 @@ export function doesSceneHaveSweepableSketch(ast: Node<Program>, count = 1) { | ||||
|   return Object.keys(theMap).length >= count | ||||
| } | ||||
|  | ||||
| export function doesSceneHaveExtrudedSketch(ast: Node<Program>) { | ||||
|   const theMap: any = {} | ||||
|   traverse(ast as any, { | ||||
|     enter(node) { | ||||
|       if ( | ||||
|         node.type === 'VariableDeclarator' && | ||||
|         node.init?.type === 'PipeExpression' | ||||
|       ) { | ||||
|         for (const pipe of node.init.body) { | ||||
|           if ( | ||||
|             pipe.type === 'CallExpression' && | ||||
|             pipe.callee.name === 'extrude' | ||||
|           ) { | ||||
|             theMap[node.id.name] = true | ||||
|             break | ||||
|           } | ||||
|         } | ||||
|       } else if ( | ||||
|         node.type === 'CallExpression' && | ||||
|         node.callee.name === 'extrude' && | ||||
|         node.arguments[1]?.type === 'Identifier' | ||||
|       ) { | ||||
|         theMap[node.moduleId] = true | ||||
|       } | ||||
|     }, | ||||
|   }) | ||||
|   return Object.keys(theMap).length > 0 | ||||
| } | ||||
|  | ||||
| export function getObjExprProperty( | ||||
|   node: ObjectExpression, | ||||
|   propName: string | ||||
|  | ||||
| @ -139,7 +139,6 @@ beforeAll(async () => { | ||||
|       makeDefaultPlanes: () => makeDefaultPlanes(engineCommandManager), | ||||
|       setMediaStream: () => {}, | ||||
|       setIsStreamReady: () => {}, | ||||
|       modifyGrid: async () => {}, | ||||
|       // eslint-disable-next-line @typescript-eslint/no-misused-promises | ||||
|       callbackOnEngineLiteConnect: async () => { | ||||
|         const cacheEntries = Object.entries(codeToWriteCacheFor) as [ | ||||
|  | ||||
| @ -1399,7 +1399,6 @@ export class EngineCommandManager extends EventTarget { | ||||
|   } | ||||
|  | ||||
|   private makeDefaultPlanes: () => Promise<DefaultPlanes> | null = () => null | ||||
|   private modifyGrid: (hidden: boolean) => Promise<void> | null = () => null | ||||
|  | ||||
|   private onEngineConnectionOpened = () => {} | ||||
|   private onEngineConnectionClosed = () => {} | ||||
| @ -1432,7 +1431,6 @@ export class EngineCommandManager extends EventTarget { | ||||
|     height, | ||||
|     token, | ||||
|     makeDefaultPlanes, | ||||
|     modifyGrid, | ||||
|     settings = { | ||||
|       pool: null, | ||||
|       theme: Themes.Dark, | ||||
| @ -1452,14 +1450,12 @@ export class EngineCommandManager extends EventTarget { | ||||
|     height: number | ||||
|     token?: string | ||||
|     makeDefaultPlanes: () => Promise<DefaultPlanes> | ||||
|     modifyGrid: (hidden: boolean) => Promise<void> | ||||
|     settings?: SettingsViaQueryString | ||||
|   }) { | ||||
|     if (settings) { | ||||
|       this.settings = settings | ||||
|     } | ||||
|     this.makeDefaultPlanes = makeDefaultPlanes | ||||
|     this.modifyGrid = modifyGrid | ||||
|     if (width === 0 || height === 0) { | ||||
|       return | ||||
|     } | ||||
| @ -1539,21 +1535,15 @@ export class EngineCommandManager extends EventTarget { | ||||
|           type: 'default_camera_get_settings', | ||||
|         }, | ||||
|       }) | ||||
|       // We want modify the grid first because we don't want it to flash. | ||||
|       // Ideally these would already be default hidden in engine (TODO do | ||||
|       // that) https://github.com/KittyCAD/engine/issues/2282 | ||||
|       // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|       this.modifyGrid(!this.settings.showScaleGrid)?.then(async () => { | ||||
|         await this.initPlanes() | ||||
|         setIsStreamReady(true) | ||||
|       await this.initPlanes() | ||||
|       setIsStreamReady(true) | ||||
|  | ||||
|         // Other parts of the application should use this to react on scene ready. | ||||
|         this.dispatchEvent( | ||||
|           new CustomEvent(EngineCommandManagerEvents.SceneReady, { | ||||
|             detail: this.engineConnection, | ||||
|           }) | ||||
|         ) | ||||
|       }) | ||||
|       // Other parts of the application should use this to react on scene ready. | ||||
|       this.dispatchEvent( | ||||
|         new CustomEvent(EngineCommandManagerEvents.SceneReady, { | ||||
|           detail: this.engineConnection, | ||||
|         }) | ||||
|       ) | ||||
|     } | ||||
|  | ||||
|     this.engineConnection.addEventListener( | ||||
| @ -1879,17 +1869,6 @@ export class EngineCommandManager extends EventTarget { | ||||
|     } | ||||
|     return JSON.stringify(this.defaultPlanes) | ||||
|   } | ||||
|   clearScene(): void { | ||||
|     const deleteCmd: EngineCommand = { | ||||
|       type: 'modeling_cmd_req', | ||||
|       cmd_id: uuidv4(), | ||||
|       cmd: { | ||||
|         type: 'scene_clear_all', | ||||
|       }, | ||||
|     } | ||||
|     this.clearDefaultPlanes() | ||||
|     this.engineConnection?.send(deleteCmd) | ||||
|   } | ||||
|   addCommandLog(message: CommandLog) { | ||||
|     if (this.commandLogs.length > 500) { | ||||
|       this.commandLogs.shift() | ||||
| @ -2223,15 +2202,6 @@ export class EngineCommandManager extends EventTarget { | ||||
|     }).catch(reportRejection) | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Set the visibility of the scale grid in the engine scene. | ||||
|    * @param visible - whether to show or hide the scale grid | ||||
|    */ | ||||
|   setScaleGridVisibility(visible: boolean) { | ||||
|     // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|     this.modifyGrid(!visible) | ||||
|   } | ||||
|  | ||||
|   // Some "objects" have the same source range, such as sketch_mode_start and start_path. | ||||
|   // So when passing a range, we need to also specify the command type | ||||
|   mapRangeToObjectId( | ||||
|  | ||||
| @ -1,9 +1,13 @@ | ||||
| import { err } from 'lib/trap' | ||||
| import { parse, ParseResult } from './wasm' | ||||
| import { initPromise, parse, ParseResult } from './wasm' | ||||
| import { enginelessExecutor } from 'lib/testHelpers' | ||||
| import { Node } from 'wasm-lib/kcl/bindings/Node' | ||||
| import { Program } from '../wasm-lib/kcl/bindings/Program' | ||||
|  | ||||
| beforeEach(async () => { | ||||
|   await initPromise | ||||
| }) | ||||
|  | ||||
| it('can execute parsed AST', async () => { | ||||
|   const code = `x = 1 | ||||
| // A comment.` | ||||
|  | ||||
| @ -1,14 +1,13 @@ | ||||
| import init, { | ||||
|   parse_wasm, | ||||
|   recast_wasm, | ||||
|   execute_wasm, | ||||
|   execute, | ||||
|   kcl_lint, | ||||
|   modify_ast_for_sketch_wasm, | ||||
|   is_points_ccw, | ||||
|   get_tangential_arc_to_info, | ||||
|   program_memory_init, | ||||
|   make_default_planes, | ||||
|   modify_grid, | ||||
|   coredump, | ||||
|   toml_stringify, | ||||
|   default_app_settings, | ||||
| @ -16,6 +15,7 @@ import init, { | ||||
|   parse_project_settings, | ||||
|   default_project_settings, | ||||
|   base64_decode, | ||||
|   clear_scene_and_bust_cache, | ||||
| } from '../wasm-lib/pkg/wasm_lib' | ||||
| import { KCLError } from './errors' | ||||
| import { KclError as RustKclError } from '../wasm-lib/kcl/bindings/KclError' | ||||
| @ -42,7 +42,9 @@ import { Environment } from '../wasm-lib/kcl/bindings/Environment' | ||||
| import { Node } from 'wasm-lib/kcl/bindings/Node' | ||||
| import { CompilationError } from 'wasm-lib/kcl/bindings/CompilationError' | ||||
| import { SourceRange as RustSourceRange } from 'wasm-lib/kcl/bindings/SourceRange' | ||||
| import { getAllCurrentSettings } from 'lib/settings/settingsUtils' | ||||
|  | ||||
| export type { Configuration } from 'wasm-lib/kcl/bindings/Configuration' | ||||
| export type { Program } from '../wasm-lib/kcl/bindings/Program' | ||||
| export type { Expr } from '../wasm-lib/kcl/bindings/Expr' | ||||
| export type { ObjectExpression } from '../wasm-lib/kcl/bindings/ObjectExpression' | ||||
| @ -91,12 +93,26 @@ export type { Solid } from '../wasm-lib/kcl/bindings/Solid' | ||||
| export type { KclValue } from '../wasm-lib/kcl/bindings/KclValue' | ||||
| export type { ExtrudeSurface } from '../wasm-lib/kcl/bindings/ExtrudeSurface' | ||||
|  | ||||
| /** | ||||
|  * The first two items are the start and end points (byte offsets from the start of the file). | ||||
|  * The third item is whether the source range belongs to the 'main' file, i.e., the file currently | ||||
|  * being rendered/displayed in the editor (TODO we need to handle modules better in the frontend). | ||||
|  */ | ||||
| export type SourceRange = [number, number, boolean] | ||||
|  | ||||
| /** | ||||
|  * Convert a SourceRange as used inside the KCL interpreter into the above one for use in the | ||||
|  * frontend (essentially we're eagerly checking whether the frontend should care about the SourceRange | ||||
|  * so as not to expose details of the interpreter's current representation of module ids throughout | ||||
|  * the frontend). | ||||
|  */ | ||||
| export function sourceRangeFromRust(s: RustSourceRange): SourceRange { | ||||
|   return [s[0], s[1], s[2] === 0] | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Create a default SourceRange for testing or as a placeholder. | ||||
|  */ | ||||
| export function defaultSourceRange(): SourceRange { | ||||
|   return [0, 0, true] | ||||
| } | ||||
| @ -121,7 +137,7 @@ const initialise = async () => { | ||||
|     const fullUrl = wasmUrl() | ||||
|     const input = await fetch(fullUrl) | ||||
|     const buffer = await input.arrayBuffer() | ||||
|     return await init(buffer) | ||||
|     return await init({ module_or_path: buffer }) | ||||
|   } catch (e) { | ||||
|     console.log('Error initialising WASM', e) | ||||
|     return Promise.reject(e) | ||||
| @ -162,6 +178,10 @@ export class ParseResult { | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Parsing was successful. There is guaranteed to be an AST and no fatal errors. There may or may | ||||
|  * not be warnings or non-fatal errors. | ||||
|  */ | ||||
| class SuccessParseResult extends ParseResult { | ||||
|   program: Node<Program> | ||||
|  | ||||
| @ -492,18 +512,19 @@ export const _executor = async ( | ||||
|     return Promise.reject(programMemoryOverride) | ||||
|  | ||||
|   try { | ||||
|     let baseUnit = 'mm' | ||||
|     let jsAppSettings = default_app_settings() | ||||
|     if (!TEST) { | ||||
|       const getSettingsState = import('components/SettingsAuthProvider').then( | ||||
|         (module) => module.getSettingsState | ||||
|       ) | ||||
|       baseUnit = | ||||
|         (await getSettingsState)()?.modeling.defaultUnit.current || 'mm' | ||||
|       const lastSettingsSnapshot = await import( | ||||
|         'components/SettingsAuthProvider' | ||||
|       ).then((module) => module.lastSettingsContextSnapshot) | ||||
|       if (lastSettingsSnapshot) { | ||||
|         jsAppSettings = getAllCurrentSettings(lastSettingsSnapshot) | ||||
|       } | ||||
|     } | ||||
|     const execState: RawExecState = await execute_wasm( | ||||
|     const execState: RawExecState = await execute( | ||||
|       JSON.stringify(node), | ||||
|       JSON.stringify(programMemoryOverride?.toRaw() || null), | ||||
|       baseUnit, | ||||
|       JSON.stringify({ settings: jsAppSettings }), | ||||
|       engineCommandManager, | ||||
|       fileSystemManager | ||||
|     ) | ||||
| @ -551,20 +572,6 @@ export const makeDefaultPlanes = async ( | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const modifyGrid = async ( | ||||
|   engineCommandManager: EngineCommandManager, | ||||
|   hidden: boolean | ||||
| ): Promise<void> => { | ||||
|   try { | ||||
|     await modify_grid(engineCommandManager, hidden) | ||||
|     return | ||||
|   } catch (e) { | ||||
|     // TODO: do something real with the error. | ||||
|     console.log('modify grid error', e) | ||||
|     return Promise.reject(e) | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const modifyAstForSketch = async ( | ||||
|   engineCommandManager: EngineCommandManager, | ||||
|   ast: Node<Program>, | ||||
| @ -698,6 +705,21 @@ export function defaultAppSettings(): DeepPartial<Configuration> | Error { | ||||
|   return default_app_settings() | ||||
| } | ||||
|  | ||||
| export async function clearSceneAndBustCache( | ||||
|   engineCommandManager: EngineCommandManager | ||||
| ): Promise<null | Error> { | ||||
|   try { | ||||
|     await clear_scene_and_bust_cache(engineCommandManager) | ||||
|   } catch (e: any) { | ||||
|     console.error('clear_scene_and_bust_cache: error', e) | ||||
|     return Promise.reject( | ||||
|       new Error(`Error on clear_scene_and_bust_cache: ${e}`) | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   return null | ||||
| } | ||||
|  | ||||
| export function parseAppSettings( | ||||
|   toml: string | ||||
| ): DeepPartial<Configuration> | Error { | ||||
|  | ||||
| @ -10,7 +10,7 @@ const noModifiersPressed = (e: MouseEvent) => | ||||
|   !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey | ||||
|  | ||||
| export type CameraSystem = | ||||
|   | 'KittyCAD' | ||||
|   | 'Zoo' | ||||
|   | 'OnShape' | ||||
|   | 'Trackpad Friendly' | ||||
|   | 'Solidworks' | ||||
| @ -19,7 +19,7 @@ export type CameraSystem = | ||||
|   | 'AutoCAD' | ||||
|  | ||||
| export const cameraSystems: CameraSystem[] = [ | ||||
|   'KittyCAD', | ||||
|   'Zoo', | ||||
|   'OnShape', | ||||
|   'Trackpad Friendly', | ||||
|   'Solidworks', | ||||
| @ -32,8 +32,13 @@ export function mouseControlsToCameraSystem( | ||||
|   mouseControl: MouseControlType | undefined | ||||
| ): CameraSystem | undefined { | ||||
|   switch (mouseControl) { | ||||
|     case 'kitty_cad': | ||||
|       return 'KittyCAD' | ||||
|     // TODO: understand why the values come back without underscores and fix the root cause | ||||
|     // @ts-ignore: TS2678 | ||||
|     case 'zoo': | ||||
|       return 'Zoo' | ||||
|     // TODO: understand why the values come back without underscores and fix the root cause | ||||
|     // @ts-ignore: TS2678 | ||||
|     case 'onshape': | ||||
|     case 'on_shape': | ||||
|       return 'OnShape' | ||||
|     case 'trackpad_friendly': | ||||
| @ -44,6 +49,9 @@ export function mouseControlsToCameraSystem( | ||||
|       return 'NX' | ||||
|     case 'creo': | ||||
|       return 'Creo' | ||||
|     // TODO: understand why the values come back without underscores and fix the root cause | ||||
|     // @ts-ignore: TS2678 | ||||
|     case 'autocad': | ||||
|     case 'auto_cad': | ||||
|       return 'AutoCAD' | ||||
|     default: | ||||
| @ -77,7 +85,7 @@ export const btnName = (e: MouseEvent) => ({ | ||||
| }) | ||||
|  | ||||
| export const cameraMouseDragGuards: Record<CameraSystem, MouseGuard> = { | ||||
|   KittyCAD: { | ||||
|   Zoo: { | ||||
|     pan: { | ||||
|       description: 'Shift + Right click drag or middle click drag', | ||||
|       callback: (e) => | ||||
|  | ||||
| @ -1,9 +1,15 @@ | ||||
| import { Models } from '@kittycad/lib' | ||||
| import { angleLengthInfo } from 'components/Toolbar/setAngleLength' | ||||
| import { transformAstSketchLines } from 'lang/std/sketchcombos' | ||||
| import { PathToNode } from 'lang/wasm' | ||||
| import { StateMachineCommandSetConfig, KclCommandValue } from 'lib/commandTypes' | ||||
| import { KCL_DEFAULT_LENGTH, KCL_DEFAULT_DEGREE } from 'lib/constants' | ||||
| import { components } from 'lib/machine-api' | ||||
| import { Selections } from 'lib/selections' | ||||
| import { kclManager } from 'lib/singletons' | ||||
| import { err } from 'lib/trap' | ||||
| import { modelingMachine, SketchTool } from 'machines/modelingMachine' | ||||
| import { revolveAxisValidator } from './validators' | ||||
|  | ||||
| type OutputFormat = Models['OutputFormat_type'] | ||||
| type OutputTypeKey = OutputFormat['type'] | ||||
| @ -34,9 +40,14 @@ export type ModelingCommandSchema = { | ||||
|   Loft: { | ||||
|     selection: Selections | ||||
|   } | ||||
|   Shell: { | ||||
|     selection: Selections | ||||
|     thickness: KclCommandValue | ||||
|   } | ||||
|   Revolve: { | ||||
|     selection: Selections | ||||
|     angle: KclCommandValue | ||||
|     axis: Selections | ||||
|   } | ||||
|   Fillet: { | ||||
|     // todo | ||||
| @ -50,6 +61,18 @@ export type ModelingCommandSchema = { | ||||
|   'change tool': { | ||||
|     tool: SketchTool | ||||
|   } | ||||
|   'Constrain length': { | ||||
|     selection: Selections | ||||
|     length: KclCommandValue | ||||
|   } | ||||
|   'Constrain with named value': { | ||||
|     currentValue: { | ||||
|       valueText: string | ||||
|       pathToNode: PathToNode | ||||
|       variableName: string | ||||
|     } | ||||
|     namedValue: KclCommandValue | ||||
|   } | ||||
|   'Text-to-CAD': { | ||||
|     prompt: string | ||||
|   } | ||||
| @ -277,6 +300,25 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig< | ||||
|       }, | ||||
|     }, | ||||
|   }, | ||||
|   Shell: { | ||||
|     description: 'Hollow out a 3D solid.', | ||||
|     icon: 'shell', | ||||
|     needsReview: true, | ||||
|     args: { | ||||
|       selection: { | ||||
|         inputType: 'selection', | ||||
|         selectionTypes: ['cap', 'wall'], | ||||
|         multiple: true, | ||||
|         required: true, | ||||
|         skip: false, | ||||
|       }, | ||||
|       thickness: { | ||||
|         inputType: 'kcl', | ||||
|         defaultValue: KCL_DEFAULT_LENGTH, | ||||
|         required: true, | ||||
|       }, | ||||
|     }, | ||||
|   }, | ||||
|   // TODO: Update this configuration, copied from extrude for MVP of revolve, specifically the args.selection | ||||
|   Revolve: { | ||||
|     description: 'Create a 3D body by rotating a sketch region about an axis.', | ||||
| @ -290,6 +332,13 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig< | ||||
|         required: true, | ||||
|         skip: true, | ||||
|       }, | ||||
|       axis: { | ||||
|         required: true, | ||||
|         inputType: 'selection', | ||||
|         selectionTypes: ['segment', 'sweepEdge', 'edgeCutEdge'], | ||||
|         multiple: false, | ||||
|         validation: revolveAxisValidator, | ||||
|       }, | ||||
|       angle: { | ||||
|         inputType: 'kcl', | ||||
|         defaultValue: KCL_DEFAULT_DEGREE, | ||||
| @ -337,6 +386,88 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig< | ||||
|       }, | ||||
|     }, | ||||
|   }, | ||||
|   'Constrain length': { | ||||
|     description: 'Constrain the length of one or more segments.', | ||||
|     icon: 'dimension', | ||||
|     args: { | ||||
|       selection: { | ||||
|         inputType: 'selection', | ||||
|         selectionTypes: ['segment'], | ||||
|         multiple: false, | ||||
|         required: true, | ||||
|         skip: true, | ||||
|       }, | ||||
|       length: { | ||||
|         inputType: 'kcl', | ||||
|         required: true, | ||||
|         createVariableByDefault: true, | ||||
|         defaultValue(_, machineContext) { | ||||
|           const selectionRanges = machineContext?.selectionRanges | ||||
|           if (!selectionRanges) return KCL_DEFAULT_LENGTH | ||||
|           const angleLength = angleLengthInfo({ | ||||
|             selectionRanges, | ||||
|             angleOrLength: 'setLength', | ||||
|           }) | ||||
|           if (err(angleLength)) return KCL_DEFAULT_LENGTH | ||||
|           const { transforms } = angleLength | ||||
|  | ||||
|           // QUESTION: is it okay to reference kclManager here? will its state be up to date? | ||||
|           const sketched = transformAstSketchLines({ | ||||
|             ast: structuredClone(kclManager.ast), | ||||
|             selectionRanges, | ||||
|             transformInfos: transforms, | ||||
|             programMemory: kclManager.programMemory, | ||||
|             referenceSegName: '', | ||||
|           }) | ||||
|           if (err(sketched)) return KCL_DEFAULT_LENGTH | ||||
|           const { valueUsedInTransform } = sketched | ||||
|           return valueUsedInTransform?.toString() || KCL_DEFAULT_LENGTH | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
|   }, | ||||
|   'Constrain with named value': { | ||||
|     description: 'Constrain a value by making it a named constant.', | ||||
|     icon: 'make-variable', | ||||
|     args: { | ||||
|       currentValue: { | ||||
|         description: | ||||
|           'Path to the node in the AST to constrain. This is never shown to the user.', | ||||
|         inputType: 'text', | ||||
|         required: false, | ||||
|         skip: true, | ||||
|       }, | ||||
|       namedValue: { | ||||
|         inputType: 'kcl', | ||||
|         required: true, | ||||
|         createVariableByDefault: true, | ||||
|         variableName(commandBarContext, machineContext) { | ||||
|           const { currentValue } = commandBarContext.argumentsToSubmit | ||||
|           if ( | ||||
|             !currentValue || | ||||
|             !(currentValue instanceof Object) || | ||||
|             !('variableName' in currentValue) || | ||||
|             typeof currentValue.variableName !== 'string' | ||||
|           ) { | ||||
|             return 'value' | ||||
|           } | ||||
|           return currentValue.variableName | ||||
|         }, | ||||
|         defaultValue: (commandBarContext) => { | ||||
|           const { currentValue } = commandBarContext.argumentsToSubmit | ||||
|           if ( | ||||
|             !currentValue || | ||||
|             !(currentValue instanceof Object) || | ||||
|             !('valueText' in currentValue) || | ||||
|             typeof currentValue.valueText !== 'string' | ||||
|           ) { | ||||
|             return KCL_DEFAULT_LENGTH | ||||
|           } | ||||
|           return currentValue.valueText | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
|   }, | ||||
|   'Text-to-CAD': { | ||||
|     description: 'Use the Zoo Text-to-CAD API to generate part starters.', | ||||
|     icon: 'chat', | ||||
|  | ||||
							
								
								
									
										106
									
								
								src/lib/commandBarConfigs/validators.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,106 @@ | ||||
| import { Models } from '@kittycad/lib' | ||||
| import { engineCommandManager } from 'lib/singletons' | ||||
| import { uuidv4 } from 'lib/utils' | ||||
| import { CommandBarContext } from 'machines/commandBarMachine' | ||||
| import { Selections } from 'lib/selections' | ||||
|  | ||||
| export const disableDryRunWithRetry = async (numberOfRetries = 3) => { | ||||
|   for (let tries = 0; tries < numberOfRetries; tries++) { | ||||
|     try { | ||||
|       await engineCommandManager.sendSceneCommand({ | ||||
|         type: 'modeling_cmd_req', | ||||
|         cmd_id: uuidv4(), | ||||
|         cmd: { type: 'disable_dry_run' }, | ||||
|       }) | ||||
|       // Exit out since the command was successful | ||||
|       return | ||||
|     } catch (e) { | ||||
|       console.error(e) | ||||
|       console.error('disable_dry_run failed. This is bad!') | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Takes a callback function and wraps it around enable_dry_run and disable_dry_run | ||||
| export const dryRunWrapper = async (callback: () => Promise<any>) => { | ||||
|   // Gotcha: What about race conditions? | ||||
|   try { | ||||
|     await engineCommandManager.sendSceneCommand({ | ||||
|       type: 'modeling_cmd_req', | ||||
|       cmd_id: uuidv4(), | ||||
|       cmd: { type: 'enable_dry_run' }, | ||||
|     }) | ||||
|     const result = await callback() | ||||
|     return result | ||||
|   } catch (e) { | ||||
|     console.error(e) | ||||
|   } finally { | ||||
|     await disableDryRunWithRetry(5) | ||||
|   } | ||||
| } | ||||
|  | ||||
| function isSelections(selections: unknown): selections is Selections { | ||||
|   return ( | ||||
|     (selections as Selections).graphSelections !== undefined && | ||||
|     (selections as Selections).otherSelections !== undefined | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export const revolveAxisValidator = async ({ | ||||
|   data, | ||||
|   context, | ||||
| }: { | ||||
|   data: { [key: string]: Selections } | ||||
|   context: CommandBarContext | ||||
| }): Promise<boolean | string> => { | ||||
|   if (!isSelections(context.argumentsToSubmit.selection)) { | ||||
|     return 'Unable to revolve, selections are missing' | ||||
|   } | ||||
|   const artifact = | ||||
|     context.argumentsToSubmit.selection.graphSelections[0].artifact | ||||
|  | ||||
|   if (!artifact) { | ||||
|     return 'Unable to revolve, sketch not found' | ||||
|   } | ||||
|  | ||||
|   if (!('pathId' in artifact)) { | ||||
|     return 'Unable to revolve, sketch has no path' | ||||
|   } | ||||
|  | ||||
|   const sketchSelection = artifact.pathId | ||||
|   let edgeSelection = data.axis.graphSelections[0].artifact?.id | ||||
|  | ||||
|   if (!sketchSelection) { | ||||
|     return 'Unable to revolve, sketch is missing' | ||||
|   } | ||||
|  | ||||
|   if (!edgeSelection) { | ||||
|     return 'Unable to revolve, edge is missing' | ||||
|   } | ||||
|  | ||||
|   const angleInDegrees: Models['Angle_type'] = { | ||||
|     unit: 'degrees', | ||||
|     value: 360, | ||||
|   } | ||||
|  | ||||
|   const revolveAboutEdgeCommand = async () => { | ||||
|     return await engineCommandManager.sendSceneCommand({ | ||||
|       type: 'modeling_cmd_req', | ||||
|       cmd_id: uuidv4(), | ||||
|       cmd: { | ||||
|         type: 'revolve_about_edge', | ||||
|         angle: angleInDegrees, | ||||
|         edge_id: edgeSelection, | ||||
|         target: sketchSelection, | ||||
|         tolerance: 0.0001, | ||||
|       }, | ||||
|     }) | ||||
|   } | ||||
|   const attemptRevolve = await dryRunWrapper(revolveAboutEdgeCommand) | ||||
|   if (attemptRevolve?.success) { | ||||
|     return true | ||||
|   } else { | ||||
|     // return error message for the toast | ||||
|     return 'Unable to revolve with selected axis' | ||||
|   } | ||||
| } | ||||
| @ -7,7 +7,7 @@ import { ReactNode } from 'react' | ||||
| import { MachineManager } from 'components/MachineManagerProvider' | ||||
| import { Node } from 'wasm-lib/kcl/bindings/Node' | ||||
| import { Artifact } from 'lang/std/artifactGraph' | ||||
|  | ||||
| import { CommandBarContext } from 'machines/commandBarMachine' | ||||
| type Icon = CustomIconName | ||||
| const PLATFORMS = ['both', 'web', 'desktop'] as const | ||||
| const INPUT_TYPES = [ | ||||
| @ -147,8 +147,30 @@ export type CommandArgumentConfig< | ||||
|       inputType: 'selection' | ||||
|       selectionTypes: Artifact['type'][] | ||||
|       multiple: boolean | ||||
|       validation?: ({ | ||||
|         data, | ||||
|         context, | ||||
|       }: { | ||||
|         data: any | ||||
|         context: CommandBarContext | ||||
|       }) => Promise<boolean | string> | ||||
|     } | ||||
|   | { | ||||
|       inputType: 'kcl' | ||||
|       createVariableByDefault?: boolean | ||||
|       variableName?: | ||||
|         | string | ||||
|         | (( | ||||
|             commandBarContext: ContextFrom<typeof commandBarMachine>, | ||||
|             machineContext?: C | ||||
|           ) => string) | ||||
|       defaultValue?: | ||||
|         | string | ||||
|         | (( | ||||
|             commandBarContext: ContextFrom<typeof commandBarMachine>, | ||||
|             machineContext?: C | ||||
|           ) => string) | ||||
|     } | ||||
|   | { inputType: 'kcl'; defaultValue?: string } // KCL expression inputs have simple strings as default values | ||||
|   | { | ||||
|       inputType: 'string' | ||||
|       defaultValue?: | ||||
| @ -221,8 +243,30 @@ export type CommandArgument< | ||||
|       inputType: 'selection' | ||||
|       selectionTypes: Artifact['type'][] | ||||
|       multiple: boolean | ||||
|       validation?: ({ | ||||
|         data, | ||||
|         context, | ||||
|       }: { | ||||
|         data: any | ||||
|         context: CommandBarContext | ||||
|       }) => Promise<boolean | string> | ||||
|     } | ||||
|   | { | ||||
|       inputType: 'kcl' | ||||
|       createVariableByDefault?: boolean | ||||
|       variableName?: | ||||
|         | string | ||||
|         | (( | ||||
|             commandBarContext: ContextFrom<typeof commandBarMachine>, | ||||
|             machineContext?: ContextFrom<T> | ||||
|           ) => string) | ||||
|       defaultValue?: | ||||
|         | string | ||||
|         | (( | ||||
|             commandBarContext: ContextFrom<typeof commandBarMachine>, | ||||
|             machineContext?: ContextFrom<T> | ||||
|           ) => string) | ||||
|     } | ||||
|   | { inputType: 'kcl'; defaultValue?: string } // KCL expression inputs have simple strings as default value | ||||
|   | { | ||||
|       inputType: 'string' | ||||
|       defaultValue?: | ||||
|  | ||||
| @ -53,6 +53,7 @@ export const KCL_DEFAULT_CONSTANT_PREFIXES = { | ||||
|   SKETCH: 'sketch', | ||||
|   EXTRUDE: 'extrude', | ||||
|   LOFT: 'loft', | ||||
|   SHELL: 'shell', | ||||
|   SEGMENT: 'seg', | ||||
|   REVOLVE: 'revolve', | ||||
|   PLANE: 'plane', | ||||
| @ -110,3 +111,28 @@ export const KCL_SAMPLES_MANIFEST_URLS = { | ||||
|  | ||||
| /** Toast id for the app auto-updater toast */ | ||||
| export const AUTO_UPDATER_TOAST_ID = 'auto-updater-toast' | ||||
|  | ||||
| /** Local sketch axis values in KCL for operations, it could either be 'X' or 'Y' */ | ||||
| export const KCL_AXIS_X = 'X' | ||||
| export const KCL_AXIS_Y = 'Y' | ||||
| export const KCL_AXIS_NEG_X = '-X' | ||||
| export const KCL_AXIS_NEG_Y = '-Y' | ||||
| export const KCL_DEFAULT_AXIS = 'X' | ||||
|  | ||||
| export enum AxisNames { | ||||
|   X = 'x', | ||||
|   Y = 'y', | ||||
|   Z = 'z', | ||||
|   NEG_X = '-x', | ||||
|   NEG_Y = '-y', | ||||
|   NEG_Z = '-z', | ||||
| } | ||||
| /** Semantic names of views from AxisNames */ | ||||
| export const VIEW_NAMES_SEMANTIC = { | ||||
|   [AxisNames.X]: 'Right', | ||||
|   [AxisNames.Y]: 'Back', | ||||
|   [AxisNames.Z]: 'Top', | ||||
|   [AxisNames.NEG_X]: 'Left', | ||||
|   [AxisNames.NEG_Y]: 'Front', | ||||
|   [AxisNames.NEG_Z]: 'Bottom', | ||||
| } as const | ||||
|  | ||||
| @ -155,6 +155,8 @@ export function buildCommandArgument< | ||||
|   context: ContextFrom<T>, | ||||
|   machineActor: Actor<T> | ||||
| ): CommandArgument<O, T> & { inputType: typeof arg.inputType } { | ||||
|   // GOTCHA: modelingCommandConfig is not a 1:1 mapping to this baseCommandArgument | ||||
|   // You need to manually add key/value pairs here. | ||||
|   const baseCommandArgument = { | ||||
|     description: arg.description, | ||||
|     required: arg.required, | ||||
| @ -181,10 +183,13 @@ export function buildCommandArgument< | ||||
|       ...baseCommandArgument, | ||||
|       multiple: arg.multiple, | ||||
|       selectionTypes: arg.selectionTypes, | ||||
|       validation: arg.validation, | ||||
|     } satisfies CommandArgument<O, T> & { inputType: 'selection' } | ||||
|   } else if (arg.inputType === 'kcl') { | ||||
|     return { | ||||
|       inputType: arg.inputType, | ||||
|       createVariableByDefault: arg.createVariableByDefault, | ||||
|       variableName: arg.variableName, | ||||
|       defaultValue: arg.defaultValue, | ||||
|       ...baseCommandArgument, | ||||
|     } satisfies CommandArgument<O, T> & { inputType: 'kcl' } | ||||
|  | ||||
| @ -13,7 +13,6 @@ import { | ||||
|   listProjects, | ||||
|   readAppSettingsFile, | ||||
| } from './desktop' | ||||
| import { engineCommandManager } from './singletons' | ||||
|  | ||||
| export const isHidden = (fileOrDir: FileEntry) => | ||||
|   !!fileOrDir.name?.startsWith('.') | ||||
| @ -116,9 +115,6 @@ export async function createAndOpenNewTutorialProject({ | ||||
|   ) => void | ||||
|   navigate: (path: string) => void | ||||
| }) { | ||||
|   // Clear the scene. | ||||
|   engineCommandManager.clearScene() | ||||
|  | ||||
|   // Create a new project with the onboarding project name | ||||
|   const configuration = await readAppSettingsFile() | ||||
|   const projects = await listProjects(configuration) | ||||
|  | ||||
| @ -3,27 +3,27 @@ export const bracket = `// Shelf Bracket | ||||
|  | ||||
|  | ||||
| // Define constants | ||||
| const sigmaAllow = 35000 // psi (6061-T6 aluminum) | ||||
| const width = 6 // inch | ||||
| const p = 300 // Force on shelf - lbs | ||||
| const factorOfSafety = 1.2 // FOS of 1.2 | ||||
| const shelfMountL = 5 // inches | ||||
| const wallMountL = 2 // inches | ||||
| const shelfDepth = 12 // Shelf is 12 inches in depth from the wall | ||||
| const moment = shelfDepth * p // assume the force is applied at the end of the shelf to be conservative (lb-in) | ||||
| sigmaAllow = 35000 // psi (6061-T6 aluminum) | ||||
| width = 6 // inch | ||||
| p = 300 // Force on shelf - lbs | ||||
| factorOfSafety = 1.2 // FOS of 1.2 | ||||
| shelfMountL = 5 // inches | ||||
| wallMountL = 2 // inches | ||||
| shelfDepth = 12 // Shelf is 12 inches in depth from the wall | ||||
| moment = shelfDepth * p // assume the force is applied at the end of the shelf to be conservative (lb-in) | ||||
|  | ||||
|  | ||||
| const filletRadius = .375 // inches | ||||
| const extFilletRadius = .25 // inches | ||||
| const mountingHoleDiameter = 0.5 // inches | ||||
| filletRadius = .375 // inches | ||||
| extFilletRadius = .25 // inches | ||||
| mountingHoleDiameter = 0.5 // inches | ||||
|  | ||||
|  | ||||
| // Calculate required thickness of bracket | ||||
| const thickness = sqrt(moment * factorOfSafety * 6 / (sigmaAllow * width)) // this is the calculation of two brackets holding up the shelf (inches) | ||||
| thickness = sqrt(moment * factorOfSafety * 6 / (sigmaAllow * width)) // this is the calculation of two brackets holding up the shelf (inches) | ||||
|  | ||||
|  | ||||
| // Sketch the bracket body and fillet the inner and outer edges of the bend | ||||
| const bracketLeg1Sketch = startSketchOn('XY') | ||||
| bracketLeg1Sketch = startSketchOn('XY') | ||||
|   |> startProfileAt([0, 0], %) | ||||
|   |> line([shelfMountL - filletRadius, 0], %, $fillet1) | ||||
|   |> line([0, width], %, $fillet2) | ||||
| @ -47,7 +47,7 @@ const bracketLeg1Sketch = startSketchOn('XY') | ||||
|      }, %), %) | ||||
|  | ||||
| // Extrude the leg 2 bracket sketch | ||||
| const bracketLeg1Extrude = extrude(thickness, bracketLeg1Sketch) | ||||
| bracketLeg1Extrude = extrude(thickness, bracketLeg1Sketch) | ||||
|   |> fillet({ | ||||
|        radius = extFilletRadius, | ||||
|        tags = [ | ||||
| @ -57,7 +57,7 @@ const bracketLeg1Extrude = extrude(thickness, bracketLeg1Sketch) | ||||
|      }, %) | ||||
|  | ||||
| // Sketch the fillet arc | ||||
| const filletSketch = startSketchOn('XZ') | ||||
| filletSketch = startSketchOn('XZ') | ||||
|   |> startProfileAt([0, 0], %) | ||||
|   |> line([0, thickness], %) | ||||
|   |> arc({ | ||||
| @ -73,10 +73,10 @@ const filletSketch = startSketchOn('XZ') | ||||
|      }, %) | ||||
|  | ||||
| // Sketch the bend | ||||
| const filletExtrude = extrude(-width, filletSketch) | ||||
| filletExtrude = extrude(-width, filletSketch) | ||||
|  | ||||
| // Create a custom plane for the leg that sits on the wall | ||||
| const customPlane = { | ||||
| customPlane = { | ||||
|   plane = { | ||||
|     origin = { x = -filletRadius, y = 0, z = 0 }, | ||||
|     xAxis = { x = 0, y = 1, z = 0 }, | ||||
| @ -86,7 +86,7 @@ const customPlane = { | ||||
| } | ||||
|  | ||||
| // Create a sketch for the second leg | ||||
| const bracketLeg2Sketch = startSketchOn(customPlane) | ||||
| bracketLeg2Sketch = startSketchOn(customPlane) | ||||
|   |> startProfileAt([0, -filletRadius], %) | ||||
|   |> line([width, 0], %) | ||||
|   |> line([0, -wallMountL], %, $fillet3) | ||||
| @ -102,7 +102,7 @@ const bracketLeg2Sketch = startSketchOn(customPlane) | ||||
|      }, %), %) | ||||
|  | ||||
| // Extrude the second leg | ||||
| const bracketLeg2Extrude = extrude(-thickness, bracketLeg2Sketch) | ||||
| bracketLeg2Extrude = extrude(-thickness, bracketLeg2Sketch) | ||||
|   |> fillet({ | ||||
|        radius = extFilletRadius, | ||||
|        tags = [ | ||||
| @ -135,8 +135,8 @@ function findLineInExampleCode({ | ||||
| } | ||||
|  | ||||
| export const bracketWidthConstantLine = findLineInExampleCode({ | ||||
|   searchText: 'const width', | ||||
|   searchText: 'width =', | ||||
| }) | ||||
| export const bracketThicknessCalculationLine = findLineInExampleCode({ | ||||
|   searchText: 'const thickness', | ||||
|   searchText: 'thickness =', | ||||
| }) | ||||
|  | ||||
| @ -5,7 +5,7 @@ import { isDesktop } from './isDesktop' | ||||
| import { FILE_EXT, PROJECT_SETTINGS_FILE_NAME } from './constants' | ||||
| import { UnitLength_type } from '@kittycad/lib/dist/types/src/models' | ||||
| import { parseProjectSettings } from 'lang/wasm' | ||||
| import { err } from './trap' | ||||
| import { err, reportRejection } from './trap' | ||||
| import { projectConfigurationToSettingsPayload } from './settings/settingsUtils' | ||||
|  | ||||
| interface OnSubmitProps { | ||||
| @ -28,7 +28,7 @@ export function kclCommands( | ||||
|       groupId: 'code', | ||||
|       icon: 'code', | ||||
|       onSubmit: () => { | ||||
|         kclManager.format() | ||||
|         kclManager.format().catch(reportRejection) | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|  | ||||
| @ -569,6 +569,17 @@ export function canSweepSelection(selection: Selections) { | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export function canRevolveSelection(selection: Selections) { | ||||
|   const commonNodes = selection.graphSelections.map((_, i) => | ||||
|     buildCommonNodeFromSelection(selection, i) | ||||
|   ) | ||||
|   return ( | ||||
|     !!isSketchPipe(selection) && | ||||
|     (commonNodes.every((n) => nodeHasClose(n)) || | ||||
|       commonNodes.every((n) => nodeHasCircle(n))) | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export function canLoftSelection(selection: Selections) { | ||||
|   const commonNodes = selection.graphSelections.map((_, i) => | ||||
|     buildCommonNodeFromSelection(selection, i) | ||||
| @ -585,6 +596,17 @@ export function canLoftSelection(selection: Selections) { | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export function canShellSelection(selection: Selections) { | ||||
|   const commonNodes = selection.graphSelections.map((_, i) => | ||||
|     buildCommonNodeFromSelection(selection, i) | ||||
|   ) | ||||
|   return commonNodes.every( | ||||
|     (n) => | ||||
|       n.selection.artifact?.type === 'cap' || | ||||
|       n.selection.artifact?.type === 'wall' | ||||
|   ) | ||||
| } | ||||
|  | ||||
| // This accounts for non-geometry selections under "other" | ||||
| export type ResolvedSelectionType = Artifact['type'] | 'other' | ||||
| export type SelectionCountsByType = Map<ResolvedSelectionType, number> | ||||
| @ -619,12 +641,29 @@ export function getSelectionCountByType( | ||||
|     } | ||||
|   }) | ||||
|  | ||||
|   selection.graphSelections.forEach((selection) => { | ||||
|     if (!selection.artifact) { | ||||
|       incrementOrInitializeSelectionType('other') | ||||
|       return | ||||
|   selection.graphSelections.forEach((graphSelection) => { | ||||
|     if (!graphSelection.artifact) { | ||||
|       /** | ||||
|        * TODO: remove this heuristic-based selection type detection. | ||||
|        * Currently, if you've created a sketch and have not left sketch mode, | ||||
|        * the selection will be a segment selection with no artifact. | ||||
|        * This is because the mock execution does not update the artifact graph. | ||||
|        * Once we move the artifactGraph creation to WASM, we can remove this, | ||||
|        * as the artifactGraph will always be up-to-date. | ||||
|        */ | ||||
|       if (isSingleCursorInPipe(selection, kclManager.ast)) { | ||||
|         incrementOrInitializeSelectionType('segment') | ||||
|         return | ||||
|       } else { | ||||
|         console.warn( | ||||
|           'Selection is outside of a sketch but has no artifact. Sketch segment selections are the only kind that can have a valid selection with no artifact.', | ||||
|           JSON.stringify(graphSelection) | ||||
|         ) | ||||
|         incrementOrInitializeSelectionType('other') | ||||
|         return | ||||
|       } | ||||
|     } | ||||
|     incrementOrInitializeSelectionType(selection.artifact.type) | ||||
|     incrementOrInitializeSelectionType(graphSelection.artifact.type) | ||||
|   }) | ||||
|  | ||||
|   return selectionsByType | ||||
|  | ||||
| @ -12,7 +12,7 @@ export type InteractionMapItem = { | ||||
|  * Controls both the available names for interaction map categories | ||||
|  * and the order in which they are displayed. | ||||
|  */ | ||||
| export const interactionMapCategories = [ | ||||
| const interactionMapCategories = [ | ||||
|   'Sketching', | ||||
|   'Modeling', | ||||
|   'Command Palette', | ||||
|  | ||||
| @ -283,7 +283,7 @@ export function createSettings() { | ||||
|        * The controls for how to navigate the 3D view | ||||
|        */ | ||||
|       mouseControls: new Setting<CameraSystem>({ | ||||
|         defaultValue: 'KittyCAD', | ||||
|         defaultValue: 'Zoo', | ||||
|         description: 'The controls for how to navigate the 3D view', | ||||
|         validate: (v) => cameraSystems.includes(v as CameraSystem), | ||||
|         hideOnLevel: 'project', | ||||
|  | ||||
| @ -2,6 +2,7 @@ import { DeepPartial } from 'lib/types' | ||||
| import { Configuration } from 'wasm-lib/kcl/bindings/Configuration' | ||||
| import { | ||||
|   configurationToSettingsPayload, | ||||
|   getAllCurrentSettings, | ||||
|   projectConfigurationToSettingsPayload, | ||||
|   setSettingsAtLevel, | ||||
| } from './settingsUtils' | ||||
| @ -65,3 +66,48 @@ describe(`testing settings initialization`, () => { | ||||
|     expect(settings.app.themeColor.current).toBe('200') | ||||
|   }) | ||||
| }) | ||||
|  | ||||
| describe(`testing getAllCurrentSettings`, () => { | ||||
|   it(`returns the correct settings`, () => { | ||||
|     // Set up the settings | ||||
|     let settings = createSettings() | ||||
|     const appConfiguration: DeepPartial<Configuration> = { | ||||
|       settings: { | ||||
|         app: { | ||||
|           appearance: { | ||||
|             theme: 'dark', | ||||
|             color: 190, | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|     } | ||||
|     const projectConfiguration: DeepPartial<Configuration> = { | ||||
|       settings: { | ||||
|         app: { | ||||
|           appearance: { | ||||
|             theme: 'light', | ||||
|             color: 200, | ||||
|           }, | ||||
|         }, | ||||
|         modeling: { | ||||
|           base_unit: 'ft', | ||||
|         }, | ||||
|       }, | ||||
|     } | ||||
|  | ||||
|     const appSettingsPayload = configurationToSettingsPayload(appConfiguration) | ||||
|     const projectSettingsPayload = | ||||
|       projectConfigurationToSettingsPayload(projectConfiguration) | ||||
|  | ||||
|     setSettingsAtLevel(settings, 'user', appSettingsPayload) | ||||
|     setSettingsAtLevel(settings, 'project', projectSettingsPayload) | ||||
|  | ||||
|     // Now the test: get all the settings' current resolved values | ||||
|     const allCurrentSettings = getAllCurrentSettings(settings) | ||||
|     // This one gets the 'user'-level theme because it's ignored at the project level | ||||
|     // (see the test "doesn't read theme from project settings") | ||||
|     expect(allCurrentSettings.app.theme).toBe('dark') | ||||
|     expect(allCurrentSettings.app.themeColor).toBe('200') | ||||
|     expect(allCurrentSettings.modeling.defaultUnit).toBe('ft') | ||||
|   }) | ||||
| }) | ||||
|  | ||||
| @ -286,6 +286,27 @@ export function getChangedSettingsAtLevel( | ||||
|   return changedSettings | ||||
| } | ||||
|  | ||||
| export function getAllCurrentSettings( | ||||
|   allSettings: typeof settings | ||||
| ): SaveSettingsPayload { | ||||
|   const currentSettings = {} as SaveSettingsPayload | ||||
|   Object.entries(allSettings).forEach(([category, settingsCategory]) => { | ||||
|     const categoryKey = category as keyof typeof settings | ||||
|     Object.entries(settingsCategory).forEach( | ||||
|       ([setting, settingValue]: [string, Setting]) => { | ||||
|         const settingKey = | ||||
|           setting as keyof (typeof settings)[typeof categoryKey] | ||||
|         currentSettings[categoryKey] = { | ||||
|           ...currentSettings[categoryKey], | ||||
|           [settingKey]: settingValue.current, | ||||
|         } | ||||
|       } | ||||
|     ) | ||||
|   }) | ||||
|  | ||||
|   return currentSettings | ||||
| } | ||||
|  | ||||
| export function setSettingsAtLevel( | ||||
|   allSettings: typeof settings, | ||||
|   level: SettingsLevel, | ||||
|  | ||||
| @ -112,9 +112,6 @@ export async function executor( | ||||
|     makeDefaultPlanes: () => { | ||||
|       return new Promise((resolve) => resolve(defaultPlanes)) | ||||
|     }, | ||||
|     modifyGrid: (hidden: boolean) => { | ||||
|       return new Promise((resolve) => resolve()) | ||||
|     }, | ||||
|   }) | ||||
|  | ||||
|   return new Promise((resolve) => { | ||||
|  | ||||
| @ -190,9 +190,15 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = { | ||||
|       }, | ||||
|       { | ||||
|         id: 'shell', | ||||
|         onClick: () => console.error('Shell not yet implemented'), | ||||
|         onClick: ({ commandBarSend }) => { | ||||
|           commandBarSend({ | ||||
|             type: 'Find and select command', | ||||
|             data: { name: 'Shell', groupId: 'modeling' }, | ||||
|           }) | ||||
|         }, | ||||
|         disabled: (state) => !state.can({ type: 'Shell' }), | ||||
|         icon: 'shell', | ||||
|         status: 'kcl-only', | ||||
|         status: 'available', | ||||
|         title: 'Shell', | ||||
|         description: 'Hollow out a 3D solid.', | ||||
|         links: [{ label: 'KCL docs', url: 'https://zoo.dev/docs/kcl/shell' }], | ||||
| @ -534,13 +540,15 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = { | ||||
|       [ | ||||
|         { | ||||
|           id: 'constraint-length', | ||||
|           disabled: (state) => | ||||
|             !( | ||||
|               state.matches({ Sketch: 'SketchIdle' }) && | ||||
|               state.can({ type: 'Constrain length' }) | ||||
|             ), | ||||
|           onClick: ({ modelingSend }) => | ||||
|             modelingSend({ type: 'Constrain length' }), | ||||
|           disabled: (state) => !state.matches({ Sketch: 'SketchIdle' }), | ||||
|           onClick: ({ commandBarSend }) => | ||||
|             commandBarSend({ | ||||
|               type: 'Find and select command', | ||||
|               data: { | ||||
|                 name: 'Constrain length', | ||||
|                 groupId: 'modeling', | ||||
|               }, | ||||
|             }), | ||||
|           icon: 'dimension', | ||||
|           status: 'available', | ||||
|           title: 'Length', | ||||
|  | ||||
| @ -8,6 +8,7 @@ import { | ||||
| import { Selections__old } from 'lib/selections' | ||||
| import { getCommandArgumentKclValuesOnly } from 'lib/commandUtils' | ||||
| import { MachineManager } from 'components/MachineManagerProvider' | ||||
| import toast from 'react-hot-toast' | ||||
|  | ||||
| export type CommandBarContext = { | ||||
|   commands: Command[] | ||||
| @ -247,14 +248,69 @@ export const commandBarMachine = setup({ | ||||
|     'All arguments are skippable': () => false, | ||||
|   }, | ||||
|   actors: { | ||||
|     'Validate argument': fromPromise(({ input }) => { | ||||
|       return new Promise((resolve, reject) => { | ||||
|         // TODO: figure out if we should validate argument data here or in the form itself, | ||||
|         // and if we should support people configuring a argument's validation function | ||||
|     'Validate argument': fromPromise( | ||||
|       ({ | ||||
|         input, | ||||
|       }: { | ||||
|         input: { | ||||
|           context: CommandBarContext | undefined | ||||
|           event: CommandBarMachineEvent | undefined | ||||
|         } | ||||
|       }) => { | ||||
|         return new Promise((resolve, reject) => { | ||||
|           if (!input || input?.event?.type !== 'Submit argument') { | ||||
|             toast.error(`Unable to validate, wrong event type.`) | ||||
|             return reject(`Unable to validate, wrong event type`) | ||||
|           } | ||||
|  | ||||
|         resolve(input) | ||||
|       }) | ||||
|     }), | ||||
|           const context = input?.context | ||||
|  | ||||
|           if (!context) { | ||||
|             toast.error(`Unable to validate, wrong argument.`) | ||||
|             return reject(`Unable to validate, wrong argument`) | ||||
|           } | ||||
|  | ||||
|           const data = input.event.data | ||||
|           const argName = context.currentArgument?.name | ||||
|           const args = context?.selectedCommand?.args | ||||
|           const argConfig = args && argName ? args[argName] : undefined | ||||
|           // Only do a validation check if the argument, selectedCommand, and the validation function are defined | ||||
|           if ( | ||||
|             context.currentArgument && | ||||
|             context.selectedCommand && | ||||
|             argConfig?.inputType === 'selection' && | ||||
|             argConfig?.validation | ||||
|           ) { | ||||
|             argConfig | ||||
|               .validation({ context, data }) | ||||
|               .then((result) => { | ||||
|                 if (typeof result === 'boolean' && result === true) { | ||||
|                   return resolve(data) | ||||
|                 } else { | ||||
|                   // validation failed | ||||
|                   if (typeof result === 'string') { | ||||
|                     // The result of the validation is the error message | ||||
|                     toast.error(result) | ||||
|                     return reject( | ||||
|                       `unable to validate ${argName}, Message: ${result}` | ||||
|                     ) | ||||
|                   } else { | ||||
|                     // Default message if there is not a custom one sent | ||||
|                     toast.error(`Unable to validate ${argName}`) | ||||
|                     return reject(`unable to validate ${argName}}`) | ||||
|                   } | ||||
|                 } | ||||
|               }) | ||||
|               .catch(() => { | ||||
|                 return reject(`unable to validate ${argName}}`) | ||||
|               }) | ||||
|           } else { | ||||
|             // Missing several requirements for validate argument, just bypass | ||||
|             return resolve(data) | ||||
|           } | ||||
|         }) | ||||
|       } | ||||
|     ), | ||||
|     'Validate all arguments': fromPromise( | ||||
|       ({ input }: { input: CommandBarContext }) => { | ||||
|         return new Promise((resolve, reject) => { | ||||
| @ -449,9 +505,10 @@ export const commandBarMachine = setup({ | ||||
|           invoke: { | ||||
|             src: 'Validate argument', | ||||
|             id: 'validateSingleArgument', | ||||
|             input: ({ event }) => { | ||||
|               if (event.type !== 'Submit argument') return {} | ||||
|               return event.data | ||||
|             input: ({ event, context }) => { | ||||
|               if (event.type !== 'Submit argument') | ||||
|                 return { event: undefined, context: undefined } | ||||
|               return { event, context } | ||||
|             }, | ||||
|             onDone: { | ||||
|               target: '#Command Bar.Checking Arguments', | ||||
|  | ||||
| @ -42,8 +42,6 @@ export const settingsMachine = setup({ | ||||
|     setClientTheme: () => {}, | ||||
|     'Execute AST': () => {}, | ||||
|     toastSuccess: () => {}, | ||||
|     setEngineEdges: () => {}, | ||||
|     setEngineScaleGridVisibility: () => {}, | ||||
|     setClientSideSceneUnits: () => {}, | ||||
|     persistSettings: () => {}, | ||||
|     resetSettings: assign(({ context, event }) => { | ||||
| @ -172,7 +170,7 @@ export const settingsMachine = setup({ | ||||
|         'set.modeling.highlightEdges': { | ||||
|           target: 'persisting settings', | ||||
|  | ||||
|           actions: ['setSettingAtLevel', 'toastSuccess', 'setEngineEdges'], | ||||
|           actions: ['setSettingAtLevel', 'toastSuccess', 'Execute AST'], | ||||
|         }, | ||||
|  | ||||
|         'Reset settings': { | ||||
| @ -201,11 +199,7 @@ export const settingsMachine = setup({ | ||||
|  | ||||
|         'set.modeling.showScaleGrid': { | ||||
|           target: 'persisting settings', | ||||
|           actions: [ | ||||
|             'setSettingAtLevel', | ||||
|             'toastSuccess', | ||||
|             'setEngineScaleGridVisibility', | ||||
|           ], | ||||
|           actions: ['setSettingAtLevel', 'toastSuccess', 'Execute AST'], | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
|  | ||||
| @ -44,11 +44,6 @@ process.env.VITE_KC_SITE_BASE_URL ??= 'https://zoo.dev' | ||||
| process.env.VITE_KC_SKIP_AUTH ??= 'false' | ||||
| process.env.VITE_KC_CONNECTION_TIMEOUT_MS ??= '15000' | ||||
|  | ||||
| // Handle creating/removing shortcuts on Windows when installing/uninstalling. | ||||
| if (require('electron-squirrel-startup')) { | ||||
|   app.quit() | ||||
| } | ||||
|  | ||||
| const ZOO_STUDIO_PROTOCOL = 'zoo-studio' | ||||
|  | ||||
| /// Register our application to handle all "electron-fiddle://" protocols. | ||||
| @ -256,6 +251,9 @@ export function getAutoUpdater(): AppUpdater { | ||||
|  | ||||
| app.on('ready', () => { | ||||
|   const autoUpdater = getAutoUpdater() | ||||
|   // TODO: we're getting `Error: Response ends without calling any handlers` with our setup, | ||||
|   // so at the moment this isn't worth enabling | ||||
|   autoUpdater.disableDifferentialDownload = true | ||||
|   setTimeout(() => { | ||||
|     autoUpdater.checkForUpdates().catch(reportRejection) | ||||
|   }, 1000) | ||||
|  | ||||
