Compare commits

...

19 Commits

Author SHA1 Message Date
01cbd9533b Move loading of OS setting to Home 2024-10-09 14:26:37 -04:00
a8d12a35cd Add pinch-to-zoom 2024-10-09 14:25:46 -04:00
24c2fe996f Add respecting OS setting for natural scroll direction 2024-10-09 14:25:43 -04:00
4da6298e2a Change to use the timeout to send to the engine 2024-10-09 14:24:50 -04:00
7021be8360 Add Apple Trackpad camera controls 2024-10-09 14:24:49 -04:00
e525b319d0 Bump clap from 4.5.19 to 4.5.20 in /src/wasm-lib (#4122)
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.19 to 4.5.20.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.19...clap_complete-v4.5.20)

---
updated-dependencies:
- dependency-name: clap
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-09 09:43:53 -07:00
01c6774c54 Nadro/2608/sketch mode scene state improvements (#3866)
* bug: fixing multiple state issues with the engine and modeling app to enable/disable planes/axis/delete code

* fix: yarn tsc fmt lint xgen

* fix: adding a comment back that I deleted on accident

* fix: adding formatting back?

* fix: reverting syntax

* fix: removing click line tool because the line tool is automatically selected. Clicking this will exit

* fix: Fixed a E2E test that had a line tool workflow with no points

---------

Co-authored-by: Frank Noirot <frank@zoo.dev>
Co-authored-by: 49fl <ircsurfer33@gmail.com>
2024-10-09 09:33:20 -05:00
b745cec079 KCL docs: Better docs for KclValue (#4096)
* KCL docs: Better docs for KclValue

* Update docs

---------

Co-authored-by: Jonathan Tran <jonnytran@gmail.com>
2024-10-09 03:56:38 +00:00
90af99abf4 fix: added more documentation on the cut and release process (#4048)
* fix: added more documentation on the cut and release process

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-10-08 11:25:37 -05:00
max
3c5bf70269 Add Warning Message for Fillet Engine Limitations in CommandBar (#4076) 2024-10-08 16:27:58 +02:00
24cd1b2ea5 Reload user settings when changed externally (#4097)
* Reload user settings when changed externally

* Fix to not use any

* Make sure listener doesn't already exist

* Fix up projects reloading

---------

Co-authored-by: Jonathan Tran <jonnytran@gmail.com>
2024-10-07 23:07:18 -04:00
7de0b74c16 Cut release v0.25.6 (#4111)
Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>
2024-10-07 20:46:55 -04:00
e5c20debfe Revert "Split artifacts per arch and re-enable updater for nightly builds" (#4114)
Revert "Split artifacts per arch and re-enable updater for nightly builds (#3…"

This reverts commit 9ca49c6366.
2024-10-07 19:28:02 -04:00
2de3ad7457 Bump once_cell from 1.20.1 to 1.20.2 in /src/wasm-lib (#4106)
Bumps [once_cell](https://github.com/matklad/once_cell) from 1.20.1 to 1.20.2.
- [Changelog](https://github.com/matklad/once_cell/blob/master/CHANGELOG.md)
- [Commits](https://github.com/matklad/once_cell/compare/v1.20.1...v1.20.2)

---
updated-dependencies:
- dependency-name: once_cell
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-07 15:13:12 -07:00
9038dc4104 Bump futures from 0.3.30 to 0.3.31 in /src/wasm-lib (#4108)
Bumps [futures](https://github.com/rust-lang/futures-rs) from 0.3.30 to 0.3.31.
- [Release notes](https://github.com/rust-lang/futures-rs/releases)
- [Changelog](https://github.com/rust-lang/futures-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/futures-rs/compare/0.3.30...0.3.31)

---
updated-dependencies:
- dependency-name: futures
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-07 13:49:15 -04:00
1491e80153 Disable msi builds for now (#4084)
Fixes #4083
2024-10-07 05:10:29 -04:00
bdf45f92aa link to download in readme (#4100) 2024-10-04 14:27:54 -07:00
d104ca2b05 Add menu item and hotkey to center view on current selection (#4068)
* tentatively adding this

* Update src/components/ModelingMachineProvider.tsx

Co-authored-by: Jonathan Tran <jonnytran@gmail.com>

* Show shortcut in UI dialog

* Move command into modelingMachine action

* Add a menu item to the view menu

* Switch gizmo tests to use "deprecated" test setup in prep for new fixture-based test

* Add e2e test for center view to selection

* Bump @kittycad/lib to latest and fix tsc

* Bump @kittycad/lib to v2.0.7 to fix electron building

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest)

---------

Co-authored-by: Jonathan Tran <jonnytran@gmail.com>
Co-authored-by: Frank Noirot <frank@kittycad.io>
Co-authored-by: 49fl <ircsurfer33@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Frank Noirot <frank@zoo.dev>
2024-10-04 16:47:44 -04:00
ec8cacb788 KCL: Reduce can take and return any KCL values (#4094)
Previously it only took Array of Number and could only return Sketch.

Now it has been unshackled from the chains of poor type signatures.
2024-10-04 13:26:16 -05:00
50 changed files with 13336 additions and 1668 deletions

View File

@ -51,6 +51,8 @@ jobs:
run: |
VERSION=$(date +'%-y.%-m.%-d') yarn bump-jsons
# TODO: see if we need to inject updater nightly URL here https://dl.zoo.dev/releases/modeling-app/nightly/last_update.json
- uses: actions/upload-artifact@v3
with:
name: prepared-files
@ -61,25 +63,12 @@ jobs:
- id: export_version
run: echo "version=`cat package.json | jq -r '.version'`" >> "$GITHUB_OUTPUT"
- name: Prepare electron-builder.yml file for nightly
if: ${{ github.event_name == 'schedule' }}
run: |
yq -i '.publish[0].url = "https://dl.zoo.dev/releases/modeling-app/nightly"' electron-builder.yml
- uses: actions/upload-artifact@v3
if: ${{ github.event_name == 'schedule' }}
with:
name: prepared-files-nightly
path: |
electron-builder.yml
- name: Prepare electron-builder.yml file for updater test
if: ${{ env.CUT_RELEASE_PR == 'true' }}
run: |
yq -i '.publish[0].url = "https://dl.zoo.dev/releases/modeling-app/updater-test"' electron-builder.yml
- uses: actions/upload-artifact@v3
if: ${{ env.CUT_RELEASE_PR == 'true' }}
with:
name: prepared-files-updater-test
path: |
@ -119,16 +108,6 @@ jobs:
mkdir src/wasm-lib/pkg
cp prepared-files/src/wasm-lib/pkg/wasm_lib* src/wasm-lib/pkg
- uses: actions/download-artifact@v3
if: ${{ github.event_name == 'schedule' }}
name: prepared-files-nightly
- name: Copy updated electron-builder.yml file for nightly build
if: ${{ github.event_name == 'schedule' }}
run: |
ls -R prepared-files-nightly
cp prepared-files-nightly/electron-builder.yml electron-builder.yml
- name: Sync node version and setup cache
uses: actions/setup-node@v4
with:
@ -173,17 +152,11 @@ jobs:
- uses: actions/upload-artifact@v3
with:
name: out-arm64-${{ matrix.os }}
name: out-${{ matrix.os }}
path: |
out/Zoo*arm64*.*
out/Zoo*.*
out/latest*.yml
- uses: actions/upload-artifact@v3
with:
name: out-x64-${{ matrix.os }}
path: |
out/Zoo*x*64*.*
# TODO: add the 'Build for Mac TestFlight (nightly)' stage back
- uses: actions/download-artifact@v3
@ -203,16 +176,10 @@ jobs:
- uses: actions/upload-artifact@v3
if: ${{ env.CUT_RELEASE_PR == 'true' }}
with:
name: updater-test-arm64-${{ matrix.os }}
name: updater-test-${{ matrix.os }}
path: |
out/Zoo*arm64*.*
- uses: actions/upload-artifact@v3
if: ${{ env.CUT_RELEASE_PR == 'true' }}
with:
name: updater-test-x64-${{ matrix.os }}
path: |
out/Zoo*x64*.*
out/Zoo*.*
out/latest*.yml
publish-apps-release:
@ -234,32 +201,17 @@ jobs:
- uses: actions/download-artifact@v3
with:
name: out-arm64-windows-2022
name: out-windows-2022
path: out
- uses: actions/download-artifact@v3
with:
name: out-x64-windows-2022
name: out-macos-14
path: out
- uses: actions/download-artifact@v3
with:
name: out-arm64-macos-14
path: out
- uses: actions/download-artifact@v3
with:
name: out-x64-macos-14
path: out
- uses: actions/download-artifact@v3
with:
name: out-arm64-ubuntu-22.04
path: out
- uses: actions/download-artifact@v3
with:
name: out-x64-ubuntu-22.04
name: out-ubuntu-22.04
path: out
- name: Generate the download static endpoint

View File

@ -2,7 +2,7 @@
## Zoo Modeling App
live at [app.zoo.dev](https://app.zoo.dev/)
download at [zoo.dev/modeling-app/download](https://zoo.dev/modeling-app/download)
A CAD application from the future, brought to you by the [Zoo team](https://zoo.dev).
@ -128,7 +128,18 @@ Before you submit a contribution PR to this repo, please ensure that:
## Release a new version
#### 1. Bump the versions by running `./make-release.sh` and create a Cut Release PR
#### 1. Bump the versions by running `./make-release.sh`
The `./make-release.sh` script has git commands to pull main but to be sure you can run the following git commands to have a fresh `main` locally.
```
git branch -D main
git checkout main
git pull origin
./make-release.sh
# Copy within the back ticks and paste the stdout of the change log
git push --set-upstream origin <branch name created from ./make-release.sh>
```
That will create the branch with the updated json files for you:
- run `./make-release.sh` or `./make-release.sh patch` for a patch update;
@ -137,28 +148,32 @@ That will create the branch with the updated json files for you:
After it runs you should just need the push the branch and open a PR.
**Important:** It needs to be prefixed with `Cut release v` to build in release mode and a few other things to test in the best context possible, the intent would be for instance to have `Cut release v1.2.3` for the `v1.2.3` release candidate.
#### 2. Create a Cut Release PR
When you open the PR copy the change log from the output of the `./make-release.sh` script into the description of the PR.
**Important:** Pull request title needs to be prefixed with `Cut release v` to build in release mode and a few other things to test in the best context possible, the intent would be for instance to have `Cut release v1.2.3` for the `v1.2.3` release candidate.
The PR may then serve as a place to discuss the human-readable changelog and extra QA. The `make-release.sh` tool suggests a changelog for you too to be used as PR description, just make sure to delete lines that are not user facing.
#### 2. Smoke test artifacts from the Cut Release PR
#### 3. Manually test artifacts from the Cut Release PR
The release builds can be find under the `artifact` zip, at the very bottom of the `ci` action page for each commit on this branch.
We don't have a strict process, but click around and check for anything obvious, posting results as comments in the Cut Release PR.
Manually test against this [list](https://github.com/KittyCAD/modeling-app/issues/3588) across Windows, MacOS, Linux and posting results as comments in the Cut Release PR.
The other `ci` output in Cut Release PRs is `updater-test`, because we don't have a way to test this fully automated, we have a semi-automated process. Download updater-test zip file, install the app, run it, expect an updater prompt to a dummy v0.99.99, install it and check that the app comes back at that version (on both macOS and Windows).
#### 3. Merge the Cut Release PR
#### 4. Merge the Cut Release PR
This will kick the `create-release` action, that creates a _Draft_ release out of this Cut Release PR merge after less than a minute, with the new version as title and Cut Release PR as description.
#### 4. Publish the release
#### 5. Publish the release
Head over to https://github.com/KittyCAD/modeling-app/releases, the draft release corresponding to the merged Cut Release PR should show up at the top as _Draft_. Click on it, verify the content, and hit _Publish_.
#### 5. Profit
#### 6. Profit
A new Action kicks in at https://github.com/KittyCAD/modeling-app/actions, which can be found under `release` event filter.

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,10 @@
---
title: "KclValue"
excerpt: "A memory item."
excerpt: "Any KCL value."
layout: manual
---
A memory item.
Any KCL value.
@ -80,7 +80,7 @@ A plane.
|----------|------|-------------|----------|
| `type` |enum: `Plane`| | No |
| `id` |`string`| The id of the plane. | No |
| `value` |[`PlaneType`](/docs/kcl/types/PlaneType)| A memory item. | No |
| `value` |[`PlaneType`](/docs/kcl/types/PlaneType)| Any KCL value. | No |
| `origin` |[`Point3d`](/docs/kcl/types/Point3d)| Origin of the plane. | No |
| `xAxis` |[`Point3d`](/docs/kcl/types/Point3d)| What should the planes X axis be? | No |
| `yAxis` |[`Point3d`](/docs/kcl/types/Point3d)| What should the planes Y axis be? | No |
@ -183,8 +183,8 @@ Data for an imported geometry.
| Property | Type | Description | Required |
|----------|------|-------------|----------|
| `type` |enum: `Function`| | No |
| `expression` |[`FunctionExpression`](/docs/kcl/types/FunctionExpression)| A memory item. | No |
| `memory` |[`ProgramMemory`](/docs/kcl/types/ProgramMemory)| A memory item. | No |
| `expression` |[`FunctionExpression`](/docs/kcl/types/FunctionExpression)| Any KCL value. | No |
| `memory` |[`ProgramMemory`](/docs/kcl/types/ProgramMemory)| Any KCL value. | No |
| `__meta` |`[` [`Metadata`](/docs/kcl/types/Metadata) `]`| | No |

View File

@ -13,6 +13,13 @@ type mouseParams = {
pixelDiff: number
}
type SceneSerialised = {
camera: {
position: [number, number, number]
target: [number, number, number]
}
}
export class SceneFixture {
public page: Page
@ -22,6 +29,22 @@ export class SceneFixture {
this.page = page
this.reConstruct(page)
}
private _serialiseScene = async (): Promise<SceneSerialised> => {
const camera = await this.getCameraInfo()
return {
camera,
}
}
expectState = async (expected: SceneSerialised) => {
return expect
.poll(() => this._serialiseScene(), {
message: `Expected scene state to match`,
})
.toEqual(expected)
}
reConstruct = (page: Page) => {
this.page = page
@ -31,7 +54,7 @@ export class SceneFixture {
makeMouseHelpers = (
x: number,
y: number,
{ steps }: { steps: number } = { steps: 5000 }
{ steps }: { steps: number } = { steps: 20 }
) =>
[
(clickParams?: mouseParams) => {
@ -87,6 +110,36 @@ export class SceneFixture {
)
await closeDebugPanel(this.page)
}
/** Forces a refresh of the camera position and target displayed
* in the debug panel and then returns the values of the fields
*/
async getCameraInfo() {
await openAndClearDebugPanel(this.page)
await sendCustomCmd(this.page, {
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
})
await this.waitForExecutionDone()
const position = await Promise.all([
this.page.getByTestId('cam-x-position').inputValue().then(Number),
this.page.getByTestId('cam-y-position').inputValue().then(Number),
this.page.getByTestId('cam-z-position').inputValue().then(Number),
])
const target = await Promise.all([
this.page.getByTestId('cam-x-target').inputValue().then(Number),
this.page.getByTestId('cam-y-target').inputValue().then(Number),
this.page.getByTestId('cam-z-target').inputValue().then(Number),
])
await closeDebugPanel(this.page)
return {
position,
target,
}
}
waitForExecutionDone = async () => {
await expect(this.exeIndicator).toBeVisible()
}
@ -114,4 +167,17 @@ export class SceneFixture {
)
})
}
get gizmo() {
return this.page.locator('[aria-label*=gizmo]')
}
async clickGizmoMenuItem(name: string) {
await this.gizmo.click({ button: 'right' })
const buttonToTest = this.page.getByRole('button', {
name: name,
})
await expect(buttonToTest).toBeVisible()
await buttonToTest.click()
}
}

View File

@ -521,7 +521,6 @@ test(
const startXPx = 600
// Equip the rectangle tool
await page.getByRole('button', { name: 'line Line', exact: true }).click()
await page
.getByRole('button', { name: 'rectangle Corner rectangle', exact: true })
.click()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -1,18 +1,18 @@
import { test, expect } from '@playwright/test'
import { _test, _expect } from './playwright-deprecated'
import { test } from './fixtures/fixtureSetup'
import { getUtils, setup, tearDown } from './test-utils'
import { uuidv4 } from 'lib/utils'
import { TEST_CODE_GIZMO } from './storageStates'
test.beforeEach(async ({ context, page }, testInfo) => {
_test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page, testInfo)
})
test.afterEach(async ({ page }, testInfo) => {
_test.afterEach(async ({ page }, testInfo) => {
await tearDown(page, testInfo)
})
test.describe('Testing Gizmo', () => {
_test.describe('Testing Gizmo', () => {
const cases = [
{
testDescription: 'top view',
@ -57,7 +57,7 @@ test.describe('Testing Gizmo', () => {
expectedCameraTarget,
testDescription,
} of cases) {
test(`check ${testDescription}`, async ({ page, browserName }) => {
_test(`check ${testDescription}`, async ({ page, browserName }) => {
const u = await getUtils(page)
await page.addInitScript((TEST_CODE_GIZMO) => {
localStorage.setItem('persistCode', TEST_CODE_GIZMO)
@ -117,30 +117,30 @@ test.describe('Testing Gizmo', () => {
await Promise.all([
// position
expect(page.getByTestId('cam-x-position')).toHaveValue(
_expect(page.getByTestId('cam-x-position')).toHaveValue(
expectedCameraPosition.x.toString()
),
expect(page.getByTestId('cam-y-position')).toHaveValue(
_expect(page.getByTestId('cam-y-position')).toHaveValue(
expectedCameraPosition.y.toString()
),
expect(page.getByTestId('cam-z-position')).toHaveValue(
_expect(page.getByTestId('cam-z-position')).toHaveValue(
expectedCameraPosition.z.toString()
),
// target
expect(page.getByTestId('cam-x-target')).toHaveValue(
_expect(page.getByTestId('cam-x-target')).toHaveValue(
expectedCameraTarget.x.toString()
),
expect(page.getByTestId('cam-y-target')).toHaveValue(
_expect(page.getByTestId('cam-y-target')).toHaveValue(
expectedCameraTarget.y.toString()
),
expect(page.getByTestId('cam-z-target')).toHaveValue(
_expect(page.getByTestId('cam-z-target')).toHaveValue(
expectedCameraTarget.z.toString()
),
])
})
}
test('Context menu and popover menu', async ({ page }) => {
_test('Context menu and popover menu', async ({ page }) => {
const testCase = {
testDescription: 'Right view',
expectedCameraPosition: { x: 5660.02, y: -152, z: 26 },
@ -196,7 +196,7 @@ test.describe('Testing Gizmo', () => {
const buttonToTest = page.getByRole('button', {
name: testCase.testDescription,
})
await expect(buttonToTest).toBeVisible()
await _expect(buttonToTest).toBeVisible()
await buttonToTest.click()
// Now assert we've moved to the correct view
@ -215,23 +215,23 @@ test.describe('Testing Gizmo', () => {
await Promise.all([
// position
expect(page.getByTestId('cam-x-position')).toHaveValue(
_expect(page.getByTestId('cam-x-position')).toHaveValue(
testCase.expectedCameraPosition.x.toString()
),
expect(page.getByTestId('cam-y-position')).toHaveValue(
_expect(page.getByTestId('cam-y-position')).toHaveValue(
testCase.expectedCameraPosition.y.toString()
),
expect(page.getByTestId('cam-z-position')).toHaveValue(
_expect(page.getByTestId('cam-z-position')).toHaveValue(
testCase.expectedCameraPosition.z.toString()
),
// target
expect(page.getByTestId('cam-x-target')).toHaveValue(
_expect(page.getByTestId('cam-x-target')).toHaveValue(
testCase.expectedCameraTarget.x.toString()
),
expect(page.getByTestId('cam-y-target')).toHaveValue(
_expect(page.getByTestId('cam-y-target')).toHaveValue(
testCase.expectedCameraTarget.y.toString()
),
expect(page.getByTestId('cam-z-target')).toHaveValue(
_expect(page.getByTestId('cam-z-target')).toHaveValue(
testCase.expectedCameraTarget.z.toString()
),
])
@ -242,8 +242,60 @@ test.describe('Testing Gizmo', () => {
const gizmoPopoverButton = page.getByRole('button', {
name: 'view settings',
})
await expect(gizmoPopoverButton).toBeVisible()
await _expect(gizmoPopoverButton).toBeVisible()
await gizmoPopoverButton.click()
await expect(buttonToTest).toBeVisible()
await _expect(buttonToTest).toBeVisible()
})
})
test.describe(`Testing gizmo, fixture-based`, () => {
test('Center on selection from menu', async ({
app,
cmdBar,
editor,
toolbar,
scene,
}) => {
test.skip(
process.platform === 'win32',
'Fails on windows in CI, can not be replicated locally on windows.'
)
await test.step(`Setup`, async () => {
const file = await app.getInputFile('test-circle-extrude.kcl')
await app.initialise(file)
await scene.expectState({
camera: {
position: [4982.21, -23865.37, 13810.64],
target: [4982.21, 0, 2737.1],
},
})
})
const [clickCircle, moveToCircle] = scene.makeMouseHelpers(582, 217)
await test.step(`Select an edge of this circle`, async () => {
const circleSnippet =
'circle({ center: [318.33, 168.1], radius: 182.8 }, %)'
await moveToCircle()
await clickCircle()
await editor.expectState({
activeLines: [circleSnippet.slice(-5)],
highlightedCode: circleSnippet,
diagnostics: [],
})
})
await test.step(`Center on selection from menu`, async () => {
await scene.clickGizmoMenuItem('Center view on selection')
})
await test.step(`Verify the camera moved`, async () => {
await scene.expectState({
camera: {
position: [0, -23865.37, 11073.54],
target: [0, 0, 0],
},
})
})
})
})

View File

@ -1208,6 +1208,12 @@ extrude001 = extrude(50, sketch001)
test('Deselecting line tool should mean nothing happens on click', async ({
page,
}) => {
/**
* If the line tool is clicked when the state is 'No Points' it will exit Sketch mode.
* This is the same exact workflow as pressing ESC.
*
* To continue to test this workflow, we now enter sketch mode and place a single point before exiting the line tool.
*/
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
@ -1228,6 +1234,7 @@ extrude001 = extrude(50, sketch001)
200
)
// Clicks the XZ Plane in the page
await page.mouse.click(700, 200)
await expect(page.locator('.cm-content')).toHaveText(
@ -1236,6 +1243,11 @@ extrude001 = extrude(50, sketch001)
await page.waitForTimeout(600)
// Place a point because the line tool will exit if no points are pressed
await page.mouse.click(650, 200)
await page.waitForTimeout(600)
// Code before exiting the tool
let previousCodeContent = await page.locator('.cm-content').innerText()
// deselect the line tool by clicking it

View File

@ -9,6 +9,7 @@ import {
executorInputPath,
} from './test-utils'
import { SaveSettingsPayload, SettingsLevel } from 'lib/settings/settingsTypes'
import { SETTINGS_FILE_NAME } from 'lib/constants'
import {
TEST_SETTINGS_KEY,
TEST_SETTINGS_CORRUPTED,
@ -343,7 +344,7 @@ test.describe('Testing settings', () => {
// Selectors and constants
const errorHeading = page.getByRole('heading', {
name: 'An unextected error occurred',
name: 'An unexpected error occurred',
})
const projectDirLink = page.getByText('Loaded from')
@ -372,7 +373,7 @@ test.describe('Testing settings', () => {
// Selectors and constants
const errorHeading = page.getByRole('heading', {
name: 'An unextected error occurred',
name: 'An unexpected error occurred',
})
const projectDirLink = page.getByText('Loaded from')
@ -384,6 +385,66 @@ test.describe('Testing settings', () => {
}
)
// It was much easier to test the logo color than the background stream color.
test(
'user settings reload on external change, on project and modeling view',
{ tag: '@electron' },
async ({ browserName }, testInfo) => {
const {
electronApp,
page,
dir: projectDirName,
} = await setupElectron({
testInfo,
appSettings: {
app: {
// Doesn't matter what you set it to. It will
// default to 264.5
themeColor: '0',
},
},
})
await page.setViewportSize({ width: 1200, height: 500 })
const logoLink = page.getByTestId('app-logo')
const projectDirLink = page.getByText('Loaded from')
await test.step('Wait for project view', async () => {
await expect(projectDirLink).toBeVisible()
await expect(logoLink).toHaveCSS('--primary-hue', '264.5')
})
const changeColor = async (color: string) => {
const tempSettingsFilePath = join(projectDirName, SETTINGS_FILE_NAME)
let tomlStr = await fsp.readFile(tempSettingsFilePath, 'utf-8')
tomlStr = tomlStr.replace(/(themeColor = ")[0-9]+(")/, `$1${color}$2`)
await fsp.writeFile(tempSettingsFilePath, tomlStr)
}
await test.step('Check color of logo changed', async () => {
await changeColor('99')
await expect(logoLink).toHaveCSS('--primary-hue', '99')
})
await test.step('Check color of logo changed when in modeling view', async () => {
await page.getByRole('button', { name: 'New project' }).click()
await page.getByTestId('project-link').first().click()
await page.getByRole('button', { name: 'Dismiss' }).click()
await changeColor('58')
await expect(logoLink).toHaveCSS('--primary-hue', '58')
})
await test.step('Check going back to projects view still changes the color', async () => {
await logoLink.click()
await expect(projectDirLink).toBeVisible()
await changeColor('21')
await expect(logoLink).toHaveCSS('--primary-hue', '21')
})
await electronApp.close()
}
)
test(
`Closing settings modal should go back to the original file being viewed`,
{ tag: '@electron' },

View File

@ -32,10 +32,10 @@ win:
arch:
- x64
- arm64
- target: msi
arch:
- x64
- arm64
# - target: msi
# arch:
# - x64
# - arm64
signingHashAlgorithms:
- sha256
sign: "./sign-win.js"
@ -47,9 +47,9 @@ win:
mimeType: text/vnd.zoo.kcl
description: Zoo KCL File
role: Editor
msi:
oneClick: false
perMachine: true
# msi:
# oneClick: false
# perMachine: true
nsis:
oneClick: false
perMachine: true

2
interface.d.ts vendored
View File

@ -23,7 +23,6 @@ export interface IElectronAPI {
callback: (eventType: string, path: string) => void
) => void
watchFileOff: (path: string) => void
watchFileObliterate: () => void
readFile: (path: string) => ReturnType<fs.readFile>
writeFile: (
path: string,
@ -70,6 +69,7 @@ export interface IElectronAPI {
kittycad: (access: string, args: any) => any
listMachines: () => Promise<MachinesListing>
getMachineApiIp: () => Promise<string | null>
readNaturalScrollDirection: () => Promise<boolean>
onUpdateDownloaded: (
callback: (value: string) => void
) => Electron.IpcRenderer

View File

@ -1,6 +1,6 @@
{
"name": "zoo-modeling-app",
"version": "0.25.5",
"version": "0.25.6",
"private": true,
"productName": "Zoo Modeling App",
"author": {
@ -26,7 +26,7 @@
"@fortawesome/react-fontawesome": "^0.2.0",
"@headlessui/react": "^1.7.19",
"@headlessui/tailwindcss": "^0.2.0",
"@kittycad/lib": "^2.0.1",
"@kittycad/lib": "2.0.7",
"@lezer/highlight": "^1.2.1",
"@lezer/lr": "^1.4.1",
"@react-hook/resize-observer": "^2.0.1",
@ -36,6 +36,7 @@
"@xstate/inspect": "^0.8.0",
"@xstate/react": "^4.1.1",
"bonjour-service": "^1.2.1",
"chokidar": "^4.0.1",
"codemirror": "^6.0.1",
"decamelize": "^6.0.0",
"electron-squirrel-startup": "^1.0.1",

View File

@ -22,7 +22,7 @@ import {
UnreliableSubscription,
} from 'lang/std/engineConnection'
import { EngineCommand } from 'lang/std/artifactGraph'
import { toSync, uuidv4 } from 'lib/utils'
import { cachedNaturalScrollDirection, toSync, uuidv4 } from 'lib/utils'
import { deg2Rad } from 'lib/utils2d'
import { isReducedMotion, roundOff, throttle } from 'lib/utils'
import * as TWEEN from '@tweenjs/tween.js'
@ -78,8 +78,13 @@ export class CameraControls {
enablePan = true
enableZoom = true
zoomDataFromLastFrame?: number = undefined
// holds coordinates, and interaction
moveDataFromLastFrame?: [number, number, string] = undefined
// Holds event type, coordinates (for wheel, it's delta), and interaction
moveDataFromLastFrame?: [
'pointer' | 'wheel',
number,
number,
interactionType
] = undefined
lastPerspectiveFov: number = 45
pendingZoom: number | null = null
pendingRotation: Vector2 | null = null
@ -283,19 +288,75 @@ export class CameraControls {
const doMove = () => {
if (this.moveDataFromLastFrame !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd: {
type: 'camera_drag_move',
interaction: this.moveDataFromLastFrame[2] as any,
window: {
x: this.moveDataFromLastFrame[0],
y: this.moveDataFromLastFrame[1],
},
},
cmd_id: uuidv4(),
})
const interaction = this.moveDataFromLastFrame[3]
if (this.moveDataFromLastFrame[0] === 'pointer') {
this.engineCommandManager
.sendSceneCommand({
type: 'modeling_cmd_req',
cmd: {
type: 'camera_drag_move',
interaction,
window: {
x: this.moveDataFromLastFrame[1],
y: this.moveDataFromLastFrame[2],
},
},
cmd_id: uuidv4(),
})
.catch(reportRejection)
} else if (this.moveDataFromLastFrame[0] === 'wheel') {
const deltaX = this.moveDataFromLastFrame[1]
const deltaY = this.moveDataFromLastFrame[2]
this.isDragging = true
this.handleStart()
this.engineCommandManager
.sendSceneCommand({
type: 'modeling_cmd_batch_req',
batch_id: uuidv4(),
requests: [
{
cmd: {
type: 'camera_drag_start',
interaction,
window: { x: 0, y: 0 },
},
cmd_id: uuidv4(),
},
{
cmd: {
type: 'camera_drag_move',
interaction,
window: {
x: -deltaX,
y: -deltaY,
},
},
cmd_id: uuidv4(),
},
{
cmd: {
type: 'camera_drag_end',
interaction,
window: {
x: -deltaX,
y: -deltaY,
},
},
cmd_id: uuidv4(),
},
],
responses: false,
})
.catch(reportRejection)
this.isDragging = false
this.handleEnd()
} else {
console.error(
`Unknown moveDataFromLastFrame event type: ${this.moveDataFromLastFrame[0]}`
)
}
}
this.moveDataFromLastFrame = undefined
}
@ -386,32 +447,16 @@ export class CameraControls {
if (interaction === 'none') return
if (this.syncDirection === 'engineToClient') {
this.moveDataFromLastFrame = [event.clientX, event.clientY, interaction]
this.moveDataFromLastFrame = [
'pointer',
event.clientX,
event.clientY,
interaction,
]
return
}
// Implement camera movement logic here based on deltaMove
// For example, for rotating the camera around the target:
if (interaction === 'rotate') {
this.pendingRotation = this.pendingRotation
? this.pendingRotation
: new Vector2()
this.pendingRotation.x += deltaMove.x
this.pendingRotation.y += deltaMove.y
} else if (interaction === 'zoom') {
this.pendingZoom = this.pendingZoom ? this.pendingZoom : 1
this.pendingZoom *= 1 + deltaMove.y * 0.01
} else if (interaction === 'pan') {
this.pendingPan = this.pendingPan ? this.pendingPan : new Vector2()
let distance = this.camera.position.distanceTo(this.target)
if (this.camera instanceof OrthographicCamera) {
const zoomFudgeFactor = 2280
distance = zoomFudgeFactor / (this.camera.zoom * 45)
}
const panSpeed = (distance / 1000 / 45) * this.perspectiveFovBeforeOrtho
this.pendingPan.x += -deltaMove.x * panSpeed
this.pendingPan.y += deltaMove.y * panSpeed
}
this.moveCamera(interaction, deltaMove)
} else {
/**
* If we're not in sketch mode and not dragging, we can highlight entities
@ -433,6 +478,31 @@ export class CameraControls {
}
}
moveCamera(interaction: interactionType, deltaMove: Vector2) {
// Implement camera movement logic here based on deltaMove
// For example, for rotating the camera around the target:
if (interaction === 'rotate') {
this.pendingRotation = this.pendingRotation
? this.pendingRotation
: new Vector2()
this.pendingRotation.x += deltaMove.x
this.pendingRotation.y += deltaMove.y
} else if (interaction === 'zoom') {
this.pendingZoom = this.pendingZoom ? this.pendingZoom : 1
this.pendingZoom *= 1 + deltaMove.y * 0.01
} else if (interaction === 'pan') {
this.pendingPan = this.pendingPan ? this.pendingPan : new Vector2()
let distance = this.camera.position.distanceTo(this.target)
if (this.camera instanceof OrthographicCamera) {
const zoomFudgeFactor = 2280
distance = zoomFudgeFactor / (this.camera.zoom * 45)
}
const panSpeed = (distance / 1000 / 45) * this.perspectiveFovBeforeOrtho
this.pendingPan.x += -deltaMove.x * panSpeed
this.pendingPan.y += deltaMove.y * panSpeed
}
}
onMouseUp = (event: PointerEvent) => {
this.domElement.releasePointerCapture(event.pointerId)
this.isDragging = false
@ -452,6 +522,20 @@ export class CameraControls {
}
}
zoomDirection = (event: WheelEvent): 1 | -1 => {
if (this.interactionGuards.zoom.pinchToZoom && isPinchToZoom(event)) {
return 1
}
if (!this.interactionGuards.zoom.scrollAllowInvertY) return 1
// Safari provides the updated user setting on every event, so it's more
// accurate than our cached value.
if ('webkitDirectionInvertedFromDevice' in event) {
return event.webkitDirectionInvertedFromDevice ? -1 : 1
}
return cachedNaturalScrollDirection ? -1 : 1
}
onMouseWheel = (event: WheelEvent) => {
const interaction = this.getInteractionType(event)
if (interaction === 'none') return
@ -459,12 +543,15 @@ export class CameraControls {
if (this.syncDirection === 'engineToClient') {
if (interaction === 'zoom') {
this.zoomDataFromLastFrame = event.deltaY
const zoomDir = this.zoomDirection(event)
this.zoomDataFromLastFrame = event.deltaY * zoomDir
} else {
// This case will get handled when we add pan and rotate using Apple trackpad.
console.error(
`Unexpected interaction type for engineToClient wheel event: ${interaction}`
)
this.moveDataFromLastFrame = [
'wheel',
event.deltaX,
event.deltaY,
interaction,
]
}
return
}
@ -478,12 +565,20 @@ export class CameraControls {
this.handleStart()
if (interaction === 'zoom') {
this.pendingZoom = 1 + (event.deltaY / window.devicePixelRatio) * 0.001
const zoomDir = this.zoomDirection(event)
this.pendingZoom =
1 + (event.deltaY / window.devicePixelRatio) * 0.001 * zoomDir
} else {
// This case will get handled when we add pan and rotate using Apple trackpad.
console.error(
`Unexpected interaction type for wheel event: ${interaction}`
this.isDragging = true
this.mouseDownPosition.set(event.clientX, event.clientY)
this.moveCamera(interaction, new Vector2(-event.deltaX, -event.deltaY))
this.mouseDownPosition.set(
event.clientX + event.deltaX,
event.clientY + event.deltaY
)
this.isDragging = false
}
this.handleEnd()
}
@ -893,6 +988,7 @@ export class CameraControls {
type: 'zoom_to_fit',
object_ids: [], // leave empty to zoom to all objects
padding: 0.2, // padding around the objects
animated: false, // don't animate the zoom for now
},
})
}
@ -1265,8 +1361,17 @@ function _getInteractionType(
enableZoom: boolean
): interactionType | 'none' {
if (event instanceof WheelEvent) {
if (enableZoom && interactionGuards.zoom.scrollCallback(event))
return 'zoom'
// If the control scheme accepts pinch-to-zoom, and the event is
// pinch-to-zoom, never consider other interaction types.
if (interactionGuards.zoom.pinchToZoom && isPinchToZoom(event)) {
if (enableZoom) return 'zoom'
} else {
if (enablePan && interactionGuards.pan.scrollCallback(event)) return 'pan'
if (enableRotate && interactionGuards.rotate.scrollCallback(event))
return 'rotate'
if (enableZoom && interactionGuards.zoom.scrollCallback(event))
return 'zoom'
}
} else {
if (enablePan && interactionGuards.pan.callback(event)) return 'pan'
if (enableRotate && interactionGuards.rotate.callback(event))
@ -1276,6 +1381,18 @@ function _getInteractionType(
return 'none'
}
function isPinchToZoom(event: WheelEvent): boolean {
// Browsers do this hack. A couple issues:
//
// - According to MDN, it doesn't work on iOS.
// - It doesn't differentiate with a user actually holding Control and
// scrolling normally. It's possible to detect this by using onKeyDown and
// onKeyUp to track the state of the Control key. But we currently don't
// care about this since only the Apple Trackpad scheme looks for
// pinch-to-zoom events using interactionGuards.zoom.pinchToZoom.
return event.ctrlKey
}
/**
* Tells the engine to fire it's animation waits for it to finish and then requests camera settings
* to ensure the client-side camera is synchronized with the engine's camera state.

View File

@ -91,7 +91,7 @@ function CommandBarSelectionInput({
<form id="arg-form" onSubmit={handleSubmit}>
<label
className={
'relative flex items-center mx-4 my-4 ' +
'relative flex flex-col mx-4 my-4 ' +
(!hasSubmitted || canSubmitSelection || 'text-destroy-50')
}
>
@ -100,13 +100,18 @@ function CommandBarSelectionInput({
: `Please select ${
arg.multiple ? 'one or more ' : 'one '
}${getSemanticSelectionType(arg.selectionTypes).join(' or ')}`}
{arg.warningMessage && (
<p className="text-warn-80 bg-warn-10 px-2 py-1 rounded-sm mt-3 mr-2 -mb-2 w-full text-sm cursor-default">
{arg.warningMessage}
</p>
)}
<input
id="selection"
name="selection"
ref={inputRef}
required
placeholder="Select an entity with your mouse"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
className="absolute inset-0 w-full h-full opacity-0 cursor-default"
onKeyDown={(event) => {
if (event.key === 'Backspace') {
stepBack()

View File

@ -28,6 +28,7 @@ import {
import { Popover } from '@headlessui/react'
import { CustomIcon } from './CustomIcon'
import { reportRejection } from 'lib/trap'
import { useModelingContext } from 'hooks/useModelingContext'
const CANVAS_SIZE = 80
const FRUSTUM_SIZE = 0.5
@ -62,6 +63,7 @@ export default function Gizmo() {
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]) => (
@ -76,6 +78,7 @@ export default function Gizmo() {
{axisSemantic} view
</ContextMenuItem>
)),
<ContextMenuDivider />,
<ContextMenuItem
onClick={() => {
sceneInfra.camControls.resetCameraPosition().catch(reportRejection)
@ -83,6 +86,13 @@ export default function Gizmo() {
>
Reset view
</ContextMenuItem>,
<ContextMenuItem
onClick={() => {
modelingSend({ type: 'Center camera on selection' })
}}
>
Center view on selection
</ContextMenuItem>,
<ContextMenuDivider />,
<ContextMenuItemRefresh />,
],

View File

@ -83,6 +83,7 @@ import {
} from 'lang/std/engineConnection'
import { submitAndAwaitTextToKcl } from 'lib/textToCad'
import { useFileContext } from 'hooks/useFileContext'
import { uuidv4 } from 'lib/utils'
type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T>
@ -148,6 +149,13 @@ export const ModelingMachineProvider = ({
},
'sketch exit execute': ({ context: { store } }) => {
;(async () => {
// When cancelling the sketch mode we should disable sketch mode within the engine.
await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: { type: 'sketch_mode_disable' },
})
sceneInfra.camControls.syncDirection = 'clientToEngine'
if (cameraProjection.current === 'perspective') {
@ -243,6 +251,17 @@ export const ModelingMachineProvider = ({
return {}
},
}),
'Center camera on selection': () => {
engineCommandManager
.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_center_to_selection',
},
})
.catch(reportRejection)
},
'Set sketchDetails': assign(({ context: { sketchDetails }, event }) => {
if (event.type !== 'Delete segment') return {}
if (!sketchDetails) return {}
@ -1037,6 +1056,11 @@ export const ModelingMachineProvider = ({
modelingSend({ type: 'Delete selection' })
})
// Allow ctrl+alt+c to center to selection
useHotkeys(['mod + alt + c'], () => {
modelingSend({ type: 'Center camera on selection' })
})
useStateMachineCommands({
machineId: 'modeling',
state: modelingState,

View File

@ -1,9 +1,10 @@
import { trap } from 'lib/trap'
import { useMachine } from '@xstate/react'
import { useNavigate, useRouteLoaderData, useLocation } from 'react-router-dom'
import { PATHS } from 'lib/paths'
import { authMachine, TOKEN_PERSIST_KEY } from '../machines/authMachine'
import withBaseUrl from '../lib/withBaseURL'
import React, { createContext, useEffect } from 'react'
import React, { createContext, useEffect, useState } from 'react'
import useStateMachineCommands from '../hooks/useStateMachineCommands'
import { settingsMachine } from 'machines/settingsMachine'
import { toast } from 'react-hot-toast'
@ -15,7 +16,6 @@ import {
} from 'lib/theme'
import decamelize from 'decamelize'
import { Actor, AnyStateMachine, ContextFrom, Prop, StateFrom } from 'xstate'
import { isDesktop } from 'lib/isDesktop'
import { authCommandBarConfig } from 'lib/commandBarConfigs/authCommandConfig'
import {
kclManager,
@ -33,8 +33,14 @@ import {
import { useCommandsContext } from 'hooks/useCommandsContext'
import { Command } from 'lib/commandTypes'
import { BaseUnit } from 'lib/settings/settingsTypes'
import { saveSettings } from 'lib/settings/settingsUtils'
import {
saveSettings,
loadAndValidateSettings,
} from 'lib/settings/settingsUtils'
import { reportRejection } from 'lib/trap'
import { getAppSettingsFilePath } from 'lib/desktop'
import { isDesktop } from 'lib/isDesktop'
import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher'
type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T>
@ -99,6 +105,9 @@ export const SettingsAuthProviderBase = ({
const location = useLocation()
const navigate = useNavigate()
const { commandBarSend } = useCommandsContext()
const [settingsPath, setSettingsPath] = useState<string | undefined>(
undefined
)
const [settingsState, settingsSend, settingsActor] = useMachine(
settingsMachine.provide({
@ -191,7 +200,11 @@ export const SettingsAuthProviderBase = ({
console.error('Error executing AST after settings change', e)
}
},
persistSettings: ({ context }) => {
persistSettings: ({ context, event }) => {
// Without this, when a user changes the file, it'd
// create a detection loop with the file-system watcher.
if (event.doNotPersist) return
// eslint-disable-next-line @typescript-eslint/no-floating-promises
saveSettings(context, loadedProject?.project?.path)
},
@ -201,6 +214,23 @@ export const SettingsAuthProviderBase = ({
)
settingsStateRef = settingsState.context
useEffect(() => {
if (!isDesktop()) return
getAppSettingsFilePath().then(setSettingsPath).catch(trap)
}, [])
useFileSystemWatcher(
async () => {
const data = await loadAndValidateSettings(loadedProject?.project?.path)
settingsSend({
type: 'Set all settings',
settings: data.settings,
doNotPersist: true,
})
},
settingsPath ? [settingsPath] : []
)
// Add settings commands to the command bar
// They're treated slightly differently than other commands
// Because their state machine doesn't have a meaningful .nextEvents,

View File

@ -1,4 +1,5 @@
import { isDesktop } from 'lib/isDesktop'
import { reportRejection } from 'lib/trap'
import { useEffect, useState, useRef } from 'react'
type Path = string
@ -11,13 +12,13 @@ type Path = string
// watcher.addListener(() => { ... }).
export const useFileSystemWatcher = (
callback: (path: Path) => void,
callback: (path: Path) => Promise<void>,
dependencyArray: Path[]
): void => {
// Track a ref to the callback. This is how we get the callback updated
// across the NodeJS<->Browser boundary.
const callbackRef = useRef<{ fn: (path: Path) => void }>({
fn: (_path) => {},
const callbackRef = useRef<{ fn: (path: Path) => Promise<void> }>({
fn: async (_path) => {},
})
useEffect(() => {
@ -35,7 +36,9 @@ export const useFileSystemWatcher = (
if (!isDesktop()) return
return () => {
window.electron.watchFileObliterate()
for (let path of dependencyArray) {
window.electron.watchFileOff(path)
}
}
}, [])
@ -46,6 +49,9 @@ export const useFileSystemWatcher = (
]
}
const hasDiff =
difference(dependencyArray, dependencyArrayTracked)[0].length !== 0
// Removing 1 watcher at a time is only possible because in a filesystem,
// a path is unique (there can never be two paths with the same name).
// Otherwise we would have to obliterate() the whole list and reconstruct it.
@ -53,6 +59,8 @@ export const useFileSystemWatcher = (
// The hook is useless on web.
if (!isDesktop()) return
if (!hasDiff) return
const [pathsRemoved, pathsRemaining] = difference(
dependencyArrayTracked,
dependencyArray
@ -62,10 +70,10 @@ export const useFileSystemWatcher = (
}
const [pathsAdded] = difference(dependencyArray, dependencyArrayTracked)
for (let path of pathsAdded) {
window.electron.watchFileOn(path, (_eventType: string, path: Path) =>
callbackRef.current.fn(path)
)
window.electron.watchFileOn(path, (_eventType: string, path: Path) => {
callbackRef.current.fn(path).catch(reportRejection)
})
}
setDependencyArrayTracked(pathsRemaining.concat(pathsAdded))
}, [difference(dependencyArray, dependencyArrayTracked)[0].length !== 0])
}, [hasDiff])
}

View File

@ -282,6 +282,7 @@ export class KclManager {
type: 'zoom_to_fit',
object_ids: zoomObjectId ? [zoomObjectId] : [], // leave empty to zoom to all objects
padding: 0.1, // padding around the objects
animated: false, // don't animate the zoom for now
},
})
}
@ -294,6 +295,12 @@ export class KclManager {
this._cancelTokens.delete(currentExecutionId)
return
}
// Exit sketch mode if the AST is empty
if (this._isAstEmpty(ast)) {
await this.disableSketchMode()
}
this.logs = logs
// Do not add the errors since the program was interrupted and the error is not a real KCL error
this.addKclErrors(isInterrupted ? [] : errors)
@ -552,6 +559,24 @@ export class KclManager {
defaultSelectionFilter() {
defaultSelectionFilter(this.programMemory, this.engineCommandManager)
}
/**
* We can send a single command of 'enable_sketch_mode' or send this in a batched request.
* When there is no code in the KCL editor we should be sending 'sketch_mode_disable' since any previous half finished
* code could leave the state of the application in sketch mode on the engine side.
*/
async disableSketchMode() {
await this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: { type: 'sketch_mode_disable' },
})
}
// Determines if there is no KCL code which means it is executing a blank KCL file
_isAstEmpty(ast: Program) {
return ast.start === 0 && ast.end === 0 && ast.body.length === 0
}
}
function defaultSelectionFilter(

View File

@ -13,6 +13,7 @@ export type CameraSystem =
| 'KittyCAD'
| 'OnShape'
| 'Trackpad Friendly'
| 'Apple Trackpad'
| 'Solidworks'
| 'NX'
| 'Creo'
@ -22,6 +23,7 @@ export const cameraSystems: CameraSystem[] = [
'KittyCAD',
'OnShape',
'Trackpad Friendly',
'Apple Trackpad',
'Solidworks',
'NX',
'Creo',
@ -38,6 +40,8 @@ export function mouseControlsToCameraSystem(
return 'OnShape'
case 'trackpad_friendly':
return 'Trackpad Friendly'
case 'apple_trackpad':
return 'Apple Trackpad'
case 'solidworks':
return 'Solidworks'
case 'nx':
@ -54,6 +58,7 @@ export function mouseControlsToCameraSystem(
interface MouseGuardHandler {
description: string
callback: (e: MouseEvent) => boolean
scrollCallback: (e: WheelEvent) => boolean
lenientDragStartButton?: number
}
@ -61,6 +66,8 @@ interface MouseGuardZoomHandler {
description: string
dragCallback: (e: MouseEvent) => boolean
scrollCallback: (e: WheelEvent) => boolean
scrollAllowInvertY?: boolean
pinchToZoom?: boolean
lenientDragStartButton?: number
}
@ -83,6 +90,7 @@ export const cameraMouseDragGuards: Record<CameraSystem, MouseGuard> = {
callback: (e) =>
(btnName(e).middle && noModifiersPressed(e)) ||
(btnName(e).right && e.shiftKey),
scrollCallback: () => false,
},
zoom: {
description: 'Scroll or Ctrl + Right click drag',
@ -92,6 +100,7 @@ export const cameraMouseDragGuards: Record<CameraSystem, MouseGuard> = {
rotate: {
description: 'Right click drag',
callback: (e) => btnName(e).right && noModifiersPressed(e),
scrollCallback: () => false,
},
},
OnShape: {
@ -100,6 +109,7 @@ export const cameraMouseDragGuards: Record<CameraSystem, MouseGuard> = {
callback: (e) =>
(btnName(e).right && e.ctrlKey) ||
(btnName(e).middle && noModifiersPressed(e)),
scrollCallback: () => false,
},
zoom: {
description: 'Scroll',
@ -109,6 +119,7 @@ export const cameraMouseDragGuards: Record<CameraSystem, MouseGuard> = {
rotate: {
description: 'Right click drag',
callback: (e) => btnName(e).right && noModifiersPressed(e),
scrollCallback: () => false,
},
},
'Trackpad Friendly': {
@ -117,6 +128,7 @@ export const cameraMouseDragGuards: Record<CameraSystem, MouseGuard> = {
callback: (e) =>
(btnName(e).left && e.altKey && e.shiftKey && !e.metaKey) ||
(btnName(e).middle && noModifiersPressed(e)),
scrollCallback: () => false,
},
zoom: {
description: `Scroll or ${ALT} + ${META} + Left click drag`,
@ -126,13 +138,45 @@ export const cameraMouseDragGuards: Record<CameraSystem, MouseGuard> = {
rotate: {
description: `${ALT} + Left click drag`,
callback: (e) => btnName(e).left && e.altKey && !e.shiftKey && !e.metaKey,
scrollCallback: () => false,
lenientDragStartButton: 0,
},
},
'Apple Trackpad': {
pan: {
description: `Scroll or one finger drag`,
callback: (e) => btnName(e).left && noModifiersPressed(e),
scrollCallback: (e) => e.deltaMode === 0 && noModifiersPressed(e),
lenientDragStartButton: 0,
},
zoom: {
description: `Shift + Scroll`,
dragCallback: (e) => false,
scrollCallback: (e) =>
e.deltaMode === 0 &&
e.shiftKey &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey,
scrollAllowInvertY: true,
pinchToZoom: true,
},
rotate: {
description: `${ALT} + Scroll`,
callback: (e) => false,
scrollCallback: (e) =>
e.deltaMode === 0 &&
e.altKey &&
!e.ctrlKey &&
!e.shiftKey &&
!e.metaKey,
},
},
Solidworks: {
pan: {
description: 'Ctrl + Right click drag',
callback: (e) => btnName(e).right && e.ctrlKey,
scrollCallback: () => false,
lenientDragStartButton: 2,
},
zoom: {
@ -143,12 +187,14 @@ export const cameraMouseDragGuards: Record<CameraSystem, MouseGuard> = {
rotate: {
description: 'Middle click drag',
callback: (e) => btnName(e).middle && noModifiersPressed(e),
scrollCallback: () => false,
},
},
NX: {
pan: {
description: 'Shift + Middle click drag',
callback: (e) => btnName(e).middle && e.shiftKey,
scrollCallback: () => false,
},
zoom: {
description: 'Scroll or Ctrl + Middle click drag',
@ -158,12 +204,14 @@ export const cameraMouseDragGuards: Record<CameraSystem, MouseGuard> = {
rotate: {
description: 'Middle click drag',
callback: (e) => btnName(e).middle && noModifiersPressed(e),
scrollCallback: () => false,
},
},
Creo: {
pan: {
description: 'Ctrl + Left click drag',
callback: (e) => btnName(e).left && !btnName(e).right && e.ctrlKey,
scrollCallback: () => false,
},
zoom: {
description: 'Scroll or Ctrl + Right click drag',
@ -176,12 +224,14 @@ export const cameraMouseDragGuards: Record<CameraSystem, MouseGuard> = {
const b = btnName(e)
return (b.middle || (b.left && b.right)) && e.ctrlKey
},
scrollCallback: () => false,
},
},
AutoCAD: {
pan: {
description: 'Middle click drag',
callback: (e) => btnName(e).middle && noModifiersPressed(e),
scrollCallback: () => false,
},
zoom: {
description: 'Scroll',
@ -191,6 +241,7 @@ export const cameraMouseDragGuards: Record<CameraSystem, MouseGuard> = {
rotate: {
description: 'Shift + Middle click drag',
callback: (e) => btnName(e).middle && e.shiftKey,
scrollCallback: () => false,
},
},
}

View File

@ -281,6 +281,8 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
multiple: true,
required: true,
skip: false,
warningMessage:
'Fillets cannot touch other fillets yet. This is under development.',
},
radius: {
inputType: 'kcl',

View File

@ -113,6 +113,7 @@ export type CommandArgumentConfig<
commandBarContext: { argumentsToSubmit: Record<string, unknown> }, // Should be the commandbarMachine's context, but it creates a circular dependency
machineContext?: C
) => boolean)
warningMessage?: string
skip?: boolean
/** For showing a summary display of the current value, such as in
* the command bar's header
@ -189,6 +190,7 @@ export type CommandArgument<
) => boolean)
skip?: boolean
machineActor?: Actor<T>
warningMessage?: string
/** For showing a summary display of the current value, such as in
* the command bar's header
*/

View File

@ -152,6 +152,7 @@ export function buildCommandArgument<
skip: arg.skip,
machineActor,
valueSummary: arg.valueSummary,
warningMessage: arg.warningMessage ?? '',
} satisfies Omit<CommandArgument<O, T>, 'inputType'>
if (arg.inputType === 'options') {

View File

@ -379,7 +379,7 @@ const getAppFolderName = () => {
return window.electron.packageJson.name
}
const getAppSettingsFilePath = async () => {
export const getAppSettingsFilePath = async () => {
const isTestEnv = window.electron.process.env.IS_PLAYWRIGHT === 'true'
const testSettingsPath = window.electron.process.env.TEST_SETTINGS_FILE_KEY
const appConfig = await window.electron.getPath('appData')
@ -565,3 +565,7 @@ export const getUser = async (
}
return Promise.reject(new Error('unreachable'))
}
export async function readNaturalScrollDirection() {
return window.electron.readNaturalScrollDirection()
}

View File

@ -145,6 +145,13 @@ export const interactionMap: Record<
description:
'Available while modeling with either a face selected or an empty selection, when not typing in the code editor.',
},
{
name: 'center-on-selection',
sequence: `${PRIMARY}+Alt+C`,
title: 'Center on selection',
description:
'Centers the view on the selected geometry, or everything if nothing is selected.',
},
],
'Code Editor': [
{

View File

@ -177,14 +177,14 @@ export async function loadAndValidateSettings(
if (err(appSettingsPayload)) return Promise.reject(appSettingsPayload)
const settings = createSettings()
let settingsNext = createSettings()
// Because getting the default directory is async, we need to set it after
if (onDesktop) {
settings.app.projectDirectory.default = await getInitialDefaultDir()
}
setSettingsAtLevel(
settings,
settingsNext = setSettingsAtLevel(
settingsNext,
'user',
configurationToSettingsPayload(appSettingsPayload)
)
@ -199,8 +199,8 @@ export async function loadAndValidateSettings(
return Promise.reject(new Error('Invalid project settings'))
const projectSettingsPayload = projectSettings
setSettingsAtLevel(
settings,
settingsNext = setSettingsAtLevel(
settingsNext,
'project',
projectConfigurationToSettingsPayload(projectSettingsPayload)
)
@ -208,7 +208,7 @@ export async function loadAndValidateSettings(
// Return the settings object
return {
settings,
settings: settingsNext,
configuration: appSettingsPayload,
}
}

View File

@ -49,6 +49,7 @@ if (typeof window !== 'undefined') {
type: 'zoom_to_fit',
object_ids: [], // leave empty to zoom to all objects
padding: 0.2, // padding around the objects
animated: false, // don't animate the zoom for now
},
})
}

View File

@ -249,7 +249,7 @@ export async function submitAndAwaitTextToKcl({
export async function sendTelemetry(
id: string,
feedback: Models['AiFeedback_type'],
feedback: Models['MlFeedback_type'],
token?: string
): Promise<void> {
const url =

View File

@ -295,15 +295,24 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
'break',
{
id: 'line',
onClick: ({ modelingState, modelingSend }) =>
modelingSend({
type: 'change tool',
data: {
tool: !modelingState.matches({ Sketch: 'Line tool' })
? 'line'
: 'none',
},
}),
onClick: ({ modelingState, modelingSend }) => {
if (modelingState.matches({ Sketch: { 'Line tool': 'No Points' } })) {
// Exit the sketch state if there are no points and they press ESC
modelingSend({
type: 'Cancel',
})
} else {
// Exit the tool if there are points and they press ESC
modelingSend({
type: 'change tool',
data: {
tool: !modelingState.matches({ Sketch: 'Line tool' })
? 'line'
: 'none',
},
})
}
},
icon: 'line',
status: 'available',
disabled: (state) =>

View File

@ -4,6 +4,7 @@ import { v4 } from 'uuid'
import { isDesktop } from './isDesktop'
import { AnyMachineSnapshot } from 'xstate'
import { AsyncFn } from './types'
import { readNaturalScrollDirection } from './desktop'
export const uuidv4 = v4
@ -262,6 +263,19 @@ export function isReducedMotion(): boolean {
)
}
/**
* True if Apple Trackpad scroll should move the content. I.e. if this is true,
* and the user scrolls down, the viewport moves up relative to the content.
*/
export let cachedNaturalScrollDirection = platform() === 'macos'
export async function refreshNaturalScrollDirection() {
if (!isDesktop()) return cachedNaturalScrollDirection
const isNatural = await readNaturalScrollDirection()
cachedNaturalScrollDirection = isNatural
return isNatural
}
export function XOR(bool1: boolean, bool2: boolean): boolean {
return (bool1 || bool2) && !(bool1 && bool2)
}

View File

@ -252,6 +252,9 @@ export type ModelingMachineEvent =
type: 'Set Segment Overlays'
data: SegmentOverlayPayload
}
| {
type: 'Center camera on selection'
}
| {
type: 'Delete segment'
data: PathToNode
@ -938,6 +941,7 @@ export const modelingMachine = setup({
'Set selection': () => {},
'Set mouse state': () => {},
'Set Segment Overlays': () => {},
'Center camera on selection': () => {},
'Engine export': () => {},
'Submit to Text-to-CAD API': () => {},
'Set sketchDetails': () => {},
@ -2105,6 +2109,10 @@ export const modelingMachine = setup({
reenter: false,
actions: 'Set Segment Overlays',
},
'Center camera on selection': {
reenter: false,
actions: 'Center camera on selection',
},
},
})

View File

@ -19,7 +19,7 @@ export const settingsMachine = setup({
types: {
context: {} as ReturnType<typeof createSettings>,
input: {} as ReturnType<typeof createSettings>,
events: {} as
events: {} as (
| WildcardSetEvent<SettingsPaths>
| SetEventTypes
| {
@ -34,7 +34,8 @@ export const settingsMachine = setup({
type: 'Reset settings'
level: SettingsLevel
}
| { type: 'Set all settings'; settings: typeof settings },
| { type: 'Set all settings'; settings: typeof settings }
) & { doNotPersist?: boolean },
},
actions: {
setEngineTheme: () => {},

View File

@ -5,6 +5,8 @@ import os from 'node:os'
import fsSync from 'node:fs'
import packageJson from '../package.json'
import { MachinesListing } from 'lib/machineManager'
import { exec } from 'child_process'
import chokidar from 'chokidar'
const open = (args: any) => ipcRenderer.invoke('dialog.showOpenDialog', args)
const save = (args: any) => ipcRenderer.invoke('dialog.showSaveDialog', args)
@ -23,36 +25,21 @@ const isMac = os.platform() === 'darwin'
const isWindows = os.platform() === 'win32'
const isLinux = os.platform() === 'linux'
let fsWatchListeners = new Map<
string,
{
watcher: fsSync.FSWatcher
callback: (eventType: string, path: string) => void
}
>()
let fsWatchListeners = new Map<string, ReturnType<typeof chokidar.watch>>()
const watchFileOn = (
path: string,
callback: (eventType: string, path: string) => void
) => {
const watcher = fsSync.watch(path)
watcher.on('change', callback)
fsWatchListeners.set(path, { watcher, callback })
const watchFileOn = (path: string, callback: (path: string) => void) => {
const watcherMaybe = fsWatchListeners.get(path)
if (watcherMaybe) return
const watcher = chokidar.watch(path)
watcher.on('all', callback)
fsWatchListeners.set(path, watcher)
}
const watchFileOff = (path: string) => {
const entry = fsWatchListeners.get(path)
if (!entry) return
const { watcher, callback } = entry
watcher.off('change', callback)
watcher.close()
const watcher = fsWatchListeners.get(path)
if (!watcher) return
watcher.unwatch(path)
fsWatchListeners.delete(path)
}
const watchFileObliterate = () => {
for (let [pathAsKey] of fsWatchListeners) {
watchFileOff(pathAsKey)
}
fsWatchListeners = new Map()
}
const readFile = (path: string) => fs.readFile(path, 'utf-8')
// It seems like from the node source code this does not actually block but also
// don't trust me on that (jess).
@ -95,6 +82,25 @@ const listMachines = async (): Promise<MachinesListing> => {
const getMachineApiIp = async (): Promise<String | null> =>
ipcRenderer.invoke('find_machine_api')
async function readNaturalScrollDirection(): Promise<boolean> {
if (os.platform() !== 'darwin') {
// TODO: Detect this on other OS's.
return false
}
return new Promise((resolve, reject) => {
exec(
'defaults read -globalDomain com.apple.swipescrolldirection',
(err, stdout) => {
if (err) {
reject(err)
} else {
resolve(stdout.trim() === '1')
}
}
)
})
}
contextBridge.exposeInMainWorld('electron', {
startDeviceFlow,
loginWithDeviceFlow,
@ -103,7 +109,6 @@ contextBridge.exposeInMainWorld('electron', {
// exported.
watchFileOn,
watchFileOff,
watchFileObliterate,
readFile,
writeFile,
exists,
@ -159,6 +164,7 @@ contextBridge.exposeInMainWorld('electron', {
kittycad,
listMachines,
getMachineApiIp,
readNaturalScrollDirection,
onUpdateDownloaded,
appRestart,
})

View File

@ -37,6 +37,8 @@ import {
} from 'lib/desktop'
import { ProjectSearchBar, useProjectSearch } from 'components/ProjectSearchBar'
import { Project } from 'lib/project'
import { refreshNaturalScrollDirection } from 'lib/utils'
import { reportRejection } from 'lib/trap'
import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher'
import { useProjectsLoader } from 'hooks/useProjectsLoader'
@ -61,6 +63,11 @@ const Home = () => {
kclManager.cancelAllExecutions()
}, [])
useEffect(() => {
// Load OS setting.
refreshNaturalScrollDirection().catch(reportRejection)
}, [])
useHotkeys('backspace', (e) => {
e.preventDefault()
})
@ -176,7 +183,7 @@ const Home = () => {
// Re-read projects listing if the projectDir has any updates.
useFileSystemWatcher(
() => {
async () => {
setProjectsLoaderTrigger(projectsLoaderTrigger + 1)
},
projectsDir ? [projectsDir] : []

View File

@ -434,9 +434,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.19"
version = "4.5.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7be5744db7978a28d9df86a214130d106a89ce49644cbc4e3f0c22c3fba30615"
checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8"
dependencies = [
"clap_builder",
"clap_derive",
@ -444,9 +444,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.19"
version = "4.5.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5fbc17d3ef8278f55b282b2a2e75ae6f6c7d4bb70ed3d0382375104bfafdb4b"
checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54"
dependencies = [
"anstream",
"anstyle",
@ -934,9 +934,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futures"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
@ -949,9 +949,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
@ -959,15 +959,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-executor"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
"futures-core",
"futures-task",
@ -976,15 +976,15 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-macro"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
@ -993,21 +993,21 @@ dependencies = [
[[package]]
name = "futures-sink"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
[[package]]
name = "futures-task"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-util"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
@ -1966,12 +1966,9 @@ dependencies = [
[[package]]
name = "once_cell"
version = "1.20.1"
version = "1.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82881c4be219ab5faaf2ad5e5e5ecdff8c66bd7402ca3160975c93b24961afd1"
dependencies = [
"portable-atomic",
]
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
[[package]]
name = "oncemutex"

View File

@ -35,7 +35,7 @@ uuid = { version = "1.10.0", features = ["v4", "js", "serde"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
console_error_panic_hook = "0.1.7"
futures = "0.3.30"
futures = "0.3.31"
js-sys = "0.3.69"
tower-lsp = { version = "0.20.0", default-features = false, features = ["runtime-agnostic"] }
wasm-bindgen-futures = { version = "0.4.41", features = ["futures-core-03-stream"] }

View File

@ -14,7 +14,7 @@ proc-macro = true
[dependencies]
Inflector = "0.11.4"
convert_case = "0.6.0"
once_cell = "1.19.0"
once_cell = "1.20.2"
proc-macro2 = "1"
quote = "1"
regex = "1.10"

View File

@ -16,13 +16,13 @@ async-recursion = "1.1.1"
async-trait = "0.1.83"
base64 = "0.22.1"
chrono = "0.4.38"
clap = { version = "4.5.19", default-features = false, optional = true, features = ["std", "derive"] }
clap = { version = "4.5.20", default-features = false, optional = true, features = ["std", "derive"] }
convert_case = "0.6.0"
dashmap = "6.1.0"
databake = { version = "0.1.8", features = ["derive"] }
derive-docs = { version = "0.1.29", path = "../derive-docs" }
form_urlencoded = "1.2.1"
futures = { version = "0.3.30" }
futures = { version = "0.3.31" }
git_rev = "0.1.0"
gltf-json = "1.4.1"
http = { workspace = true }

View File

@ -787,12 +787,14 @@ fn test_generate_stdlib_json_schema() {
let stdlib = StdLib::new();
let combined = stdlib.combined();
let mut json_data = vec![];
for key in combined.keys().sorted() {
let internal_fn = combined.get(key).unwrap();
json_data.push(internal_fn.to_json().unwrap());
}
let json_data: Vec<_> = combined
.keys()
.sorted()
.map(|key| {
let internal_fn = combined.get(key).unwrap();
internal_fn.to_json().unwrap()
})
.collect();
expectorate::assert_contents(
"../../../docs/kcl/std.json",
&serde_json::to_string_pretty(&json_data).unwrap(),

View File

@ -83,6 +83,8 @@ impl StdLibFnArg {
return Ok(Some((index, format!("${{{}:{}}}", index, "myTag"))));
} else if self.type_ == "[KclValue]" && self.required {
return Ok(Some((index, format!("${{{}:{}}}", index, "[0..9]"))));
} else if self.type_ == "KclValue" && self.required {
return Ok(Some((index, format!("${{{}:{}}}", index, "3"))));
}
get_autocomplete_snippet_from_schema(&self.schema.schema.clone().into(), index)
}

View File

@ -292,7 +292,7 @@ impl DynamicState {
}
}
/// A memory item.
/// Any KCL value.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]

View File

@ -389,6 +389,8 @@ pub enum MouseControlType {
OnShape,
#[serde(alias = "Trackpad Friendly")]
TrackpadFriendly,
#[serde(alias = "Apple Trackpad")]
AppleTrackpad,
#[serde(alias = "Solidworks")]
Solidworks,
#[serde(alias = "NX")]

View File

@ -4,7 +4,7 @@ use serde_json::Value as JValue;
use super::{args::FromArgs, Args, FnAsArg};
use crate::{
errors::{KclError, KclErrorDetails},
executor::{ExecState, KclValue, Sketch, SourceRange, UserVal},
executor::{ExecState, KclValue, SourceRange, UserVal},
function_param::FunctionParam,
};
@ -98,7 +98,16 @@ async fn call_map_closure<'a>(
/// For each item in an array, update a value.
pub async fn reduce(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
let (array, start, f): (Vec<u64>, Sketch, FnAsArg<'_>) = FromArgs::from_args(&args, 0)?;
let (array, start, f): (Vec<JValue>, KclValue, FnAsArg<'_>) = FromArgs::from_args(&args, 0)?;
let array: Vec<KclValue> = array
.into_iter()
.map(|jval| {
KclValue::UserVal(UserVal {
value: jval,
meta: vec![args.source_range.into()],
})
})
.collect();
let reduce_fn = FunctionParam {
inner: f.func,
fn_expr: f.expr,
@ -106,9 +115,7 @@ pub async fn reduce(exec_state: &mut ExecState, args: Args) -> Result<KclValue,
ctx: args.ctx.clone(),
memory: *f.memory,
};
inner_reduce(array, start, reduce_fn, exec_state, &args)
.await
.map(|sg| KclValue::UserVal(UserVal::new(sg.meta.clone(), sg)))
inner_reduce(array, start, reduce_fn, exec_state, &args).await
}
/// Take a starting value. Then, for each element of an array, calculate the next value,
@ -125,60 +132,52 @@ pub async fn reduce(exec_state: &mut ExecState, args: Args) -> Result<KclValue,
/// }
/// decagon(5.0) |> close(%)
/// ```
/// ```no_run
/// array = [1, 2, 3]
/// sum = reduce(array, 0, (i, result_so_far) => { return i + result_so_far })
/// assertEqual(sum, 6, 0.00001, "1 + 2 + 3 summed is 6")
/// ```
/// ```no_run
/// fn add = (a, b) => { return a + b }
/// fn sum = (array) => { return reduce(array, 0, add) }
/// assertEqual(sum([1, 2, 3]), 6, 0.00001, "1 + 2 + 3 summed is 6")
/// ```
#[stdlib {
name = "reduce",
}]
async fn inner_reduce<'a>(
array: Vec<u64>,
start: Sketch,
array: Vec<KclValue>,
start: KclValue,
reduce_fn: FunctionParam<'a>,
exec_state: &mut ExecState,
args: &'a Args,
) -> Result<Sketch, KclError> {
) -> Result<KclValue, KclError> {
let mut reduced = start;
for i in array {
reduced = call_reduce_closure(i, reduced, &reduce_fn, args.source_range, exec_state).await?;
for elem in array {
reduced = call_reduce_closure(elem, reduced, &reduce_fn, args.source_range, exec_state).await?;
}
Ok(reduced)
}
async fn call_reduce_closure<'a>(
i: u64,
start: Sketch,
elem: KclValue,
start: KclValue,
reduce_fn: &FunctionParam<'a>,
source_range: SourceRange,
exec_state: &mut ExecState,
) -> Result<Sketch, KclError> {
) -> Result<KclValue, KclError> {
// Call the reduce fn for this repetition.
let reduce_fn_args = vec![
KclValue::UserVal(UserVal {
value: serde_json::Value::Number(i.into()),
meta: vec![source_range.into()],
}),
KclValue::new_user_val(start.meta.clone(), start),
];
let reduce_fn_args = vec![elem, start];
let transform_fn_return = reduce_fn.call(exec_state, reduce_fn_args).await?;
// Unpack the returned transform object.
let source_ranges = vec![source_range];
let closure_retval = transform_fn_return.ok_or_else(|| {
let out = transform_fn_return.ok_or_else(|| {
KclError::Semantic(KclErrorDetails {
message: "Reducer function must return a value".to_string(),
source_ranges: source_ranges.clone(),
})
})?;
let Some(out) = closure_retval.as_user_val() else {
return Err(KclError::Semantic(KclErrorDetails {
message: "Reducer function must return a UserValue".to_string(),
source_ranges: source_ranges.clone(),
}));
};
let Some((out, _meta)) = out.get() else {
return Err(KclError::Semantic(KclErrorDetails {
message: "Reducer function must return a Sketch".to_string(),
source_ranges: source_ranges.clone(),
}));
};
Ok(out)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -2075,10 +2075,10 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@kittycad/lib@^2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@kittycad/lib/-/lib-2.0.1.tgz#d3f1c80d9903452b0b9df378c72ed1e83b19a73d"
integrity sha512-VYunezWS+cNZbdKfVkB3zg2YbDCQEb/AjzER85+yyDAlTU5PL4paQDpNlEI6icSglDGRUIR4Er/bRFj68r3UQg==
"@kittycad/lib@2.0.7":
version "2.0.7"
resolved "https://registry.yarnpkg.com/@kittycad/lib/-/lib-2.0.7.tgz#63e9c81fc7705c9d0c5fab5939e5d839ec6f393b"
integrity sha512-P26rRZ0KF8C3zhEG2beLlkTJhTPtJF6Nn1wg7w1MxXNvK9RZF6P7DcXqdIh7nJGQt72+JrXoPmApB8Z/R1gQRg==
dependencies:
openapi-types "^12.0.0"
ts-node "^10.9.1"
@ -3780,6 +3780,13 @@ chokidar@^3.5.3:
optionalDependencies:
fsevents "~2.3.2"
chokidar@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.1.tgz#4a6dff66798fb0f72a94f616abbd7e1a19f31d41"
integrity sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==
dependencies:
readdirp "^4.0.1"
chownr@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece"
@ -8155,6 +8162,11 @@ readable-stream@~2.3.6:
string_decoder "~1.1.1"
util-deprecate "~1.0.1"
readdirp@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.0.2.tgz#388fccb8b75665da3abffe2d8f8ed59fe74c230a"
integrity sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==
readdirp@~3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
@ -8773,16 +8785,7 @@ string-natural-compare@^3.0.1:
resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -8876,14 +8879,7 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -9757,16 +9753,7 @@ word-wrap@^1.2.3, word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==